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:
@@ -125,7 +125,7 @@ function isChatLikeTask(task) {
|
||||
return task === 'legacy.chat' || task.startsWith('chat.') || task.startsWith('r42.');
|
||||
}
|
||||
|
||||
const SIMULATION_EXTRACTION_PAYLOAD = JSON.stringify({
|
||||
const SIMULATION_EXTRACTION_GRAPH = {
|
||||
topics: [
|
||||
{ id: 'radicale-transparantie', label: 'Radicale Transparantie', type: 'concept', description: 'De kernwaarde van Respellion waarbij alle informatie publiek toegankelijk is.', learning_relevance: 'core' },
|
||||
{ id: 'kennisbeheer', label: 'Kennisbeheer', type: 'process', description: 'Het proces van het vastleggen en ontsluiten van organisatiekennis.', learning_relevance: 'standard' },
|
||||
@@ -135,28 +135,66 @@ const SIMULATION_EXTRACTION_PAYLOAD = JSON.stringify({
|
||||
{ source: 'kennisbeheer', target: 'radicale-transparantie', type: 'depends_on' },
|
||||
{ source: 'wekelijkse-sessie', target: 'kennisbeheer', type: 'part_of' },
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
const SIMULATION_EXTRACTION_PAYLOAD = JSON.stringify(SIMULATION_EXTRACTION_GRAPH);
|
||||
|
||||
const SIMULATION_CHAT_TEXT =
|
||||
'Simulatiemodus staat aan — vraag een beheerder om Simulation Mode uit te zetten in Admin → Settings om met R42 te chatten.';
|
||||
|
||||
async function simulatedResponse({ task }) {
|
||||
await new Promise((r) => setTimeout(r, 400));
|
||||
if (isChatLikeTask(task)) {
|
||||
return {
|
||||
text: SIMULATION_CHAT_TEXT,
|
||||
toolUses: [],
|
||||
stopReason: 'end_turn',
|
||||
usage: { input_tokens: 0, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
|
||||
requestId: null,
|
||||
model: 'simulation',
|
||||
durationMs: 400,
|
||||
};
|
||||
}
|
||||
const SIMULATION_ARTICLE = {
|
||||
title: 'Voorbeeld leermodule',
|
||||
intro: 'Dit is een simulatie. Schakel Simulation Mode uit om echte content te genereren.',
|
||||
sections: [
|
||||
{ heading: 'Wat dit is', body: 'Dit is een placeholder-sectie die alleen verschijnt wanneer simulatiemodus aan staat. Hij illustreert de structuur van het artikel zonder een echte API-aanroep te doen. Dat is handig voor UI-werk.' },
|
||||
],
|
||||
keyTakeaways: ['Simulatiemodus levert geen echte inhoud.', 'Schakel uit voor productie.'],
|
||||
};
|
||||
|
||||
const SIMULATION_SLIDE = {
|
||||
title: 'Voorbeeldslide',
|
||||
bullets: ['Eerste punt', 'Tweede punt'],
|
||||
speakerNote: 'Spreker-notitie ter illustratie.',
|
||||
};
|
||||
|
||||
const SIMULATION_INFOGRAPHIC = {
|
||||
headline: 'Simulatie',
|
||||
tagline: 'Vervang door echte content',
|
||||
stats: [{ value: '100%', label: 'simulatie', icon: '📊' }],
|
||||
steps: [{ number: 1, title: 'Schakel uit', description: 'Zet simulatiemodus uit in Admin → Settings.', icon: '🔧' }],
|
||||
quote: 'Een simulatie vertelt niets nieuws.',
|
||||
colorTheme: 'teal',
|
||||
};
|
||||
|
||||
const SIMULATION_TOOL_STUBS = {
|
||||
emit_knowledge_graph: SIMULATION_EXTRACTION_GRAPH,
|
||||
emit_handbook_delta: SIMULATION_EXTRACTION_GRAPH,
|
||||
emit_learning_article: { article: SIMULATION_ARTICLE },
|
||||
emit_learning_slides: { slides: [SIMULATION_SLIDE] },
|
||||
emit_learning_infographic: { infographic: SIMULATION_INFOGRAPHIC },
|
||||
emit_learning_all: { article: SIMULATION_ARTICLE, slides: [SIMULATION_SLIDE], infographic: SIMULATION_INFOGRAPHIC },
|
||||
emit_custom_topic: { label: 'Simulatie onderwerp', type: 'concept', description: 'Een placeholder-onderwerp gegenereerd in simulatiemodus.' },
|
||||
emit_quiz_questions: {
|
||||
questions: [
|
||||
{
|
||||
id: 'sim-q1',
|
||||
question: 'Wat doet simulatiemodus?',
|
||||
topicLabel: 'Simulatie',
|
||||
options: ['Echte API-aanroepen', 'Stub-data tonen', 'Niets', 'Crasht de app'],
|
||||
correctIndex: 1,
|
||||
explanation: 'Simulatiemodus retourneert vaste stub-data zonder de API te raken.',
|
||||
},
|
||||
],
|
||||
},
|
||||
emit_graph_actions: { merges: [], deletions: [], newRelations: [], relevanceUpdates: [] },
|
||||
set_intro: { intro: 'Bijgewerkte intro (simulatie).' },
|
||||
};
|
||||
|
||||
function stubResponse({ stopReason = 'end_turn', text = '', toolUses = [] }) {
|
||||
return {
|
||||
text: SIMULATION_EXTRACTION_PAYLOAD,
|
||||
toolUses: [],
|
||||
stopReason: 'end_turn',
|
||||
text,
|
||||
toolUses,
|
||||
stopReason,
|
||||
usage: { input_tokens: 0, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
|
||||
requestId: null,
|
||||
model: 'simulation',
|
||||
@@ -164,6 +202,22 @@ async function simulatedResponse({ task }) {
|
||||
};
|
||||
}
|
||||
|
||||
async function simulatedResponse({ task, toolChoice }) {
|
||||
await new Promise((r) => setTimeout(r, 400));
|
||||
|
||||
if (toolChoice?.type === 'tool' && SIMULATION_TOOL_STUBS[toolChoice.name]) {
|
||||
return stubResponse({
|
||||
stopReason: 'tool_use',
|
||||
toolUses: [{ name: toolChoice.name, input: SIMULATION_TOOL_STUBS[toolChoice.name] }],
|
||||
});
|
||||
}
|
||||
|
||||
if (isChatLikeTask(task)) {
|
||||
return stubResponse({ text: SIMULATION_CHAT_TEXT });
|
||||
}
|
||||
return stubResponse({ text: SIMULATION_EXTRACTION_PAYLOAD });
|
||||
}
|
||||
|
||||
function linkSignals(userSignal, timeoutSignal) {
|
||||
const controller = new AbortController();
|
||||
const abort = (reason) => controller.abort(reason);
|
||||
@@ -241,7 +295,7 @@ export async function callLLM(options) {
|
||||
if (!task) throw new Error('callLLM requires a `task` label.');
|
||||
|
||||
const useSimulation = storage.get('admin:use_simulation') === true;
|
||||
if (useSimulation) return simulatedResponse({ task });
|
||||
if (useSimulation) return simulatedResponse({ task, toolChoice });
|
||||
|
||||
const model = resolveModel(tier);
|
||||
const messagesPayload = buildMessages({ messages, user });
|
||||
|
||||
Reference in New Issue
Block a user