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>
199 lines
8.0 KiB
JavaScript
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);
|
|
}
|