Compare commits
1 Commits
main
...
feat/onboa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
41c22b2f06 |
@@ -18,8 +18,6 @@ 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`.
|
||||
|
||||
---
|
||||
|
||||
@@ -27,21 +25,13 @@ 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. 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`.
|
||||
2. Retrieve the top **10** topics for the user's 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.
|
||||
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.
|
||||
5. For explicitly mentioned topics, inject up to ~1200 chars of their generated
|
||||
content.
|
||||
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.
|
||||
|
||||
8
knowledge-base/Rebel ideas.md
Normal file
8
knowledge-base/Rebel ideas.md
Normal file
@@ -0,0 +1,8 @@
|
||||
- [ ] 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
|
||||
@@ -0,0 +1,232 @@
|
||||
---
|
||||
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.
|
||||
@@ -1,118 +0,0 @@
|
||||
# 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,5 +1,4 @@
|
||||
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';
|
||||
@@ -7,7 +6,7 @@ import { kbStore } from '../../lib/kbStore';
|
||||
import { BOT_NAME, STRINGS } from './prompts';
|
||||
|
||||
export default function ChatWindow({ user, isAdmin, onClose }) {
|
||||
const { messages, isThinking, send, clearThread } = useChat({ user, isAdmin });
|
||||
const { messages, isThinking, send } = useChat({ user, isAdmin });
|
||||
const [draft, setDraft] = useState('');
|
||||
const bodyRef = useRef(null);
|
||||
const inputRef = useRef(null);
|
||||
@@ -61,14 +60,6 @@ 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';
|
||||
@@ -90,17 +81,6 @@ 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"
|
||||
@@ -109,7 +89,6 @@ export default function ChatWindow({ user, isAdmin, onClose }) {
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="r42-window-body" ref={bodyRef}>
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
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,13 +87,8 @@
|
||||
border-radius: 999px;
|
||||
background: var(--sage);
|
||||
}
|
||||
.r42-window-hd-actions {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
.r42-window-hd-x {
|
||||
margin-left: auto;
|
||||
color: rgba(236, 233, 233, 0.7);
|
||||
background: transparent;
|
||||
border: none;
|
||||
@@ -104,18 +99,6 @@
|
||||
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,8 +21,6 @@ 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 = [
|
||||
@@ -37,16 +35,11 @@ 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:`,
|
||||
`- 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).`,
|
||||
`- Houd antwoorden onder de 4 zinnen tenzij de gebruiker om uitleg vraagt.`,
|
||||
`- Geen markdown-headers; gewone Nederlandse tekst.`,
|
||||
`- Bij onzekerheid: "Ik weet het niet zeker — controleer dit in het handboek."`,
|
||||
].join('\n');
|
||||
|
||||
@@ -2,13 +2,6 @@ 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);
|
||||
@@ -78,7 +71,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, DESC_SNIPPET_CHARS);
|
||||
const desc = (t.description || '').replace(/\s+/g, ' ').trim().slice(0, 200);
|
||||
return `- ${t.id} (${t.type || 'concept'}) "${t.label}": ${desc}`;
|
||||
});
|
||||
|
||||
@@ -92,30 +85,19 @@ export async function buildKbContext(userMessage = '') {
|
||||
}
|
||||
}
|
||||
|
||||
// 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 mentionedDeepContent = [];
|
||||
for (const id of mentionedIds) {
|
||||
const t = includedById.get(id);
|
||||
if (!t) return null;
|
||||
const content = await db.getContent(id).catch(() => null);
|
||||
if (!content) return null;
|
||||
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, DEEP_SNIPPET_CHARS);
|
||||
if (!snippet) return null;
|
||||
return `### ${t.label}\n${snippet}`;
|
||||
}),
|
||||
);
|
||||
const mentionedDeepContent = deepBlocks.filter(Boolean);
|
||||
const snippet = raw.replace(/\s+/g, ' ').trim().slice(0, 1200);
|
||||
mentionedDeepContent.push(`### ${t.label}\n${snippet}`);
|
||||
}
|
||||
|
||||
const context = [
|
||||
`KENNISGRAAF — RELEVANTE TOPICS (top ${included.length} van ${allTopics.length}):`,
|
||||
@@ -124,7 +106,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 (volledige leerinhoud van de meest relevante topics — gebruik álle feiten hieruit die de vraag beantwoorden):\n${mentionedDeepContent.join('\n\n')}`
|
||||
? `\n\nDIEPERE INHOUD (voor genoemde topics):\n${mentionedDeepContent.join('\n\n')}`
|
||||
: '',
|
||||
``,
|
||||
`Als de relevante context hierboven te beperkt is, gebruik dan de tool "lookup_topic" om de volledige beschrijving en eventuele leerinhoud van een specifiek topic op te halen.`,
|
||||
|
||||
@@ -92,19 +92,6 @@ 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;
|
||||
@@ -238,6 +225,5 @@ export function useChat({ user, isAdmin }) {
|
||||
errored,
|
||||
send,
|
||||
updateMessage,
|
||||
clearThread,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
customTopicSchema,
|
||||
graphActionsSchema,
|
||||
proposeGraphDeltaSchema,
|
||||
onboardingOverviewSchema,
|
||||
} from '../llmSchemas';
|
||||
|
||||
const sampleTopic = {
|
||||
@@ -193,56 +192,3 @@ describe('proposeGraphDeltaSchema', () => {
|
||||
).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,31 +50,6 @@ 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);
|
||||
|
||||
@@ -229,43 +229,14 @@ export const themeSessionSchema = z.object({
|
||||
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({
|
||||
title: z.string().min(1),
|
||||
what_it_is: z.string().min(1),
|
||||
why_it_matters: z.string().min(1),
|
||||
key_points: onboardingKeyPoints,
|
||||
topics_covered: z.preprocess(
|
||||
coerceStringifiedArray,
|
||||
z.array(z.object({ topic_id: z.string().min(1), label: z.string().min(1) })).min(1),
|
||||
),
|
||||
key_points: z.array(z.string().min(1)).min(3).max(5),
|
||||
topics_covered: 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' },
|
||||
minItems: 3,
|
||||
maxItems: 5,
|
||||
description: '3–5 short, concrete takeaways a newcomer should remember about this theme. Must be a JSON array of strings — never a single string.',
|
||||
description: '3–5 short, concrete takeaways a newcomer should remember about this theme.',
|
||||
},
|
||||
topics_covered: {
|
||||
type: 'array',
|
||||
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.',
|
||||
description: 'The topics that make up this theme. Reuse the exact topic_id you were given so the UI can link back.',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
|
||||
@@ -63,29 +63,6 @@ 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);
|
||||
@@ -103,19 +80,8 @@ export function retrieveTopK(index, query, k = 10) {
|
||||
let s = 0;
|
||||
for (const t of qTokens) {
|
||||
const f = tf.get(t);
|
||||
if (f) {
|
||||
if (!f) continue;
|
||||
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