buzz-rss/validate_filter.py
Eddy de Vink 91d0c3d7fe v1.14.0: refactor OV-filter naar driestapsmodel — één regel i.p.v. drie uitzonderingstakken
- _filter_passes: 'zwak-alleen in samenvatting' en 'sterk+zwak uitsluitend in
  samenvatting' reduceren tot één regel: zonder enig titel-signaal (familie-hit,
  whitelist-term of storingszin) mag de score geen zwak woord bevatten.
  Equivalentie bewezen over de volledige beslissing-ruimte (420 configuraties).
- Nieuwe dienststoring-context 'vervoerswoord rijdt/rijden niet' (gedeeld
  patroon): sterk titel-signaal (+2) en familie (2) — 'sprinters rijden niet
  tussen Utrecht en Amsterdam', 'metro rijdt niet tussen Rotterdam en
  Schiedam' passeren nu; bus/tram/metro/sprinter zelf blijven zwak/medium.
- Overlappende regexes opgeruimd: busbaan/bushokje uit de halte-familie.
- Blacklist 29 -> 2 woorden: restant (stationsstraat, stationair, stationcar,
  stationeren ...) was inert sinds de woordgrens-familie; vastgelegd in tests.
- validate_filter.py: 56 -> 90 regressie-cases + invariant 4 (whitelist-term
  in familie, blacklist-term door familie gematcht).
- _filter_summary/beleidsdocumentatie geactualiseerd.
2026-09-13 08:57:49 +02:00

289 lines
No EOL
21 KiB
Python
Executable file
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""Blijvende filter-invarianten + regressie voor het OV-matchfilter.
Draaien (van de repo-root): python3 validate_filter.py [alleen stdlib]
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 re
import sys
import textwrap
from pathlib import Path
ROOT = Path(__file__).resolve().parent
BOT = ROOT / "buzzrss" / "bot.py"
# --- extractie zonder maubot-imports (zelfde techniek als de audits) ---
src = BOT.read_text()
tree = ast.parse(src)
WANT_ASSIGN = {
"MATCH_FILTER_FAMILIES", "MATCH_FILTER_THRESHOLD", "MATCH_FILTER_BLACKLIST",
"MATCH_FILTER_WHITELIST", "_compiled_match_families",
"_WHITELIST_RE", "_WHITELIST_PHRASES",
"_DISRUPTION_RE_SRC", "_DISRUPTION_RE",
}
WANT_METHODS = {"_fam_hits", "_filter_passes", "_filter_text", "_whitelist_hit", "_disruption_hit"}
nodes = []
for n in tree.body:
if isinstance(n, ast.AnnAssign) and getattr(n.target, "id", "") in WANT_ASSIGN:
nodes.append(ast.unparse(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":
methods = [
ast.unparse(m)
for m in n.body
if isinstance(m, (ast.FunctionDef, ast.AsyncFunctionDef)) and m.name in WANT_METHODS
]
nodes.append("class RSSBot:\n" + textwrap.indent("\n".join(methods), " "))
ns = {}
exec(compile("from __future__ import annotations\nimport re, html\n" + "\n".join(nodes) + "\n", "<bot>", "exec"), ns)
R = ns["RSSBot"]
FAMILIES = ns["MATCH_FILTER_FAMILIES"] # [(regex, gewicht)]
WHITELIST = ns["MATCH_FILTER_WHITELIST"] # frozenset
COMPILED = [(re.compile(p, re.I), w) for p, w in FAMILIES]
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 = []
def check(cond: bool, msg: str):
print((" OK " if cond else " FOUT ") + msg)
if not cond:
failures.append(msg)
# --- Invariant 1: dubbelzinnige woorden mogen nooit sterk/medium of whitelist zijn
AMBIGUOUS = [
"trein", "bus", "station", "stations", "sprinter", "sprinters",
"reiziger", "reizigers", "passagier", "passagiers", "vervoerder", "vervoerders",
"inchecken", "uitchecken", "sein", "overweg", "overwegen", "breng",
"kaartje", "opwaarderen", "rails", "dienstregeling", "chipkaart",
"reisproduct", "vervoersbewijs", "vervoerbewijs",
"pendelbus", "shuttlebus",
"ontspor", "ontspoor", "ontspoord", "ontspoorde", "ontsporing",
# audit 2026-09-12 (max-2-stelsel + completheids-audit)
"hermes", "spoor", "staking", "stakingen", "vervoer", "vervoersbedrijf",
"vervoerregio", "ritten", "bovenleiding", "abonnement",
"busongeluk", "busongelukken", "busvervoer",
]
print(f"== invariant 1: {len(AMBIGUOUS)} dubbelzinnige woorden -> alleen zwak (1), nooit whitelist ==")
for w in AMBIGUOUS:
gew = weights_for(w)
check(1 in gew, f"{w!r} heeft een zwakke familie")
check(not (gew - {1}), f"{w!r} zit NIET in een sterke/medium familie (gewichten {sorted(gew)})")
check(not whitelisted(w), f"{w!r} staat NIET op de whitelist")
# --- Invariant 2: onmiskenbare OV-termen mogen niet uitsluitend zwak zijn
UNAMBIGUOUS = [
"spoorstoring", "spoorlijn", "perron", "bushalte", "bushokje",
"conducteur", "ovpay", "ns", "prorail",
"treinstoring", "halte", "openbaar vervoer", "intercity", "buurtbus",
"veerdienst", "lijn 12", "ov-chipkaart", "spoorbrug",
# audit 2026-09-12 (completheids-audit, max-2-stelsel)
"spoorsector", "spoorbeheerder", "spoornet", "spoorvervoerder",
"treinvertraging", "treinhinder", "treinuitval", "treinverstoring",
"ertms", "teso", "rangeerterrein", "rangeren", "ice-berlijn",
"maaslijn", "nedersaksenlijn", "kamperlijn",
"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)
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 = [
# (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),
("Amerikaanse talkshowhost Jimmy Kimmel schrapt gesprek 'na druk regering Trump'",
"ABC, onze regionale omroepen en lokale stations.", False),
("Inchecken op Schiphol duurt langer", "Passagiers staan in de rij bij de balies.", False),
("Sprinter verpulvert record op de 100 meter", "De atleet liep een nationale toptijd.", False),
("Zakenreizigers vliegen weer massaal", "Het aantal vluchten is gestegen.", False),
("Vrachtvervoerder failliet", "De transporteur heeft uitstel van betaling aangevraagd.", False),
("Het plan voor de nieuwe wijk ontspoorde", "De gemeente moet de kosten herzien.", False),
("Sein brandmeester: brandweer rukt uit", "De brandweer kreeg een sein om uit te rukken.", False),
("VW-bus in brand op de A2", "De inzittenden kwamen er veilig uit.", False),
("Fietser gewond op de Stationsstraat", "Het slachtoffer is naar het ziekenhuis gebracht.", False),
("Kunstenaar maakt kaartje voor het museum", "", False),
("Benzineprijs stijgt opnieuw", "De pomp wordt duurder door de olieprijs.", False),
# geweerd — zwakke woorden alléén in de samenvatting (geen titel-anker).
# Echt geval: Omroep Brabant, Solheim Cup-golfverslag (11-09-2026) — 'pendelbus'
# stond op de whitelist en 'station'+'pendelbus'+'bus' in de samenvatting was
# samen score 3.
("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),
("Toernooi trekt duizenden bezoekers", "Vanaf het station rijden pendelbussen en extra bussen.", False),
# door — echte OV-berichten
("Trein vertraagd, reizigers pakken de bus", "De storing duurde een uur.", True),
("Station Amersfoort krijgt nieuwe perrons", "De NS laat de perrons vernieuwen.", True),
("Trein ontspoorde bij Weesp", "De spoorlijn was uren dicht.", True),
("Bushalte aan de Markt wordt verplaatst", "De halte gaat 200 meter opschuiven.", True),
("Conducteur bekogeld in stoptrein bij Den Haag", "De conducteur is nagekeken.", True),
("Nieuwe app voor ovpay", "De ovpay-app wordt dit najaar vernieuwd.", True),
# fix 2026-09-11: station-samenstellingen zijn medium (niet meer whitelist/
# sterk) — een enkel 'busstation' in de samenvatting naast een ander onderwerp
# ('pand naast busstation') moet GEWEERD worden, en 'busstation' telt maar 1×.
("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),
("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)
("Nieuwe fietsenstalling bij busstation", "", False), # besluit 2026-09-11: één medium-hit in de titel is onvoldoende (titel weegt 1×, was 2×)
("Restaurant naast busstation wil uitbreiden", "De eigenaar wil uitbreiden naar het naastgelegen pand.", False), # zelfde regel, letterlijk gebruikersvoorbeeld
("Busstation wordt verbouwd", "De verbouwing duurt tot het voorjaar.", False), # herhaling/één woord = 1 signaal: 2 < 3 ('busstation' 60× weegt ook 1×)
("Metro rijdt niet", "De halte is tijdelijk gesloten.", True), # medium-combinatie over twee families: metro(2)+halte(2)=4
# fix 2026-09-12: whitelist-term alleen in de SAMENVATTING is geen OV-bewijs
# (valt terug op gewogen families); medium+zwak uitsluitend in de samenvatting
# (geen hit in de titel) wordt geweerd. Echte cases 11-09-2026:
# AT5-economiebericht ('openbaar vervoer' + 'zakelijke reizigers' als bijzin)
# en Telegraaf-overstroming Japan ('openbaar vervoer ontregeld' in de tekst).
("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),
("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),
# recall-bewaakt: 'openbaar vervoer' in de TITEL blijft onvoorwaardelijk door
# (whitelist-titel-anker; Havenloods 11-09-2026, 'einde aan gratis ov').
("Er komt echt een einde aan gratis openbaar vervoer voor ouderen",
"De gemeente stopt vanaf januari met de gratis reizen voor 65-plussers.", True),
# busstation mag niet dubbel meetellen via 'bus' én 'station' (was 2×3=6→nu 1×2)
("Metrostation Centraal tijdelijk gesloten", "De metro's rijden niet.", True),
# fix 2026-09-12: 'spoorbrug' is medium (niet meer sterk/whitelist). Echte
# case: Omroep Brabant Stuifmail (12-09-2026) — een natuurverhaal waarin iemand
# 'bij de spoorbrug' een waterschildpad zag; dat passeerde voorheen op één
# sterke spoorbrug-hit in de samenvatting.
("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),
("Spoorbrug in Boxtel wordt aangepakt", "De werkzaamheden duren tot het najaar.", False), # één medium-hit in de titel: 2 < 3
("Spoorbrug wordt verhoogd voor treinen", "De brug gaat omhoog zodat er hogere treinen onderdoor kunnen.", True), # spoorbrug(2) + trein(1) = 3 → door
("NS verlengt noodregeling op traject Zwolle", "Reizigers moeten rekening houden met minder treinen.", True),
("Spoorstoring bij Utrecht opgeheven", "ProRail heeft de wisselstoring verholpen.", True),
("Nieuwe intercity naar Berlijn", "De trein rijdt vanaf december vier keer per dag.", True),
("Streekvervoer kampt met personeelstekort", "Vervoerders schrappen ritten.", True),
("Buurtbus in de Achterhoek rijdt niet meer", "Er zijn te weinig vrijwillige chauffeurs.", True),
("Veerdienst Vlissingen-Breskens vaart uit de vaart", "De rederij zet een boot in.", True),
("Lijn 12 rijdt vanaf maandag een andere route", "De route gaat via het centrum.", True),
("Werkzaamheden aan perron", "Het perron wordt volgende maand opgeknapt.", True),
("Reactie Nederlandse Spoorwegen", "De Nederlandse Spoorwegen zeggen meer in te zetten op punctualiteit.", True),
# max-2-stelsel (besluit 2026-09-12): geproduceerd op 205 productie-artikelen
# + LLM-audit. Geen enkele familie weegt meer dan 2 → één hit kan nooit solo
# doorlaten; whitelist-term in de titel telt +2 (titel-anker).
("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),
# ^ echte false positive 2026-09-12: 'hermes' (achternaam) scoorde vroeger 3 via de vervoerdersfamilie
("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), # teso(2) + veerboot(2) = 4
("Vrouw buschauffeur Rob zwaar aangeslagen na dodelijk ongeval",
"Bij het dodelijke busongeluk in Zwitserland zat de politicus achter het stuur.", True), # buschauffeur(2) + busongeluk(1) = 3
("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), # ov(2) + staking(1) + spoorsector(2)
("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
("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), # ice-berlijn(2) + ns(2) = 4
("Laatste noot voor vernielde stationspiano Eindhoven",
"De piano in het station was jarenlang een begrip reizigers produceerden er filmpjes.", False), # cultuurstukje; 'station'/'reizigers' zwak-alleen zonder titel-anker
("Meerdere doden na busongeluk in Zwitserland", "Het busongeluk gebeurde naast de snelweg bij een afrit.", False), # busongeluk(1) alleen = zwak-alleen
("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
("NS verlengt noodregeling tot volgend jaar", "De vervoerder kampt met personeelstekorten.", True), # ns(2) + vervoerder(1) = 3
# fix 2026-09-13: één term mag nooit via twee families dubbel wegen.
# Echte case: Omroep Brabant (13-09-2026) — politie-achtervolging van fatbikers
# 'via de busbaan' scoorde 2+2=4 omdat 'busbaan' in de bus-samenstelling-familie
# (fam 4) én in de halte/busbaan-familie (fam 13) staat. Nu één signaal (2):
# zonder tweede OV-term onder de drempel.
("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),
("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
# recall bewaakt: echt busbaan-nieuws blijft door (whitelist-titel-anker +2) en
# twee VERSCHILLENDE termen blijven samen tellen (geen over-merge).
("Busbaan wordt afgesloten voor het doorgaande verkeer", "De omleiding duurt tot het voorjaar.", True),
("Bus rijdt om, busbaan is afgesloten", "De bussen rijden via de wijk.", True), # bus(1) + busbaan(2) = 3
# refactor 2026-09-13 — positieve gevallen die de dienststoring-context én
# sterke signalen vastleggen (prompt "Starke OV-signalen").
("NS meldt storing: sprinters rijden niet", "De storing wordt verholpen.", True), # ns-titel + 'sprinters rijden niet'-context
("Metro rijdt niet tussen Rotterdam en Schiedam", "De metro's stoppen na de storing.", True), # 'metro rijdt niet'-context (2) + metro(2)
("Buslijn 12 vervalt tijdelijk", "De vervanging rijdt via de wijk.", True), # buslijn(2) + vervoer(1)
("Werkzaamheden aan perron", "Het perron wordt nieuwe maand opgeknapt.", True), # perron-titel-anker (2+2)
("Nieuwe bushalte geopend", "De halte ligt aan de rand van de wijk.", True), # bushalte-whitelist (2+2)
("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) ==")
for t, s, want in GOLDEN:
got = R._filter_passes(t, s)
check(got == want, f"'{t[:55]}' -> {'door' if got else 'geweerd'} (verwacht {'door' if want else 'geweerd'})")
print()
if failures:
print(f"FILTER-INVALIDATIE: {len(failures)} afwijking(en) — corrigeer vóór commit/deploy.")
sys.exit(1)
print("ALLES OK — filter volgt het precision-first-beleid.")