Compare commits
5 Commits
feat/onboa
...
17cb30003d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
17cb30003d | ||
|
|
85452f66a7 | ||
| e73700763d | |||
|
|
182fb2357f | ||
| b1d3686d35 |
@@ -18,6 +18,8 @@ client-side and is grounded by local TF-IDF retrieval — **no vector database**
|
|||||||
context is truncated with a notice.
|
context is truncated with a notice.
|
||||||
- A greeting message seeds an empty thread.
|
- A greeting message seeds an empty thread.
|
||||||
- Each turn calls `callLLM` (fast/standard Claude tier — low latency matters for chat).
|
- 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`:
|
`buildKbContext` in `rag.js`:
|
||||||
1. Build / reuse the TF-IDF index over `topics` (`src/lib/retrieval.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.
|
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.
|
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
|
5. Inject up to ~1000 chars of generated content for up to **5** topics —
|
||||||
content.
|
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.
|
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
|
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
|
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.
|
a per-turn tail with the user's name and admin/non-admin flag.
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
- [ ] Whereabouts voor kantoor aanwezigheid
|
|
||||||
- [ ] Interne kennis teams
|
|
||||||
- [ ] Check Daisy labs
|
|
||||||
- [ ] DCMR hidden layer run voor AI litteracy
|
|
||||||
- [ ] AI works summit voor Evan
|
|
||||||
- [ ] Klantevemementen
|
|
||||||
- [ ] Lectures TU
|
|
||||||
- [ ] Marketing targets stellen
|
|
||||||
@@ -1,232 +0,0 @@
|
|||||||
---
|
|
||||||
title: Respellion — organisatie-inrichting
|
|
||||||
category: Inrichtingsdocument
|
|
||||||
summary: >
|
|
||||||
Beschrijvend inrichtingsdocument van Respellion: hoe de organisatie is
|
|
||||||
ingericht langs organisatiemodel, communicatiemodel, performancemodel,
|
|
||||||
coachingcultuur, beloningsstructuur (richtinggevend Baarda-model) en
|
|
||||||
opschalen/toekomstperspectief. Beperkt tot inrichting; operationele
|
|
||||||
procedures zijn bewust buiten scope gehouden.
|
|
||||||
tags:
|
|
||||||
- inrichting
|
|
||||||
- holacracy
|
|
||||||
- sociocracy
|
|
||||||
- consent
|
|
||||||
- coachingcultuur
|
|
||||||
- performancemodel
|
|
||||||
- baarda
|
|
||||||
- opschalen
|
|
||||||
status: beschrijvend inrichtingsdocument (geen operationeel beleid)
|
|
||||||
related_topics:
|
|
||||||
- who-we-are.md
|
|
||||||
- how-we-work-together.md
|
|
||||||
- roles-and-accountabilities.md
|
|
||||||
- learning-personal-development.md
|
|
||||||
---
|
|
||||||
|
|
||||||
# Respellion — organisatie-inrichting
|
|
||||||
|
|
||||||
Dit document beschrijft *hoe Respellion als organisatie is ingericht*. Het gaat
|
|
||||||
over de structuur, de spelregels en de onderliggende ontwerpkeuzes — niet over
|
|
||||||
operationele procedures zoals verlof, urenregistratie of tooling-instellingen.
|
|
||||||
Waar een uitspraak op vastgesteld handboekbeleid berust, is dat de basis; waar
|
|
||||||
een onderdeel richtinggevend en nog niet vastgesteld is, staat dat expliciet
|
|
||||||
vermeld.
|
|
||||||
|
|
||||||
Respellion is een softwareontwikkelbedrijf, opgericht in 2024 door voormalige
|
|
||||||
leiders van QDelft, met als doel "een maatschappij waarin open-source technologie
|
|
||||||
wordt ingezet om digitalisering op een duurzame manier mogelijk te maken". De
|
|
||||||
organisatie positioneert zich als *responsibly rebellious*: innovatieve
|
|
||||||
werkwijzen gecombineerd met maatschappelijke verantwoordelijkheid. Vier
|
|
||||||
kernwaarden — Trust, Courage, Self-discipline en Entrepreneurship — vormen de
|
|
||||||
grondslag onder elke inrichtingskeuze die hieronder wordt beschreven.
|
|
||||||
|
|
||||||
## 1. Organisatiemodel
|
|
||||||
|
|
||||||
Respellion is ingericht volgens **Holacracy**, een governancemodel voor
|
|
||||||
zelforganisatie zonder traditionele managementhiërarchie. Het uitgangspunt is
|
|
||||||
kernachtig samengevat als "no management, no job descriptions": er zijn geen
|
|
||||||
leidinggevende functies, alleen dynamische rollen met expliciete
|
|
||||||
verantwoordelijkheden.
|
|
||||||
|
|
||||||
Vier ontwerpkeuzes typeren het model:
|
|
||||||
|
|
||||||
- statische functieomschrijvingen worden vervangen door **dynamische rollen**;
|
|
||||||
- **gedelegeerd gezag** wordt vervangen door **gedistribueerd gezag** — autoriteit
|
|
||||||
ligt bij de rol, niet bij een persoon of een baas;
|
|
||||||
- grote reorganisaties worden vervangen door **continue micro-iteraties**;
|
|
||||||
- kantoorpolitiek wordt vervangen door **transparante regels en gedisciplineerde
|
|
||||||
processen**.
|
|
||||||
|
|
||||||
De organisatie is opgebouwd uit geneste **circles**: de *Anchor Circle* met
|
|
||||||
daaronder *Operations* en daaronder weer *Software Delivery*. Rollen zijn
|
|
||||||
geclusterd in de circle waar ze thuishoren. Vier structurele rollen zijn
|
|
||||||
constitutioneel verplicht en keren in elke circle terug: **Circle Lead** (houdt de
|
|
||||||
purpose van de circle), **Facilitator** (bewaakt proces en governance),
|
|
||||||
**Secretary** (borgt besluiten en administratie) en **Circle Rep** (kanaliseert
|
|
||||||
spanningen naar de bredere circle). Daarnaast bestaan tal van operationele rollen
|
|
||||||
(zoals People Officer, Privacy Officer, Process Guardian, Treasury Keeper) die
|
|
||||||
elk een eigen purpose en accountabilities dragen.
|
|
||||||
|
|
||||||
De motor onder het model is de **tension**: het ervaren verschil tussen de huidige
|
|
||||||
en een betere situatie. Iedereen kan een tension inbrengen; die wordt verwerkt in
|
|
||||||
een tactical of governance meeting en leidt tot een concrete actie of een
|
|
||||||
rolwijziging. Zo evolueert de structuur voortdurend van onderop, in plaats van via
|
|
||||||
periodieke reorganisaties. Een oprichter vat de houding samen als *tuinieren in
|
|
||||||
plaats van bouwen*: condities scheppen voor groei, niet bouwen naar een rigide
|
|
||||||
blauwdruk. De actuele rolinvulling is voor iedereen transparant vastgelegd in
|
|
||||||
GlassFrog.
|
|
||||||
|
|
||||||
## 2. Communicatiemodel
|
|
||||||
|
|
||||||
Het communicatiemodel is de directe consequentie van gedistribueerd gezag: als
|
|
||||||
niemand "de baas" is, moeten afstemming en besluitvorming via heldere,
|
|
||||||
transparante gremia verlopen. Respellion kent daarvoor twee vergaderlijnen, een
|
|
||||||
besluitvormingsnorm en een aantal cultuurafspraken.
|
|
||||||
|
|
||||||
**Twee vergaderlijnen.** *Tactical meetings* richten zich op operationele
|
|
||||||
coördinatie en resulteren in concrete taken, toegewezen aan rollen en bijgehouden
|
|
||||||
op het taakbord. *Governance meetings* richten zich op de structuur zelf: rollen
|
|
||||||
worden er gecreëerd, gewijzigd of opgeheven om spanningen op te lossen. Beide
|
|
||||||
worden vastgelegd in Microsoft Loop, zodat besluiten en hun onderbouwing
|
|
||||||
naspeurbaar blijven.
|
|
||||||
|
|
||||||
**Besluitvorming op basis van consent.** Onder Holacracy ligt de oudere
|
|
||||||
**sociocratische** traditie, en Respellion houdt die laag bewust zichtbaar. Een
|
|
||||||
besluit komt tot stand op basis van *commitment/consent* — niet op basis van
|
|
||||||
consensus en niet via meerderheid. Het leidende principe is dat een voorstel
|
|
||||||
doorgaat zolang er geen onderbouwd bezwaar is, en dat *iedereen die door een
|
|
||||||
besluit wordt geraakt daar invloed op moet kunnen uitoefenen*. In de woorden van
|
|
||||||
de organisatie: "de stem van de minderheid wordt altijd in aanmerking genomen".
|
|
||||||
Sociocracy voegt daar een pragmatisch principe aan toe dat naadloos bij de
|
|
||||||
rebelse cultuur past — *verander alleen wat nodig is en start waar de spanning
|
|
||||||
zit* — wat verklaart waarom Respellion evolueert in kleine stappen in plaats van
|
|
||||||
in grote ontwerpen. Deze consent-laag is wat gedistribueerd gezag legitiem en
|
|
||||||
gedragen maakt; zonder consent zou autonomie verworden tot willekeur.
|
|
||||||
|
|
||||||
**Cultuurafspraken.** De waarde Trust vertaalt zich in een expliciete norm: "we
|
|
||||||
praten mét elkaar, niet óver elkaar", en spanningen worden direct benoemd in
|
|
||||||
plaats van te laten sudderen. De transparantie van GlassFrog (wie welke rol
|
|
||||||
vervult) en Loop (welke besluiten er zijn genomen) maakt dat de organisatie
|
|
||||||
zonder hiërarchische informatiestroom toch afgestemd blijft.
|
|
||||||
|
|
||||||
## 3. Performancemodel
|
|
||||||
|
|
||||||
Respellion heeft het klassieke performancemanagement — periodieke beoordeling met
|
|
||||||
oordeel en sanctie — bewust vervangen door een **continue, ontwikkelingsgerichte
|
|
||||||
benadering**. Prestatie wordt niet één keer per jaar beoordeeld, maar doorlopend
|
|
||||||
besproken in de coachingcultuur (zie §4).
|
|
||||||
|
|
||||||
De inhoudelijke maatstaf zijn de **5 Pillars of Value**, die samen bepalen welke
|
|
||||||
waarde iemand toevoegt. De pijlers wegen bewust verschillend:
|
|
||||||
|
|
||||||
- **A. Basiservaring & expertise** (weging 100%) — fundamentele vakkennis en
|
|
||||||
vaardigheden;
|
|
||||||
- **B. Rolvervulling & verantwoordelijkheid** (75%) — het eigenaarschap in de
|
|
||||||
holacratische rollen;
|
|
||||||
- **C. Impact & resultaat** (80%) — de tastbare waarde voor klanten en purpose;
|
|
||||||
- **D. Ontwikkeling & leercurve** (100%) — groei op intellectueel, emotioneel en
|
|
||||||
fysiek vlak, gekoppeld aan het persoonlijk ontwikkelplan;
|
|
||||||
- **E. Cultuur & samenwerking** (80%) — bijdrage aan het team en het naleven van
|
|
||||||
de waarden.
|
|
||||||
|
|
||||||
De beoordeling verloopt via **self-evaluation**: medewerkers scoren zichzelf op de
|
|
||||||
vijf pijlers, waarna collega's dat oordeel tijdens de Huddle Elevators actief
|
|
||||||
challengen om het compleet en eerlijk te houden. Op organisatieniveau is dit
|
|
||||||
verankerd in het ISO 9001-kwaliteitssysteem, waar de klassieke *management review*
|
|
||||||
is vervangen door een strategische review in de Anchor Circle en waar een formele
|
|
||||||
review van rolvervulling twee keer per jaar plaatsvindt. Het uitgangspunt daarbij
|
|
||||||
is dat mensen "ons belangrijkste kapitaal" zijn.
|
|
||||||
|
|
||||||
## 4. Coachingcultuur
|
|
||||||
|
|
||||||
De coachingcultuur is het hart van de mensgerichte inrichting en de drager van het
|
|
||||||
performancemodel. Het is een cultuur van continue feedback, ondersteuning en
|
|
||||||
begeleiding, gericht op ontwikkeling in plaats van beoordeling. De cultuur is
|
|
||||||
ingericht met vier vaste elementen.
|
|
||||||
|
|
||||||
**Huddle Check-ins.** Twee keer per week komen kleine, vaste groepen bij elkaar
|
|
||||||
voor snelle verbinding, updates en onderlinge steun. De frequentie en het vaste
|
|
||||||
karakter maken de verbinding betekenisvoller dan klassieke mentoring.
|
|
||||||
|
|
||||||
**Huddle Elevators.** Elke twee maanden vindt een diepere, gefaciliteerde
|
|
||||||
ontwikkelsessie plaats. De "elevator"-gedachte legt de nadruk op *voortbouwen op
|
|
||||||
sterktes* in plaats van alleen zwaktes repareren. Hier vindt ook de
|
|
||||||
self-evaluation van het performancemodel plaats.
|
|
||||||
|
|
||||||
**Capacity Building Framework.** Ontwikkeling is holistisch ingericht in drie
|
|
||||||
domeinen: **Professional** (vakinhoudelijke en intellectuele vaardigheden),
|
|
||||||
**Emotional** (sociale vaardigheden, zelfreflectie, afstemming van persoonlijke
|
|
||||||
waarden op het werk) en **Physical** (gezonde leefstijl).
|
|
||||||
|
|
||||||
**Persoonlijk Ontwikkelplan (POP).** Elke medewerker stelt een POP op, gebaseerd
|
|
||||||
op **Ofman's kernkwadrant** (kernkwaliteit → valkuil → uitdaging → allergie). Het
|
|
||||||
plan wordt gedeeld op een Miro-bord en besproken tijdens de Elevators.
|
|
||||||
|
|
||||||
Twee kenmerken maken de cultuur werkbaar. Ten eerste zijn **openheid en
|
|
||||||
kwetsbaarheid** een expliciete voorwaarde: medewerkers worden aangemoedigd open te
|
|
||||||
zijn over ontwikkelpunten, zodat collega's beter kunnen ondersteunen. Ten tweede
|
|
||||||
worden **fouten gevierd** — de "Most Rebellious Failures" bij de tweemaandelijkse
|
|
||||||
celebration maken van leren-door-falen een norm en versterken de psychologische
|
|
||||||
veiligheid.
|
|
||||||
|
|
||||||
## 5. Beloningsstructuur
|
|
||||||
|
|
||||||
De beloningsfilosofie is een directe vertaling van de kernwaarden: **belonen naar
|
|
||||||
toegevoegde waarde, niet naar dienstjaren of functietitel.** De inrichting bestaat
|
|
||||||
uit een vastgesteld deel en een richtinggevend deel.
|
|
||||||
|
|
||||||
**Vastgesteld — Growth & Reward.** De salarisontwikkeling volgt de prestatie op de
|
|
||||||
5 Pillars of Value. De self-evaluationscore wordt gekoppeld aan een **bandingmodel**
|
|
||||||
met oplopende verhogingscategorieën (bijvoorbeeld 0–2%, 2–4%, enzovoort), zodat
|
|
||||||
bovengemiddelde prestatie ook bovengemiddeld wordt beloond in plaats van een
|
|
||||||
automatische verhoging voor iedereen. Bij het bepalen van de banden wegen de
|
|
||||||
financiële gezondheid van het bedrijf, de inflatie (CPI) en de marktconformiteit
|
|
||||||
mee; instroommoment binnen het jaar wordt pro-rata verrekend. De banding wordt ten
|
|
||||||
slotte over de huddles heen gevalideerd om het beeld eerlijk en consistent te
|
|
||||||
houden.
|
|
||||||
|
|
||||||
**Richtinggevend, nog niet vastgesteld — het Baarda-model.** Als volgende stap in
|
|
||||||
de beloningsstructuur verkent Respellion een **IT-geoptimaliseerde Baarda-ladder**
|
|
||||||
(conceptdocument "Optie 3"). Dit model is gebaseerd op Rolf Baarda's principe
|
|
||||||
*toegevoegde waarde = kennis × probleemoplossend vermogen* (TW = K × POV). Kern is
|
|
||||||
dat het **niveau van een medewerker uitsluitend wordt bepaald door
|
|
||||||
probleemoplossend vermogen** — niet door dienstjaren, opleiding of titel. De ladder
|
|
||||||
kent acht niveaus die de Baarda-rollen vertalen naar IT-disciplines (full stack
|
|
||||||
developers, QA-engineers, business analisten en agile coaches), met per niveau een
|
|
||||||
tariefklasse-indicatie die in detacheringscontext als kwalificatie richting
|
|
||||||
klanten dient. Dit onderdeel is expliciet **conceptueel en richtinggevend**: er
|
|
||||||
liggen nog open besluiten over onder meer de naamgeving, de eenmalige initiële
|
|
||||||
plaatsing van medewerkers, de te gebruiken marktbenchmark en de interne validatie
|
|
||||||
van de gedragsindicatoren.
|
|
||||||
|
|
||||||
## 6. Opschalen en toekomstperspectief
|
|
||||||
|
|
||||||
De inrichting is ontworpen om te groeien **zonder managementlagen toe te voegen**.
|
|
||||||
Respellion groeit van 15 teamleden (2025) naar een beoogde 30 (2026), met als doel
|
|
||||||
complete, inzetbare ontwikkelteams te kunnen leveren.
|
|
||||||
|
|
||||||
Twee mechanismen maken die schaalbaarheid mogelijk. Ten eerste **circle-nesting**:
|
|
||||||
zodra de complexiteit binnen een circle toeneemt, splitst een deel zich af als
|
|
||||||
sub-circle met eigen rollen en structurele bezetting. De bestaande gelaagdheid
|
|
||||||
(Anchor → Operations → Software Delivery) is daar al de uitdrukking van; verdere
|
|
||||||
groei betekent meer geneste circles, niet meer hiërarchie. Ten tweede biedt de
|
|
||||||
**Baarda-ladder** — zodra vastgesteld — een schaalbaar beloningsraamwerk dat ook
|
|
||||||
bij groei intern vergelijkbaar en naar klanten uitlegbaar blijft, doordat elk
|
|
||||||
niveau een objectieve niveau-kwalificatie geeft.
|
|
||||||
|
|
||||||
De consent- en tension-mechanismen (§1 en §2) zijn intrinsiek schaalbaar: ze
|
|
||||||
verwerken verandering lokaal en continu, waardoor de organisatie ook bij groei
|
|
||||||
adaptief blijft zonder centrale sturing. Het toekomstperspectief is daarmee een
|
|
||||||
organisatie die in omvang verdubbelt terwijl ze haar platte, zelforganiserende
|
|
||||||
karakter en haar maatschappelijke, open-source purpose behoudt.
|
|
||||||
|
|
||||||
## Samenhang
|
|
||||||
|
|
||||||
De zes onderdelen vormen één consistente logica. **Consent** legitimeert het
|
|
||||||
gedistribueerde gezag van **Holacracy**; het **communicatiemodel** maakt dat gezag
|
|
||||||
werkbaar; het **performancemodel** en de **coachingcultuur** houden de mensen
|
|
||||||
erin gezond en in ontwikkeling; de **beloningsstructuur** zorgt dat toegevoegde
|
|
||||||
waarde — niet anciënniteit — telt; en het **opschaalmodel** laat dit alles groeien
|
|
||||||
zonder de platte structuur op te geven. Alle zes wortelen in de vier kernwaarden:
|
|
||||||
Trust, Courage, Self-discipline en Entrepreneurship.
|
|
||||||
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 { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { Trash2 } from 'lucide-react';
|
||||||
import Mark from '../ui/Mark';
|
import Mark from '../ui/Mark';
|
||||||
import ChatMessage from './ChatMessage';
|
import ChatMessage from './ChatMessage';
|
||||||
import { useChat } from './useChat';
|
import { useChat } from './useChat';
|
||||||
@@ -6,7 +7,7 @@ import { kbStore } from '../../lib/kbStore';
|
|||||||
import { BOT_NAME, STRINGS } from './prompts';
|
import { BOT_NAME, STRINGS } from './prompts';
|
||||||
|
|
||||||
export default function ChatWindow({ user, isAdmin, onClose }) {
|
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 [draft, setDraft] = useState('');
|
||||||
const bodyRef = useRef(null);
|
const bodyRef = useRef(null);
|
||||||
const inputRef = useRef(null);
|
const inputRef = useRef(null);
|
||||||
@@ -60,6 +61,14 @@ export default function ChatWindow({ user, isAdmin, onClose }) {
|
|||||||
setDecided(prev => ({ ...prev, [msgId]: 'rejected' }));
|
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 => {
|
const renderedMessages = messages.map(m => {
|
||||||
if (!m.suggestion) return m;
|
if (!m.suggestion) return m;
|
||||||
const status = decided[m.id] || m.suggestion.status || 'pending';
|
const status = decided[m.id] || m.suggestion.status || 'pending';
|
||||||
@@ -81,14 +90,26 @@ export default function ChatWindow({ user, isAdmin, onClose }) {
|
|||||||
<div className="r42-window-hd-name">{BOT_NAME}</div>
|
<div className="r42-window-hd-name">{BOT_NAME}</div>
|
||||||
<div className="r42-window-hd-status"><i /> {STRINGS.status}</div>
|
<div className="r42-window-hd-status"><i /> {STRINGS.status}</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<div className="r42-window-hd-actions">
|
||||||
type="button"
|
<button
|
||||||
className="r42-window-hd-x"
|
type="button"
|
||||||
onClick={onClose}
|
className="r42-window-hd-clear"
|
||||||
aria-label={STRINGS.closeAria}
|
onClick={handleClear}
|
||||||
>
|
disabled={isThinking}
|
||||||
×
|
aria-label={STRINGS.clearAria}
|
||||||
</button>
|
title={STRINGS.clearAria}
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="r42-window-hd-x"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label={STRINGS.closeAria}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="r42-window-body" ref={bodyRef}>
|
<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;
|
border-radius: 999px;
|
||||||
background: var(--sage);
|
background: var(--sage);
|
||||||
}
|
}
|
||||||
.r42-window-hd-x {
|
.r42-window-hd-actions {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
.r42-window-hd-x {
|
||||||
color: rgba(236, 233, 233, 0.7);
|
color: rgba(236, 233, 233, 0.7);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: none;
|
border: none;
|
||||||
@@ -99,6 +104,18 @@
|
|||||||
border-radius: var(--r-sm);
|
border-radius: var(--r-sm);
|
||||||
}
|
}
|
||||||
.r42-window-hd-x:hover { background: rgba(236, 233, 233, 0.1); }
|
.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 {
|
.r42-window-body {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ export const STRINGS = {
|
|||||||
suggestionDismissed: 'Oké, niets gedaan.',
|
suggestionDismissed: 'Oké, niets gedaan.',
|
||||||
closeAria: 'Sluit chatvenster',
|
closeAria: 'Sluit chatvenster',
|
||||||
openAria: 'Open R42 chatbot',
|
openAria: 'Open R42 chatbot',
|
||||||
|
clearAria: 'Wis gesprek',
|
||||||
|
clearConfirm: 'Dit gesprek wissen? Dit kan niet ongedaan worden gemaakt.',
|
||||||
};
|
};
|
||||||
|
|
||||||
const STABLE_PREAMBLE = [
|
const STABLE_PREAMBLE = [
|
||||||
@@ -35,11 +37,16 @@ const STABLE_PREAMBLE = [
|
|||||||
`JE KENNIS:`,
|
`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.`,
|
`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:`,
|
`KENNISGRAAF VERFIJNEN:`,
|
||||||
`Wanneer de gebruiker iets noemt dat duidelijk een nieuw topic, nieuwe relatie, proces of rol is — en dat nog niet in de kennisgraaf staat — gebruik dan de tool "propose_graph_delta" om een voorstel te maken. Verzin niets: stel alleen iets voor als de gebruiker het concreet noemt. Stel maximaal 3 topics en 5 relaties per beurt voor.`,
|
`Wanneer de gebruiker iets noemt dat duidelijk een nieuw topic, nieuwe relatie, proces of rol is — en dat nog niet in de kennisgraaf staat — gebruik dan de tool "propose_graph_delta" om een voorstel te maken. Verzin niets: stel alleen iets voor als de gebruiker het concreet noemt. Stel maximaal 3 topics en 5 relaties per beurt voor.`,
|
||||||
``,
|
``,
|
||||||
`STIJL:`,
|
`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.`,
|
`- Geen markdown-headers; gewone Nederlandse tekst.`,
|
||||||
`- Bij onzekerheid: "Ik weet het niet zeker — controleer dit in het handboek."`,
|
`- Bij onzekerheid: "Ik weet het niet zeker — controleer dit in het handboek."`,
|
||||||
].join('\n');
|
].join('\n');
|
||||||
|
|||||||
@@ -2,6 +2,13 @@ import * as db from '../../lib/db';
|
|||||||
import { buildIndex, retrieveTopK } from '../../lib/retrieval';
|
import { buildIndex, retrieveTopK } from '../../lib/retrieval';
|
||||||
|
|
||||||
const TOP_K = 10;
|
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) {
|
async function sha256Hex(input) {
|
||||||
const enc = new TextEncoder().encode(input);
|
const enc = new TextEncoder().encode(input);
|
||||||
@@ -71,7 +78,7 @@ export async function buildKbContext(userMessage = '') {
|
|||||||
const included = [...includedById.values()];
|
const included = [...includedById.values()];
|
||||||
|
|
||||||
const topicLines = included.map(t => {
|
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}`;
|
return `- ${t.id} (${t.type || 'concept'}) "${t.label}": ${desc}`;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -85,19 +92,30 @@ export async function buildKbContext(userMessage = '') {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const mentionedDeepContent = [];
|
// Pick which topics get their full article body: verbatim mentions first,
|
||||||
for (const id of mentionedIds) {
|
// then the highest-ranked retrieved topics, capped at DEEP_CONTENT_LIMIT.
|
||||||
const t = includedById.get(id);
|
const deepIds = [];
|
||||||
if (!t) continue;
|
for (const id of mentionedIds) deepIds.push(id);
|
||||||
const content = await db.getContent(t.id).catch(() => null);
|
for (const t of retrieved) {
|
||||||
if (!content) continue;
|
if (deepIds.length >= DEEP_CONTENT_LIMIT) break;
|
||||||
let raw;
|
if (!mentionedIds.has(t.id)) deepIds.push(t.id);
|
||||||
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 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 = [
|
const context = [
|
||||||
`KENNISGRAAF — RELEVANTE TOPICS (top ${included.length} van ${allTopics.length}):`,
|
`KENNISGRAAF — RELEVANTE TOPICS (top ${included.length} van ${allTopics.length}):`,
|
||||||
@@ -106,7 +124,7 @@ export async function buildKbContext(userMessage = '') {
|
|||||||
`KENNISGRAAF — RELATIES (binnen deze selectie):`,
|
`KENNISGRAAF — RELATIES (binnen deze selectie):`,
|
||||||
relLines.length ? relLines.join('\n') : '(geen relaties binnen deze selectie)',
|
relLines.length ? relLines.join('\n') : '(geen relaties binnen deze selectie)',
|
||||||
mentionedDeepContent.length
|
mentionedDeepContent.length
|
||||||
? `\n\nDIEPERE INHOUD (voor genoemde topics):\n${mentionedDeepContent.join('\n\n')}`
|
? `\n\nDIEPERE INHOUD (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.`,
|
`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)));
|
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 send = useCallback(async (text) => {
|
||||||
const trimmed = (text || '').trim();
|
const trimmed = (text || '').trim();
|
||||||
if (!trimmed || !user) return;
|
if (!trimmed || !user) return;
|
||||||
@@ -225,5 +238,6 @@ export function useChat({ user, isAdmin }) {
|
|||||||
errored,
|
errored,
|
||||||
send,
|
send,
|
||||||
updateMessage,
|
updateMessage,
|
||||||
|
clearThread,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
customTopicSchema,
|
customTopicSchema,
|
||||||
graphActionsSchema,
|
graphActionsSchema,
|
||||||
proposeGraphDeltaSchema,
|
proposeGraphDeltaSchema,
|
||||||
|
onboardingOverviewSchema,
|
||||||
} from '../llmSchemas';
|
} from '../llmSchemas';
|
||||||
|
|
||||||
const sampleTopic = {
|
const sampleTopic = {
|
||||||
@@ -192,3 +193,56 @@ describe('proposeGraphDeltaSchema', () => {
|
|||||||
).toThrow();
|
).toThrow();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('onboardingOverviewSchema (issue #32 — fast-tier stringified arrays)', () => {
|
||||||
|
const base = {
|
||||||
|
title: 'Privacy',
|
||||||
|
what_it_is: 'How we handle data.',
|
||||||
|
why_it_matters: 'You touch personal data weekly.',
|
||||||
|
};
|
||||||
|
const points = ['Point one', 'Point two', 'Point three'];
|
||||||
|
const topics = [{ topic_id: 'avg', label: 'AVG' }];
|
||||||
|
|
||||||
|
it('accepts well-formed output', () => {
|
||||||
|
const r = onboardingOverviewSchema.parse({ ...base, key_points: points, topics_covered: topics });
|
||||||
|
expect(r.key_points).toEqual(points);
|
||||||
|
expect(r.topics_covered).toEqual(topics);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('coerces JSON-stringified arrays back to arrays (the exact #32 failure)', () => {
|
||||||
|
const r = onboardingOverviewSchema.parse({
|
||||||
|
...base,
|
||||||
|
key_points: JSON.stringify(points),
|
||||||
|
topics_covered: JSON.stringify(topics),
|
||||||
|
});
|
||||||
|
expect(r.key_points).toEqual(points);
|
||||||
|
expect(r.topics_covered).toEqual(topics);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('splits a bullet/newline string of key points as a fallback', () => {
|
||||||
|
const r = onboardingOverviewSchema.parse({
|
||||||
|
...base,
|
||||||
|
key_points: '- Point one\n• Point two\n3. Point three',
|
||||||
|
topics_covered: topics,
|
||||||
|
});
|
||||||
|
expect(r.key_points).toEqual(points);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the first 5 key points instead of failing on overage', () => {
|
||||||
|
const seven = Array.from({ length: 7 }, (_, i) => `P${i + 1}`);
|
||||||
|
const r = onboardingOverviewSchema.parse({ ...base, key_points: seven, topics_covered: topics });
|
||||||
|
expect(r.key_points).toEqual(['P1', 'P2', 'P3', 'P4', 'P5']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still rejects genuinely bad output', () => {
|
||||||
|
expect(() =>
|
||||||
|
onboardingOverviewSchema.parse({ ...base, key_points: ['only', 'two'], topics_covered: topics }),
|
||||||
|
).toThrow();
|
||||||
|
expect(() =>
|
||||||
|
onboardingOverviewSchema.parse({ ...base, key_points: points, topics_covered: 'not json at all' }),
|
||||||
|
).toThrow();
|
||||||
|
expect(() =>
|
||||||
|
onboardingOverviewSchema.parse({ ...base, key_points: 42, topics_covered: topics }),
|
||||||
|
).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -50,6 +50,31 @@ describe('buildIndex / retrieveTopK', () => {
|
|||||||
expect(retrieveTopK(idx, 'kwantumfysica raketten')).toEqual([]);
|
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', () => {
|
it('caches the index per topics array reference', () => {
|
||||||
const idx1 = buildIndex(sampleTopics);
|
const idx1 = buildIndex(sampleTopics);
|
||||||
const idx2 = buildIndex(sampleTopics);
|
const idx2 = buildIndex(sampleTopics);
|
||||||
|
|||||||
@@ -229,14 +229,43 @@ export const themeSessionSchema = z.object({
|
|||||||
keyTakeaways: z.array(z.string().min(1)).min(3),
|
keyTakeaways: z.array(z.string().min(1)).min(3),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Fast-tier models occasionally emit array tool fields as a JSON-encoded
|
||||||
|
// string ("[\"a\",\"b\"]") instead of a real array (issue #32). Parse those
|
||||||
|
// back to arrays before validating; leave anything else untouched so real
|
||||||
|
// type errors still fail validation.
|
||||||
|
function coerceStringifiedArray(v) {
|
||||||
|
if (typeof v === 'string') {
|
||||||
|
const s = v.trim();
|
||||||
|
if (s.startsWith('[')) {
|
||||||
|
try { return JSON.parse(s); } catch { /* keep original, let Zod report */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
const onboardingKeyPoints = z.preprocess((v) => {
|
||||||
|
let out = coerceStringifiedArray(v);
|
||||||
|
// Fallback: a bullet/newline list as one string → split into points.
|
||||||
|
if (typeof out === 'string' && out.includes('\n')) {
|
||||||
|
out = out
|
||||||
|
.split('\n')
|
||||||
|
.map((line) => line.replace(/^\s*[-•*\d.]+\s*/, '').trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
// Overage is trivial — keep the first 5 rather than failing the user.
|
||||||
|
if (Array.isArray(out) && out.length > 5) out = out.slice(0, 5);
|
||||||
|
return out;
|
||||||
|
}, z.array(z.string().min(1)).min(3).max(5));
|
||||||
|
|
||||||
export const onboardingOverviewSchema = z.object({
|
export const onboardingOverviewSchema = z.object({
|
||||||
title: z.string().min(1),
|
title: z.string().min(1),
|
||||||
what_it_is: z.string().min(1),
|
what_it_is: z.string().min(1),
|
||||||
why_it_matters: z.string().min(1),
|
why_it_matters: z.string().min(1),
|
||||||
key_points: z.array(z.string().min(1)).min(3).max(5),
|
key_points: onboardingKeyPoints,
|
||||||
topics_covered: z
|
topics_covered: z.preprocess(
|
||||||
.array(z.object({ topic_id: z.string().min(1), label: z.string().min(1) }))
|
coerceStringifiedArray,
|
||||||
.min(1),
|
z.array(z.object({ topic_id: z.string().min(1), label: z.string().min(1) })).min(1),
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -470,11 +470,11 @@ export const EMIT_ONBOARDING_OVERVIEW_TOOL = {
|
|||||||
items: { type: 'string' },
|
items: { type: 'string' },
|
||||||
minItems: 3,
|
minItems: 3,
|
||||||
maxItems: 5,
|
maxItems: 5,
|
||||||
description: '3–5 short, concrete takeaways a newcomer should remember about this theme.',
|
description: '3–5 short, concrete takeaways a newcomer should remember about this theme. Must be a JSON array of strings — never a single string.',
|
||||||
},
|
},
|
||||||
topics_covered: {
|
topics_covered: {
|
||||||
type: 'array',
|
type: 'array',
|
||||||
description: 'The topics that make up this theme. Reuse the exact topic_id you were given so the UI can link back.',
|
description: 'The topics that make up this theme. Reuse the exact topic_id you were given so the UI can link back. Must be a JSON array of objects — never a string.',
|
||||||
items: {
|
items: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
|
|||||||
@@ -63,6 +63,29 @@ export function buildIndex(topics) {
|
|||||||
return index;
|
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) {
|
export function retrieveTopK(index, query, k = 10) {
|
||||||
if (!index || !index.N || !query) return [];
|
if (!index || !index.N || !query) return [];
|
||||||
const qTokens = tokenize(query);
|
const qTokens = tokenize(query);
|
||||||
@@ -80,8 +103,19 @@ export function retrieveTopK(index, query, k = 10) {
|
|||||||
let s = 0;
|
let s = 0;
|
||||||
for (const t of qTokens) {
|
for (const t of qTokens) {
|
||||||
const f = tf.get(t);
|
const f = tf.get(t);
|
||||||
if (!f) continue;
|
if (f) {
|
||||||
s += (1 + Math.log(f)) * idf(t);
|
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;
|
scores[i] = s;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user