feat: phase 2 of AI pipeline hardening — tool-based structured outputs + prompt caching
Every structured-output call now uses an Anthropic tool instead of
parsing JSON out of free-form prose, and stable system prompts are
sent as cacheable blocks. Behaviour-equivalent to phase 1 from the
caller's point of view; the savings show up in token usage and in the
absence of "AI returned non-JSON response" failure modes.
* src/lib/llmTools.js — single source of truth for tool definitions:
emit_knowledge_graph, emit_handbook_delta, emit_learning_article /
_slides / _infographic / _all, emit_custom_topic, emit_quiz_questions,
emit_graph_actions, plus five article-patch tools (set_intro,
set_section, add_section, remove_section, replace_takeaways).
* src/lib/articlePatches.js — pure applyArticlePatches +
applyAndValidate; rebuilds the article from a sequence of patch tool
calls and re-validates against learningArticleSchema. set_section
falls back to appending when no matching heading exists so the
model's intent is preserved rather than silently dropped.
* src/lib/llmSchemas.js — Zod schemas for the five patch ops,
registered in toolSchemaRegistry so callLLM validates them
automatically.
* src/lib/llm.js — simulation mode now returns a tool_use stub matching
toolChoice.name, so the UI keeps working with Simulation Mode on
after the structured-output migration.
* src/lib/extractionPipeline.js — processSourceText and
analyzeHandbookDelta migrated to callLLM + tool use. System prompts
sent as { cache_control: ephemeral } blocks. Handbook results pass
through normalizeHandbookResult to collapse legacy "executes"
relations into executed_by with swapped source/target.
* src/lib/learningService.js — generateLearningContent picks the right
tool per selectedType; generateCustomTopic uses emit_custom_topic;
refineLearningContent now drives the five patch tools with
toolChoice 'any' and rejects the whole turn if the patched article
fails validation. Article-only refinement is intentional for phase 2;
refining a topic without an article surfaces a clear error.
* src/lib/testService.js — quiz generation via emit_quiz_questions.
* src/components/admin/KnowledgeGraph.jsx — analyzeGraph routed through
the reasoning tier (Opus) since graph-wide consolidation benefits
from a stronger reasoner.
* src/components/chat/prompts.js — buildSystemPrompt now returns three
text blocks: stable preamble (cached), KB context (cached, hash-bust
deferred to phase 5), per-turn user/admin tail (uncached).
* src/lib/__tests__/ — 13 new tests covering each patch op, multi-op
sequencing, post-patch validation failure, and tool/registry shape.
Acceptance: lint and 45/45 tests green; build succeeds; no
`match(/\{[\s\S]*\}/)` JSON extraction left in src/. Live verification
of cache hits on a second extraction within 5 minutes is deferred to
manual smoke testing — needs real `/api/anthropic` traffic.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,7 +2,8 @@ import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import * as d3 from 'd3';
|
||||
import { Trash2, Edit2, Save, X, RefreshCw, AlertCircle, Plus, Link as LinkIcon } from 'lucide-react';
|
||||
import * as db from '../../lib/db';
|
||||
import { anthropicApi } from '../../lib/api';
|
||||
import { callLLM } from '../../lib/llm';
|
||||
import { EMIT_GRAPH_ACTIONS_TOOL } from '../../lib/llmTools';
|
||||
import { analyzeHandbookDelta } from '../../lib/extractionPipeline';
|
||||
import { getRepoFolder, getFileContent } from '../../lib/githubService';
|
||||
import Button from '../ui/Button';
|
||||
@@ -304,18 +305,18 @@ const KnowledgeGraph = () => {
|
||||
const currentTopics = await db.getTopics();
|
||||
const currentRelations = await db.getRelations();
|
||||
|
||||
const systemPrompt = `You are a strict Data Quality AI maintaining a Knowledge Graph.
|
||||
Your goal is to evaluate the provided topics and relations, identify duplicates to merge, useless nodes to delete, and new logical relations to add.
|
||||
const systemPrompt = `You are a strict Data Quality AI maintaining a Knowledge Graph for Respellion.
|
||||
Evaluate the provided topics and relations and emit the actions to take via the emit_graph_actions tool.
|
||||
|
||||
Rules:
|
||||
1. Identify topics that mean exactly the same thing. Choose one to keep, and one to delete.
|
||||
2. Identify topics that are too vague, irrelevant, or malformed to delete.
|
||||
3. Identify missing logical relations (depends_on, part_of, related_to) if two topics are conceptually linked but missing a relation.
|
||||
4. Evaluate the learning_relevance of each topic. If a topic is purely operational/mundane (like a printer guide), mark it as "exclude". If it's low priority, mark "peripheral".
|
||||
5. Return ONLY a valid JSON object describing the ACTIONS to take. Do not return the entire graph. Do not wrap in markdown blocks.`;
|
||||
1. Identify topics that mean exactly the same thing. Choose one to keep, one to delete (merges).
|
||||
2. Identify topics that are too vague, irrelevant, or malformed (deletions).
|
||||
3. Identify missing logical relations (depends_on, part_of, related_to, executed_by) between conceptually linked topics (newRelations).
|
||||
4. Evaluate learning_relevance. Mark purely operational topics (printer guides, etc.) as "exclude"; low-priority as "peripheral" (relevanceUpdates).
|
||||
|
||||
Do not return the entire graph — only the actions to take.`;
|
||||
|
||||
// Send a compact representation to minimize token usage and avoid rate limits.
|
||||
// The AI only needs id, label, type, and relevance to identify duplicates/merges and adjust relevance.
|
||||
const compactTopics = currentTopics.map(({ id, label, type, learning_relevance }) => ({ id, label, type, learning_relevance }));
|
||||
const compactRelations = currentRelations.map(r => ({
|
||||
source: r.source?.id || r.source,
|
||||
@@ -324,21 +325,20 @@ Rules:
|
||||
}));
|
||||
|
||||
const userPrompt = `Here is the current knowledge graph:
|
||||
${JSON.stringify({ topics: compactTopics, relations: compactRelations })}
|
||||
${JSON.stringify({ topics: compactTopics, relations: compactRelations })}`;
|
||||
|
||||
Analyze this graph and return ONLY the optimized JSON object with this EXACT structure:
|
||||
{
|
||||
"merges": [ { "keepId": "id_to_keep", "deleteId": "id_to_delete" } ],
|
||||
"deletions": [ "id_to_delete_completely" ],
|
||||
"newRelations": [ { "source": "id1", "target": "id2", "type": "depends_on" } ],
|
||||
"relevanceUpdates": [ { "id": "topic_id", "learning_relevance": "exclude" } ]
|
||||
}`;
|
||||
const llmResult = await callLLM({
|
||||
task: 'graph.analyze',
|
||||
tier: 'reasoning',
|
||||
system: [{ type: 'text', text: systemPrompt, cache_control: { type: 'ephemeral' } }],
|
||||
user: userPrompt,
|
||||
tools: [EMIT_GRAPH_ACTIONS_TOOL],
|
||||
toolChoice: { type: 'tool', name: EMIT_GRAPH_ACTIONS_TOOL.name },
|
||||
maxTokens: 4096,
|
||||
});
|
||||
|
||||
const responseText = await anthropicApi.generateContent(systemPrompt, userPrompt);
|
||||
const jsonMatch = responseText.match(/\{[\s\S]*\}/);
|
||||
if (!jsonMatch) throw new Error('AI returned invalid format.');
|
||||
|
||||
const actions = JSON.parse(jsonMatch[0]);
|
||||
const actions = llmResult.toolUses[0]?.input;
|
||||
if (!actions) throw new Error('Graph analysis did not emit a tool result.');
|
||||
|
||||
let updatedTopics = [...currentTopics];
|
||||
let updatedRelations = [...currentRelations];
|
||||
|
||||
@@ -23,32 +23,49 @@ export const STRINGS = {
|
||||
openAria: 'Open R42 chatbot',
|
||||
};
|
||||
|
||||
const STABLE_PREAMBLE = [
|
||||
`Je bent R42, de chatbot-avatar van Respellion — een leerplatform voor microlearning, quizzen en kennisontwikkeling.`,
|
||||
`Antwoord altijd in het Nederlands, kort en zakelijk-vriendelijk. Spreek de gebruiker aan met hun voornaam wanneer dat natuurlijk voelt.`,
|
||||
``,
|
||||
`JE TAKEN:`,
|
||||
`1. Leg onderwerpen uit die in de kennisbasis staan.`,
|
||||
`2. Help de gebruiker quizvragen begrijpen ná afloop (niet tijdens een actieve quiz).`,
|
||||
`3. Verwijs bij twijfel terug naar het bronmateriaal of zeg eerlijk dat je het niet weet.`,
|
||||
``,
|
||||
`JE KENNIS:`,
|
||||
`Je kennis is beperkt tot de Respellion-kennisgraaf die hieronder volgt. Als een vraag duidelijk buiten dit bereik valt, zeg dat dan eerlijk en stel voor dat de gebruiker de bron toevoegt via Admin → Sources.`,
|
||||
``,
|
||||
`KENNISGRAAF VERFIJNEN:`,
|
||||
`Wanneer de gebruiker iets noemt dat duidelijk een nieuw topic, nieuwe relatie, proces of rol is — en dat nog niet in de kennisgraaf staat — gebruik dan de tool "propose_graph_delta" om een voorstel te maken. Verzin niets: stel alleen iets voor als de gebruiker het concreet noemt. Stel maximaal 3 topics en 5 relaties per beurt voor.`,
|
||||
``,
|
||||
`STIJL:`,
|
||||
`- Houd antwoorden onder de 4 zinnen tenzij de gebruiker om uitleg vraagt.`,
|
||||
`- Geen markdown-headers; gewone Nederlandse tekst.`,
|
||||
`- Bij onzekerheid: "Ik weet het niet zeker — controleer dit in het handboek."`,
|
||||
].join('\n');
|
||||
|
||||
/**
|
||||
* Build the R42 system prompt as three cacheable blocks:
|
||||
* 1. stable preamble (role, tasks, style) — cached
|
||||
* 2. KB context (current topics + relations) — cached (hash-bust comes in Phase 5)
|
||||
* 3. per-turn tail (user name + admin status) — NOT cached
|
||||
*
|
||||
* Returning an array lets `callLLM` pass it through unchanged so the
|
||||
* Anthropic API caches each block with the 5-minute ephemeral TTL.
|
||||
*/
|
||||
export function buildSystemPrompt({ userName, isAdmin, kbContext }) {
|
||||
return [
|
||||
`Je bent R42, de chatbot-avatar van Respellion — een leerplatform voor microlearning, quizzen en kennisontwikkeling.`,
|
||||
`Antwoord altijd in het Nederlands, kort en zakelijk-vriendelijk. Spreek de gebruiker aan met hun voornaam wanneer dat natuurlijk voelt (${userName}).`,
|
||||
``,
|
||||
`JE TAKEN:`,
|
||||
`1. Leg onderwerpen uit die in de kennisbasis staan.`,
|
||||
`2. Help de gebruiker quizvragen begrijpen ná afloop (niet tijdens een actieve quiz).`,
|
||||
`3. Verwijs bij twijfel terug naar het bronmateriaal of zeg eerlijk dat je het niet weet.`,
|
||||
``,
|
||||
`JE KENNIS:`,
|
||||
`Je kennis is beperkt tot de onderstaande Respellion-kennisgraaf. Als een vraag duidelijk buiten dit bereik valt, zeg dat dan eerlijk en stel voor dat de gebruiker de bron toevoegt via Admin → Sources.`,
|
||||
``,
|
||||
kbContext,
|
||||
``,
|
||||
`KENNISGRAAF VERFIJNEN:`,
|
||||
`Wanneer de gebruiker iets noemt dat duidelijk een nieuw topic, nieuwe relatie, proces of rol is — en dat nog niet in de kennisgraaf staat — gebruik dan de tool "propose_graph_delta" om een voorstel te maken. Verzin niets: stel alleen iets voor als de gebruiker het concreet noemt. Stel maximaal 3 topics en 5 relaties per beurt voor.`,
|
||||
``,
|
||||
`STIJL:`,
|
||||
`- Houd antwoorden onder de 4 zinnen tenzij de gebruiker om uitleg vraagt.`,
|
||||
`- Geen markdown-headers; gewone Nederlandse tekst.`,
|
||||
`- Bij onzekerheid: "Ik weet het niet zeker — controleer dit in het handboek."`,
|
||||
const tail = [
|
||||
`De gebruiker heet ${userName}.`,
|
||||
isAdmin
|
||||
? `\nDe gebruiker is beheerder; voorstellen die de tool genereert worden direct toegepast.`
|
||||
: `\nDe gebruiker is geen beheerder; voorstellen worden in een goedkeuringswachtrij gezet.`,
|
||||
? `De gebruiker is beheerder; voorstellen die de tool genereert worden direct toegepast.`
|
||||
: `De gebruiker is geen beheerder; voorstellen worden in een goedkeuringswachtrij gezet.`,
|
||||
].join('\n');
|
||||
|
||||
return [
|
||||
{ type: 'text', text: STABLE_PREAMBLE, cache_control: { type: 'ephemeral' } },
|
||||
{ type: 'text', text: kbContext, cache_control: { type: 'ephemeral' } },
|
||||
{ type: 'text', text: tail },
|
||||
];
|
||||
}
|
||||
|
||||
export const PROPOSE_GRAPH_DELTA_TOOL = {
|
||||
|
||||
Reference in New Issue
Block a user