From 7997d7572af324055330d41c51c23867539ac2b5 Mon Sep 17 00:00:00 2001 From: Eddy de Vink Date: Sat, 12 Sep 2026 19:53:41 +0200 Subject: [PATCH 1/5] =?UTF-8?q?v1.12.0:=20max-2-stelsel=20=E2=80=94=20enke?= =?UTF-8?q?le=20familie-hit=20kan=20nooit=20solo=20doorlaten?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Gewicht-3-tier vervallen: alle families wegen max 2; drempel blijft 3, dus elke echte OV-bericht combineert met een tweede signaal (besluit 2026-09-12, gevalideerd op 205 productie-artikelen). - 'hermes' uit vervoerders + whitelist -> eigen zwakke familie (achternaam Ton Hermes, Margraten false positive 2026-09-12). - Whitelist geen auto-pass meer: titel-hit = +2 titel-anker (onmiskenbare termen behouden hun recall; samenvatting blijft inert). - Completheids-audit (2026-09-12): +20 termen/frasen — sterk: spoorsector, spoorbeheerder, spoornet, spoorvervoerder, treinvertraging/hinder/uitval/verstoring, ertms, teso, rangeer/rangeren, ice-/ice-trein, maaslijn/nedersaksenlijn/kamperlijn, ns international, deutsche bahn, european sleeper, italo, nightjet; zwak: busongeluk, busvervoer, spoor, staking, passagier(+reizigers), vervoer, vervoersbedrijf, vervoerregio, ritten, bovenleiding, abonnement. - validate_filter.py: invariant gewicht in {1,2}, 46 dubbelzinnige + 39 onmiskenbare termen, 52 regressie-cases groen. --- buzzrss/bot.py | 328 ++++++++++++++++++++++++++------------------- maubot.yaml | 2 +- validate_filter.py | 38 +++++- 3 files changed, 223 insertions(+), 145 deletions(-) diff --git a/buzzrss/bot.py b/buzzrss/bot.py index d0d78a3..8b19018 100644 --- a/buzzrss/bot.py +++ b/buzzrss/bot.py @@ -57,21 +57,21 @@ rss_change_level = EventType.find("xyz.maubot.rss", t_class=EventType.Class.STAT # of het woord in beide velden = één signaal — 'busstation' 60× weegt 1×, besluit # 2026-09-11). Drempel (3): doorlaten als de som van de unieke familie-gewichten # >= MATCH_FILTER_THRESHOLD (titel weegt niet extra). -# Gewicht 3 (sterk, onmiskenbaar OV): één woord in titel óf samenvatting volstaat. -# Gewicht 2 (medium): moet combineren met een andere OV-familie; één medium-hit, -# in titel óf samenvatting, haalt de drempel niet. -# Gewicht 1 (zwak/dubbelzinnig: los "bus", "breng", "overweg", "overwegen", "sein", -# "kaartje", "opwaarderen", "rails", "pendelbus"): moet met een ander OV-woord -# 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" ("hoe breng je ... in beeld"), het idioom "goed met elkaar -# overweg kunnen", het werkwoord "overwegen" ("gratieverlening overwegen"), het -# werkwoord "opwaarderen" ("een wijk opwaarderen") en brandweerjargon -# ("sein brandmeester"), zonder eenduidige bushalte/overweg/NS/trein-berichten -# te raken. +# 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" @@ -80,61 +80,69 @@ rss_change_level = EventType.find("xyz.maubot.rss", t_class=EventType.Class.STAT # 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" (medium). -# 'spoorbrug' is MEDIUM (niet sterk/whitelist): een brug is vaak louter locatie -# ('bij de spoorbrug tussen Liempde en Boxtel', Stuifmail 2026-09-12) — één -# spoorbrug-hit in de samenvatting is geen OV-nieuws; écht spoorbrug-nieuws -# combineert met trein/spoor/verkeer en haalt de drempel alsnog (besluit 2026-09-12). -# Losse algemene woorden zijn zwak (moeten combineren met een andere familie): -# "trein", "sprinter" (atleet), "vervoerder" (logistiek), "reiziger", -# "inchecken"/"uitchecken" (Schiphol/hotel), "station(s)" (omroepen), "bus", -# "sein" (brandweer), "overweg", "breng", "kaartje", "opwaarderen", "rails". +# 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) # ===================================================================== # Doel: precisie boven recall. Liever een OV-bericht te veel gefilterd dan # een niet-OV-bericht te veel doorgelaten (gebruikersbesluit, 2026-09-10). # -# Drie lagen, elk met een eigen regel: -# 1. WHITELIST -> ALTIJD door BIJ EEN HIT IN DE TITEL. Alleen termen die -# in élke context OV betekenen (merken: NS/ProRail/Arriva; onmiskenbaar jargon: -# spoorstoring, perron, bushalte, conducteur, ovpay; multi-woord termen). -# REGEL: twijfel je of de term ook buiten het OV kan voorkomen -> NIET hier. -# NIET: station-samenstellingen (busstation/metrostation) — een bericht over -# een pand 'naast het busstation' is geen OV-nieuws (besluit 2026-09-11); -# die wegen mee via de gewogen families (medium). -# Een whitelist-term ALLEEN in de SAMENVATTING laat NIET onvoorwaardelijk -# door: 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 via de gewogen families (dezelfde termen staan daar ook in). -# 2. GEWOGEN FAMILIES -> score = som van unieke OV-families over het hele artikel -# (titel + samenvatting; per familie 1× per artikel — herhaling of beide velden -# tellen niet dubbel, besluit 2026-09-11) >= drempel (3). -# - sterk (3): onmiskenbaar OV-jargon/merken/samenvoegingen (spoorlijn, -# bushalte, busverkeer, intercity, lijn 12, ...). -# - medium (2): onmiskenbaar OV maar iets algemener (tram, metro, halte, -# pont, ovpay, openbaar vervoer, trein-/bus-/metrostation). -# Moet COMBINEREN met een andere OV-familie: één medium-hit, -# in titel óf samenvatting, is onvoldoende — de titel weegt -# niet extra ('restaurant naast busstation' in de titel is -# geen OV-bericht, besluit 2026-09-11). -# EXTRA (besluit 2026-09-12): een medium + zwak-combinatie -# UITSLUITEND in de samenvatting (geen enkele hit in de -# titel) is geen OV-bewijs — 'openbaar vervoer' + 'zakelijke -# reizigers' in een economiebericht telt dan toch op tot de -# drempel. Sterke (3) families blijven in de samenvatting -# gewoon tellen. +# Lagen, elk met een eigen regel (MAX-2-stelsel, besluit 2026-09-12): +# 1. WHITELIST -> termen die in élke context OV betekenen (merken: +# NS/ProRail/Arriva; onmiskenbaar jargon: spoorstoring, perron, bushalte, +# conducteur, ovpay; multi-woord-frasen: nederlandse spoorwegen, openbaar +# vervoer, ice-berlijn ...). Een whitelist-hit in de TITEL telt +2 (geen +# auto-pass meer). Omdat elke whitelist-term óók in een familie zit, is dat +# effectief het oude titel-anker voor onmiskenbare termen — 'Reactie +# Nederlandse Spoorwegen' blijft daardoor door. 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 overeenkomstige familie. +# 2. GEWOGEN FAMILIES -> score = som van unieke OV-families over het hele +# artikel (titel + samenvatting; per familie 1× per artikel — herhaling of +# beide velden tellen niet dubbel, besluit 2026-09-11) >= drempel (3). +# - sterk (2): onmiskenbaar OV-jargon/merken/samenvoegingen (spoorlijn, +# bushalte, busverkeer, intercity, veerboot, ertms, +# rangeer..., ice-frasen, ...). 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). +# EXTRA (besluit 2026-09-12): een sterk/medium + zwak- +# combinatie UITSLUITEND in de samenvatting (geen enkele hit +# in de titel én geen whitelist-term in de titel) is geen +# OV-bewijs — 'openbaar vervoer' + 'zakelijke reizigers' in +# een economiebericht telt anders op tot de drempel. # - 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, vervoerder, -# inchecken/uitchecken, sein, overweg, breng, kaartje, -# opwaarderen, rails, dienstregeling, chipkaart, ontsporen, -# pendelbus/shuttlebus (evenement-/hotel-/parkeerpendel; -# synoniemen -> bewust één familie, anders tellen twee -# synoniemen als twee onafhankelijke signalen). +# trein, bus, station(s), sprinter, reiziger/passagier, +# vervoerder, inchecken/uitchecken, sein, overweg, breng, +# kaartje, opwaarderen, rails, dienstregeling, chipkaart, +# ontsporen, pendelbus/shuttlebus (evenement-/hotel-/ +# parkeerpendel; synoniemen -> bewust één familie), +# busongeluk/busvervoer (touringcar/schoolbus), staking, +# vervoer, vervoersbedrijf, vervoerregio, ritten, +# bovenleiding (industrie), abonnement, hermes, spoor. # EXTRA REGEL voor zwak-alleen: staat er uitsluitend zwak # bewijs in de SAMENVATTING (geen enkele zwakke hit in de # titel), dan niet doorlaten. Twee/meer zwakke woorden in @@ -143,27 +151,38 @@ rss_change_level = EventType.find("xyz.maubot.rss", t_class=EventType.Class.STAT # 3. BLACKLIST -> gematchte woorden zonder OV-waarde (stationsstraat, # stationair, stationcar, ...); blokkeren de familie niet. # -# BEOORDELEN VAN EEN NIEUW WOORD: +# BEOORDELEN VAN EEN NIEUW WOORD (incl. COMPLETEHEIDS-AUDIT, 2026-09-12): # a) Kan het ook buiten het OV voorkomen? ja -> zwak (1), eigen familie. -# b) Is het een samenvoeging/merk die alleen OV kan betekenen? -> sterk (3) -# of whitelist. +# b) Is het een samenvoeging/merk die alleen OV kan betekenen? -> sterk (2) +# of whitelist (frasen die in élke context OV betekenen). # c) Bij twijfel: zwak (1). Conservatief = precision-first. # d) Ter controle: leg nieuwe woordkandidaten voor aan minimaal één extra # taalmodel (de audit op 2026-09-10 gebruikte 3 modellen; enige stem # 'TWIJFEL'/'NIET-OV' => behandelen als dubbelzinnig). +# e) COMPLETEHEID (besluit 2026-09-12): check bij elke filterwijziging óók of +# de families compleet zijn — draai de OV-gap-scan over de afgewezen +# artikelen van OV-specialist-feeds (SpoorPro/Treinreiziger/Mobiliteit), +# classificeer nieuwe kandidaten via stap a-d en vul aan. De audit van +# 2026-09-12 leverde o.a. spoorsector, spoorbeheerder, spoornet, +# spoorvervoerder, treinvertraging/uitval, ertms, teso, rangeer, ice-frasen, +# maaslijn, ns international, deutsche bahn, european sleeper, italo, +# nightjet (sterk) en 11 zwakke woorden op. # ===================================================================== MATCH_FILTER_FAMILIES: list[tuple[str, int]] = [ - # sterk (3) - (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|hermes|rrreis|qliner|ov)\b", 3), # vervoerders ('Breng' staat in de zwakke groep: botst met het werkwoord "brengen") - (r"\b(?:spoorweg|spoorba(?:an|nen)|spoorlijn|spoorverkeer|spoorwegovergang|spoorstoring|perron|seinstoring|wisselstoring|spoorstaking)\w*\b", 3), # spoorinfra; let op de klinkerwisseling: 'ontsporing' (open lettergreep, één o) vs 'ontspoord'/'ontspoorde' (dicht, dubbel o) — vandaar beide stammen. Géén 'spoorbrug': die is medium (vaak locatie-referentie, besluit 2026-09-12) - (r"\b(?:treinstation|treinstations|metrostation|metrostations|busstation|busstations)\b", 2), # station-samenstellingen: onmiskenbaar OV, maar MEDIUM — éé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 + # sterk (2) — onmiskenbaar OV, maar één hit is onvoldoende (max-2-stelsel, + # besluit 2026-09-12): elke echte OV-bericht combineert met een tweede signaal. + (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"\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"\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"(?= 3. Max-2-stelsel +# (besluit 2026-09-12): geen enkele familie weegt meer dan 2, dus een enkele hit +# kan nooit solo doorlaten; een whitelist-term in de titel telt +2 (zie +# _filter_passes). De oude '2×titel + 1×samenvatting'-rekensom is vervallen +# (2026-09-11: de titel weegt niet extra). MATCH_FILTER_THRESHOLD = 3 # Woorden die een familie wél matcht maar die geen OV-waarde hebben. Een hit dat @@ -216,34 +250,38 @@ MATCH_FILTER_BLACKLIST: frozenset[str] = frozenset({ "stationnement", "stationering", }) -# MATCH_FILTER_WHITELIST: termen die een bericht per definitie OV maken. Eén enkele -# hit in de TITEL laat ALTIJD door (geen familiescore nodig) — dit is het expliciete -# "onmiskenbaar OV"-signaal, zodat we voor deze termen niet op de gewogen -# heuristiek hoeven te leunen. Vergelijking is case-insensitive, dus ook 'nederlandse -# spoorwegen', 'prorail' en 'NS' in all-caps titels tellen mee. -# Een whitelist-hit ALLEEN in de samenvatting is géén onvoorwaardelijk signaal -# (besluit 2026-09-12: 'openbaar vervoer' als bijzin in een economiebericht is geen -# OV-nieuws); die term scoort dan via de gewogen families — voor whitelist-termen -# die geen eigen familie hebben (bv. 'treinstel': alleen zwak via \w*trein\w*) is -# een samenvatting-alleen hit dus zwak. -# Termen MOETEN in elke context OV betekenen. Niet hierop (algemeen/dubbelzinnig): -# los 'trein', 'bus', 'metro', 'tram', 'station', 'lijn', 'ov', 'reiziger', -# 'vervoerder', 'sprinter', -# 'inchecken' (Schiphol/hotel), 'sein' (brandweerjargon), 'overweg', 'treinramp' -# (films) — die blijven in de gewogen families. +# 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", "hermes", "rrreis", + "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", "perron", "wisselstoring", "seinstoring", "treinstoring", "treinverkeer", - "treinstaking", "treinongeval", "treinbotsing", "treinstel", + "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", "tramhalte", "tramlijn", "tramnet", "tramverkeer", + "busstaking", "buurtbus", "veerdienst", "lijn 12", + "tramhalte", "tramlijn", "tramnet", "tramverkeer", "metrolijn", "metroverkeer", # Tarief/vervoerder-jargon "ov-chipkaart", "ov-staking", "lightrail", @@ -420,54 +458,63 @@ class RSSBot(Plugin): @staticmethod def _filter_passes(title: str, summary: str) -> bool: - """Gewogen familiescore: elke OV-familie telt 1× per artikel (titel + samenvatting - samen; herhaling of beide velden = één signaal). Doorlaten bij som >= drempel. - (titel weegt NIET extra — één medium-hit, 'restaurant naast busstation', - is geen OV-bewijs; besluit 2026-09-11). + """Gewogen familiescore (max-2-stelsel, besluit 2026-09-12): elke OV-familie + telt 1× per artikel (titel + samenvatting samen; herhaling of beide velden = + één signaal). Doorlaten bij som >= drempel (3). Geen enkele familie weegt + meer dan 2, dus een enkele familie-hit kan nooit solo doorlaten — 'hermes' + als achternaam passeerde vroeger op één vervoerder-hit van 3 (Margraten, + 2026-09-12); écht OV-nieuws combineert in de praktijk altijd met een tweede + signaal (gevalideerd op 205 productie-artikelen). + + Whitelist-termen (onmiskenbaar OV in élke context) tellen in de TITEL +2 — + géén auto-pass meer; omdat elke whitelist-term óók in een familie zit, is + dat effectief het behouden titel-anker ('Reactie Nederlandse Spoorwegen', + 'Werkzaamheden aan perron', 'Buurtbus rijdt niet meer' blijven door). Een + whitelist-term ALLEEN in de samenvatting is geen OV-bewijs (besluit + 2026-09-12: 'openbaar vervoer' als bijzin in een economiebericht) en scoort + alleen via de overeenkomstige familie. - Sterke families (gewicht 3) halen de drempel al met één woord in titel óf - samenvatting. Medium (2) moet combineren met een andere OV-familie. Zwakke woorden (losse 'bus', 'breng', 'overweg', 'overwegen', 'sein', - 'opwaarderen', 'kaartje', 'rails', 'pendelbus') moeten combineren met een - ander OV-woord én minstens één zwak woord moet in de titel staan; dezelfde - zwakke familie in titel én samenvatting is géén combinatie. Whitelist-termen - (NS, perron, bushalte, openbaar vervoer, ...) laten alleen onvoorwaardelijk - door BIJ EEN HIT IN DE TITEL; in de samenvatting wegen ze mee als gewogen - familie. Een medium + zwak-combinatie UITSLUITEND in de samenvatting (geen - enkele hit in de titel) is geen OV-bewijs ('openbaar vervoer' + 'zakelijke - reizigers' in een economiebericht, besluit 2026-09-12). Weert oa - 'VW-bus' (bus matcht niet ná een koppelteken), 'schoolbus', 'bussen rijden - om' in een woningbrand-lead, het werkwoord 'brengen', het idioom 'goed met - elkaar overweg kunnen', 'een wijk opwaarderen', brandweerjargon - 'sein brandmeester', figuratief 'weer op de rails' en een golfverslag - waarin de samenvatting 'station' + 'pendelbus' + 'bus' noemt. - Bewust doorgelaten: krant 'De Metro'/café 'De Tram' (medium) en atleet- - 'sprinter' in combinatie met ander OV-woord.""" - # Whitelist: een onmiskenbare OV-term in de TITEL laat altijd door (het - # artikel kondigt het onderwerp aan: 'NS verlengt noodregeling'). Een - # whitelist-term ALLEEN in de samenvatting is geen OV-bewijs ('openbaar - # vervoer' als bijzin in een economiebericht, besluit 2026-09-12) en - # scoort via de gewogen families hieronder. - if RSSBot._whitelist_hit(title): - return True + 'opwaarderen', 'kaartje', 'rails', 'pendelbus', 'spoor', 'staking', + 'vervoer', 'busongeluk', ...) moeten combineren met een ander OV-woord én + minstens één zwak woord moet in de titel staan (of een whitelist-term in de + titel); dezelfde zwakke familie in titel én samenvatting is géén combinatie. + Een sterk (2) + zwak-combinatie UITSLUITEND in de samenvatting (geen + familie-hit én geen whitelist-term in de titel) is geen OV-bewijs + ('openbaar vervoer' + 'zakelijke reizigers' in een economiebericht, besluit + 2026-09-12). Weert oa 'VW-bus' (bus matcht niet ná een koppelteken), + 'schoolbus', 'bussen rijden om' in een woningbrand-lead, het werkwoord + 'brengen', het idioom 'goed met elkaar overweg kunnen', 'een wijk + opwaarderen', brandweerjargon 'sein brandmeester', figuratief 'weer op de + rails', een golfverslag waarin de samenvatting 'station' + 'pendelbus' + + 'bus' noemt, en het Margraten-bericht met 'Ton Hermes' als achternaam. + Bewust doorgelaten: krant 'De Metro'/café 'De Tram' (2, combineert) en + atleet-'sprinter' in combinatie met ander OV-woord.""" t_hits = RSSBot._fam_hits(title) s_hits = RSSBot._fam_hits(summary) # Eén familie telt 1× per ARTIKEL (titel én samenvatting samen): 'busstation' # in beide velden, of 60× in de tekst, blijft één signaal (besluit 2026-09-11). combined = dict(t_hits + s_hits) score = sum(combined.values()) + whitelist_title = RSSBot._whitelist_hit(title) + if whitelist_title: + # Whitelist-term in de titel = +2 (geen auto-pass, max-2-stelsel + # 2026-09-12): houdt het titel-anker voor onmiskenbare termen, maar + # een enkele whitelist-hit laat nooit meer solo door. + score += 2 if score < MATCH_FILTER_THRESHOLD: return False - if max(combined.values()) == 1: + if max(combined.values(), default=0) == 1: # Alleen zwakke families: dan moeten ≥2 verschillende families meetellen - # én minstens één zwak woord moet in de TITEL staan. Zwakke woorden in - # de samenvatting alleen zijn geen bewijs ('station' + 'pendelbus' + - # 'bus' in een golfverslag telt anders op tot de drempel). - if not t_hits or len(combined) < 2: + # én een titel-anker (familie-hit óf whitelist-term in de titel). + # Zwakke woorden in de samenvatting alleen zijn geen bewijs + # ('station' + 'pendelbus' + 'bus' in een golfverslag telt anders op + # tot de drempel). + if not whitelist_title and (not t_hits or len(combined) < 2): return False - elif not t_hits and 1 in combined.values(): - # Medium + zwak uitsluitend in de samenvatting, niets in de titel: geen - # OV-bewijs. 'openbaar vervoer' + 'zakelijke reizigers' in een + elif not t_hits and not whitelist_title and 1 in combined.values(): + # Sterk (2) + zwak uitsluitend in de samenvatting, niets in de titel: + # geen OV-bewijs. 'openbaar vervoer' + 'zakelijke reizigers' in een # economiebericht telt anders op tot de drempel (besluit 2026-09-12). return False return True @@ -476,20 +523,21 @@ class RSSBot(Plugin): def _filter_summary() -> str: """Mensvriendelijke weergave van het vaste gewogen match-filter.""" return ( - "**OV-matchfilter (vast in code, gewogen families):**\n" - "- Sterk (3): vervoerders (NS, Arriva, ProRail, Qbuzz, ...), spoorinfra (spoorlijn, spoorwegovergang, perron, spoorstoring), " - "samenstellingen als bushalte/buslijn/bushokje/busbaan/busverkeer, treinstoring/treinverkeer/treinstaking, " - "intercity/streekvervoer/veerdienst/buurtbus/belbus, 'lijn 12'\n" - "- Medium (2): tram, metro, openbaar vervoer, lightrail, halte, pont, spoorbrug, ov-chipkaart/ovpay, ov-staking, conducteur, " - "trein-/bus-/metrostation — één medium-hit volstaat niet, moet combineren met een andere OV-familie; " - "medium + zwak uitsluitend in de samenvatting (geen hit in de titel) telt niet\n" + "**OV-matchfilter (vast in code, gewogen families, max-2):**\n" + "- Sterk (2): vervoerders (NS, Arriva, ProRail, Qbuzz, Deutsche Bahn, TESO, ...), spoorinfra (spoorlijn, spoorstoring, perron, spoorsector, ertms), " + "samenstellingen als bushalte/buslijn/buschauffeur, treinstoring/treinverkeer/treinvertraging, " + "intercity/streekvervoer/veerdienst/buurtbus/belbus, 'lijn 12', ICE-frasen, rangeer... — één hit volstaat NIET: " + "moet combineren (max-2-stelsel, besluit 2026-09-12)\n" + "- Middel (2): tram, metro, openbaar vervoer, lightrail, halte, pont, spoorbrug, ov-chipkaart/ovpay, ov-staking, conducteur, " + "trein-/bus-/metrostation — zelfde regel: moet combineren met een andere OV-familie; " + "sterk/middel + zwak uitsluitend in de samenvatting (geen hit in de titel) telt niet\n" "- Zwak (1): losse 'bus' (niet ná een koppelteken), 'pendelbus'/'shuttlebus', 'trein', 'station(s)', 'breng', 'overweg', 'overwegen', " "'sein', 'kaartje', 'opwaarderen', 'rails', 'inchecken'/'uitchecken', 'dienstregeling', 'chipkaart', 'reisproduct', 'vervoersbewijs', " - "'ontsporen', 'reiziger', 'vervoerder', 'sprinter' — moet combineren met een ander OV-woord, met minstens één zwak woord in de titel\n\n" - "**Whitelist (altijd door bij een hit in de TITEL):** een onmisbare OV-term in de titel is genoeg " + "'ontsporen', 'reiziger'/'passagier', 'vervoerder', 'sprinter', 'spoor', 'staking', 'vervoer', 'busongeluk', ... — moet combineren met een ander OV-woord, met minstens één zwak woord in de titel\n\n" + "**Whitelist (+2 bij een hit in de TITEL, geen auto-pass):** onmiskenbare OV-termen " "(NS, ProRail, Nederlandse Spoorwegen, spoorstoring, perron, bushalte, conducteur, " - "ov-chipkaart, ovpay, openbaar vervoer, ...); in de samenvatting alleen weegt zo'n term mee " - "als gewogen familie (medium/sterk)\n\n" + "ov-chipkaart, ovpay, openbaar vervoer, Deutsche Bahn, ICE Berlijn, ...); in de samenvatting alleen weegt zo'n term mee " + "als gewogen familie\n\n" "Uitgesloten (blacklist): woorden die wél matchen maar geen OV-waarde hebben, " "o.a. straatnamen rond stations ('stationsstraat', 'stationsweg', ...) en niet-OV-vormen als 'stationcar' en 'stationair'\n\n" "Doorlaten: som van unieke OV-families ≥ 3 — één familie telt 1× per artikel " diff --git a/maubot.yaml b/maubot.yaml index 28b5889..84064ec 100644 --- a/maubot.yaml +++ b/maubot.yaml @@ -1,5 +1,5 @@ id: nl.eddydevink.rss -version: 1.11.0 +version: 1.12.0 modules: - buzzrss main_class: RSSBot diff --git a/validate_filter.py b/validate_filter.py index 5637491..d82c66c 100755 --- a/validate_filter.py +++ b/validate_filter.py @@ -73,12 +73,16 @@ def check(cond: bool, msg: str): # --- Invariant 1: dubbelzinnige woorden mogen nooit sterk/medium of whitelist zijn AMBIGUOUS = [ "trein", "bus", "station", "stations", "sprinter", "sprinters", - "reiziger", "reizigers", "vervoerder", "vervoerders", + "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: @@ -93,6 +97,12 @@ UNAMBIGUOUS = [ "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: @@ -100,10 +110,10 @@ for w in UNAMBIGUOUS: 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} ==") +# --- 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, 3), f"familie {p[:40]!r} heeft geldig gewicht {w}") + 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") # --- Regressie: gedrag van _filter_passes op bekende cases (precision-first) @@ -183,6 +193,26 @@ GOLDEN = [ ("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 ] print(f"== regressie: {len(GOLDEN)} cases (precision-first) ==") for t, s, want in GOLDEN: From e1b30fc2ee49c32634787180f2f114c9a57d0359 Mon Sep 17 00:00:00 2001 From: Eddy de Vink Date: Sat, 12 Sep 2026 19:58:41 +0200 Subject: [PATCH 2/5] AGENTS.md: compleetheids-audit verplicht bij elke filterwijziging (max-2-stelsel, besluit 2026-09-12) --- AGENTS.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 31bfb7a..71c366b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,10 +4,15 @@ Maubot-plugin `nl.eddydevink.rss` (RSS → Matrix + WhatsApp), met een OV-nieuws ## 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). + (max-2-stelsel: whitelist + gewichten 2/1, 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 +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 + OV-specialist-feeds (SpoorPro.nl / Treinreiziger.nl / Mobiliteit, query op `entry` in schema `mbp_rss`), + 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`. +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 sterk/whitelist. From 1346cbb5494546903eb74601b9e2726747f12fdb Mon Sep 17 00:00:00 2001 From: Eddy de Vink Date: Sun, 13 Sep 2026 08:37:17 +0200 Subject: [PATCH 3/5] =?UTF-8?q?v1.13.0:=20=C3=A9=C3=A9n=20term=20=3D=20?= =?UTF-8?q?=C3=A9=C3=A9n=20signaal=20=E2=80=94=20overlappende=20familie-tr?= =?UTF-8?q?effers=20wegen=201=C3=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 'busbaan' stond in de bus-samenstelling-familie (fam 4) én de halte/busbaan-familie (fam 13): één woord scoorde 2+2=4 en liet zo een niet-OV-bericht door (Omroep Brabant politie-achtervolging 'via de busbaan', 13-09-2026). Latente dubbeltellingen: 'ov-chipkaart' bevat 'chipkaart', 'openbaar vervoer' bevat 'vervoer'. _fam_hits voegt nu overlappende span van verschillende families samen met het hoogste gewicht (retour blijft (familie-index, gewicht), dus de familie-één-signaal-regel over titel+samenvatting is intact). Beleidscommentaar + regressie (56 cases) bijgewerkt; 45-dagen-replay (90 geposte entries): precies 1 gedragsverschil — het fatbikers- bericht wordt nu geweerd, delftopzondag (tram+halte, twee termen) blijft door (besluit 2026-09-13). --- buzzrss/bot.py | 50 ++++++++++++++++++++++++++++++++++++---------- maubot.yaml | 2 +- validate_filter.py | 12 +++++++++++ 3 files changed, 53 insertions(+), 11 deletions(-) diff --git a/buzzrss/bot.py b/buzzrss/bot.py index 8b19018..d9cdac1 100644 --- a/buzzrss/bot.py +++ b/buzzrss/bot.py @@ -55,8 +55,11 @@ rss_change_level = EventType.find("xyz.maubot.rss", t_class=EventType.Class.STAT # 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). Drempel (3): doorlaten als de som van de unieke familie-gewichten -# >= MATCH_FILTER_THRESHOLD (titel weegt niet extra). +# 2026-09-11). Eén term die meerdere families matcht telt óók maar 1× (hoogste +# gewicht wint): 'busbaan' zit in de bus-samenstelling- én de halte/busbaan-familie, +# 'ov-chipkaart' bevat 'chipkaart', 'openbaar vervoer' bevat 'vervoer' — geen +# dubbeltelling (besluit 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). @@ -118,6 +121,10 @@ rss_change_level = EventType.find("xyz.maubot.rss", t_class=EventType.Class.STAT # 2. GEWOGEN FAMILIES -> score = som van unieke OV-families over het hele # artikel (titel + samenvatting; per familie 1× per artikel — herhaling of # beide velden tellen niet dubbel, besluit 2026-09-11) >= drempel (3). +# Overlappende treffers van verschillende families (één term op één plek) +# tellen samen als 1× met het hoogste gewicht — een term mag nooit dubbel +# wegen ('busbaan' in de bus-samenstelling- én de halte/busbaan-familie, +# besluit 2026-09-13). # - sterk (2): onmiskenbaar OV-jargon/merken/samenvoegingen (spoorlijn, # bushalte, busverkeer, intercity, veerboot, ertms, # rangeer..., ice-frasen, ...). Eén hit (titel óf samenvatting) @@ -175,7 +182,7 @@ MATCH_FILTER_FAMILIES: list[tuple[str, int]] = [ (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"\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"(? list[tuple[int, int]]: - """(familie-index, gewicht) voor elke OV-familie die in text voorkomt (1× per familie). + """(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.""" - hits: list[tuple[int, int]] = [] + 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 — 'busbaan' zit in de bus-samenstelling-familie + én in de halte/busbaan-familie (2+2=4), 'ov-chipkaart' bevat 'chipkaart' + (2+1=3), 'openbaar vervoer' bevat 'vervoer' (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 - hits.append((idx, weight)) + spans.append((m.start(), m.end(), idx, weight)) break - return hits + 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 def _filter_passes(title: str, summary: str) -> bool: """Gewogen familiescore (max-2-stelsel, besluit 2026-09-12): elke OV-familie telt 1× per artikel (titel + samenvatting samen; herhaling of beide velden = - één signaal). Doorlaten bij som >= drempel (3). Geen enkele familie weegt + één signaal). Overlappende treffers van verschillende families tellen samen + als 1× met het hoogste gewicht — 'busbaan' (bus-samenstelling- én + halte/busbaan-familie) levert één signaal van 2, geen 4 (besluit 2026-09-13). + Doorlaten bij som >= drempel (3). Geen enkele familie weegt meer dan 2, dus een enkele familie-hit kan nooit solo doorlaten — 'hermes' als achternaam passeerde vroeger op één vervoerder-hit van 3 (Margraten, 2026-09-12); écht OV-nieuws combineert in de praktijk altijd met een tweede @@ -494,6 +523,7 @@ class RSSBot(Plugin): s_hits = RSSBot._fam_hits(summary) # Eén familie telt 1× per ARTIKEL (titel én samenvatting samen): 'busstation' # in beide velden, of 60× in de tekst, blijft één signaal (besluit 2026-09-11). + # _fam_hits levert per term al het hoogste gewicht bij overlappende families. combined = dict(t_hits + s_hits) score = sum(combined.values()) whitelist_title = RSSBot._whitelist_hit(title) diff --git a/maubot.yaml b/maubot.yaml index 84064ec..3755ba0 100644 --- a/maubot.yaml +++ b/maubot.yaml @@ -1,5 +1,5 @@ id: nl.eddydevink.rss -version: 1.12.0 +version: 1.13.0 modules: - buzzrss main_class: RSSBot diff --git a/validate_filter.py b/validate_filter.py index d82c66c..2c54614 100755 --- a/validate_filter.py +++ b/validate_filter.py @@ -213,6 +213,18 @@ GOLDEN = [ ("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 ] print(f"== regressie: {len(GOLDEN)} cases (precision-first) ==") for t, s, want in GOLDEN: From 91d0c3d7fe825d14fc078809c52bba078c865382 Mon Sep 17 00:00:00 2001 From: Eddy de Vink Date: Sun, 13 Sep 2026 08:57:49 +0200 Subject: [PATCH 4/5] =?UTF-8?q?v1.14.0:=20refactor=20OV-filter=20naar=20dr?= =?UTF-8?q?iestapsmodel=20=E2=80=94=20=C3=A9=C3=A9n=20regel=20i.p.v.=20dri?= =?UTF-8?q?e=20uitzonderingstakken?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - _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. --- buzzrss/bot.py | 297 ++++++++++++++++++++++++--------------------- maubot.yaml | 2 +- validate_filter.py | 53 +++++++- 3 files changed, 213 insertions(+), 139 deletions(-) diff --git a/buzzrss/bot.py b/buzzrss/bot.py index d9cdac1..2215ced 100644 --- a/buzzrss/bot.py +++ b/buzzrss/bot.py @@ -56,10 +56,12 @@ rss_change_level = EventType.find("xyz.maubot.rss", t_class=EventType.Class.STAT # 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): 'busbaan' zit in de bus-samenstelling- én de halte/busbaan-familie, -# 'ov-chipkaart' bevat 'chipkaart', 'openbaar vervoer' bevat 'vervoer' — geen -# dubbeltelling (besluit 2026-09-13). Drempel (3): doorlaten als de som van de -# unieke familie-gewichten >= MATCH_FILTER_THRESHOLD (titel weegt niet extra). +# 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). @@ -105,38 +107,37 @@ rss_change_level = EventType.find("xyz.maubot.rss", t_class=EventType.Class.STAT # Doel: precisie boven recall. Liever een OV-bericht te veel gefilterd dan # een niet-OV-bericht te veel doorgelaten (gebruikersbesluit, 2026-09-10). # -# Lagen, elk met een eigen regel (MAX-2-stelsel, besluit 2026-09-12): -# 1. WHITELIST -> termen die in élke context OV betekenen (merken: +# Beslissing, in drie stappen (refactor 2026-09-13; logica equivalent met de +# voorheen gelaagde takken — MAX-2-stelsel, besluit 2026-09-12): +# 1. STERK SIGNAAL (whitelist-term óf dienststoring-zin in de TITEL) -> DOORLATEN. +# Whitelist = termen die in élke context OV betekenen (merken: # NS/ProRail/Arriva; onmiskenbaar jargon: spoorstoring, perron, bushalte, # conducteur, ovpay; multi-woord-frasen: nederlandse spoorwegen, openbaar -# vervoer, ice-berlijn ...). Een whitelist-hit in de TITEL telt +2 (geen -# auto-pass meer). Omdat elke whitelist-term óók in een familie zit, is dat -# effectief het oude titel-anker voor onmiskenbare termen — 'Reactie -# Nederlandse Spoorwegen' blijft daardoor door. 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 overeenkomstige familie. -# 2. GEWOGEN FAMILIES -> score = som van unieke OV-families over het hele -# artikel (titel + samenvatting; per familie 1× per artikel — herhaling of -# beide velden tellen niet dubbel, besluit 2026-09-11) >= drempel (3). -# Overlappende treffers van verschillende families (één term op één plek) -# tellen samen als 1× met het hoogste gewicht — een term mag nooit dubbel -# wegen ('busbaan' in de bus-samenstelling- én de halte/busbaan-familie, -# besluit 2026-09-13). +# 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, ...). 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). -# EXTRA (besluit 2026-09-12): een sterk/medium + zwak- -# combinatie UITSLUITEND in de samenvatting (geen enkele hit -# in de titel én geen whitelist-term in de titel) is geen -# OV-bewijs — 'openbaar vervoer' + 'zakelijke reizigers' in -# een economiebericht telt anders op tot de drempel. +# 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 @@ -145,18 +146,27 @@ rss_change_level = EventType.find("xyz.maubot.rss", t_class=EventType.Class.STAT # trein, bus, station(s), sprinter, reiziger/passagier, # vervoerder, inchecken/uitchecken, sein, overweg, breng, # kaartje, opwaarderen, rails, dienstregeling, chipkaart, -# ontsporen, pendelbus/shuttlebus (evenement-/hotel-/ -# parkeerpendel; synoniemen -> bewust één familie), -# busongeluk/busvervoer (touringcar/schoolbus), staking, -# vervoer, vervoersbedrijf, vervoerregio, ritten, -# bovenleiding (industrie), abonnement, hermes, spoor. -# EXTRA REGEL voor zwak-alleen: staat er uitsluitend zwak -# bewijs in de SAMENVATTING (geen enkele zwakke hit in de -# titel), dan niet doorlaten. Twee/meer zwakke woorden in -# de samenvatting zijn anders samen al 3 punten -# ("station" + "pendelbus" + "bus" in een golfverslag). -# 3. BLACKLIST -> gematchte woorden zonder OV-waarde (stationsstraat, -# stationair, stationcar, ...); blokkeren de familie niet. +# 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): # a) Kan het ook buiten het OV voorkomen? ja -> zwak (1), eigen familie. @@ -174,6 +184,17 @@ rss_change_level = EventType.find("xyz.maubot.rss", t_class=EventType.Class.STAT # spoorvervoerder, treinvertraging/uitval, ertms, teso, rangeer, ice-frasen, # maaslijn, ns international, deutsche bahn, european sleeper, italo, # nightjet (sterk) en 11 zwakke woorden op. +# Dienststoring-contextpatroon: vervoerswoord + 'rijdt/rijden (niet)' + 'niet' +# ('sprinters rijden niet', 'metro rijdt niet tussen Rotterdam en Schiedam'). +# Gedeeld door de familie én het titel-anker (_disruption_hit): in de TITEL is de +# storingszin een sterk OV-signaal (net als een whitelist-term), in de samenvatting +# weegt hij als familie (2). 'rijden' an sich is géén OV-signaal. 'schoolbus' +# 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]] = [ # sterk (2) — onmiskenbaar OV, maar één hit is onvoldoende (max-2-stelsel, @@ -182,7 +203,7 @@ MATCH_FILTER_FAMILIES: list[tuple[str, int]] = [ (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"\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"(? None: @@ -447,6 +467,14 @@ class RSSBot(Plugin): low = text.lower() return any(ph in low for ph in _WHITELIST_PHRASES) + @staticmethod + def _disruption_hit(text: str) -> bool: + """True als een dienststoring-zin ('sprinters rijden niet', 'metro rijdt + niet tussen Rotterdam en Schiedam') in text staat. In de TITEL telt zo'n + zin als sterk OV-signaal (+2, net als een whitelist-term); als familie + (2) scoort hij overal — zie _DISRUPTION_RE_SRC.""" + 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). @@ -458,10 +486,10 @@ class RSSBot(Plugin): 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 — 'busbaan' zit in de bus-samenstelling-familie - én in de halte/busbaan-familie (2+2=4), 'ov-chipkaart' bevat 'chipkaart' - (2+1=3), 'openbaar vervoer' bevat 'vervoer' (2+1=3). Max-2-stelsel: - één term kan nooit solo doorlaten.""" + 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): @@ -484,68 +512,66 @@ class RSSBot(Plugin): @staticmethod def _filter_passes(title: str, summary: str) -> bool: - """Gewogen familiescore (max-2-stelsel, besluit 2026-09-12): elke OV-familie - telt 1× per artikel (titel + samenvatting samen; herhaling of beide velden = - één signaal). Overlappende treffers van verschillende families tellen samen - als 1× met het hoogste gewicht — 'busbaan' (bus-samenstelling- én - halte/busbaan-familie) levert één signaal van 2, geen 4 (besluit 2026-09-13). - Doorlaten bij som >= drempel (3). Geen enkele familie weegt - meer dan 2, dus een enkele familie-hit kan nooit solo doorlaten — 'hermes' - als achternaam passeerde vroeger op één vervoerder-hit van 3 (Margraten, - 2026-09-12); écht OV-nieuws combineert in de praktijk altijd met een tweede - signaal (gevalideerd op 205 productie-artikelen). + """Driestaps-beslissing, precision-first (refactor 2026-09-13; beslissing + equivalent met v1.13.0, maar de drie historische uitzonderings-takken + reduceren tot één regel — zie stap 3): - Whitelist-termen (onmiskenbaar OV in élke context) tellen in de TITEL +2 — - géén auto-pass meer; omdat elke whitelist-term óók in een familie zit, is - dat effectief het behouden titel-anker ('Reactie Nederlandse Spoorwegen', - 'Werkzaamheden aan perron', 'Buurtbus rijdt niet meer' blijven door). Een - whitelist-term ALLEEN in de samenvatting is geen OV-bewijs (besluit - 2026-09-12: 'openbaar vervoer' als bijzin in een economiebericht) en scoort - alleen via de overeenkomstige familie. + 1. STERK SIGNAAL: een whitelist-term óf een dienststoring-zin + ('sprinters rijden niet') in de titel is voldoende. Elke + whitelist-term staat óók in een familie, dus zo'n titel telt +2 én + het familie-gewicht — samen altijd >= drempel ('NS meldt storing', + 'Werkzaamheden aan perron', 'Buurtbus rijdt niet meer'). In de + 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. - Zwakke woorden (losse 'bus', 'breng', 'overweg', 'overwegen', 'sein', - 'opwaarderen', 'kaartje', 'rails', 'pendelbus', 'spoor', 'staking', - 'vervoer', 'busongeluk', ...) moeten combineren met een ander OV-woord én - minstens één zwak woord moet in de titel staan (of een whitelist-term in de - titel); dezelfde zwakke familie in titel én samenvatting is géén combinatie. - Een sterk (2) + zwak-combinatie UITSLUITEND in de samenvatting (geen - familie-hit én geen whitelist-term in de titel) is geen OV-bewijs - ('openbaar vervoer' + 'zakelijke reizigers' in een economiebericht, besluit - 2026-09-12). Weert oa 'VW-bus' (bus matcht niet ná een koppelteken), - 'schoolbus', 'bussen rijden om' in een woningbrand-lead, het werkwoord - 'brengen', het idioom 'goed met elkaar overweg kunnen', 'een wijk - opwaarderen', brandweerjargon 'sein brandmeester', figuratief 'weer op de - rails', een golfverslag waarin de samenvatting 'station' + 'pendelbus' + - 'bus' noemt, en het Margraten-bericht met 'Ton Hermes' als achternaam. - Bewust doorgelaten: krant 'De Metro'/café 'De Tram' (2, combineert) en - atleet-'sprinter' in combinatie met ander OV-woord.""" + 2. CONTEXT: de som van unieke OV-signalen over titel + samenvatting + >= drempel (3). Eén term telt één signaal per artikel ('busstation' + 60× of in beide velden = één keer); 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). Geen + familie weegt meer dan 2 (max-2-stelsel, besluit 2026-09-12): een + enkel sterk woord kan nooit solo doorlaten ('hermes' als + 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 + familie-hit, geen whitelist-term), dan is de score uitsluitend + samenvatting-bewijs en mag die geen zwak/ambigue woord bevatten — + 'station'+'pendelbus'+'bus' in een golfverslag, 'openbaar + vervoer'+'zakelijke reizigers' in een economiebericht en 'sterk + + 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', + 'bussen rijden om' in een woningbrand-lead, het werkwoord 'brengen', + het idioom 'goed met elkaar overweg kunnen', 'een wijk opwaarderen', + brandweerjargon 'sein brandmeester', figuratief 'weer op de rails', + en een cultuurstukje over een 'stationspiano'. Bewust doorgelaten: + krant 'De Metro'/café 'De Tram' (2, combineert) en atleet-'sprinter' + in combinatie met ander OV-woord.""" t_hits = RSSBot._fam_hits(title) s_hits = RSSBot._fam_hits(summary) - # Eén familie telt 1× per ARTIKEL (titel én samenvatting samen): 'busstation' - # in beide velden, of 60× in de tekst, blijft één signaal (besluit 2026-09-11). - # _fam_hits levert per term al het hoogste gewicht bij overlappende families. + # Unieke OV-signalen over beide velden: één term telt één signaal per + # artikel ('busstation' in titel én samenvatting, of 60×, blijft één + # treffer); _fam_hits levert per term al het hoogste gewicht bij + # overlappende familie-treffers. combined = dict(t_hits + s_hits) score = sum(combined.values()) - whitelist_title = RSSBot._whitelist_hit(title) - if whitelist_title: - # Whitelist-term in de titel = +2 (geen auto-pass, max-2-stelsel - # 2026-09-12): houdt het titel-anker voor onmiskenbare termen, maar - # een enkele whitelist-hit laat nooit meer solo door. + 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 max(combined.values(), default=0) == 1: - # Alleen zwakke families: dan moeten ≥2 verschillende families meetellen - # én een titel-anker (familie-hit óf whitelist-term in de titel). - # Zwakke woorden in de samenvatting alleen zijn geen bewijs - # ('station' + 'pendelbus' + 'bus' in een golfverslag telt anders op - # tot de drempel). - if not whitelist_title and (not t_hits or len(combined) < 2): - return False - elif not t_hits and not whitelist_title and 1 in combined.values(): - # Sterk (2) + zwak uitsluitend in de samenvatting, niets in de titel: - # geen OV-bewijs. 'openbaar vervoer' + 'zakelijke reizigers' in een - # economiebericht telt anders op tot de drempel (besluit 2026-09-12). + 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 @@ -553,27 +579,24 @@ class RSSBot(Plugin): def _filter_summary() -> str: """Mensvriendelijke weergave van het vaste gewogen match-filter.""" return ( - "**OV-matchfilter (vast in code, gewogen families, max-2):**\n" - "- Sterk (2): vervoerders (NS, Arriva, ProRail, Qbuzz, Deutsche Bahn, TESO, ...), spoorinfra (spoorlijn, spoorstoring, perron, spoorsector, ertms), " - "samenstellingen als bushalte/buslijn/buschauffeur, treinstoring/treinverkeer/treinvertraging, " - "intercity/streekvervoer/veerdienst/buurtbus/belbus, 'lijn 12', ICE-frasen, rangeer... — één hit volstaat NIET: " - "moet combineren (max-2-stelsel, besluit 2026-09-12)\n" - "- Middel (2): tram, metro, openbaar vervoer, lightrail, halte, pont, spoorbrug, ov-chipkaart/ovpay, ov-staking, conducteur, " - "trein-/bus-/metrostation — zelfde regel: moet combineren met een andere OV-familie; " - "sterk/middel + zwak uitsluitend in de samenvatting (geen hit in de titel) telt niet\n" - "- Zwak (1): losse 'bus' (niet ná een koppelteken), 'pendelbus'/'shuttlebus', 'trein', 'station(s)', 'breng', 'overweg', 'overwegen', " - "'sein', 'kaartje', 'opwaarderen', 'rails', 'inchecken'/'uitchecken', 'dienstregeling', 'chipkaart', 'reisproduct', 'vervoersbewijs', " - "'ontsporen', 'reiziger'/'passagier', 'vervoerder', 'sprinter', 'spoor', 'staking', 'vervoer', 'busongeluk', ... — moet combineren met een ander OV-woord, met minstens één zwak woord in de titel\n\n" - "**Whitelist (+2 bij een hit in de TITEL, geen auto-pass):** onmiskenbare OV-termen " - "(NS, ProRail, Nederlandse Spoorwegen, spoorstoring, perron, bushalte, conducteur, " - "ov-chipkaart, ovpay, openbaar vervoer, Deutsche Bahn, ICE Berlijn, ...); in de samenvatting alleen weegt zo'n term mee " - "als gewogen familie\n\n" - "Uitgesloten (blacklist): woorden die wél matchen maar geen OV-waarde hebben, " - "o.a. straatnamen rond stations ('stationsstraat', 'stationsweg', ...) en niet-OV-vormen als 'stationcar' en 'stationair'\n\n" - "Doorlaten: som van unieke OV-families ≥ 3 — één familie telt 1× per artikel " - "(titel + samenvatting samen; herhaling of beide velden = één signaal); " - "bij uitsluitend zwakke woorden tellen alleen " - "≥2 verschillende families én een zwak woord in de titel" + "**OV-matchfilter (vast in code; max-2-stelsel, precision-first):**\n" + "- **Sterk signaal in de TITEL → door**: whitelist-term (NS, ProRail, Nederlandse Spoorwegen, spoorstoring, perron, " + "bushalte, conducteur, ov-chipkaart, ovpay, openbaar vervoer, Deutsche Bahn, ICE Berlijn, ...) of een dienststoring-zin " + "('sprinters rijden niet', 'metro rijdt niet') — die telt +2 bovenop het familie-gewicht. In de samenvatting alleen " + "weegt zo'n term mee als gewone familie.\n" + "- **Context (som ≥ 3)**: unieke OV-families over titel + samenvatting. Sterk (2): vervoerders (NS, Arriva, ProRail, Qbuzz, ...), " + "spoorinfra (spoorlijn, spoorstoring, perron, ertms, spoorsector), bus-samenstellingen (bushalte, buslijn, buschauffeur, busbaan), " + "treinstoring/treinverkeer/treinvertraging, intercity/streekvervoer/veerdienst/buurtbus/belbus, 'lijn 12', ICE-frasen, rangeer..., " + "tram, metro, openbaar vervoer, lightrail, halte, pont, spoorbrug, ov-chipkaart/ovpay, ov-staking, conducteur, dienststoring-zin. " + "Zwak (1): losse 'bus' (niet ná een koppelteken), 'pendelbus'/'shuttlebus', 'trein', 'station(s)', 'breng', 'overweg', " + "'overwegen', 'sein', 'kaartje', 'opwaarderen', 'rails', 'inchecken'/'uitchecken', 'dienstregeling', 'chipkaart', " + "'reisproduct', 'vervoersbewijs', 'ontsporen', 'reiziger'/'passagier', 'vervoerder', 'sprinter', 'spoor', 'staking', " + "'vervoer', 'busongeluk', 'busvervoer', ... — elke dubbelzinnige term heeft een eigen familie en moet combineren.\n" + "- **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( diff --git a/maubot.yaml b/maubot.yaml index 3755ba0..d6cc96d 100644 --- a/maubot.yaml +++ b/maubot.yaml @@ -1,5 +1,5 @@ id: nl.eddydevink.rss -version: 1.13.0 +version: 1.14.0 modules: - buzzrss main_class: RSSBot diff --git a/validate_filter.py b/validate_filter.py index 2c54614..ab6d773 100755 --- a/validate_filter.py +++ b/validate_filter.py @@ -25,8 +25,9 @@ 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"} +WANT_METHODS = {"_fam_hits", "_filter_passes", "_filter_text", "_whitelist_hit", "_disruption_hit"} nodes = [] for n in tree.body: @@ -116,6 +117,16 @@ 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) @@ -225,6 +236,46 @@ GOLDEN = [ # 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: From 0c5a07071c4c9ea18a782f1c390c6991f7cc5c01 Mon Sep 17 00:00:00 2001 From: Eddy de Vink Date: Sun, 13 Sep 2026 09:01:03 +0200 Subject: [PATCH 5/5] =?UTF-8?q?docs:=20README=20voor=20de=20repo=20?= =?UTF-8?q?=E2=80=94=20filter=20v1.14.0,=20commando's,=20config=20en=20dep?= =?UTF-8?q?loy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 + README.md | 157 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 160 insertions(+) create mode 100644 README.md diff --git a/.gitignore b/.gitignore index 7a60b85..dee8344 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,5 @@ __pycache__/ *.pyc + +# Taakbestanden die geen onderdeel zijn van de repo +Prompt voor agent*.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..8e6f504 --- /dev/null +++ b/README.md @@ -0,0 +1,157 @@ +# buzz-rss — RSS/Atom → Matrix, gefilterd op OV-relevantie + +Maubot-plugin `nl.eddydevink.rss` (fork van [`xyz.maubot.rss`](https://github.com/maubot/rss)) +die nieuwsfeeds pollt en **alleen openbaar-vervoer-gerelateerde berichten** doorplaatst +naar Matrix-rooms (optioneel ook alleen de link naar een WhatsApp-groep). + +De filterlogica is **bewust vast in code** (niet via config) en volgt één businessregel: + +> **Precision boven recall** — liever een relevant OV-artikel missen dan één +> niet-OV-artikel ten onrechte doorlaten. (Gebruikersbesluit 2026-09-10) + +## Functionaliteit + +- Abonneren op feeds per room, met per-feed meldingssjabloon en `m.notice`-optie. +- **Conditional fetch** (`ETag`/`Last-Modified`) en backoff bij faalende feeds. +- **HTML-scrape-modus**: bronnen zonder RSS/Atom/JSON worden als pagina gepolled via + per-feed `feed.scrape_config` (zie `buzzrss/html_scrape.py`; eerste productiefeed: CNV). +- **Paywall-uitsluiting** (`exclude_paywalled`) en **titel-dedup over feeds** + (`title_dedup_days`: zelfde titel van een andere site van dezelfde uitgever = niet dubbel posten). +- Per-run logfile met aantallen (`run_log_file`). +- **WhatsApp-forward**: alleen `entry.link` wordt gepost (vooruit-links, zonder titel), + met dedup-check tegen de doelgroep. Uit zolang `whatsapp_api`/`whatsapp_chat_id` leeg zijn. + +## Commando's + +Prefix uit config (`command_prefix`, default `rss`), eventueel zonder `!`: + +| Commando | Aliassen | Werking | +|---|---|---| +| `!rss subscribe ` | `s`, `sub` | Deze room op een feed abonneren (nieuwe feed wordt direct geïndexeerd) | +| `!rss unsubscribe ` | `u`, `unsub` | Abonnement van deze room opzeggen | +| `!rss subscriptions` | `ls`, `list`, `subs` | Abonnementen in deze room + huidige filter | +| `!rss template [sjabloon]` | `t`, `tpl` | Meldingssjabloon bekijken/wijzigen (variabelen: `$title`, `$link`, `$summary`, `$feed_title`, …) | +| `!rss notice ` | `n` | Als `m.notice` versturen i.p.v. `m.text` | +| `!rss filter` | `f`, `set-filter`, `show-filter`, `get-filter` | Toont het vaste OV-matchfilter | + +Bestuurders: admins uit de config, of gebruikers met een powerlevel ≥ dat van het +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. + +## Het OV-matchfilter (v1.14.0) + +De volledige classificatie + de werkwijze voor nieuwe woorden staat in +`buzzrss/bot.py`, boven `MATCH_FILTER_FAMILIES` — **dat blok is leidend** bij elke +wijziging. Samengevat: + +```text +score = Σ gewichten van unieke OV-termen over titel + samenvatting + (één term = één signaal per artikel; overlappende familie-treffers wegen 1× + met het hoogste gewicht — span-merge, besluit 2026-09-13) + + 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: + +1. **Sterk titel-signaal — door.** Een whitelist-term (NS, ProRail, spoorstoring, + perron, bushalte, ovpay, openbaar vervoer, …) of een dienststoring-zin + (‹vervoerswoord› `rijdt/rijden … niet`, bv. 'sprinters rijden niet', 'metro rijdt niet') + in de titel is voldoende. +2. **Context — som ≥ 3.** Gewichten: **sterk (2)** = onmiskenbaar OV + (vervoerders, spoorinfra, bus-samenstellingen, trein-storingsjargon, tram/metro/halte, + storingszin, …), **zwak (1)** = dubbelzinnig, elk met een eigen familie + (trein, bus, station(s), sprinter, reiziger, vervoerder, sein, overweg, breng, …) + en alleen als combinatie geldig. Max-2-stelsel: geen enkele familie weegt meer dan 2, + één sterke hit kan nooit solo doorlaten. +3. **Titel-anker.** Zonder enig OV-signaal in de titel (geen familie-hit, geen + whitelist-term, geen storingszin) mag de score geen zwak woord bevatten — + 'station' + 'pendelbus' + 'bus' in een samenvatting is geen OV-bewijs. + Uitzondering die bewust blijft: **twee sterke termen in de samenvatting** (bv. de + delftopzondag-bereikbaarheidszin 'tram 1, halte Buitenhofdreef', besluit 2026-09-13). + +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', + brandweerjargon 'sein brandmeester', figuratief 'weer op de rails', + achternaam 'Hermes' (Margraten), 'restaurant/woning naast busstation'. + +## Tests + +```bash +python3 validate_filter.py +``` + +Vaste invarianten + regressie (momenteel **90 cases**): + +1. dubbelzinnige woorden → alleen zwak (1), nooit whitelist; +2. onmiskenbare OV-termen → sterk (2) of whitelist; +3. alle families compileren, gewichten in {1, 2}; +4. elke whitelist-term zit óók in een familie én elke zwartgelijste term wordt door + een familie gematcht (geen inert gewicht). + +**Verplicht vóór elke filterwijziging** (zie `AGENTS.md`): het classificatiebeleid in +`buzzrss/bot.py` lezen, `validate_filter.py` groen, en de **compleetheids-audit**: +OV-gap-scan over de afgewezen artikelen van de specialist-feeds (SpoorPro, +Treinreiziger, Mobiliteit; query op `entry` in PostgreSQL-schema `mbp_rss`), +nieuwe woordkandidaten klasseer je volgens het beleid (minimaal één extra +taalmodel; enige stem 'TWIJFEL'/'NIET-OV' ⇒ zwak). + +## Configuratie + +Zie `base-config.yaml` (wordt in het gebouwde `.mbp` meegeleverd): + +| Sleutel | Default | Werking | +|---|---|---| +| `update_interval` | 60 | Poll-interval in minuten | +| `max_backoff` | 7200 | Maximale backoff (minuten) bij faalende feeds | +| `poll_parallelism_limit` | 10 | Maximaal aantal feeds tegelijk pollend | +| `spam_sleep` | 2 | Pauze tussen berichten bij broadcast (‑1 = parallel) | +| `command_prefix` | `rss` | Commando-prefix | +| `notification_template` | `New post in $feed_title: [$title]($link)` | Standaard meldingssjabloon | +| `run_log_file` | `/data/buzzrss-runs.log` | Per-run samenvattingsregel (leeg = uit) | +| `whatsapp_api` / `whatsapp_session` / `whatsapp_chat_id` | – | WhatsApp-forward (leeg adapter = uit) | +| `exclude_paywalled` | true | Betaalmuur-artikelen overslaan | +| `title_dedup_days` | 30 | Cross-feed titel-dedup-venster (0 = uit) | +| `admins` | – | Gebruikers die room-permissies mogen omzeilen | + +## Deploy + +Productie draait het **geïnstalleerde `.mbp`** (maubot), niet deze werkdirectory — +een commit is pas actief na build + upload + restart. Exacte recepten staan in de +maubot-beheerskill (`skill://maubot`) en de Obsidian-vault (Matrix Synapse en MAS.md). + +In het kort: + +```text +1. mbc build uit de VOLLEDIGE bron (buzzrss/ + maubot.yaml + base-config.yaml — + zonder base-config.yaml faalt de instance met BaseMissingError) +2. POST /_matrix/maubot/v1/plugins/upload?allow_override=true (admin-login) +3. docker restart matrix-bot-maubot +``` + +**NOOIT maubot `reload` gebruiken** — die laat zombie-commandhandlers achter +(één commando → N antwoorden). Alleen een proces-herstart wist de handlers. + +## Architectuur in het kort + +- Poll-loop → per feed conditional-fetch of HTML-scrape → nieuwe entries + (`feed_id` + `id` PK-dedup) → cross-feed titel-dedup → `_filter_passes` → + broadcast naar alle subscriptions + optioneel WhatsApp (`_send_whatsapp`). +- Persistente tabel `whatsapp_posted` voor WhatsApp-dedup. +- DB: PostgreSQL-schema `mbp_rss` (`feed`, `entry`, `subscription`, + `whatsapp_posted`); migraties via maubot's `UpgradeTable` (`buzzrss/migrations.py`). + +## Licentie + +AGPL-3.0-or-later (upstream: maubot/rss, © Tulir Asokan). \ No newline at end of file