v1.14.0: refactor OV-filter naar driestapsmodel — één regel i.p.v. drie uitzonderingstakken

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

View file

@ -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"(?<![\w-])\bstations?\b", 1), # los 'station(s)' is ZWAK: ook tv-/radio-/regionale omroepstations ('de lokale stations'), moet combineren met een andere OV-familie
(r"\b(?:bus|tram|metro)-?(?:halte|haltes|lijn|lijnen|verkeer|net|staking|ba(?:an|nen)|hok(?:je|jes)|chauffeur)\w*\b", 2), # voertuig-samenstelling (bushalte/buslijn/bushokje/busbanen/buschauffeur/…); géén 'station': *station-samenstellingen zitten in de station-familie, anders telde 'busstation' dubbel mee — fix 2026-09-11. Géén 'ongeluk'/'vervoer': die zijn bewust zwak (busongeluk = ook touringcar, busvervoer = ook bedrijfsvervoer, audit 2026-09-12). 'busbaan' staat óók in fam 13: overlappende treffers tellen 1× met het hoogste gewicht (besluit 2026-09-13)
(r"\b(?:bus|tram|metro)-?(?:halte|haltes|lijn|lijnen|verkeer|net|staking|ba(?:an|nen)|hok(?:je|jes)|chauffeur)\w*\b", 2), # voertuig-samenstelling (bushalte/buslijn/bushokje/busbanen/buschauffeur/…); géén 'station': *station-samenstellingen zitten in de station-familie, anders telde 'busstation' dubbel mee — fix 2026-09-11. Géén 'ongeluk'/'vervoer': die zijn bewust zwak (busongeluk = ook touringcar, busvervoer = ook bedrijfsvervoer, audit 2026-09-12). 'busbaan'/'bushokje' stonden óók in de halte-familie — verwijderd bij de refactor 2026-09-13 (geen dubbelwoorden in twee families)
(r"\b(?:treinstoring|treinverkeer|treinstaking|treinongeval|treinbotsing|treinongeluk|treinvertraging|treinhinder|treinuitval|treinverstoring)\w*\b", 2), # trein-jargon-samenstellingen: onmiskenbaar OV; uitgebreid met vertraging/hinder/uitval/verstoring (audit 2026-09-12)
(r"\b\w*trein\w*\b", 1), # losse 'trein' en algemene samenstellingen (stoptrein, goederentrein, treinverkeer al in bovenstaande familie) — ZWAK: 'de trein naar mijn nieuwe huis' is geen OV-jargon, moet combineren met een andere familie
(r"\b(?:intercity|streekvervoer|stadsvervoer|veerdienst|veerboot|veerboten|veerpont|voetveer|veerverbinding|buurtbus|belbus|maaslijn|nedersaksenlijn|kamperlijn)\w*\b", 2), # treintypes, vervoerssectoren, veren en lijnnamen ('sprinter' staat in de zwakke groep: atleet-botsing); maaslijn/nedersaksenlijn/kamperlijn via audit 2026-09-12
@ -192,9 +213,10 @@ MATCH_FILTER_FAMILIES: list[tuple[str, int]] = [
# onmiskenbaar OV maar iets algemener (2, moet ook combineren)
(r"\btrams?\b", 2),
(r"\bmetro(?:'?s)?\b", 2),
(r"\b(?:openbaar vervoer|lightrail|halte|bushokje|busbaan)\w*\b", 2), # 'busbaan' ook in fam 4, 'openbaar vervoer' bevat 'vervoer' (fam 41): één term = één signaal (besluit 2026-09-13)
(r"\b(?:openbaar vervoer|lightrail|halte)\w*\b", 2), # frasen en halte-hits; 'bushokje'/'busbaan' matchen al de bus-samenstelling-familie — bewust niet dubbel in twee families (refactor 2026-09-13)
(r"\b(?:ov-chipkaart|ovpay|ov-pay|studentenreisproduct)\w*\b", 2),
(r"\b(?:ov-staking|conducteursstaking)\w*\b", 2),
(_DISRUPTION_RE_SRC, 2), # dienststoring-context ('sprinters rijden niet', 'metro rijdt niet...'); zie _DISRUPTION_RE_SRC
(r"\bconduct(?:eur|eurs)\b", 2), # trein-/buspersoneel: onmiskenbaar OV-jargon
(r"\b(?:pont|ponten)\b", 2), # waterbus/veerdienst
(r"\bspoorbrug(?:gen)?\b", 2), # spoorinfra, maar vaak locatie-referentie ('bij de spoorbrug', Stuifmail 2026-09-12); moet combineren — zelfde regel als station-samenstellingen
@ -240,21 +262,16 @@ MATCH_FILTER_THRESHOLD = 3
# Woorden die een familie wél matcht maar die geen OV-waarde hebben. Een hit dat
# hierin staat telt niet mee voor de score (en blokkeert de familie niet). Vorm:
# exact woord (lowercase), zoals door de familie-regex wordt gematcht.
# Refactor 2026-09-13: alleen woorden die een HUIDIGE familie nog matchen blijven
# staan ('treinstraat'/'treinweg' via de zwakke \b\w*trein\w*\b-familie). De
# stations*-varianten (stationsstraat, stationair, stationeren, stationcar,
# stationwagen, stationsplein, ...) waren inert sinds de woordgrens-familie
# \bstations?\b en zijn verwijderd — dat gedrag is in validate_filter.py
# vastgelegd.
MATCH_FILTER_BLACKLIST: frozenset[str] = frozenset({
# Straatnamen rond stations (genitief-s-vorm van "\bstation\w*\b"): een straat
# die toevallig bij een station ligt maakt een bericht nog geen OV-bericht.
"stationsstraat", "stationsweg", "stationslaan", "stationsplein",
"stationssingel", "stationskade", "stationsdreef", "stationshof",
"stationspad", "stationsboulevard", "stationsgracht", "stationsdijk",
"stationssteeg", "stationswal", "stationsbuurt", "stationswijk", "stationspark",
# Idem voor "\btrein\w*\b".
# Straatnamen rond spoorlijnen die de \b\w*trein\w*\b-familie zou matchen:
# een straat die toevallig 'Trein' heet maakt een bericht geen OV-bericht.
"treinstraat", "treinweg",
# Idem voor "\bstation\w*\b": werkwoords-/bijvoeglijke vormen zonder OV-waarde
# ("motor stationair laten draaien", "auto's hier stationeren") en
# samenstellingen met een station in de naam ("stationcar", "stationwagen").
"stationair", "stationeel", "stationeren", "stationeert", "stationeerde",
"stationeerden", "stationerend", "stationcar", "stationwagen",
"stationnement", "stationering",
})
# MATCH_FILTER_WHITELIST: termen die een bericht per definitie OV maken. Sinds
@ -308,6 +325,9 @@ _WHITELIST_RE = re.compile(
)
_WHITELIST_PHRASES: tuple[str, ...] = tuple(w for w in MATCH_FILTER_WHITELIST if " " in w)
# Dienststoring-context als sterk TITEL-signaal (zie _DISRUPTION_RE_SRC).
_DISRUPTION_RE = re.compile(_DISRUPTION_RE_SRC, re.IGNORECASE)
class Config(BaseProxyConfig):
def do_update(self, helper: ConfigUpdateHelper) -> 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(

View file

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

View file

@ -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: