Compare commits

..

1 Commits

Author SHA1 Message Date
RaymondVerhoef
41c22b2f06 Add comprehensive documentation for roles, security, spending policies, and organizational identity
- Introduced a new document detailing the roles and accountabilities within Respellion, outlining the Holacracy structure and operational roles.
- Created a security policy document covering GDPR compliance, personal data handling, and workplace safety procedures.
- Added a spending and contracting policy that defines expense categories, reimbursement processes, and guidelines for asset expenses.
- Established a document outlining Respellion's identity, services, governance model, and onboarding procedures for new employees.
2026-07-13 09:22:01 +02:00
25 changed files with 276 additions and 446 deletions

View File

@@ -18,8 +18,6 @@ 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`.
--- ---
@@ -27,21 +25,13 @@ 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. Scoring is exact-token 2. Retrieve the top **10** topics for the user's message.
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. Inject up to ~1000 chars of generated content for up to **5** topics — 5. For explicitly mentioned topics, inject up to ~1200 chars of their generated
verbatim-mentioned first, then the highest-ranked retrieved ones — so a query content.
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.

View 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

View File

@@ -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 02%, 24%, 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.

View File

@@ -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

View File

@@ -1,5 +1,4 @@
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';
@@ -7,7 +6,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, clearThread } = useChat({ user, isAdmin }); const { messages, isThinking, send } = 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);
@@ -61,14 +60,6 @@ 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';
@@ -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-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>
<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 <button
type="button" type="button"
className="r42-window-hd-x" className="r42-window-hd-x"
@@ -109,7 +89,6 @@ export default function ChatWindow({ user, isAdmin, onClose }) {
> >
× ×
</button> </button>
</div>
</header> </header>
<div className="r42-window-body" ref={bodyRef}> <div className="r42-window-body" ref={bodyRef}>

View File

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

View File

@@ -87,13 +87,8 @@
border-radius: 999px; border-radius: 999px;
background: var(--sage); background: var(--sage);
} }
.r42-window-hd-actions {
margin-left: auto;
display: flex;
align-items: center;
gap: 2px;
}
.r42-window-hd-x { .r42-window-hd-x {
margin-left: auto;
color: rgba(236, 233, 233, 0.7); color: rgba(236, 233, 233, 0.7);
background: transparent; background: transparent;
border: none; border: none;
@@ -104,18 +99,6 @@
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;

View File

@@ -21,8 +21,6 @@ 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 = [
@@ -37,16 +35,11 @@ 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:`,
`- 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.`, `- 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');

View File

@@ -2,13 +2,6 @@ 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);
@@ -78,7 +71,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, DESC_SNIPPET_CHARS); const desc = (t.description || '').replace(/\s+/g, ' ').trim().slice(0, 200);
return `- ${t.id} (${t.type || 'concept'}) "${t.label}": ${desc}`; 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, const mentionedDeepContent = [];
// then the highest-ranked retrieved topics, capped at DEEP_CONTENT_LIMIT. for (const id of mentionedIds) {
const deepIds = [];
for (const id of mentionedIds) deepIds.push(id);
for (const t of retrieved) {
if (deepIds.length >= DEEP_CONTENT_LIMIT) break;
if (!mentionedIds.has(t.id)) deepIds.push(t.id);
}
const deepBlocks = await Promise.all(
deepIds.slice(0, DEEP_CONTENT_LIMIT).map(async (id) => {
const t = includedById.get(id); const t = includedById.get(id);
if (!t) return null; if (!t) continue;
const content = await db.getContent(id).catch(() => null); const content = await db.getContent(t.id).catch(() => null);
if (!content) return null; if (!content) continue;
let raw; let raw;
if (typeof content === 'string') raw = content; if (typeof content === 'string') raw = content;
else if (content.article) raw = typeof content.article === 'string' ? content.article : JSON.stringify(content.article); else if (content.article) raw = typeof content.article === 'string' ? content.article : JSON.stringify(content.article);
else raw = JSON.stringify(content); else raw = JSON.stringify(content);
const snippet = raw.replace(/\s+/g, ' ').trim().slice(0, DEEP_SNIPPET_CHARS); const snippet = raw.replace(/\s+/g, ' ').trim().slice(0, 1200);
if (!snippet) return null; mentionedDeepContent.push(`### ${t.label}\n${snippet}`);
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}):`,
@@ -124,7 +106,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 (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.`, `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.`,

View File

@@ -92,19 +92,6 @@ 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;
@@ -238,6 +225,5 @@ export function useChat({ user, isAdmin }) {
errored, errored,
send, send,
updateMessage, updateMessage,
clearThread,
}; };
} }

View File

@@ -9,7 +9,6 @@ import {
customTopicSchema, customTopicSchema,
graphActionsSchema, graphActionsSchema,
proposeGraphDeltaSchema, proposeGraphDeltaSchema,
onboardingOverviewSchema,
} from '../llmSchemas'; } from '../llmSchemas';
const sampleTopic = { const sampleTopic = {
@@ -193,56 +192,3 @@ 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();
});
});

View File

@@ -50,31 +50,6 @@ 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);

View File

@@ -229,43 +229,14 @@ 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: onboardingKeyPoints, key_points: z.array(z.string().min(1)).min(3).max(5),
topics_covered: z.preprocess( topics_covered: z
coerceStringifiedArray, .array(z.object({ topic_id: z.string().min(1), label: z.string().min(1) }))
z.array(z.object({ topic_id: z.string().min(1), label: z.string().min(1) })).min(1), .min(1),
),
}); });
/** /**

View File

@@ -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: '35 short, concrete takeaways a newcomer should remember about this theme. Must be a JSON array of strings — never a single string.', description: '35 short, concrete takeaways a newcomer should remember about this theme.',
}, },
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. 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: { items: {
type: 'object', type: 'object',
properties: { properties: {

View File

@@ -63,29 +63,6 @@ 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);
@@ -103,19 +80,8 @@ 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) { if (!f) continue;
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;
} }