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>
169 lines
6.6 KiB
JavaScript
169 lines
6.6 KiB
JavaScript
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 EXTRACTION_SYSTEM_PROMPT = `You are an AI knowledge extractor for Respellion, an IT company built on radical transparency.
|
|
You receive a source text. Extract every distinct concept, role, and process from it and emit them through the emit_knowledge_graph tool.
|
|
|
|
CRITICAL INSTRUCTIONS FOR COMPLETENESS:
|
|
- Extract EVERY SINGLE distinct role, process, and concept described or mentioned in the source text.
|
|
- DO NOT summarise, skip, truncate, or omit any items.
|
|
- If the document contains 29 roles, the topics array must contain exactly 29 role topics.
|
|
- Completeness is paramount. Failing to extract all topics loses critical company knowledge.
|
|
- 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.
|
|
|
|
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".
|
|
|
|
Assign a learning_relevance to every topic:
|
|
- "core": fundamental company knowledge.
|
|
- "standard": normal learning topics.
|
|
- "peripheral": good to know, low priority.
|
|
- "exclude": pure operational reference (printer guides, wifi passwords) that should never be tested.
|
|
|
|
Relation types: related_to | depends_on | part_of | executed_by.
|
|
`;
|
|
|
|
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.
|
|
|
|
CRITICAL INSTRUCTIONS:
|
|
- Every process must have a role attached (the role that executes it).
|
|
- Every concept must connect to a process or role.
|
|
- Mark handbook topics with metadata.source = "github_handbook".
|
|
- Assign learning_relevance using the same scale as extraction: core | standard | peripheral | exclude.
|
|
|
|
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.)
|
|
`;
|
|
|
|
const cachedSystem = (text) => [{ type: 'text', text, cache_control: { type: 'ephemeral' } }];
|
|
|
|
export async function analyzeHandbookDelta(fileContent, filePath) {
|
|
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,
|
|
});
|
|
|
|
const raw = result.toolUses[0]?.input;
|
|
if (!raw) throw new Error('Handbook extraction did not emit a tool result.');
|
|
const extractedData = normalizeHandbookResult(raw);
|
|
|
|
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) {
|
|
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 result = await callLLM({
|
|
task: 'extract.source',
|
|
tier: 'standard',
|
|
system: cachedSystem(EXTRACTION_SYSTEM_PROMPT),
|
|
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,
|
|
});
|
|
|
|
const extractedData = result.toolUses[0]?.input;
|
|
if (!extractedData) throw new Error(`Extraction did not emit a tool result for chunk ${i + 1}.`);
|
|
|
|
if (extractedData.topics && Array.isArray(extractedData.topics)) {
|
|
allExtractedTopics.push(...extractedData.topics);
|
|
}
|
|
if (extractedData.relations && Array.isArray(extractedData.relations)) {
|
|
allExtractedRelations.push(...extractedData.relations);
|
|
}
|
|
}
|
|
|
|
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)) {
|
|
const existing = topicsMap.get(t.id);
|
|
topicsMap.set(t.id, {
|
|
...existing,
|
|
...t,
|
|
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);
|
|
}
|