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:
324
src/lib/llmTools.js
Normal file
324
src/lib/llmTools.js
Normal 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 (1–2 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,
|
||||
];
|
||||
Reference in New Issue
Block a user