Compare commits
11 Commits
feat/ai-pi
...
feat/ai-pi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
66e0c275da | ||
|
|
c82e4fc3a1 | ||
| fd3b849c19 | |||
|
|
aeb197d5f4 | ||
| 9771928926 | |||
|
|
40eff976b4 | ||
| 33529dfb2b | |||
|
|
f838755991 | ||
|
|
8a8745fad2 | ||
| a5c18ccd0f | |||
| d07d15b2a7 |
@@ -14,7 +14,11 @@ export default defineConfig([
|
|||||||
reactRefresh.configs.vite,
|
reactRefresh.configs.vite,
|
||||||
],
|
],
|
||||||
languageOptions: {
|
languageOptions: {
|
||||||
globals: globals.browser,
|
globals: {
|
||||||
|
...globals.browser,
|
||||||
|
__BUILD_SHA__: 'readonly',
|
||||||
|
__BUILD_TIME__: 'readonly',
|
||||||
|
},
|
||||||
parserOptions: { ecmaFeatures: { jsx: true } },
|
parserOptions: { ecmaFeatures: { jsx: true } },
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
|
|||||||
23
pb_migrations/1780500000_updated_topics_relevance_locked.js
Normal file
23
pb_migrations/1780500000_updated_topics_relevance_locked.js
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_2800040823")
|
||||||
|
|
||||||
|
// add field — relevance_locked is set to true whenever an admin edits
|
||||||
|
// learning_relevance via the UI; mergeKnowledgeGraph must never overwrite
|
||||||
|
// learning_relevance on a locked topic during re-extraction.
|
||||||
|
collection.fields.addAt(5, new Field({
|
||||||
|
"hidden": false,
|
||||||
|
"id": "bool_relevance_locked",
|
||||||
|
"name": "relevance_locked",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "bool"
|
||||||
|
}))
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
}, (app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_2800040823")
|
||||||
|
collection.fields.removeById("bool_relevance_locked")
|
||||||
|
return app.save(collection)
|
||||||
|
})
|
||||||
43
pb_migrations/1780500001_normalize_relation_types.js
Normal file
43
pb_migrations/1780500001_normalize_relation_types.js
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
// One-shot data migration: rewrite legacy "executes" relations to the
|
||||||
|
// canonical "executed_by" vocabulary by swapping source and target.
|
||||||
|
// Previously `role --executes--> process`; canonical is
|
||||||
|
// `process --executed_by--> role`.
|
||||||
|
migrate((app) => {
|
||||||
|
const records = app.findRecordsByFilter(
|
||||||
|
"pbc_1883724256", // relations collection
|
||||||
|
'type = "executes"',
|
||||||
|
'',
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
|
||||||
|
for (const rec of records) {
|
||||||
|
const source = rec.get("source")
|
||||||
|
const target = rec.get("target")
|
||||||
|
rec.set("type", "executed_by")
|
||||||
|
rec.set("source", target)
|
||||||
|
rec.set("target", source)
|
||||||
|
app.save(rec)
|
||||||
|
}
|
||||||
|
}, (app) => {
|
||||||
|
// Reverse: turn executed_by back into executes (best-effort — only those
|
||||||
|
// created before this migration would have been "executes"; rolling back
|
||||||
|
// will affect any newer executed_by rows too).
|
||||||
|
const records = app.findRecordsByFilter(
|
||||||
|
"pbc_1883724256",
|
||||||
|
'type = "executed_by"',
|
||||||
|
'',
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
|
||||||
|
for (const rec of records) {
|
||||||
|
const source = rec.get("source")
|
||||||
|
const target = rec.get("target")
|
||||||
|
rec.set("type", "executes")
|
||||||
|
rec.set("source", target)
|
||||||
|
rec.set("target", source)
|
||||||
|
app.save(rec)
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -2,6 +2,7 @@ import { Routes, Route, Navigate, Link } from 'react-router-dom'
|
|||||||
import { BookOpen, CheckSquare, LayoutDashboard, Trophy, Settings, LogOut } from 'lucide-react'
|
import { BookOpen, CheckSquare, LayoutDashboard, Trophy, Settings, LogOut } from 'lucide-react'
|
||||||
import { useApp } from './store/AppContext'
|
import { useApp } from './store/AppContext'
|
||||||
import Mark from './components/ui/Mark'
|
import Mark from './components/ui/Mark'
|
||||||
|
import BuildStamp from './components/ui/BuildStamp'
|
||||||
import ChatLauncher from './components/chat/ChatLauncher'
|
import ChatLauncher from './components/chat/ChatLauncher'
|
||||||
|
|
||||||
import Login from './pages/Login'
|
import Login from './pages/Login'
|
||||||
@@ -88,6 +89,7 @@ function App() {
|
|||||||
if (state.isLoading) return <div className="p-8 flex items-center justify-center min-h-screen">Loading application...</div>
|
if (state.isLoading) return <div className="p-8 flex items-center justify-center min-h-screen">Loading application...</div>
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={<Login />} />
|
<Route path="/login" element={<Login />} />
|
||||||
<Route path="/" element={<ProtectedRoute><Dashboard /></ProtectedRoute>} />
|
<Route path="/" element={<ProtectedRoute><Dashboard /></ProtectedRoute>} />
|
||||||
@@ -96,6 +98,8 @@ function App() {
|
|||||||
<Route path="/leaderboard" element={<ProtectedRoute><Leaderboard /></ProtectedRoute>} />
|
<Route path="/leaderboard" element={<ProtectedRoute><Leaderboard /></ProtectedRoute>} />
|
||||||
<Route path="/admin/*" element={<ProtectedRoute requireAdmin={true}><Admin /></ProtectedRoute>} />
|
<Route path="/admin/*" element={<ProtectedRoute requireAdmin={true}><Admin /></ProtectedRoute>} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
<BuildStamp />
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -179,10 +180,17 @@ const KnowledgeGraph = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const saveEdit = async () => {
|
const saveEdit = async () => {
|
||||||
await db.upsertTopic({ ...selectedNode, ...editData });
|
// If the admin touched learning_relevance, lock it so re-extraction won't overwrite the choice.
|
||||||
const updated = topics.map(t => t.id === selectedNode.id ? { ...t, ...editData } : t);
|
// But an explicit relevance_locked in editData (the unlock checkbox) always wins.
|
||||||
|
const relevanceChanged =
|
||||||
|
editData.learning_relevance !== undefined &&
|
||||||
|
editData.learning_relevance !== selectedNode.learning_relevance;
|
||||||
|
const next = { ...editData };
|
||||||
|
if (relevanceChanged && next.relevance_locked === undefined) next.relevance_locked = true;
|
||||||
|
await db.upsertTopic({ ...selectedNode, ...next });
|
||||||
|
const updated = topics.map(t => t.id === selectedNode.id ? { ...t, ...next } : t);
|
||||||
setTopics(updated);
|
setTopics(updated);
|
||||||
setSelectedNode({ ...selectedNode, ...editData });
|
setSelectedNode({ ...selectedNode, ...next });
|
||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -264,22 +272,11 @@ const KnowledgeGraph = () => {
|
|||||||
setSyncProgress(`Processing ${count} of ${filesToProcess.length}: ${file.name}...`);
|
setSyncProgress(`Processing ${count} of ${filesToProcess.length}: ${file.name}...`);
|
||||||
try {
|
try {
|
||||||
const rawContent = await getFileContent('respellion', 'employee-handbook', file.path);
|
const rawContent = await getFileContent('respellion', 'employee-handbook', file.path);
|
||||||
|
// Pacing is handled centrally by extractionLimiter inside analyzeHandbookDelta.
|
||||||
await analyzeHandbookDelta(rawContent, file.path);
|
await analyzeHandbookDelta(rawContent, file.path);
|
||||||
await db.updateHandbookSyncState(file.path, file.sha);
|
await db.updateHandbookSyncState(file.path, file.sha);
|
||||||
|
|
||||||
// To respect Anthropic's 5 requests per minute rate limit on this tier,
|
|
||||||
// we pause for 15 seconds before processing the next file.
|
|
||||||
if (count < filesToProcess.length) {
|
|
||||||
setSyncProgress(`Waiting 15s to avoid rate limits... (${count}/${filesToProcess.length})`);
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 15000));
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to process file:', file.path, err);
|
console.error('Failed to process file:', file.path, err);
|
||||||
// We continue processing other files even if one fails, but still wait to avoid further rate limits
|
|
||||||
if (count < filesToProcess.length) {
|
|
||||||
setSyncProgress(`Error on ${file.name}. Waiting 15s... (${count}/${filesToProcess.length})`);
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 15000));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setSyncProgress('Sync Complete! Click "Analyze & Optimize Graph" above to clean up and merge.');
|
setSyncProgress('Sync Complete! Click "Analyze & Optimize Graph" above to clean up and merge.');
|
||||||
@@ -304,18 +301,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 +321,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];
|
||||||
@@ -369,7 +365,7 @@ Analyze this graph and return ONLY the optimized JSON object with this EXACT str
|
|||||||
if (actions.relevanceUpdates && Array.isArray(actions.relevanceUpdates)) {
|
if (actions.relevanceUpdates && Array.isArray(actions.relevanceUpdates)) {
|
||||||
for (const update of actions.relevanceUpdates) {
|
for (const update of actions.relevanceUpdates) {
|
||||||
const topicIndex = updatedTopics.findIndex(t => t.id === update.id);
|
const topicIndex = updatedTopics.findIndex(t => t.id === update.id);
|
||||||
if (topicIndex !== -1) {
|
if (topicIndex !== -1 && !updatedTopics[topicIndex].relevance_locked) {
|
||||||
updatedTopics[topicIndex] = { ...updatedTopics[topicIndex], learning_relevance: update.learning_relevance };
|
updatedTopics[topicIndex] = { ...updatedTopics[topicIndex], learning_relevance: update.learning_relevance };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -532,6 +528,17 @@ Analyze this graph and return ONLY the optimized JSON object with this EXACT str
|
|||||||
<option value="peripheral">Peripheral</option>
|
<option value="peripheral">Peripheral</option>
|
||||||
<option value="exclude">Exclude</option>
|
<option value="exclude">Exclude</option>
|
||||||
</select>
|
</select>
|
||||||
|
{selectedNode.relevance_locked && (
|
||||||
|
<label className="flex items-center gap-2 text-xs text-fg-muted mt-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={editData.relevance_locked !== false}
|
||||||
|
onChange={e => setEditData({...editData, relevance_locked: e.target.checked})}
|
||||||
|
className="rounded bg-bg-warm border-transparent focus:ring-0 text-teal"
|
||||||
|
/>
|
||||||
|
Locked — re-extraction will not change this
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-fg-muted uppercase tracking-wider mb-1 block">Description</label>
|
<label className="text-xs text-fg-muted uppercase tracking-wider mb-1 block">Description</label>
|
||||||
@@ -554,9 +561,12 @@ Analyze this graph and return ONLY the optimized JSON object with this EXACT str
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-fg-muted uppercase tracking-wider mb-1">Type & Relevance</p>
|
<p className="text-xs text-fg-muted uppercase tracking-wider mb-1">Type & Relevance</p>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2 flex-wrap">
|
||||||
<span className="inline-block px-2 py-1 bg-bg-warm rounded-[var(--r-pill)] text-xs font-mono">{selectedNode.type}</span>
|
<span className="inline-block px-2 py-1 bg-bg-warm rounded-[var(--r-pill)] text-xs font-mono">{selectedNode.type}</span>
|
||||||
<span className="inline-block px-2 py-1 bg-bg-warm rounded-[var(--r-pill)] text-xs font-mono opacity-80">{selectedNode.learning_relevance || 'standard'}</span>
|
<span className="inline-block px-2 py-1 bg-bg-warm rounded-[var(--r-pill)] text-xs font-mono opacity-80">{selectedNode.learning_relevance || 'standard'}</span>
|
||||||
|
{selectedNode.relevance_locked && (
|
||||||
|
<span className="inline-block px-2 py-1 bg-teal/10 text-teal rounded-[var(--r-pill)] text-xs font-mono" title="Re-extraction will not change relevance for this topic.">locked</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ const TestManager = () => {
|
|||||||
loadData();
|
loadData();
|
||||||
}, [selectedTopic]);
|
}, [selectedTopic]);
|
||||||
|
|
||||||
const handleGenerate = async (topic, count = 10) => {
|
const handleGenerate = async (topic, count = 5) => {
|
||||||
setLoadingTopicId(topic.id);
|
setLoadingTopicId(topic.id);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
@@ -97,8 +97,8 @@ const TestManager = () => {
|
|||||||
{questions.length === 0 ? (
|
{questions.length === 0 ? (
|
||||||
<Card className="text-center py-12 text-fg-muted border-dashed border-2">
|
<Card className="text-center py-12 text-fg-muted border-dashed border-2">
|
||||||
<p>No questions generated for this topic yet.</p>
|
<p>No questions generated for this topic yet.</p>
|
||||||
<Button className="mt-4" onClick={() => handleGenerate(selectedTopic, 10)} disabled={loadingTopicId === selectedTopic.id}>
|
<Button className="mt-4" onClick={() => handleGenerate(selectedTopic, 5)} disabled={loadingTopicId === selectedTopic.id}>
|
||||||
Generate 10 Questions
|
Generate 5 Questions
|
||||||
</Button>
|
</Button>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useRef } from 'react';
|
import { useState, useRef } from 'react';
|
||||||
import { UploadCloud, AlertCircle, CheckCircle } from 'lucide-react';
|
import { UploadCloud, AlertCircle, CheckCircle, X } from 'lucide-react';
|
||||||
import { processSourceText } from '../../lib/extractionPipeline';
|
import { processSourceText } from '../../lib/extractionPipeline';
|
||||||
import Card from '../ui/Card';
|
import Card from '../ui/Card';
|
||||||
import Button from '../ui/Button';
|
import Button from '../ui/Button';
|
||||||
@@ -12,24 +12,36 @@ const UploadZone = ({ onUploadComplete }) => {
|
|||||||
const [status, setStatus] = useState(null);
|
const [status, setStatus] = useState(null);
|
||||||
|
|
||||||
const fileInputRef = useRef(null);
|
const fileInputRef = useRef(null);
|
||||||
|
const abortRef = useRef(null);
|
||||||
|
|
||||||
// ── File upload (drag & drop / browse) ────────────────────────────────────
|
// ── File upload (drag & drop / browse) ────────────────────────────────────
|
||||||
|
|
||||||
const processFile = async (file) => {
|
const processFile = async (file) => {
|
||||||
setIsProcessing(true);
|
setIsProcessing(true);
|
||||||
setStatus(null);
|
setStatus(null);
|
||||||
|
const controller = new AbortController();
|
||||||
|
abortRef.current = controller;
|
||||||
try {
|
try {
|
||||||
const text = await file.text();
|
const text = await file.text();
|
||||||
await processSourceText(text, file.name);
|
await processSourceText(text, file.name, { signal: controller.signal });
|
||||||
setStatus({ type: 'success', msg: `Successfully processed: ${file.name}` });
|
setStatus({ type: 'success', msg: `Successfully processed: ${file.name}` });
|
||||||
if (onUploadComplete) onUploadComplete();
|
if (onUploadComplete) onUploadComplete();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error?.name === 'AbortError') {
|
||||||
|
setStatus({ type: 'error', msg: `Cancelled: ${file.name}` });
|
||||||
|
} else {
|
||||||
setStatus({ type: 'error', msg: `Error processing file: ${error.message}` });
|
setStatus({ type: 'error', msg: `Error processing file: ${error.message}` });
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
abortRef.current = null;
|
||||||
setIsProcessing(false);
|
setIsProcessing(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const cancelProcessing = () => {
|
||||||
|
abortRef.current?.abort(new DOMException('Cancelled by user', 'AbortError'));
|
||||||
|
};
|
||||||
|
|
||||||
const handleDragOver = (e) => { e.preventDefault(); setIsDragging(true); };
|
const handleDragOver = (e) => { e.preventDefault(); setIsDragging(true); };
|
||||||
const handleDragLeave = () => setIsDragging(false);
|
const handleDragLeave = () => setIsDragging(false);
|
||||||
|
|
||||||
@@ -80,6 +92,19 @@ const UploadZone = ({ onUploadComplete }) => {
|
|||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Cancel sits outside the upload card so pointer-events-none doesn't disable it. */}
|
||||||
|
{isProcessing && (
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={cancelProcessing}
|
||||||
|
className="flex items-center gap-1 text-sm text-red-600 hover:text-red-700 underline"
|
||||||
|
>
|
||||||
|
<X size={14} /> Cancel extraction
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* ─── Status messages ─── */}
|
{/* ─── Status messages ─── */}
|
||||||
{status && (
|
{status && (
|
||||||
<div className={`p-4 rounded-[var(--r-sm)] flex items-start gap-3 ${
|
<div className={`p-4 rounded-[var(--r-sm)] flex items-start gap-3 ${
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
19
src/components/ui/BuildStamp.jsx
Normal file
19
src/components/ui/BuildStamp.jsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
const sha = typeof __BUILD_SHA__ === 'string' ? __BUILD_SHA__ : 'dev';
|
||||||
|
const time = typeof __BUILD_TIME__ === 'string' ? __BUILD_TIME__ : new Date().toISOString();
|
||||||
|
|
||||||
|
const formatted = (() => {
|
||||||
|
const d = new Date(time);
|
||||||
|
if (Number.isNaN(d.getTime())) return time;
|
||||||
|
return d.toLocaleString('nl-NL', { dateStyle: 'short', timeStyle: 'short' });
|
||||||
|
})();
|
||||||
|
|
||||||
|
const BuildStamp = () => (
|
||||||
|
<div
|
||||||
|
className="hidden md:block fixed bottom-1 right-2 text-[10px] font-mono text-fg-muted/60 z-40 pointer-events-none select-none"
|
||||||
|
title={`Build ${sha} at ${time}`}
|
||||||
|
>
|
||||||
|
v{sha} · {formatted}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default BuildStamp;
|
||||||
104
src/lib/__tests__/articlePatches.test.js
Normal file
104
src/lib/__tests__/articlePatches.test.js
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
89
src/lib/__tests__/extractionPipeline.test.js
Normal file
89
src/lib/__tests__/extractionPipeline.test.js
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('../pb', () => ({
|
||||||
|
pb: { collection: () => ({ create: () => ({ catch: () => {} }) }) },
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { chunkText, buildKnownIdsHint, MAX_CHUNK_CHARS, OVERLAP_CHARS } from '../extractionPipeline';
|
||||||
|
|
||||||
|
describe('chunkText', () => {
|
||||||
|
let warnSpy;
|
||||||
|
beforeEach(() => { warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); });
|
||||||
|
afterEach(() => { warnSpy.mockRestore(); });
|
||||||
|
|
||||||
|
it('returns the original text as a single chunk when below maxChars', () => {
|
||||||
|
const result = chunkText('A short paragraph.');
|
||||||
|
expect(result).toEqual(['A short paragraph.']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty array for empty/whitespace input', () => {
|
||||||
|
expect(chunkText('')).toEqual([]);
|
||||||
|
expect(chunkText(' \n ')).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('splits along sentence boundaries with overlap between adjacent chunks', () => {
|
||||||
|
const sentence = 'This is a sentence with exactly a known length. ';
|
||||||
|
const text = sentence.repeat(100); // ~5000 chars
|
||||||
|
const chunks = chunkText(text, { maxChars: 600, overlapChars: 150 });
|
||||||
|
|
||||||
|
expect(chunks.length).toBeGreaterThan(1);
|
||||||
|
for (const c of chunks) {
|
||||||
|
expect(c.length).toBeLessThanOrEqual(600);
|
||||||
|
}
|
||||||
|
// Adjacent chunks share trailing text — the overlap should be non-empty.
|
||||||
|
for (let i = 1; i < chunks.length; i++) {
|
||||||
|
const tail = chunks[i - 1].slice(-150);
|
||||||
|
// The new chunk must begin with content that appears at the tail of the prior chunk.
|
||||||
|
const firstHundred = chunks[i].slice(0, 100);
|
||||||
|
// At least one word from the tail should appear in the head of the next chunk.
|
||||||
|
const words = tail.split(/\s+/).filter((w) => w.length > 3);
|
||||||
|
const shared = words.some((w) => firstHundred.includes(w));
|
||||||
|
expect(shared).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hard-splits a single sentence that exceeds maxChars and logs a warning', () => {
|
||||||
|
const huge = 'word '.repeat(400).trim() + '.'; // ~2000 chars, no sentence break
|
||||||
|
const chunks = chunkText(huge, { maxChars: 500, overlapChars: 50 });
|
||||||
|
expect(chunks.length).toBeGreaterThan(1);
|
||||||
|
expect(warnSpy).toHaveBeenCalled();
|
||||||
|
for (const c of chunks) expect(c.length).toBeLessThanOrEqual(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles paragraph-only splits when no sentence punctuation is present', () => {
|
||||||
|
const paragraphs = Array.from({ length: 10 }, (_, i) => `paragraph ${i} content here`).join('\n\n');
|
||||||
|
const chunks = chunkText(paragraphs, { maxChars: 80, overlapChars: 20 });
|
||||||
|
expect(chunks.length).toBeGreaterThan(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the documented defaults', () => {
|
||||||
|
expect(MAX_CHUNK_CHARS).toBe(8000);
|
||||||
|
expect(OVERLAP_CHARS).toBe(800);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildKnownIdsHint', () => {
|
||||||
|
it('returns empty string when no IDs are known', () => {
|
||||||
|
expect(buildKnownIdsHint([])).toBe('');
|
||||||
|
expect(buildKnownIdsHint(undefined)).toBe('');
|
||||||
|
expect(buildKnownIdsHint(null)).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formats the known IDs as a bulleted list with a leading instruction', () => {
|
||||||
|
const hint = buildKnownIdsHint(['software-engineer', 'onboarding-buddy']);
|
||||||
|
expect(hint).toContain('Already-extracted topic IDs');
|
||||||
|
expect(hint).toContain('- software-engineer');
|
||||||
|
expect(hint).toContain('- onboarding-buddy');
|
||||||
|
expect(hint.endsWith('\n')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('caps the hint at the 200 most recent IDs', () => {
|
||||||
|
const ids = Array.from({ length: 250 }, (_, i) => `topic-${i}`);
|
||||||
|
const hint = buildKnownIdsHint(ids);
|
||||||
|
// The newest IDs must appear; the oldest must not.
|
||||||
|
expect(hint).toContain('topic-249');
|
||||||
|
expect(hint).toContain('topic-50');
|
||||||
|
expect(hint).not.toContain('topic-49');
|
||||||
|
expect(hint).not.toContain('topic-0\n');
|
||||||
|
});
|
||||||
|
});
|
||||||
72
src/lib/__tests__/llmRetry.test.js
Normal file
72
src/lib/__tests__/llmRetry.test.js
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { describe, expect, it, vi, afterEach } from 'vitest';
|
||||||
|
import { createLimiter, extractionLimiter } from '../llmRetry';
|
||||||
|
|
||||||
|
afterEach(() => { vi.useRealTimers(); });
|
||||||
|
|
||||||
|
describe('createLimiter', () => {
|
||||||
|
it('rejects an invalid rps', () => {
|
||||||
|
expect(() => createLimiter({ rps: 0 })).toThrow();
|
||||||
|
expect(() => createLimiter({ rps: -1 })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an invalid burst', () => {
|
||||||
|
expect(() => createLimiter({ rps: 1, burst: 0 })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets the first call through immediately (initial burst token)', async () => {
|
||||||
|
const limiter = createLimiter({ rps: 1, burst: 1 });
|
||||||
|
const start = Date.now();
|
||||||
|
await limiter.acquire();
|
||||||
|
expect(Date.now() - start).toBeLessThan(50);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('queues subsequent calls to respect the spacing', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const limiter = createLimiter({ rps: 10, burst: 1 }); // 100ms spacing
|
||||||
|
await limiter.acquire(); // consume initial token
|
||||||
|
|
||||||
|
let resolved = false;
|
||||||
|
const p = limiter.acquire().then(() => { resolved = true; });
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(50);
|
||||||
|
expect(resolved).toBe(false);
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(100);
|
||||||
|
await p;
|
||||||
|
expect(resolved).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('honours pauseUntil — no acquire returns before the pause expires', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const limiter = createLimiter({ rps: 100, burst: 5 });
|
||||||
|
limiter.pauseUntil(Date.now() + 1000);
|
||||||
|
|
||||||
|
let resolved = false;
|
||||||
|
const p = limiter.acquire().then(() => { resolved = true; });
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(500);
|
||||||
|
expect(resolved).toBe(false);
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(600);
|
||||||
|
await p;
|
||||||
|
expect(resolved).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('aborts a queued acquire when the signal fires', async () => {
|
||||||
|
const limiter = createLimiter({ rps: 1, burst: 1 });
|
||||||
|
await limiter.acquire(); // consume
|
||||||
|
|
||||||
|
const ctl = new AbortController();
|
||||||
|
const p = limiter.acquire({ signal: ctl.signal });
|
||||||
|
ctl.abort();
|
||||||
|
|
||||||
|
await expect(p).rejects.toBeInstanceOf(DOMException);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('extractionLimiter', () => {
|
||||||
|
it('is exported and exposes the limiter shape', () => {
|
||||||
|
expect(typeof extractionLimiter.acquire).toBe('function');
|
||||||
|
expect(typeof extractionLimiter.pauseUntil).toBe('function');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -108,7 +108,7 @@ describe('learning schemas', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('quizQuestionsSchema', () => {
|
describe('quizQuestionsSchema', () => {
|
||||||
it('accepts a quiz with four options and a valid correctIndex', () => {
|
it('accepts a quiz with four options, a valid correctIndex and a difficulty', () => {
|
||||||
const parsed = quizQuestionsSchema.parse({
|
const parsed = quizQuestionsSchema.parse({
|
||||||
questions: [
|
questions: [
|
||||||
{
|
{
|
||||||
@@ -118,10 +118,12 @@ describe('quizQuestionsSchema', () => {
|
|||||||
options: ['A', 'B', 'C', 'D'],
|
options: ['A', 'B', 'C', 'D'],
|
||||||
correctIndex: 2,
|
correctIndex: 2,
|
||||||
explanation: 'C describes the buddy system best.',
|
explanation: 'C describes the buddy system best.',
|
||||||
|
difficulty: 'easy',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
expect(parsed.questions[0].options).toHaveLength(4);
|
expect(parsed.questions[0].options).toHaveLength(4);
|
||||||
|
expect(parsed.questions[0].difficulty).toBe('easy');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects three options or an out-of-range correctIndex', () => {
|
it('rejects three options or an out-of-range correctIndex', () => {
|
||||||
@@ -135,6 +137,7 @@ describe('quizQuestionsSchema', () => {
|
|||||||
options: ['A', 'B', 'C'],
|
options: ['A', 'B', 'C'],
|
||||||
correctIndex: 0,
|
correctIndex: 0,
|
||||||
explanation: 'e',
|
explanation: 'e',
|
||||||
|
difficulty: 'medium',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
@@ -149,11 +152,27 @@ describe('quizQuestionsSchema', () => {
|
|||||||
options: ['A', 'B', 'C', 'D'],
|
options: ['A', 'B', 'C', 'D'],
|
||||||
correctIndex: 4,
|
correctIndex: 4,
|
||||||
explanation: 'e',
|
explanation: 'e',
|
||||||
|
difficulty: 'medium',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
).toThrow();
|
).toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('rejects a missing or unknown difficulty', () => {
|
||||||
|
const base = {
|
||||||
|
id: 'q',
|
||||||
|
question: 'q',
|
||||||
|
topicLabel: 't',
|
||||||
|
options: ['A', 'B', 'C', 'D'],
|
||||||
|
correctIndex: 0,
|
||||||
|
explanation: 'because',
|
||||||
|
};
|
||||||
|
expect(() => quizQuestionsSchema.parse({ questions: [base] })).toThrow();
|
||||||
|
expect(() =>
|
||||||
|
quizQuestionsSchema.parse({ questions: [{ ...base, difficulty: 'trivial' }] }),
|
||||||
|
).toThrow();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('customTopicSchema', () => {
|
describe('customTopicSchema', () => {
|
||||||
|
|||||||
44
src/lib/__tests__/llmTools.test.js
Normal file
44
src/lib/__tests__/llmTools.test.js
Normal 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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
48
src/lib/__tests__/random.test.js
Normal file
48
src/lib/__tests__/random.test.js
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { shuffle, sample, pickInt } from '../random';
|
||||||
|
|
||||||
|
describe('shuffle', () => {
|
||||||
|
it('returns a new array containing the same elements', () => {
|
||||||
|
const input = [1, 2, 3, 4, 5];
|
||||||
|
const out = shuffle(input);
|
||||||
|
expect(out).not.toBe(input);
|
||||||
|
expect([...out].sort()).toEqual([...input].sort());
|
||||||
|
expect(input).toEqual([1, 2, 3, 4, 5]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles empty and single-element arrays', () => {
|
||||||
|
expect(shuffle([])).toEqual([]);
|
||||||
|
expect(shuffle([42])).toEqual([42]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('sample', () => {
|
||||||
|
it('returns up to n unique elements from the source array', () => {
|
||||||
|
const out = sample([1, 2, 3, 4, 5], 3);
|
||||||
|
expect(out).toHaveLength(3);
|
||||||
|
expect(new Set(out).size).toBe(3);
|
||||||
|
for (const v of out) expect([1, 2, 3, 4, 5]).toContain(v);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the full shuffled array when n exceeds length', () => {
|
||||||
|
const out = sample([1, 2, 3], 10);
|
||||||
|
expect(out).toHaveLength(3);
|
||||||
|
expect([...out].sort()).toEqual([1, 2, 3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns an empty array when n is zero or negative', () => {
|
||||||
|
expect(sample([1, 2, 3], 0)).toEqual([]);
|
||||||
|
expect(sample([1, 2, 3], -2)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('pickInt', () => {
|
||||||
|
it('returns an integer in the inclusive range', () => {
|
||||||
|
for (let i = 0; i < 100; i++) {
|
||||||
|
const v = pickInt(2, 5);
|
||||||
|
expect(Number.isInteger(v)).toBe(true);
|
||||||
|
expect(v).toBeGreaterThanOrEqual(2);
|
||||||
|
expect(v).toBeLessThanOrEqual(5);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
112
src/lib/__tests__/testService.test.js
Normal file
112
src/lib/__tests__/testService.test.js
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
|
||||||
|
const bankStore = new Map();
|
||||||
|
const callLLMMock = vi.fn();
|
||||||
|
|
||||||
|
vi.mock('../pb', () => ({ pb: { collection: () => ({}) } }));
|
||||||
|
|
||||||
|
vi.mock('../db', () => ({
|
||||||
|
getQuizBank: vi.fn(async (topicId) => bankStore.get(topicId) || []),
|
||||||
|
setQuizBank: vi.fn(async (topicId, qs) => { bankStore.set(topicId, qs); }),
|
||||||
|
getTopics: vi.fn(async () => []),
|
||||||
|
deleteQuestionFromBank: vi.fn(),
|
||||||
|
getCachedQuiz: vi.fn(),
|
||||||
|
setCachedQuiz: vi.fn(),
|
||||||
|
getQuizResult: vi.fn(),
|
||||||
|
saveQuizResult: vi.fn(),
|
||||||
|
getTeamMembers: vi.fn(async () => []),
|
||||||
|
upsertLeaderboardEntry: vi.fn(),
|
||||||
|
getCurriculum: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../llm', () => ({ callLLM: (...args) => callLLMMock(...args) }));
|
||||||
|
vi.mock('../curriculumService', () => ({
|
||||||
|
getCurriculumTopic: vi.fn(async () => ({ topic: null })),
|
||||||
|
getQuarterForWeek: vi.fn(() => 1),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { forceGenerateTopicQuestions } from '../testService';
|
||||||
|
|
||||||
|
const topic = { id: 'onboarding', label: 'Onboarding', type: 'concept', description: 'Onboarding for new joiners.' };
|
||||||
|
|
||||||
|
function makeQuestion(i, overrides = {}) {
|
||||||
|
return {
|
||||||
|
id: `q-${i}`,
|
||||||
|
question: `Sample question ${i}?`,
|
||||||
|
topicLabel: 'Onboarding',
|
||||||
|
options: ['A) one', 'B) two', 'C) three', 'D) four'],
|
||||||
|
correctIndex: i % 4,
|
||||||
|
explanation: 'This is a substantive explanation for the correct answer.',
|
||||||
|
difficulty: 'medium',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function llmEmits(questions) {
|
||||||
|
callLLMMock.mockResolvedValueOnce({ toolUses: [{ name: 'emit_quiz_questions', input: { questions } }] });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('forceGenerateTopicQuestions', () => {
|
||||||
|
let debugSpy, warnSpy;
|
||||||
|
beforeEach(() => {
|
||||||
|
bankStore.clear();
|
||||||
|
callLLMMock.mockReset();
|
||||||
|
debugSpy = vi.spyOn(console, 'debug').mockImplementation(() => {});
|
||||||
|
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
|
});
|
||||||
|
afterEach(() => { debugSpy.mockRestore(); warnSpy.mockRestore(); });
|
||||||
|
|
||||||
|
it('persists a well-formed batch and assigns topic-scoped ids', async () => {
|
||||||
|
llmEmits([0, 1, 2, 3, 4].map((i) => makeQuestion(i)));
|
||||||
|
const out = await forceGenerateTopicQuestions(topic, 5);
|
||||||
|
expect(out).toHaveLength(5);
|
||||||
|
for (const q of out) expect(q.id.startsWith('onboarding-')).toBe(true);
|
||||||
|
expect(bankStore.get('onboarding')).toHaveLength(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('re-rolls when one correctIndex dominates the batch, then accepts on the third try', async () => {
|
||||||
|
const allZero = [0, 1, 2, 3, 4].map((i) => makeQuestion(i, { correctIndex: 0 }));
|
||||||
|
llmEmits(allZero);
|
||||||
|
llmEmits(allZero);
|
||||||
|
llmEmits(allZero);
|
||||||
|
const out = await forceGenerateTopicQuestions(topic, 5);
|
||||||
|
expect(callLLMMock).toHaveBeenCalledTimes(3);
|
||||||
|
expect(out).toHaveLength(5);
|
||||||
|
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('correctIndex dominated'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a batch containing a banned "all of the above" option', async () => {
|
||||||
|
const bad = [0, 1, 2, 3, 4].map((i) =>
|
||||||
|
makeQuestion(i, { options: ['A) x', 'B) y', 'C) z', 'D) All of the above'] }),
|
||||||
|
);
|
||||||
|
llmEmits(bad);
|
||||||
|
llmEmits(bad);
|
||||||
|
llmEmits(bad);
|
||||||
|
await expect(forceGenerateTopicQuestions(topic, 5)).rejects.toThrow(/banned filler|rejected/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a batch where an explanation is too short', async () => {
|
||||||
|
const bad = [0, 1, 2, 3, 4].map((i) => makeQuestion(i, { explanation: 'Because.' }));
|
||||||
|
llmEmits(bad);
|
||||||
|
llmEmits(bad);
|
||||||
|
llmEmits(bad);
|
||||||
|
await expect(forceGenerateTopicQuestions(topic, 5)).rejects.toThrow(/too short|rejected/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops duplicates whose normalized text matches an existing bank entry', async () => {
|
||||||
|
bankStore.set('onboarding', [
|
||||||
|
{ ...makeQuestion(99), id: 'old-1', question: 'What is the BUDDY system???' },
|
||||||
|
]);
|
||||||
|
llmEmits([
|
||||||
|
makeQuestion(0, { question: 'what is the buddy system!' }),
|
||||||
|
makeQuestion(1, { question: 'Brand new question one?' }),
|
||||||
|
makeQuestion(2, { question: 'Brand new question two?' }),
|
||||||
|
makeQuestion(3, { question: 'Brand new question three?' }),
|
||||||
|
makeQuestion(4, { question: 'Brand new question four?' }),
|
||||||
|
]);
|
||||||
|
const out = await forceGenerateTopicQuestions(topic, 5);
|
||||||
|
expect(out).toHaveLength(4);
|
||||||
|
expect(out.find((q) => q.question.toLowerCase().includes('buddy'))).toBeUndefined();
|
||||||
|
expect(debugSpy).toHaveBeenCalledWith(expect.stringContaining('dropped duplicate'), expect.any(String));
|
||||||
|
});
|
||||||
|
});
|
||||||
80
src/lib/articlePatches.js
Normal file
80
src/lib/articlePatches.js
Normal 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;
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ export async function saveTopics(topics) {
|
|||||||
type: t.type,
|
type: t.type,
|
||||||
description: t.description,
|
description: t.description,
|
||||||
learning_relevance: t.learning_relevance || 'standard',
|
learning_relevance: t.learning_relevance || 'standard',
|
||||||
|
relevance_locked: t.relevance_locked === true,
|
||||||
}, { requestKey: null });
|
}, { requestKey: null });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -89,10 +90,15 @@ export async function deleteContent(topicId) {
|
|||||||
|
|
||||||
// ── Quiz Banks ───────────────────────────────────────────────────────────────
|
// ── Quiz Banks ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function normalizeQuizQuestion(q) {
|
||||||
|
if (!q || typeof q !== 'object') return q;
|
||||||
|
return q.difficulty ? q : { ...q, difficulty: 'medium' };
|
||||||
|
}
|
||||||
|
|
||||||
export async function getQuizBank(topicId) {
|
export async function getQuizBank(topicId) {
|
||||||
try {
|
try {
|
||||||
const r = await pb.collection('quiz_banks').getFirstListItem(`topic_id="${topicId}"`);
|
const r = await pb.collection('quiz_banks').getFirstListItem(`topic_id="${topicId}"`);
|
||||||
return r.questions || [];
|
return (r.questions || []).map(normalizeQuizQuestion);
|
||||||
} catch { return []; }
|
} catch { return []; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,104 +1,157 @@
|
|||||||
import { anthropicApi } from './api';
|
|
||||||
import * as db from './db';
|
import * as db from './db';
|
||||||
|
import { callLLM } from './llm';
|
||||||
|
import { extractionLimiter } from './llmRetry';
|
||||||
|
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 MAX_KNOWN_IDS_HINT = 200;
|
||||||
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.
|
/**
|
||||||
|
* Build the "already-extracted topic IDs" hint that prepends every chunk
|
||||||
|
* after the first. Capped at the most-recent `MAX_KNOWN_IDS_HINT` IDs so
|
||||||
|
* the prompt stays a bounded size; the model uses this list to reuse IDs
|
||||||
|
* rather than invent variants like `software-developer` for
|
||||||
|
* `software-engineer`.
|
||||||
|
*/
|
||||||
|
export function buildKnownIdsHint(ids) {
|
||||||
|
if (!ids || !ids.length) return '';
|
||||||
|
const recent = ids.slice(-MAX_KNOWN_IDS_HINT);
|
||||||
|
return [
|
||||||
|
'Already-extracted topic IDs (do NOT create new IDs for these — reuse them if the same concept appears here):',
|
||||||
|
...recent.map((id) => `- ${id}`),
|
||||||
|
'',
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
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:
|
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. Express this as: process --executed_by--> role.
|
||||||
- "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.
|
||||||
{
|
`;
|
||||||
"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 cachedSystem = (text) => [{ type: 'text', text, cache_control: { type: 'ephemeral' } }];
|
||||||
const responseText = await anthropicApi.generateContent(HANDBOOK_SYSTEM_PROMPT, `Analyze the following handbook file update (${filePath}):\n\n${fileContent}`);
|
|
||||||
|
|
||||||
let extractedData;
|
export async function analyzeHandbookDelta(fileContent, filePath, { signal } = {}) {
|
||||||
try {
|
const result = await callLLM({
|
||||||
const jsonMatch = responseText.match(/\{[\s\S]*\}/);
|
task: 'extract.handbook',
|
||||||
const jsonStr = jsonMatch ? jsonMatch[0] : responseText;
|
tier: 'standard',
|
||||||
extractedData = JSON.parse(jsonStr);
|
system: cachedSystem(HANDBOOK_SYSTEM_PROMPT),
|
||||||
} catch (e) {
|
user: `Analyze the following handbook file update (${filePath}):\n\n${fileContent}`,
|
||||||
console.error('[Pipeline] AI returned non-JSON response for handbook delta:', responseText?.substring(0, 500));
|
tools: [EMIT_HANDBOOK_DELTA_TOOL],
|
||||||
throw new Error(`AI response was not valid JSON. The model responded with: "${responseText?.substring(0, 120)}..."`, { cause: e });
|
toolChoice: { type: 'tool', name: EMIT_HANDBOOK_DELTA_TOOL.name },
|
||||||
}
|
maxTokens: 8192,
|
||||||
|
limiter: extractionLimiter,
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
await mergeKnowledgeGraph(extractedData);
|
||||||
return { success: true, data: 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) {
|
* Sentence-aware chunker with overlap.
|
||||||
if (currentChunk) chunks.push(currentChunk.trim());
|
*
|
||||||
currentChunk = para;
|
* Targets ~2000 input tokens per chunk (`MAX_CHUNK_CHARS / 4`). Splits on
|
||||||
} else {
|
* sentence boundaries first, then falls back to paragraph boundaries, and
|
||||||
currentChunk = currentChunk ? currentChunk + '\n' + para : para;
|
* hard-splits inside an oversized sentence as a last resort. Adjacent chunks
|
||||||
|
* share `overlapChars` of trailing text to preserve cross-boundary context
|
||||||
|
* for the model.
|
||||||
|
*
|
||||||
|
* Exported for unit tests; callers in this module use it directly.
|
||||||
|
*
|
||||||
|
* @param {string} text
|
||||||
|
* @param {{ maxChars?: number, overlapChars?: number }} [opts]
|
||||||
|
* @returns {string[]}
|
||||||
|
*/
|
||||||
|
export const MAX_CHUNK_CHARS = 8000;
|
||||||
|
export const OVERLAP_CHARS = 800;
|
||||||
|
|
||||||
|
export function chunkText(text, { maxChars = MAX_CHUNK_CHARS, overlapChars = OVERLAP_CHARS } = {}) {
|
||||||
|
if (typeof text !== 'string' || !text.trim()) return [];
|
||||||
|
const trimmed = text.trim();
|
||||||
|
if (trimmed.length <= maxChars) return [trimmed];
|
||||||
|
|
||||||
|
const units = splitIntoChunkableUnits(trimmed, maxChars);
|
||||||
|
if (units.length === 0) return [];
|
||||||
|
|
||||||
|
const chunks = [];
|
||||||
|
let buf = '';
|
||||||
|
let bufLen = 0; // length of new (non-overlap) content added since last flush
|
||||||
|
|
||||||
|
for (const unit of units) {
|
||||||
|
const wouldOverflow = (buf ? buf.length + 1 + unit.length : unit.length) > maxChars;
|
||||||
|
if (wouldOverflow && bufLen > 0) {
|
||||||
|
chunks.push(buf.trim());
|
||||||
|
const overlap = buf.length > overlapChars ? buf.slice(-overlapChars) : '';
|
||||||
|
buf = overlap;
|
||||||
|
bufLen = 0;
|
||||||
}
|
}
|
||||||
|
// If the overlap + unit still won't fit, drop the overlap so the unit fits cleanly.
|
||||||
|
if (buf && (buf.length + 1 + unit.length) > maxChars) {
|
||||||
|
buf = '';
|
||||||
}
|
}
|
||||||
if (currentChunk) chunks.push(currentChunk.trim());
|
buf = buf ? buf + ' ' + unit : unit;
|
||||||
|
bufLen += unit.length + (bufLen > 0 ? 1 : 0);
|
||||||
|
}
|
||||||
|
if (bufLen > 0 && buf.trim()) chunks.push(buf.trim());
|
||||||
return chunks;
|
return chunks;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function processSourceText(textContent, sourceName) {
|
function splitIntoChunkableUnits(text, maxChars) {
|
||||||
// Deduplicate: skip if a source with the same name was already successfully processed
|
const paragraphs = text.split(/\n\s*\n+/);
|
||||||
|
const units = [];
|
||||||
|
for (const para of paragraphs) {
|
||||||
|
const trimmedPara = para.trim();
|
||||||
|
if (!trimmedPara) continue;
|
||||||
|
const sentences = trimmedPara.split(/(?<=[.!?])\s+/);
|
||||||
|
for (const s of sentences) {
|
||||||
|
const sentence = s.trim();
|
||||||
|
if (!sentence) continue;
|
||||||
|
if (sentence.length <= maxChars) {
|
||||||
|
units.push(sentence);
|
||||||
|
} else {
|
||||||
|
for (let i = 0; i < sentence.length; i += maxChars) {
|
||||||
|
units.push(sentence.slice(i, i + maxChars));
|
||||||
|
}
|
||||||
|
console.warn(`[chunkText] Hard-split a sentence of ${sentence.length} chars (exceeds maxChars=${maxChars}).`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return units;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function processSourceText(textContent, sourceName, { signal } = {}) {
|
||||||
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'
|
||||||
@@ -111,51 +164,54 @@ export async function processSourceText(textContent, sourceName) {
|
|||||||
const sourceId = rec.id;
|
const sourceId = rec.id;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const chunks = chunkText(textContent, 4000);
|
const chunks = chunkText(textContent);
|
||||||
console.log(`[Pipeline] Split "${sourceName}" into ${chunks.length} chunks for processing.`);
|
console.log(`[Pipeline] Split "${sourceName}" into ${chunks.length} chunks for processing.`);
|
||||||
|
|
||||||
let allExtractedTopics = [];
|
const existingTopics = await db.getTopics();
|
||||||
let allExtractedRelations = [];
|
const knownIds = existingTopics.map((t) => t.id);
|
||||||
|
|
||||||
|
const allExtractedTopics = [];
|
||||||
|
const allExtractedRelations = [];
|
||||||
|
|
||||||
for (let i = 0; i < chunks.length; i++) {
|
for (let i = 0; i < chunks.length; i++) {
|
||||||
if (i > 0) {
|
if (signal?.aborted) throw signal.reason ?? new DOMException('Aborted', 'AbortError');
|
||||||
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)...`);
|
console.log(`[Pipeline] Processing chunk ${i + 1}/${chunks.length} (${chunks[i].length} chars)...`);
|
||||||
const responseText = await anthropicApi.generateContent(
|
const hint = i > 0 ? buildKnownIdsHint(knownIds) : '';
|
||||||
SYSTEM_PROMPT,
|
const result = await callLLM({
|
||||||
`Analyze this part of the document (${i + 1}/${chunks.length}):\n\n${chunks[i]}`
|
task: 'extract.source',
|
||||||
);
|
tier: 'standard',
|
||||||
console.log(`[Pipeline] Raw AI response for chunk ${i + 1}:`, responseText);
|
system: cachedSystem(EXTRACTION_SYSTEM_PROMPT),
|
||||||
|
user: `${hint}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,
|
||||||
|
limiter: extractionLimiter,
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
|
||||||
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 (Array.isArray(extractedData.topics)) {
|
||||||
allExtractedTopics.push(...extractedData.topics);
|
allExtractedTopics.push(...extractedData.topics);
|
||||||
|
for (const t of extractedData.topics) {
|
||||||
|
if (t?.id && !knownIds.includes(t.id)) knownIds.push(t.id);
|
||||||
}
|
}
|
||||||
if (extractedData.relations && Array.isArray(extractedData.relations)) {
|
}
|
||||||
|
if (Array.isArray(extractedData.relations)) {
|
||||||
allExtractedRelations.push(...extractedData.relations);
|
allExtractedRelations.push(...extractedData.relations);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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');
|
||||||
|
|
||||||
return { success: true, data: { topics: allExtractedTopics, relations: allExtractedRelations } };
|
return { success: true, data: { topics: allExtractedTopics, relations: allExtractedRelations } };
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await db.updateSourceStatus(sourceId, 'failed', error.message);
|
const isAbort = error?.name === 'AbortError';
|
||||||
|
await db.updateSourceStatus(sourceId, isAbort ? 'cancelled' : 'failed', isAbort ? 'cancelled by user' : error.message);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -169,14 +225,17 @@ 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, {
|
const merged = {
|
||||||
...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
|
};
|
||||||
});
|
if (existing.relevance_locked) {
|
||||||
|
merged.learning_relevance = existing.learning_relevance;
|
||||||
|
merged.relevance_locked = true;
|
||||||
|
}
|
||||||
|
topicsMap.set(t.id, merged);
|
||||||
} else {
|
} else {
|
||||||
topicsMap.set(t.id, t);
|
topicsMap.set(t.id, t);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,59 +117,85 @@ ${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) {
|
||||||
return db.deleteContent(topicId);
|
return db.deleteContent(topicId);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateCustomTopic(label) {
|
function slugify(label) {
|
||||||
const prompt = `A user wants to learn about "${label}".
|
const base = String(label || '')
|
||||||
Create a short description (2-3 sentences) and categorize it.
|
.toLowerCase()
|
||||||
|
.normalize('NFKD')
|
||||||
Return ONLY a JSON object with this structure:
|
.replace(/\p{Diacritic}/gu, '')
|
||||||
{
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
"label": "Polished topic title",
|
.replace(/^-+|-+$/g, '');
|
||||||
"type": "concept", // one of: concept, role, process
|
return base || 'topic';
|
||||||
"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 });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function pickUniqueTopicId(label) {
|
||||||
|
const existing = await db.getTopics();
|
||||||
|
const used = new Set(existing.map((t) => t.id));
|
||||||
|
const base = slugify(label);
|
||||||
|
if (!used.has(base)) return base;
|
||||||
|
for (let i = 2; i < 1000; i++) {
|
||||||
|
const candidate = `${base}-${i}`;
|
||||||
|
if (!used.has(candidate)) return candidate;
|
||||||
|
}
|
||||||
|
return `${base}-${Date.now().toString(36)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateCustomTopic(label) {
|
||||||
|
const result = await callLLM({
|
||||||
|
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 2–3 sentence description via the emit_custom_topic tool.`,
|
||||||
|
tools: [EMIT_CUSTOM_TOPIC_TOOL],
|
||||||
|
toolChoice: { type: 'tool', name: EMIT_CUSTOM_TOPIC_TOOL.name },
|
||||||
|
maxTokens: 1024,
|
||||||
|
});
|
||||||
|
|
||||||
|
const emitted = result.toolUses[0]?.input;
|
||||||
|
if (!emitted) throw new Error('Could not process custom topic. Please try again.');
|
||||||
|
|
||||||
|
const id = await pickUniqueTopicId(emitted.label);
|
||||||
|
const newTopic = {
|
||||||
|
...emitted,
|
||||||
|
id,
|
||||||
|
learning_relevance: emitted.learning_relevance || 'standard',
|
||||||
|
};
|
||||||
await db.upsertTopic(newTopic);
|
await db.upsertTopic(newTopic);
|
||||||
return newTopic;
|
return newTopic;
|
||||||
}
|
}
|
||||||
|
|||||||
101
src/lib/llm.js
101
src/lib/llm.js
@@ -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) {
|
||||||
@@ -218,6 +272,7 @@ function validateToolInputs(toolUses, task, toolSchemas) {
|
|||||||
* @property {number} [maxTokens=4096]
|
* @property {number} [maxTokens=4096]
|
||||||
* @property {number} [temperature=0]
|
* @property {number} [temperature=0]
|
||||||
* @property {AbortSignal} [signal]
|
* @property {AbortSignal} [signal]
|
||||||
|
* @property {{ acquire: (opts?:{signal?:AbortSignal}) => Promise<void>, pauseUntil: (untilMs:number) => void }} [limiter]
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -237,11 +292,12 @@ export async function callLLM(options) {
|
|||||||
maxTokens = 4096,
|
maxTokens = 4096,
|
||||||
temperature = 0,
|
temperature = 0,
|
||||||
signal,
|
signal,
|
||||||
|
limiter,
|
||||||
} = options;
|
} = 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 });
|
||||||
@@ -249,9 +305,12 @@ export async function callLLM(options) {
|
|||||||
const body = {
|
const body = {
|
||||||
model,
|
model,
|
||||||
max_tokens: maxTokens,
|
max_tokens: maxTokens,
|
||||||
temperature,
|
|
||||||
messages: messagesPayload,
|
messages: messagesPayload,
|
||||||
};
|
};
|
||||||
|
// Temperature is not supported for reasoning tier models
|
||||||
|
if (tier !== 'reasoning') {
|
||||||
|
body.temperature = temperature;
|
||||||
|
}
|
||||||
if (system !== undefined) body.system = system;
|
if (system !== undefined) body.system = system;
|
||||||
if (tools && tools.length) body.tools = tools;
|
if (tools && tools.length) body.tools = tools;
|
||||||
if (toolChoice) body.tool_choice = toolChoice;
|
if (toolChoice) body.tool_choice = toolChoice;
|
||||||
@@ -261,6 +320,7 @@ export async function callLLM(options) {
|
|||||||
try {
|
try {
|
||||||
result = await withRetry(
|
result = await withRetry(
|
||||||
async () => {
|
async () => {
|
||||||
|
if (limiter) await limiter.acquire({ signal });
|
||||||
const timeoutCtl = signal ? null : new AbortController();
|
const timeoutCtl = signal ? null : new AbortController();
|
||||||
const timer = timeoutCtl ? setTimeout(() => timeoutCtl.abort(new DOMException('Timeout', 'AbortError')), DEFAULT_TIMEOUT_MS) : null;
|
const timer = timeoutCtl ? setTimeout(() => timeoutCtl.abort(new DOMException('Timeout', 'AbortError')), DEFAULT_TIMEOUT_MS) : null;
|
||||||
const fetchSignal = linkSignals(signal, timeoutCtl?.signal);
|
const fetchSignal = linkSignals(signal, timeoutCtl?.signal);
|
||||||
@@ -280,6 +340,9 @@ export async function callLLM(options) {
|
|||||||
const errBody = await response.json().catch(() => ({}));
|
const errBody = await response.json().catch(() => ({}));
|
||||||
if (isRetryableStatus(response.status)) {
|
if (isRetryableStatus(response.status)) {
|
||||||
const retryAfterMs = parseRetryAfter(response.headers.get('Retry-After'));
|
const retryAfterMs = parseRetryAfter(response.headers.get('Retry-After'));
|
||||||
|
if (response.status === 429 && retryAfterMs != null && limiter) {
|
||||||
|
limiter.pauseUntil(Date.now() + retryAfterMs);
|
||||||
|
}
|
||||||
throw new RetryableError(response.status, retryAfterMs, `HTTP ${response.status}`);
|
throw new RetryableError(response.status, retryAfterMs, `HTTP ${response.status}`);
|
||||||
}
|
}
|
||||||
throw new LLMHttpError(response.status, response.statusText, errBody);
|
throw new LLMHttpError(response.status, response.statusText, errBody);
|
||||||
|
|||||||
@@ -62,6 +62,109 @@ function sleep(ms, signal) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Token-bucket rate limiter shared by callers that hit the same upstream
|
||||||
|
* quota (e.g. handbook extraction loops). Replaces the static
|
||||||
|
* `setTimeout(..., 12000)` / 15s sleeps that Phase 1 relied on. The bucket
|
||||||
|
* refills continuously; `acquire` resolves either immediately (token
|
||||||
|
* available) or after the next refill tick.
|
||||||
|
*
|
||||||
|
* `rps` is "requests per second" (use fractional values for per-minute
|
||||||
|
* limits: `5/60` for 5 req/min). `burst` is the maximum number of tokens
|
||||||
|
* the bucket can hold; default 1 means strict spacing.
|
||||||
|
*
|
||||||
|
* Call `pauseUntil(timestampMs)` after a 429 with a `Retry-After` hint —
|
||||||
|
* no acquire returns before that timestamp.
|
||||||
|
*
|
||||||
|
* @param {{ rps?: number, burst?: number }} [opts]
|
||||||
|
*/
|
||||||
|
export function createLimiter({ rps = 1, burst = 1 } = {}) {
|
||||||
|
if (rps <= 0) throw new Error('createLimiter: rps must be > 0');
|
||||||
|
if (burst < 1) throw new Error('createLimiter: burst must be >= 1');
|
||||||
|
const intervalMs = 1000 / rps;
|
||||||
|
let tokens = burst;
|
||||||
|
let lastRefill = Date.now();
|
||||||
|
let pausedUntil = 0;
|
||||||
|
const waiters = [];
|
||||||
|
|
||||||
|
function refill(now) {
|
||||||
|
const elapsed = now - lastRefill;
|
||||||
|
if (elapsed <= 0) return;
|
||||||
|
const earned = elapsed / intervalMs;
|
||||||
|
if (earned >= 1) {
|
||||||
|
tokens = Math.min(burst, tokens + Math.floor(earned));
|
||||||
|
lastRefill = now;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function drain() {
|
||||||
|
while (waiters.length) {
|
||||||
|
const now = Date.now();
|
||||||
|
if (now < pausedUntil) {
|
||||||
|
scheduleWake(pausedUntil - now);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
refill(now);
|
||||||
|
if (tokens >= 1) {
|
||||||
|
tokens -= 1;
|
||||||
|
const w = waiters.shift();
|
||||||
|
w.resolve();
|
||||||
|
} else {
|
||||||
|
const wait = Math.max(intervalMs - (now - lastRefill), 0);
|
||||||
|
scheduleWake(wait);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let wakeTimer = null;
|
||||||
|
function scheduleWake(ms) {
|
||||||
|
if (wakeTimer) return;
|
||||||
|
wakeTimer = setTimeout(() => {
|
||||||
|
wakeTimer = null;
|
||||||
|
drain();
|
||||||
|
}, ms);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
/** @param {{signal?:AbortSignal}} [opts] */
|
||||||
|
async acquire({ signal } = {}) {
|
||||||
|
if (signal?.aborted) throw signal.reason ?? new DOMException('Aborted', 'AbortError');
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const entry = { resolve, reject };
|
||||||
|
const onAbort = () => {
|
||||||
|
const i = waiters.indexOf(entry);
|
||||||
|
if (i !== -1) waiters.splice(i, 1);
|
||||||
|
reject(signal.reason ?? new DOMException('Aborted', 'AbortError'));
|
||||||
|
};
|
||||||
|
if (signal) signal.addEventListener('abort', onAbort, { once: true });
|
||||||
|
const wrapped = {
|
||||||
|
resolve: () => { if (signal) signal.removeEventListener('abort', onAbort); resolve(); },
|
||||||
|
reject,
|
||||||
|
};
|
||||||
|
waiters.push(wrapped);
|
||||||
|
drain();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
/** Block all `acquire`s until `untilMs` (epoch milliseconds). */
|
||||||
|
pauseUntil(untilMs) {
|
||||||
|
if (untilMs > pausedUntil) pausedUntil = untilMs;
|
||||||
|
drain();
|
||||||
|
},
|
||||||
|
/** Inspect state — primarily for tests. */
|
||||||
|
_state() {
|
||||||
|
return { tokens, pausedUntil, waiters: waiters.length };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared limiter for the multi-call extraction loops (source chunks,
|
||||||
|
* handbook file sync). 5 requests/minute matches the lowest published
|
||||||
|
* Anthropic tier so we stay well clear of 429.
|
||||||
|
*/
|
||||||
|
export const extractionLimiter = createLimiter({ rps: 5 / 60, burst: 1 });
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Run `fn(attempt)` with retry. `fn` may throw a `RetryableError` to request
|
* Run `fn(attempt)` with retry. `fn` may throw a `RetryableError` to request
|
||||||
* a retry, or any other error to fail immediately.
|
* a retry, or any other error to fail immediately.
|
||||||
|
|||||||
@@ -122,6 +122,8 @@ export const learningAllSchema = z.object({
|
|||||||
infographic: infographicBodySchema,
|
infographic: infographicBodySchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const quizDifficultyEnum = z.enum(['easy', 'medium', 'hard']);
|
||||||
|
|
||||||
const quizQuestionSchema = z.object({
|
const quizQuestionSchema = z.object({
|
||||||
id: z.string().min(1),
|
id: z.string().min(1),
|
||||||
question: z.string().min(1),
|
question: z.string().min(1),
|
||||||
@@ -129,6 +131,7 @@ const quizQuestionSchema = z.object({
|
|||||||
options: z.array(z.string().min(1)).length(4),
|
options: z.array(z.string().min(1)).length(4),
|
||||||
correctIndex: z.number().int().min(0).max(3),
|
correctIndex: z.number().int().min(0).max(3),
|
||||||
explanation: z.string().min(1),
|
explanation: z.string().min(1),
|
||||||
|
difficulty: quizDifficultyEnum,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const quizQuestionsSchema = z.object({
|
export const quizQuestionsSchema = z.object({
|
||||||
@@ -183,6 +186,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 +227,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,
|
||||||
};
|
};
|
||||||
|
|||||||
327
src/lib/llmTools.js
Normal file
327
src/lib/llmTools.js
Normal file
@@ -0,0 +1,327 @@
|
|||||||
|
/**
|
||||||
|
* 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 QUIZ_DIFFICULTIES = ['easy', 'medium', 'hard'];
|
||||||
|
|
||||||
|
const quizQuestionSchema = {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
id: { type: 'string' },
|
||||||
|
question: { type: 'string' },
|
||||||
|
topicLabel: { type: 'string' },
|
||||||
|
options: { type: 'array', items: { type: 'string' }, minItems: 4, maxItems: 4 },
|
||||||
|
correctIndex: { type: 'integer', minimum: 0, maximum: 3 },
|
||||||
|
explanation: { type: 'string', description: 'Why the correct answer is correct (1–2 sentences).' },
|
||||||
|
difficulty: { type: 'string', enum: QUIZ_DIFFICULTIES, description: 'Per-question difficulty tag.' },
|
||||||
|
},
|
||||||
|
required: ['id', 'question', 'topicLabel', 'options', 'correctIndex', 'explanation', 'difficulty'],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EMIT_QUIZ_QUESTIONS_TOOL = {
|
||||||
|
name: 'emit_quiz_questions',
|
||||||
|
description: 'Return a batch of multiple-choice questions for a topic. Exactly four options each; correctIndex is 0-based.',
|
||||||
|
input_schema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: { questions: { type: 'array', items: quizQuestionSchema, minItems: 1 } },
|
||||||
|
required: ['questions'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EMIT_GRAPH_ACTIONS_TOOL = {
|
||||||
|
name: 'emit_graph_actions',
|
||||||
|
description: 'Return the actions to take on the knowledge graph: merges, deletions, new relations and relevance updates. Do not return the entire graph.',
|
||||||
|
input_schema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
merges: {
|
||||||
|
type: 'array',
|
||||||
|
items: {
|
||||||
|
type: 'object',
|
||||||
|
properties: { keepId: { type: 'string' }, deleteId: { type: 'string' } },
|
||||||
|
required: ['keepId', 'deleteId'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
deletions: { type: 'array', items: { type: 'string' } },
|
||||||
|
newRelations: { type: 'array', items: extractionRelationSchema },
|
||||||
|
relevanceUpdates: {
|
||||||
|
type: 'array',
|
||||||
|
items: {
|
||||||
|
type: 'object',
|
||||||
|
properties: { id: { type: 'string' }, learning_relevance: { type: 'string', enum: LEARNING_RELEVANCE } },
|
||||||
|
required: ['id', 'learning_relevance'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Patch tools for refineLearningContent (Phase 2.4) ─────────────────────────
|
||||||
|
|
||||||
|
export const SET_INTRO_TOOL = {
|
||||||
|
name: 'set_intro',
|
||||||
|
description: 'Replace the article intro with a new one or two sentences.',
|
||||||
|
input_schema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: { intro: { type: 'string', description: 'New intro text.' } },
|
||||||
|
required: ['intro'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SET_SECTION_TOOL = {
|
||||||
|
name: 'set_section',
|
||||||
|
description: 'Replace the body of an existing section, matched by its heading (case-insensitive). Use add_section if no section with that heading exists.',
|
||||||
|
input_schema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
heading: { type: 'string', description: 'Heading of the section to replace.' },
|
||||||
|
body: { type: 'string', description: 'New body for that section, at least three sentences.' },
|
||||||
|
},
|
||||||
|
required: ['heading', 'body'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ADD_SECTION_TOOL = {
|
||||||
|
name: 'add_section',
|
||||||
|
description: 'Insert a new section into the article at the start or end.',
|
||||||
|
input_schema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
heading: { type: 'string' },
|
||||||
|
body: { type: 'string', description: 'At least three sentences.' },
|
||||||
|
position: { type: 'string', enum: ['start', 'end'] },
|
||||||
|
},
|
||||||
|
required: ['heading', 'body', 'position'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const REMOVE_SECTION_TOOL = {
|
||||||
|
name: 'remove_section',
|
||||||
|
description: 'Delete a section from the article, matched by its heading (case-insensitive).',
|
||||||
|
input_schema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: { heading: { type: 'string' } },
|
||||||
|
required: ['heading'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const REPLACE_TAKEAWAYS_TOOL = {
|
||||||
|
name: 'replace_takeaways',
|
||||||
|
description: 'Replace the key takeaways list with a new one.',
|
||||||
|
input_schema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: { items: { type: 'array', items: { type: 'string' }, minItems: 1 } },
|
||||||
|
required: ['items'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ARTICLE_PATCH_TOOLS = [
|
||||||
|
SET_INTRO_TOOL,
|
||||||
|
SET_SECTION_TOOL,
|
||||||
|
ADD_SECTION_TOOL,
|
||||||
|
REMOVE_SECTION_TOOL,
|
||||||
|
REPLACE_TAKEAWAYS_TOOL,
|
||||||
|
];
|
||||||
29
src/lib/random.js
Normal file
29
src/lib/random.js
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
/**
|
||||||
|
* Shared randomness helpers.
|
||||||
|
*
|
||||||
|
* `Array.prototype.sort(() => 0.5 - Math.random())` is biased — modern V8
|
||||||
|
* sorts use Timsort, which compares each element more than once and skews
|
||||||
|
* the resulting permutation. Use `shuffle` for anything user-visible
|
||||||
|
* (quiz options, review topic selection, leaderboards).
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function shuffle(arr) {
|
||||||
|
const out = [...arr];
|
||||||
|
for (let i = out.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(Math.random() * (i + 1));
|
||||||
|
[out[i], out[j]] = [out[j], out[i]];
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sample(arr, n) {
|
||||||
|
if (n <= 0) return [];
|
||||||
|
if (n >= arr.length) return shuffle(arr);
|
||||||
|
return shuffle(arr).slice(0, n);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pickInt(min, maxInclusive) {
|
||||||
|
const lo = Math.ceil(min);
|
||||||
|
const hi = Math.floor(maxInclusive);
|
||||||
|
return lo + Math.floor(Math.random() * (hi - lo + 1));
|
||||||
|
}
|
||||||
@@ -1,30 +1,107 @@
|
|||||||
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';
|
||||||
|
import { shuffle, sample } from './random';
|
||||||
|
|
||||||
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. Tag every question with difficulty ('easy', 'medium' or 'hard'). For a typical 5-question batch, mix difficulty roughly 2 easy / 2 medium / 1 hard; scale the ratio proportionally for larger batches.
|
||||||
|
|
||||||
|
Distribute correctIndex roughly evenly across 0, 1, 2, and 3. Do not place the correct answer at the same position more than 4 out of 10 times.
|
||||||
|
|
||||||
|
Never use filler options such as "all of the above", "none of the above", or "both A and B". Every explanation must be a substantive sentence (≥ 20 characters) describing why the correct answer is correct.`;
|
||||||
|
|
||||||
|
const BANNED_OPTION_PATTERNS = [
|
||||||
|
/all of the above/i,
|
||||||
|
/none of the above/i,
|
||||||
|
/both a and b/i,
|
||||||
|
/both b and c/i,
|
||||||
|
/both c and d/i,
|
||||||
|
/both a and c/i,
|
||||||
|
/both b and d/i,
|
||||||
|
/both a and d/i,
|
||||||
|
];
|
||||||
|
|
||||||
|
const cachedSystem = (text) => [{ type: 'text', text, cache_control: { type: 'ephemeral' } }];
|
||||||
|
|
||||||
|
function normalizeQuestionText(text) {
|
||||||
|
return String(text || '')
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[\p{P}\p{S}]/gu, ' ')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function dominantCorrectIndex(questions) {
|
||||||
|
if (!questions.length) return null;
|
||||||
|
const counts = [0, 0, 0, 0];
|
||||||
|
for (const q of questions) counts[q.correctIndex] = (counts[q.correctIndex] || 0) + 1;
|
||||||
|
const max = Math.max(...counts);
|
||||||
|
return max / questions.length > 0.5 ? { index: counts.indexOf(max), ratio: max / questions.length } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateBatchQuality(questions) {
|
||||||
|
for (const q of questions) {
|
||||||
|
const distinct = new Set(q.options.map((o) => o.trim().toLowerCase()));
|
||||||
|
if (distinct.size < 4) {
|
||||||
|
return `Question "${q.question}" has duplicate options.`;
|
||||||
|
}
|
||||||
|
for (const opt of q.options) {
|
||||||
|
if (BANNED_OPTION_PATTERNS.some((re) => re.test(opt))) {
|
||||||
|
return `Question "${q.question}" uses a banned filler option ("${opt}").`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!q.explanation || q.explanation.trim().length < 20) {
|
||||||
|
return `Question "${q.question}" has an explanation that is too short.`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function callQuizModel(topic, count) {
|
||||||
|
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}
|
||||||
|
Type: ${topic.type}
|
||||||
|
Description: ${topic.description}
|
||||||
|
|
||||||
|
Options must be prefixed "A) ", "B) ", "C) ", "D) ". Make questions specific and practical, not trivial. Example: a question whose correct answer is option C uses "correctIndex": 2.`;
|
||||||
|
|
||||||
|
const result = await callLLM({
|
||||||
|
task: 'quiz.generate',
|
||||||
|
tier: 'standard',
|
||||||
|
system: cachedSystem(QUIZ_SYSTEM),
|
||||||
|
user: prompt,
|
||||||
|
tools: [EMIT_QUIZ_QUESTIONS_TOOL],
|
||||||
|
toolChoice: { type: 'tool', name: EMIT_QUIZ_QUESTIONS_TOOL.name },
|
||||||
|
maxTokens: 4096,
|
||||||
|
});
|
||||||
|
|
||||||
|
const emitted = result.toolUses[0]?.input;
|
||||||
|
if (!emitted?.questions?.length) {
|
||||||
|
throw new Error(`Could not generate questions for ${topic.label}`);
|
||||||
|
}
|
||||||
|
return emitted.questions;
|
||||||
|
}
|
||||||
|
|
||||||
async function selectTestTopics(userId, weekNumber) {
|
async function selectTestTopics(userId, weekNumber) {
|
||||||
const allTopics = await db.getTopics();
|
const allTopics = await db.getTopics();
|
||||||
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 { primaryTopic: null, reviewTopics: [], isReviewWeek: false };
|
if (!topics || topics.length === 0) return { primaryTopic: null, reviewTopics: [], isReviewWeek: false };
|
||||||
|
|
||||||
// Try curriculum-based selection first
|
|
||||||
try {
|
try {
|
||||||
const { topic, curriculumEntry } = await getCurriculumTopic(weekNumber);
|
const { topic, curriculumEntry } = await getCurriculumTopic(weekNumber);
|
||||||
|
|
||||||
if (curriculumEntry?.is_review_week) {
|
if (curriculumEntry?.is_review_week) {
|
||||||
// Review week: pull topics from the whole quarter
|
|
||||||
const quarter = getQuarterForWeek(weekNumber);
|
const quarter = getQuarterForWeek(weekNumber);
|
||||||
const curriculum = await db.getCurriculum(new Date().getFullYear());
|
const curriculum = await db.getCurriculum(new Date().getFullYear());
|
||||||
const quarterTopicIds = curriculum
|
const quarterTopicIds = curriculum
|
||||||
.filter(w => w.quarter === quarter && w.topic_id && !w.is_review_week)
|
.filter(w => w.quarter === quarter && w.topic_id && !w.is_review_week)
|
||||||
.map(w => w.topic_id);
|
.map(w => w.topic_id);
|
||||||
const quarterTopics = topics.filter(t => quarterTopicIds.includes(t.id));
|
const quarterTopics = topics.filter(t => quarterTopicIds.includes(t.id));
|
||||||
// Use all quarter topics as review topics (no single primary)
|
|
||||||
return {
|
return {
|
||||||
primaryTopic: quarterTopics[0] || topics[0],
|
primaryTopic: quarterTopics[0] || topics[0],
|
||||||
reviewTopics: quarterTopics.slice(1),
|
reviewTopics: quarterTopics.slice(1),
|
||||||
@@ -34,15 +111,13 @@ async function selectTestTopics(userId, weekNumber) {
|
|||||||
|
|
||||||
if (topic) {
|
if (topic) {
|
||||||
const others = topics.filter(t => t.id !== topic.id);
|
const others = topics.filter(t => t.id !== topic.id);
|
||||||
const shuffled = others.sort(() => 0.5 - Math.random());
|
const reviewTopics = sample(others, Math.min(5, others.length));
|
||||||
const reviewTopics = shuffled.slice(0, Math.min(5, shuffled.length));
|
|
||||||
return { primaryTopic: topic, reviewTopics, isReviewWeek: false };
|
return { primaryTopic: topic, reviewTopics, isReviewWeek: false };
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('[Test] Curriculum lookup failed, falling back to hash:', e.message);
|
console.warn('[Test] Curriculum lookup failed, falling back to hash:', e.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: hash-based selection
|
|
||||||
const str = `${userId}:${weekNumber}`;
|
const str = `${userId}:${weekNumber}`;
|
||||||
let hash = 0;
|
let hash = 0;
|
||||||
for (let i = 0; i < str.length; i++) {
|
for (let i = 0; i < str.length; i++) {
|
||||||
@@ -53,8 +128,7 @@ async function selectTestTopics(userId, weekNumber) {
|
|||||||
const primaryTopic = topics[primaryIndex];
|
const primaryTopic = topics[primaryIndex];
|
||||||
|
|
||||||
const others = topics.filter((_, i) => i !== primaryIndex);
|
const others = topics.filter((_, i) => i !== primaryIndex);
|
||||||
const shuffled = others.sort(() => 0.5 - Math.random());
|
const reviewTopics = sample(others, Math.min(5, others.length));
|
||||||
const reviewTopics = shuffled.slice(0, Math.min(5, shuffled.length));
|
|
||||||
|
|
||||||
return { primaryTopic, reviewTopics, isReviewWeek: false };
|
return { primaryTopic, reviewTopics, isReviewWeek: false };
|
||||||
}
|
}
|
||||||
@@ -63,64 +137,69 @@ export async function getCachedQuiz(userId, weekNumber) {
|
|||||||
return db.getCachedQuiz(userId, weekNumber);
|
return db.getCachedQuiz(userId, weekNumber);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function forceGenerateTopicQuestions(topic, count = 10) {
|
export async function forceGenerateTopicQuestions(topic, count = 5) {
|
||||||
let bank = await db.getQuizBank(topic.id);
|
const existingBank = await db.getQuizBank(topic.id);
|
||||||
|
const existingKeys = new Set(existingBank.map((q) => normalizeQuestionText(q.question)));
|
||||||
|
|
||||||
const prompt = `Generate exactly ${count} multiple-choice quiz questions based on this knowledge topic:
|
let lastQualityError = null;
|
||||||
|
let candidates = null;
|
||||||
|
|
||||||
Topic: ${topic.label}
|
for (let attempt = 0; attempt < 3; attempt++) {
|
||||||
Type: ${topic.type}
|
const questions = await callQuizModel(topic, count);
|
||||||
Description: ${topic.description}
|
|
||||||
|
|
||||||
Return ONLY a JSON object with this structure:
|
const qualityError = validateBatchQuality(questions);
|
||||||
{
|
if (qualityError) {
|
||||||
"questions": [
|
lastQualityError = qualityError;
|
||||||
{
|
console.warn(`[quiz] batch rejected (attempt ${attempt + 1}): ${qualityError}`);
|
||||||
"id": "unique-id-string",
|
continue;
|
||||||
"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 dominant = dominantCorrectIndex(questions);
|
||||||
- Each question must have exactly 4 options.
|
if (dominant && attempt < 2) {
|
||||||
- correctIndex is 0-based (0=A, 1=B, 2=C, 3=D).
|
console.warn(`[quiz] correctIndex dominated by ${dominant.index} (${Math.round(dominant.ratio * 100)}%) — re-rolling`);
|
||||||
- Mix difficulty: 4 easy, 4 medium, 2 hard.
|
continue;
|
||||||
- Make questions specific and practical, not trivial.`;
|
}
|
||||||
|
|
||||||
const responseText = await anthropicApi.generateContent(QUIZ_SYSTEM, prompt);
|
candidates = questions;
|
||||||
let newQuestions;
|
break;
|
||||||
try {
|
}
|
||||||
const jsonMatch = responseText.match(/\{[\s\S]*\}/);
|
|
||||||
const parsed = JSON.parse(jsonMatch ? jsonMatch[0] : responseText);
|
if (!candidates) {
|
||||||
newQuestions = parsed.questions || [];
|
throw new Error(`Quality gate rejected the generated batch for ${topic.label}: ${lastQualityError || 'unbalanced answer distribution'}. Click "Generate" to try again.`);
|
||||||
newQuestions.forEach(q => {
|
}
|
||||||
q.id = `${topic.id}-${Math.random().toString(36).substr(2, 9)}`;
|
|
||||||
|
const accepted = [];
|
||||||
|
for (const q of candidates) {
|
||||||
|
const key = normalizeQuestionText(q.question);
|
||||||
|
if (existingKeys.has(key)) {
|
||||||
|
console.debug('[quiz] dropped duplicate:', q.question);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
existingKeys.add(key);
|
||||||
|
accepted.push({
|
||||||
|
...q,
|
||||||
|
id: `${topic.id}-${Math.random().toString(36).slice(2, 11)}`,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to generate questions for topic', topic.label, e);
|
|
||||||
throw new Error(`Could not generate questions for ${topic.label}`, { cause: e });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
bank = [...bank, ...newQuestions];
|
if (!accepted.length) {
|
||||||
await db.setQuizBank(topic.id, bank);
|
throw new Error(`All generated questions for ${topic.label} were duplicates of existing ones.`);
|
||||||
return newQuestions;
|
}
|
||||||
|
|
||||||
|
const merged = [...existingBank, ...accepted];
|
||||||
|
await db.setQuizBank(topic.id, merged);
|
||||||
|
return accepted;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getOrGenerateTopicQuestions(topic, count) {
|
async function getOrGenerateTopicQuestions(topic, count) {
|
||||||
let bank = await db.getQuizBank(topic.id);
|
let bank = await db.getQuizBank(topic.id);
|
||||||
|
|
||||||
if (bank.length < count) {
|
if (bank.length < count) {
|
||||||
await forceGenerateTopicQuestions(topic, 10);
|
await forceGenerateTopicQuestions(topic, 5);
|
||||||
bank = await db.getQuizBank(topic.id);
|
bank = await db.getQuizBank(topic.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
const shuffled = [...bank].sort(() => 0.5 - Math.random());
|
return sample(bank, Math.min(count, bank.length));
|
||||||
return shuffled.slice(0, Math.min(count, shuffled.length));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getTopicQuestionBank(topicId) {
|
export async function getTopicQuestionBank(topicId) {
|
||||||
@@ -161,10 +240,10 @@ export async function generateWeeklyQuiz(userId, weekNumber, force = false) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
questions.sort(() => 0.5 - Math.random());
|
const shuffled = shuffle(questions);
|
||||||
|
|
||||||
const quiz = {
|
const quiz = {
|
||||||
questions,
|
questions: shuffled,
|
||||||
meta: {
|
meta: {
|
||||||
userId,
|
userId,
|
||||||
weekNumber,
|
weekNumber,
|
||||||
|
|||||||
@@ -1,9 +1,24 @@
|
|||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
|
import { execSync } from 'node:child_process'
|
||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
import tailwindcss from '@tailwindcss/vite'
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
|
||||||
|
function getBuildSha() {
|
||||||
|
try {
|
||||||
|
return execSync('git rev-parse --short HEAD', { stdio: ['ignore', 'pipe', 'ignore'] })
|
||||||
|
.toString()
|
||||||
|
.trim()
|
||||||
|
} catch {
|
||||||
|
return 'unknown'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
define: {
|
||||||
|
__BUILD_SHA__: JSON.stringify(getBuildSha()),
|
||||||
|
__BUILD_TIME__: JSON.stringify(new Date().toISOString()),
|
||||||
|
},
|
||||||
plugins: [react(), tailwindcss()],
|
plugins: [react(), tailwindcss()],
|
||||||
server: {
|
server: {
|
||||||
proxy: {
|
proxy: {
|
||||||
|
|||||||
Reference in New Issue
Block a user