Files
learning-platform/src/lib/llmSchemas.js
RaymondVerhoef f838755991
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
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>
2026-05-20 15:47:20 +02:00

233 lines
6.5 KiB
JavaScript

/**
* Zod schemas for every structured LLM output the platform consumes.
*
* Field names mirror what callers already produce — do not rename them
* without migrating the corresponding service module.
*/
import { z } from 'zod';
const topicTypeEnum = z.enum(['concept', 'role', 'process']);
const relationTypeStrict = z.enum(['related_to', 'depends_on', 'part_of', 'executed_by']);
const relationTypeLoose = z.enum(['related_to', 'depends_on', 'part_of', 'executed_by', 'executes']);
const learningRelevanceEnum = z.enum(['core', 'standard', 'peripheral', 'exclude']);
const extractionTopicSchema = z.object({
id: z.string().min(1),
label: z.string().min(1),
type: topicTypeEnum,
description: z.string().min(1),
learning_relevance: learningRelevanceEnum,
});
const extractionRelationSchema = z.object({
source: z.string().min(1),
target: z.string().min(1),
type: relationTypeStrict,
});
export const extractionResultSchema = z.object({
topics: z.array(extractionTopicSchema),
relations: z.array(extractionRelationSchema),
});
const handbookTopicSchema = extractionTopicSchema.extend({
metadata: z.object({ source: z.string() }).optional(),
});
const handbookRelationSchema = z.object({
source: z.string().min(1),
target: z.string().min(1),
type: relationTypeLoose,
description: z.string().optional(),
});
export const handbookResultSchema = z.object({
topics: z.array(handbookTopicSchema),
relations: z.array(handbookRelationSchema),
});
/**
* Normalise legacy `executes` relations into the canonical `executed_by`
* vocabulary by swapping source and target. The handbook prompt previously
* emitted `role → executes → process`; the canonical form is
* `process → executed_by → role`.
*/
export function normalizeHandbookResult(parsed) {
return {
...parsed,
relations: parsed.relations.map((r) =>
r.type === 'executes'
? { ...r, type: 'executed_by', source: r.target, target: r.source }
: r,
),
};
}
const articleSectionSchema = z.object({
heading: z.string().min(1),
body: z.string().min(1),
});
const articleBodySchema = z.object({
title: z.string().min(1),
intro: z.string().min(1),
sections: z.array(articleSectionSchema).min(1),
keyTakeaways: z.array(z.string().min(1)).min(1),
});
export const learningArticleSchema = z.object({
article: articleBodySchema,
});
const slideSchema = z.object({
title: z.string().min(1),
bullets: z.array(z.string().min(1)).min(1),
speakerNote: z.string().min(1),
});
export const learningSlidesSchema = z.object({
slides: z.array(slideSchema).min(1),
});
const infographicStatSchema = z.object({
value: z.string().min(1),
label: z.string().min(1),
icon: z.string().min(1),
});
const infographicStepSchema = z.object({
number: z.number().int().min(1),
title: z.string().min(1),
description: z.string().min(1),
icon: z.string().min(1),
});
const infographicBodySchema = z.object({
headline: z.string().min(1),
tagline: z.string().min(1),
stats: z.array(infographicStatSchema).min(1),
steps: z.array(infographicStepSchema).min(1),
quote: z.string().min(1),
colorTheme: z.string().min(1),
});
export const learningInfographicSchema = z.object({
infographic: infographicBodySchema,
});
export const learningAllSchema = z.object({
article: articleBodySchema,
slides: z.array(slideSchema).min(1),
infographic: infographicBodySchema,
});
const quizQuestionSchema = z.object({
id: z.string().min(1),
question: z.string().min(1),
topicLabel: z.string().min(1),
options: z.array(z.string().min(1)).length(4),
correctIndex: z.number().int().min(0).max(3),
explanation: z.string().min(1),
});
export const quizQuestionsSchema = z.object({
questions: z.array(quizQuestionSchema).min(1),
});
export const customTopicSchema = z.object({
label: z.string().min(1),
type: topicTypeEnum,
description: z.string().min(1),
});
const mergeActionSchema = z.object({
keepId: z.string().min(1),
deleteId: z.string().min(1),
});
const newRelationSchema = z.object({
source: z.string().min(1),
target: z.string().min(1),
type: relationTypeStrict,
});
const relevanceUpdateSchema = z.object({
id: z.string().min(1),
learning_relevance: learningRelevanceEnum,
});
export const graphActionsSchema = z.object({
merges: z.array(mergeActionSchema).optional().default([]),
deletions: z.array(z.string().min(1)).optional().default([]),
newRelations: z.array(newRelationSchema).optional().default([]),
relevanceUpdates: z.array(relevanceUpdateSchema).optional().default([]),
});
const deltaTopicSchema = z.object({
id: z.string().min(1),
label: z.string().min(1),
type: topicTypeEnum,
description: z.string().min(1),
});
const deltaRelationSchema = z.object({
source: z.string().min(1),
target: z.string().min(1),
type: relationTypeStrict,
});
export const proposeGraphDeltaSchema = z.object({
reason: z.string().min(1),
topics: z.array(deltaTopicSchema).max(3).optional(),
relations: z.array(deltaRelationSchema).max(5).optional(),
});
// ── Article patch operation schemas (Phase 2.4) ──────────────────────────────
export const setIntroPatchSchema = z.object({
intro: z.string().min(1),
});
export const setSectionPatchSchema = z.object({
heading: z.string().min(1),
body: z.string().min(1),
});
export const addSectionPatchSchema = z.object({
heading: z.string().min(1),
body: z.string().min(1),
position: z.enum(['start', 'end']),
});
export const removeSectionPatchSchema = z.object({
heading: z.string().min(1),
});
export const replaceTakeawaysPatchSchema = z.object({
items: z.array(z.string().min(1)).min(1),
});
/**
* Registry mapping known tool names to their input schemas. `callLLM`
* consults this when the caller does not pass an explicit `toolSchemas`
* override.
*/
export const toolSchemaRegistry = {
emit_knowledge_graph: extractionResultSchema,
emit_handbook_delta: handbookResultSchema,
emit_learning_article: learningArticleSchema,
emit_learning_slides: learningSlidesSchema,
emit_learning_infographic: learningInfographicSchema,
emit_learning_all: learningAllSchema,
emit_quiz_questions: quizQuestionsSchema,
emit_custom_topic: customTopicSchema,
emit_graph_actions: graphActionsSchema,
propose_graph_delta: proposeGraphDeltaSchema,
set_intro: setIntroPatchSchema,
set_section: setSectionPatchSchema,
add_section: addSectionPatchSchema,
remove_section: removeSectionPatchSchema,
replace_takeaways: replaceTakeawaysPatchSchema,
};