v1.15.0: filter vereenvoudigd naar drie regels — 30 semantische groepen i.p.v. families+whitelist+blacklist

- Strip de filter terug tot: (1) sterk signaal in de titel -> door;
  (2) score van unieke semantische groepen >= 3 met titel-signaal -> door;
  (3) anders weg. Geen whitelist (82 termen), geen blacklist, geen span-merge,
  geen dienststoring-titelanker meer; woordgrenzen/lookbehinds weren de
  bekende botsingen structureel (schoolbus/VW-bus/stationsstraat/stationair/
  treinstraat/treinweg).
- Lijsten 49 families + 82 whitelist + 2 blacklist = 133 -> 16 sterk + 14
  ambigu = 30 groepen; filterzone 524 -> ~292 regels; match-methodes 4 -> 2.
- Vijf historische uitzonderings-families geschrapt (busongeluk, sein, breng,
  hermes, inchecken, kaartje, rails, staking, e.d.) — die woorden leveren nu
  0 signalen op en de bekende false positives blijven geweerd.
- Completheids-audit ving de Maaslijn/Nedersaksenlijn/Kamperlijn/waterbus-
  regressie op (hersteld); resterende zero-hit-kandidaten geen wijziging.
- validate_filter.py: invarianten 1-3 + 113 regressie-cases (90 onveranderd
  groen + 23 nieuw); prompt-matrix 27/27.
- Gedragswijziging op 45 productiedagen: uitsluitend false positives extra
  geweerd (buitenlandse veerboot-rampen, Zwitsers busongeluk, delftopzondag
  tram+halte, Cox/RET, tram-blokkade); 0 recalls verloren.
- README/AGENTS.md/vault bijgewerkt.
This commit is contained in:
Eddy de Vink 2026-09-13 09:28:28 +02:00
commit ac30575660
6 changed files with 381 additions and 695 deletions

View file

@ -52,283 +52,103 @@ from .migrations import upgrade_table
rss_change_level = EventType.find("xyz.maubot.rss", t_class=EventType.Class.STATE)
# 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). Eén term die meerdere families matcht telt óók maar 1× (hoogste
# 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).
# 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"
# vallen af (lookbehind bij 'bus' resp. 'station').
# MATCH_FILTER_BLACKLIST: gematchte woorden die tóch geen OV-waarde hebben (o.a.
# 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" (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).
# OV-MATCHFILTER (v1.15) — precisie boven recall (gebruikersbesluit
# 2026-09-10): liever een OV-bericht te veel wegfilteren dan één niet-OV-bericht
# te veel plaatsen.
#
# 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,
# 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, ...) é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
# zwakke woorden wél als combinatie tellen, maar één enkel
# woord nooit de doorslag kan geven. Bekende dubbelzinnige:
# trein, bus, station(s), sprinter, reiziger/passagier,
# vervoerder, inchecken/uitchecken, sein, overweg, breng,
# kaartje, opwaarderen, rails, dienstregeling, chipkaart,
# 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.
# Beslissing, in drie regels:
# 1. STERK signaal in de TITEL -> plaatsen
# 2. score >= drempel (3) én de TITEL bevat minstens één signaal
# -> plaatsen (context)
# 3. anders -> wegfilteren
#
# 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 (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.
# 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"
)
# score = som van de gewichten van unieke SEMANTISCHE GROEPEN over titel +
# samenvatting. Elke groep telt hooguit één keer per artikel — meerdere woorden
# uit dezelfde groep ('bus' + 'buslijn' + 'busvervoer', of 'station' +
# 'stationsgebouw' + 'stationsplein') leveren samen één signaal op en tellen
# nooit kunstmatig op (refactor v1.15). Woordgrenzen en koppelteken-lookbehinds
# weren de bekende botsingen structureel: 'schoolbus'/'VW-bus' matchen de
# bus-groep niet, 'stationsstraat'/'stationair'/'stationcar'/'stationwagen' de
# station-groep niet — er is GEEN zwarte lijst meer nodig.
#
# STERK (2 punten): termen die vrijwel altijd betekenen dat het artikel over
# OV gaat; in de TITEL zijn ze zelfstandig voldoende ("een sterk signaal mag
# zelfstandig voldoende zijn"). In de samenvatting tellen ze als context van 2.
# Honkvaste typografie: 'ns' alleen op woordgrens (niet in 'kunstenaar'),
# 'ov' niet in 'ov-chipkaart'/'ov-staking' ((?!-)-lookahead), de Meervouds-
# vormen via (?:en)?-suffix.
# AMBIGU (1 punt; halte/tram/metro/spoorbrug 2): contextafhankelijk en nooit
# zelfstandig voldoende — 'bus', 'trein', 'station', 'tram', 'metro', 'halte',
# 'reiziger', 'vervoerder', 'sprinter', 'pendelbus', 'spoorbrug',
# 'dienstregeling', 'ontsporen', 'spoor'. Alleen combinaties tellen:
# trein+spoorlijn, metro+rijdt-niet, tram+dienstregeling, sprinter+NS,
# bus+halte. Dezelfde groep in titel én samenvatting blijft één signaal.
# =====================================================================
MATCH_FILTER_FAMILIES: list[tuple[str, int]] = [
# 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"(?<![\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'/'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
(r"\blijn\s?\d{1,3}\b", 2), # 'lijn 12' — vervoerslijn met nummer
(r"(?:\brangeer\w*|\brangeren\w*)\b", 2), # rangeren/rangerend/rangeerterrein/rangeerbedrijf: onmiskenbaar spoorjargon (audit 2026-09-12; stam 'rangeren' = ranger+en, 'rangeerterrein' = rangeer+...)
(r"\bice[\s\-–—]*(?:berlijn|hamburg|brussel|bremen|parijs|frankfurt|keulen|amsterdam|bazel|milaan|kopenhagen|zurich|zürich|duitsland|international)\b|\bice-trein\w*\b", 2), # ICE-frasen: onmiskenbaar trein; los 'ice' is NIET toevoegbaar (case-insensitive botsing met 'ijs', besluit 2026-09-12)
# onmiskenbaar OV maar iets algemener (2, moet ook combineren)
(r"\btrams?\b", 2),
(r"\bmetro(?:'?s)?\b", 2),
(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
# zwak (1) — dubbelzinnig, moet combineren
(r"(?<!-)\bbus(?:sen)?\b", 1), # ná een koppelteken geen OV: 'VW-bus', 'school-bus'
(r"\b(?:pendelbus|shuttlebus)\w*\b", 1), # evenement-/hotel-/parkeerpendel ('pendelbus naar de golfbaan') is geen OV; synoniemen bewust in één familie
(r"\b(?:inchecken|uitchecken)\w*\b", 1), # ook op luchthavens/hotels — geen OV-jargon, moet combineren
(r"\bsprinters?\b", 1), # ook atleet ('sprinter verpulvert record') — aparte familie, moet combineren
(r"\bvervoerders?\b", 1), # ook logistiek ('vrachtvervoerder') — aparte familie, moet combineren
(r"\b(?:reizigers?|passagiers?)\b", 1), # ook 'zakenreizigers vliegen' / vliegtuigpassagiers — synoniemen bewust één familie (audit 2026-09-12)
(r"\bdienstregeling\w*\b", 1), # dubbelzinnig (modelaudit) — meestal OV, maar moet combineren
(r"\bchipkaart(?:en)?\b", 1), # dubbelzinnig (bankpas/algemeen) — aparte familie
(r"\breisproduct(?:en)?\b", 1), # dubbelzinnig — aparte familie
(r"\bvervoer(?:s)?bewijs\w*\b", 1), # dubbelzinnig — aparte familie
(r"\b(?:ontspor|ontspoor)\w*\b", 1), # werkwoord 'ontsporen' is dubbelzinnig ('het plan ontspoorde'); echte trein-ontsporing combineert met trein/spoor
(r"\bbreng\b", 1), # vervoerder Breng vs werkwoord "brengen" ("hoe breng je ... in beeld")
(r"\boverweg\b", 1), # spoorwegovergang vs idioom "goed met elkaar overweg kunnen"
(r"\boverwegen\b", 1), # meervoud van overweg vs werkwoord ("een gratieverlening overwegen")
(r"\bsein\b", 1), # spoorsein vs brandweerjargon ("sein brandmeester") en figuurlijk gebruik
(r"\bkaart(?:je|jes)\b", 1),
(r"\bopwaarderen\b", 1), # ov-chipkaart opladen vs werkwoord "een wijk/park opwaarderen"
(r"\brails\b", 1),
(r"\bbusongeluk(?:ken)?\b", 1), # audit 2026-09-12: ook touringcar/schoolbus; combineert met buschauffeur/bus → door (busongeluk Zwitserland)
(r"\bbusvervoer\b", 1), # audit 2026-09-12: ook touringcar-/bedrijfsvervoer
(r"\bspoor\b", 1), # figuurlijk 'op het spoor van de daders', 'het spoor bijster'; écht spoornieuws combineert met spoorlijn/trein/perron
(r"\bstaking(?:en)?\b", 1), # arbeidsstaking in alle sectoren; 'spoorstaking'/'busstaking'/'ov-staking' zitten in de sterke families (besluit 2026-09-12)
(r"\bvervoer\b", 1), # ook vracht-/autovervoer; 'openbaar vervoer' zit in de sterke familie; samenstellingen (streekvervoer e.d.) matchen hier niet (geen \b)
(r"\bvervoersbedrijf(?:ven)?\b", 1), # ook goederen-/taxibedrijf (audit 2026-09-12)
(r"\bvervoerregio'?s?\b", 1), # OV-autoriteit ('Vervoerregio Amsterdam'), maar audit: ook algemener vervoersgebied
(r"\britten\b", 1), # ook fiets-/taxiritten (audit 2026-09-12)
(r"\bbovenleiding(?:en)?\b", 1), # audit 2026-09-12: ook water/gas/elektra-bovenleiding; spoor-combinatie (spoorlijn/ertms) haalt de drempel
(r"\babonnement(?:en)?\b", 1), # gym/museum/telefoon (audit 2026-09-12)
(r"\bhermes\b", 1), # vervoerder Hermes vs. achternaam (Ton Hermes, stichting Adoptie Graven Margraten, 2026-09-12)
MATCH_FILTER_STRONG: list[tuple[str, str]] = [
# vervoerders / OV-merken
(r"\b(?:ns|prorail|arriva|qbuzz|connexxion|cxx|transdev|keolis|ret|gvb|htm|ebs"
r"|syntus|u-ov|allgo|flixbus|blauwnet|r-net|valleilijn|gvu|rrreis|qliner|teso"
r"|eurostar|thalys|randstadrail|deutsche bahn|ns international|european sleeper"
r"|italo|nightjet)\b", "vervoerders"),
(r"(?<!-)\bov\b(?!-)", "ov"), # 'het OV'; niet in ov-chipkaart/ov-staking
(r"\b(?:spoorweg(?:en)?|spoorba(?:an|nen)|spoorlijn(?:en)?|spoorverkeer"
r"|spoorwegovergang(?:en)?|spoorstoring(?:en)?|spoorstaking(?:en)?|spoorsector"
r"|spoorbeheerder|spoornet|spoorvervoerder|wisselstoring(?:en)?|seinstoring(?:en)?"
r"|ertms|treinstoring(?:en)?|treinverkeer|treinstaking(?:en)?|treinongeval(?:len)?"
r"|treinbotsing(?:en)?|treinvertraging(?:en)?|treinhinder|treinuitval"
r"|treinverstoring(?:en)?|treinstel(?:len)?)\b", "spoorjargon"),
(r"\bperrons?\b", "perron"),
(r"\b(?:bushaltes?|buslijn(?:en)?|busba(?:an|nen)|buschauffeurs?|bushokjes?"
r"|busnet(?:ten)?|busverkeer|busstaking(?:en)?)\b", "bussamenstelling"),
(r"\b(?:tramhaltes?|tramlijn(?:en)?|tramverkeer|tramnet(?:ten)?)\b", "tramsamenstelling"),
(r"\b(?:metrolijn(?:en)?|metroverkeer)\b", "metrosamenstelling"),
(r"\b(?:intercity'?s?|streekvervoer|stadsvervoer|buurtbus(?:sen)?|belbus(?:sen)?"
r"|veerdienst(?:en)?|waterbus(?:sen)?)\b", "treintype"),
(r"\b(?:maaslijn|nedersaksenlijn|kamperlijn)\b", "lijnnaam"), # OV-lijnnamen (audit 2026-09-12)
(r"\bconducteurs?\b", "conducteur"),
(r"\b(?:ov-chipkaart|ovpay|ov-pay|studentenreisproduct)\b", "ovtarief"),
(r"\blightrail\b", "lightrail"),
(r"\blijn\s?\d{1,3}\b", "lijnnummer"),
(r"\b(?:rangeer\w*|rangeren)\b", "rangeren"),
(r"\bopenbaar vervoer\b", "openbaar vervoer"),
(r"\bice[\s\-–—]*(?:berlijn|hamburg|brussel|bremen|parijs|frankfurt|keulen"
r"|amsterdam|bazel|milaan|kopenhagen|zurich|zürich|duitsland|international)\b"
r"|\bice-trein(?:en)?\b", "ice"),
# dienststoring: vervoerswoord + 'rijdt/rijden/vaart (niet)' + 'niet'
(r"\b(?:sprinters?|stoptrein(?:en)?|intercity'?s?|trein(?:en)?|trams?|bussen|bus"
r"|metro'?s?|veerdienst(?:en)?)\s+(?:rijdt|rijden|reed|reden|vaart|varen)"
r"\s+niet\b", "dienststoring"),
]
MATCH_FILTER_AMBIGUE: list[tuple[str, int, str]] = [
(r"\bhaltes?\b", 2, "halte"), # contextzwaar, niet zelfstandig
(r"\btrams?\b", 2, "tram"),
(r"\bmetro'?s?\b", 2, "metro"),
(r"\bspoorbrug(?:gen)?\b", 2, "spoorbrug"), # vaak louter locatie (v1.10.1)
(r"(?<!-)\bbus(?:sen)?\b", 1, "bus"), # 'VW-bus'/'schoolbus' matchen niet
(r"\b\w*trein\w*\b", 1, "trein"), # trein, stoptrein, treinreizigers…
(r"\bstations?\b|\b(?:trein|bus|metro)station(?:s)?\b", 1, "station"),
(r"\breizigers?\b|\bpassagiers?\b", 1, "reiziger"),
(r"(?<!openbaar )\bvervoer(?:der|ders|sbedrij(?:f|ven)|regio'?s?)?\b", 1, "vervoer"),
(r"\bsprinters?\b", 1, "sprinter"),
(r"\b(?:pendel|shuttle)bus(?:sen)?\b", 1, "pendel"), # evenement-/hotelpendel
(r"\bdienstregeling\w*\b", 1, "dienstregeling"),
(r"\b(?:ontspor(?:en|ing)?|ontspoord(?:e)?)\b", 1, "ontsporen"),
(r"\bspoor\b", 1, "spoor"), # figuurlijk 'op het spoor van'
]
# Drempel voor doorlaten: som van unieke familie-gewichten >= 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).
# Drempel voor de context-regel (stap 2): som van unieke groeps-gewichten >= 3.
# Max-gewICHT 2: een enkel signaal (zelfs halte/tram/metro/spoorbrug) kan nooit
# solo doorlaten; écht OV-nieuws combineert in de praktijk altijd (gevalideerd
# op 205 productie-artikelen, 2026-09-12).
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 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",
})
# 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", "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", "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", "buurtbus", "veerdienst", "lijn 12",
"tramhalte", "tramlijn", "tramnet", "tramverkeer",
"metrolijn", "metroverkeer",
# Tarief/vervoerder-jargon
"ov-chipkaart", "ov-staking", "lightrail",
"conducteur", "conducteurs",
"openbaar vervoer",
})
_compiled_match_families: list[tuple[re.Pattern, int]] = [
(re.compile(pat, re.IGNORECASE), w) for pat, w in MATCH_FILTER_FAMILIES
_compiled_match_strong: list[tuple[re.Pattern, str]] = [
(re.compile(pat, re.IGNORECASE), name) for pat, name in MATCH_FILTER_STRONG
]
_compiled_match_ambigue: list[tuple[re.Pattern, int, str]] = [
(re.compile(pat, re.IGNORECASE), w, name) for pat, w, name in MATCH_FILTER_AMBIGUE
]
# Whitelist: enkel-woord termen als \\b-term\\b (geen substring-botsingen zoals
# 'ns' in 'kunstenaar'), multi-woord termen als exacte frase.
_WHITELIST_RE = re.compile(
r"\b(?:" + "|".join(re.escape(w) for w in MATCH_FILTER_WHITELIST if " " not in w) + r")\b",
re.IGNORECASE,
)
_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:
helper.copy("update_interval")
@ -456,147 +276,84 @@ class RSSBot(Plugin):
return re.sub(r"<[^>]+>", "", html.unescape(raw))
@staticmethod
def _whitelist_hit(text: str) -> bool:
"""True als een onmiskenbare OV-term uit MATCH_FILTER_WHITELIST in text zit.
def _signals(text: str) -> dict[str, int]:
"""Unieke semantische OV-groepen in text -> gewicht (sterk 2, ambigu 1/2).
Enkele-woord termen matchen op woordgrens ('ns' telt NIET binnen 'kunstenaar'),
multi-woord termen ('nederlandse spoorwegen') als exacte frase, beide
case-insensitive."""
if _WHITELIST_RE.search(text):
return True
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).
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.
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 — '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):
if m.group(0).lower() in MATCH_FILTER_BLACKLIST:
continue
spans.append((m.start(), m.end(), idx, weight))
break
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]
Elke groep telt hooguit één keer: meerdere woorden uit dezelfde groep
('bus' + 'buslijn' + 'busvervoer', 'station' + 'stationsgebouw') leveren
samen één signaal op; er is geen span-merge of zwarte lijst nodig omdat
de groepen met woordgrenzen/lookbehinds overlappingsvrij zijn
('schoolbus'/'VW-bus' matchen de bus-groep niet, 'stationsstraat'/
'stationair' de station-groep niet, 'ov-chipkaart' niet de ov-groep)."""
out: dict[str, int] = {}
for family, name in _compiled_match_strong:
if family.search(text):
out[name] = 2
for family, weight, name in _compiled_match_ambigue:
if family.search(text):
out[name] = max(out.get(name, 0), weight)
return out
@staticmethod
def _filter_passes(title: str, summary: str) -> bool:
"""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):
"""Beslissing in drie regels (v1.15, precision-first):
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.
1. STERK signaal in de TITEL -> plaatsen. Een sterk signaal (vervoerder,
spoorjargon, perron, bushalte/buslijn, conducteur, ovpay, ...
zie MATCH_FILTER_STRONG) vrijwel altijd over OV; in de titel is het
zelfstandig voldoende ('NS meldt storing', 'Werkzaamheden aan
perron', 'Sprinters rijden niet tussen Utrecht en Amsterdam').
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).
2. CONTEXT: score = som van unieke groeps-gewichten over titel +
samenvatting >= drempel (3) én de titel bevat minstens één signaal.
Sterke groepen wegen 2, ambigue 1 (halte/tram/metro/spoorbrug 2).
Elke groep telt één keer per artikel — 'bus' + 'buslijn' +
'busvervoer' is één bus-signaal; dezelfde groep in titel én
samenvatting blijft één signaal. Voorbeelden: bus+halte (1+2),
trein+spoorlijn (1+2), metro+'rijdt niet' (2+2), tram+
dienstregeling (2+1), sprinter+NS (1+2).
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).
3. ANDERS -> wegfilteren. Een artikel mag niet door omdat de summary
toevallig een OV-woord bevat ('station'+'pendelbus'+'bus' in een
golfverslag, 'openbaar vervoer' als bijzin in een economiebericht);
één enkel signaal — zelfs halte/tram/metro/spoorbrug (2) — is nooit
zelfstandig voldoende.
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)
# 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)
Woordgrenzen en koppelteken-lookbehinds weren structureel: 'VW-bus'/
'schoolbus' (bus-groep), 'stationsstraat'/'stationair'/'stationcar'
(station-groep), 'treinstraat'/'treinweg' (trein-groep matchen niet
buiten woordgrenzen) — er is geen zwarte lijst nodig. Krant 'De Metro'/
café 'De Tram' wegen 2 en combineren alleen; atleet-'sprinter' alleen.
"""
title_hits = RSSBot._signals(title)
combined = dict(title_hits)
for group, weight in RSSBot._signals(summary).items():
combined[group] = max(combined.get(group, 0), weight)
# Stap 1: sterk signaal in de titel is zelfstandig voldoende.
for family, name in _compiled_match_strong:
if family.search(title):
return True
# Stap 2+3: context met titel-anker; anders wegfilteren.
score = sum(combined.values())
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 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
return score >= MATCH_FILTER_THRESHOLD and bool(title_hits)
@staticmethod
def _filter_summary() -> str:
"""Mensvriendelijke weergave van het vaste gewogen match-filter."""
"""Mensvriendelijke weergave van het vaste OV-matchfilter."""
return (
"**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"
"**OV-matchfilter (vast in code, v1.15, precision-first) — drie regels:**\n"
"1. Sterk OV-signaal in de TITEL → door. Sterk = vervoerders (NS, ProRail, Arriva, RET, GVB, Qbuzz, ...), "
"spoorjargon (spoorstoring, spoorlijn, wisselstoring, treinstoring, ertms, perron, ...), bus-samenstellingen "
"(bushalte, buslijn, busbaan, buschauffeur), tramverkeer/tramlijn, intercity/streekvervoer/veerdienst/buurtbus, "
"conducteur, ov-chipkaart/ovpay, openbaar vervoer, 'lijn 12', ICE-frasen, rangeer... en de storingszin "
"'vervoerswoord rijdt/rijden (niet) niet'.\n"
"2. Context: som van unieke groeps-gewichten over titel + samenvatting ≥ 3 én de titel bevat een signaal. "
"Sterke groepen 2 punten; ambigue groepen 1 (halte/tram/metro/spoorbrug 2): 'bus', 'trein', 'station(s)', "
"'reiziger'/'passagier', 'vervoer(der)', 'sprinter', 'pendelbus'/'shuttlebus', 'dienstregeling', 'ontsporen', 'spoor'.\n"
"3. Anders → wegfilteren. Eén signaal is nooit genoeg; een OV-woord in de samenvatting zonder titel-signaal is "
"geen OV-bewijs.\n"
"Semantische groepen tellen één keer per artikel ('bus' + 'buslijn' + 'busvervoer' = één bus-signaal); "
"'schoolbus'/'VW-bus'/'stationsstraat'/'stationair' matchen structureel niet — geen zwarte lijst."
)
async def _send(