Compare commits

...

12 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
RaymondVerhoef
aeb197d5f4 feat: phase 3 of AI pipeline hardening — extraction 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 1m32s
Replace stateless one-shot extraction with a stateful, paced, cancellable
pipeline. Six subtasks:

- 3.1 Sentence-aware chunking with 800-char overlap (was paragraph-only
  at 4000 chars). Hard-split fallback for runaway sentences.
- 3.2 Stateful extraction: chunks 2+ receive an "already-extracted topic
  IDs" hint capped at 200 IDs, so the model reuses IDs instead of
  inventing variants like software-developer vs software-engineer.
- 3.3 Token-bucket limiter in llmRetry.js (extractionLimiter, 5 req/min).
  callLLM awaits the limiter before fetch; 429+Retry-After calls
  pauseUntil. Replaces hard setTimeout(12000) and setTimeout(15000).
- 3.4 relevance_locked column on topics — admin edits to relevance are
  sticky across re-extraction. Migration + merge respects the flag +
  unlock checkbox in KnowledgeGraph edit form.
- 3.5 Unify relation vocabulary — handbook prompt no longer mentions
  legacy "executes"; one-shot migration rewrites existing executes rows
  to executed_by with source/target swapped.
- 3.6 Cancellation — Cancel button on UploadZone wired to an
  AbortController threaded into callLLM; aborted runs persist status =
  "cancelled" rather than "failed".

Tests: 16 new unit tests for chunkText, buildKnownIdsHint, and
createLimiter. All 61 tests pass, 0 lint errors, build clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 17:56:45 +02:00
9771928926 Merge pull request 'Fix: exclude temperature parameter for reasoning-tier models' (#4) from feat/ai-pipeline-hardening-phase-2 into main
All checks were successful
On Push to Main / test (push) Successful in 25s
On Push to Main / publish (push) Successful in 57s
On Push to Main / deploy-dev (push) Successful in 1m33s
Reviewed-on: #4
2026-05-20 15:18:00 +00:00
RaymondVerhoef
40eff976b4 Fix: exclude temperature parameter for reasoning-tier models
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 1m3s
On Pull Request to Main / deploy-dev (pull_request) Successful in 1m33s
Anthropic has deprecated the temperature parameter for their reasoning
models (claude-opus-4-7). This was causing a 400 error when analyzeGraph
called callLLM with tier: 'reasoning'.

Solution: conditionally exclude temperature from the request body when
tier === 'reasoning'. Fast and standard tiers retain their temperature
parameter.

This unblocks the "Analyse and Optimize" button in the Knowledge Graph
admin panel post-Phase-2 deployment.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 17:14:17 +02:00
33529dfb2b Merge pull request 'feat: phase 2 of AI pipeline hardening — tool-based structured outputs + prompt caching' (#3) from feat/ai-pipeline-hardening-phase-2 into main
All checks were successful
On Push to Main / test (push) Successful in 25s
On Push to Main / publish (push) Successful in 58s
On Push to Main / deploy-dev (push) Successful in 1m32s
Reviewed-on: #3
2026-05-20 13:48:08 +00:00
RaymondVerhoef
f838755991 feat: phase 2 of AI pipeline hardening — tool-based structured outputs + prompt caching
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
Every structured-output call now uses an Anthropic tool instead of
parsing JSON out of free-form prose, and stable system prompts are
sent as cacheable blocks. Behaviour-equivalent to phase 1 from the
caller's point of view; the savings show up in token usage and in the
absence of "AI returned non-JSON response" failure modes.

* src/lib/llmTools.js — single source of truth for tool definitions:
  emit_knowledge_graph, emit_handbook_delta, emit_learning_article /
  _slides / _infographic / _all, emit_custom_topic, emit_quiz_questions,
  emit_graph_actions, plus five article-patch tools (set_intro,
  set_section, add_section, remove_section, replace_takeaways).
* src/lib/articlePatches.js — pure applyArticlePatches +
  applyAndValidate; rebuilds the article from a sequence of patch tool
  calls and re-validates against learningArticleSchema. set_section
  falls back to appending when no matching heading exists so the
  model's intent is preserved rather than silently dropped.
* src/lib/llmSchemas.js — Zod schemas for the five patch ops,
  registered in toolSchemaRegistry so callLLM validates them
  automatically.
* src/lib/llm.js — simulation mode now returns a tool_use stub matching
  toolChoice.name, so the UI keeps working with Simulation Mode on
  after the structured-output migration.
* src/lib/extractionPipeline.js — processSourceText and
  analyzeHandbookDelta migrated to callLLM + tool use. System prompts
  sent as { cache_control: ephemeral } blocks. Handbook results pass
  through normalizeHandbookResult to collapse legacy "executes"
  relations into executed_by with swapped source/target.
* src/lib/learningService.js — generateLearningContent picks the right
  tool per selectedType; generateCustomTopic uses emit_custom_topic;
  refineLearningContent now drives the five patch tools with
  toolChoice 'any' and rejects the whole turn if the patched article
  fails validation. Article-only refinement is intentional for phase 2;
  refining a topic without an article surfaces a clear error.
* src/lib/testService.js — quiz generation via emit_quiz_questions.
* src/components/admin/KnowledgeGraph.jsx — analyzeGraph routed through
  the reasoning tier (Opus) since graph-wide consolidation benefits
  from a stronger reasoner.
* src/components/chat/prompts.js — buildSystemPrompt now returns three
  text blocks: stable preamble (cached), KB context (cached, hash-bust
  deferred to phase 5), per-turn user/admin tail (uncached).
* src/lib/__tests__/ — 13 new tests covering each patch op, multi-op
  sequencing, post-patch validation failure, and tool/registry shape.

Acceptance: lint and 45/45 tests green; build succeeds; no
`match(/\{[\s\S]*\}/)` JSON extraction left in src/. Live verification
of cache hits on a second extraction within 5 minutes is deferred to
manual smoke testing — needs real `/api/anthropic` traffic.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 15:47:20 +02:00
RaymondVerhoef
8a8745fad2 feat: show build SHA + timestamp in a small footer for deploy verification
All checks were successful
On Push to Main / test (push) Successful in 30s
On Push to Main / publish (push) Successful in 59s
On Push to Main / deploy-dev (push) Successful in 1m33s
Adds a BuildStamp component pinned to the bottom-right of every page
(desktop only, dim monospace text) so it's obvious at a glance which
build is currently running.

The git short SHA and an ISO build timestamp are injected at build time
via Vite's `define`, falling back to 'unknown' if git is not available.
ESLint config declares the two compile-time constants as readonly
globals so no per-file disable comments are needed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 15:20:46 +02:00
a5c18ccd0f Merge pull request 'feat/ai-pipeline-hardening-plan' (#2) from feat/ai-pipeline-hardening-plan into main
All checks were successful
On Push to Main / test (push) Successful in 25s
On Push to Main / publish (push) Successful in 59s
On Push to Main / deploy-dev (push) Successful in 1m33s
Reviewed-on: #2
2026-05-20 12:50:34 +00:00
d07d15b2a7 Merge pull request 'docs: AI pipeline hardening plan + rename giteaService -> githubService' (#1) from feat/ai-pipeline-hardening-plan into main
All checks were successful
On Push to Main / test (push) Successful in 26s
On Push to Main / publish (push) Successful in 59s
On Push to Main / deploy-dev (push) Successful in 1m40s
Reviewed-on: #1
2026-05-20 10:09:39 +00:00
35 changed files with 2544 additions and 425 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.
> **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
@@ -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.

View File

@@ -14,7 +14,11 @@ export default defineConfig([
reactRefresh.configs.vite,
],
languageOptions: {
globals: globals.browser,
globals: {
...globals.browser,
__BUILD_SHA__: 'readonly',
__BUILD_TIME__: 'readonly',
},
parserOptions: { ecmaFeatures: { jsx: true } },
},
rules: {

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

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

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

@@ -2,6 +2,7 @@ import { Routes, Route, Navigate, Link } from 'react-router-dom'
import { BookOpen, CheckSquare, LayoutDashboard, Trophy, Settings, LogOut } from 'lucide-react'
import { useApp } from './store/AppContext'
import Mark from './components/ui/Mark'
import BuildStamp from './components/ui/BuildStamp'
import ChatLauncher from './components/chat/ChatLauncher'
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>
return (
<>
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/" element={<ProtectedRoute><Dashboard /></ProtectedRoute>} />
@@ -96,6 +98,8 @@ function App() {
<Route path="/leaderboard" element={<ProtectedRoute><Leaderboard /></ProtectedRoute>} />
<Route path="/admin/*" element={<ProtectedRoute requireAdmin={true}><Admin /></ProtectedRoute>} />
</Routes>
<BuildStamp />
</>
)
}

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

@@ -2,7 +2,8 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import * as d3 from 'd3';
import { Trash2, Edit2, Save, X, RefreshCw, AlertCircle, Plus, Link as LinkIcon } from 'lucide-react';
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 { getRepoFolder, getFileContent } from '../../lib/githubService';
import Button from '../ui/Button';
@@ -179,10 +180,17 @@ const KnowledgeGraph = () => {
};
const saveEdit = async () => {
await db.upsertTopic({ ...selectedNode, ...editData });
const updated = topics.map(t => t.id === selectedNode.id ? { ...t, ...editData } : t);
// If the admin touched learning_relevance, lock it so re-extraction won't overwrite the choice.
// 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);
setSelectedNode({ ...selectedNode, ...editData });
setSelectedNode({ ...selectedNode, ...next });
setIsEditing(false);
};
@@ -264,22 +272,11 @@ const KnowledgeGraph = () => {
setSyncProgress(`Processing ${count} of ${filesToProcess.length}: ${file.name}...`);
try {
const rawContent = await getFileContent('respellion', 'employee-handbook', file.path);
// Pacing is handled centrally by extractionLimiter inside analyzeHandbookDelta.
await analyzeHandbookDelta(rawContent, file.path);
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) {
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.');
@@ -304,18 +301,18 @@ const KnowledgeGraph = () => {
const currentTopics = await db.getTopics();
const currentRelations = await db.getRelations();
const systemPrompt = `You are a strict Data Quality AI maintaining a Knowledge Graph.
Your goal is to evaluate the provided topics and relations, identify duplicates to merge, useless nodes to delete, and new logical relations to add.
const systemPrompt = `You are a strict Data Quality AI maintaining a Knowledge Graph for Respellion.
Evaluate the provided topics and relations and emit the actions to take via the emit_graph_actions tool.
Rules:
1. Identify topics that mean exactly the same thing. Choose one to keep, and one to delete.
2. Identify topics that are too vague, irrelevant, or malformed to delete.
3. Identify missing logical relations (depends_on, part_of, related_to) if two topics are conceptually linked but missing a relation.
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".
5. Return ONLY a valid JSON object describing the ACTIONS to take. Do not return the entire graph. Do not wrap in markdown blocks.`;
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 (deletions).
3. Identify missing logical relations (depends_on, part_of, related_to, executed_by) between conceptually linked topics (newRelations).
4. Evaluate learning_relevance. Mark purely operational topics (printer guides, etc.) as "exclude"; low-priority as "peripheral" (relevanceUpdates).
Do not return the entire graph — only the actions to take.`;
// 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 compactRelations = currentRelations.map(r => ({
source: r.source?.id || r.source,
@@ -324,21 +321,20 @@ Rules:
}));
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:
{
"merges": [ { "keepId": "id_to_keep", "deleteId": "id_to_delete" } ],
"deletions": [ "id_to_delete_completely" ],
"newRelations": [ { "source": "id1", "target": "id2", "type": "depends_on" } ],
"relevanceUpdates": [ { "id": "topic_id", "learning_relevance": "exclude" } ]
}`;
const llmResult = await callLLM({
task: 'graph.analyze',
tier: 'reasoning',
system: [{ type: 'text', text: systemPrompt, cache_control: { type: 'ephemeral' } }],
user: userPrompt,
tools: [EMIT_GRAPH_ACTIONS_TOOL],
toolChoice: { type: 'tool', name: EMIT_GRAPH_ACTIONS_TOOL.name },
maxTokens: 4096,
});
const responseText = await anthropicApi.generateContent(systemPrompt, userPrompt);
const jsonMatch = responseText.match(/\{[\s\S]*\}/);
if (!jsonMatch) throw new Error('AI returned invalid format.');
const actions = JSON.parse(jsonMatch[0]);
const actions = llmResult.toolUses[0]?.input;
if (!actions) throw new Error('Graph analysis did not emit a tool result.');
let updatedTopics = [...currentTopics];
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)) {
for (const update of actions.relevanceUpdates) {
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 };
}
}
@@ -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="exclude">Exclude</option>
</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>
<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>
<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 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>

View File

@@ -34,7 +34,7 @@ const TestManager = () => {
loadData();
}, [selectedTopic]);
const handleGenerate = async (topic, count = 10) => {
const handleGenerate = async (topic, count = 5) => {
setLoadingTopicId(topic.id);
setError(null);
try {
@@ -97,8 +97,8 @@ const TestManager = () => {
{questions.length === 0 ? (
<Card className="text-center py-12 text-fg-muted border-dashed border-2">
<p>No questions generated for this topic yet.</p>
<Button className="mt-4" onClick={() => handleGenerate(selectedTopic, 10)} disabled={loadingTopicId === selectedTopic.id}>
Generate 10 Questions
<Button className="mt-4" onClick={() => handleGenerate(selectedTopic, 5)} disabled={loadingTopicId === selectedTopic.id}>
Generate 5 Questions
</Button>
</Card>
) : (

View File

@@ -1,5 +1,5 @@
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 Card from '../ui/Card';
import Button from '../ui/Button';
@@ -12,24 +12,36 @@ const UploadZone = ({ onUploadComplete }) => {
const [status, setStatus] = useState(null);
const fileInputRef = useRef(null);
const abortRef = useRef(null);
// ── File upload (drag & drop / browse) ────────────────────────────────────
const processFile = async (file) => {
setIsProcessing(true);
setStatus(null);
const controller = new AbortController();
abortRef.current = controller;
try {
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}` });
if (onUploadComplete) onUploadComplete();
} catch (error) {
if (error?.name === 'AbortError') {
setStatus({ type: 'error', msg: `Cancelled: ${file.name}` });
} else {
setStatus({ type: 'error', msg: `Error processing file: ${error.message}` });
}
} finally {
abortRef.current = null;
setIsProcessing(false);
}
};
const cancelProcessing = () => {
abortRef.current?.abort(new DOMException('Cancelled by user', 'AbortError'));
};
const handleDragOver = (e) => { e.preventDefault(); setIsDragging(true); };
const handleDragLeave = () => setIsDragging(false);
@@ -80,6 +92,19 @@ const UploadZone = ({ onUploadComplete }) => {
/>
</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 && (
<div className={`p-4 rounded-[var(--r-sm)] flex items-start gap-3 ${

View File

@@ -23,10 +23,9 @@ export const STRINGS = {
openAria: 'Open R42 chatbot',
};
export function buildSystemPrompt({ userName, isAdmin, kbContext }) {
return [
const STABLE_PREAMBLE = [
`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:`,
`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.`,
``,
`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.`,
``,
kbContext,
`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.`,
``,
`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.`,
@@ -45,12 +42,48 @@ export function buildSystemPrompt({ userName, isAdmin, kbContext }) {
`- Houd antwoorden onder de 4 zinnen tenzij de gebruiker om uitleg vraagt.`,
`- Geen markdown-headers; gewone Nederlandse tekst.`,
`- 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');
/**
* 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 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 = {
name: 'propose_graph_delta',
description:

View File

@@ -1,68 +1,119 @@
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.
* Reads topics + relations from PocketBase via db.js.
* Topic-level content is loaded only when a topic id/label appears in the user's message.
* Build a retrieval-scoped KB context. Instead of dumping the whole graph,
* we pick the top-K topics by TF-IDF over `userMessage`, plus any topic
* 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
* for validateDelta without a second round-trip.
* A `[kb_hash: …]` suffix is appended so the Anthropic ephemeral prompt
* 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 = '') {
const [topics, relations] = await Promise.all([
const [allTopics, allRelations] = await Promise.all([
db.getTopics(),
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 {
context: 'KENNISGRAAF: (leeg — er zijn nog geen onderwerpen geëxtraheerd)',
topics: [],
context: `KENNISGRAAF: (leeg — er zijn nog geen onderwerpen geëxtraheerd)\n[kb_hash: ${kbHash}]`,
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);
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 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 = [];
for (const t of topics) {
const idHit = t.id && lowered.includes(t.id.toLowerCase());
const labelHit = t.label && lowered.includes(t.label.toLowerCase());
if (idHit || labelHit) {
for (const id of mentionedIds) {
const t = includedById.get(id);
if (!t) continue;
const content = await db.getContent(t.id).catch(() => null);
if (content) {
if (!content) continue;
let raw;
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);
const snippet = raw.replace(/\s+/g, ' ').trim().slice(0, 1200);
mentionedDeepContent.push(`### ${t.label}\n${snippet}`);
}
}
}
const context = [
`KENNISGRAAF — TOPICS:`,
`KENNISGRAAF — RELEVANTE TOPICS (top ${included.length} van ${allTopics.length}):`,
topicLines.join('\n'),
``,
`KENNISGRAAF — RELATIES:`,
relLines.length ? relLines.join('\n') : '(geen relaties)',
`KENNISGRAAF — RELATIES (binnen deze selectie):`,
relLines.length ? relLines.join('\n') : '(geen relaties binnen deze selectie)',
mentionedDeepContent.length
? `\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');
return { context, topics };
return { context, retrievedTopics: included, allTopics };
}
/**

View File

@@ -1,17 +1,59 @@
import { useCallback, useEffect, useRef, useState } from 'react';
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 { 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 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.
* Owns the message list, persists to chat:thread:{userId}, calls Anthropic,
* and surfaces validated graph delta suggestions inline.
*
* Note: buildKbContext is async (reads PocketBase), so send() is fully async.
* Owns the message list, persists to chat:thread:{userId}, calls Anthropic
* via the shared `callLLM` client (with a `lookup_topic` multi-hop loop and
* the `propose_graph_delta` tool), and surfaces validated graph delta
* suggestions inline.
*/
export function useChat({ user, isAdmin }) {
const threadKey = user ? `chat:thread:${user.id}` : null;
@@ -20,7 +62,6 @@ export function useChat({ user, isAdmin }) {
const [errored, setErrored] = useState(false);
const seenDeltaKeys = useRef(new Set());
// Load persisted thread + seed greeting
useEffect(() => {
if (!user) return;
const stored = storage.get(threadKey, null);
@@ -41,7 +82,6 @@ export function useChat({ user, isAdmin }) {
}
}, [user, threadKey]);
// Persist on change
useEffect(() => {
if (!threadKey) return;
const capped = messages.slice(-MAX_HISTORY);
@@ -68,29 +108,43 @@ export function useChat({ user, isAdmin }) {
setErrored(false);
try {
const { context: kbContext, topics: kbTopics } = await buildKbContext(trimmed);
const { context: kbContext, allTopics } = await buildKbContext(trimmed);
const systemPrompt = buildSystemPrompt({
userName: user.name || 'daar',
isAdmin,
kbContext,
});
// Strip UI-only fields before sending to Anthropic
const apiMessages = next
const historyMessages = next
.filter(m => m.role === 'user' || m.role === 'assistant')
.map(m => ({ role: m.role, content: m.content }));
const response = await anthropicApi.chat(systemPrompt, apiMessages, {
tools: [PROPOSE_GRAPH_DELTA_TOOL],
});
const apiMessages = truncateApiMessages(historyMessages);
let textOut = '';
let suggestion = null;
for (const block of response.content || []) {
if (block.type === 'text') {
textOut += (textOut ? '\n' : '') + (block.text || '');
} else if (block.type === 'tool_use' && block.name === PROPOSE_GRAPH_DELTA_TOOL.name) {
const validated = validateDelta(block.input, kbTopics);
let hops = 0;
while (true) {
const response = await callLLM({
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) {
const key = deltaKey(validated);
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 = {

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

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

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

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

View File

@@ -108,7 +108,7 @@ describe('learning schemas', () => {
});
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({
questions: [
{
@@ -118,10 +118,12 @@ describe('quizQuestionsSchema', () => {
options: ['A', 'B', 'C', 'D'],
correctIndex: 2,
explanation: 'C describes the buddy system best.',
difficulty: 'easy',
},
],
});
expect(parsed.questions[0].options).toHaveLength(4);
expect(parsed.questions[0].difficulty).toBe('easy');
});
it('rejects three options or an out-of-range correctIndex', () => {
@@ -135,6 +137,7 @@ describe('quizQuestionsSchema', () => {
options: ['A', 'B', 'C'],
correctIndex: 0,
explanation: 'e',
difficulty: 'medium',
},
],
}),
@@ -149,11 +152,27 @@ describe('quizQuestionsSchema', () => {
options: ['A', 'B', 'C', 'D'],
correctIndex: 4,
explanation: 'e',
difficulty: 'medium',
},
],
}),
).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', () => {

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

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

80
src/lib/articlePatches.js Normal file
View 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;
}

View File

@@ -18,6 +18,7 @@ export async function saveTopics(topics) {
type: t.type,
description: t.description,
learning_relevance: t.learning_relevance || 'standard',
relevance_locked: t.relevance_locked === true,
}, { requestKey: null });
}
}
@@ -89,10 +90,15 @@ export async function deleteContent(topicId) {
// ── Quiz Banks ───────────────────────────────────────────────────────────────
function normalizeQuizQuestion(q) {
if (!q || typeof q !== 'object') return q;
return q.difficulty ? q : { ...q, difficulty: 'medium' };
}
export async function getQuizBank(topicId) {
try {
const r = await pb.collection('quiz_banks').getFirstListItem(`topic_id="${topicId}"`);
return r.questions || [];
return (r.questions || []).map(normalizeQuizQuestion);
} catch { return []; }
}
@@ -321,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 ───────────────────────────────────────────────────────
export async function getHandbookSyncStates() {

View File

@@ -1,104 +1,157 @@
import { anthropicApi } from './api';
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.
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.
const MAX_KNOWN_IDS_HINT = 200;
/**
* 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:
- You must extract EVERY SINGLE distinct role, process, and concept described or mentioned in the source text.
- DO NOT summarize, skip, truncate, or omit any items.
- If the document contains 29 roles, your JSON topics array must contain exactly 29 role topics.
- Completeness is of paramount importance. Failing to extract all topics will result in loss of critical company knowledge.
- Keep descriptions concise (max 3 sentences) to ensure you have enough output tokens to list everything.
- Extract EVERY SINGLE distinct role, process, and concept described or mentioned in the source text.
- DO NOT summarise, skip, truncate, or omit any items.
- If the document contains 29 roles, the topics array must contain exactly 29 role topics.
- Completeness is paramount. Failing to extract all topics loses critical company knowledge.
- Facts should be integrated into the descriptions of other topics — never extracted as standalone topics.
- Keep descriptions concise (max 3 sentences) so the response fits.
You MUST assign a learning_relevance to each topic:
- "core": Fundamental company knowledge.
- "standard": Normal learning topics.
- "peripheral": Good to know, but low priority.
- "exclude": Pure operational reference material (e.g., printer guides, wifi passwords) that should NEVER be tested.
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".
ALWAYS return a valid JSON object in the following format:
{
"topics": [
{
"id": "a-unique-lowercase-kebab-case-slug-specific-to-this-topic (e.g., 'software-engineer' or 'data-quality-review'). DO NOT use generic IDs like 'role-1' or 'concept-2'.",
"label": "Topic title",
"type": "concept | role | process",
"description": "A concise, clear explanation of max 3 sentences.",
"learning_relevance": "core | standard | peripheral | exclude"
}
],
"relations": [
{
"source": "topic-id-1",
"target": "topic-id-2",
"type": "related_to | depends_on | part_of | executed_by"
}
]
}
Return JSON only. No markdown blocks or other text.`;
Assign a learning_relevance to every topic:
- "core": fundamental company knowledge.
- "standard": normal learning topics.
- "peripheral": good to know, low priority.
- "exclude": pure operational reference (printer guides, wifi passwords) that should never be tested.
const HANDBOOK_SYSTEM_PROMPT = `You are analyzing an update to the Respellion Employee Handbook.
Your task is to identify changes and extract structural knowledge.
Relation types: related_to | depends_on | part_of | executed_by.
`;
CRITICAL INSTRUCTION:
You must explicitly identify and create relations between Roles, Processes, and Concepts.
Every Process must have a Role attached (who does it).
Every Concept must have a relation to a Process or Role.
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 assign a learning_relevance to each topic:
- "core": Fundamental company knowledge.
- "standard": Normal learning topics.
- "peripheral": Good to know, but low priority.
- "exclude": Pure operational reference material (e.g., printer guides, wifi passwords) that should NEVER be tested.
CRITICAL INSTRUCTIONS:
- Every process must have a role attached. Express this as: process --executed_by--> role.
- Every concept must connect to a process or role.
- Mark handbook topics with metadata.source = "github_handbook".
- Assign learning_relevance using the same scale as extraction: core | standard | peripheral | exclude.
Return a JSON object:
{
"topics": [
{ "id": "...", "label": "...", "type": "role | process | concept", "description": "...", "learning_relevance": "standard", "metadata": { "source": "github_handbook" } }
],
"relations": [
{ "source": "role-id", "target": "process-id", "type": "executes | related_to | depends_on | part_of", "description": "Brief metadata about this specific relation" }
]
}
Return JSON only. No markdown blocks or other text.`;
Relation types: related_to | depends_on | part_of | executed_by.
`;
export async function analyzeHandbookDelta(fileContent, filePath) {
const responseText = await anthropicApi.generateContent(HANDBOOK_SYSTEM_PROMPT, `Analyze the following handbook file update (${filePath}):\n\n${fileContent}`);
const cachedSystem = (text) => [{ type: 'text', text, cache_control: { type: 'ephemeral' } }];
let extractedData;
try {
const jsonMatch = responseText.match(/\{[\s\S]*\}/);
const jsonStr = jsonMatch ? jsonMatch[0] : responseText;
extractedData = JSON.parse(jsonStr);
} catch (e) {
console.error('[Pipeline] AI returned non-JSON response for handbook delta:', responseText?.substring(0, 500));
throw new Error(`AI response was not valid JSON. The model responded with: "${responseText?.substring(0, 120)}..."`, { cause: e });
}
export async function analyzeHandbookDelta(fileContent, filePath, { signal } = {}) {
const result = await callLLM({
task: 'extract.handbook',
tier: 'standard',
system: cachedSystem(HANDBOOK_SYSTEM_PROMPT),
user: `Analyze the following handbook file update (${filePath}):\n\n${fileContent}`,
tools: [EMIT_HANDBOOK_DELTA_TOOL],
toolChoice: { type: 'tool', name: EMIT_HANDBOOK_DELTA_TOOL.name },
maxTokens: 8192,
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);
return { success: true, data: extractedData };
}
function chunkText(text, maxChunkSize = 4000) {
const paragraphs = text.split(/\n+/);
const chunks = [];
let currentChunk = '';
for (const para of paragraphs) {
if ((currentChunk + '\n' + para).length > maxChunkSize) {
if (currentChunk) chunks.push(currentChunk.trim());
currentChunk = para;
} else {
currentChunk = currentChunk ? currentChunk + '\n' + para : para;
/**
* Sentence-aware chunker with overlap.
*
* Targets ~2000 input tokens per chunk (`MAX_CHUNK_CHARS / 4`). Splits on
* sentence boundaries first, then falls back to paragraph boundaries, and
* 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;
}
export async function processSourceText(textContent, sourceName) {
// Deduplicate: skip if a source with the same name was already successfully processed
function splitIntoChunkableUnits(text, maxChars) {
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 alreadyDone = existing.find(
s => s.name === sourceName && s.status === 'completed'
@@ -111,51 +164,54 @@ export async function processSourceText(textContent, sourceName) {
const sourceId = rec.id;
try {
const chunks = chunkText(textContent, 4000);
const chunks = chunkText(textContent);
console.log(`[Pipeline] Split "${sourceName}" into ${chunks.length} chunks for processing.`);
let allExtractedTopics = [];
let allExtractedRelations = [];
const existingTopics = await db.getTopics();
const knownIds = existingTopics.map((t) => t.id);
const allExtractedTopics = [];
const allExtractedRelations = [];
for (let i = 0; i < chunks.length; i++) {
if (i > 0) {
console.log(`[Pipeline] Pacing delay (12s) to prevent rate limits before chunk ${i + 1}/${chunks.length}...`);
await new Promise(r => setTimeout(r, 12000));
}
if (signal?.aborted) throw signal.reason ?? new DOMException('Aborted', 'AbortError');
console.log(`[Pipeline] Processing chunk ${i + 1}/${chunks.length} (${chunks[i].length} chars)...`);
const responseText = await anthropicApi.generateContent(
SYSTEM_PROMPT,
`Analyze this part of the document (${i + 1}/${chunks.length}):\n\n${chunks[i]}`
);
console.log(`[Pipeline] Raw AI response for chunk ${i + 1}:`, responseText);
const hint = i > 0 ? buildKnownIdsHint(knownIds) : '';
const result = await callLLM({
task: 'extract.source',
tier: 'standard',
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;
try {
const jsonMatch = responseText.match(/\{[\s\S]*\}/);
const jsonStr = jsonMatch ? jsonMatch[0] : responseText;
extractedData = JSON.parse(jsonStr);
} catch (e) {
console.error(`[Pipeline] AI returned non-JSON response for chunk ${i + 1}:`, responseText?.substring(0, 500));
throw new Error(`AI response for chunk ${i + 1} was not valid JSON.`, { cause: e });
}
const extractedData = result.toolUses[0]?.input;
if (!extractedData) throw new Error(`Extraction did not emit a tool result for chunk ${i + 1}.`);
if (extractedData.topics && Array.isArray(extractedData.topics)) {
if (Array.isArray(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);
}
}
// Merge everything together
await mergeKnowledgeGraph({ topics: allExtractedTopics, relations: allExtractedRelations });
await db.updateSourceStatus(sourceId, 'completed');
return { success: true, data: { topics: allExtractedTopics, relations: allExtractedRelations } };
} catch (error) {
await db.updateSourceStatus(sourceId, 'failed', error.message);
const isAbort = error?.name === 'AbortError';
await db.updateSourceStatus(sourceId, isAbort ? 'cancelled' : 'failed', isAbort ? 'cancelled by user' : error.message);
throw error;
}
}
@@ -169,14 +225,17 @@ async function mergeKnowledgeGraph(newData) {
if (newData.topics && Array.isArray(newData.topics)) {
for (const t of newData.topics) {
if (topicsMap.has(t.id)) {
// Upsert: merge new data into existing topic
const existing = topicsMap.get(t.id);
topicsMap.set(t.id, {
const merged = {
...existing,
...t,
// Keep existing description if new one is empty, or combine them if needed. Here we prefer the new one.
description: t.description || existing.description
});
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 {
topicsMap.set(t.id, t);
}

View File

@@ -1,51 +1,37 @@
import { anthropicApi } from './api';
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';
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.
Always write in clear, professional English.
ALWAYS return valid JSON only — no markdown code blocks, no extra text.`;
const CONTENT_SCHEMA_ARTICLE = `{
"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"]
}
}`;
Emit the requested content through the matching tool — do not return prose JSON.`;
const CONTENT_SCHEMA_SLIDES = `{
"slides": [
{ "title": "Slide title", "bullets": ["Point 1", "Point 2", "Point 3"], "speakerNote": "Speaker note for this slide." }
]
}`;
const cachedSystem = (text) => [{ type: 'text', text, cache_control: { type: 'ephemeral' } }];
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 CONTENT_SCHEMA_INFOGRAPHIC = `{
"infographic": {
"headline": "A short, punchy headline summarizing the topic (max 8 words)",
"tagline": "A subtitle of max 15 words",
"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()}
}`;
const INSTRUCTIONS_BY_TYPE = {
article: 'Provide at least 3 article sections and at least 2 key takeaways.',
slides: 'Provide at least 4 slides.',
infographic: 'Provide at least 3 stats and 3 steps.',
all: 'Provide at least 3 article sections, 4 slides, 3 stats, and 3 steps in the infographic.',
};
/**
* 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.
*/
export async function getAssignedTopic(userId, weekNumber) {
// Try curriculum first
try {
const { topic } = await getCurriculumTopic(weekNumber);
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);
}
// Fallback: hash-based assignment (backwards compatible)
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');
if (!topics || topics.length === 0) return null;
@@ -96,29 +79,15 @@ export async function generateLearningContent(topic, force = false, selectedType
let cached = null;
if (!force) {
cached = await db.getContent(topic.id);
if (cached) {
if (cached[selectedType]) {
if (cached && cached[selectedType]) {
console.log(`[Learn] Cache hit for topic: ${topic.id} (${selectedType})`);
return cached;
}
}
}
let schema = '';
let instructions = '';
if (selectedType === 'all') {
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 tool = TOOL_BY_TYPE[selectedType];
if (!tool) throw new Error(`Unknown learning content type: ${selectedType}`);
const instructions = INSTRUCTIONS_BY_TYPE[selectedType];
const prompt = `Generate a learning module piece for the following topic:
@@ -126,20 +95,20 @@ Label: ${topic.label}
Type: ${topic.type}
Description: ${topic.description}
Return ONLY a JSON object with the following structure:
${schema}
${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;
try {
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 newContent = result.toolUses[0]?.input;
if (!newContent) throw new Error('AI did not return learning content. Please try again.');
const mergedContent = { ...(cached || {}), ...newContent };
await db.setContent(topic.id, mergedContent);
@@ -148,59 +117,85 @@ ${instructions}`;
export async function refineLearningContent(topic, refinementInstruction) {
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:
"${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;
try {
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 });
if (!result.toolUses.length) {
throw new Error('AI did not propose any changes for that instruction. Try a more specific request.');
}
await db.setContent(topic.id, content);
return content;
const patchedArticle = applyAndValidate(existing.article, result.toolUses);
const merged = { ...existing, article: patchedArticle };
await db.setContent(topic.id, merged);
return merged;
}
export async function deleteCachedContent(topicId) {
return db.deleteContent(topicId);
}
export async function generateCustomTopic(label) {
const prompt = `A user wants to learn about "${label}".
Create a short description (2-3 sentences) and categorize it.
Return ONLY a JSON object with this structure:
{
"label": "Polished topic title",
"type": "concept", // one of: concept, role, process
"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 });
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) {
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 23 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);
return newTopic;
}

View File

@@ -125,7 +125,7 @@ function isChatLikeTask(task) {
return task === 'legacy.chat' || task.startsWith('chat.') || task.startsWith('r42.');
}
const SIMULATION_EXTRACTION_PAYLOAD = JSON.stringify({
const SIMULATION_EXTRACTION_GRAPH = {
topics: [
{ 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' },
@@ -135,33 +135,87 @@ const SIMULATION_EXTRACTION_PAYLOAD = JSON.stringify({
{ source: 'kennisbeheer', target: 'radicale-transparantie', type: 'depends_on' },
{ source: 'wekelijkse-sessie', target: 'kennisbeheer', type: 'part_of' },
],
});
};
const SIMULATION_EXTRACTION_PAYLOAD = JSON.stringify(SIMULATION_EXTRACTION_GRAPH);
const SIMULATION_CHAT_TEXT =
'Simulatiemodus staat aan — vraag een beheerder om Simulation Mode uit te zetten in Admin → Settings om met R42 te chatten.';
async function simulatedResponse({ task }) {
await new Promise((r) => setTimeout(r, 400));
if (isChatLikeTask(task)) {
const SIMULATION_ARTICLE = {
title: 'Voorbeeld leermodule',
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 {
text: SIMULATION_CHAT_TEXT,
toolUses: [],
stopReason: 'end_turn',
text,
toolUses,
stopReason,
usage: { input_tokens: 0, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
requestId: null,
model: 'simulation',
durationMs: 400,
};
}
return {
text: SIMULATION_EXTRACTION_PAYLOAD,
toolUses: [],
stopReason: 'end_turn',
usage: { input_tokens: 0, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
requestId: null,
model: 'simulation',
durationMs: 400,
};
async function simulatedResponse({ task, toolChoice }) {
await new Promise((r) => setTimeout(r, 400));
if (toolChoice?.type === 'tool' && SIMULATION_TOOL_STUBS[toolChoice.name]) {
return stubResponse({
stopReason: 'tool_use',
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) {
@@ -182,7 +236,7 @@ function extractToolUses(content) {
if (!Array.isArray(content)) return [];
return content
.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) {
@@ -218,6 +272,7 @@ function validateToolInputs(toolUses, task, toolSchemas) {
* @property {number} [maxTokens=4096]
* @property {number} [temperature=0]
* @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,
temperature = 0,
signal,
limiter,
} = options;
if (!task) throw new Error('callLLM requires a `task` label.');
const useSimulation = storage.get('admin:use_simulation') === true;
if (useSimulation) return simulatedResponse({ task });
if (useSimulation) return simulatedResponse({ task, toolChoice });
const model = resolveModel(tier);
const messagesPayload = buildMessages({ messages, user });
@@ -249,9 +305,12 @@ export async function callLLM(options) {
const body = {
model,
max_tokens: maxTokens,
temperature,
messages: messagesPayload,
};
// Temperature is not supported for reasoning tier models
if (tier !== 'reasoning') {
body.temperature = temperature;
}
if (system !== undefined) body.system = system;
if (tools && tools.length) body.tools = tools;
if (toolChoice) body.tool_choice = toolChoice;
@@ -261,6 +320,7 @@ export async function callLLM(options) {
try {
result = await withRetry(
async () => {
if (limiter) await limiter.acquire({ signal });
const timeoutCtl = signal ? null : new AbortController();
const timer = timeoutCtl ? setTimeout(() => timeoutCtl.abort(new DOMException('Timeout', 'AbortError')), DEFAULT_TIMEOUT_MS) : null;
const fetchSignal = linkSignals(signal, timeoutCtl?.signal);
@@ -280,6 +340,9 @@ export async function callLLM(options) {
const errBody = await response.json().catch(() => ({}));
if (isRetryableStatus(response.status)) {
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 LLMHttpError(response.status, response.statusText, errBody);

View File

@@ -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
* a retry, or any other error to fail immediately.

View File

@@ -122,6 +122,8 @@ export const learningAllSchema = z.object({
infographic: infographicBodySchema,
});
const quizDifficultyEnum = z.enum(['easy', 'medium', 'hard']);
const quizQuestionSchema = z.object({
id: 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),
correctIndex: z.number().int().min(0).max(3),
explanation: z.string().min(1),
difficulty: quizDifficultyEnum,
});
export const quizQuestionsSchema = z.object({
@@ -183,6 +186,31 @@ export const proposeGraphDeltaSchema = z.object({
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`
* consults this when the caller does not pass an explicit `toolSchemas`
@@ -199,4 +227,9 @@ export const toolSchemaRegistry = {
emit_custom_topic: customTopicSchema,
emit_graph_actions: graphActionsSchema,
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
View 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 (12 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
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

@@ -1,30 +1,107 @@
import { anthropicApi } from './api';
import * as db from './db';
import { callLLM } from './llm';
import { EMIT_QUIZ_QUESTIONS_TOOL } from './llmTools';
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.
You generate multiple-choice questions to test employee knowledge on specific topics.
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) {
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 curriculum-based selection first
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),
@@ -34,15 +111,13 @@ async function selectTestTopics(userId, weekNumber) {
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));
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);
}
// Fallback: hash-based selection
const str = `${userId}:${weekNumber}`;
let hash = 0;
for (let i = 0; i < str.length; i++) {
@@ -53,8 +128,7 @@ async function selectTestTopics(userId, weekNumber) {
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));
const reviewTopics = sample(others, Math.min(5, others.length));
return { primaryTopic, reviewTopics, isReviewWeek: false };
}
@@ -63,64 +137,69 @@ export async function getCachedQuiz(userId, weekNumber) {
return db.getCachedQuiz(userId, weekNumber);
}
export async function forceGenerateTopicQuestions(topic, count = 10) {
let bank = await db.getQuizBank(topic.id);
export async function forceGenerateTopicQuestions(topic, count = 5) {
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}
Type: ${topic.type}
Description: ${topic.description}
for (let attempt = 0; attempt < 3; attempt++) {
const questions = await callQuizModel(topic, count);
Return ONLY a JSON object with this structure:
{
"questions": [
{
"id": "unique-id-string",
"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."
}
]
const qualityError = validateBatchQuality(questions);
if (qualityError) {
lastQualityError = qualityError;
console.warn(`[quiz] batch rejected (attempt ${attempt + 1}): ${qualityError}`);
continue;
}
Rules:
- Each question must have exactly 4 options.
- correctIndex is 0-based (0=A, 1=B, 2=C, 3=D).
- Mix difficulty: 4 easy, 4 medium, 2 hard.
- Make questions specific and practical, not trivial.`;
const dominant = dominantCorrectIndex(questions);
if (dominant && attempt < 2) {
console.warn(`[quiz] correctIndex dominated by ${dominant.index} (${Math.round(dominant.ratio * 100)}%) — re-rolling`);
continue;
}
const responseText = await anthropicApi.generateContent(QUIZ_SYSTEM, prompt);
let newQuestions;
try {
const jsonMatch = responseText.match(/\{[\s\S]*\}/);
const parsed = JSON.parse(jsonMatch ? jsonMatch[0] : responseText);
newQuestions = parsed.questions || [];
newQuestions.forEach(q => {
q.id = `${topic.id}-${Math.random().toString(36).substr(2, 9)}`;
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,
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];
await db.setQuizBank(topic.id, bank);
return newQuestions;
if (!accepted.length) {
throw new Error(`All generated questions for ${topic.label} were duplicates of existing ones.`);
}
const merged = [...existingBank, ...accepted];
await db.setQuizBank(topic.id, merged);
return accepted;
}
async function getOrGenerateTopicQuestions(topic, count) {
let bank = await db.getQuizBank(topic.id);
if (bank.length < count) {
await forceGenerateTopicQuestions(topic, 10);
await forceGenerateTopicQuestions(topic, 5);
bank = await db.getQuizBank(topic.id);
}
const shuffled = [...bank].sort(() => 0.5 - Math.random());
return shuffled.slice(0, Math.min(count, shuffled.length));
return sample(bank, Math.min(count, bank.length));
}
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 = {
questions,
questions: shuffled,
meta: {
userId,
weekNumber,

View File

@@ -1,5 +1,5 @@
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 Tag from '../../components/ui/Tag';
import Button from '../../components/ui/Button';
@@ -12,6 +12,7 @@ import ContentManager from '../../components/admin/ContentManager';
import TestManager from '../../components/admin/TestManager';
import TeamManager from '../../components/admin/TeamManager';
import CurriculumManager from '../../components/admin/CurriculumManager';
import Diagnostics from '../../components/admin/Diagnostics';
import { Trash2 } from 'lucide-react';
const TIER_PLACEHOLDERS = {
@@ -71,6 +72,7 @@ const Admin = () => {
{ key: 'curriculum', icon: CalendarDays, label: 'Curriculum' },
{ key: 'graph', icon: Network, label: 'Graph' },
{ key: 'team', icon: Users, label: 'Team' },
{ key: 'diagnostics', icon: Activity, label: 'Diagnostics' },
{ key: 'settings', icon: Settings, label: 'Settings', bottom: true },
];
@@ -181,6 +183,14 @@ const Admin = () => {
</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' && (
<div className="animate-in fade-in duration-300 max-w-2xl">
<h1 className="text-3xl text-teal mb-2">Settings</h1>

View File

@@ -1,9 +1,24 @@
import { defineConfig } from 'vite'
import { execSync } from 'node:child_process'
import react from '@vitejs/plugin-react'
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/
export default defineConfig({
define: {
__BUILD_SHA__: JSON.stringify(getBuildSha()),
__BUILD_TIME__: JSON.stringify(new Date().toISOString()),
},
plugins: [react(), tailwindcss()],
server: {
proxy: {