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:
202
src/lib/llmSchemas.js
Normal file
202
src/lib/llmSchemas.js
Normal 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,
|
||||
};
|
||||
Reference in New Issue
Block a user