feat: phase 2 of AI pipeline hardening — tool-based structured outputs + prompt caching
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

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:
RaymondVerhoef
2026-05-20 15:47:20 +02:00
parent 8a8745fad2
commit f838755991
11 changed files with 872 additions and 291 deletions

View File

@@ -2,7 +2,8 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import * as d3 from 'd3'; import * as d3 from 'd3';
import { Trash2, Edit2, Save, X, RefreshCw, AlertCircle, Plus, Link as LinkIcon } from 'lucide-react'; import { Trash2, Edit2, Save, X, RefreshCw, AlertCircle, Plus, Link as LinkIcon } from 'lucide-react';
import * as db from '../../lib/db'; import * as db from '../../lib/db';
import { anthropicApi } from '../../lib/api'; import { callLLM } from '../../lib/llm';
import { EMIT_GRAPH_ACTIONS_TOOL } from '../../lib/llmTools';
import { analyzeHandbookDelta } from '../../lib/extractionPipeline'; import { analyzeHandbookDelta } from '../../lib/extractionPipeline';
import { getRepoFolder, getFileContent } from '../../lib/githubService'; import { getRepoFolder, getFileContent } from '../../lib/githubService';
import Button from '../ui/Button'; import Button from '../ui/Button';
@@ -304,18 +305,18 @@ const KnowledgeGraph = () => {
const currentTopics = await db.getTopics(); const currentTopics = await db.getTopics();
const currentRelations = await db.getRelations(); const currentRelations = await db.getRelations();
const systemPrompt = `You are a strict Data Quality AI maintaining a Knowledge Graph. const systemPrompt = `You are a strict Data Quality AI maintaining a Knowledge Graph for Respellion.
Your goal is to evaluate the provided topics and relations, identify duplicates to merge, useless nodes to delete, and new logical relations to add. Evaluate the provided topics and relations and emit the actions to take via the emit_graph_actions tool.
Rules: Rules:
1. Identify topics that mean exactly the same thing. Choose one to keep, and one to delete. 1. Identify topics that mean exactly the same thing. Choose one to keep, one to delete (merges).
2. Identify topics that are too vague, irrelevant, or malformed to delete. 2. Identify topics that are too vague, irrelevant, or malformed (deletions).
3. Identify missing logical relations (depends_on, part_of, related_to) if two topics are conceptually linked but missing a relation. 3. Identify missing logical relations (depends_on, part_of, related_to, executed_by) between conceptually linked topics (newRelations).
4. Evaluate the learning_relevance of each topic. If a topic is purely operational/mundane (like a printer guide), mark it as "exclude". If it's low priority, mark "peripheral". 4. Evaluate learning_relevance. Mark purely operational topics (printer guides, etc.) as "exclude"; low-priority as "peripheral" (relevanceUpdates).
5. Return ONLY a valid JSON object describing the ACTIONS to take. Do not return the entire graph. Do not wrap in markdown blocks.`;
Do not return the entire graph — only the actions to take.`;
// Send a compact representation to minimize token usage and avoid rate limits. // Send a compact representation to minimize token usage and avoid rate limits.
// The AI only needs id, label, type, and relevance to identify duplicates/merges and adjust relevance.
const compactTopics = currentTopics.map(({ id, label, type, learning_relevance }) => ({ id, label, type, learning_relevance })); const compactTopics = currentTopics.map(({ id, label, type, learning_relevance }) => ({ id, label, type, learning_relevance }));
const compactRelations = currentRelations.map(r => ({ const compactRelations = currentRelations.map(r => ({
source: r.source?.id || r.source, source: r.source?.id || r.source,
@@ -324,21 +325,20 @@ Rules:
})); }));
const userPrompt = `Here is the current knowledge graph: const userPrompt = `Here is the current knowledge graph:
${JSON.stringify({ topics: compactTopics, relations: compactRelations })} ${JSON.stringify({ topics: compactTopics, relations: compactRelations })}`;
Analyze this graph and return ONLY the optimized JSON object with this EXACT structure: const llmResult = await callLLM({
{ task: 'graph.analyze',
"merges": [ { "keepId": "id_to_keep", "deleteId": "id_to_delete" } ], tier: 'reasoning',
"deletions": [ "id_to_delete_completely" ], system: [{ type: 'text', text: systemPrompt, cache_control: { type: 'ephemeral' } }],
"newRelations": [ { "source": "id1", "target": "id2", "type": "depends_on" } ], user: userPrompt,
"relevanceUpdates": [ { "id": "topic_id", "learning_relevance": "exclude" } ] tools: [EMIT_GRAPH_ACTIONS_TOOL],
}`; toolChoice: { type: 'tool', name: EMIT_GRAPH_ACTIONS_TOOL.name },
maxTokens: 4096,
});
const responseText = await anthropicApi.generateContent(systemPrompt, userPrompt); const actions = llmResult.toolUses[0]?.input;
const jsonMatch = responseText.match(/\{[\s\S]*\}/); if (!actions) throw new Error('Graph analysis did not emit a tool result.');
if (!jsonMatch) throw new Error('AI returned invalid format.');
const actions = JSON.parse(jsonMatch[0]);
let updatedTopics = [...currentTopics]; let updatedTopics = [...currentTopics];
let updatedRelations = [...currentRelations]; let updatedRelations = [...currentRelations];

View File

@@ -23,10 +23,9 @@ export const STRINGS = {
openAria: 'Open R42 chatbot', openAria: 'Open R42 chatbot',
}; };
export function buildSystemPrompt({ userName, isAdmin, kbContext }) { const STABLE_PREAMBLE = [
return [
`Je bent R42, de chatbot-avatar van Respellion — een leerplatform voor microlearning, quizzen en kennisontwikkeling.`, `Je bent R42, de chatbot-avatar van Respellion — een leerplatform voor microlearning, quizzen en kennisontwikkeling.`,
`Antwoord altijd in het Nederlands, kort en zakelijk-vriendelijk. Spreek de gebruiker aan met hun voornaam wanneer dat natuurlijk voelt (${userName}).`, `Antwoord altijd in het Nederlands, kort en zakelijk-vriendelijk. Spreek de gebruiker aan met hun voornaam wanneer dat natuurlijk voelt.`,
``, ``,
`JE TAKEN:`, `JE TAKEN:`,
`1. Leg onderwerpen uit die in de kennisbasis staan.`, `1. Leg onderwerpen uit die in de kennisbasis staan.`,
@@ -34,9 +33,7 @@ export function buildSystemPrompt({ userName, isAdmin, kbContext }) {
`3. Verwijs bij twijfel terug naar het bronmateriaal of zeg eerlijk dat je het niet weet.`, `3. Verwijs bij twijfel terug naar het bronmateriaal of zeg eerlijk dat je het niet weet.`,
``, ``,
`JE KENNIS:`, `JE KENNIS:`,
`Je kennis is beperkt tot de onderstaande Respellion-kennisgraaf. Als een vraag duidelijk buiten dit bereik valt, zeg dat dan eerlijk en stel voor dat de gebruiker de bron toevoegt via Admin → Sources.`, `Je kennis is beperkt tot de Respellion-kennisgraaf die hieronder volgt. Als een vraag duidelijk buiten dit bereik valt, zeg dat dan eerlijk en stel voor dat de gebruiker de bron toevoegt via Admin → Sources.`,
``,
kbContext,
``, ``,
`KENNISGRAAF VERFIJNEN:`, `KENNISGRAAF VERFIJNEN:`,
`Wanneer de gebruiker iets noemt dat duidelijk een nieuw topic, nieuwe relatie, proces of rol is — en dat nog niet in de kennisgraaf staat — gebruik dan de tool "propose_graph_delta" om een voorstel te maken. Verzin niets: stel alleen iets voor als de gebruiker het concreet noemt. Stel maximaal 3 topics en 5 relaties per beurt voor.`, `Wanneer de gebruiker iets noemt dat duidelijk een nieuw topic, nieuwe relatie, proces of rol is — en dat nog niet in de kennisgraaf staat — gebruik dan de tool "propose_graph_delta" om een voorstel te maken. Verzin niets: stel alleen iets voor als de gebruiker het concreet noemt. Stel maximaal 3 topics en 5 relaties per beurt voor.`,
@@ -45,10 +42,30 @@ export function buildSystemPrompt({ userName, isAdmin, kbContext }) {
`- Houd antwoorden onder de 4 zinnen tenzij de gebruiker om uitleg vraagt.`, `- Houd antwoorden onder de 4 zinnen tenzij de gebruiker om uitleg vraagt.`,
`- Geen markdown-headers; gewone Nederlandse tekst.`, `- Geen markdown-headers; gewone Nederlandse tekst.`,
`- Bij onzekerheid: "Ik weet het niet zeker — controleer dit in het handboek."`, `- Bij onzekerheid: "Ik weet het niet zeker — controleer dit in het handboek."`,
isAdmin
? `\nDe gebruiker is beheerder; voorstellen die de tool genereert worden direct toegepast.`
: `\nDe gebruiker is geen beheerder; voorstellen worden in een goedkeuringswachtrij gezet.`,
].join('\n'); ].join('\n');
/**
* Build the R42 system prompt as three cacheable blocks:
* 1. stable preamble (role, tasks, style) — cached
* 2. KB context (current topics + relations) — cached (hash-bust comes in Phase 5)
* 3. per-turn tail (user name + admin status) — NOT cached
*
* Returning an array lets `callLLM` pass it through unchanged so the
* Anthropic API caches each block with the 5-minute ephemeral TTL.
*/
export function buildSystemPrompt({ userName, isAdmin, kbContext }) {
const tail = [
`De gebruiker heet ${userName}.`,
isAdmin
? `De gebruiker is beheerder; voorstellen die de tool genereert worden direct toegepast.`
: `De gebruiker is geen beheerder; voorstellen worden in een goedkeuringswachtrij gezet.`,
].join('\n');
return [
{ type: 'text', text: STABLE_PREAMBLE, cache_control: { type: 'ephemeral' } },
{ type: 'text', text: kbContext, cache_control: { type: 'ephemeral' } },
{ type: 'text', text: tail },
];
} }
export const PROPOSE_GRAPH_DELTA_TOOL = { export const PROPOSE_GRAPH_DELTA_TOOL = {

View File

@@ -0,0 +1,104 @@
import { describe, expect, it } from 'vitest';
import { applyArticlePatches, applyAndValidate } from '../articlePatches';
const article = () => ({
title: 'Onboarding',
intro: 'Old intro.',
sections: [
{ heading: 'Day one', body: 'First day body, three sentences long. Welcome. Read the handbook.' },
{ heading: 'Day two', body: 'Second day body. Three sentences. Meet your team.' },
],
keyTakeaways: ['Show up', 'Ask questions'],
});
describe('applyArticlePatches', () => {
it('does not mutate the input article', () => {
const original = article();
const snapshot = JSON.parse(JSON.stringify(original));
applyArticlePatches(original, [
{ name: 'set_intro', input: { intro: 'New intro.' } },
]);
expect(original).toEqual(snapshot);
});
it('set_intro replaces the intro', () => {
const result = applyArticlePatches(article(), [
{ name: 'set_intro', input: { intro: 'Punchier intro.' } },
]);
expect(result.intro).toBe('Punchier intro.');
});
it('set_section replaces the matching section body (case-insensitive)', () => {
const result = applyArticlePatches(article(), [
{ name: 'set_section', input: { heading: 'DAY ONE', body: 'Rewritten body. With several sentences. Indeed.' } },
]);
expect(result.sections[0].body).toMatch(/Rewritten body/);
expect(result.sections[1].body).toMatch(/Second day body/);
});
it('add_section position=start prepends a new section', () => {
const result = applyArticlePatches(article(), [
{ name: 'add_section', input: { heading: 'Before', body: 'New intro section. Three sentences. Indeed.', position: 'start' } },
]);
expect(result.sections[0].heading).toBe('Before');
expect(result.sections).toHaveLength(3);
});
it('add_section position=end appends a new section', () => {
const result = applyArticlePatches(article(), [
{ name: 'add_section', input: { heading: 'After', body: 'Closing section. Three sentences. Indeed.', position: 'end' } },
]);
expect(result.sections[2].heading).toBe('After');
});
it('remove_section drops the matching section', () => {
const result = applyArticlePatches(article(), [
{ name: 'remove_section', input: { heading: 'Day one' } },
]);
expect(result.sections).toHaveLength(1);
expect(result.sections[0].heading).toBe('Day two');
});
it('replace_takeaways swaps the key takeaways', () => {
const result = applyArticlePatches(article(), [
{ name: 'replace_takeaways', input: { items: ['First', 'Second', 'Third'] } },
]);
expect(result.keyTakeaways).toEqual(['First', 'Second', 'Third']);
});
it('applies multiple patches in order', () => {
const result = applyArticlePatches(article(), [
{ name: 'set_intro', input: { intro: 'Brand new intro.' } },
{ name: 'remove_section', input: { heading: 'Day one' } },
{ name: 'add_section', input: { heading: 'New', body: 'Body of the new section. Three sentences. Yes.', position: 'end' } },
]);
expect(result.intro).toBe('Brand new intro.');
expect(result.sections.map(s => s.heading)).toEqual(['Day two', 'New']);
});
it('falls back to appending when set_section cannot find a matching heading', () => {
const result = applyArticlePatches(article(), [
{ name: 'set_section', input: { heading: 'Nonexistent', body: 'New body, with three sentences. Yes indeed. Foo.' } },
]);
expect(result.sections).toHaveLength(3);
expect(result.sections[2].heading).toBe('Nonexistent');
});
});
describe('applyAndValidate', () => {
it('returns the patched article when valid', () => {
const patched = applyAndValidate(article(), [
{ name: 'set_intro', input: { intro: 'Tighter intro.' } },
]);
expect(patched.intro).toBe('Tighter intro.');
});
it('throws when patches strip the article to invalid', () => {
expect(() =>
applyAndValidate(article(), [
{ name: 'remove_section', input: { heading: 'Day one' } },
{ name: 'remove_section', input: { heading: 'Day two' } },
]),
).toThrow(/invalid article/i);
});
});

View File

@@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest';
import {
EMIT_KNOWLEDGE_GRAPH_TOOL,
EMIT_HANDBOOK_DELTA_TOOL,
EMIT_LEARNING_ARTICLE_TOOL,
EMIT_LEARNING_SLIDES_TOOL,
EMIT_LEARNING_INFOGRAPHIC_TOOL,
EMIT_LEARNING_ALL_TOOL,
EMIT_CUSTOM_TOPIC_TOOL,
EMIT_QUIZ_QUESTIONS_TOOL,
EMIT_GRAPH_ACTIONS_TOOL,
ARTICLE_PATCH_TOOLS,
} from '../llmTools';
import { toolSchemaRegistry } from '../llmSchemas';
const allTools = [
EMIT_KNOWLEDGE_GRAPH_TOOL,
EMIT_HANDBOOK_DELTA_TOOL,
EMIT_LEARNING_ARTICLE_TOOL,
EMIT_LEARNING_SLIDES_TOOL,
EMIT_LEARNING_INFOGRAPHIC_TOOL,
EMIT_LEARNING_ALL_TOOL,
EMIT_CUSTOM_TOPIC_TOOL,
EMIT_QUIZ_QUESTIONS_TOOL,
EMIT_GRAPH_ACTIONS_TOOL,
...ARTICLE_PATCH_TOOLS,
];
describe('llmTools', () => {
it('every tool has a name, description, and object input_schema', () => {
for (const t of allTools) {
expect(typeof t.name).toBe('string');
expect(t.name.length).toBeGreaterThan(0);
expect(typeof t.description).toBe('string');
expect(t.input_schema).toMatchObject({ type: 'object' });
}
});
it('every tool has a matching Zod validator in toolSchemaRegistry', () => {
for (const t of allTools) {
expect(toolSchemaRegistry[t.name]).toBeTruthy();
}
});
});

80
src/lib/articlePatches.js Normal file
View File

@@ -0,0 +1,80 @@
/**
* Apply a sequence of patch operations (the tool_use calls returned by
* `refineLearningContent`) to an article object, in order. The returned
* article is a fresh object — the input is not mutated.
*
* Recognised tool names mirror `llmTools.js`:
* set_intro, set_section, add_section, remove_section, replace_takeaways.
*
* Unknown tool names are ignored on purpose; the caller validates the
* result against `learningArticleSchema` and rejects the whole turn if
* the patches produced an invalid article.
*/
import { learningArticleSchema } from './llmSchemas';
function matchesHeading(section, heading) {
return (section.heading ?? '').trim().toLowerCase() === heading.trim().toLowerCase();
}
function cloneArticle(article) {
return {
...article,
sections: article.sections.map((s) => ({ ...s })),
keyTakeaways: [...article.keyTakeaways],
};
}
export function applyArticlePatches(article, toolUses) {
let next = cloneArticle(article);
for (const tu of toolUses) {
switch (tu.name) {
case 'set_intro':
next = { ...next, intro: tu.input.intro };
break;
case 'set_section': {
const idx = next.sections.findIndex((s) => matchesHeading(s, tu.input.heading));
if (idx === -1) {
// No matching section — fall back to appending so the model's
// intent (provide that body) is preserved rather than lost.
next.sections = [...next.sections, { heading: tu.input.heading, body: tu.input.body }];
} else {
next.sections = next.sections.map((s, i) => (i === idx ? { ...s, body: tu.input.body } : s));
}
break;
}
case 'add_section': {
const newSection = { heading: tu.input.heading, body: tu.input.body };
next.sections = tu.input.position === 'start'
? [newSection, ...next.sections]
: [...next.sections, newSection];
break;
}
case 'remove_section':
next.sections = next.sections.filter((s) => !matchesHeading(s, tu.input.heading));
break;
case 'replace_takeaways':
next = { ...next, keyTakeaways: [...tu.input.items] };
break;
default:
// Unknown patch op — ignore.
break;
}
}
return next;
}
/**
* Apply the patches and re-validate against the article schema. Throws
* a clear error if the result is invalid.
*/
export function applyAndValidate(article, toolUses) {
const updated = applyArticlePatches(article, toolUses);
const parsed = learningArticleSchema.safeParse({ article: updated });
if (!parsed.success) {
const err = new Error(`Refinement produced an invalid article: ${parsed.error.message}`);
err.cause = parsed.error;
throw err;
}
return parsed.data.article;
}

View File

@@ -1,85 +1,62 @@
import { anthropicApi } from './api';
import * as db from './db'; import * as db from './db';
import { callLLM } from './llm';
import { EMIT_KNOWLEDGE_GRAPH_TOOL, EMIT_HANDBOOK_DELTA_TOOL } from './llmTools';
import { normalizeHandbookResult } from './llmSchemas';
const SYSTEM_PROMPT = `You are an AI knowledge extractor for Respellion, an IT company built on radical transparency. const EXTRACTION_SYSTEM_PROMPT = `You are an AI knowledge extractor for Respellion, an IT company built on radical transparency.
You receive a source text. Your task is to extract all core concepts, roles, and processes from the text, and return them as a structured JSON Knowledge Graph. You receive a source text. Extract every distinct concept, role, and process from it and emit them through the emit_knowledge_graph tool.
Facts should be integrated into the descriptions of the other labels and NOT be extracted as unique topics.
CRITICAL INSTRUCTIONS FOR COMPLETENESS: CRITICAL INSTRUCTIONS FOR COMPLETENESS:
- You must extract EVERY SINGLE distinct role, process, and concept described or mentioned in the source text. - Extract EVERY SINGLE distinct role, process, and concept described or mentioned in the source text.
- DO NOT summarize, skip, truncate, or omit any items. - DO NOT summarise, skip, truncate, or omit any items.
- If the document contains 29 roles, your JSON topics array must contain exactly 29 role topics. - If the document contains 29 roles, the topics array must contain exactly 29 role topics.
- Completeness is of paramount importance. Failing to extract all topics will result in loss of critical company knowledge. - Completeness is paramount. Failing to extract all topics loses critical company knowledge.
- Keep descriptions concise (max 3 sentences) to ensure you have enough output tokens to list everything. - Facts should be integrated into the descriptions of other topics — never extracted as standalone topics.
- Keep descriptions concise (max 3 sentences) so the response fits.
You MUST assign a learning_relevance to each topic: Topic IDs are lowercase kebab-case slugs specific to the topic (e.g. "software-engineer", "data-quality-review"). Do not use generic IDs like "role-1" or "concept-2".
- "core": Fundamental company knowledge.
- "standard": Normal learning topics.
- "peripheral": Good to know, but low priority.
- "exclude": Pure operational reference material (e.g., printer guides, wifi passwords) that should NEVER be tested.
ALWAYS return a valid JSON object in the following format: Assign a learning_relevance to every topic:
{ - "core": fundamental company knowledge.
"topics": [ - "standard": normal learning topics.
{ - "peripheral": good to know, low priority.
"id": "a-unique-lowercase-kebab-case-slug-specific-to-this-topic (e.g., 'software-engineer' or 'data-quality-review'). DO NOT use generic IDs like 'role-1' or 'concept-2'.", - "exclude": pure operational reference (printer guides, wifi passwords) that should never be tested.
"label": "Topic title",
"type": "concept | role | process",
"description": "A concise, clear explanation of max 3 sentences.",
"learning_relevance": "core | standard | peripheral | exclude"
}
],
"relations": [
{
"source": "topic-id-1",
"target": "topic-id-2",
"type": "related_to | depends_on | part_of | executed_by"
}
]
}
Return JSON only. No markdown blocks or other text.`;
const HANDBOOK_SYSTEM_PROMPT = `You are analyzing an update to the Respellion Employee Handbook. Relation types: related_to | depends_on | part_of | executed_by.
Your task is to identify changes and extract structural knowledge. `;
CRITICAL INSTRUCTION: const HANDBOOK_SYSTEM_PROMPT = `You are analysing an update to the Respellion Employee Handbook. Emit the extracted topics and relations through the emit_handbook_delta tool.
You must explicitly identify and create relations between Roles, Processes, and Concepts.
Every Process must have a Role attached (who does it).
Every Concept must have a relation to a Process or Role.
You MUST assign a learning_relevance to each topic: CRITICAL INSTRUCTIONS:
- "core": Fundamental company knowledge. - Every process must have a role attached (the role that executes it).
- "standard": Normal learning topics. - Every concept must connect to a process or role.
- "peripheral": Good to know, but low priority. - Mark handbook topics with metadata.source = "github_handbook".
- "exclude": Pure operational reference material (e.g., printer guides, wifi passwords) that should NEVER be tested. - Assign learning_relevance using the same scale as extraction: core | standard | peripheral | exclude.
Return a JSON object: Relation types: related_to | depends_on | part_of | executed_by. (Legacy "executes" relations are normalised by the client into executed_by with source/target swapped.)
{ `;
"topics": [
{ "id": "...", "label": "...", "type": "role | process | concept", "description": "...", "learning_relevance": "standard", "metadata": { "source": "github_handbook" } } const cachedSystem = (text) => [{ type: 'text', text, cache_control: { type: 'ephemeral' } }];
],
"relations": [
{ "source": "role-id", "target": "process-id", "type": "executes | related_to | depends_on | part_of", "description": "Brief metadata about this specific relation" }
]
}
Return JSON only. No markdown blocks or other text.`;
export async function analyzeHandbookDelta(fileContent, filePath) { export async function analyzeHandbookDelta(fileContent, filePath) {
const responseText = await anthropicApi.generateContent(HANDBOOK_SYSTEM_PROMPT, `Analyze the following handbook file update (${filePath}):\n\n${fileContent}`); const result = await callLLM({
task: 'extract.handbook',
tier: 'standard',
system: cachedSystem(HANDBOOK_SYSTEM_PROMPT),
user: `Analyze the following handbook file update (${filePath}):\n\n${fileContent}`,
tools: [EMIT_HANDBOOK_DELTA_TOOL],
toolChoice: { type: 'tool', name: EMIT_HANDBOOK_DELTA_TOOL.name },
maxTokens: 8192,
});
let extractedData; const raw = result.toolUses[0]?.input;
try { if (!raw) throw new Error('Handbook extraction did not emit a tool result.');
const jsonMatch = responseText.match(/\{[\s\S]*\}/); const extractedData = normalizeHandbookResult(raw);
const jsonStr = jsonMatch ? jsonMatch[0] : responseText;
extractedData = JSON.parse(jsonStr);
} catch (e) {
console.error('[Pipeline] AI returned non-JSON response for handbook delta:', responseText?.substring(0, 500));
throw new Error(`AI response was not valid JSON. The model responded with: "${responseText?.substring(0, 120)}..."`, { cause: e });
}
await mergeKnowledgeGraph(extractedData); await mergeKnowledgeGraph(extractedData);
return { success: true, data: extractedData }; return { success: true, data: extractedData };
} }
function chunkText(text, maxChunkSize = 4000) { function chunkText(text, maxChunkSize = 4000) {
const paragraphs = text.split(/\n+/); const paragraphs = text.split(/\n+/);
const chunks = []; const chunks = [];
@@ -98,7 +75,6 @@ function chunkText(text, maxChunkSize = 4000) {
} }
export async function processSourceText(textContent, sourceName) { export async function processSourceText(textContent, sourceName) {
// Deduplicate: skip if a source with the same name was already successfully processed
const existing = await db.getSources(); const existing = await db.getSources();
const alreadyDone = existing.find( const alreadyDone = existing.find(
s => s.name === sourceName && s.status === 'completed' s => s.name === sourceName && s.status === 'completed'
@@ -124,21 +100,18 @@ export async function processSourceText(textContent, sourceName) {
} }
console.log(`[Pipeline] Processing chunk ${i + 1}/${chunks.length} (${chunks[i].length} chars)...`); console.log(`[Pipeline] Processing chunk ${i + 1}/${chunks.length} (${chunks[i].length} chars)...`);
const responseText = await anthropicApi.generateContent( const result = await callLLM({
SYSTEM_PROMPT, task: 'extract.source',
`Analyze this part of the document (${i + 1}/${chunks.length}):\n\n${chunks[i]}` tier: 'standard',
); system: cachedSystem(EXTRACTION_SYSTEM_PROMPT),
console.log(`[Pipeline] Raw AI response for chunk ${i + 1}:`, responseText); user: `Analyze this part of the document (${i + 1}/${chunks.length}):\n\n${chunks[i]}`,
tools: [EMIT_KNOWLEDGE_GRAPH_TOOL],
toolChoice: { type: 'tool', name: EMIT_KNOWLEDGE_GRAPH_TOOL.name },
maxTokens: 8192,
});
let extractedData; const extractedData = result.toolUses[0]?.input;
try { if (!extractedData) throw new Error(`Extraction did not emit a tool result for chunk ${i + 1}.`);
const jsonMatch = responseText.match(/\{[\s\S]*\}/);
const jsonStr = jsonMatch ? jsonMatch[0] : responseText;
extractedData = JSON.parse(jsonStr);
} catch (e) {
console.error(`[Pipeline] AI returned non-JSON response for chunk ${i + 1}:`, responseText?.substring(0, 500));
throw new Error(`AI response for chunk ${i + 1} was not valid JSON.`, { cause: e });
}
if (extractedData.topics && Array.isArray(extractedData.topics)) { if (extractedData.topics && Array.isArray(extractedData.topics)) {
allExtractedTopics.push(...extractedData.topics); allExtractedTopics.push(...extractedData.topics);
@@ -148,7 +121,6 @@ export async function processSourceText(textContent, sourceName) {
} }
} }
// Merge everything together
await mergeKnowledgeGraph({ topics: allExtractedTopics, relations: allExtractedRelations }); await mergeKnowledgeGraph({ topics: allExtractedTopics, relations: allExtractedRelations });
await db.updateSourceStatus(sourceId, 'completed'); await db.updateSourceStatus(sourceId, 'completed');
@@ -169,13 +141,11 @@ async function mergeKnowledgeGraph(newData) {
if (newData.topics && Array.isArray(newData.topics)) { if (newData.topics && Array.isArray(newData.topics)) {
for (const t of newData.topics) { for (const t of newData.topics) {
if (topicsMap.has(t.id)) { if (topicsMap.has(t.id)) {
// Upsert: merge new data into existing topic
const existing = topicsMap.get(t.id); const existing = topicsMap.get(t.id);
topicsMap.set(t.id, { topicsMap.set(t.id, {
...existing, ...existing,
...t, ...t,
// Keep existing description if new one is empty, or combine them if needed. Here we prefer the new one. description: t.description || existing.description,
description: t.description || existing.description
}); });
} else { } else {
topicsMap.set(t.id, t); topicsMap.set(t.id, t);

View File

@@ -1,51 +1,37 @@
import { anthropicApi } from './api';
import * as db from './db'; import * as db from './db';
import { callLLM } from './llm';
import {
EMIT_LEARNING_ARTICLE_TOOL,
EMIT_LEARNING_SLIDES_TOOL,
EMIT_LEARNING_INFOGRAPHIC_TOOL,
EMIT_LEARNING_ALL_TOOL,
EMIT_CUSTOM_TOPIC_TOOL,
ARTICLE_PATCH_TOOLS,
} from './llmTools';
import { applyAndValidate } from './articlePatches';
import { getCurriculumTopic } from './curriculumService'; import { getCurriculumTopic } from './curriculumService';
const CONTENT_GENERATION_SYSTEM = `You are an expert learning content writer for Respellion, an internal IT company. const CONTENT_GENERATION_SYSTEM = `You are an expert learning content writer for Respellion, an internal IT company.
You write training material for employees based on knowledge topics. You write training material for employees based on knowledge topics.
Always write in clear, professional English. Always write in clear, professional English.
ALWAYS return valid JSON only — no markdown code blocks, no extra text.`;
const CONTENT_SCHEMA_ARTICLE = `{ Emit the requested content through the matching tool — do not return prose JSON.`;
"article": {
"title": "Article title",
"intro": "Short intro of 1-2 sentences",
"sections": [
{ "heading": "Section title", "body": "Section text of at least 3 sentences." }
],
"keyTakeaways": ["Takeaway 1", "Takeaway 2", "Takeaway 3"]
}
}`;
const CONTENT_SCHEMA_SLIDES = `{ const cachedSystem = (text) => [{ type: 'text', text, cache_control: { type: 'ephemeral' } }];
"slides": [
{ "title": "Slide title", "bullets": ["Point 1", "Point 2", "Point 3"], "speakerNote": "Speaker note for this slide." }
]
}`;
const TOOL_BY_TYPE = {
article: EMIT_LEARNING_ARTICLE_TOOL,
slides: EMIT_LEARNING_SLIDES_TOOL,
infographic: EMIT_LEARNING_INFOGRAPHIC_TOOL,
all: EMIT_LEARNING_ALL_TOOL,
};
const INSTRUCTIONS_BY_TYPE = {
const CONTENT_SCHEMA_INFOGRAPHIC = `{ article: 'Provide at least 3 article sections and at least 2 key takeaways.',
"infographic": { slides: 'Provide at least 4 slides.',
"headline": "A short, punchy headline summarizing the topic (max 8 words)", infographic: 'Provide at least 3 stats and 3 steps.',
"tagline": "A subtitle of max 15 words", all: 'Provide at least 3 article sections, 4 slides, 3 stats, and 3 steps in the infographic.',
"stats": [ };
{ "value": "Number or %", "label": "Short description", "icon": "📊" }
],
"steps": [
{ "number": 1, "title": "Step title", "description": "One-sentence description.", "icon": "🔑" }
],
"quote": "An inspiring or insightful quote about the topic.",
"colorTheme": "teal"
}
}`;
const CONTENT_SCHEMA_ALL = `{
"article": ${CONTENT_SCHEMA_ARTICLE.replace(/^\{|\}$/g, '').trim()},
"slides": ${CONTENT_SCHEMA_SLIDES.replace(/^\{|\}$/g, '').trim()},
"infographic": ${CONTENT_SCHEMA_INFOGRAPHIC.replace(/^\{|\}$/g, '').trim()}
}`;
/** /**
* Get the assigned topic for a given week. * Get the assigned topic for a given week.
@@ -53,7 +39,6 @@ const CONTENT_SCHEMA_ALL = `{
* Falls back to hash-based assignment if no curriculum is configured. * Falls back to hash-based assignment if no curriculum is configured.
*/ */
export async function getAssignedTopic(userId, weekNumber) { export async function getAssignedTopic(userId, weekNumber) {
// Try curriculum first
try { try {
const { topic } = await getCurriculumTopic(weekNumber); const { topic } = await getCurriculumTopic(weekNumber);
if (topic && topic.learning_relevance !== 'exclude') return topic; if (topic && topic.learning_relevance !== 'exclude') return topic;
@@ -61,9 +46,7 @@ export async function getAssignedTopic(userId, weekNumber) {
console.warn('[Learn] Curriculum lookup failed, falling back to hash:', e.message); console.warn('[Learn] Curriculum lookup failed, falling back to hash:', e.message);
} }
// Fallback: hash-based assignment (backwards compatible)
const allTopics = await db.getTopics(); const allTopics = await db.getTopics();
// Filter out 'fact' type topics and 'exclude' relevance topics
const topics = allTopics.filter(t => t.type !== 'fact' && t.learning_relevance !== 'exclude'); const topics = allTopics.filter(t => t.type !== 'fact' && t.learning_relevance !== 'exclude');
if (!topics || topics.length === 0) return null; if (!topics || topics.length === 0) return null;
@@ -96,29 +79,15 @@ export async function generateLearningContent(topic, force = false, selectedType
let cached = null; let cached = null;
if (!force) { if (!force) {
cached = await db.getContent(topic.id); cached = await db.getContent(topic.id);
if (cached) { if (cached && cached[selectedType]) {
if (cached[selectedType]) {
console.log(`[Learn] Cache hit for topic: ${topic.id} (${selectedType})`); console.log(`[Learn] Cache hit for topic: ${topic.id} (${selectedType})`);
return cached; return cached;
} }
} }
}
let schema = ''; const tool = TOOL_BY_TYPE[selectedType];
let instructions = ''; if (!tool) throw new Error(`Unknown learning content type: ${selectedType}`);
if (selectedType === 'all') { const instructions = INSTRUCTIONS_BY_TYPE[selectedType];
schema = CONTENT_SCHEMA_ALL;
instructions = 'Provide at least 3 article sections, 4 slides, 3 stats, and 3-5 steps in the infographic.';
} else if (selectedType === 'article') {
schema = CONTENT_SCHEMA_ARTICLE;
instructions = 'Provide at least 3 article sections.';
} else if (selectedType === 'slides') {
schema = CONTENT_SCHEMA_SLIDES;
instructions = 'Provide at least 4 slides.';
} else if (selectedType === 'infographic') {
schema = CONTENT_SCHEMA_INFOGRAPHIC;
instructions = 'Provide at least 3 stats, and 3-5 steps in the infographic.';
}
const prompt = `Generate a learning module piece for the following topic: const prompt = `Generate a learning module piece for the following topic:
@@ -126,20 +95,20 @@ Label: ${topic.label}
Type: ${topic.type} Type: ${topic.type}
Description: ${topic.description} Description: ${topic.description}
Return ONLY a JSON object with the following structure:
${schema}
${instructions}`; ${instructions}`;
const responseText = await anthropicApi.generateContent(CONTENT_GENERATION_SYSTEM, prompt); const result = await callLLM({
task: `learning.${selectedType}`,
tier: 'standard',
system: cachedSystem(CONTENT_GENERATION_SYSTEM),
user: prompt,
tools: [tool],
toolChoice: { type: 'tool', name: tool.name },
maxTokens: 8192,
});
let newContent; const newContent = result.toolUses[0]?.input;
try { if (!newContent) throw new Error('AI did not return learning content. Please try again.');
const jsonMatch = responseText.match(/\{[\s\S]*\}/);
newContent = JSON.parse(jsonMatch ? jsonMatch[0] : responseText);
} catch (e) {
throw new Error('AI could not generate valid learning content. Please try again.', { cause: e });
}
const mergedContent = { ...(cached || {}), ...newContent }; const mergedContent = { ...(cached || {}), ...newContent };
await db.setContent(topic.id, mergedContent); await db.setContent(topic.id, mergedContent);
@@ -148,28 +117,37 @@ ${instructions}`;
export async function refineLearningContent(topic, refinementInstruction) { export async function refineLearningContent(topic, refinementInstruction) {
const existing = await db.getContent(topic.id); const existing = await db.getContent(topic.id);
if (!existing?.article) {
throw new Error('Refinement is currently only supported for the article. Generate an article for this topic first.');
}
const prompt = `You have previously generated the following learning module for the topic "${topic.label}": const prompt = `You have previously generated the following article for the topic "${topic.label}":
${JSON.stringify(existing, null, 2)} ${JSON.stringify(existing.article, null, 2)}
The admin has requested the following refinement: The admin has requested the following refinement:
"${refinementInstruction}" "${refinementInstruction}"
Apply the refinement and return the complete updated JSON object using the same structure. Return ONLY valid JSON.`; Apply the refinement by calling one or more of the available patch tools. Make the smallest set of changes that satisfies the instruction — do not rewrite untouched sections.`;
const responseText = await anthropicApi.generateContent(CONTENT_GENERATION_SYSTEM, prompt); const result = await callLLM({
task: 'learning.refine',
tier: 'standard',
system: cachedSystem(CONTENT_GENERATION_SYSTEM),
user: prompt,
tools: ARTICLE_PATCH_TOOLS,
toolChoice: { type: 'any' },
maxTokens: 4096,
});
let content; if (!result.toolUses.length) {
try { throw new Error('AI did not propose any changes for that instruction. Try a more specific request.');
const jsonMatch = responseText.match(/\{[\s\S]*\}/);
content = JSON.parse(jsonMatch ? jsonMatch[0] : responseText);
} catch (e) {
throw new Error('AI could not process the refinement. Please try a different instruction.', { cause: e });
} }
await db.setContent(topic.id, content); const patchedArticle = applyAndValidate(existing.article, result.toolUses);
return content; const merged = { ...existing, article: patchedArticle };
await db.setContent(topic.id, merged);
return merged;
} }
export async function deleteCachedContent(topicId) { export async function deleteCachedContent(topicId) {
@@ -177,30 +155,20 @@ export async function deleteCachedContent(topicId) {
} }
export async function generateCustomTopic(label) { export async function generateCustomTopic(label) {
const prompt = `A user wants to learn about "${label}". const result = await callLLM({
Create a short description (2-3 sentences) and categorize it. task: 'topic.custom',
tier: 'standard',
system: cachedSystem('You are a knowledge graph AI categorising user-requested topics for the Respellion learning platform.'),
user: `A user wants to learn about "${label}". Provide a polished label, type, and 23 sentence description via the emit_custom_topic tool.`,
tools: [EMIT_CUSTOM_TOPIC_TOOL],
toolChoice: { type: 'tool', name: EMIT_CUSTOM_TOPIC_TOOL.name },
maxTokens: 1024,
});
Return ONLY a JSON object with this structure: const emitted = result.toolUses[0]?.input;
{ if (!emitted) throw new Error('Could not process custom topic. Please try again.');
"label": "Polished topic title",
"type": "concept", // one of: concept, role, process
"description": "Short description"
}`;
const responseText = await anthropicApi.generateContent(
"You are a knowledge graph AI categorizing topics.",
prompt
);
let newTopic;
try {
const jsonMatch = responseText.match(/\{[\s\S]*\}/);
newTopic = JSON.parse(jsonMatch ? jsonMatch[0] : responseText);
newTopic.id = 'custom_' + Date.now().toString(36);
} catch (e) {
throw new Error('Could not process custom topic. Please try again.', { cause: e });
}
const newTopic = { ...emitted, id: 'custom_' + Date.now().toString(36) };
await db.upsertTopic(newTopic); await db.upsertTopic(newTopic);
return newTopic; return newTopic;
} }

View File

@@ -125,7 +125,7 @@ function isChatLikeTask(task) {
return task === 'legacy.chat' || task.startsWith('chat.') || task.startsWith('r42.'); return task === 'legacy.chat' || task.startsWith('chat.') || task.startsWith('r42.');
} }
const SIMULATION_EXTRACTION_PAYLOAD = JSON.stringify({ const SIMULATION_EXTRACTION_GRAPH = {
topics: [ topics: [
{ id: 'radicale-transparantie', label: 'Radicale Transparantie', type: 'concept', description: 'De kernwaarde van Respellion waarbij alle informatie publiek toegankelijk is.', learning_relevance: 'core' }, { id: 'radicale-transparantie', label: 'Radicale Transparantie', type: 'concept', description: 'De kernwaarde van Respellion waarbij alle informatie publiek toegankelijk is.', learning_relevance: 'core' },
{ id: 'kennisbeheer', label: 'Kennisbeheer', type: 'process', description: 'Het proces van het vastleggen en ontsluiten van organisatiekennis.', learning_relevance: 'standard' }, { id: 'kennisbeheer', label: 'Kennisbeheer', type: 'process', description: 'Het proces van het vastleggen en ontsluiten van organisatiekennis.', learning_relevance: 'standard' },
@@ -135,33 +135,87 @@ const SIMULATION_EXTRACTION_PAYLOAD = JSON.stringify({
{ source: 'kennisbeheer', target: 'radicale-transparantie', type: 'depends_on' }, { source: 'kennisbeheer', target: 'radicale-transparantie', type: 'depends_on' },
{ source: 'wekelijkse-sessie', target: 'kennisbeheer', type: 'part_of' }, { source: 'wekelijkse-sessie', target: 'kennisbeheer', type: 'part_of' },
], ],
}); };
const SIMULATION_EXTRACTION_PAYLOAD = JSON.stringify(SIMULATION_EXTRACTION_GRAPH);
const SIMULATION_CHAT_TEXT = const SIMULATION_CHAT_TEXT =
'Simulatiemodus staat aan — vraag een beheerder om Simulation Mode uit te zetten in Admin → Settings om met R42 te chatten.'; 'Simulatiemodus staat aan — vraag een beheerder om Simulation Mode uit te zetten in Admin → Settings om met R42 te chatten.';
async function simulatedResponse({ task }) { const SIMULATION_ARTICLE = {
await new Promise((r) => setTimeout(r, 400)); title: 'Voorbeeld leermodule',
if (isChatLikeTask(task)) { intro: 'Dit is een simulatie. Schakel Simulation Mode uit om echte content te genereren.',
sections: [
{ heading: 'Wat dit is', body: 'Dit is een placeholder-sectie die alleen verschijnt wanneer simulatiemodus aan staat. Hij illustreert de structuur van het artikel zonder een echte API-aanroep te doen. Dat is handig voor UI-werk.' },
],
keyTakeaways: ['Simulatiemodus levert geen echte inhoud.', 'Schakel uit voor productie.'],
};
const SIMULATION_SLIDE = {
title: 'Voorbeeldslide',
bullets: ['Eerste punt', 'Tweede punt'],
speakerNote: 'Spreker-notitie ter illustratie.',
};
const SIMULATION_INFOGRAPHIC = {
headline: 'Simulatie',
tagline: 'Vervang door echte content',
stats: [{ value: '100%', label: 'simulatie', icon: '📊' }],
steps: [{ number: 1, title: 'Schakel uit', description: 'Zet simulatiemodus uit in Admin → Settings.', icon: '🔧' }],
quote: 'Een simulatie vertelt niets nieuws.',
colorTheme: 'teal',
};
const SIMULATION_TOOL_STUBS = {
emit_knowledge_graph: SIMULATION_EXTRACTION_GRAPH,
emit_handbook_delta: SIMULATION_EXTRACTION_GRAPH,
emit_learning_article: { article: SIMULATION_ARTICLE },
emit_learning_slides: { slides: [SIMULATION_SLIDE] },
emit_learning_infographic: { infographic: SIMULATION_INFOGRAPHIC },
emit_learning_all: { article: SIMULATION_ARTICLE, slides: [SIMULATION_SLIDE], infographic: SIMULATION_INFOGRAPHIC },
emit_custom_topic: { label: 'Simulatie onderwerp', type: 'concept', description: 'Een placeholder-onderwerp gegenereerd in simulatiemodus.' },
emit_quiz_questions: {
questions: [
{
id: 'sim-q1',
question: 'Wat doet simulatiemodus?',
topicLabel: 'Simulatie',
options: ['Echte API-aanroepen', 'Stub-data tonen', 'Niets', 'Crasht de app'],
correctIndex: 1,
explanation: 'Simulatiemodus retourneert vaste stub-data zonder de API te raken.',
},
],
},
emit_graph_actions: { merges: [], deletions: [], newRelations: [], relevanceUpdates: [] },
set_intro: { intro: 'Bijgewerkte intro (simulatie).' },
};
function stubResponse({ stopReason = 'end_turn', text = '', toolUses = [] }) {
return { return {
text: SIMULATION_CHAT_TEXT, text,
toolUses: [], toolUses,
stopReason: 'end_turn', stopReason,
usage: { input_tokens: 0, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 }, usage: { input_tokens: 0, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
requestId: null, requestId: null,
model: 'simulation', model: 'simulation',
durationMs: 400, durationMs: 400,
}; };
} }
return {
text: SIMULATION_EXTRACTION_PAYLOAD, async function simulatedResponse({ task, toolChoice }) {
toolUses: [], await new Promise((r) => setTimeout(r, 400));
stopReason: 'end_turn',
usage: { input_tokens: 0, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 }, if (toolChoice?.type === 'tool' && SIMULATION_TOOL_STUBS[toolChoice.name]) {
requestId: null, return stubResponse({
model: 'simulation', stopReason: 'tool_use',
durationMs: 400, toolUses: [{ name: toolChoice.name, input: SIMULATION_TOOL_STUBS[toolChoice.name] }],
}; });
}
if (isChatLikeTask(task)) {
return stubResponse({ text: SIMULATION_CHAT_TEXT });
}
return stubResponse({ text: SIMULATION_EXTRACTION_PAYLOAD });
} }
function linkSignals(userSignal, timeoutSignal) { function linkSignals(userSignal, timeoutSignal) {
@@ -241,7 +295,7 @@ export async function callLLM(options) {
if (!task) throw new Error('callLLM requires a `task` label.'); if (!task) throw new Error('callLLM requires a `task` label.');
const useSimulation = storage.get('admin:use_simulation') === true; const useSimulation = storage.get('admin:use_simulation') === true;
if (useSimulation) return simulatedResponse({ task }); if (useSimulation) return simulatedResponse({ task, toolChoice });
const model = resolveModel(tier); const model = resolveModel(tier);
const messagesPayload = buildMessages({ messages, user }); const messagesPayload = buildMessages({ messages, user });

View File

@@ -183,6 +183,31 @@ export const proposeGraphDeltaSchema = z.object({
relations: z.array(deltaRelationSchema).max(5).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` * Registry mapping known tool names to their input schemas. `callLLM`
* consults this when the caller does not pass an explicit `toolSchemas` * consults this when the caller does not pass an explicit `toolSchemas`
@@ -199,4 +224,9 @@ export const toolSchemaRegistry = {
emit_custom_topic: customTopicSchema, emit_custom_topic: customTopicSchema,
emit_graph_actions: graphActionsSchema, emit_graph_actions: graphActionsSchema,
propose_graph_delta: proposeGraphDeltaSchema, propose_graph_delta: proposeGraphDeltaSchema,
set_intro: setIntroPatchSchema,
set_section: setSectionPatchSchema,
add_section: addSectionPatchSchema,
remove_section: removeSectionPatchSchema,
replace_takeaways: replaceTakeawaysPatchSchema,
}; };

324
src/lib/llmTools.js Normal file
View 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 (12 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,
];

View File

@@ -1,11 +1,15 @@
import { anthropicApi } from './api';
import * as db from './db'; import * as db from './db';
import { callLLM } from './llm';
import { EMIT_QUIZ_QUESTIONS_TOOL } from './llmTools';
import { getCurriculumTopic, getQuarterForWeek } from './curriculumService'; import { getCurriculumTopic, getQuarterForWeek } from './curriculumService';
const QUIZ_SYSTEM = `You are a quiz generator for Respellion, an internal IT company learning platform. const QUIZ_SYSTEM = `You are a quiz generator for Respellion, an internal IT company learning platform.
You generate multiple-choice questions to test employee knowledge on specific topics. You generate multiple-choice questions to test employee knowledge on specific topics.
Always write in clear, professional English. Always write in clear, professional English.
ALWAYS return valid JSON only — no markdown code blocks, no extra text.`;
Emit questions through the emit_quiz_questions tool. Each question has exactly four options; correctIndex is 0-based; mix difficulty roughly 4 easy / 4 medium / 2 hard.`;
const cachedSystem = (text) => [{ type: 'text', text, cache_control: { type: 'ephemeral' } }];
async function selectTestTopics(userId, weekNumber) { async function selectTestTopics(userId, weekNumber) {
const allTopics = await db.getTopics(); const allTopics = await db.getTopics();
@@ -66,45 +70,31 @@ export async function getCachedQuiz(userId, weekNumber) {
export async function forceGenerateTopicQuestions(topic, count = 10) { export async function forceGenerateTopicQuestions(topic, count = 10) {
let bank = await db.getQuizBank(topic.id); let bank = await db.getQuizBank(topic.id);
const prompt = `Generate exactly ${count} multiple-choice quiz questions based on this knowledge topic: const prompt = `Generate exactly ${count} multiple-choice quiz questions for this knowledge topic and emit them via the emit_quiz_questions tool:
Topic: ${topic.label} Topic: ${topic.label}
Type: ${topic.type} Type: ${topic.type}
Description: ${topic.description} Description: ${topic.description}
Return ONLY a JSON object with this structure: Options must be prefixed "A) ", "B) ", "C) ", "D) ". Make questions specific and practical, not trivial.`;
{
"questions": [
{
"id": "unique-id-string",
"question": "The question text",
"topicLabel": "${topic.label}",
"options": ["A) First option", "B) Second option", "C) Third option", "D) Fourth option"],
"correctIndex": 0,
"explanation": "A clear 1-2 sentence explanation of why the correct answer is correct."
}
]
}
Rules: const result = await callLLM({
- Each question must have exactly 4 options. task: 'quiz.generate',
- correctIndex is 0-based (0=A, 1=B, 2=C, 3=D). tier: 'standard',
- Mix difficulty: 4 easy, 4 medium, 2 hard. system: cachedSystem(QUIZ_SYSTEM),
- Make questions specific and practical, not trivial.`; user: prompt,
tools: [EMIT_QUIZ_QUESTIONS_TOOL],
const responseText = await anthropicApi.generateContent(QUIZ_SYSTEM, prompt); toolChoice: { type: 'tool', name: EMIT_QUIZ_QUESTIONS_TOOL.name },
let newQuestions; maxTokens: 4096,
try {
const jsonMatch = responseText.match(/\{[\s\S]*\}/);
const parsed = JSON.parse(jsonMatch ? jsonMatch[0] : responseText);
newQuestions = parsed.questions || [];
newQuestions.forEach(q => {
q.id = `${topic.id}-${Math.random().toString(36).substr(2, 9)}`;
}); });
} catch (e) {
console.error('Failed to generate questions for topic', topic.label, e); const emitted = result.toolUses[0]?.input;
throw new Error(`Could not generate questions for ${topic.label}`, { cause: e }); if (!emitted) throw new Error(`Could not generate questions for ${topic.label}`);
}
const newQuestions = (emitted.questions || []).map(q => ({
...q,
id: `${topic.id}-${Math.random().toString(36).slice(2, 11)}`,
}));
bank = [...bank, ...newQuestions]; bank = [...bank, ...newQuestions];
await db.setQuizBank(topic.id, bank); await db.setQuizBank(topic.id, bank);