feat: phase 2 of AI pipeline hardening — tool-based structured outputs + prompt caching
All checks were successful
On Pull Request to Main / test (pull_request) Successful in 31s
On Pull Request to Main / publish (pull_request) Successful in 1m1s
On Pull Request to Main / deploy-dev (pull_request) Successful in 1m31s

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:
RaymondVerhoef
2026-05-20 15:47:20 +02:00
parent 8a8745fad2
commit f838755991
11 changed files with 872 additions and 291 deletions

324
src/lib/llmTools.js Normal file
View File

@@ -0,0 +1,324 @@
/**
* Anthropic tool definitions used by every structured-output flow.
*
* Each `tool_use` reply the model emits is validated against the matching
* Zod schema in `llmSchemas.js` (see `toolSchemaRegistry`). The two stay
* in lock-step on purpose — JSON Schema here drives the model, Zod there
* defends the application.
*/
const TOPIC_TYPES = ['concept', 'role', 'process'];
const LEARNING_RELEVANCE = ['core', 'standard', 'peripheral', 'exclude'];
const RELATION_TYPES_STRICT = ['related_to', 'depends_on', 'part_of', 'executed_by'];
const RELATION_TYPES_LOOSE = ['related_to', 'depends_on', 'part_of', 'executed_by', 'executes'];
const extractionTopicSchema = {
type: 'object',
properties: {
id: { type: 'string', description: 'kebab-case slug specific to the topic. Reuse existing IDs when the same concept recurs.' },
label: { type: 'string' },
type: { type: 'string', enum: TOPIC_TYPES },
description: { type: 'string', description: 'Max 3 sentences.' },
learning_relevance: { type: 'string', enum: LEARNING_RELEVANCE },
},
required: ['id', 'label', 'type', 'description', 'learning_relevance'],
};
const extractionRelationSchema = {
type: 'object',
properties: {
source: { type: 'string', description: 'Topic id.' },
target: { type: 'string', description: 'Topic id.' },
type: { type: 'string', enum: RELATION_TYPES_STRICT },
},
required: ['source', 'target', 'type'],
};
export const EMIT_KNOWLEDGE_GRAPH_TOOL = {
name: 'emit_knowledge_graph',
description: 'Return the complete knowledge graph extracted from the supplied source text — every distinct role, process and concept as a topic, plus the relations between them.',
input_schema: {
type: 'object',
properties: {
topics: { type: 'array', items: extractionTopicSchema },
relations: { type: 'array', items: extractionRelationSchema },
},
required: ['topics', 'relations'],
},
};
const handbookTopicSchema = {
type: 'object',
properties: {
...extractionTopicSchema.properties,
metadata: {
type: 'object',
properties: { source: { type: 'string' } },
},
},
required: extractionTopicSchema.required,
};
const handbookRelationSchema = {
type: 'object',
properties: {
source: { type: 'string' },
target: { type: 'string' },
type: { type: 'string', enum: RELATION_TYPES_LOOSE },
description: { type: 'string' },
},
required: ['source', 'target', 'type'],
};
export const EMIT_HANDBOOK_DELTA_TOOL = {
name: 'emit_handbook_delta',
description: 'Return the topics and relations extracted from a handbook file update. Every process must have a role attached; every concept must connect to a process or role.',
input_schema: {
type: 'object',
properties: {
topics: { type: 'array', items: handbookTopicSchema },
relations: { type: 'array', items: handbookRelationSchema },
},
required: ['topics', 'relations'],
},
};
const articleSectionSchema = {
type: 'object',
properties: {
heading: { type: 'string' },
body: { type: 'string', description: 'At least three sentences.' },
},
required: ['heading', 'body'],
};
const articleBodySchema = {
type: 'object',
properties: {
title: { type: 'string' },
intro: { type: 'string', description: 'One or two sentences.' },
sections: { type: 'array', items: articleSectionSchema, minItems: 1 },
keyTakeaways: { type: 'array', items: { type: 'string' }, minItems: 1 },
},
required: ['title', 'intro', 'sections', 'keyTakeaways'],
};
const slideSchema = {
type: 'object',
properties: {
title: { type: 'string' },
bullets: { type: 'array', items: { type: 'string' }, minItems: 1 },
speakerNote: { type: 'string' },
},
required: ['title', 'bullets', 'speakerNote'],
};
const infographicStatSchema = {
type: 'object',
properties: {
value: { type: 'string' },
label: { type: 'string' },
icon: { type: 'string' },
},
required: ['value', 'label', 'icon'],
};
const infographicStepSchema = {
type: 'object',
properties: {
number: { type: 'integer', minimum: 1 },
title: { type: 'string' },
description: { type: 'string' },
icon: { type: 'string' },
},
required: ['number', 'title', 'description', 'icon'],
};
const infographicBodySchema = {
type: 'object',
properties: {
headline: { type: 'string', description: 'Punchy, max 8 words.' },
tagline: { type: 'string', description: 'Max 15 words.' },
stats: { type: 'array', items: infographicStatSchema, minItems: 1 },
steps: { type: 'array', items: infographicStepSchema, minItems: 1 },
quote: { type: 'string' },
colorTheme: { type: 'string', description: 'Tailwind colour token (e.g. "teal").' },
},
required: ['headline', 'tagline', 'stats', 'steps', 'quote', 'colorTheme'],
};
export const EMIT_LEARNING_ARTICLE_TOOL = {
name: 'emit_learning_article',
description: 'Return the article body for a learning module. At least three sections.',
input_schema: {
type: 'object',
properties: { article: articleBodySchema },
required: ['article'],
},
};
export const EMIT_LEARNING_SLIDES_TOOL = {
name: 'emit_learning_slides',
description: 'Return the slide deck for a learning module. At least four slides.',
input_schema: {
type: 'object',
properties: { slides: { type: 'array', items: slideSchema, minItems: 1 } },
required: ['slides'],
},
};
export const EMIT_LEARNING_INFOGRAPHIC_TOOL = {
name: 'emit_learning_infographic',
description: 'Return the infographic for a learning module. At least three stats and three steps.',
input_schema: {
type: 'object',
properties: { infographic: infographicBodySchema },
required: ['infographic'],
},
};
export const EMIT_LEARNING_ALL_TOOL = {
name: 'emit_learning_all',
description: 'Return article, slides and infographic for a learning module in one call.',
input_schema: {
type: 'object',
properties: {
article: articleBodySchema,
slides: { type: 'array', items: slideSchema, minItems: 1 },
infographic: infographicBodySchema,
},
required: ['article', 'slides', 'infographic'],
},
};
export const EMIT_CUSTOM_TOPIC_TOOL = {
name: 'emit_custom_topic',
description: 'Return a polished label, type and short description for a user-requested topic.',
input_schema: {
type: 'object',
properties: {
label: { type: 'string' },
type: { type: 'string', enum: TOPIC_TYPES },
description: { type: 'string', description: 'Two or three sentences.' },
},
required: ['label', 'type', 'description'],
},
};
const quizQuestionSchema = {
type: 'object',
properties: {
id: { type: 'string' },
question: { type: 'string' },
topicLabel: { type: 'string' },
options: { type: 'array', items: { type: 'string' }, minItems: 4, maxItems: 4 },
correctIndex: { type: 'integer', minimum: 0, maximum: 3 },
explanation: { type: 'string', description: 'Why the correct answer is correct (12 sentences).' },
},
required: ['id', 'question', 'topicLabel', 'options', 'correctIndex', 'explanation'],
};
export const EMIT_QUIZ_QUESTIONS_TOOL = {
name: 'emit_quiz_questions',
description: 'Return a batch of multiple-choice questions for a topic. Exactly four options each; correctIndex is 0-based.',
input_schema: {
type: 'object',
properties: { questions: { type: 'array', items: quizQuestionSchema, minItems: 1 } },
required: ['questions'],
},
};
export const EMIT_GRAPH_ACTIONS_TOOL = {
name: 'emit_graph_actions',
description: 'Return the actions to take on the knowledge graph: merges, deletions, new relations and relevance updates. Do not return the entire graph.',
input_schema: {
type: 'object',
properties: {
merges: {
type: 'array',
items: {
type: 'object',
properties: { keepId: { type: 'string' }, deleteId: { type: 'string' } },
required: ['keepId', 'deleteId'],
},
},
deletions: { type: 'array', items: { type: 'string' } },
newRelations: { type: 'array', items: extractionRelationSchema },
relevanceUpdates: {
type: 'array',
items: {
type: 'object',
properties: { id: { type: 'string' }, learning_relevance: { type: 'string', enum: LEARNING_RELEVANCE } },
required: ['id', 'learning_relevance'],
},
},
},
},
};
// ── Patch tools for refineLearningContent (Phase 2.4) ─────────────────────────
export const SET_INTRO_TOOL = {
name: 'set_intro',
description: 'Replace the article intro with a new one or two sentences.',
input_schema: {
type: 'object',
properties: { intro: { type: 'string', description: 'New intro text.' } },
required: ['intro'],
},
};
export const SET_SECTION_TOOL = {
name: 'set_section',
description: 'Replace the body of an existing section, matched by its heading (case-insensitive). Use add_section if no section with that heading exists.',
input_schema: {
type: 'object',
properties: {
heading: { type: 'string', description: 'Heading of the section to replace.' },
body: { type: 'string', description: 'New body for that section, at least three sentences.' },
},
required: ['heading', 'body'],
},
};
export const ADD_SECTION_TOOL = {
name: 'add_section',
description: 'Insert a new section into the article at the start or end.',
input_schema: {
type: 'object',
properties: {
heading: { type: 'string' },
body: { type: 'string', description: 'At least three sentences.' },
position: { type: 'string', enum: ['start', 'end'] },
},
required: ['heading', 'body', 'position'],
},
};
export const REMOVE_SECTION_TOOL = {
name: 'remove_section',
description: 'Delete a section from the article, matched by its heading (case-insensitive).',
input_schema: {
type: 'object',
properties: { heading: { type: 'string' } },
required: ['heading'],
},
};
export const REPLACE_TAKEAWAYS_TOOL = {
name: 'replace_takeaways',
description: 'Replace the key takeaways list with a new one.',
input_schema: {
type: 'object',
properties: { items: { type: 'array', items: { type: 'string' }, minItems: 1 } },
required: ['items'],
},
};
export const ARTICLE_PATCH_TOOLS = [
SET_INTRO_TOOL,
SET_SECTION_TOOL,
ADD_SECTION_TOOL,
REMOVE_SECTION_TOOL,
REPLACE_TAKEAWAYS_TOOL,
];