feat: phase 1 of AI pipeline hardening — single LLM client + tier-aware models

Implements phase 1 of AI_PIPELINE_HARDENING_PLAN.md. Every Anthropic call
now goes through one module that owns retry, timeout, abort, structured-
output parsing, schema validation, and best-effort call telemetry.

* src/lib/llm.js — single callLLM entry point. Resolves model per tier
  (fast / standard / reasoning) with admin:model legacy fallback for the
  standard tier; 60s default timeout via AbortController; balanced-brace
  JSON extraction; LLMHttpError, LLMTruncatedError, LLMOutputError, and
  LLMValidationError surface clearly distinct failure modes.
* src/lib/llmRetry.js — exponential backoff with full jitter, retries
  only on transient HTTP statuses, honours Retry-After up to 60s, never
  retries on AbortError.
* src/lib/llmSchemas.js — Zod schemas for every structured task plus
  normalizeHandbookResult (collapses legacy "executes" relations into
  the canonical "executed_by" vocabulary).
* src/lib/api.js — thin shim over callLLM so existing callers (extraction
  pipeline, learning, quiz, R42, knowledge graph) keep working unchanged.
* src/lib/__tests__/ — 32 Vitest cases covering parse paths, error
  surfaces, simulation mode, model resolution, and schema validation.
* src/pages/Admin/index.jsx — three model inputs (fast / standard /
  reasoning) replacing the single legacy field; legacy value falls back
  for the standard tier so existing overrides survive.

Adds Zod and Vitest, plus an "npm run test" script.

Also cleans up the pre-existing repo-wide ESLint failures so phase 1's
"npm run lint passes" acceptance criterion can be checked: drops unused
React imports across the JSX tree (React 19 JSX runtime auto-imports),
attaches cause to rethrown errors in the service modules, ignores
pb_migrations in the ESLint config (PocketBase JSVM globals), and
removes one dead handleCreateCustom function in Leren.jsx. A real
behaviour bug surfaced in Testen.jsx — the quiz timer captured a stale
finishQuiz via setInterval closure; now updated via finishQuizRef so the
timer always invokes the latest callback.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
RaymondVerhoef
2026-05-20 13:50:09 +02:00
parent db5bb854c3
commit 4a8dbee7df
36 changed files with 1612 additions and 233 deletions

202
src/lib/llmSchemas.js Normal file
View File

@@ -0,0 +1,202 @@
/**
* 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(),
});
/**
* 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,
};