Compare commits

...

4 Commits

Author SHA1 Message Date
RaymondVerhoef
229246f7b6 feat: phase 5 of AI pipeline hardening — R42 retrieval & telemetry
All checks were successful
On Pull Request to Main / test (pull_request) Successful in 32s
On Pull Request to Main / publish (pull_request) Successful in 57s
On Pull Request to Main / deploy-dev (pull_request) Successful in 1m34s
- Add dependency-free TF-IDF retrieval (src/lib/retrieval.js) with NL+EN
  stopwords and a WeakMap-cached index.
- Rewrite buildKbContext to ship the top-K relevant topics + verbatim-
  mentioned ids only, filter relations to the included set, and append a
  [kb_hash: <8 hex>] suffix so the ephemeral prompt cache busts when the
  graph changes. Returns { context, retrievedTopics, allTopics }.
- Add LOOKUP_TOPIC_TOOL and drive useChat through callLLM directly with a
  multi-hop tool_result loop capped at 3 hops; preserve Anthropic-provided
  tool_use ids through callLLM so the loop can echo correct tool_use_id.
- Truncate R42 history to the last 12 turns and prepend a single
  "(earlier conversation truncated)" assistant message.
- Set R42 chat defaults: temperature 0.3, maxTokens 2048.
- Add pb_migrations/1780500002_created_llm_calls.js (the best-effort
  logger in callLLM was already wired) and a new Admin → Diagnostics
  view showing the last 100 calls with token usage, cache-hit rate, and
  USD cost from a local Anthropic price table.
- Finalize AI_PIPELINE_HARDENING_PLAN.md: mark Phases 1–5 shipped and
  Phase 6 (eval harness) explicitly out of scope.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 21:36:40 +02:00
RaymondVerhoef
66e0c275da feat: phase 4 of AI pipeline hardening — quiz & content quality
All checks were successful
On Pull Request to Main / test (pull_request) Successful in 31s
On Pull Request to Main / publish (pull_request) Successful in 1m1s
On Pull Request to Main / deploy-dev (pull_request) Successful in 1m31s
- src/lib/random.js: Fisher–Yates shuffle/sample/pickInt; replace every
  biased .sort(() => 0.5 - Math.random()) site in testService.
- testService: debias correctIndex via prompt + runtime re-roll (up to 2x
  when one position holds >50%); quality gate rejecting <4 distinct
  options, banned filler ("all of the above" etc) and explanations
  shorter than 20 chars; dedup new questions against the existing bank
  via normalised question text.
- Quiz schema/tool/prompt require difficulty ('easy'|'medium'|'hard');
  db.getQuizBank defaults legacy records to 'medium' on read.
- learningService.generateCustomTopic: kebab-case slug ID from the
  polished label with collision suffixes; default learning_relevance
  'standard' when the model omits it.
- Tests for random helpers, dedup/quality-gate behaviour and the
  extended quiz schema.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 19:22:10 +02:00
RaymondVerhoef
c82e4fc3a1 feat: reduce initial question batch size for a topic to 5
All checks were successful
On Push to Main / test (push) Successful in 32s
On Push to Main / publish (push) Successful in 1m0s
On Push to Main / deploy-dev (push) Successful in 1m33s
When a topic's quiz bank is empty (or below the requested count), we
previously seeded it with a fresh batch of 10 questions. That meant the
first weekly quiz for any new topic triggered a 10-question LLM call —
heavy for what's ultimately a 1-question sample for review topics, and
overkill for the typical 5-question primary topic.

- forceGenerateTopicQuestions default count: 10 → 5
- getOrGenerateTopicQuestions seed amount: 10 → 5
- TestManager "Generate" defaults + empty-state button copy: 10 → 5
- QUIZ_SYSTEM difficulty hint: rewritten for a 5-question batch (2 easy
  / 2 medium / 1 hard) with explicit "scale proportionally for larger
  batches" so admins can still generate 10+ via TestManager when they
  want more depth.

Tests 61/61 pass, lint clean (0 errors), build clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 19:12:16 +02:00
fd3b849c19 Merge pull request 'feat: phase 3 of AI pipeline hardening — extraction quality' (#5) from feat/ai-pipeline-hardening-phase-3 into main
All checks were successful
On Push to Main / test (push) Successful in 25s
On Push to Main / publish (push) Successful in 1m10s
On Push to Main / deploy-dev (push) Successful in 1m31s
Reviewed-on: #5
2026-05-20 15:57:40 +00:00
20 changed files with 1164 additions and 132 deletions

View File

@@ -6,6 +6,8 @@
This plan upgrades the platform's interaction with the Anthropic API: how prompts are built, how responses are parsed, how the model is retried, and how outputs are validated. It is broken into six phases that can be implemented and shipped independently. Each phase ends with verifiable acceptance criteria. This plan upgrades the platform's interaction with the Anthropic API: how prompts are built, how responses are parsed, how the model is retried, and how outputs are validated. It is broken into six phases that can be implemented and shipped independently. Each phase ends with verifiable acceptance criteria.
> **Status (2026-05-20):** Phases 15 implemented and shipped. Phase 6 (eval harness) is intentionally **out of scope** for this initiative — the production pipeline is hardened to the level the platform needs, and a golden-set runner can be reopened later as a stand-alone task if regression risk grows. The repo no longer carries any TODO from this plan.
--- ---
## 0. Operating principles ## 0. Operating principles
@@ -461,7 +463,9 @@ Wire `callLLM` to write to it (best-effort, never throws). Add a minimal `Admin
--- ---
## Phase 6 — Eval harness (optional, high-leverage) ## Phase 6 — Eval harness (NOT IMPLEMENTED — out of scope)
> Phase 6 was deliberately skipped. The acceptance criteria for Phases 15 give enough confidence in extraction, content, quiz, R42, and telemetry that a golden-set harness is not currently load-bearing. Leave this section intact as a starting point for a future, separately-scoped initiative.
**Goal:** prompt or model changes can be measured before they ship. **Goal:** prompt or model changes can be measured before they ship.

View File

@@ -0,0 +1,209 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = new Collection({
"createRule": "",
"deleteRule": "",
"fields": [
{
"autogeneratePattern": "[a-z0-9]{15}",
"help": "",
"hidden": false,
"id": "text3208210256",
"max": 15,
"min": 15,
"name": "id",
"pattern": "^[a-z0-9]+$",
"presentable": false,
"primaryKey": true,
"required": true,
"system": true,
"type": "text"
},
{
"autogeneratePattern": "",
"help": "",
"hidden": false,
"id": "text_llm_task",
"max": 0,
"min": 0,
"name": "task",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"autogeneratePattern": "",
"help": "",
"hidden": false,
"id": "text_llm_model",
"max": 0,
"min": 0,
"name": "model",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"autogeneratePattern": "",
"help": "",
"hidden": false,
"id": "text_llm_tier",
"max": 0,
"min": 0,
"name": "tier",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"help": "",
"hidden": false,
"id": "number_llm_duration",
"max": null,
"min": null,
"name": "duration_ms",
"onlyInt": false,
"presentable": false,
"required": false,
"system": false,
"type": "number"
},
{
"help": "",
"hidden": false,
"id": "number_llm_input",
"max": null,
"min": null,
"name": "input_tokens",
"onlyInt": false,
"presentable": false,
"required": false,
"system": false,
"type": "number"
},
{
"help": "",
"hidden": false,
"id": "number_llm_output",
"max": null,
"min": null,
"name": "output_tokens",
"onlyInt": false,
"presentable": false,
"required": false,
"system": false,
"type": "number"
},
{
"help": "",
"hidden": false,
"id": "number_llm_cache_r",
"max": null,
"min": null,
"name": "cache_read_tokens",
"onlyInt": false,
"presentable": false,
"required": false,
"system": false,
"type": "number"
},
{
"help": "",
"hidden": false,
"id": "number_llm_cache_c",
"max": null,
"min": null,
"name": "cache_create_tokens",
"onlyInt": false,
"presentable": false,
"required": false,
"system": false,
"type": "number"
},
{
"autogeneratePattern": "",
"help": "",
"hidden": false,
"id": "text_llm_stop",
"max": 0,
"min": 0,
"name": "stop_reason",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"hidden": false,
"id": "bool_llm_ok",
"name": "ok",
"presentable": false,
"required": false,
"system": false,
"type": "bool"
},
{
"autogeneratePattern": "",
"help": "",
"hidden": false,
"id": "text_llm_err",
"max": 0,
"min": 0,
"name": "error_msg",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"hidden": false,
"id": "autodate_llm_created",
"name": "created",
"onCreate": true,
"onUpdate": false,
"presentable": false,
"system": false,
"type": "autodate"
},
{
"hidden": false,
"id": "autodate_llm_updated",
"name": "updated",
"onCreate": true,
"onUpdate": true,
"presentable": false,
"system": false,
"type": "autodate"
}
],
"id": "pbc_llm_calls_001",
"indexes": [
"CREATE INDEX `idx_llm_calls_created` ON `llm_calls` (`created`)",
"CREATE INDEX `idx_llm_calls_task` ON `llm_calls` (`task`)"
],
"listRule": "",
"name": "llm_calls",
"system": false,
"type": "base",
"updateRule": "",
"viewRule": ""
});
return app.save(collection);
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_llm_calls_001");
return app.delete(collection);
})

View File

@@ -0,0 +1,162 @@
import { useEffect, useState } from 'react';
import { RefreshCw, AlertCircle, CheckCircle2 } from 'lucide-react';
import Card from '../ui/Card';
import Button from '../ui/Button';
import Tag from '../ui/Tag';
import * as db from '../../lib/db';
// Public Anthropic pricing per 1M tokens. Update manually when prices change.
const PRICES = {
'claude-haiku-4-5-20251001': { input: 1.0, output: 5.0, cache_read: 0.10, cache_write: 1.25 },
'claude-haiku-4-5': { input: 1.0, output: 5.0, cache_read: 0.10, cache_write: 1.25 },
'claude-sonnet-4-6': { input: 3.0, output: 15.0, cache_read: 0.30, cache_write: 3.75 },
'claude-opus-4-7': { input: 15.0, output: 75.0, cache_read: 1.50, cache_write: 18.75 },
};
function pricesFor(model) {
if (!model) return null;
if (PRICES[model]) return PRICES[model];
if (model.includes('haiku')) return PRICES['claude-haiku-4-5'];
if (model.includes('sonnet')) return PRICES['claude-sonnet-4-6'];
if (model.includes('opus')) return PRICES['claude-opus-4-7'];
return null;
}
function costUsd(row) {
const p = pricesFor(row.model);
if (!p) return null;
const inTok = (row.input_tokens || 0) - (row.cache_read_tokens || 0) - (row.cache_create_tokens || 0);
const out = row.output_tokens || 0;
const cr = row.cache_read_tokens || 0;
const cc = row.cache_create_tokens || 0;
const usd = (Math.max(inTok, 0) * p.input + out * p.output + cr * p.cache_read + cc * p.cache_write) / 1_000_000;
return usd;
}
function fmtUsd(n) {
if (n == null) return '—';
if (n < 0.0001) return '<$0.0001';
return `$${n.toFixed(4)}`;
}
function fmtMs(n) {
if (n == null) return '—';
if (n < 1000) return `${Math.round(n)}ms`;
return `${(n / 1000).toFixed(1)}s`;
}
const Diagnostics = () => {
const [rows, setRows] = useState([]);
const [loading, setLoading] = useState(false);
const load = async () => {
setLoading(true);
try {
const r = await db.getRecentLlmCalls(100);
setRows(r);
} finally {
setLoading(false);
}
};
useEffect(() => { load(); }, []);
const totals = rows.reduce((acc, r) => {
acc.input += r.input_tokens || 0;
acc.output += r.output_tokens || 0;
acc.cacheRead += r.cache_read_tokens || 0;
acc.cacheCreate += r.cache_create_tokens || 0;
const c = costUsd(r);
if (c != null) acc.cost += c;
acc.duration += r.duration_ms || 0;
if (r.ok) acc.ok++;
else acc.fail++;
return acc;
}, { input: 0, output: 0, cacheRead: 0, cacheCreate: 0, cost: 0, duration: 0, ok: 0, fail: 0 });
const cacheHitRate = totals.input > 0
? Math.round((totals.cacheRead / totals.input) * 100)
: 0;
return (
<div>
<div className="flex items-center justify-between mb-4">
<p className="text-fg-muted text-sm">
Laatste 100 LLM-aanroepen. Kosten worden lokaal berekend met publieke Anthropic-prijzen (handmatig verversen).
</p>
<Button onClick={load} disabled={loading}>
<RefreshCw size={16} className={`mr-2 ${loading ? 'animate-spin' : ''}`} /> Vernieuwen
</Button>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-6">
<Card className="p-4 border border-bg-warm">
<p className="text-xs text-fg-muted uppercase">Calls</p>
<p className="text-2xl font-bold mt-1">{rows.length}</p>
<p className="text-xs text-fg-muted mt-1">{totals.ok} ok · {totals.fail} fail</p>
</Card>
<Card className="p-4 border border-bg-warm">
<p className="text-xs text-fg-muted uppercase">Tokens (in/out)</p>
<p className="text-2xl font-bold mt-1">{totals.input.toLocaleString()} / {totals.output.toLocaleString()}</p>
<p className="text-xs text-fg-muted mt-1">cache read: {totals.cacheRead.toLocaleString()} ({cacheHitRate}%)</p>
</Card>
<Card className="p-4 border border-bg-warm">
<p className="text-xs text-fg-muted uppercase">Geschatte kosten</p>
<p className="text-2xl font-bold mt-1">{fmtUsd(totals.cost)}</p>
<p className="text-xs text-fg-muted mt-1">over deze 100 aanroepen</p>
</Card>
<Card className="p-4 border border-bg-warm">
<p className="text-xs text-fg-muted uppercase">Gem. duur</p>
<p className="text-2xl font-bold mt-1">{rows.length ? fmtMs(totals.duration / rows.length) : '—'}</p>
</Card>
</div>
<Card className="p-0 border border-bg-warm overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-bg-warm/40 text-fg-muted text-xs uppercase">
<tr>
<th className="text-left p-3">Tijd</th>
<th className="text-left p-3">Task</th>
<th className="text-left p-3">Tier</th>
<th className="text-left p-3">Model</th>
<th className="text-right p-3">In</th>
<th className="text-right p-3">Out</th>
<th className="text-right p-3">Cache</th>
<th className="text-right p-3">$</th>
<th className="text-right p-3">Duur</th>
<th className="text-left p-3">Status</th>
</tr>
</thead>
<tbody className="divide-y divide-bg-warm">
{rows.length === 0 ? (
<tr><td colSpan={10} className="p-8 text-center text-fg-muted">Nog geen aanroepen geregistreerd.</td></tr>
) : rows.map(r => (
<tr key={r.id} className="hover:bg-bg-warm/20">
<td className="p-3 text-xs text-fg-muted whitespace-nowrap">
{r.created ? new Date(r.created).toLocaleString() : '—'}
</td>
<td className="p-3 font-mono text-xs">{r.task || '—'}</td>
<td className="p-3 text-xs">{r.tier || '—'}</td>
<td className="p-3 font-mono text-xs">{r.model || '—'}</td>
<td className="p-3 text-right">{(r.input_tokens || 0).toLocaleString()}</td>
<td className="p-3 text-right">{(r.output_tokens || 0).toLocaleString()}</td>
<td className="p-3 text-right text-fg-muted">{(r.cache_read_tokens || 0).toLocaleString()}</td>
<td className="p-3 text-right">{fmtUsd(costUsd(r))}</td>
<td className="p-3 text-right">{fmtMs(r.duration_ms)}</td>
<td className="p-3">
{r.ok
? <Tag variant="success" className="flex items-center gap-1 w-fit"><CheckCircle2 size={12}/> ok</Tag>
: <Tag variant="dark" className="bg-red-100 text-red-800 flex items-center gap-1 w-fit"><AlertCircle size={12}/> {(r.error_msg || r.stop_reason || 'fail').slice(0, 24)}</Tag>}
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
</div>
);
};
export default Diagnostics;

View File

@@ -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>
) : ( ) : (

View File

@@ -68,6 +68,22 @@ export function buildSystemPrompt({ userName, isAdmin, kbContext }) {
]; ];
} }
export const LOOKUP_TOPIC_TOOL = {
name: 'lookup_topic',
description:
'Haal de volledige beschrijving en eventuele leerinhoud van één topic op uit de kennisgraaf. Gebruik dit wanneer de samenvattende KENNISGRAAF in de systeemprompt niet genoeg informatie bevat om de vraag te beantwoorden.',
input_schema: {
type: 'object',
properties: {
id: {
type: 'string',
description: 'Het exacte topic id (kebab-case) zoals het in de kennisgraaf staat.',
},
},
required: ['id'],
},
};
export const PROPOSE_GRAPH_DELTA_TOOL = { export const PROPOSE_GRAPH_DELTA_TOOL = {
name: 'propose_graph_delta', name: 'propose_graph_delta',
description: description:

View File

@@ -1,68 +1,119 @@
import * as db from '../../lib/db'; import * as db from '../../lib/db';
import { buildIndex, retrieveTopK } from '../../lib/retrieval';
const TOP_K = 10;
async function sha256Hex(input) {
const enc = new TextEncoder().encode(input);
if (globalThis.crypto?.subtle?.digest) {
const buf = await globalThis.crypto.subtle.digest('SHA-256', enc);
return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join('');
}
let h = 2166136261 >>> 0;
for (let i = 0; i < input.length; i++) {
h ^= input.charCodeAt(i);
h = Math.imul(h, 16777619);
}
return (h >>> 0).toString(16).padStart(8, '0');
}
/** /**
* Build a compact knowledge-base context string to inject into the system prompt. * Build a retrieval-scoped KB context. Instead of dumping the whole graph,
* Reads topics + relations from PocketBase via db.js. * we pick the top-K topics by TF-IDF over `userMessage`, plus any topic
* Topic-level content is loaded only when a topic id/label appears in the user's message. * whose id or label appears verbatim in the message. Relations are filtered
* to those that touch the included set.
* *
* Returns { context: string, topics: Array } so callers can reuse the fetched topics * A `[kb_hash: …]` suffix is appended so the Anthropic ephemeral prompt
* for validateDelta without a second round-trip. * cache automatically busts when topics are added/removed.
*
* Returns:
* { context, retrievedTopics, allTopics }
* — `allTopics` is the full PocketBase list so callers can still run
* `validateDelta` against the entire current graph.
*/ */
export async function buildKbContext(userMessage = '') { export async function buildKbContext(userMessage = '') {
const [topics, relations] = await Promise.all([ const [allTopics, allRelations] = await Promise.all([
db.getTopics(), db.getTopics(),
db.getRelations(), db.getRelations(),
]); ]);
if (topics.length === 0) { const sortedIds = allTopics.map(t => t.id).sort().join('|');
const fullHash = await sha256Hex(sortedIds);
const kbHash = fullHash.slice(0, 8);
if (allTopics.length === 0) {
return { return {
context: 'KENNISGRAAF: (leeg — er zijn nog geen onderwerpen geëxtraheerd)', context: `KENNISGRAAF: (leeg — er zijn nog geen onderwerpen geëxtraheerd)\n[kb_hash: ${kbHash}]`,
topics: [], retrievedTopics: [],
allTopics: [],
}; };
} }
const topicLines = topics.map(t => { const lowered = userMessage.toLowerCase();
const mentionedIds = new Set();
for (const t of allTopics) {
const idHit = t.id && lowered.includes(t.id.toLowerCase());
const labelHit = t.label && lowered.includes(t.label.toLowerCase());
if (idHit || labelHit) mentionedIds.add(t.id);
}
const index = buildIndex(allTopics);
const retrieved = retrieveTopK(index, userMessage, TOP_K);
const includedById = new Map();
for (const id of mentionedIds) {
const t = allTopics.find(x => x.id === id);
if (t) includedById.set(id, t);
}
for (const t of retrieved) {
if (!includedById.has(t.id)) includedById.set(t.id, t);
}
const included = [...includedById.values()];
const topicLines = included.map(t => {
const desc = (t.description || '').replace(/\s+/g, ' ').trim().slice(0, 200); const desc = (t.description || '').replace(/\s+/g, ' ').trim().slice(0, 200);
return `- ${t.id} (${t.type || 'concept'}) "${t.label}": ${desc}`; return `- ${t.id} (${t.type || 'concept'}) "${t.label}": ${desc}`;
}); });
const relLines = relations.map(r => { const includedIds = new Set(included.map(t => t.id));
const relLines = [];
for (const r of allRelations) {
const src = typeof r.source === 'object' ? r.source.id : r.source; const src = typeof r.source === 'object' ? r.source.id : r.source;
const tgt = typeof r.target === 'object' ? r.target.id : r.target; const tgt = typeof r.target === 'object' ? r.target.id : r.target;
return `- ${src} --${r.type}--> ${tgt}`; if (includedIds.has(src) && includedIds.has(tgt)) {
}); relLines.push(`- ${src} --${r.type}--> ${tgt}`);
}
}
// Pull deep content for any topic explicitly mentioned in the user message.
const lowered = userMessage.toLowerCase();
const mentionedDeepContent = []; const mentionedDeepContent = [];
for (const t of topics) { for (const id of mentionedIds) {
const idHit = t.id && lowered.includes(t.id.toLowerCase()); const t = includedById.get(id);
const labelHit = t.label && lowered.includes(t.label.toLowerCase()); if (!t) continue;
if (idHit || labelHit) {
const content = await db.getContent(t.id).catch(() => null); const content = await db.getContent(t.id).catch(() => null);
if (content) { if (!content) continue;
let raw; let raw;
if (typeof content === 'string') raw = content; if (typeof content === 'string') raw = content;
else if (content.article) raw = content.article; else if (content.article) raw = typeof content.article === 'string' ? content.article : JSON.stringify(content.article);
else raw = JSON.stringify(content); else raw = JSON.stringify(content);
const snippet = raw.replace(/\s+/g, ' ').trim().slice(0, 1200); const snippet = raw.replace(/\s+/g, ' ').trim().slice(0, 1200);
mentionedDeepContent.push(`### ${t.label}\n${snippet}`); mentionedDeepContent.push(`### ${t.label}\n${snippet}`);
} }
}
}
const context = [ const context = [
`KENNISGRAAF — TOPICS:`, `KENNISGRAAF — RELEVANTE TOPICS (top ${included.length} van ${allTopics.length}):`,
topicLines.join('\n'), topicLines.join('\n'),
``, ``,
`KENNISGRAAF — RELATIES:`, `KENNISGRAAF — RELATIES (binnen deze selectie):`,
relLines.length ? relLines.join('\n') : '(geen relaties)', relLines.length ? relLines.join('\n') : '(geen relaties binnen deze selectie)',
mentionedDeepContent.length mentionedDeepContent.length
? `\n\nDIEPERE INHOUD (voor genoemde topics):\n${mentionedDeepContent.join('\n\n')}` ? `\n\nDIEPERE INHOUD (voor genoemde topics):\n${mentionedDeepContent.join('\n\n')}`
: '', : '',
``,
`Als de relevante context hierboven te beperkt is, gebruik dan de tool "lookup_topic" om de volledige beschrijving en eventuele leerinhoud van een specifiek topic op te halen.`,
`[kb_hash: ${kbHash}]`,
].join('\n'); ].join('\n');
return { context, topics }; return { context, retrievedTopics: included, allTopics };
} }
/** /**

View File

@@ -1,17 +1,59 @@
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { storage } from '../../lib/storage'; import { storage } from '../../lib/storage';
import { anthropicApi } from '../../lib/api'; import { callLLM } from '../../lib/llm';
import * as db from '../../lib/db';
import { buildKbContext, validateDelta, deltaKey } from './rag'; import { buildKbContext, validateDelta, deltaKey } from './rag';
import { buildSystemPrompt, PROPOSE_GRAPH_DELTA_TOOL, STRINGS } from './prompts'; import {
buildSystemPrompt,
PROPOSE_GRAPH_DELTA_TOOL,
LOOKUP_TOPIC_TOOL,
STRINGS,
} from './prompts';
const MAX_HISTORY = 50; const MAX_HISTORY = 50;
const VERBATIM_TURNS = 12;
const MAX_LOOKUP_HOPS = 3;
const TRUNCATION_NOTICE = '(earlier conversation truncated)';
/** Trim API history to the last N turns and prepend a truncation notice. */
function truncateApiMessages(history) {
if (history.length <= VERBATIM_TURNS) return history;
const tail = history.slice(-VERBATIM_TURNS);
return [{ role: 'assistant', content: TRUNCATION_NOTICE }, ...tail];
}
async function resolveLookupTopic(id, allTopics) {
const topic = allTopics.find(t => t.id === id);
if (!topic) {
return { ok: false, payload: `Geen topic gevonden met id "${id}".` };
}
const content = await db.getContent(id).catch(() => null);
let contentSnippet = null;
if (content) {
let raw;
if (typeof content === 'string') raw = content;
else if (content.article) raw = typeof content.article === 'string' ? content.article : JSON.stringify(content.article);
else raw = JSON.stringify(content);
contentSnippet = raw.replace(/\s+/g, ' ').trim().slice(0, 2400);
}
return {
ok: true,
payload: {
id: topic.id,
label: topic.label,
type: topic.type || 'concept',
description: topic.description || '',
learning_content: contentSnippet,
},
};
}
/** /**
* Conversation hook for R42. * Conversation hook for R42.
* Owns the message list, persists to chat:thread:{userId}, calls Anthropic, * Owns the message list, persists to chat:thread:{userId}, calls Anthropic
* and surfaces validated graph delta suggestions inline. * via the shared `callLLM` client (with a `lookup_topic` multi-hop loop and
* * the `propose_graph_delta` tool), and surfaces validated graph delta
* Note: buildKbContext is async (reads PocketBase), so send() is fully async. * suggestions inline.
*/ */
export function useChat({ user, isAdmin }) { export function useChat({ user, isAdmin }) {
const threadKey = user ? `chat:thread:${user.id}` : null; const threadKey = user ? `chat:thread:${user.id}` : null;
@@ -20,7 +62,6 @@ export function useChat({ user, isAdmin }) {
const [errored, setErrored] = useState(false); const [errored, setErrored] = useState(false);
const seenDeltaKeys = useRef(new Set()); const seenDeltaKeys = useRef(new Set());
// Load persisted thread + seed greeting
useEffect(() => { useEffect(() => {
if (!user) return; if (!user) return;
const stored = storage.get(threadKey, null); const stored = storage.get(threadKey, null);
@@ -41,7 +82,6 @@ export function useChat({ user, isAdmin }) {
} }
}, [user, threadKey]); }, [user, threadKey]);
// Persist on change
useEffect(() => { useEffect(() => {
if (!threadKey) return; if (!threadKey) return;
const capped = messages.slice(-MAX_HISTORY); const capped = messages.slice(-MAX_HISTORY);
@@ -68,29 +108,43 @@ export function useChat({ user, isAdmin }) {
setErrored(false); setErrored(false);
try { try {
const { context: kbContext, topics: kbTopics } = await buildKbContext(trimmed); const { context: kbContext, allTopics } = await buildKbContext(trimmed);
const systemPrompt = buildSystemPrompt({ const systemPrompt = buildSystemPrompt({
userName: user.name || 'daar', userName: user.name || 'daar',
isAdmin, isAdmin,
kbContext, kbContext,
}); });
// Strip UI-only fields before sending to Anthropic const historyMessages = next
const apiMessages = next
.filter(m => m.role === 'user' || m.role === 'assistant') .filter(m => m.role === 'user' || m.role === 'assistant')
.map(m => ({ role: m.role, content: m.content })); .map(m => ({ role: m.role, content: m.content }));
const response = await anthropicApi.chat(systemPrompt, apiMessages, { const apiMessages = truncateApiMessages(historyMessages);
tools: [PROPOSE_GRAPH_DELTA_TOOL],
});
let textOut = ''; let textOut = '';
let suggestion = null; let suggestion = null;
for (const block of response.content || []) { let hops = 0;
if (block.type === 'text') {
textOut += (textOut ? '\n' : '') + (block.text || ''); while (true) {
} else if (block.type === 'tool_use' && block.name === PROPOSE_GRAPH_DELTA_TOOL.name) { const response = await callLLM({
const validated = validateDelta(block.input, kbTopics); task: 'chat.r42',
tier: 'standard',
system: systemPrompt,
messages: apiMessages,
tools: [LOOKUP_TOPIC_TOOL, PROPOSE_GRAPH_DELTA_TOOL],
maxTokens: 2048,
temperature: 0.3,
});
if (response.text) {
textOut += (textOut ? '\n' : '') + response.text;
}
const lookupCalls = response.toolUses.filter(tu => tu.name === LOOKUP_TOPIC_TOOL.name);
const deltaCall = response.toolUses.find(tu => tu.name === PROPOSE_GRAPH_DELTA_TOOL.name);
if (deltaCall && !suggestion) {
const validated = validateDelta(deltaCall.input, allTopics);
if (validated) { if (validated) {
const key = deltaKey(validated); const key = deltaKey(validated);
if (!seenDeltaKeys.current.has(key)) { if (!seenDeltaKeys.current.has(key)) {
@@ -99,6 +153,33 @@ export function useChat({ user, isAdmin }) {
} }
} }
} }
if (lookupCalls.length === 0 || hops >= MAX_LOOKUP_HOPS) break;
hops++;
const assistantBlocks = [];
if (response.text) assistantBlocks.push({ type: 'text', text: response.text });
for (const tu of response.toolUses) {
if (!tu.id) continue;
assistantBlocks.push({ type: 'tool_use', id: tu.id, name: tu.name, input: tu.input });
}
apiMessages.push({ role: 'assistant', content: assistantBlocks });
const toolResults = [];
for (const tu of lookupCalls) {
if (!tu.id) continue;
const topicId = String(tu.input?.id || '').trim();
const resolved = await resolveLookupTopic(topicId, allTopics);
toolResults.push({
type: 'tool_result',
tool_use_id: tu.id,
content: typeof resolved.payload === 'string'
? resolved.payload
: JSON.stringify(resolved.payload),
...(resolved.ok ? {} : { is_error: true }),
});
}
apiMessages.push({ role: 'user', content: toolResults });
} }
const assistantMsg = { const assistantMsg = {

View File

@@ -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', () => {

View 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);
}
});
});

View File

@@ -0,0 +1,58 @@
import { describe, expect, it } from 'vitest';
import { buildIndex, retrieveTopK, tokenize } from '../retrieval';
const sampleTopics = [
{ id: 'software-engineer', label: 'Software Engineer', description: 'Bouwt en onderhoudt applicaties; werkt in agile teams.' },
{ id: 'onboarding-buddy', label: 'Onboarding Buddy', description: 'Begeleidt nieuwe medewerkers in hun eerste weken.' },
{ id: 'kennisbeheer', label: 'Kennisbeheer', description: 'Het proces van het vastleggen en ontsluiten van organisatiekennis.' },
{ id: 'wekelijkse-sessie', label: 'Wekelijkse Leersessie', description: 'Microlearning sessie waarin medewerkers wekelijks leren via AI-gegenereerde quizzen.' },
];
describe('tokenize', () => {
it('lowercases and splits on non-alphanumeric', () => {
expect(tokenize('Hello, World!')).toEqual(['hello', 'world']);
});
it('drops stopwords and short tokens', () => {
expect(tokenize('de software engineer is hier')).toEqual(['software', 'engineer', 'hier']);
});
it('keeps hyphenated identifiers', () => {
expect(tokenize('software-engineer onboarding-buddy')).toEqual(['software-engineer', 'onboarding-buddy']);
});
});
describe('buildIndex / retrieveTopK', () => {
it('returns empty for empty topics', () => {
const idx = buildIndex([]);
expect(retrieveTopK(idx, 'anything')).toEqual([]);
});
it('returns empty for empty query', () => {
const idx = buildIndex(sampleTopics);
expect(retrieveTopK(idx, '')).toEqual([]);
});
it('ranks the most relevant topic first', () => {
const idx = buildIndex(sampleTopics);
const hits = retrieveTopK(idx, 'wat doet een onboarding buddy?', 2);
expect(hits[0].id).toBe('onboarding-buddy');
});
it('matches on description when label does not contain query terms', () => {
const idx = buildIndex(sampleTopics);
const hits = retrieveTopK(idx, 'microlearning quizzen', 3);
expect(hits.map(h => h.id)).toContain('wekelijkse-sessie');
});
it('returns no hits when no terms match', () => {
const idx = buildIndex(sampleTopics);
expect(retrieveTopK(idx, 'kwantumfysica raketten')).toEqual([]);
});
it('caches the index per topics array reference', () => {
const idx1 = buildIndex(sampleTopics);
const idx2 = buildIndex(sampleTopics);
expect(idx1).toBe(idx2);
});
});

View 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));
});
});

View File

@@ -90,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 []; }
} }
@@ -322,6 +327,17 @@ export async function bulkSetCurriculum(year, weeks) {
); );
} }
// ── LLM Call Telemetry ───────────────────────────────────────────────────────
export async function getRecentLlmCalls(limit = 100) {
try {
const r = await pb.collection('llm_calls').getList(1, limit, { sort: '-created' });
return r.items;
} catch {
return [];
}
}
// ── Handbook Sync State ─────────────────────────────────────────────────────── // ── Handbook Sync State ───────────────────────────────────────────────────────
export async function getHandbookSyncStates() { export async function getHandbookSyncStates() {

View File

@@ -154,6 +154,28 @@ export async function deleteCachedContent(topicId) {
return db.deleteContent(topicId); return db.deleteContent(topicId);
} }
function slugify(label) {
const base = String(label || '')
.toLowerCase()
.normalize('NFKD')
.replace(/\p{Diacritic}/gu, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
return base || 'topic';
}
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) { export async function generateCustomTopic(label) {
const result = await callLLM({ const result = await callLLM({
task: 'topic.custom', task: 'topic.custom',
@@ -168,7 +190,12 @@ export async function generateCustomTopic(label) {
const emitted = result.toolUses[0]?.input; const emitted = result.toolUses[0]?.input;
if (!emitted) throw new Error('Could not process custom topic. Please try again.'); if (!emitted) throw new Error('Could not process custom topic. Please try again.');
const newTopic = { ...emitted, id: 'custom_' + Date.now().toString(36) }; 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;
} }

View File

@@ -236,7 +236,7 @@ function extractToolUses(content) {
if (!Array.isArray(content)) return []; if (!Array.isArray(content)) return [];
return content return content
.filter((b) => b?.type === 'tool_use') .filter((b) => b?.type === 'tool_use')
.map((b) => ({ name: b.name, input: b.input })); .map((b) => ({ id: b.id, name: b.name, input: b.input }));
} }
function extractText(content) { function extractText(content) {

View File

@@ -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({

View File

@@ -205,6 +205,8 @@ export const EMIT_CUSTOM_TOPIC_TOOL = {
}, },
}; };
const QUIZ_DIFFICULTIES = ['easy', 'medium', 'hard'];
const quizQuestionSchema = { const quizQuestionSchema = {
type: 'object', type: 'object',
properties: { properties: {
@@ -214,8 +216,9 @@ const quizQuestionSchema = {
options: { type: 'array', items: { type: 'string' }, minItems: 4, maxItems: 4 }, options: { type: 'array', items: { type: 'string' }, minItems: 4, maxItems: 4 },
correctIndex: { type: 'integer', minimum: 0, maximum: 3 }, correctIndex: { type: 'integer', minimum: 0, maximum: 3 },
explanation: { type: 'string', description: 'Why the correct answer is correct (12 sentences).' }, explanation: { type: 'string', description: 'Why the correct answer is correct (12 sentences).' },
difficulty: { type: 'string', enum: QUIZ_DIFFICULTIES, description: 'Per-question difficulty tag.' },
}, },
required: ['id', 'question', 'topicLabel', 'options', 'correctIndex', 'explanation'], required: ['id', 'question', 'topicLabel', 'options', 'correctIndex', 'explanation', 'difficulty'],
}; };
export const EMIT_QUIZ_QUESTIONS_TOOL = { export const EMIT_QUIZ_QUESTIONS_TOOL = {

29
src/lib/random.js Normal file
View 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));
}

95
src/lib/retrieval.js Normal file
View File

@@ -0,0 +1,95 @@
/**
* Lightweight, dependency-free TF-IDF retrieval over the knowledge graph.
*
* `buildIndex(topics)` tokenises the `label + description` of each topic and
* computes document-frequency stats so queries can be scored with TF-IDF in
* `retrieveTopK`. The index is cached against the `topics` array reference,
* so repeated calls with the same array don't rebuild.
*
* Tokeniser: lowercase, split on `[^a-zA-Z0-9-]`, drop short tokens and a
* small Dutch/English stopword list.
*/
const STOPWORDS = new Set([
// English
'a', 'an', 'and', 'are', 'as', 'at', 'be', 'by', 'for', 'from', 'has', 'have',
'how', 'i', 'in', 'is', 'it', 'its', 'of', 'on', 'or', 'than', 'that', 'the',
'this', 'to', 'was', 'were', 'what', 'when', 'where', 'which', 'who', 'why',
'with', 'you', 'your', 'do', 'does', 'did',
// Dutch
'de', 'het', 'een', 'en', 'of', 'in', 'op', 'aan', 'bij', 'voor', 'naar',
'met', 'uit', 'om', 'door', 'over', 'tegen', 'ook', 'er', 'is', 'zijn',
'was', 'waren', 'wat', 'wie', 'hoe', 'waar', 'wanneer', 'welke', 'die',
'dat', 'deze', 'dit', 'ik', 'jij', 'hij', 'zij', 'we', 'wij', 'jullie',
'als', 'dan', 'maar', 'want', 'omdat', 'niet', 'wel', 'heeft', 'hebben',
'word', 'wordt', 'worden', 'kan', 'kunnen', 'mag', 'moet', 'moeten',
'zal', 'zou', 'zouden', 'al', 'ook', 'nog', 'naar',
]);
export function tokenize(text) {
if (!text) return [];
return String(text)
.toLowerCase()
.split(/[^a-z0-9-]+/i)
.filter(t => t.length >= 2 && !STOPWORDS.has(t));
}
const indexCache = new WeakMap();
export function buildIndex(topics) {
if (!Array.isArray(topics) || topics.length === 0) {
return { topics: [], docFreq: new Map(), termsByDoc: [], N: 0 };
}
const cached = indexCache.get(topics);
if (cached) return cached;
const termsByDoc = topics.map(t => {
const text = `${t.label || ''} ${t.description || ''}`;
const tokens = tokenize(text);
const tf = new Map();
for (const tk of tokens) tf.set(tk, (tf.get(tk) || 0) + 1);
return tf;
});
const docFreq = new Map();
for (const tf of termsByDoc) {
for (const term of tf.keys()) {
docFreq.set(term, (docFreq.get(term) || 0) + 1);
}
}
const index = { topics, docFreq, termsByDoc, N: topics.length };
indexCache.set(topics, index);
return index;
}
export function retrieveTopK(index, query, k = 10) {
if (!index || !index.N || !query) return [];
const qTokens = tokenize(query);
if (qTokens.length === 0) return [];
const idf = (term) => {
const df = index.docFreq.get(term) || 0;
if (df === 0) return 0;
return Math.log((index.N + 1) / (df + 1)) + 1;
};
const scores = new Array(index.N);
for (let i = 0; i < index.N; i++) {
const tf = index.termsByDoc[i];
let s = 0;
for (const t of qTokens) {
const f = tf.get(t);
if (!f) continue;
s += (1 + Math.log(f)) * idf(t);
}
scores[i] = s;
}
const ranked = [];
for (let i = 0; i < index.N; i++) {
if (scores[i] > 0) ranked.push({ i, s: scores[i] });
}
ranked.sort((a, b) => b.s - a.s);
return ranked.slice(0, k).map(r => index.topics[r.i]);
}

View File

@@ -2,81 +2,73 @@ import * as db from './db';
import { callLLM } from './llm'; import { callLLM } from './llm';
import { EMIT_QUIZ_QUESTIONS_TOOL } from './llmTools'; 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.
Emit questions through the emit_quiz_questions tool. Each question has exactly four options; correctIndex is 0-based; mix difficulty roughly 4 easy / 4 medium / 2 hard.`; 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' } }]; const cachedSystem = (text) => [{ type: 'text', text, cache_control: { type: 'ephemeral' } }];
async function selectTestTopics(userId, weekNumber) { function normalizeQuestionText(text) {
const allTopics = await db.getTopics(); return String(text || '')
const topics = allTopics.filter(t => t.type !== 'fact' && t.learning_relevance !== 'exclude'); .toLowerCase()
if (!topics || topics.length === 0) return { primaryTopic: null, reviewTopics: [], isReviewWeek: false }; .replace(/[\p{P}\p{S}]/gu, ' ')
.replace(/\s+/g, ' ')
// Try curriculum-based selection first .trim();
try {
const { topic, curriculumEntry } = await getCurriculumTopic(weekNumber);
if (curriculumEntry?.is_review_week) {
// Review week: pull topics from the whole quarter
const quarter = getQuarterForWeek(weekNumber);
const curriculum = await db.getCurriculum(new Date().getFullYear());
const quarterTopicIds = curriculum
.filter(w => w.quarter === quarter && w.topic_id && !w.is_review_week)
.map(w => w.topic_id);
const quarterTopics = topics.filter(t => quarterTopicIds.includes(t.id));
// Use all quarter topics as review topics (no single primary)
return {
primaryTopic: quarterTopics[0] || topics[0],
reviewTopics: quarterTopics.slice(1),
isReviewWeek: true,
};
}
if (topic) {
const others = topics.filter(t => t.id !== topic.id);
const shuffled = others.sort(() => 0.5 - Math.random());
const reviewTopics = shuffled.slice(0, Math.min(5, shuffled.length));
return { primaryTopic: topic, reviewTopics, isReviewWeek: false };
}
} catch (e) {
console.warn('[Test] Curriculum lookup failed, falling back to hash:', e.message);
}
// Fallback: hash-based selection
const str = `${userId}:${weekNumber}`;
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = (hash << 5) - hash + str.charCodeAt(i);
hash |= 0;
}
const primaryIndex = Math.abs(hash) % topics.length;
const primaryTopic = topics[primaryIndex];
const others = topics.filter((_, i) => i !== primaryIndex);
const shuffled = others.sort(() => 0.5 - Math.random());
const reviewTopics = shuffled.slice(0, Math.min(5, shuffled.length));
return { primaryTopic, reviewTopics, isReviewWeek: false };
} }
export async function getCachedQuiz(userId, weekNumber) { function dominantCorrectIndex(questions) {
return db.getCachedQuiz(userId, weekNumber); 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;
} }
export async function forceGenerateTopicQuestions(topic, count = 10) { function validateBatchQuality(questions) {
let bank = await db.getQuizBank(topic.id); 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: const prompt = `Generate exactly ${count} multiple-choice quiz questions for this knowledge topic and emit them via the emit_quiz_questions tool:
Topic: ${topic.label} Topic: ${topic.label}
Type: ${topic.type} Type: ${topic.type}
Description: ${topic.description} Description: ${topic.description}
Options must be prefixed "A) ", "B) ", "C) ", "D) ". Make questions specific and practical, not trivial.`; 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({ const result = await callLLM({
task: 'quiz.generate', task: 'quiz.generate',
@@ -89,28 +81,125 @@ Options must be prefixed "A) ", "B) ", "C) ", "D) ". Make questions specific and
}); });
const emitted = result.toolUses[0]?.input; const emitted = result.toolUses[0]?.input;
if (!emitted) throw new Error(`Could not generate questions for ${topic.label}`); if (!emitted?.questions?.length) {
throw new Error(`Could not generate questions for ${topic.label}`);
}
return emitted.questions;
}
const newQuestions = (emitted.questions || []).map(q => ({ async function selectTestTopics(userId, weekNumber) {
const allTopics = await db.getTopics();
const topics = allTopics.filter(t => t.type !== 'fact' && t.learning_relevance !== 'exclude');
if (!topics || topics.length === 0) return { primaryTopic: null, reviewTopics: [], isReviewWeek: false };
try {
const { topic, curriculumEntry } = await getCurriculumTopic(weekNumber);
if (curriculumEntry?.is_review_week) {
const quarter = getQuarterForWeek(weekNumber);
const curriculum = await db.getCurriculum(new Date().getFullYear());
const quarterTopicIds = curriculum
.filter(w => w.quarter === quarter && w.topic_id && !w.is_review_week)
.map(w => w.topic_id);
const quarterTopics = topics.filter(t => quarterTopicIds.includes(t.id));
return {
primaryTopic: quarterTopics[0] || topics[0],
reviewTopics: quarterTopics.slice(1),
isReviewWeek: true,
};
}
if (topic) {
const others = topics.filter(t => t.id !== topic.id);
const reviewTopics = sample(others, Math.min(5, others.length));
return { primaryTopic: topic, reviewTopics, isReviewWeek: false };
}
} catch (e) {
console.warn('[Test] Curriculum lookup failed, falling back to hash:', e.message);
}
const str = `${userId}:${weekNumber}`;
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = (hash << 5) - hash + str.charCodeAt(i);
hash |= 0;
}
const primaryIndex = Math.abs(hash) % topics.length;
const primaryTopic = topics[primaryIndex];
const others = topics.filter((_, i) => i !== primaryIndex);
const reviewTopics = sample(others, Math.min(5, others.length));
return { primaryTopic, reviewTopics, isReviewWeek: false };
}
export async function getCachedQuiz(userId, weekNumber) {
return db.getCachedQuiz(userId, weekNumber);
}
export async function forceGenerateTopicQuestions(topic, count = 5) {
const existingBank = await db.getQuizBank(topic.id);
const existingKeys = new Set(existingBank.map((q) => normalizeQuestionText(q.question)));
let lastQualityError = null;
let candidates = null;
for (let attempt = 0; attempt < 3; attempt++) {
const questions = await callQuizModel(topic, count);
const qualityError = validateBatchQuality(questions);
if (qualityError) {
lastQualityError = qualityError;
console.warn(`[quiz] batch rejected (attempt ${attempt + 1}): ${qualityError}`);
continue;
}
const dominant = dominantCorrectIndex(questions);
if (dominant && attempt < 2) {
console.warn(`[quiz] correctIndex dominated by ${dominant.index} (${Math.round(dominant.ratio * 100)}%) — re-rolling`);
continue;
}
candidates = questions;
break;
}
if (!candidates) {
throw new Error(`Quality gate rejected the generated batch for ${topic.label}: ${lastQualityError || 'unbalanced answer distribution'}. Click "Generate" to try again.`);
}
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, ...q,
id: `${topic.id}-${Math.random().toString(36).slice(2, 11)}`, id: `${topic.id}-${Math.random().toString(36).slice(2, 11)}`,
})); });
}
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) {
@@ -151,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,

View File

@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { Database, FileText, Settings, Users, Network, Clock, CheckCircle2, AlertCircle, Save, Info, Layers, CheckSquare, CalendarDays } from 'lucide-react'; import { Database, FileText, Settings, Users, Network, Clock, CheckCircle2, AlertCircle, Save, Info, Layers, CheckSquare, CalendarDays, Activity } from 'lucide-react';
import Card from '../../components/ui/Card'; import Card from '../../components/ui/Card';
import Tag from '../../components/ui/Tag'; import Tag from '../../components/ui/Tag';
import Button from '../../components/ui/Button'; import Button from '../../components/ui/Button';
@@ -12,6 +12,7 @@ import ContentManager from '../../components/admin/ContentManager';
import TestManager from '../../components/admin/TestManager'; import TestManager from '../../components/admin/TestManager';
import TeamManager from '../../components/admin/TeamManager'; import TeamManager from '../../components/admin/TeamManager';
import CurriculumManager from '../../components/admin/CurriculumManager'; import CurriculumManager from '../../components/admin/CurriculumManager';
import Diagnostics from '../../components/admin/Diagnostics';
import { Trash2 } from 'lucide-react'; import { Trash2 } from 'lucide-react';
const TIER_PLACEHOLDERS = { const TIER_PLACEHOLDERS = {
@@ -71,6 +72,7 @@ const Admin = () => {
{ key: 'curriculum', icon: CalendarDays, label: 'Curriculum' }, { key: 'curriculum', icon: CalendarDays, label: 'Curriculum' },
{ key: 'graph', icon: Network, label: 'Graph' }, { key: 'graph', icon: Network, label: 'Graph' },
{ key: 'team', icon: Users, label: 'Team' }, { key: 'team', icon: Users, label: 'Team' },
{ key: 'diagnostics', icon: Activity, label: 'Diagnostics' },
{ key: 'settings', icon: Settings, label: 'Settings', bottom: true }, { key: 'settings', icon: Settings, label: 'Settings', bottom: true },
]; ];
@@ -181,6 +183,14 @@ const Admin = () => {
</div> </div>
)} )}
{activeTab === 'diagnostics' && (
<div className="animate-in fade-in duration-300 max-w-6xl mx-auto">
<h1 className="text-3xl text-teal mb-2">Diagnostics</h1>
<p className="text-fg-muted mb-8">LLM-aanroepen, tokenverbruik en geschatte kosten.</p>
<Diagnostics />
</div>
)}
{activeTab === 'settings' && ( {activeTab === 'settings' && (
<div className="animate-in fade-in duration-300 max-w-2xl"> <div className="animate-in fade-in duration-300 max-w-2xl">
<h1 className="text-3xl text-teal mb-2">Settings</h1> <h1 className="text-3xl text-teal mb-2">Settings</h1>