Compare commits
2 Commits
e73700763d
...
17cb30003d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
17cb30003d | ||
|
|
85452f66a7 |
@@ -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.
|
||||
|
||||
118
knowledge-base/pensioenpremie-proforma-berekening.md
Normal file
118
knowledge-base/pensioenpremie-proforma-berekening.md
Normal file
@@ -0,0 +1,118 @@
|
||||
# pensioenpremie berekenen en verwerken in een proforma-loonstrook
|
||||
|
||||
## doel
|
||||
|
||||
Handleiding voor het handmatig berekenen en verwerken van de pensioenpremie bij het
|
||||
opstellen van een proforma-salarisspecificatie in NMBRS, wanneer de pensioengrondslag
|
||||
niet automatisch op de proforma-berekening verschijnt.
|
||||
|
||||
## wanneer van toepassing
|
||||
|
||||
Bij het opstellen van een proforma-loonstrook (bijvoorbeeld ter voorbereiding van een
|
||||
aanbod aan een kandidaat) toont NMBRS geen automatische pensioenpremie-regel. Deze moet
|
||||
dan handmatig worden toegevoegd volgens onderstaande methode.
|
||||
|
||||
## benodigde gegevens vooraf
|
||||
|
||||
Voordat je begint moet je de volgende vier parameters hebben. Geen van deze staat op de
|
||||
proforma zelf.
|
||||
|
||||
| parameter | waar te vinden |
|
||||
| --- | --- |
|
||||
| pensioenfranchise (Respellion / a.s.r. Doen Pensioen) | `pension-scheme-and-benefits.md` of het a.s.r.-polisblad; kan afwijken van het wettelijk minimum |
|
||||
| verdeelsleutel werkgever/werknemer | `pension-scheme-and-benefits.md` of het a.s.r.-contract |
|
||||
| definitie pensioengevend salaris | a.s.r.-polisvoorwaarden — bevestig welke looncomponenten meetellen (onkostenvergoedingen in elk geval niet) |
|
||||
| fiscaal maximum pensioengevend salaris (aftoppingsgrens) | jaarlijkse publicatie belastingdienst/SZW, zie tabel hieronder |
|
||||
|
||||
Deze vier punten waren bij het opstellen van dit document nog niet allemaal bevestigd
|
||||
voor Respellion specifiek — zie "openstaande vragen" onderaan.
|
||||
|
||||
## rekenstappen
|
||||
|
||||
1. **pensioengevend salaris (jaarbasis) bepalen**
|
||||
Standaard: 12 × maandsalaris + vakantietoeslag (8%).
|
||||
`pensioengevend salaris = 12 × bruto maandsalaris × 1,08`
|
||||
Onkostenvergoedingen (reiskosten, telefoonvergoeding) tellen niet mee.
|
||||
|
||||
2. **aftoppen op het fiscaal maximum**
|
||||
`gemaximeerd pensioengevend salaris = min(pensioengevend salaris, aftoppingsgrens)`
|
||||
|
||||
3. **franchise aftrekken**
|
||||
`pensioengrondslag = gemaximeerd pensioengevend salaris − franchise`
|
||||
|
||||
4. **parttimefactor toepassen**
|
||||
`pensioengrondslag (parttime) = pensioengrondslag × parttimefactor`
|
||||
Parttimefactor = contracturen / 40 (of de fulltime-norm die Respellion hanteert).
|
||||
|
||||
5. **premie berekenen**
|
||||
`jaarpremie = pensioengrondslag (parttime) × 5%`
|
||||
`maandpremie = jaarpremie / 12`
|
||||
|
||||
6. **verdelen werkgever/werknemer**
|
||||
`werknemersdeel = maandpremie × werknemerspercentage`
|
||||
`werkgeversdeel = maandpremie − werknemersdeel`
|
||||
|
||||
## fiscale kernbedragen 2026
|
||||
|
||||
| bedrag | waarde 2026 | bron |
|
||||
| --- | --- | --- |
|
||||
| aftoppingsgrens pensioengevend salaris | € 137.800 | belastingdienst/SZW, voorlopige bedragen 2026 |
|
||||
| minimale AOW-franchise (Wtp-regelingen / beschikbare premieregelingen) | € 19.172 | belastingdienst/SZW, voorlopige bedragen 2026 |
|
||||
|
||||
Deze twee bedragen worden jaarlijks (eind december) opnieuw gepubliceerd. Controleer bij
|
||||
elke proforma-berekening of de bedragen nog actueel zijn.
|
||||
|
||||
## verwerking in NMBRS — fiscale nuance
|
||||
|
||||
Het werknemersdeel van de pensioenpremie is een pre-tax inhouding:
|
||||
|
||||
- het verlaagt de grondslag voor de loonheffing (de kolom "Tabel" op de proforma-uitdraai)
|
||||
- het verlaagt **niet** het SV-loon (de kolom "SVW" blijft ongewijzigd)
|
||||
|
||||
Trek het werknemersdeel dus niet rechtstreeks af van het bestaande netto-bedrag. Voer de
|
||||
premie in met dezelfde paycode die in de reguliere (niet-proforma) loonrun voor
|
||||
pensioenpremie werknemer/werkgever is ingericht — controleer de codeconfiguratie in
|
||||
NMBRS (niet de bedragen) op de vlag "aftrekbaar voor loonheffing, niet voor SVW". Laat
|
||||
NMBRS de loonheffing zelf herberekenen over de verlaagde grondslag; reken dit niet
|
||||
handmatig na.
|
||||
|
||||
Het werkgeversdeel is een werkgeverslast (vergelijkbaar met de bestaande WGA/Aof-regels
|
||||
op de proforma) en raakt het netto van de werknemer niet, wel de totale loonkosten.
|
||||
|
||||
## rekenvoorbeeld
|
||||
|
||||
Uitgangspunt: proforma fulltime (40 uur/week), bruto maandsalaris € 6.700, 100%
|
||||
dienstverband.
|
||||
|
||||
1. Pensioengevend salaris (jaar): 12 × 6.700 × 1,08 = € 86.832
|
||||
2. Aftopping: 86.832 < 137.800 → geen aftopping
|
||||
3. Franchise (voorbeeld met het wettelijk minimum € 19.172 — niet bevestigd als
|
||||
Respellion-waarde): 86.832 − 19.172 = € 67.660
|
||||
4. Parttimefactor: 100% → geen aanpassing
|
||||
5. Premie: 67.660 × 5% = € 3.383 per jaar = € 281,92 per maand
|
||||
6. Verdeling (voorbeeld 50/50 — verdeelsleutel niet bevestigd): € 140,96 werknemer,
|
||||
€ 140,96 werkgever
|
||||
|
||||
Dit rekenvoorbeeld gebruikt aannames voor franchise en verdeelsleutel die nog niet
|
||||
bevestigd zijn voor Respellion — zie "openstaande vragen".
|
||||
|
||||
## openstaande vragen
|
||||
|
||||
- Exacte pensioenfranchise van de Respellion a.s.r. Doen Pensioen-regeling (mogelijk
|
||||
hoger dan het wettelijk minimum van € 19.172).
|
||||
- Verdeelsleutel werkgever/werknemer voor de 5% premie.
|
||||
- Of vakantietoeslag standaard meetelt in het pensioengevend salaris volgens de
|
||||
a.s.r.-polis, of dat er een afwijkende definitie geldt.
|
||||
- Exacte NMBRS-paycode voor pensioenpremie werknemer/werkgever (te vinden in een
|
||||
bestaande productierun of de paycode-lijst).
|
||||
|
||||
Zodra deze vier punten bevestigd zijn, kan dit document worden aangevuld met de
|
||||
definitieve waarden in plaats van de wettelijke minimum-aannames.
|
||||
|
||||
## bronnen
|
||||
|
||||
- Proforma-salarisspecificatie Respellion B.V. (voorbeeldcasus, dit document)
|
||||
- Belastingdienst/SZW: voorlopige bedragen AOW-franchise en maximum pensioengevend
|
||||
salaris 2026
|
||||
- `pension-scheme-and-benefits.md` (Respellion-handboek) — te raadplegen voor de
|
||||
resterende openstaande vragen
|
||||
@@ -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,6 +90,17 @@ export default function ChatWindow({ user, isAdmin, onClose }) {
|
||||
<div className="r42-window-hd-name">{BOT_NAME}</div>
|
||||
<div className="r42-window-hd-status"><i /> {STRINGS.status}</div>
|
||||
</div>
|
||||
<div className="r42-window-hd-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="r42-window-hd-clear"
|
||||
onClick={handleClear}
|
||||
disabled={isThinking}
|
||||
aria-label={STRINGS.clearAria}
|
||||
title={STRINGS.clearAria}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="r42-window-hd-x"
|
||||
@@ -89,6 +109,7 @@ export default function ChatWindow({ user, isAdmin, onClose }) {
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="r42-window-body" ref={bodyRef}>
|
||||
|
||||
63
src/components/chat/__tests__/rag.test.js
Normal file
63
src/components/chat/__tests__/rag.test.js
Normal file
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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) {
|
||||
// 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) continue;
|
||||
const content = await db.getContent(t.id).catch(() => null);
|
||||
if (!content) continue;
|
||||
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, 1200);
|
||||
mentionedDeepContent.push(`### ${t.label}\n${snippet}`);
|
||||
}
|
||||
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.`,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user