From 85452f66a745eb1c86f0757188863f3512541afa Mon Sep 17 00:00:00 2001 From: RaymondVerhoef Date: Mon, 13 Jul 2026 14:25:08 +0200 Subject: [PATCH] feat(r42): improve KB grounding accuracy and add clear-history R42 was missing knowledge-graph information (e.g. pension questions) because retrieval and context-building dropped relevant facts: - retrieval: exact-token TF-IDF could not match Dutch compound words, so a "pensioen" query scored 0 against "pensioenregeling" / "partnerpensioen" and never retrieved them. Add a compound-word fallback (shared >=6-char stem or containment, 0.4x weight) alongside exact matching. - rag: deep article content was only injected for verbatim-mentioned topics; retrieved topics contributed just a 200-char description. Inject ~1000 chars of content for up to 5 topics (mentions first, then top-ranked retrieved) and widen the description snippet to 320. - prompts: add a NAUWKEURIGHEID block (use all relevant facts, call lookup_topic before giving up) and relax the 4-sentence cap for detail/list answers so complete facts aren't summarised away. Also add a clear-history control: a trash button in the chat header (confirm dialog) wipes chat:thread:{userId} and reseeds the greeting via clearThread() in useChat. Tests: compound-word matching + rag deep-content injection. Spec updated. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/r42-spec.md | 16 ++++-- src/components/chat/ChatWindow.jsx | 39 ++++++++++---- src/components/chat/__tests__/rag.test.js | 63 +++++++++++++++++++++++ src/components/chat/chat.css | 19 ++++++- src/components/chat/prompts.js | 9 +++- src/components/chat/rag.js | 46 ++++++++++++----- src/components/chat/useChat.js | 14 +++++ src/lib/__tests__/retrieval.test.js | 25 +++++++++ src/lib/retrieval.js | 38 +++++++++++++- 9 files changed, 239 insertions(+), 30 deletions(-) create mode 100644 src/components/chat/__tests__/rag.test.js diff --git a/docs/r42-spec.md b/docs/r42-spec.md index a9643b5..ff3d05c 100644 --- a/docs/r42-spec.md +++ b/docs/r42-spec.md @@ -18,6 +18,8 @@ client-side and is grounded by local TF-IDF retrieval — **no vector database** context is truncated with a notice. - A greeting message seeds an empty thread. - Each turn calls `callLLM` (fast/standard Claude tier — low latency matters for chat). +- The chat header has a **clear** button (trash icon). It confirms, then wipes + `chat:thread:{userId}` and reseeds the greeting via `clearThread` in `useChat.js`. --- @@ -25,13 +27,21 @@ client-side and is grounded by local TF-IDF retrieval — **no vector database** `buildKbContext` in `rag.js`: 1. Build / reuse the TF-IDF index over `topics` (`src/lib/retrieval.js`). -2. Retrieve the top **10** topics for the user's message. +2. Retrieve the top **10** topics for the user's message. Scoring is exact-token + TF-IDF **plus a compound-word fallback**: an unmatched query token (≥6 chars) + also matches a document term when they share a ≥6-char stem or one contains + the other, at a reduced weight. This recovers Dutch compounds — e.g. a + `pensioen` query matches `pensioenregeling` and `partnerpensioen`. 3. Always include topics whose `id` or `label` appears verbatim in the message. 4. Include relations only when **both** endpoints are in the retrieved set. -5. For explicitly mentioned topics, inject up to ~1200 chars of their generated - content. +5. Inject up to ~1000 chars of generated content for up to **5** topics — + verbatim-mentioned first, then the highest-ranked retrieved ones — so a query + that never names a topic exactly still gets rich content for what it matched. 6. Append a short KB hash so the cached context busts when topics change. +If the summarised context is still too thin, R42 can call the `lookup_topic` +tool to pull a topic's full description and learning content on demand. + The system prompt (`prompts.js`) is assembled as cacheable blocks: a stable preamble (role, tasks, style, "answer only from the KB"), the KB context block, and a per-turn tail with the user's name and admin/non-admin flag. diff --git a/src/components/chat/ChatWindow.jsx b/src/components/chat/ChatWindow.jsx index 9e64c13..8b47b3d 100644 --- a/src/components/chat/ChatWindow.jsx +++ b/src/components/chat/ChatWindow.jsx @@ -1,4 +1,5 @@ import { useCallback, useEffect, useRef, useState } from 'react'; +import { Trash2 } from 'lucide-react'; import Mark from '../ui/Mark'; import ChatMessage from './ChatMessage'; import { useChat } from './useChat'; @@ -6,7 +7,7 @@ import { kbStore } from '../../lib/kbStore'; import { BOT_NAME, STRINGS } from './prompts'; export default function ChatWindow({ user, isAdmin, onClose }) { - const { messages, isThinking, send } = useChat({ user, isAdmin }); + const { messages, isThinking, send, clearThread } = useChat({ user, isAdmin }); const [draft, setDraft] = useState(''); const bodyRef = useRef(null); const inputRef = useRef(null); @@ -60,6 +61,14 @@ export default function ChatWindow({ user, isAdmin, onClose }) { setDecided(prev => ({ ...prev, [msgId]: 'rejected' })); }, []); + const handleClear = useCallback(() => { + if (isThinking) return; + if (!window.confirm(STRINGS.clearConfirm)) return; + setDecided({}); + clearThread(); + inputRef.current?.focus(); + }, [isThinking, clearThread]); + const renderedMessages = messages.map(m => { if (!m.suggestion) return m; const status = decided[m.id] || m.suggestion.status || 'pending'; @@ -81,14 +90,26 @@ export default function ChatWindow({ user, isAdmin, onClose }) {
{BOT_NAME}
{STRINGS.status}
- +
+ + +
diff --git a/src/components/chat/__tests__/rag.test.js b/src/components/chat/__tests__/rag.test.js new file mode 100644 index 0000000..a87289f --- /dev/null +++ b/src/components/chat/__tests__/rag.test.js @@ -0,0 +1,63 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// In-memory KB the mocked db serves from. +const store = { + topics: [], + relations: [], + content: new Map(), +}; + +vi.mock('../../../lib/db', () => ({ + getTopics: vi.fn(async () => store.topics), + getRelations: vi.fn(async () => store.relations), + getContent: vi.fn(async (id) => store.content.get(id) ?? null), +})); + +import { buildKbContext } from '../rag'; + +beforeEach(() => { + store.topics = []; + store.relations = []; + store.content = new Map(); +}); + +describe('buildKbContext', () => { + it('reports an empty graph', async () => { + const { context, allTopics } = await buildKbContext('pensioen'); + expect(context).toMatch(/leeg/); + expect(allTopics).toEqual([]); + }); + + it('injects deep content for a retrieved topic that is not named verbatim', async () => { + store.topics = [ + { id: 'pensioenregeling', label: 'Pensioenregeling', type: 'concept', description: 'De beschikbare premieregeling.' }, + { id: 'onboarding-buddy', label: 'Onboarding Buddy', type: 'role', description: 'Begeleidt nieuwe medewerkers.' }, + ]; + store.content.set('pensioenregeling', { + article: 'De premie is 10% van de pensioengrondslag; werkgever en werknemer betalen elk 50%.', + }); + + // "pensioen" is never a verbatim topic id/label, but the compound-word + // matching should retrieve pensioenregeling and pull its article body in. + const { context, retrievedTopics } = await buildKbContext('wat dekt mijn pensioen?'); + + expect(retrievedTopics.map(t => t.id)).toContain('pensioenregeling'); + expect(context).toMatch(/DIEPERE INHOUD/); + expect(context).toMatch(/10% van de pensioengrondslag/); + }); + + it('only includes relations whose endpoints are both in the selection', async () => { + store.topics = [ + { id: 'pensioenregeling', label: 'Pensioenregeling', type: 'concept', description: 'De beschikbare premieregeling.' }, + { id: 'partnerpensioen', label: 'Partnerpensioen', type: 'concept', description: 'Uitkering aan de partner.' }, + ]; + store.relations = [ + { source: 'partnerpensioen', target: 'pensioenregeling', type: 'part_of' }, + { source: 'pensioenregeling', target: 'iets-anders', type: 'related_to' }, + ]; + + const { context } = await buildKbContext('pensioen'); + expect(context).toMatch(/partnerpensioen --part_of--> pensioenregeling/); + expect(context).not.toMatch(/iets-anders/); + }); +}); diff --git a/src/components/chat/chat.css b/src/components/chat/chat.css index e5a0691..230d741 100644 --- a/src/components/chat/chat.css +++ b/src/components/chat/chat.css @@ -87,8 +87,13 @@ border-radius: 999px; background: var(--sage); } -.r42-window-hd-x { +.r42-window-hd-actions { margin-left: auto; + display: flex; + align-items: center; + gap: 2px; +} +.r42-window-hd-x { color: rgba(236, 233, 233, 0.7); background: transparent; border: none; @@ -99,6 +104,18 @@ border-radius: var(--r-sm); } .r42-window-hd-x:hover { background: rgba(236, 233, 233, 0.1); } +.r42-window-hd-clear { + color: rgba(236, 233, 233, 0.7); + background: transparent; + border: none; + cursor: pointer; + padding: 6px; + border-radius: var(--r-sm); + display: grid; + place-items: center; +} +.r42-window-hd-clear:hover { background: rgba(236, 233, 233, 0.1); } +.r42-window-hd-clear:disabled { opacity: 0.4; cursor: default; } .r42-window-body { flex: 1; diff --git a/src/components/chat/prompts.js b/src/components/chat/prompts.js index 040f44b..32463cb 100644 --- a/src/components/chat/prompts.js +++ b/src/components/chat/prompts.js @@ -21,6 +21,8 @@ export const STRINGS = { suggestionDismissed: 'Oké, niets gedaan.', closeAria: 'Sluit chatvenster', openAria: 'Open R42 chatbot', + clearAria: 'Wis gesprek', + clearConfirm: 'Dit gesprek wissen? Dit kan niet ongedaan worden gemaakt.', }; const STABLE_PREAMBLE = [ @@ -35,11 +37,16 @@ const STABLE_PREAMBLE = [ `JE KENNIS:`, `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.`, ``, + `NAUWKEURIGHEID (belangrijk):`, + `- Baseer je antwoord uitsluitend op de KENNISGRAAF en DIEPERE INHOUD hieronder; verzin niets.`, + `- Gebruik ALLE relevante feiten die daar staan. Bij een vraag om details, bedragen, percentages, voorwaarden of een opsomming: noem elk relevant feit — vat niet samen ten koste van volledigheid.`, + `- Als de samenvattende KENNISGRAAF te dun is om de vraag volledig te beantwoorden, roep dan éérst de tool "lookup_topic" aan (met het exacte topic-id) voordat je concludeert dat je het niet weet.`, + ``, `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.`, ``, `STIJL:`, - `- Houd antwoorden onder de 4 zinnen tenzij de gebruiker om uitleg vraagt.`, + `- Zo kort als kan, zo volledig als nodig: houd eenvoudige antwoorden onder de 4 zinnen, maar som bij details- of opsommingsvragen álle relevante feiten op (desnoods als korte lijst met streepjes).`, `- Geen markdown-headers; gewone Nederlandse tekst.`, `- Bij onzekerheid: "Ik weet het niet zeker — controleer dit in het handboek."`, ].join('\n'); diff --git a/src/components/chat/rag.js b/src/components/chat/rag.js index 2fbb805..35c0344 100644 --- a/src/components/chat/rag.js +++ b/src/components/chat/rag.js @@ -2,6 +2,13 @@ import * as db from '../../lib/db'; import { buildIndex, retrieveTopK } from '../../lib/retrieval'; const TOP_K = 10; +// How many topics get their full article body injected (not just the short +// description). Verbatim-mentioned topics come first, then the highest-ranked +// retrieved ones, so a query that never names a topic exactly still gets rich +// content for what it matched. +const DEEP_CONTENT_LIMIT = 5; +const DEEP_SNIPPET_CHARS = 1000; +const DESC_SNIPPET_CHARS = 320; async function sha256Hex(input) { const enc = new TextEncoder().encode(input); @@ -71,7 +78,7 @@ export async function buildKbContext(userMessage = '') { const included = [...includedById.values()]; const topicLines = included.map(t => { - const desc = (t.description || '').replace(/\s+/g, ' ').trim().slice(0, 200); + const desc = (t.description || '').replace(/\s+/g, ' ').trim().slice(0, DESC_SNIPPET_CHARS); return `- ${t.id} (${t.type || 'concept'}) "${t.label}": ${desc}`; }); @@ -85,19 +92,30 @@ export async function buildKbContext(userMessage = '') { } } - const mentionedDeepContent = []; - for (const id of mentionedIds) { - const t = includedById.get(id); - if (!t) continue; - const content = await db.getContent(t.id).catch(() => null); - if (!content) continue; - 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); - const snippet = raw.replace(/\s+/g, ' ').trim().slice(0, 1200); - mentionedDeepContent.push(`### ${t.label}\n${snippet}`); + // Pick which topics get their full article body: verbatim mentions first, + // then the highest-ranked retrieved topics, capped at DEEP_CONTENT_LIMIT. + const deepIds = []; + for (const id of mentionedIds) deepIds.push(id); + for (const t of retrieved) { + if (deepIds.length >= DEEP_CONTENT_LIMIT) break; + if (!mentionedIds.has(t.id)) deepIds.push(t.id); } + const deepBlocks = await Promise.all( + deepIds.slice(0, DEEP_CONTENT_LIMIT).map(async (id) => { + const t = includedById.get(id); + if (!t) return null; + const content = await db.getContent(id).catch(() => null); + if (!content) return null; + 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); + const snippet = raw.replace(/\s+/g, ' ').trim().slice(0, DEEP_SNIPPET_CHARS); + if (!snippet) return null; + return `### ${t.label}\n${snippet}`; + }), + ); + const mentionedDeepContent = deepBlocks.filter(Boolean); const context = [ `KENNISGRAAF — RELEVANTE TOPICS (top ${included.length} van ${allTopics.length}):`, @@ -106,7 +124,7 @@ export async function buildKbContext(userMessage = '') { `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')}` + ? `\n\nDIEPERE INHOUD (volledige leerinhoud van de meest relevante topics — gebruik álle feiten hieruit die de vraag beantwoorden):\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.`, diff --git a/src/components/chat/useChat.js b/src/components/chat/useChat.js index 788bddd..25aea90 100644 --- a/src/components/chat/useChat.js +++ b/src/components/chat/useChat.js @@ -92,6 +92,19 @@ export function useChat({ user, isAdmin }) { setMessages(prev => prev.map(m => (m.id === id ? { ...m, ...patch } : m))); }, []); + /** Wipe the persisted thread and reset to a fresh greeting. */ + const clearThread = useCallback(() => { + seenDeltaKeys.current = new Set(); + const greeting = { + id: `m_${Date.now()}`, + role: 'assistant', + content: STRINGS.greeting(user?.name || 'daar'), + ts: Date.now(), + }; + setMessages([greeting]); + if (threadKey) storage.set(threadKey, [greeting]); + }, [user, threadKey]); + const send = useCallback(async (text) => { const trimmed = (text || '').trim(); if (!trimmed || !user) return; @@ -225,5 +238,6 @@ export function useChat({ user, isAdmin }) { errored, send, updateMessage, + clearThread, }; } diff --git a/src/lib/__tests__/retrieval.test.js b/src/lib/__tests__/retrieval.test.js index 6079138..ff0ca42 100644 --- a/src/lib/__tests__/retrieval.test.js +++ b/src/lib/__tests__/retrieval.test.js @@ -50,6 +50,31 @@ describe('buildIndex / retrieveTopK', () => { expect(retrieveTopK(idx, 'kwantumfysica raketten')).toEqual([]); }); + it('matches Dutch compound words on a shared stem', () => { + const pensionTopics = [ + { id: 'pensioenregeling', label: 'Pensioenregeling', description: 'De beschikbare premieregeling bij a.s.r. Doen Pensioen.' }, + { id: 'partnerpensioen', label: 'Partnerpensioen', description: 'Uitkering aan de partner bij overlijden.' }, + { id: 'reiskostenvergoeding', label: 'Reiskostenvergoeding', description: 'EUR 0,23 per kilometer voor woon-werkverkeer.' }, + ]; + const idx = buildIndex(pensionTopics); + // "pensioen" never appears as a standalone token in a label, yet the stem is + // a prefix of "pensioenregeling" and an infix of "partnerpensioen". + const hits = retrieveTopK(idx, 'wat dekt mijn pensioen?', 3).map(h => h.id); + expect(hits).toContain('pensioenregeling'); + expect(hits).toContain('partnerpensioen'); + expect(hits).not.toContain('reiskostenvergoeding'); + }); + + it('does not partial-match on short shared prefixes', () => { + const topics = [ + { id: 'onderhoud', label: 'Onderhoud', description: 'Technisch beheer van systemen.' }, + ]; + const idx = buildIndex(topics); + // "onderneming" shares only "onder" (5) with "onderhoud" — below the overlap + // needed for a query token this size to count. + expect(retrieveTopK(idx, 'onderneming')).toEqual([]); + }); + it('caches the index per topics array reference', () => { const idx1 = buildIndex(sampleTopics); const idx2 = buildIndex(sampleTopics); diff --git a/src/lib/retrieval.js b/src/lib/retrieval.js index 36f69e3..b76bbc4 100644 --- a/src/lib/retrieval.js +++ b/src/lib/retrieval.js @@ -63,6 +63,29 @@ export function buildIndex(topics) { return index; } +// Compound-word matching. Dutch is heavily compounding, so a user's word +// (`pensioenafspraken`) is a *different* token than the graph's labels +// (`pensioenregeling`, `partnerpensioen`), even though they share the stem +// `pensioen`. Exact TF-IDF scores those pairs at 0, so the relevant topics are +// never retrieved. These heuristics recover that recall at a reduced weight, +// so exact matches still dominate the ranking. +const PARTIAL_MIN_QUERY_LEN = 6; // only expand meaty query tokens +const PARTIAL_MIN_OVERLAP = 6; // shared stem / substring must be this long +const PARTIAL_WEIGHT = 0.4; // discount vs. an exact term hit + +/** True when two distinct tokens share a long stem or one contains the other. */ +function partialMatch(q, d) { + if (q === d) return false; + const shorter = q.length <= d.length ? q : d; + const longer = q.length <= d.length ? d : q; + if (shorter.length < PARTIAL_MIN_OVERLAP) return false; + if (longer.includes(shorter)) return true; + let n = 0; + const m = shorter.length; + while (n < m && q[n] === d[n]) n++; + return n >= PARTIAL_MIN_OVERLAP; +} + export function retrieveTopK(index, query, k = 10) { if (!index || !index.N || !query) return []; const qTokens = tokenize(query); @@ -80,8 +103,19 @@ export function retrieveTopK(index, query, k = 10) { let s = 0; for (const t of qTokens) { const f = tf.get(t); - if (!f) continue; - s += (1 + Math.log(f)) * idf(t); + if (f) { + s += (1 + Math.log(f)) * idf(t); + continue; + } + // No exact hit — try a compound-word match against this doc's terms. + if (t.length < PARTIAL_MIN_QUERY_LEN) continue; + let best = 0; + for (const [term, tf2] of tf) { + if (!partialMatch(t, term)) continue; + const w = PARTIAL_WEIGHT * (1 + Math.log(tf2)) * idf(term); + if (w > best) best = w; + } + s += best; } scores[i] = s; }