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: