Files
learning-platform/src/lib/extractionPipeline.js
RaymondVerhoef 4a8dbee7df 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>
2026-05-20 13:50:09 +02:00

199 lines
8.0 KiB
JavaScript

import { anthropicApi } from './api';
import * as db from './db';
const 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.
Facts should be integrated into the descriptions of the other labels and NOT be extracted as unique topics.
CRITICAL INSTRUCTIONS FOR COMPLETENESS:
- You must extract EVERY SINGLE distinct role, process, and concept described or mentioned in the source text.
- DO NOT summarize, skip, truncate, or omit any items.
- If the document contains 29 roles, your JSON 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.
- Keep descriptions concise (max 3 sentences) to ensure you have enough output tokens to list everything.
You MUST assign a learning_relevance to each topic:
- "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:
{
"topics": [
{
"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'.",
"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.
Your task is to identify changes and extract structural knowledge.
CRITICAL INSTRUCTION:
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:
- "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.
Return a JSON object:
{
"topics": [
{ "id": "...", "label": "...", "type": "role | process | concept", "description": "...", "learning_relevance": "standard", "metadata": { "source": "github_handbook" } }
],
"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) {
const responseText = await anthropicApi.generateContent(HANDBOOK_SYSTEM_PROMPT, `Analyze the following handbook file update (${filePath}):\n\n${fileContent}`);
let extractedData;
try {
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 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);
return { success: true, data: extractedData };
}
function chunkText(text, maxChunkSize = 4000) {
const paragraphs = text.split(/\n+/);
const chunks = [];
let currentChunk = '';
for (const para of paragraphs) {
if ((currentChunk + '\n' + para).length > maxChunkSize) {
if (currentChunk) chunks.push(currentChunk.trim());
currentChunk = para;
} else {
currentChunk = currentChunk ? currentChunk + '\n' + para : para;
}
}
if (currentChunk) chunks.push(currentChunk.trim());
return chunks;
}
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 alreadyDone = existing.find(
s => s.name === sourceName && s.status === 'completed'
);
if (alreadyDone) {
throw new Error(`"${sourceName}" has already been processed. Delete the existing source first if you want to re-analyse it.`);
}
const rec = await db.addSource({ name: sourceName, status: 'processing' });
const sourceId = rec.id;
try {
const chunks = chunkText(textContent, 4000);
console.log(`[Pipeline] Split "${sourceName}" into ${chunks.length} chunks for processing.`);
let allExtractedTopics = [];
let allExtractedRelations = [];
for (let i = 0; i < chunks.length; i++) {
if (i > 0) {
console.log(`[Pipeline] Pacing delay (12s) to prevent rate limits before chunk ${i + 1}/${chunks.length}...`);
await new Promise(r => setTimeout(r, 12000));
}
console.log(`[Pipeline] Processing chunk ${i + 1}/${chunks.length} (${chunks[i].length} chars)...`);
const responseText = await anthropicApi.generateContent(
SYSTEM_PROMPT,
`Analyze this part of the document (${i + 1}/${chunks.length}):\n\n${chunks[i]}`
);
console.log(`[Pipeline] Raw AI response for chunk ${i + 1}:`, responseText);
let extractedData;
try {
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)) {
allExtractedTopics.push(...extractedData.topics);
}
if (extractedData.relations && Array.isArray(extractedData.relations)) {
allExtractedRelations.push(...extractedData.relations);
}
}
// Merge everything together
await mergeKnowledgeGraph({ topics: allExtractedTopics, relations: allExtractedRelations });
await db.updateSourceStatus(sourceId, 'completed');
return { success: true, data: { topics: allExtractedTopics, relations: allExtractedRelations } };
} catch (error) {
await db.updateSourceStatus(sourceId, 'failed', error.message);
throw error;
}
}
async function mergeKnowledgeGraph(newData) {
const existingTopics = await db.getTopics();
const existingRelations = await db.getRelations();
const topicsMap = new Map(existingTopics.map(t => [t.id, t]));
if (newData.topics && Array.isArray(newData.topics)) {
for (const t of newData.topics) {
if (topicsMap.has(t.id)) {
// Upsert: merge new data into existing topic
const existing = topicsMap.get(t.id);
topicsMap.set(t.id, {
...existing,
...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
});
} else {
topicsMap.set(t.id, t);
}
}
}
const newRelations = [...existingRelations];
if (newData.relations && Array.isArray(newData.relations)) {
for (const r of newData.relations) {
const isDup = newRelations.some(ex => ex.source === r.source && ex.target === r.target && ex.type === r.type);
if (!isDup) {
newRelations.push(r);
}
}
}
await db.saveTopics(Array.from(topicsMap.values()));
await db.saveRelations(newRelations);
}