From 3cdc02aebe69ff5137f699ad923463b362456ca8 Mon Sep 17 00:00:00 2001 From: Eddy de Vink Date: Thu, 10 Sep 2026 23:17:21 +0200 Subject: [PATCH] v1.9.5: fix chipkaart/reisproduct zwakke familie (chipkaart(?:en)?), plus blijvend validate_filter.py + AGENTS.md (beleid wordt automatisch geladen en afdwingbaar) --- AGENTS.md | 19 ++++++ buzzrss/bot.py | 4 +- maubot.yaml | 2 +- validate_filter.py | 152 +++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 174 insertions(+), 3 deletions(-) create mode 100644 AGENTS.md create mode 100755 validate_filter.py diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..ff6fd78 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,19 @@ +# buzz-rss — verplichte regels bij elke wijziging (wordt automatisch bij sessiestart geladen) + +Maubot-plugin `nl.eddydevink.rss` (RSS → Matrix + WhatsApp), met een OV-nieuwsfilter dat **vast in code** staat. + +## Verplicht vóór elke filterwijziging +1. Lees het **volledige classificatiebeleid** in `buzzrss/bot.py` — het blok BOVEN `MATCH_FILTER_FAMILIES` + (driedeling whitelist / sterk / medium / zwak, precision-first, en de werkwijze voor nieuwe woorden). +2. Draai **`python3 buzzrss/validate_filter.py`** — de filter-invarianten en regressie-cases MOETEN groen + zijn voordat filtercode gecommit/gedeployed wordt. +3. 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 + sterk/whitelist. + +## Niet doen +- **NOOIT** maubot `reload` gebruiken. Deploy = mbp bouwen uit de VOLLEDIGE bron + (incl. `base-config.yaml`, anders `BaseMissingError`), upload met `allow_override=true`, + daarna container-restart. Exacte procedure: `skill://maubot`. +- **NOOIT** de wwebjs-container of WhatsApp-sessies (her)starten/beëindigen — meerdere bots delen die sessies. +- Productie draait het **geïnstalleerde mbp**, niet deze werkdirectory: een commit is pas actief na build+upload+restart. \ No newline at end of file diff --git a/buzzrss/bot.py b/buzzrss/bot.py index 8933bc2..7fc981e 100644 --- a/buzzrss/bot.py +++ b/buzzrss/bot.py @@ -140,8 +140,8 @@ MATCH_FILTER_FAMILIES: list[tuple[str, int]] = [ (r"\bvervoerders?\b", 1), # ook logistiek ('vrachtvervoerder') — aparte familie, moet combineren (r"\breizigers?\b", 1), # ook algemeen ('zakenreizigers vliegen') — aparte familie, moet combineren (r"\bdienstregeling\w*\b", 1), # dubbelzinnig (modelaudit) — meestal OV, maar moet combineren - (r"\bchipkaarten?\b", 1), # dubbelzinnig (bankpas/algemeen) — aparte familie - (r"\breisproducten?\b", 1), # dubbelzinnig — aparte familie + (r"\bchipkaart(?:en)?\b", 1), # dubbelzinnig (bankpas/algemeen) — aparte familie + (r"\breisproduct(?:en)?\b", 1), # dubbelzinnig — aparte familie (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 (r"\bbreng\b", 1), # vervoerder Breng vs werkwoord "brengen" ("hoe breng je ... in beeld") diff --git a/maubot.yaml b/maubot.yaml index 7cfce47..5509c9b 100644 --- a/maubot.yaml +++ b/maubot.yaml @@ -1,5 +1,5 @@ id: nl.eddydevink.rss -version: 1.9.4 +version: 1.9.5 modules: - buzzrss main_class: RSSBot diff --git a/validate_filter.py b/validate_filter.py new file mode 100755 index 0000000..b10f9ab --- /dev/null +++ b/validate_filter.py @@ -0,0 +1,152 @@ +#!/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", +} +WANT_METHODS = {"_fam_hits", "_filter_passes", "_filter_text", "_whitelist_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", "", "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", "vervoerder", "vervoerders", + "inchecken", "uitchecken", "sein", "overweg", "overwegen", "breng", + "kaartje", "opwaarderen", "rails", "dienstregeling", "chipkaart", + "reisproduct", "vervoersbewijs", "vervoerbewijs", + "ontspor", "ontspoor", "ontspoord", "ontspoorde", "ontsporing", +] +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", + "metrostation", "busstation", "conducteur", "ovpay", "ns", "prorail", + "treinstoring", "halte", "openbaar vervoer", "intercity", "buurtbus", + "veerdienst", "lijn 12", "ov-chipkaart", +] +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 +print("== invariant 3: alle 24+ families compileren, gewicht in {1,2,3} ==") +for p, w in FAMILIES: + check(w in (1, 2, 3), f"familie {p[:40]!r} heeft geldig gewicht {w}") +check(len(COMPILED) == len(FAMILIES), "alle families compileren") + +# --- 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), + # door — echte OV-berichten + ("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), + ("Metrostation Centraal tijdelijk gesloten", "De metro's rijden niet.", True), + ("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), +] +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.") \ No newline at end of file