v1.13.0: één term = één signaal — overlappende familie-treffers wegen 1×

'busbaan' stond in de bus-samenstelling-familie (fam 4) én de
halte/busbaan-familie (fam 13): één woord scoorde 2+2=4 en liet zo
een niet-OV-bericht door (Omroep Brabant politie-achtervolging 'via
de busbaan', 13-09-2026). Latente dubbeltellingen: 'ov-chipkaart'
bevat 'chipkaart', 'openbaar vervoer' bevat 'vervoer'.

_fam_hits voegt nu overlappende span van verschillende families samen
met het hoogste gewicht (retour blijft (familie-index, gewicht), dus
de familie-één-signaal-regel over titel+samenvatting is intact).
Beleidscommentaar + regressie (56 cases) bijgewerkt; 45-dagen-replay
(90 geposte entries): precies 1 gedragsverschil — het fatbikers-
bericht wordt nu geweerd, delftopzondag (tram+halte, twee termen)
blijft door (besluit 2026-09-13).
This commit is contained in:
Eddy de Vink 2026-09-13 08:37:17 +02:00
commit 1346cbb549
3 changed files with 53 additions and 11 deletions

View file

@ -55,8 +55,11 @@ rss_change_level = EventType.find("xyz.maubot.rss", t_class=EventType.Class.STAT
# Gewogen OV-trefwoord-families voor het match-filter. Elke tuple = (regex, gewicht).
# Per familie wordt 1× geteld per ARTIKEL (titel én samenvatting samen; herhaling
# of het woord in beide velden = één signaal — 'busstation' 60× weegt 1×, besluit
# 2026-09-11). Drempel (3): doorlaten als de som van de unieke familie-gewichten
# >= MATCH_FILTER_THRESHOLD (titel weegt niet extra).
# 2026-09-11). Eén term die meerdere families matcht telt óók maar 1× (hoogste
# gewicht wint): 'busbaan' zit in de bus-samenstelling- én de halte/busbaan-familie,
# 'ov-chipkaart' bevat 'chipkaart', 'openbaar vervoer' bevat 'vervoer' — geen
# dubbeltelling (besluit 2026-09-13). Drempel (3): doorlaten als de som van de
# unieke familie-gewichten >= MATCH_FILTER_THRESHOLD (titel weegt niet extra).
# MAX-GEWICHT 2 — besluit 2026-09-12: een enkele FAMILIE-hit kan nooit solo
# doorlaten (2 < 3); écht OV-nieuws combineert in de praktijk altijd met een
# tweede signaal (gevalideerd op 205 productie-artikelen; zie beleidsblok).
@ -118,6 +121,10 @@ rss_change_level = EventType.find("xyz.maubot.rss", t_class=EventType.Class.STAT
# 2. GEWOGEN FAMILIES -> score = som van unieke OV-families over het hele
# artikel (titel + samenvatting; per familie 1× per artikel — herhaling of
# beide velden tellen niet dubbel, besluit 2026-09-11) >= drempel (3).
# Overlappende treffers van verschillende families (één term op één plek)
# tellen samen als 1× met het hoogste gewicht — een term mag nooit dubbel
# wegen ('busbaan' in de bus-samenstelling- én de halte/busbaan-familie,
# besluit 2026-09-13).
# - sterk (2): onmiskenbaar OV-jargon/merken/samenvoegingen (spoorlijn,
# bushalte, busverkeer, intercity, veerboot, ertms,
# rangeer..., ice-frasen, ...). Eén hit (titel óf samenvatting)
@ -175,7 +182,7 @@ MATCH_FILTER_FAMILIES: list[tuple[str, int]] = [
(r"\b(?:spoorweg|spoorba(?:an|nen)|spoorlijn|spoorverkeer|spoorwegovergang|spoorstoring|perron|seinstoring|wisselstoring|spoorstaking|spoorsector|spoorbeheerder|spoornet|spoorvervoerder|ertms)\w*\b", 2), # spoorinfra; let op de klinkerwisseling: 'ontsporing' (open lettergreep, één o) vs 'ontspoord'/'ontspoorde' (dicht, dubbel o) — vandaar beide stammen. Géén 'spoorbrug': die is 2 maar vaak louter locatie (besluit 2026-09-12). Uitbreiding 2026-09-12 (completheids-audit): spoorsector, spoorbeheerder, spoornet, spoorvervoerder, ertms
(r"\b(?:treinstation|treinstations|metrostation|metrostations|busstation|busstations)\b", 2), # station-samenstellingen: onmiskenbaar OV, maar één station-woord náást een ander onderwerp ('pand naast busstation', besluit 2026-09-11) is geen OV-bericht; in de samenvatting moet een tweede OV-woord bijkomen
(r"(?<![\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)
(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(?: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
@ -185,7 +192,7 @@ 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),
(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(?:ov-chipkaart|ovpay|ov-pay|studentenreisproduct)\w*\b", 2),
(r"\b(?:ov-staking|conducteursstaking)\w*\b", 2),
(r"\bconduct(?:eur|eurs)\b", 2), # trein-/buspersoneel: onmiskenbaar OV-jargon
@ -442,25 +449,47 @@ class RSSBot(Plugin):
@staticmethod
def _fam_hits(text: str) -> list[tuple[int, int]]:
"""(familie-index, gewicht) voor elke OV-familie die in text voorkomt (1× per familie).
"""(familie-index, gewicht) per OV-signaal in text (1× per familie).
Gematchte woorden uit MATCH_FILTER_BLACKLIST (o.a. 'stationsstraat')
tellen niet mee en blokkeren de familie niet: een écht OV-woord in
dezelfde tekst scoort gewoon verder."""
hits: list[tuple[int, int]] = []
dezelfde tekst scoort gewoon verder.
Eén term telt maximaal één signaal (besluit 2026-09-13): overlappende
treffers van verschillende families op dezelfde plek worden samengevoegd
met het hoogste gewicht. Voorheen telde één woord dubbel als het in
meerdere families stond — 'busbaan' zit in de bus-samenstelling-familie
én in de halte/busbaan-familie (2+2=4), 'ov-chipkaart' bevat 'chipkaart'
(2+1=3), 'openbaar vervoer' bevat 'vervoer' (2+1=3). Max-2-stelsel:
één term kan nooit solo doorlaten."""
spans: list[tuple[int, int, int, int]] = [] # (start, end, familie-idx, gewicht)
for idx, (family, weight) in enumerate(_compiled_match_families):
for m in family.finditer(text):
if m.group(0).lower() in MATCH_FILTER_BLACKLIST:
continue
hits.append((idx, weight))
spans.append((m.start(), m.end(), idx, weight))
break
return hits
spans.sort()
merged: list[tuple[int, int, int, int]] = []
for start, end, idx, weight in spans:
if merged and start < merged[-1][1]:
s0, e0, i0, w0 = merged[-1]
if weight > w0:
merged[-1] = (s0, max(e0, end), idx, weight)
else:
merged[-1] = (s0, max(e0, end), i0, w0)
else:
merged.append((start, end, idx, weight))
return [(idx, w) for _, _, idx, w in merged]
@staticmethod
def _filter_passes(title: str, summary: str) -> bool:
"""Gewogen familiescore (max-2-stelsel, besluit 2026-09-12): elke OV-familie
telt 1× per artikel (titel + samenvatting samen; herhaling of beide velden =
één signaal). Doorlaten bij som >= drempel (3). Geen enkele familie weegt
één signaal). Overlappende treffers van verschillende families tellen samen
als 1× met het hoogste gewicht — 'busbaan' (bus-samenstelling- én
halte/busbaan-familie) levert één signaal van 2, geen 4 (besluit 2026-09-13).
Doorlaten bij som >= drempel (3). Geen enkele familie weegt
meer dan 2, dus een enkele familie-hit kan nooit solo doorlaten — 'hermes'
als achternaam passeerde vroeger op één vervoerder-hit van 3 (Margraten,
2026-09-12); écht OV-nieuws combineert in de praktijk altijd met een tweede
@ -494,6 +523,7 @@ class RSSBot(Plugin):
s_hits = RSSBot._fam_hits(summary)
# Eén familie telt 1× per ARTIKEL (titel én samenvatting samen): 'busstation'
# in beide velden, of 60× in de tekst, blijft één signaal (besluit 2026-09-11).
# _fam_hits levert per term al het hoogste gewicht bij overlappende families.
combined = dict(t_hits + s_hits)
score = sum(combined.values())
whitelist_title = RSSBot._whitelist_hit(title)

View file

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

View file

@ -213,6 +213,18 @@ GOLDEN = [
("Meerdere doden na busongeluk in Zwitserland", "Het busongeluk gebeurde naast de snelweg bij een afrit.", False), # busongeluk(1) alleen = zwak-alleen
("Antwerpen ontvangt eerste ICE", "De eerste ICE van Deutsche Bahn rijdt vanaf volgende maand naar Antwerpen.", False), # bewust geweerd: 'ice van' is NIET opgenomen (ijs-botsing); deutsche bahn(2) alleen < 3 — was vóór de wijziging óók al geweerd
("NS verlengt noodregeling tot volgend jaar", "De vervoerder kampt met personeelstekorten.", True), # ns(2) + vervoerder(1) = 3
# fix 2026-09-13: één term mag nooit via twee families dubbel wegen.
# Echte case: Omroep Brabant (13-09-2026) — politie-achtervolging van fatbikers
# 'via de busbaan' scoorde 2+2=4 omdat 'busbaan' in de bus-samenstelling-familie
# (fam 4) én in de halte/busbaan-familie (fam 13) staat. Nu één signaal (2):
# zonder tweede OV-term onder de drempel.
("Groep jonge fatbikers door Eindhoven achtervolgd na beroving met geweld",
"Agenten reden met zwaailichten achter de groep aan, onder meer via de busbaan en een fietspad.", False),
("De politie achtervolgde de groep via de busbaan", "De achtervolging duurde enkele minuten.", True), # whitelist-titel-anker: 'busbaan' in de TITEL blijft door (+2); de merge raakt alleen de teller
# recall bewaakt: echt busbaan-nieuws blijft door (whitelist-titel-anker +2) en
# twee VERSCHILLENDE termen blijven samen tellen (geen over-merge).
("Busbaan wordt afgesloten voor het doorgaande verkeer", "De omleiding duurt tot het voorjaar.", True),
("Bus rijdt om, busbaan is afgesloten", "De bussen rijden via de wijk.", True), # bus(1) + busbaan(2) = 3
]
print(f"== regressie: {len(GOLDEN)} cases (precision-first) ==")
for t, s, want in GOLDEN: