v1.15.0: filter vereenvoudigd naar drie regels — 30 semantische groepen i.p.v. families+whitelist+blacklist

- Strip de filter terug tot: (1) sterk signaal in de titel -> door;
  (2) score van unieke semantische groepen >= 3 met titel-signaal -> door;
  (3) anders weg. Geen whitelist (82 termen), geen blacklist, geen span-merge,
  geen dienststoring-titelanker meer; woordgrenzen/lookbehinds weren de
  bekende botsingen structureel (schoolbus/VW-bus/stationsstraat/stationair/
  treinstraat/treinweg).
- Lijsten 49 families + 82 whitelist + 2 blacklist = 133 -> 16 sterk + 14
  ambigu = 30 groepen; filterzone 524 -> ~292 regels; match-methodes 4 -> 2.
- Vijf historische uitzonderings-families geschrapt (busongeluk, sein, breng,
  hermes, inchecken, kaartje, rails, staking, e.d.) — die woorden leveren nu
  0 signalen op en de bekende false positives blijven geweerd.
- Completheids-audit ving de Maaslijn/Nedersaksenlijn/Kamperlijn/waterbus-
  regressie op (hersteld); resterende zero-hit-kandidaten geen wijziging.
- validate_filter.py: invarianten 1-3 + 113 regressie-cases (90 onveranderd
  groen + 23 nieuw); prompt-matrix 27/27.
- Gedragswijziging op 45 productiedagen: uitsluitend false positives extra
  geweerd (buitenlandse veerboot-rampen, Zwitsers busongeluk, delftopzondag
  tram+halte, Cox/RET, tram-blokkade); 0 recalls verloren.
- README/AGENTS.md/vault bijgewerkt.
This commit is contained in:
Eddy de Vink 2026-09-13 09:28:28 +02:00
commit ac30575660
6 changed files with 381 additions and 695 deletions

2
.gitignore vendored
View file

@ -2,4 +2,4 @@ __pycache__/
*.pyc *.pyc
# Taakbestanden die geen onderdeel zijn van de repo # Taakbestanden die geen onderdeel zijn van de repo
Prompt voor agent*.md Prompt*.md

View file

@ -3,9 +3,11 @@
Maubot-plugin `nl.eddydevink.rss` (RSS → Matrix + WhatsApp), met een OV-nieuwsfilter dat **vast in code** staat. Maubot-plugin `nl.eddydevink.rss` (RSS → Matrix + WhatsApp), met een OV-nieuwsfilter dat **vast in code** staat.
## Verplicht vóór elke filterwijziging ## Verplicht vóór elke filterwijziging
1. Lees het **volledige classificatiebeleid** in `buzzrss/bot.py` — het blok BOVEN `MATCH_FILTER_FAMILIES` 1. Lees het **volledige classificatiebeleid** in `buzzrss/bot.py` — het blok BOVEN `MATCH_FILTER_STRONG`
(max-2-stelsel: whitelist + gewichten 2/1, precision-first, en de werkwijze voor nieuwe woorden). (drie regels: sterk signaal in de titel → door; score van unieke semantische groepen ≥ 3 mét
2. Draai **`python3 buzzrss/validate_filter.py`** — de filter-invarianten en regressie-cases MOETEN groen titel-signaal → door; anders wegfilteren. Sterk=2, ambigu=1/2, drempel 3; geen whitelist/blacklist —
woordgrenzen weren de botsingen. Werkwijze voor nieuwe woorden = beleidsblok).
2. Draai **`python3 validate_filter.py`** (repo-root) — de filter-invarianten en regressie-cases MOETEN groen
zijn voordat filtercode gecommit/gedeployed wordt. zijn voordat filtercode gecommit/gedeployed wordt.
3. **Compleetheids-audit (verplicht, besluit 2026-09-12):** check bij elke filterwijziging óók of de 3. **Compleetheids-audit (verplicht, besluit 2026-09-12):** check bij elke filterwijziging óók of de
woordenlijsten compleet zijn — draai de OV-gap-scan over de afgewezen artikelen van de woordenlijsten compleet zijn — draai de OV-gap-scan over de afgewezen artikelen van de
@ -13,8 +15,8 @@ Maubot-plugin `nl.eddydevink.rss` (RSS → Matrix + WhatsApp), met een OV-nieuws
klasseer nieuwe kandidaten volgens het beleid (minimaal één extra LLM; enige stem TWIJFEL/NIET-OV => zwak) klasseer nieuwe kandidaten volgens het beleid (minimaal één extra LLM; enige stem TWIJFEL/NIET-OV => zwak)
en vul de families desgevraagd aan. Exacte procedure = stap (e) in het beleidsblok in `buzzrss/bot.py`. en vul de families desgevraagd aan. Exacte procedure = stap (e) in het beleidsblok in `buzzrss/bot.py`.
4. Precision-first (gebruikersbesluit 2026-09-10): **liever een OV-bericht te veel gefilterd dan een 4. Precision-first (gebruikersbesluit 2026-09-10): **liever een OV-bericht te veel gefilterd dan een
niet-OV-bericht te veel doorgelaten.** Bij twijfel over een woord: zwak maken (eigen familie), nooit niet-OV-bericht te veel doorgelaten.** Bij twijfel over een woord: ambigu maken (eigen groep), nooit
sterk/whitelist. sterk.
## Niet doen ## Niet doen
- **NOOIT** maubot `reload` gebruiken. Deploy = mbp bouwen uit de VOLLEDIGE bron - **NOOIT** maubot `reload` gebruiken. Deploy = mbp bouwen uit de VOLLEDIGE bron

View file

@ -38,53 +38,40 @@ Bestuurders: admins uit de config, of gebruikers met een powerlevel ≥ dat van
RSS-state-event in die room (default 50 als geen level is ingesteld). RSS-state-event in die room (default 50 als geen level is ingesteld).
Bij een room-tombstone worden abonnementen automatisch naar de vervangende room verplaatst. Bij een room-tombstone worden abonnementen automatisch naar de vervangende room verplaatst.
## Het OV-matchfilter (v1.14.0) ## Het OV-matchfilter (v1.15)
De volledige classificatie + de werkwijze voor nieuwe woorden staat in De volledige classificatie + de werkwijze voor nieuwe woorden staat in
`buzzrss/bot.py`, boven `MATCH_FILTER_FAMILIES` — **dat blok is leidend** bij elke `buzzrss/bot.py`, boven `MATCH_FILTER_STRONG` — **dat blok is leidend** bij elke
wijziging. Samengevat: wijziging. Samengevat, in drie regels:
```text ```text
score = Σ gewichten van unieke OV-termen over titel + samenvatting 1. staat er een STERK signaal in de TITEL? -> PLAATSEN
(één term = één signaal per artikel; overlappende familie-treffers wegen 1× 2. score = som unieke groeps-gewichten ≥ 3 én de titel bevat een signaal? -> PLAATSEN
met het hoogste gewicht — span-merge, besluit 2026-09-13) 3. anders -> NIET PLAATSEN
+ 2 als een whitelist-term óf een dienststoring-zin in de TITEL staat
doorlaten ⇔ score ≥ 3 én (titel-signaal ∨ geen zwakke term in de score)
``` ```
Stappen: - **Sterk (2 punten, titel = zelfstandig)**: vervoerders (NS, ProRail, Arriva,
RET, GVB, Qbuzz, Deutsche Bahn, TESO, ...), spoorjargon (spoorstoring, spoorlijn,
1. **Sterk titel-signaal — door.** Een whitelist-term (NS, ProRail, spoorstoring, wisselstoring, treinstoring, ertms, perron, ...), bus-samenstellingen
perron, bushalte, ovpay, openbaar vervoer, …) of een dienststoring-zin (bushalte, buslijn, busbaan, buschauffeur), tramverkeer/tramlijn, metrolijn,
(‹vervoerswoord› `rijdt/rijden … niet`, bv. 'sprinters rijden niet', 'metro rijdt niet') intercity/streekvervoer/veerdienst/buurtbus, conducteur, ov-chipkaart/ovpay,
in de titel is voldoende. openbaar vervoer, lightrail, 'lijn 12', ICE-frasen, rangeer... én de
2. **Context — som ≥ 3.** Gewichten: **sterk (2)** = onmiskenbaar OV dienststoring-zin ('sprinters rijden niet', 'metro rijdt niet').
(vervoerders, spoorinfra, bus-samenstellingen, trein-storingsjargon, tram/metro/halte, - **Ambugue (1 punt; halte/tram/metro/spoorbrug 2)** — contextafhankelijk,
storingszin, …), **zwak (1)** = dubbelzinnig, elk met een eigen familie nooit zelfstandig voldoende, alleen als combinatie: `bus`, `trein`,
(trein, bus, station(s), sprinter, reiziger, vervoerder, sein, overweg, breng, …) `station(s)`, `reiziger`/`passagier`, `vervoer(der)`, `sprinter`,
en alleen als combinatie geldig. Max-2-stelsel: geen enkele familie weegt meer dan 2, `pendelbus`/`shuttlebus`, `dienstregeling`, `ontsporen`, `spoor`.
één sterke hit kan nooit solo doorlaten. - **Semantische groepen tellen één keer per artikel**: 'bus' + 'buslijn' +
3. **Titel-anker.** Zonder enig OV-signaal in de titel (geen familie-hit, geen 'busvervoer' + 'busstation' is één bus-signaal (bus-samenstelling, bus,
whitelist-term, geen storingszin) mag de score geen zwak woord bevatten — station — nooit vier onafhankelijke signalen).
'station' + 'pendelbus' + 'bus' in een samenvatting is geen OV-bewijs. - **Geen whitelist, geen blacklist**: woordgrenzen en koppelteken-lookbehinds
Uitzondering die bewust blijft: **twee sterke termen in de samenvatting** (bv. de weren structureel 'schoolbus'/'VW-bus', 'stationsstraat'/'stationair'/
delftopzondag-bereikbaarheidszin 'tram 1, halte Buitenhofdreef', besluit 2026-09-13). 'stationcar'/'treinstraat'/'treinweg'. Bekende false positives blijven geweerd:
krant 'De Metro', café 'De Tram', atleet-'sprinter', werkwoord 'brengen',
Zwarte lijst (`MATCH_FILTER_BLACKLIST`): gematchte woorden zonder OV-waarde —
na de v1.14.0-opschoning alleen nog `treinstraat`/`treinweg`. De oudere
stations\*-vormen (`stationsstraat`, `stationair`, `stationcar`, `stationeren`, …)
zijn inert sinds de woordgrens-familie `\bstations?\b` en worden door tests bewaakt.
### Bekende bewust-geaccepteerde doorlatingen / weigeringen
- *Door*: 'met een tram en halte bereikbaar'-zinnen in lokale berichten (2 sterke
termen, samenvatting) — kurk op de fles is het max-2-stelsel + titel-anker.
- *Geweerd*: krant 'De Metro', café 'De Tram', atleet-'sprinter',
'VW-bus'/'schoolbus' (koppelteken- en woordgrensregels), werkwoord 'brengen',
idioom 'goed met elkaar overweg kunnen', 'een wijk opwaarderen', idioom 'goed met elkaar overweg kunnen', 'een wijk opwaarderen',
brandweerjargon 'sein brandmeester', figuratief 'weer op de rails', brandweerjargon 'sein brandmeester', figuratief 'weer op de rails',
achternaam 'Hermes' (Margraten), 'restaurant/woning naast busstation'. achternaam 'Hermes', buitenlandse veerboot-/busrampen, 'restaurant/woning naast
busstation'.
## Tests ## Tests
@ -92,13 +79,13 @@ zijn inert sinds de woordgrens-familie `\bstations?\b` en worden door tests bewa
python3 validate_filter.py python3 validate_filter.py
``` ```
Vaste invarianten + regressie (momenteel **90 cases**): Vaste invarianten + regressie (momenteel **110 cases**):
1. dubbelzinnige woorden → alleen zwak (1), nooit whitelist; 1. gewichten geldig (sterk 2; ambigu in {1, 2}), drempel 3;
2. onmiskenbare OV-termen → sterk (2) of whitelist; 2. trefwoorden matchen hooguit één groep per categorie (geen overlappende regexes);
3. alle families compileren, gewichten in {1, 2}; 3. semantische varianten ('bus' + 'buslijn' + 'busvervoer', 'station'-reeks)
4. elke whitelist-term zit óók in een familie én elke zwartgelijste term wordt door leveren samen één signaal op;
een familie gematcht (geen inert gewicht). 4. regressie: bekende positive/negative/combinatie-cases (precision-first).
**Verplicht vóór elke filterwijziging** (zie `AGENTS.md`): het classificatiebeleid in **Verplicht vóór elke filterwijziging** (zie `AGENTS.md`): het classificatiebeleid in
`buzzrss/bot.py` lezen, `validate_filter.py` groen, en de **compleetheids-audit**: `buzzrss/bot.py` lezen, `validate_filter.py` groen, en de **compleetheids-audit**:

View file

@ -52,283 +52,103 @@ from .migrations import upgrade_table
rss_change_level = EventType.find("xyz.maubot.rss", t_class=EventType.Class.STATE) rss_change_level = EventType.find("xyz.maubot.rss", t_class=EventType.Class.STATE)
# Gewogen OV-trefwoord-families voor het match-filter. Elke tuple = (regex, gewicht).
# Per familie wordt 1× geteld per ARTIKEL (titel én samenvatting samen; herhaling
# of het woord in beide velden = één signaal — 'busstation' 60× weegt 1×, besluit
# 2026-09-11). Eén term die meerdere families matcht telt óók maar 1× (hoogste
# gewicht wint): 'ov-chipkaart' bevat 'chipkaart', 'openbaar vervoer' bevat
# 'vervoer', 'treinstoring' matcht de zwakke \w*trein\w*-familie mee — geen
# dubbeltelling (besluit 2026-09-13; 'busbaan' stond in twee families en is
# uit de halte-familie verwijderd, refactor 2026-09-13). Drempel (3): doorlaten
# als de som van de unieke familie-gewichten >= MATCH_FILTER_THRESHOLD
# (titel weegt niet extra).
# MAX-GEWICHT 2 — besluit 2026-09-12: een enkele FAMILIE-hit kan nooit solo
# doorlaten (2 < 3); écht OV-nieuws combineert in de praktijk altijd met een
# tweede signaal (gevalideerd op 205 productie-artikelen; zie beleidsblok).
# Gewicht 2 (onmiskenbaar OV: merken, sectorjargon, samenstellingen als bushalte/
# spoorlijn/veerboot — de vroegere 'sterk(3)' én 'medium(2)' zijn hierin
# opgegaan, want beide moeten nu combineren).
# Gewicht 1 (zwak/dubbelzinnig): moet met een andere OV-familie combineren
# (een tweede, andere familie — dezelfde zwakke familie in titel én samenvatting
# telt niet als combinatie) EN minstens één zwak woord moet in de TITEL staan:
# een combinatie van alleen zwakke woorden in de samenvatting is geen bewijs
# (het golfbericht met "station" + "pendelbus" + "bus" in de samenvatting is
# geen OV-nieuws). Weert zo "VW-bus"/"schoolbus", "weer op de rails", het
# werkwoord "brengen", het idioom "goed met elkaar overweg kunnen", het werkwoord
# "overwegen", het werkwoord "opwaarderen" en brandweerjargon ("sein brandmeester"),
# zonder eenduidige bushalte/overweg/NS/trein-berichten te raken.
# Inflectie: families matchen met \w* aan het eind, zodat meervoud en vervoeging
# meegenomen worden ("spoorlijnen", "seinstoringen", "ontspoorde", "chipkaarten").
# Een koppelteken vóór een match is géén OV-signaal: "VW-bus" en "radio-station"
# vallen af (lookbehind bij 'bus' resp. 'station').
# MATCH_FILTER_BLACKLIST: gematchte woorden die tóch geen OV-waarde hebben (o.a.
# straatnamen rond stations — "de Stationsstraat afsluiten voor een festival"
# is geen OV-nieuws). Zwartgelijste woorden leveren geen score op, maar
# blokkeren de familie niet: een écht "station"-woord ernaast scoort gewoon.
# Bewust geaccepteerde botsingen: krant "De Metro"/café "De Tram" (gewicht 2,
# moet combineren). 'spoorbrug' weegt 2: een brug is vaak louter locatie
# ('bij de spoorbrug tussen Liempde en Boxtel', Stuifmail 2026-09-12) — écht
# spoorbrug-nieuws combineert met trein/spoor/verkeer (besluit 2026-09-12).
# Naam-botsingen die bewust zijn afgezwakt (2026-09-12): 'hermes' (vervoerder
# Hermes vs. achternaam Ton Hermes — Margraten-false positive), 'breng'
# (vervoerder vs. werkwoord 'brengen'), 'spoor' (figuurlijk 'op het spoor van').
# Losse algemene woorden zijn zwak (moeten combineren): "trein", "sprinter"
# (atleet), "vervoerder" (logistiek), "reiziger"/"passagier" (vliegen),
# "inchecken"/"uitchecken" (Schiphol/hotel), "station(s)" (omroepen), "bus",
# "sein" (brandweer), "overweg", "breng", "kaartje", "opwaarderen", "rails",
# "staking" (alle sectoren), "vervoer" (vracht/auto), "abonnement", "ritten".
# Multi-woord-frasen en merknamen (ice-berlijn, deutsche bahn, european sleeper,
# ns international, teso, italo, nightjet, maaslijn) zijn onmiskenbaar OV en
# wegen 2; een los 'ice' is NIET toevoegbaar (case-insensitive botsing met 'ijs',
# besluit 2026-09-12).
# ===================================================================== # =====================================================================
# FILTERCLASSIFICATIE-BELEID (bij elke aanpassing aan het filter volgen) # OV-MATCHFILTER (v1.15) — precisie boven recall (gebruikersbesluit
# ===================================================================== # 2026-09-10): liever een OV-bericht te veel wegfilteren dan één niet-OV-bericht
# Doel: precisie boven recall. Liever een OV-bericht te veel gefilterd dan # te veel plaatsen.
# een niet-OV-bericht te veel doorgelaten (gebruikersbesluit, 2026-09-10).
# #
# Beslissing, in drie stappen (refactor 2026-09-13; logica equivalent met de # Beslissing, in drie regels:
# voorheen gelaagde takken — MAX-2-stelsel, besluit 2026-09-12): # 1. STERK signaal in de TITEL -> plaatsen
# 1. STERK SIGNAAL (whitelist-term óf dienststoring-zin in de TITEL) -> DOORLATEN. # 2. score >= drempel (3) én de TITEL bevat minstens één signaal
# Whitelist = termen die in élke context OV betekenen (merken: # -> plaatsen (context)
# NS/ProRail/Arriva; onmiskenbaar jargon: spoorstoring, perron, bushalte, # 3. anders -> wegfilteren
# conducteur, ovpay; multi-woord-frasen: nederlandse spoorwegen, openbaar
# vervoer, ice-berlijn ...). Een whitelist-hit in de titel telt +2,
# bovenop het familie-gewicht van dezelfde term (elke whitelist-term zit
# óók in een familie) — samen altijd >= drempel, dus eenduidige OV-titels
# ('Reactie Nederlandse Spoorwegen', 'Werkzaamheden aan perron') passeren
# alleen. REGEL: twijfel je of de term ook buiten het OV kan voorkomen ->
# NIET hier, maar zwak (eigen familie). Een whitelist-term ALLEEN in de
# SAMENVATTING blijft inert: een bijzin als 'de zakelijke bezoeker is ook
# nodig voor het openbaar vervoer' in een economiebericht is geen
# OV-nieuws (besluit 2026-09-12); zo'n hit scoort alleen via de familie.
# 2. CONTEXT: som van unieke OV-signalen over titel + samenvatting
# >= drempel (3). Per familie 1× per artikel (herhaling of beide velden
# tellen niet dubbel, besluit 2026-09-11); overlappende familie-treffers
# van dezelfde term wegen 1× met het hoogste gewicht (span-merge in
# _fam_hits, besluit 2026-09-13 — 'ov-chipkaart' bevat 'chipkaart' maar
# weegt als één term 2, geen 3).
# - sterk (2): onmiskenbaar OV-jargon/merken/samenvoegingen (spoorlijn,
# bushalte, busverkeer, intercity, veerboot, ertms,
# rangeer..., ice-frasen, ...) én het dienststoring-
# contextpatroon 'vervoerswoord + rijdt/rijden (niet) +
# niet' ('sprinters rijden niet', 'metro rijdt niet').
# Eén hit (titel óf samenvatting) is onvoldoende: moet
# combineren (2 + 1 = 3). De vroegere 'één sterk woord
# volstaat'-regel is vervallen — 'hermes' als achternaam
# passeerde zo op één vervoerder-hit (Margraten,
# 2026-09-12).
# - zwak (1): DUBBELZINNIGHEID. Woorden die (ook) buiten het OV
# voorkomen. REGEL: elk dubbelzinnig woord krijgt een EIGEN
# familie (niet samengevoegd), zodat twee verschillende
# zwakke woorden wél als combinatie tellen, maar één enkel
# woord nooit de doorslag kan geven. Bekende dubbelzinnige:
# trein, bus, station(s), sprinter, reiziger/passagier,
# vervoerder, inchecken/uitchecken, sein, overweg, breng,
# kaartje, opwaarderen, rails, dienstregeling, chipkaart,
# reisproduct, vervoer(s)bewijs, ontsporen,
# pendelbus/shuttlebus (evenement-/hotel-/parkeerpendel;
# synoniemen -> bewust één familie), busongeluk/busvervoer
# (touringcar/schoolbus), staking, vervoer, vervoersbedrijf,
# vervoerregio, ritten, bovenleiding (industrie),
# abonnement, hermes, spoor.
# 3. TITEL-ANKER: staat er geen enkel OV-signaal in de titel (geen
# familie-hit, geen whitelist-term), dan is de score uitsluitend
# samenvatting-bewijs en mag die geen zwak woord bevatten. Twee/meer
# zwakke woorden in de samenvatting ('station' + 'pendelbus' + 'bus' in
# een golfverslag) en sterk+zwak uitsluitend in de samenvatting
# ('openbaar vervoer' + 'zakelijke reizigers' in een economiebericht,
# beide besluiten 2026-09-12) zijn geen OV-bewijs. Alleen twee sterke
# termen in de samenvatting blijven bewust door (delftopzondag
# 'tram 1, halte Buitenhofdreef', besluit 2026-09-13).
# BLACKLIST -> gematchte woorden zonder OV-waarde, restant van
# straatnamen rond spoorlijnen die de zwakke \b\w*trein\w*\b-familie zou
# matchen ('treinstraat'/'treinweg'); blokkeren de familie niet. De
# oudere stations*-varianten zijn sinds de woordgrens-familie
# \bstations?\b inert en verwijderd (2026-09-13) — gedrag vastgelegd in
# validate_filter.py.
# #
# BEOORDELEN VAN EEN NIEUW WOORD (incl. COMPLETEHEIDS-AUDIT, 2026-09-12): # score = som van de gewichten van unieke SEMANTISCHE GROEPEN over titel +
# a) Kan het ook buiten het OV voorkomen? ja -> zwak (1), eigen familie. # samenvatting. Elke groep telt hooguit één keer per artikel — meerdere woorden
# b) Is het een samenvoeging/merk die alleen OV kan betekenen? -> sterk (2) # uit dezelfde groep ('bus' + 'buslijn' + 'busvervoer', of 'station' +
# of whitelist (frasen die in élke context OV betekenen). # 'stationsgebouw' + 'stationsplein') leveren samen één signaal op en tellen
# c) Bij twijfel: zwak (1). Conservatief = precision-first. # nooit kunstmatig op (refactor v1.15). Woordgrenzen en koppelteken-lookbehinds
# d) Ter controle: leg nieuwe woordkandidaten voor aan minimaal één extra # weren de bekende botsingen structureel: 'schoolbus'/'VW-bus' matchen de
# taalmodel (de audit op 2026-09-10 gebruikte 3 modellen; enige stem # bus-groep niet, 'stationsstraat'/'stationair'/'stationcar'/'stationwagen' de
# 'TWIJFEL'/'NIET-OV' => behandelen als dubbelzinnig). # station-groep niet — er is GEEN zwarte lijst meer nodig.
# e) COMPLETEHEID (besluit 2026-09-12): check bij elke filterwijziging óók of #
# de families compleet zijn — draai de OV-gap-scan over de afgewezen # STERK (2 punten): termen die vrijwel altijd betekenen dat het artikel over
# artikelen van OV-specialist-feeds (SpoorPro/Treinreiziger/Mobiliteit), # OV gaat; in de TITEL zijn ze zelfstandig voldoende ("een sterk signaal mag
# classificeer nieuwe kandidaten via stap a-d en vul aan. De audit van # zelfstandig voldoende zijn"). In de samenvatting tellen ze als context van 2.
# 2026-09-12 leverde o.a. spoorsector, spoorbeheerder, spoornet, # Honkvaste typografie: 'ns' alleen op woordgrens (niet in 'kunstenaar'),
# spoorvervoerder, treinvertraging/uitval, ertms, teso, rangeer, ice-frasen, # 'ov' niet in 'ov-chipkaart'/'ov-staking' ((?!-)-lookahead), de Meervouds-
# maaslijn, ns international, deutsche bahn, european sleeper, italo, # vormen via (?:en)?-suffix.
# nightjet (sterk) en 11 zwakke woorden op. # AMBIGU (1 punt; halte/tram/metro/spoorbrug 2): contextafhankelijk en nooit
# Dienststoring-contextpatroon: vervoerswoord + 'rijdt/rijden (niet)' + 'niet' # zelfstandig voldoende — 'bus', 'trein', 'station', 'tram', 'metro', 'halte',
# ('sprinters rijden niet', 'metro rijdt niet tussen Rotterdam en Schiedam'). # 'reiziger', 'vervoerder', 'sprinter', 'pendelbus', 'spoorbrug',
# Gedeeld door de familie én het titel-anker (_disruption_hit): in de TITEL is de # 'dienstregeling', 'ontsporen', 'spoor'. Alleen combinaties tellen:
# storingszin een sterk OV-signaal (net als een whitelist-term), in de samenvatting # trein+spoorlijn, metro+rijdt-niet, tram+dienstregeling, sprinter+NS,
# weegt hij als familie (2). 'rijden' an sich is géén OV-signaal. 'schoolbus' # bus+halte. Dezelfde groep in titel én samenvatting blijft één signaal.
# matcht niet ('bus' niet aan het woordbegin), refactor 2026-09-13.
_DISRUPTION_RE_SRC = (
r"\b(?:sprinters?|stoptrein(?:en)?|intercity'?s?|trein(?:en)?|trams?|bussen|bus|"
r"metro'?s?|veerdienst(?:en)?)\s+(?:rijdt|rijden|reed|reden)\s+niet\b"
)
# ===================================================================== # =====================================================================
MATCH_FILTER_FAMILIES: list[tuple[str, int]] = [ MATCH_FILTER_STRONG: list[tuple[str, str]] = [
# sterk (2) — onmiskenbaar OV, maar één hit is onvoldoende (max-2-stelsel, # vervoerders / OV-merken
# besluit 2026-09-12): elke echte OV-bericht combineert met een tweede signaal. (r"\b(?:ns|prorail|arriva|qbuzz|connexxion|cxx|transdev|keolis|ret|gvb|htm|ebs"
(r"\b(?:ns|arriva|qbuzz|connexxion|cxx|transdev|keolis|ret|gvb|ebs|htm|syntus|u-ov|allgo|flixbus|blauwnet|r-net|valleilijn|waterbus|eurostar|thalys|randstadrail|gvu|prorail|rrreis|qliner|ov|teso|deutsche bahn|european sleeper|ns international|italo|nightjet)\b", 2), # vervoerders; 'hermes' is zwak (achternaam-botsing, Margraten 2026-09-12), 'Breng' ook (werkwoord "brengen"); internationale vervoerders: teso (veer), deutsche bahn/european sleeper/italo/nightjet/ns international (audit 2026-09-12) r"|syntus|u-ov|allgo|flixbus|blauwnet|r-net|valleilijn|gvu|rrreis|qliner|teso"
(r"\b(?:spoorweg|spoorba(?:an|nen)|spoorlijn|spoorverkeer|spoorwegovergang|spoorstoring|perron|seinstoring|wisselstoring|spoorstaking|spoorsector|spoorbeheerder|spoornet|spoorvervoerder|ertms)\w*\b", 2), # spoorinfra; let op de klinkerwisseling: 'ontsporing' (open lettergreep, één o) vs 'ontspoord'/'ontspoorde' (dicht, dubbel o) — vandaar beide stammen. Géén 'spoorbrug': die is 2 maar vaak louter locatie (besluit 2026-09-12). Uitbreiding 2026-09-12 (completheids-audit): spoorsector, spoorbeheerder, spoornet, spoorvervoerder, ertms r"|eurostar|thalys|randstadrail|deutsche bahn|ns international|european sleeper"
(r"\b(?:treinstation|treinstations|metrostation|metrostations|busstation|busstations)\b", 2), # station-samenstellingen: onmiskenbaar OV, maar één station-woord náást een ander onderwerp ('pand naast busstation', besluit 2026-09-11) is geen OV-bericht; in de samenvatting moet een tweede OV-woord bijkomen r"|italo|nightjet)\b", "vervoerders"),
(r"(?<![\w-])\bstations?\b", 1), # los 'station(s)' is ZWAK: ook tv-/radio-/regionale omroepstations ('de lokale stations'), moet combineren met een andere OV-familie (r"(?<!-)\bov\b(?!-)", "ov"), # 'het OV'; niet in ov-chipkaart/ov-staking
(r"\b(?:bus|tram|metro)-?(?:halte|haltes|lijn|lijnen|verkeer|net|staking|ba(?:an|nen)|hok(?:je|jes)|chauffeur)\w*\b", 2), # voertuig-samenstelling (bushalte/buslijn/bushokje/busbanen/buschauffeur/…); géén 'station': *station-samenstellingen zitten in de station-familie, anders telde 'busstation' dubbel mee — fix 2026-09-11. Géén 'ongeluk'/'vervoer': die zijn bewust zwak (busongeluk = ook touringcar, busvervoer = ook bedrijfsvervoer, audit 2026-09-12). 'busbaan'/'bushokje' stonden óók in de halte-familie — verwijderd bij de refactor 2026-09-13 (geen dubbelwoorden in twee families) (r"\b(?:spoorweg(?:en)?|spoorba(?:an|nen)|spoorlijn(?:en)?|spoorverkeer"
(r"\b(?:treinstoring|treinverkeer|treinstaking|treinongeval|treinbotsing|treinongeluk|treinvertraging|treinhinder|treinuitval|treinverstoring)\w*\b", 2), # trein-jargon-samenstellingen: onmiskenbaar OV; uitgebreid met vertraging/hinder/uitval/verstoring (audit 2026-09-12) r"|spoorwegovergang(?:en)?|spoorstoring(?:en)?|spoorstaking(?:en)?|spoorsector"
(r"\b\w*trein\w*\b", 1), # losse 'trein' en algemene samenstellingen (stoptrein, goederentrein, treinverkeer al in bovenstaande familie) — ZWAK: 'de trein naar mijn nieuwe huis' is geen OV-jargon, moet combineren met een andere familie r"|spoorbeheerder|spoornet|spoorvervoerder|wisselstoring(?:en)?|seinstoring(?:en)?"
(r"\b(?:intercity|streekvervoer|stadsvervoer|veerdienst|veerboot|veerboten|veerpont|voetveer|veerverbinding|buurtbus|belbus|maaslijn|nedersaksenlijn|kamperlijn)\w*\b", 2), # treintypes, vervoerssectoren, veren en lijnnamen ('sprinter' staat in de zwakke groep: atleet-botsing); maaslijn/nedersaksenlijn/kamperlijn via audit 2026-09-12 r"|ertms|treinstoring(?:en)?|treinverkeer|treinstaking(?:en)?|treinongeval(?:len)?"
(r"\blijn\s?\d{1,3}\b", 2), # 'lijn 12' — vervoerslijn met nummer r"|treinbotsing(?:en)?|treinvertraging(?:en)?|treinhinder|treinuitval"
(r"(?:\brangeer\w*|\brangeren\w*)\b", 2), # rangeren/rangerend/rangeerterrein/rangeerbedrijf: onmiskenbaar spoorjargon (audit 2026-09-12; stam 'rangeren' = ranger+en, 'rangeerterrein' = rangeer+...) r"|treinverstoring(?:en)?|treinstel(?:len)?)\b", "spoorjargon"),
(r"\bice[\s\-–—]*(?:berlijn|hamburg|brussel|bremen|parijs|frankfurt|keulen|amsterdam|bazel|milaan|kopenhagen|zurich|zürich|duitsland|international)\b|\bice-trein\w*\b", 2), # ICE-frasen: onmiskenbaar trein; los 'ice' is NIET toevoegbaar (case-insensitive botsing met 'ijs', besluit 2026-09-12) (r"\bperrons?\b", "perron"),
# onmiskenbaar OV maar iets algemener (2, moet ook combineren) (r"\b(?:bushaltes?|buslijn(?:en)?|busba(?:an|nen)|buschauffeurs?|bushokjes?"
(r"\btrams?\b", 2), r"|busnet(?:ten)?|busverkeer|busstaking(?:en)?)\b", "bussamenstelling"),
(r"\bmetro(?:'?s)?\b", 2), (r"\b(?:tramhaltes?|tramlijn(?:en)?|tramverkeer|tramnet(?:ten)?)\b", "tramsamenstelling"),
(r"\b(?:openbaar vervoer|lightrail|halte)\w*\b", 2), # frasen en halte-hits; 'bushokje'/'busbaan' matchen al de bus-samenstelling-familie — bewust niet dubbel in twee families (refactor 2026-09-13) (r"\b(?:metrolijn(?:en)?|metroverkeer)\b", "metrosamenstelling"),
(r"\b(?:ov-chipkaart|ovpay|ov-pay|studentenreisproduct)\w*\b", 2), (r"\b(?:intercity'?s?|streekvervoer|stadsvervoer|buurtbus(?:sen)?|belbus(?:sen)?"
(r"\b(?:ov-staking|conducteursstaking)\w*\b", 2), r"|veerdienst(?:en)?|waterbus(?:sen)?)\b", "treintype"),
(_DISRUPTION_RE_SRC, 2), # dienststoring-context ('sprinters rijden niet', 'metro rijdt niet...'); zie _DISRUPTION_RE_SRC (r"\b(?:maaslijn|nedersaksenlijn|kamperlijn)\b", "lijnnaam"), # OV-lijnnamen (audit 2026-09-12)
(r"\bconduct(?:eur|eurs)\b", 2), # trein-/buspersoneel: onmiskenbaar OV-jargon (r"\bconducteurs?\b", "conducteur"),
(r"\b(?:pont|ponten)\b", 2), # waterbus/veerdienst (r"\b(?:ov-chipkaart|ovpay|ov-pay|studentenreisproduct)\b", "ovtarief"),
(r"\bspoorbrug(?:gen)?\b", 2), # spoorinfra, maar vaak locatie-referentie ('bij de spoorbrug', Stuifmail 2026-09-12); moet combineren — zelfde regel als station-samenstellingen (r"\blightrail\b", "lightrail"),
# zwak (1) — dubbelzinnig, moet combineren (r"\blijn\s?\d{1,3}\b", "lijnnummer"),
(r"(?<!-)\bbus(?:sen)?\b", 1), # ná een koppelteken geen OV: 'VW-bus', 'school-bus' (r"\b(?:rangeer\w*|rangeren)\b", "rangeren"),
(r"\b(?:pendelbus|shuttlebus)\w*\b", 1), # evenement-/hotel-/parkeerpendel ('pendelbus naar de golfbaan') is geen OV; synoniemen bewust in één familie (r"\bopenbaar vervoer\b", "openbaar vervoer"),
(r"\b(?:inchecken|uitchecken)\w*\b", 1), # ook op luchthavens/hotels — geen OV-jargon, moet combineren (r"\bice[\s\-–—]*(?:berlijn|hamburg|brussel|bremen|parijs|frankfurt|keulen"
(r"\bsprinters?\b", 1), # ook atleet ('sprinter verpulvert record') — aparte familie, moet combineren r"|amsterdam|bazel|milaan|kopenhagen|zurich|zürich|duitsland|international)\b"
(r"\bvervoerders?\b", 1), # ook logistiek ('vrachtvervoerder') — aparte familie, moet combineren r"|\bice-trein(?:en)?\b", "ice"),
(r"\b(?:reizigers?|passagiers?)\b", 1), # ook 'zakenreizigers vliegen' / vliegtuigpassagiers — synoniemen bewust één familie (audit 2026-09-12) # dienststoring: vervoerswoord + 'rijdt/rijden/vaart (niet)' + 'niet'
(r"\bdienstregeling\w*\b", 1), # dubbelzinnig (modelaudit) — meestal OV, maar moet combineren (r"\b(?:sprinters?|stoptrein(?:en)?|intercity'?s?|trein(?:en)?|trams?|bussen|bus"
(r"\bchipkaart(?:en)?\b", 1), # dubbelzinnig (bankpas/algemeen) — aparte familie r"|metro'?s?|veerdienst(?:en)?)\s+(?:rijdt|rijden|reed|reden|vaart|varen)"
(r"\breisproduct(?:en)?\b", 1), # dubbelzinnig — aparte familie r"\s+niet\b", "dienststoring"),
(r"\bvervoer(?:s)?bewijs\w*\b", 1), # dubbelzinnig — aparte familie ]
(r"\b(?:ontspor|ontspoor)\w*\b", 1), # werkwoord 'ontsporen' is dubbelzinnig ('het plan ontspoorde'); echte trein-ontsporing combineert met trein/spoor MATCH_FILTER_AMBIGUE: list[tuple[str, int, str]] = [
(r"\bbreng\b", 1), # vervoerder Breng vs werkwoord "brengen" ("hoe breng je ... in beeld") (r"\bhaltes?\b", 2, "halte"), # contextzwaar, niet zelfstandig
(r"\boverweg\b", 1), # spoorwegovergang vs idioom "goed met elkaar overweg kunnen" (r"\btrams?\b", 2, "tram"),
(r"\boverwegen\b", 1), # meervoud van overweg vs werkwoord ("een gratieverlening overwegen") (r"\bmetro'?s?\b", 2, "metro"),
(r"\bsein\b", 1), # spoorsein vs brandweerjargon ("sein brandmeester") en figuurlijk gebruik (r"\bspoorbrug(?:gen)?\b", 2, "spoorbrug"), # vaak louter locatie (v1.10.1)
(r"\bkaart(?:je|jes)\b", 1), (r"(?<!-)\bbus(?:sen)?\b", 1, "bus"), # 'VW-bus'/'schoolbus' matchen niet
(r"\bopwaarderen\b", 1), # ov-chipkaart opladen vs werkwoord "een wijk/park opwaarderen" (r"\b\w*trein\w*\b", 1, "trein"), # trein, stoptrein, treinreizigers…
(r"\brails\b", 1), (r"\bstations?\b|\b(?:trein|bus|metro)station(?:s)?\b", 1, "station"),
(r"\bbusongeluk(?:ken)?\b", 1), # audit 2026-09-12: ook touringcar/schoolbus; combineert met buschauffeur/bus → door (busongeluk Zwitserland) (r"\breizigers?\b|\bpassagiers?\b", 1, "reiziger"),
(r"\bbusvervoer\b", 1), # audit 2026-09-12: ook touringcar-/bedrijfsvervoer (r"(?<!openbaar )\bvervoer(?:der|ders|sbedrij(?:f|ven)|regio'?s?)?\b", 1, "vervoer"),
(r"\bspoor\b", 1), # figuurlijk 'op het spoor van de daders', 'het spoor bijster'; écht spoornieuws combineert met spoorlijn/trein/perron (r"\bsprinters?\b", 1, "sprinter"),
(r"\bstaking(?:en)?\b", 1), # arbeidsstaking in alle sectoren; 'spoorstaking'/'busstaking'/'ov-staking' zitten in de sterke families (besluit 2026-09-12) (r"\b(?:pendel|shuttle)bus(?:sen)?\b", 1, "pendel"), # evenement-/hotelpendel
(r"\bvervoer\b", 1), # ook vracht-/autovervoer; 'openbaar vervoer' zit in de sterke familie; samenstellingen (streekvervoer e.d.) matchen hier niet (geen \b) (r"\bdienstregeling\w*\b", 1, "dienstregeling"),
(r"\bvervoersbedrijf(?:ven)?\b", 1), # ook goederen-/taxibedrijf (audit 2026-09-12) (r"\b(?:ontspor(?:en|ing)?|ontspoord(?:e)?)\b", 1, "ontsporen"),
(r"\bvervoerregio'?s?\b", 1), # OV-autoriteit ('Vervoerregio Amsterdam'), maar audit: ook algemener vervoersgebied (r"\bspoor\b", 1, "spoor"), # figuurlijk 'op het spoor van'
(r"\britten\b", 1), # ook fiets-/taxiritten (audit 2026-09-12)
(r"\bbovenleiding(?:en)?\b", 1), # audit 2026-09-12: ook water/gas/elektra-bovenleiding; spoor-combinatie (spoorlijn/ertms) haalt de drempel
(r"\babonnement(?:en)?\b", 1), # gym/museum/telefoon (audit 2026-09-12)
(r"\bhermes\b", 1), # vervoerder Hermes vs. achternaam (Ton Hermes, stichting Adoptie Graven Margraten, 2026-09-12)
] ]
# Drempel voor doorlaten: som van unieke familie-gewichten >= 3. Max-2-stelsel # Drempel voor de context-regel (stap 2): som van unieke groeps-gewichten >= 3.
# (besluit 2026-09-12): geen enkele familie weegt meer dan 2, dus een enkele hit # Max-gewICHT 2: een enkel signaal (zelfs halte/tram/metro/spoorbrug) kan nooit
# kan nooit solo doorlaten; een whitelist-term in de titel telt +2 (zie # solo doorlaten; écht OV-nieuws combineert in de praktijk altijd (gevalideerd
# _filter_passes). De oude '2×titel + 1×samenvatting'-rekensom is vervallen # op 205 productie-artikelen, 2026-09-12).
# (2026-09-11: de titel weegt niet extra).
MATCH_FILTER_THRESHOLD = 3 MATCH_FILTER_THRESHOLD = 3
# Woorden die een familie wél matcht maar die geen OV-waarde hebben. Een hit dat _compiled_match_strong: list[tuple[re.Pattern, str]] = [
# hierin staat telt niet mee voor de score (en blokkeert de familie niet). Vorm: (re.compile(pat, re.IGNORECASE), name) for pat, name in MATCH_FILTER_STRONG
# exact woord (lowercase), zoals door de familie-regex wordt gematcht. ]
# Refactor 2026-09-13: alleen woorden die een HUIDIGE familie nog matchen blijven _compiled_match_ambigue: list[tuple[re.Pattern, int, str]] = [
# staan ('treinstraat'/'treinweg' via de zwakke \b\w*trein\w*\b-familie). De (re.compile(pat, re.IGNORECASE), w, name) for pat, w, name in MATCH_FILTER_AMBIGUE
# stations*-varianten (stationsstraat, stationair, stationeren, stationcar,
# stationwagen, stationsplein, ...) waren inert sinds de woordgrens-familie
# \bstations?\b en zijn verwijderd — dat gedrag is in validate_filter.py
# vastgelegd.
MATCH_FILTER_BLACKLIST: frozenset[str] = frozenset({
# Straatnamen rond spoorlijnen die de \b\w*trein\w*\b-familie zou matchen:
# een straat die toevallig 'Trein' heet maakt een bericht geen OV-bericht.
"treinstraat", "treinweg",
})
# MATCH_FILTER_WHITELIST: termen die een bericht per definitie OV maken. Sinds
# besluit 2026-09-12 is dit geen auto-pass meer: een whitelist-hit in de TITEL
# telt +2 (één onmiskenbare term 2, nog een signaal nodig voor de drempel 3), en
# in de SAMENVATTING helemaal niet (die scoort via de overeenkomstige familie —
# een whitelist-term zit altijd óók in een familie). Zie _filter_passes.
# "Onmiskenbaar" = in élke context OV: merken (NS/ProRail/Arriva), onmiskenbaar
# jargon (spoorstoring, perron, bushalte, conducteur, ovpay), multi-woord-frasen
# (nederlandse spoorwegen, openbaar vervoer, ice-berlijn ...).
# Regel: twijfel je of de term ook buiten het OV kan voorkomen -> NIET hier, maar
# zwak (eigen familie). Daarom 'hermes' hieruit (achternaam, besluit 2026-09-12).
# 'treinstel'/'veerdienst'/'buurtbus'/'lijn 12' staan hier alleen als TITEL-anker
# (= het oude effect van één onmiskenbare term in de titel, nu via +2) — in de
# samenvatting wegen ze als familie (2/1). Vergelijking is case-insensitive.
MATCH_FILTER_WHITELIST: frozenset[str] = frozenset({
# Vervoerders en OV-merken (incl. multi-woord termen)
"nederlandse spoorwegen", "ns", "prorail", "randstadrail", "arriva", "qbuzz",
"connexxion", "transdev", "keolis", "ret", "gvb", "htm", "ebs", "syntus",
"u-ov", "allgo", "blauwnet", "r-net", "valleilijn", "rrreis",
"qliner", "flixbus", "eurostar", "thalys", "gvu", "ovpay",
"deutsche bahn", "european sleeper", "ns international", "italo", "nightjet",
"teso", "ice-berlijn",
# Spoorinfra-jargon (samenvoegingen, onmiskenbaar OV)
"spoorstoring", "spoorverkeer", "spoorwegovergang", "spoorlijn", "spoorbaan",
"spoorstaking", "spoorsector", "spoorbeheerder", "spoornet", "spoorvervoerder",
"ertms", "perron", "wisselstoring", "seinstoring", "treinstoring", "treinverkeer",
"treinstaking", "treinongeval", "treinbotsing", "treinstel", "treinvertraging",
"treinhinder", "treinuitval", "treinverstoring", "maaslijn", "nedersaksenlijn",
"kamperlijn",
# Bus/tram/metro-jargon
"bushalte", "bushaltes", "busbaan", "buslijn", "busnet",
"busstaking", "buurtbus", "veerdienst", "lijn 12",
"tramhalte", "tramlijn", "tramnet", "tramverkeer",
"metrolijn", "metroverkeer",
# Tarief/vervoerder-jargon
"ov-chipkaart", "ov-staking", "lightrail",
"conducteur", "conducteurs",
"openbaar vervoer",
})
_compiled_match_families: list[tuple[re.Pattern, int]] = [
(re.compile(pat, re.IGNORECASE), w) for pat, w in MATCH_FILTER_FAMILIES
] ]
# Whitelist: enkel-woord termen als \\b-term\\b (geen substring-botsingen zoals
# 'ns' in 'kunstenaar'), multi-woord termen als exacte frase.
_WHITELIST_RE = re.compile(
r"\b(?:" + "|".join(re.escape(w) for w in MATCH_FILTER_WHITELIST if " " not in w) + r")\b",
re.IGNORECASE,
)
_WHITELIST_PHRASES: tuple[str, ...] = tuple(w for w in MATCH_FILTER_WHITELIST if " " in w)
# Dienststoring-context als sterk TITEL-signaal (zie _DISRUPTION_RE_SRC).
_DISRUPTION_RE = re.compile(_DISRUPTION_RE_SRC, re.IGNORECASE)
class Config(BaseProxyConfig): class Config(BaseProxyConfig):
def do_update(self, helper: ConfigUpdateHelper) -> None: def do_update(self, helper: ConfigUpdateHelper) -> None:
helper.copy("update_interval") helper.copy("update_interval")
@ -456,147 +276,84 @@ class RSSBot(Plugin):
return re.sub(r"<[^>]+>", "", html.unescape(raw)) return re.sub(r"<[^>]+>", "", html.unescape(raw))
@staticmethod @staticmethod
def _whitelist_hit(text: str) -> bool: def _signals(text: str) -> dict[str, int]:
"""True als een onmiskenbare OV-term uit MATCH_FILTER_WHITELIST in text zit. """Unieke semantische OV-groepen in text -> gewicht (sterk 2, ambigu 1/2).
Enkele-woord termen matchen op woordgrens ('ns' telt NIET binnen 'kunstenaar'), Elke groep telt hooguit één keer: meerdere woorden uit dezelfde groep
multi-woord termen ('nederlandse spoorwegen') als exacte frase, beide ('bus' + 'buslijn' + 'busvervoer', 'station' + 'stationsgebouw') leveren
case-insensitive.""" samen één signaal op; er is geen span-merge of zwarte lijst nodig omdat
if _WHITELIST_RE.search(text): de groepen met woordgrenzen/lookbehinds overlappingsvrij zijn
return True ('schoolbus'/'VW-bus' matchen de bus-groep niet, 'stationsstraat'/
low = text.lower() 'stationair' de station-groep niet, 'ov-chipkaart' niet de ov-groep)."""
return any(ph in low for ph in _WHITELIST_PHRASES) out: dict[str, int] = {}
for family, name in _compiled_match_strong:
@staticmethod if family.search(text):
def _disruption_hit(text: str) -> bool: out[name] = 2
"""True als een dienststoring-zin ('sprinters rijden niet', 'metro rijdt for family, weight, name in _compiled_match_ambigue:
niet tussen Rotterdam en Schiedam') in text staat. In de TITEL telt zo'n if family.search(text):
zin als sterk OV-signaal (+2, net als een whitelist-term); als familie out[name] = max(out.get(name, 0), weight)
(2) scoort hij overal — zie _DISRUPTION_RE_SRC.""" return out
return _DISRUPTION_RE.search(text) is not None
@staticmethod
def _fam_hits(text: str) -> list[tuple[int, int]]:
"""(familie-index, gewicht) per OV-signaal in text (1× per familie).
Gematchte woorden uit MATCH_FILTER_BLACKLIST (o.a. 'stationsstraat')
tellen niet mee en blokkeren de familie niet: een écht OV-woord in
dezelfde tekst scoort gewoon verder.
Eén term telt maximaal één signaal (besluit 2026-09-13): overlappende
treffers van verschillende families op dezelfde plek worden samengevoegd
met het hoogste gewicht. Voorheen telde één woord dubbel als het in
meerdere families stond — 'ov-chipkaart' bevat 'chipkaart' (2+1=3),
'openbaar vervoer' bevat 'vervoer' (2+1=3), 'treinstoring' matcht ook
de zwakke \\w*trein\\w*-familie (2+1=3). Max-2-stelsel: één term kan
nooit solo doorlaten."""
spans: list[tuple[int, int, int, int]] = [] # (start, end, familie-idx, gewicht)
for idx, (family, weight) in enumerate(_compiled_match_families):
for m in family.finditer(text):
if m.group(0).lower() in MATCH_FILTER_BLACKLIST:
continue
spans.append((m.start(), m.end(), idx, weight))
break
spans.sort()
merged: list[tuple[int, int, int, int]] = []
for start, end, idx, weight in spans:
if merged and start < merged[-1][1]:
s0, e0, i0, w0 = merged[-1]
if weight > w0:
merged[-1] = (s0, max(e0, end), idx, weight)
else:
merged[-1] = (s0, max(e0, end), i0, w0)
else:
merged.append((start, end, idx, weight))
return [(idx, w) for _, _, idx, w in merged]
@staticmethod @staticmethod
def _filter_passes(title: str, summary: str) -> bool: def _filter_passes(title: str, summary: str) -> bool:
"""Driestaps-beslissing, precision-first (refactor 2026-09-13; beslissing """Beslissing in drie regels (v1.15, precision-first):
equivalent met v1.13.0, maar de drie historische uitzonderings-takken
reduceren tot één regel — zie stap 3):
1. STERK SIGNAAL: een whitelist-term óf een dienststoring-zin 1. STERK signaal in de TITEL -> plaatsen. Een sterk signaal (vervoerder,
('sprinters rijden niet') in de titel is voldoende. Elke spoorjargon, perron, bushalte/buslijn, conducteur, ovpay, ...
whitelist-term staat óók in een familie, dus zo'n titel telt +2 én zie MATCH_FILTER_STRONG) vrijwel altijd over OV; in de titel is het
het familie-gewicht — samen altijd >= drempel ('NS meldt storing', zelfstandig voldoende ('NS meldt storing', 'Werkzaamheden aan
'Werkzaamheden aan perron', 'Buurtbus rijdt niet meer'). In de perron', 'Sprinters rijden niet tussen Utrecht en Amsterdam').
samenvatting ALLEEN is zo'n term géén bewijs ('openbaar vervoer'
als bijzin in een economiebericht, besluit 2026-09-12) en scoort
die alleen via de overeenkomstige familie.
2. CONTEXT: de som van unieke OV-signalen over titel + samenvatting 2. CONTEXT: score = som van unieke groeps-gewichten over titel +
>= drempel (3). Eén term telt één signaal per artikel ('busstation' samenvatting >= drempel (3) én de titel bevat minstens één signaal.
60× of in beide velden = één keer); overlappende familie-treffers Sterke groepen wegen 2, ambigue 1 (halte/tram/metro/spoorbrug 2).
van dezelfde term wegen 1× met het hoogste gewicht (span-merge in Elke groep telt één keer per artikel — 'bus' + 'buslijn' +
_fam_hits, besluit 2026-09-13 — 'ov-chipkaart' bevat 'chipkaart' 'busvervoer' is één bus-signaal; dezelfde groep in titel én
maar weegt als één term 2, geen 3). Geen samenvatting blijft één signaal. Voorbeelden: bus+halte (1+2),
familie weegt meer dan 2 (max-2-stelsel, besluit 2026-09-12): een trein+spoorlijn (1+2), metro+'rijdt niet' (2+2), tram+
enkel sterk woord kan nooit solo doorlaten ('hermes' als dienstregeling (2+1), sprinter+NS (1+2).
achternaam passeerde vroeger op één vervoerder-hit van 3 —
Margraten; écht OV-nieuws combineert in de praktijk altijd met een
tweede signaal, gevalideerd op 205 productie-artikelen).
3. TITEL-ANKER: staat er geen enkel OV-signaal in de titel (geen 3. ANDERS -> wegfilteren. Een artikel mag niet door omdat de summary
familie-hit, geen whitelist-term), dan is de score uitsluitend toevallig een OV-woord bevat ('station'+'pendelbus'+'bus' in een
samenvatting-bewijs en mag die geen zwak/ambigue woord bevatten — golfverslag, 'openbaar vervoer' als bijzin in een economiebericht);
'station'+'pendelbus'+'bus' in een golfverslag, 'openbaar één enkel signaal — zelfs halte/tram/metro/spoorbrug (2) — is nooit
vervoer'+'zakelijke reizigers' in een economiebericht en 'sterk + zelfstandig voldoende.
zwak uitsluitend in de samenvatting' (beide besluiten 2026-09-12)
vallen hieronder. Twee sterke termen in de samenvatting blijven
bewust door (delftopzondag 'tram 1, halte Buitenhofdreef',
besluit 2026-09-13).
Weert zo 'VW-bus' (bus matcht niet ná een koppelteken), 'schoolbus', Woordgrenzen en koppelteken-lookbehinds weren structureel: 'VW-bus'/
'bussen rijden om' in een woningbrand-lead, het werkwoord 'brengen', 'schoolbus' (bus-groep), 'stationsstraat'/'stationair'/'stationcar'
het idioom 'goed met elkaar overweg kunnen', 'een wijk opwaarderen', (station-groep), 'treinstraat'/'treinweg' (trein-groep matchen niet
brandweerjargon 'sein brandmeester', figuratief 'weer op de rails', buiten woordgrenzen) — er is geen zwarte lijst nodig. Krant 'De Metro'/
en een cultuurstukje over een 'stationspiano'. Bewust doorgelaten: café 'De Tram' wegen 2 en combineren alleen; atleet-'sprinter' alleen.
krant 'De Metro'/café 'De Tram' (2, combineert) en atleet-'sprinter' """
in combinatie met ander OV-woord.""" title_hits = RSSBot._signals(title)
t_hits = RSSBot._fam_hits(title) combined = dict(title_hits)
s_hits = RSSBot._fam_hits(summary) for group, weight in RSSBot._signals(summary).items():
# Unieke OV-signalen over beide velden: één term telt één signaal per combined[group] = max(combined.get(group, 0), weight)
# artikel ('busstation' in titel én samenvatting, of 60×, blijft één # Stap 1: sterk signaal in de titel is zelfstandig voldoende.
# treffer); _fam_hits levert per term al het hoogste gewicht bij for family, name in _compiled_match_strong:
# overlappende familie-treffers. if family.search(title):
combined = dict(t_hits + s_hits)
score = sum(combined.values())
strong_title = RSSBot._whitelist_hit(title) or RSSBot._disruption_hit(title)
if strong_title:
# Stap 1: sterk titel-signaal — +2 bovenop het familie-gewicht van
# dezelfde term, waardoor een eenduidige OV-titel alleen voldoet.
score += 2
if score < MATCH_FILTER_THRESHOLD:
return False
if not t_hits and not strong_title and 1 in combined.values():
# Stap 3: geen enkel titel-signaal én een zwak woord in de score —
# samenvatting-bewijs zonder titel-anker is geen OV-bewijs.
return False
return True return True
# Stap 2+3: context met titel-anker; anders wegfilteren.
score = sum(combined.values())
return score >= MATCH_FILTER_THRESHOLD and bool(title_hits)
@staticmethod @staticmethod
def _filter_summary() -> str: def _filter_summary() -> str:
"""Mensvriendelijke weergave van het vaste gewogen match-filter.""" """Mensvriendelijke weergave van het vaste OV-matchfilter."""
return ( return (
"**OV-matchfilter (vast in code; max-2-stelsel, precision-first):**\n" "**OV-matchfilter (vast in code, v1.15, precision-first) — drie regels:**\n"
"- **Sterk signaal in de TITEL → door**: whitelist-term (NS, ProRail, Nederlandse Spoorwegen, spoorstoring, perron, " "1. Sterk OV-signaal in de TITEL → door. Sterk = vervoerders (NS, ProRail, Arriva, RET, GVB, Qbuzz, ...), "
"bushalte, conducteur, ov-chipkaart, ovpay, openbaar vervoer, Deutsche Bahn, ICE Berlijn, ...) of een dienststoring-zin " "spoorjargon (spoorstoring, spoorlijn, wisselstoring, treinstoring, ertms, perron, ...), bus-samenstellingen "
"('sprinters rijden niet', 'metro rijdt niet') — die telt +2 bovenop het familie-gewicht. In de samenvatting alleen " "(bushalte, buslijn, busbaan, buschauffeur), tramverkeer/tramlijn, intercity/streekvervoer/veerdienst/buurtbus, "
"weegt zo'n term mee als gewone familie.\n" "conducteur, ov-chipkaart/ovpay, openbaar vervoer, 'lijn 12', ICE-frasen, rangeer... en de storingszin "
"- **Context (som ≥ 3)**: unieke OV-families over titel + samenvatting. Sterk (2): vervoerders (NS, Arriva, ProRail, Qbuzz, ...), " "'vervoerswoord rijdt/rijden (niet) niet'.\n"
"spoorinfra (spoorlijn, spoorstoring, perron, ertms, spoorsector), bus-samenstellingen (bushalte, buslijn, buschauffeur, busbaan), " "2. Context: som van unieke groeps-gewichten over titel + samenvatting ≥ 3 én de titel bevat een signaal. "
"treinstoring/treinverkeer/treinvertraging, intercity/streekvervoer/veerdienst/buurtbus/belbus, 'lijn 12', ICE-frasen, rangeer..., " "Sterke groepen 2 punten; ambigue groepen 1 (halte/tram/metro/spoorbrug 2): 'bus', 'trein', 'station(s)', "
"tram, metro, openbaar vervoer, lightrail, halte, pont, spoorbrug, ov-chipkaart/ovpay, ov-staking, conducteur, dienststoring-zin. " "'reiziger'/'passagier', 'vervoer(der)', 'sprinter', 'pendelbus'/'shuttlebus', 'dienstregeling', 'ontsporen', 'spoor'.\n"
"Zwak (1): losse 'bus' (niet ná een koppelteken), 'pendelbus'/'shuttlebus', 'trein', 'station(s)', 'breng', 'overweg', " "3. Anders → wegfilteren. Eén signaal is nooit genoeg; een OV-woord in de samenvatting zonder titel-signaal is "
"'overwegen', 'sein', 'kaartje', 'opwaarderen', 'rails', 'inchecken'/'uitchecken', 'dienstregeling', 'chipkaart', " "geen OV-bewijs.\n"
"'reisproduct', 'vervoersbewijs', 'ontsporen', 'reiziger'/'passagier', 'vervoerder', 'sprinter', 'spoor', 'staking', " "Semantische groepen tellen één keer per artikel ('bus' + 'buslijn' + 'busvervoer' = één bus-signaal); "
"'vervoer', 'busongeluk', 'busvervoer', ... — elke dubbelzinnige term heeft een eigen familie en moet combineren.\n" "'schoolbus'/'VW-bus'/'stationsstraat'/'stationair' matchen structureel niet — geen zwarte lijst."
"- **Eén term = één signaal**: herhaling, beide velden en overlappende familie-treffers wegen niet dubbel. "
"Zonder enig titel-signaal (familie-hit, whitelist-term of storingszin in de titel) mag de score geen zwak woord "
"bevatten — samenvatting-bewijs is geen OV-bewijs (twee sterke termen in de samenvatting blijven bewust door).\n"
"- **Uitgesloten (blacklist)**: 'treinstraat'/'treinweg' (straatnamen rond spoorlijnen); historische stations*-gevallen "
"('stationsstraat', 'stationair', 'stationcar', ...) worden al door de woordgrens-familie geweerd"
) )
async def _send( async def _send(

View file

@ -1,5 +1,5 @@
id: nl.eddydevink.rss id: nl.eddydevink.rss
version: 1.14.0 version: 1.15.0
modules: modules:
- buzzrss - buzzrss
main_class: RSSBot main_class: RSSBot

View file

@ -1,13 +1,8 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Blijvende filter-invarianten + regressie voor het OV-matchfilter. """Blijvende filter-invarianten + regressie voor het OV-matchfilter (v1.15).
Draaien (van de repo-root): python3 validate_filter.py [alleen stdlib] De code wordt zonder maubot-imports uit buzzrss/bot.py geëxtraheerd (AST), zodat
dit script overal los te draaien is: `python3 validate_filter.py`.
Waarom dit bestaat (zie AGENTS.md):
- het classificatiebeleid (precision-first) staat als commentaar in buzzrss/bot.py;
- DIT script maakt dat beleid afdwingbaar: een latere filterwijziging die een
dubbelzinnig woord sterk/whitelist maakt, of een onmiskenbare OV-term verwakt,
faalt hierop met exit 1 — vóór commit/deploy.
""" """
import ast import ast
import re import re
@ -22,45 +17,32 @@ BOT = ROOT / "buzzrss" / "bot.py"
src = BOT.read_text() src = BOT.read_text()
tree = ast.parse(src) tree = ast.parse(src)
WANT_ASSIGN = { WANT_ASSIGN = {
"MATCH_FILTER_FAMILIES", "MATCH_FILTER_THRESHOLD", "MATCH_FILTER_BLACKLIST", "MATCH_FILTER_STRONG", "MATCH_FILTER_AMBIGUE", "MATCH_FILTER_THRESHOLD",
"MATCH_FILTER_WHITELIST", "_compiled_match_families", "_compiled_match_strong", "_compiled_match_ambigue",
"_WHITELIST_RE", "_WHITELIST_PHRASES",
"_DISRUPTION_RE_SRC", "_DISRUPTION_RE",
} }
WANT_METHODS = {"_fam_hits", "_filter_passes", "_filter_text", "_whitelist_hit", "_disruption_hit"} WANT_METHODS = {"_filter_text", "_signals", "_filter_passes"}
nodes = [] nodes = []
for n in tree.body: for n in tree.body:
if isinstance(n, ast.Assign) and any(isinstance(t, ast.Name) and t.id in WANT_ASSIGN for t in n.targets):
nodes.append(ast.get_source_segment(src, n))
if isinstance(n, ast.AnnAssign) and getattr(n.target, "id", "") in WANT_ASSIGN: if isinstance(n, ast.AnnAssign) and getattr(n.target, "id", "") in WANT_ASSIGN:
nodes.append(ast.unparse(n)) nodes.append(ast.get_source_segment(src, n))
elif isinstance(n, ast.Assign) and any(getattr(t, "id", "") in WANT_ASSIGN for t in n.targets):
nodes.append(ast.unparse(n))
if isinstance(n, ast.ClassDef) and n.name == "RSSBot": if isinstance(n, ast.ClassDef) and n.name == "RSSBot":
methods = [ methods = []
ast.unparse(m) for m in n.body:
for m in n.body if isinstance(m, (ast.FunctionDef, ast.AsyncFunctionDef)) and m.name in WANT_METHODS:
if isinstance(m, (ast.FunctionDef, ast.AsyncFunctionDef)) and m.name in WANT_METHODS methods.append(ast.get_source_segment(src, m))
]
nodes.append("class RSSBot:\n" + textwrap.indent("\n".join(methods), " ")) nodes.append("class RSSBot:\n" + textwrap.indent("\n".join(methods), " "))
ns = {} ns = {}
exec(compile("from __future__ import annotations\nimport re, html\n" + "\n".join(nodes) + "\n", "<bot>", "exec"), ns) exec(compile("from __future__ import annotations\nimport re, html\n" + "\n".join(nodes) + "\n", "<bot>", "exec"), ns)
R = ns["RSSBot"] R = ns["RSSBot"]
FAMILIES = ns["MATCH_FILTER_FAMILIES"] # [(regex, gewicht)] STRONG = ns["MATCH_FILTER_STRONG"] # [(regex, naam)] — 2 punten
WHITELIST = ns["MATCH_FILTER_WHITELIST"] # frozenset AMBIGUE = ns["MATCH_FILTER_AMBIGUE"] # [(regex, gewicht, naam)]
COMPILED = [(re.compile(p, re.I), w) for p, w in FAMILIES] THRESHOLD = ns["MATCH_FILTER_THRESHOLD"]
COMPILED_STRONG = [(re.compile(p, re.I), n) for p, n in STRONG]
COMPILED_AMBIGUE = [(re.compile(p, re.I), w, n) for p, w, n in AMBIGUE]
def weights_for(word: str) -> set[int]:
"""gewichten van families die `word` matchen (case-insensitive)."""
return {w for pat, w in COMPILED if pat.search(word)}
def whitelisted(word: str) -> bool:
return ns["_WHITELIST_RE"].search(word) is not None or any(
ph in word.lower() for ph in ns["_WHITELIST_PHRASES"]
)
failures = [] failures = []
@ -71,219 +53,177 @@ def check(cond: bool, msg: str):
failures.append(msg) failures.append(msg)
# --- Invariant 1: dubbelzinnige woorden mogen nooit sterk/medium of whitelist zijn # --- Invariant 1: gewichten geldig (max-2; sterk=2, ambigu in {1,2}) ---
AMBIGUOUS = [ print("== invariant 1: gewichten -- sterk=2, ambigu in {1,2}, drempel 3 ==")
"trein", "bus", "station", "stations", "sprinter", "sprinters", for p, w, n in AMBIGUE:
"reiziger", "reizigers", "passagier", "passagiers", "vervoerder", "vervoerders", check(w in (1, 2), f"ambigu-groep {n!r} heeft geldig gewicht {w} (max-2-stelsel)")
"inchecken", "uitchecken", "sein", "overweg", "overwegen", "breng", check(THRESHOLD == 3, f"drempel = 3 (is {THRESHOLD})")
"kaartje", "opwaarderen", "rails", "dienstregeling", "chipkaart", check(len(COMPILED_STRONG) == len(STRONG) and len(COMPILED_AMBIGUE) == len(AMBIGUE), "alle groepen compileren")
"reisproduct", "vervoersbewijs", "vervoerbewijs",
"pendelbus", "shuttlebus", # --- Invariant 2: overlappingen -- een trefwoord mag hooguit door 1 groep van
"ontspor", "ontspoor", "ontspoord", "ontspoorde", "ontsporing", # dezelfde categorie worden gematcht; sterk<->ambigu alleen voor de bewuste
# audit 2026-09-12 (max-2-stelsel + completheids-audit) # trein-groep (\w*trein\w* vangnet, dekt ook spoorjargon-woorden af).
"hermes", "spoor", "staking", "stakingen", "vervoer", "vervoersbedrijf", TOKENS = [
"vervoerregio", "ritten", "bovenleiding", "abonnement", "ns", "prorail", "arriva", "ret", "gvb", "deutsche bahn", "teso", "ov",
"busongeluk", "busongelukken", "busvervoer", "ov-chipkaart", "ovpay", "spoorstoring", "spoorlijn", "spoorwegen",
"wisselstoring", "seinstoring", "ertms", "treinstoring", "treinverkeer",
"treinvertraging", "treinstel", "perron", "bushalte", "buslijn", "busbaan",
"buschauffeur", "bushokje", "busverkeer", "busstaking", "tramhalte",
"tramlijn", "tramverkeer", "metrolijn", "metroverkeer", "intercity",
"streekvervoer", "buurtbus", "belbus", "veerdienst", "conducteur",
"lightrail", "lijn 12", "rangeren", "openbaar vervoer", "ice-berlijn",
"bus", "bussen", "busstation", "busvervoer", "busongeluk", "trein",
"treinen", "stoptrein", "treinreizigers", "treinstraat", "treinweg",
"station", "stations", "stationstraat", "stationair", "stationcar",
"stationsgebouw", "metrostation", "busstation", "reiziger", "reizigers",
"passagier", "vervoer", "vervoerder", "vervoersbedrijf", "vervoerregio",
"sprinter", "pendelbus", "shuttlebus", "dienstregeling", "ontspoorde",
"ontsporing", "spoor", "spoorbrug", "halte", "haltes", "tram", "metro",
"metro's", "sprinters rijden niet", "metro rijdt niet",
] ]
print(f"== invariant 1: {len(AMBIGUOUS)} dubbelzinnige woorden -> alleen zwak (1), nooit whitelist ==") print(f"== invariant 2: {len(TOKENS)} trefwoorden in hooguit 1 groep per categorie ==")
for w in AMBIGUOUS: for tok in TOKENS:
gew = weights_for(w) strong_hits = [n for p, n in COMPILED_STRONG if p.search(tok)]
check(1 in gew, f"{w!r} heeft een zwakke familie") amb_hits = [n for p, w, n in COMPILED_AMBIGUE if p.search(tok)]
check(not (gew - {1}), f"{w!r} zit NIET in een sterke/medium familie (gewichten {sorted(gew)})") check(len(strong_hits) <= 1, f"{tok!r} in hooguit 1 sterke groep (nu: {sorted(strong_hits)})")
check(not whitelisted(w), f"{w!r} staat NIET op de whitelist") check(len(amb_hits) <= 1, f"{tok!r} in hooguit 1 ambigue groep (nu: {sorted(amb_hits)})")
# --- Invariant 2: onmiskenbare OV-termen mogen niet uitsluitend zwak zijn # --- Invariant 3: semantische groepen -- varianten stapelen niet op ---
UNAMBIGUOUS = [ print("== invariant 3: varianten van 'bus'/'station'/'trein' = één signaal ==")
"spoorstoring", "spoorlijn", "perron", "bushalte", "bushokje", for txt, want in [
"conducteur", "ovpay", "ns", "prorail", # 'bus' (1) + buslijn als sterke samenstelling (2) + busstation via station-groep (1):
"treinstoring", "halte", "openbaar vervoer", "intercity", "buurtbus", # vier bus-varianten leveren géén vier onafhankelijke signalen op.
"veerdienst", "lijn 12", "ov-chipkaart", "spoorbrug", ("bus buslijn busvervoer busstation", {"bussamenstelling": 2, "bus": 1, "station": 1}),
# audit 2026-09-12 (completheids-audit, max-2-stelsel) ("station stationsgebouw stationsplein", {"station": 1}),
"spoorsector", "spoorbeheerder", "spoornet", "spoorvervoerder", ("trein treinstoring treinreizigers", {"trein": 1, "spoorjargon": 2}),
"treinvertraging", "treinhinder", "treinuitval", "treinverstoring", ]:
"ertms", "teso", "rangeerterrein", "rangeren", "ice-berlijn", got = R._signals(txt)
"maaslijn", "nedersaksenlijn", "kamperlijn", check(got == want, f"{txt!r} -> {got} (verwacht {want})")
"ns international", "deutsche bahn", "european sleeper", "italo", "nightjet",
]
print(f"== invariant 2: {len(UNAMBIGUOUS)} onmiskenbare OV-termen -> sterk/medium of whitelist ==")
for w in UNAMBIGUOUS:
gew = weights_for(w)
check(whitelisted(w) or any(g >= 2 for g in gew),
f"{w!r} heeft whitelist of gewicht >= 2 (gewichten {sorted(gew)})")
# --- Invariant 3: patronen compileren en gewichten geldig (max-2, besluit 2026-09-12) # --- Regressie: gewenst gedrag (precision-first) ---
print("== invariant 3: alle families compileren, gewicht in {1,2} ==")
for p, w in FAMILIES:
check(w in (1, 2), f"familie {p[:40]!r} heeft geldig gewicht {w} (max-2-stelsel)")
check(len(COMPILED) == len(FAMILIES), "alle families compileren")
# --- Invariant 4 (refactor 2026-09-13): sterke-signaal-garantie + geen dode
# blacklist. Elke whitelist-term moet óók in een familie zitten (anders telt een
# whitelist-titel niet als sterk signaal 2+2) en elke zwartgelijste term moet door
# minstens één familie worden gematcht (anders is de blacklist-regel dood gewicht).
print("== invariant 4: whitelist-term in familie; blacklist-term door familie gematcht ==")
for w in sorted(ns["MATCH_FILTER_WHITELIST"]):
check(any(p.search(w) for p, _ in COMPILED), f"whitelist-term {w!r} zit ook in een familie")
for w in sorted(ns["MATCH_FILTER_BLACKLIST"]):
check(any(p.search(w) for p, _ in COMPILED), f"blacklist-term {w!r} wordt door een familie gematcht")
# --- Regressie: gedrag van _filter_passes op bekende cases (precision-first)
GOLDEN = [ GOLDEN = [
# (titel, samenvatting, verwacht_door)
# geweerd — bekende ruis ("Na mooi seizoen in wereldstad Parijs wacht nu EK voor 'lange man' Tom Koops", 'een weekje vakantie pak ik de trein naar mijn nieuwe huis. Berlijn', False),
("Na mooi seizoen in wereldstad Parijs wacht nu EK voor 'lange man' Tom Koops", ("Amerikaanse talkshowhost Jimmy Kimmel schrapt gesprek 'na druk regering Trump'", 'ABC, onze regionale omroepen en lokale stations.', False),
"een weekje vakantie pak ik de trein naar mijn nieuwe huis. Berlijn", False), ('Inchecken op Schiphol duurt langer', 'Passagiers staan in de rij bij de balies.', False),
("Amerikaanse talkshowhost Jimmy Kimmel schrapt gesprek 'na druk regering Trump'", ('Sprinter verpulvert record op de 100 meter', 'De atleet liep een nationale toptijd.', False),
"ABC, onze regionale omroepen en lokale stations.", False), ('Zakenreizigers vliegen weer massaal', 'Het aantal vluchten is gestegen.', False),
("Inchecken op Schiphol duurt langer", "Passagiers staan in de rij bij de balies.", False), ('Vrachtvervoerder failliet', 'De transporteur heeft uitstel van betaling aangevraagd.', False),
("Sprinter verpulvert record op de 100 meter", "De atleet liep een nationale toptijd.", False), ('Het plan voor de nieuwe wijk ontspoorde', 'De gemeente moet de kosten herzien.', False),
("Zakenreizigers vliegen weer massaal", "Het aantal vluchten is gestegen.", False), ('Sein brandmeester: brandweer rukt uit', 'De brandweer kreeg een sein om uit te rukken.', False),
("Vrachtvervoerder failliet", "De transporteur heeft uitstel van betaling aangevraagd.", False), ('VW-bus in brand op de A2', 'De inzittenden kwamen er veilig uit.', False),
("Het plan voor de nieuwe wijk ontspoorde", "De gemeente moet de kosten herzien.", False), ('Fietser gewond op de Stationsstraat', 'Het slachtoffer is naar het ziekenhuis gebracht.', False),
("Sein brandmeester: brandweer rukt uit", "De brandweer kreeg een sein om uit te rukken.", False), ('Kunstenaar maakt kaartje voor het museum', '', False),
("VW-bus in brand op de A2", "De inzittenden kwamen er veilig uit.", False), ('Benzineprijs stijgt opnieuw', 'De pomp wordt duurder door de olieprijs.', False),
("Fietser gewond op de Stationsstraat", "Het slachtoffer is naar het ziekenhuis gebracht.", False), ("Golf-fans uit de hele wereld naar Cromvoirt: 'Beter dan sterrenrestaurant'", 'Op het station in Den Bosch stapte de oud-prof in de pendelbus naar Bernardus Golf. In de bus bleek ze zeker niet de enige Amerikaanse.', False),
("Kunstenaar maakt kaartje voor het museum", "", False), ('Toernooi trekt duizenden bezoekers', 'Vanaf het station rijden pendelbussen en extra bussen.', False),
("Benzineprijs stijgt opnieuw", "De pomp wordt duurder door de olieprijs.", False), ('Trein vertraagd, reizigers pakken de bus', 'De storing duurde een uur.', True),
# geweerd — zwakke woorden alléén in de samenvatting (geen titel-anker). ('Station Amersfoort krijgt nieuwe perrons', 'De NS laat de perrons vernieuwen.', True),
# Echt geval: Omroep Brabant, Solheim Cup-golfverslag (11-09-2026) — 'pendelbus' ('Trein ontspoorde bij Weesp', 'De spoorlijn was uren dicht.', True),
# stond op de whitelist en 'station'+'pendelbus'+'bus' in de samenvatting was ('Bushalte aan de Markt wordt verplaatst', 'De halte gaat 200 meter opschuiven.', True),
# samen score 3. ('Conducteur bekogeld in stoptrein bij Den Haag', 'De conducteur is nagekeken.', True),
("Golf-fans uit de hele wereld naar Cromvoirt: 'Beter dan sterrenrestaurant'", ('Nieuwe app voor ovpay', 'De ovpay-app wordt dit najaar vernieuwd.', True),
"Op het station in Den Bosch stapte de oud-prof in de pendelbus naar Bernardus Golf. " ("Restaurant weg, pand staat te verloederen: 'Doorn in het oog'", 'De gemeente Midden-Delfland wil het leegstaande horecapand naast busstation Maasland Viaduct kopen. Het gebouw staat al jaren leeg en maakt een vervallen indruk.', False),
"In de bus bleek ze zeker niet de enige Amerikaanse.", False), ('Pand naast busstation wordt gesloopt', 'Het leegstaande pand naast het busstation gaat plat.', False),
("Toernooi trekt duizenden bezoekers", "Vanaf het station rijden pendelbussen en extra bussen.", False), ('Nieuwe fietsenstalling bij busstation', '', False),
# door — echte OV-berichten ('Restaurant naast busstation wil uitbreiden', 'De eigenaar wil uitbreiden naar het naastgelegen pand.', False),
("Trein vertraagd, reizigers pakken de bus", "De storing duurde een uur.", True), ('Busstation wordt verbouwd', 'De verbouwing duurt tot het voorjaar.', False),
("Station Amersfoort krijgt nieuwe perrons", "De NS laat de perrons vernieuwen.", True), ('Metro rijdt niet', 'De halte is tijdelijk gesloten.', True),
("Trein ontspoorde bij Weesp", "De spoorlijn was uren dicht.", True), ('Economische groei van de stad niet meer vanzelfsprekend: ondernemersclubs waarschuwen', 'Volgens Evers is de zakelijke bezoeker ook nodig voor het openbaar vervoer, de culturele sector en musea. Het aantal zakelijke reizigers in de stad daalt.', False),
("Bushalte aan de Markt wordt verplaatst", "De halte gaat 200 meter opschuiven.", True), ('Straat in Japan verandert in rivier door hevige regen', 'Het verkeer ligt plat en ook het openbaar vervoer is ontregeld in de getroffen regio.', False),
("Conducteur bekogeld in stoptrein bij Den Haag", "De conducteur is nagekeken.", True), ('Er komt echt een einde aan gratis openbaar vervoer voor ouderen', 'De gemeente stopt vanaf januari met de gratis reizen voor 65-plussers.', True),
("Nieuwe app voor ovpay", "De ovpay-app wordt dit najaar vernieuwd.", True), ('Metrostation Centraal tijdelijk gesloten', "De metro's rijden niet.", True),
# fix 2026-09-11: station-samenstellingen zijn medium (niet meer whitelist/ ('Frans weet wat voor warrig beestje Mony heeft gefilmd', 'Ruud Klomp zag in de Dommel bij de spoorbrug tussen Liempde en Boxtel een waterschildpad.', False),
# sterk) — een enkel 'busstation' in de samenvatting naast een ander onderwerp ('Spoorbrug in Boxtel wordt aangepakt', 'De werkzaamheden duren tot het najaar.', False),
# ('pand naast busstation') moet GEWEERD worden, en 'busstation' telt maar 1×. ('Spoorbrug wordt verhoogd voor treinen', 'De brug gaat omhoog zodat er hogere treinen onderdoor kunnen.', True),
("Restaurant weg, pand staat te verloederen: 'Doorn in het oog'", ('NS verlengt noodregeling op traject Zwolle', 'Reizigers moeten rekening houden met minder treinen.', True),
"De gemeente Midden-Delfland wil het leegstaande horecapand naast busstation Maasland " ('Spoorstoring bij Utrecht opgeheven', 'ProRail heeft de wisselstoring verholpen.', True),
"Viaduct kopen. Het gebouw staat al jaren leeg en maakt een vervallen indruk.", False), ('Nieuwe intercity naar Berlijn', 'De trein rijdt vanaf december vier keer per dag.', True),
("Pand naast busstation wordt gesloopt", "Het leegstaande pand naast het busstation gaat plat.", False), # één familie in titel ÉN samenvatting = één signaal: 2 < 3 (besluit 2026-09-11) ('Streekvervoer kampt met personeelstekort', 'Vervoerders schrappen ritten.', True),
("Nieuwe fietsenstalling bij busstation", "", False), # besluit 2026-09-11: één medium-hit in de titel is onvoldoende (titel weegt 1×, was 2×) ('Buurtbus in de Achterhoek rijdt niet meer', 'Er zijn te weinig vrijwillige chauffeurs.', True),
("Restaurant naast busstation wil uitbreiden", "De eigenaar wil uitbreiden naar het naastgelegen pand.", False), # zelfde regel, letterlijk gebruikersvoorbeeld ('Veerdienst Vlissingen-Breskens vaart uit de vaart', 'De rederij zet een boot in.', True),
("Busstation wordt verbouwd", "De verbouwing duurt tot het voorjaar.", False), # herhaling/één woord = 1 signaal: 2 < 3 ('busstation' 60× weegt ook 1×) ('Lijn 12 rijdt vanaf maandag een andere route', 'De route gaat via het centrum.', True),
("Metro rijdt niet", "De halte is tijdelijk gesloten.", True), # medium-combinatie over twee families: metro(2)+halte(2)=4 ('Werkzaamheden aan perron', 'Het perron wordt volgende maand opgeknapt.', True),
# fix 2026-09-12: whitelist-term alleen in de SAMENVATTING is geen OV-bewijs ('Reactie Nederlandse Spoorwegen', 'De Nederlandse Spoorwegen zeggen meer in te zetten op punctualiteit.', True),
# (valt terug op gewogen families); medium+zwak uitsluitend in de samenvatting ("Veel animo voor 'adopteren' graf op Amerikaanse begraafplaats Margraten", "…'Het voelde echt alsof je in de rij stond voor festivaltickets'… 'Ik ben bang dat mensen met de slaapzak voor de laptop gaan liggen', zei Ton Hermes eerder.", False),
# (geen hit in de titel) wordt geweerd. Echte cases 11-09-2026: ('TESO onderzoekt komst derde veerboot tussen Texel en Den Oever', 'Veerdienst TESO onderzoekt of het haalbaar is om een derde veerboot in te zetten tussen Oudeschild en Den Oever.', True),
# AT5-economiebericht ('openbaar vervoer' + 'zakelijke reizigers' als bijzin) ('Vrouw buschauffeur Rob zwaar aangeslagen na dodelijk ongeval', 'Bij het dodelijke busongeluk in Zwitserland zat de politicus achter het stuur.', True),
# en Telegraaf-overstroming Japan ('openbaar vervoer ontregeld' in de tekst). ("Vakbonden noemen stakingsdag in het OV 'een geslaagde dag'", 'Gisteren was de landelijke stakingsdag in het OV; medewerkers legden in de spoorsector 24 uur het werk neer.', True),
("Economische groei van de stad niet meer vanzelfsprekend: ondernemersclubs waarschuwen", ('Dodental veerbootbrand Filipijnen loopt verder op', 'Zeker 76 mensen kwamen om bij de brand op de veerboot.', False),
"Volgens Evers is de zakelijke bezoeker ook nodig voor het openbaar vervoer, de culturele sector en musea. " ('ICE Berlijn voegt na Hamburg vanaf december ook stop in Bremen toe', 'De ICE Berlijn gaat vanaf december via Bremen en Hamburg Hauptbahnhof naar Berlijn rijden. Dat meldt NS International.', True),
"Het aantal zakelijke reizigers in de stad daalt.", False), ('Laatste noot voor vernielde stationspiano Eindhoven', 'De piano in het station was jarenlang een begrip reizigers produceerden er filmpjes.', False),
("Straat in Japan verandert in rivier door hevige regen", ('Meerdere doden na busongeluk in Zwitserland', 'Het busongeluk gebeurde naast de snelweg bij een afrit.', False),
"Het verkeer ligt plat en ook het openbaar vervoer is ontregeld in de getroffen regio.", False), ('Antwerpen ontvangt eerste ICE', 'De eerste ICE van Deutsche Bahn rijdt vanaf volgende maand naar Antwerpen.', False),
# recall-bewaakt: 'openbaar vervoer' in de TITEL blijft onvoorwaardelijk door ('NS verlengt noodregeling tot volgend jaar', 'De vervoerder kampt met personeelstekorten.', True),
# (whitelist-titel-anker; Havenloods 11-09-2026, 'einde aan gratis ov'). ('Groep jonge fatbikers door Eindhoven achtervolgd na beroving met geweld', 'Agenten reden met zwaailichten achter de groep aan, onder meer via de busbaan en een fietspad.', False),
("Er komt echt een einde aan gratis openbaar vervoer voor ouderen", ('De politie achtervolgde de groep via de busbaan', 'De achtervolging duurde enkele minuten.', True),
"De gemeente stopt vanaf januari met de gratis reizen voor 65-plussers.", True), ('Busbaan wordt afgesloten voor het doorgaande verkeer', 'De omleiding duurt tot het voorjaar.', True),
# busstation mag niet dubbel meetellen via 'bus' én 'station' (was 2×3=6→nu 1×2) ('Bus rijdt om, busbaan is afgesloten', 'De bussen rijden via de wijk.', True),
("Metrostation Centraal tijdelijk gesloten", "De metro's rijden niet.", True), ('NS meldt storing: sprinters rijden niet', 'De storing wordt verholpen.', True),
# fix 2026-09-12: 'spoorbrug' is medium (niet meer sterk/whitelist). Echte ('Metro rijdt niet tussen Rotterdam en Schiedam', "De metro's stoppen na de storing.", True),
# case: Omroep Brabant Stuifmail (12-09-2026) — een natuurverhaal waarin iemand ('Buslijn 12 vervalt tijdelijk', 'De vervanging rijdt via de wijk.', True),
# 'bij de spoorbrug' een waterschildpad zag; dat passeerde voorheen op één ('Werkzaamheden aan perron', 'Het perron wordt nieuwe maand opgeknapt.', True),
# sterke spoorbrug-hit in de samenvatting. ('Nieuwe bushalte geopend', 'De halte ligt aan de rand van de wijk.', True),
("Frans weet wat voor warrig beestje Mony heeft gefilmd", ('Sprinters rijden niet tussen Utrecht en Amsterdam', 'De oorzaak is nog onbekend.', True),
"Ruud Klomp zag in de Dommel bij de spoorbrug tussen Liempde en Boxtel een waterschildpad.", False), ('Stoptreinen rijden niet naar Den Haag vanwege een storing', 'De NS zet bussen in.', True),
("Spoorbrug in Boxtel wordt aangepakt", "De werkzaamheden duren tot het najaar.", False), # één medium-hit in de titel: 2 < 3 ('Nieuwe woning bij station', '', False),
("Spoorbrug wordt verhoogd voor treinen", "De brug gaat omhoog zodat er hogere treinen onderdoor kunnen.", True), # spoorbrug(2) + trein(1) = 3 → door ('Restaurant opent nieuwe vestiging', 'Het restaurant ligt tegenover het station waar dagelijks veel reizigers komen.', False),
("NS verlengt noodregeling op traject Zwolle", "Reizigers moeten rekening houden met minder treinen.", True), ('Stationstraat krijgt nieuwe woningen', '', False),
("Spoorstoring bij Utrecht opgeheven", "ProRail heeft de wisselstoring verholpen.", True), ('Werkzaamheden aan de stationsweg', '', False),
("Nieuwe intercity naar Berlijn", "De trein rijdt vanaf december vier keer per dag.", True), ('Het stationsplein wordt heringericht voor de markt', '', False),
("Streekvervoer kampt met personeelstekort", "Vervoerders schrappen ritten.", True), ('De auto stond stationair te draaien', '', False),
("Buurtbus in de Achterhoek rijdt niet meer", "Er zijn te weinig vrijwillige chauffeurs.", True), ('Nieuwe stationwagen van het merk', '', False),
("Veerdienst Vlissingen-Breskens vaart uit de vaart", "De rederij zet een boot in.", True), ('Stationcar uit de jaren zestig', '', False),
("Lijn 12 rijdt vanaf maandag een andere route", "De route gaat via het centrum.", True), ('De Metro publiceert vandaag een interview', 'De krant verschijnt wekelijks.', False),
("Werkzaamheden aan perron", "Het perron wordt volgende maand opgeknapt.", True), ('Tram is een populaire naam voor het café', '', False),
("Reactie Nederlandse Spoorwegen", "De Nederlandse Spoorwegen zeggen meer in te zetten op punctualiteit.", True), ('Schoolbus betrokken bij ongeval', 'De kinderen werden opgevangen.', False),
# max-2-stelsel (besluit 2026-09-12): geproduceerd op 205 productie-artikelen ('Bus en station tijdelijk gesloten voor het feest', '', False),
# + LLM-audit. Geen enkele familie weegt meer dan 2 → één hit kan nooit solo ('Wijkbewoners klagen over het station en de bussen', 'De gemeente komt langs.', False),
# doorlaten; whitelist-term in de titel telt +2 (titel-anker). ('Passagiers klagen over de incheckbalie', 'De balie opende te laat.', False),
("Veel animo voor 'adopteren' graf op Amerikaanse begraafplaats Margraten", ('Gemeente verhuist naar het stationsgebouw', 'Het station en het stationsgebied worden vernieuwd.', False),
"…'Het voelde echt alsof je in de rij stond voor festivaltickets'… 'Ik ben bang dat mensen met de slaapzak voor de laptop gaan liggen', zei Ton Hermes eerder.", False), ('Bus en tram krijgen in december een nieuwe dienstregeling', '', True),
# ^ echte false positive 2026-09-12: 'hermes' (achternaam) scoorde vroeger 3 via de vervoerdersfamilie ('Tram krijgt nieuwe dienstregeling', 'De wijziging geldt vanaf december.', True),
("TESO onderzoekt komst derde veerboot tussen Texel en Den Oever", ('De halte wordt verplaatst voor de bus', 'De nieuwe halte komt bij de rotonde.', True),
"Veerdienst TESO onderzoekt of het haalbaar is om een derde veerboot in te zetten tussen Oudeschild en Den Oever.", True), # teso(2) + veerboot(2) = 4 ('Metro rijdt niet vanwege een storing', 'Er is onbekend wanneer het rijden hervat.', True),
("Vrouw buschauffeur Rob zwaar aangeslagen na dodelijk ongeval", ('Trein rijdt niet meer op de spoorlijn naar Den Bosch', 'Werkzaamheden duren tot maart.', True),
"Bij het dodelijke busongeluk in Zwitserland zat de politicus achter het stuur.", True), # buschauffeur(2) + busongeluk(1) = 3 ('Sprinters rijden niet; NS zet bussen in', '', True),
("Vakbonden noemen stakingsdag in het OV 'een geslaagde dag'", ('De trams rijden niet door een storing', 'Er zijn vervangende bussen ingezet.', True),
"Gisteren was de landelijke stakingsdag in het OV; medewerkers legden in de spoorsector 24 uur het werk neer.", True), # ov(2) + staking(1) + spoorsector(2) ('Tramverkeer stilgelegd na aanrijding', '', True),
("Dodental veerbootbrand Filipijnen loopt verder op", "Zeker 76 mensen kwamen om bij de brand op de veerboot.", False), # één veerboot-hit (2) = geen NL-OV ('ProRail meldt wisselstoring op traject Utrecht', 'Het treinverkeer ligt stil.', True),
("ICE Berlijn voegt na Hamburg vanaf december ook stop in Bremen toe", ('Treinstoring tussen Den Haag en Leiden', 'Reizigers moeten rekening houden met vertraging.', True),
"De ICE Berlijn gaat vanaf december via Bremen en Hamburg Hauptbahnhof naar Berlijn rijden. Dat meldt NS International.", True), # ice-berlijn(2) + ns(2) = 4 ('Treinvertraging op het spoor bij Utrecht', '', True),
("Laatste noot voor vernielde stationspiano Eindhoven", ('NS past de dienstregeling aan', 'Door werkzaamheden rijden er minder treinen.', True),
"De piano in het station was jarenlang een begrip reizigers produceerden er filmpjes.", False), # cultuurstukje; 'station'/'reizigers' zwak-alleen zonder titel-anker ('NS kondigt staking aan', '', True), # prompt-matrix v1.15
("Meerdere doden na busongeluk in Zwitserland", "Het busongeluk gebeurde naast de snelweg bij een afrit.", False), # busongeluk(1) alleen = zwak-alleen ('ProRail meldt storing', '', True), # prompt-matrix v1.15
("Antwerpen ontvangt eerste ICE", "De eerste ICE van Deutsche Bahn rijdt vanaf volgende maand naar Antwerpen.", False), # bewust geweerd: 'ice van' is NIET opgenomen (ijs-botsing); deutsche bahn(2) alleen < 3 — was vóór de wijziging óók al geweerd ('Buslijn 12 gewijzigd', '', True), # prompt-matrix v1.15
("NS verlengt noodregeling tot volgend jaar", "De vervoerder kampt met personeelstekorten.", True), # ns(2) + vervoerder(1) = 3 ('Nieuwe perrons op station', '', True), # prompt-matrix v1.15
# fix 2026-09-13: één term mag nooit via twee families dubbel wegen. ('Metro rijdt niet vanwege storing', '', True), # prompt-matrix v1.15
# Echte case: Omroep Brabant (13-09-2026) — politie-achtervolging van fatbikers ('Tramverkeer stilgelegd', '', True), # prompt-matrix v1.15
# 'via de busbaan' scoorde 2+2=4 omdat 'busbaan' in de bus-samenstelling-familie ('Sprinters rijden niet tussen twee steden', '', True), # prompt-matrix v1.15
# (fam 4) én in de halte/busbaan-familie (fam 13) staat. Nu één signaal (2): ('OV-chipkaart wordt vervangen', '', True), # prompt-matrix v1.15
# zonder tweede OV-term onder de drempel. ('Nieuwe woningen bij station', '', False), # prompt-matrix v1.15
("Groep jonge fatbikers door Eindhoven achtervolgd na beroving met geweld", ('Stationair draaiende auto', '', False), # prompt-matrix v1.15
"Agenten reden met zwaailichten achter de groep aan, onder meer via de busbaan en een fietspad.", False), ('Hotel biedt pendelbus', '', False), # prompt-matrix v1.15
("De politie achtervolgde de groep via de busbaan", "De achtervolging duurde enkele minuten.", True), # whitelist-titel-anker: 'busbaan' in de TITEL blijft door (+2); de merge raakt alleen de teller ('Festival zet pendelbussen in', '', False), # prompt-matrix v1.15
# recall bewaakt: echt busbaan-nieuws blijft door (whitelist-titel-anker +2) en ('Winkel opent naast metrostation', '', False), # prompt-matrix v1.15
# twee VERSCHILLENDE termen blijven samen tellen (geen over-merge). ('Bus rijdt tegen boom', '', False), # prompt-matrix v1.15
("Busbaan wordt afgesloten voor het doorgaande verkeer", "De omleiding duurt tot het voorjaar.", True), ('Trein raakt koe', '', False), # prompt-matrix v1.15
("Bus rijdt om, busbaan is afgesloten", "De bussen rijden via de wijk.", True), # bus(1) + busbaan(2) = 3 ('Reizigers stranden', '', False), # prompt-matrix v1.15
# refactor 2026-09-13 — positieve gevallen die de dienststoring-context én ('Station wordt verbouwd', '', False), # prompt-matrix v1.15
# sterke signalen vastleggen (prompt "Starke OV-signalen"). ('Festival gebruikt bussen', '', False), # prompt-matrix v1.15
("NS meldt storing: sprinters rijden niet", "De storing wordt verholpen.", True), # ns-titel + 'sprinters rijden niet'-context ('Hotel heeft shuttlebus', '', False), # prompt-matrix v1.15
("Metro rijdt niet tussen Rotterdam en Schiedam", "De metro's stoppen na de storing.", True), # 'metro rijdt niet'-context (2) + metro(2) ('Spoorbrug wordt vervangen', '', False), # prompt-matrix v1.15
("Buslijn 12 vervalt tijdelijk", "De vervanging rijdt via de wijk.", True), # buslijn(2) + vervoer(1) ("# lijnnamen (audit v1.15): onmiskenbaar OV", "", True),
("Werkzaamheden aan perron", "Het perron wordt nieuwe maand opgeknapt.", True), # perron-titel-anker (2+2) ("Bezwaren tegen werkzaamheden Maaslijn afgewezen - werk mag doorgaan", "", True),
("Nieuwe bushalte geopend", "De halte ligt aan de rand van de wijk.", True), # bushalte-whitelist (2+2) ("Provincie Groningen vreest voor uitstel Nedersaksenlijn", "", True),
("Sprinters rijden niet tussen Utrecht en Amsterdam", "De oorzaak is nog onbekend.", True), # dienststoring-context(2) + sprinter(1) = 3 — prompt: echte sprinter-context
("Stoptreinen rijden niet naar Den Haag vanwege een storing", "De NS zet bussen in.", True), # 'stoptreinen rijden niet'(2) + trein(1) + ns(2)
# refactor 2026-09-13 — negatieve gevallen (prompt): ambigue woorden en
# straatnamen mogen niet zelfstandig voldoende zijn.
("Nieuwe woning bij station", "", False), # woning naast station: station(1) < 3
("Restaurant opent nieuwe vestiging", "Het restaurant ligt tegenover het station waar dagelijks veel reizigers komen.", False), # station+reizigers als bijzin — titel zegt niets over OV
("Stationstraat krijgt nieuwe woningen", "", False), # straatnaam-inert (oudere blacklist-entries verwijderd, refactor 2026-09-13)
("Werkzaamheden aan de stationsweg", "", False),
("Het stationsplein wordt heringericht voor de markt", "", False),
("De auto stond stationair te draaien", "", False),
("Nieuwe stationwagen van het merk", "", False),
("Stationcar uit de jaren zestig", "", False),
("De Metro publiceert vandaag een interview", "De krant verschijnt wekelijks.", False), # krant 'De Metro': metro(2) alleen
("Tram is een populaire naam voor het café", "", False), # café 'De Tram': tram(2) alleen
("Schoolbus betrokken bij ongeval", "De kinderen werden opgevangen.", False), # schoolbus: 0 OV-hits
("Bus en station tijdelijk gesloten voor het feest", "", False), # bus(1)+station(1) = 2 — geen auto-pass
("Wijkbewoners klagen over het station en de bussen", "De gemeente komt langs.", False), # station(1)+bussen(1)=2 — bus+station is geen auto-pass
("Passagiers klagen over de incheckbalie", "De balie opende te laat.", False), # passagiers(1)+inchecken(1) = 2
("Gemeente verhuist naar het stationsgebouw", "Het station en het stationsgebied worden vernieuwd.", False), # semantische stapel: station-varianten tellen niet als 4 signalen — alleen station(1) in samenvatting
# combinaties (prompt): twee onafhankelijke context-signalen zijn genoeg,
# tenzij de context niet-OV is (bovenstaande negatieve cases).
("Bus en tram krijgen in december een nieuwe dienstregeling", "", True), # bus(1)+dienstregeling(1)+tram(2) = 4 — echte OV-context van bus+dienstregeling
("Tram krijgt nieuwe dienstregeling", "De wijziging geldt vanaf december.", True), # tram(2)+dienstregeling(1) = 3
("De halte wordt verplaatst voor de bus", "De nieuwe halte komt bij de rotonde.", True), # halte(2)+bus(1) = 3
("Metro rijdt niet vanwege een storing", "Er is onbekend wanneer het rijden hervat.", True), # metro(2)+storing(1) = 3 (prompt: metro+storing)
("Trein rijdt niet meer op de spoorlijn naar Den Bosch", "Werkzaamheden duren tot maart.", True), # trein(1)+spoorlijn(2) = 3 (prompt: trein+spoorlijn)
("Sprinters rijden niet; NS zet bussen in", "", True), # sprinter(1)+rijden-niet(2)+ns(2) (prompt: sprinter+NS)
("De trams rijden niet door een storing", "Er zijn vervangende bussen ingezet.", True), # trams(2)+rijden-niet(2) (prompt: tram-storing)
("Tramverkeer stilgelegd na aanrijding", "", True), # tramverkeer-whitelist (prompt: tram-storing)
("ProRail meldt wisselstoring op traject Utrecht", "Het treinverkeer ligt stil.", True), # prorail-whitelist + treinverkeer(2)
("Treinstoring tussen Den Haag en Leiden", "Reizigers moeten rekening houden met vertraging.", True), # treinstoring-whitelist (prompt: treinstoring)
("Treinvertraging op het spoor bij Utrecht", "", True), # treinvertraging-whitelist (prompt: echte treinvertraging)
("NS past de dienstregeling aan", "Door werkzaamheden rijden er minder treinen.", True), # prompt-voorbeeld: ns + treinen
] ]
print(f"== regressie: {len(GOLDEN)} cases (precision-first) ==") print(f"== regressie: {len(GOLDEN)} cases (precision-first) ==")
for t, s, want in GOLDEN: for t, s, want in GOLDEN:
got = R._filter_passes(t, s) got = R._filter_passes(t, R._filter_text(s))
check(got == want, f"'{t[:55]}' -> {'door' if got else 'geweerd'} (verwacht {'door' if want else 'geweerd'})") check(got == want, f"'{t[:55]}' -> {'door' if got else 'geweerd'} (verwacht {'door' if want else 'geweerd'})")
print() print()
if failures: if failures:
print(f"FILTER-INVALIDATIE: {len(failures)} afwijking(en) — corrigeer vóór commit/deploy.") print(f"FILTER-INVALIDATIE: {len(failures)} afwijking(en) - corrigeer vóór commit/deploy.")
sys.exit(1) sys.exit(1)
print("ALLES OK — filter volgt het precision-first-beleid.") print("ALLES OK - filter volgt het precision-first-beleid.")