Opening an onboarding theme could fail with "LLM output failed schema validation": the fast-tier model occasionally emits the key_points and topics_covered tool fields as JSON-encoded strings instead of real arrays. Same failure class the quiz schema already guards against with z.preprocess. - onboardingOverviewSchema: parse "[...]" strings back to arrays before validation; split a bullet/newline string of key points as a fallback; keep the first 5 key points on overage instead of failing the user. Genuinely bad output (too few points, non-JSON strings, wrong types) still fails validation. - emit_onboarding_overview tool: descriptions now state the fields must be JSON arrays, never strings (prompt-side nudge). - 5 regression tests reproducing the exact #32 payload shapes (npm test 139/139). Closes #32 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
493 lines
18 KiB
JavaScript
493 lines
18 KiB
JavaScript
/**
|
||
* 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 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'],
|
||
},
|
||
};
|
||
|
||
export const EMIT_CURRICULUM_SCHEDULE_TOOL = {
|
||
name: 'emit_curriculum_schedule',
|
||
description: 'Emit a 26-week curriculum schedule. One theme per week, with an ordered subset of topics from that theme.',
|
||
input_schema: {
|
||
type: 'object',
|
||
properties: {
|
||
weeks: {
|
||
type: 'array',
|
||
items: {
|
||
type: 'object',
|
||
properties: {
|
||
week_number: { type: 'integer', minimum: 1, maximum: 26 },
|
||
theme: { type: 'string' },
|
||
topic_ids: { type: 'array', items: { type: 'string' }, minItems: 1 },
|
||
estimated_duration: { type: 'integer', minimum: 15, maximum: 45 },
|
||
week_rationale: { type: 'string' },
|
||
},
|
||
required: ['week_number', 'theme', 'topic_ids', 'estimated_duration', 'week_rationale'],
|
||
},
|
||
minItems: 26,
|
||
maxItems: 26,
|
||
},
|
||
},
|
||
required: ['weeks'],
|
||
},
|
||
};
|
||
|
||
export const EMIT_TOPIC_ENRICHMENT_TOOL = {
|
||
name: 'emit_topic_enrichment',
|
||
description: 'Enrich a batch of topics with a theme, complexity_weight, and difficulty.',
|
||
input_schema: {
|
||
type: 'object',
|
||
properties: {
|
||
topics: {
|
||
type: 'array',
|
||
items: {
|
||
type: 'object',
|
||
properties: {
|
||
id: { type: 'string' },
|
||
theme: { type: 'string' },
|
||
complexity_weight: { type: 'integer', minimum: 1, maximum: 5 },
|
||
difficulty: { type: 'string', enum: ['introductory', 'intermediate', 'advanced'] },
|
||
},
|
||
required: ['id', 'theme', 'complexity_weight', 'difficulty'],
|
||
},
|
||
minItems: 1,
|
||
},
|
||
},
|
||
required: ['topics'],
|
||
},
|
||
};
|
||
|
||
|
||
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 QUIZ_DIFFICULTIES = ['easy', 'medium', 'hard'];
|
||
|
||
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).' },
|
||
difficulty: { type: 'string', enum: QUIZ_DIFFICULTIES, description: 'Per-question difficulty tag.' },
|
||
},
|
||
required: ['id', 'question', 'topicLabel', 'options', 'correctIndex', 'explanation', 'difficulty'],
|
||
};
|
||
|
||
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,
|
||
];
|
||
|
||
// ── Micro Learning generation tools ───────────────────────────────────────────
|
||
|
||
export const EMIT_CONCEPT_EXPLAINER_TOOL = {
|
||
name: 'emit_concept_explainer',
|
||
description: 'Return a structured concept explanation with multiple sections. Each section moves from definition → importance → practical application. The final section must include a concrete workplace example.',
|
||
input_schema: {
|
||
type: 'object',
|
||
properties: {
|
||
sections: {
|
||
type: 'array',
|
||
items: {
|
||
type: 'object',
|
||
properties: {
|
||
title: { type: 'string', description: 'Section heading.' },
|
||
content: { type: 'string', description: 'Section body in HTML. Use <p>, <ul>, <li>, <strong> tags for formatting. At least 3 sentences.' },
|
||
},
|
||
required: ['title', 'content'],
|
||
},
|
||
minItems: 3,
|
||
description: 'At least 3 sections: What it is, Why it matters, Practical example.',
|
||
},
|
||
},
|
||
required: ['sections'],
|
||
},
|
||
};
|
||
|
||
export const EMIT_SCENARIO_QUIZ_TOOL = {
|
||
name: 'emit_scenario_quiz',
|
||
description: 'Return a realistic workplace scenario with 3–4 plausible answer options. Exactly one option is correct. Each option must have a detailed explanation teaching why it is right or wrong.',
|
||
input_schema: {
|
||
type: 'object',
|
||
properties: {
|
||
scenario: { type: 'string', description: 'A realistic workplace situation (3–5 sentences) where the employee must decide what to do.' },
|
||
options: {
|
||
type: 'array',
|
||
items: {
|
||
type: 'object',
|
||
properties: {
|
||
text: { type: 'string', description: 'The action the employee could take.' },
|
||
isCorrect: { type: 'boolean', description: 'True for exactly one option.' },
|
||
explanation: { type: 'string', description: 'Why this option is correct or incorrect (2–3 sentences). Teach, do not just state.' },
|
||
},
|
||
required: ['text', 'isCorrect', 'explanation'],
|
||
},
|
||
minItems: 3,
|
||
maxItems: 4,
|
||
},
|
||
},
|
||
required: ['scenario', 'options'],
|
||
},
|
||
};
|
||
|
||
export const EMIT_FLASHCARD_SET_TOOL = {
|
||
name: 'emit_flashcard_set',
|
||
description: 'Return a set of 5–10 flashcards covering key facts, terms, and relationships from the topic. Mix question types: definitions, applications, and relationships.',
|
||
input_schema: {
|
||
type: 'object',
|
||
properties: {
|
||
cards: {
|
||
type: 'array',
|
||
items: {
|
||
type: 'object',
|
||
properties: {
|
||
front: { type: 'string', description: 'The question or prompt shown on the front of the card.' },
|
||
back: { type: 'string', description: 'The answer revealed on the back of the card.' },
|
||
},
|
||
required: ['front', 'back'],
|
||
},
|
||
minItems: 5,
|
||
maxItems: 10,
|
||
},
|
||
},
|
||
required: ['cards'],
|
||
},
|
||
};
|
||
|
||
// ── Theme session (theme-level weekly summary) ───────────────────────────────
|
||
|
||
export const EMIT_THEME_SESSION_TOOL = {
|
||
name: 'emit_theme_session',
|
||
description: 'Return a detailed weekly theme session that synthesises every topic in the week into one coherent learning experience. Cover each topic in its own section and finish with how the topics connect plus key takeaways for the theme.',
|
||
input_schema: {
|
||
type: 'object',
|
||
properties: {
|
||
title: { type: 'string', description: 'A short, learner-facing title for this weekly theme session. 3–8 words.' },
|
||
intro: { type: 'string', description: 'One-paragraph introduction (3–5 sentences) framing the theme and why it matters to a Respellion employee this week.' },
|
||
topicSections: {
|
||
type: 'array',
|
||
description: 'One section per topic in the week. Use the topic_id you were given so the UI can link back to per-topic practice.',
|
||
items: {
|
||
type: 'object',
|
||
properties: {
|
||
topic_id: { type: 'string', description: 'The id of the topic this section covers (must match one of the topic ids provided).' },
|
||
label: { type: 'string', description: 'The topic label, as provided.' },
|
||
summary: { type: 'string', description: 'Detailed summary of the topic in HTML. Use <p>, <ul>, <li>, <strong>. At least 4 sentences with a concrete example or application.' },
|
||
},
|
||
required: ['topic_id', 'label', 'summary'],
|
||
},
|
||
minItems: 1,
|
||
},
|
||
connections: { type: 'string', description: 'A short HTML paragraph (2–4 sentences) explaining how the topics in this week connect to each other within the theme.' },
|
||
keyTakeaways: {
|
||
type: 'array',
|
||
items: { type: 'string' },
|
||
minItems: 3,
|
||
description: 'At least 3 punchy bullet takeaways covering the theme as a whole.',
|
||
},
|
||
},
|
||
required: ['title', 'intro', 'topicSections', 'connections', 'keyTakeaways'],
|
||
},
|
||
};
|
||
|
||
// ── Onboarding overview (breadth-first, one theme) ───────────────────────────
|
||
|
||
export const EMIT_ONBOARDING_OVERVIEW_TOOL = {
|
||
name: 'emit_onboarding_overview',
|
||
description: 'Return a SHORT, breadth-first onboarding overview of ONE theme for a brand-new Respellion employee. This is a light introduction, NOT a deep lesson: what the theme is in plain language, why it matters for day-to-day and week-to-week work at Respellion, a few key points, and which topics belong to it. Keep it skimmable.',
|
||
input_schema: {
|
||
type: 'object',
|
||
properties: {
|
||
title: { type: 'string', description: 'Learner-facing theme title, 2–6 words.' },
|
||
what_it_is: { type: 'string', description: '1–2 plain-language sentences defining what this theme is about.' },
|
||
why_it_matters: { type: 'string', description: '1–3 sentences on why this theme matters for a new employee\'s daily and weekly work at Respellion.' },
|
||
key_points: {
|
||
type: 'array',
|
||
items: { type: 'string' },
|
||
minItems: 3,
|
||
maxItems: 5,
|
||
description: '3–5 short, concrete takeaways a newcomer should remember about this theme. Must be a JSON array of strings — never a single string.',
|
||
},
|
||
topics_covered: {
|
||
type: 'array',
|
||
description: 'The topics that make up this theme. Reuse the exact topic_id you were given so the UI can link back. Must be a JSON array of objects — never a string.',
|
||
items: {
|
||
type: 'object',
|
||
properties: {
|
||
topic_id: { type: 'string', description: 'The id of the topic (must match one of the ids provided).' },
|
||
label: { type: 'string', description: 'The topic label, as provided.' },
|
||
},
|
||
required: ['topic_id', 'label'],
|
||
},
|
||
minItems: 1,
|
||
},
|
||
},
|
||
required: ['title', 'what_it_is', 'why_it_matters', 'key_points', 'topics_covered'],
|
||
},
|
||
};
|
||
|