Commit f37216d
authored
fix(classifier): two-layer historical-retrospective downgrade (L1 + L3) (#3429)
* fix(classifier): two-layer historical-retrospective downgrade (L1 + L3)
Brief 2026-04-26-1302 surfaced a 40-year Chernobyl anniversary article from
Live Science:
"Science history: Chernobyl nuclear power plant melts down, bringing the
world to the brink of disaster — April 26, 1986 - Live Science"
Investigation showed two distinct failure modes that cooperate to ship
retrospective/anniversary content as if it were a current crisis:
L1 — enrichWithAiCache skipped the LLM cache result for keyword=critical
items (`if (0.9 <= item.confidence) continue;`). Original intent was
"trust keyword when confident", but for retrospective titles that
trip a CRITICAL keyword (e.g. "meltdown"), the LLM is the only thing
that could disambiguate context — and the gate locked it out. Removed.
The U4 +2-tier cap already protects against UPWARD over-promotion;
symmetric DOWNWARD demotion is unconditionally safer than keeping a
suspect keyword classification (capLlmUpgrade is a Math.min so
downgrades pass freely). Cache application also now covers the new
'keyword-historical-downgrade' classSource so LLM can confirm/override
the heuristic.
L3 — Two-layer historical-marker downgrade:
L3a (classifier-side): classifyByKeyword now downgrades CRITICAL/HIGH
keyword matches to info when the title contains a retrospective
marker. Markers (cheap regex):
- Prefix: ^(Science history|On this day|Today in|This day in|
Throwback|Flashback)
- Phrase: \b(N years/decades/months ago|after|later|anniversary|
in memoriam|remembering|commemorat|retrospective)\b
- Full date: "April 26, 1986" or ISO 1986-04-26
Standalone 4-digit year is intentionally NOT a marker (current-event
headlines like "Russia warns of 2026 strike" would falsely trigger).
Downgrade applies only to CRITICAL/HIGH; LOW/MEDIUM are left alone
(don't clear brief thresholds anyway, over-aggression cost outweighs
signal). Distinct source tag 'keyword-historical-downgrade' for
telemetry.
L3b (LLM-cache-side defense-in-depth): The Chernobyl headline ABOVE
doesn't match any CRITICAL keyword in the keyword classifier
("meltdown" substring isn't present in "melts down" — there's a
space). So L3a wouldn't catch it. But the LLM cache had promoted
this title (or one with the same hash) to CRITICAL at some prior
point, and enrichWithAiCache applied that cache hit. The new
L3b guard re-runs hasHistoricalMarker AFTER capLlmUpgrade — if the
LLM-promoted level is CRITICAL/HIGH AND the title has a marker,
force info. Logs on every fire so operators can audit.
Tests:
- tests/news-classifier-historical-downgrade.test.mts — 23 cases for
hasHistoricalMarker predicate matrix + classifyByKeyword integration.
Includes regression guards: current-year mentions don't trigger,
"history" alone doesn't trigger (only the prefix forms), LOW/MEDIUM
not downgraded, current-event critical headlines unchanged.
- tests/news-classifier-llm-historical-guard.test.mts — 9 cases
covering the LLM-cache guard predicate behavior + behavioral
semantics doc (CRITICAL+marker → info; HIGH+marker → info;
MEDIUM+marker → unchanged; CRITICAL-no-marker → unchanged). Includes
the EXACT brief 2026-04-26-1302 title as a test fixture.
Type tightening:
- ClassificationResult.source: 'keyword' | 'keyword-historical-downgrade'
- ParsedItem.classSource: 'keyword' | 'keyword-historical-downgrade' | 'llm'
106/106 tests pass across the touched + parity-coupled surface;
importance-score-parity preserved.
* fix(classifier): narrow historical markers (P2 PR #3429 round 2)
Reviewer found two false-positive vectors that suppressed real critical
alerts:
- "Today in Ukraine: Russian missile strikes Kyiv" → was downgraded
to info because of bare "Today in" prefix. This is a current-event
headline pattern, not a historical one.
- "Missile launch reported on April 26, 2026" → was downgraded
because any full-date pattern matched, regardless of year.
Both vectors would have suppressed real missile/attack/invasion alerts
before they reached the brief.
Fixes:
1. Prefix regex narrowed:
REMOVED: bare "Today in" / "This day in" (too broad — legitimate
current-event uses).
KEPT: "Science history:", "Throwback", "Flashback" (always
retrospective).
ADDED: "On this day in YYYY" (year required — prevents bare
"On this day, Iran fires missile" from triggering).
ADDED: "This day in history" (specific phrasing — distinct from
bare "This day in").
2. Full-date matching now extracts the year and only treats dates as
retrospective when year < currentYear - 1 (i.e. 2+ years old). In
2026:
- 2024 and earlier dates → retrospective marker fires.
- 2025 (last year) → NOT a marker (could be current context, e.g.
"court ruling on April 15, 2025 takes effect").
- 2026 (current) → NOT a marker.
- 2027+ (future) → NOT a marker (clock skew or scheduled events).
Same logic applies to ISO date format.
3. hasHistoricalMarker(title, nowMs?) now takes optional nowMs for
unit testability. Defaults to Date.now(); production callers omit.
Tests:
- 4 explicit reviewer-fix safety cases:
* "Today in Ukraine: Russian missile strikes Kyiv" → high (preserved)
* "Missile launch reported on April 26, 2026" → high (preserved)
* "Today in tech: Apple unveils iPhone" → no marker
* "On this day, Iran invasion begins" (no year) → no marker
- 4 full-date boundary cases (1986, 2024, 2025, 2026, 2027) verify
the year-based gating.
- All test calls pass NOW = 2026-04-15 UTC to pin "current year"
deterministically.
120/120 tests pass; importance-score-parity preserved.
* fix(classifier): run historical-marker guard before capLlmUpgrade (P1 PR #3429 round 3)
Previously the L3 defense-in-depth marker check ran on cappedLevel and
only fired for critical/high. For keyword=info + hit=critical, capLlm-
Upgrade demotes to medium (info+2=medium) — the post-cap check missed
it and the brief 2026-04-26-1302 Chernobyl case shipped at MEDIUM,
which still ships in 'all'-sensitivity briefs.
Move the check to run on the RAW hit before the cap and force info
unconditionally (not just critical/high). Retrospective markers now
suppress the LLM verdict at every non-info level.
* fix(classifier): restore confidence>=0.9 skip for keyword=critical (P1 PR #3429 round 4)
Greptile flagged that removing the confidence>=0.9 skip unconditionally
opens genuine current keyword=critical events to silent demotion: the LLM
cache hit feeds capLlmUpgrade which is Math.min, so a stale/wrong cache
entry saying 'info' demotes critical -> info with no safeguard.
The retrospective case PR #3424 wanted to handle is already handled
UPSTREAM in classifyByKeyword via classSource='keyword-historical-
downgrade' (confidence 0.85, level=info), which still flows through this
function and is caught by the L3 marker check. Items reaching
enrichWithAiCache at confidence 0.9 are by construction keyword=critical
matches where the keyword classifier saw NO marker - trust the keyword
verdict for those.
The L3 marker check still runs BEFORE this skip, so the keyword=info
(no-match, confidence 0.3) + marker case - the brief 2026-04-26-1302
'Science history: melts down...' shape - still gets forced to info.1 parent 737ec36 commit f37216d
4 files changed
Lines changed: 692 additions & 11 deletions
File tree
- server/worldmonitor/news/v1
- tests
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
17 | 17 | | |
18 | 18 | | |
19 | 19 | | |
20 | | - | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
21 | 28 | | |
22 | 29 | | |
23 | 30 | | |
| |||
212 | 219 | | |
213 | 220 | | |
214 | 221 | | |
| 222 | + | |
| 223 | + | |
| 224 | + | |
| 225 | + | |
| 226 | + | |
| 227 | + | |
| 228 | + | |
| 229 | + | |
| 230 | + | |
| 231 | + | |
| 232 | + | |
| 233 | + | |
| 234 | + | |
| 235 | + | |
| 236 | + | |
| 237 | + | |
| 238 | + | |
| 239 | + | |
| 240 | + | |
| 241 | + | |
| 242 | + | |
| 243 | + | |
| 244 | + | |
| 245 | + | |
| 246 | + | |
| 247 | + | |
| 248 | + | |
| 249 | + | |
| 250 | + | |
| 251 | + | |
| 252 | + | |
| 253 | + | |
| 254 | + | |
| 255 | + | |
| 256 | + | |
| 257 | + | |
| 258 | + | |
| 259 | + | |
| 260 | + | |
| 261 | + | |
| 262 | + | |
| 263 | + | |
| 264 | + | |
| 265 | + | |
| 266 | + | |
| 267 | + | |
| 268 | + | |
| 269 | + | |
| 270 | + | |
| 271 | + | |
| 272 | + | |
| 273 | + | |
| 274 | + | |
| 275 | + | |
| 276 | + | |
| 277 | + | |
| 278 | + | |
| 279 | + | |
| 280 | + | |
| 281 | + | |
| 282 | + | |
| 283 | + | |
| 284 | + | |
| 285 | + | |
| 286 | + | |
| 287 | + | |
| 288 | + | |
| 289 | + | |
| 290 | + | |
| 291 | + | |
| 292 | + | |
| 293 | + | |
| 294 | + | |
| 295 | + | |
| 296 | + | |
| 297 | + | |
| 298 | + | |
| 299 | + | |
| 300 | + | |
| 301 | + | |
| 302 | + | |
| 303 | + | |
| 304 | + | |
| 305 | + | |
| 306 | + | |
215 | 307 | | |
216 | 308 | | |
217 | 309 | | |
| |||
220 | 312 | | |
221 | 313 | | |
222 | 314 | | |
| 315 | + | |
| 316 | + | |
| 317 | + | |
| 318 | + | |
| 319 | + | |
| 320 | + | |
| 321 | + | |
223 | 322 | | |
224 | 323 | | |
225 | | - | |
| 324 | + | |
| 325 | + | |
| 326 | + | |
| 327 | + | |
| 328 | + | |
| 329 | + | |
226 | 330 | | |
227 | 331 | | |
228 | | - | |
| 332 | + | |
| 333 | + | |
| 334 | + | |
| 335 | + | |
| 336 | + | |
| 337 | + | |
229 | 338 | | |
230 | 339 | | |
231 | 340 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
13 | 13 | | |
14 | 14 | | |
15 | 15 | | |
16 | | - | |
| 16 | + | |
17 | 17 | | |
18 | 18 | | |
19 | 19 | | |
| |||
129 | 129 | | |
130 | 130 | | |
131 | 131 | | |
132 | | - | |
| 132 | + | |
133 | 133 | | |
134 | 134 | | |
135 | 135 | | |
| |||
348 | 348 | | |
349 | 349 | | |
350 | 350 | | |
351 | | - | |
| 351 | + | |
352 | 352 | | |
353 | 353 | | |
354 | 354 | | |
| |||
498 | 498 | | |
499 | 499 | | |
500 | 500 | | |
501 | | - | |
| 501 | + | |
| 502 | + | |
| 503 | + | |
| 504 | + | |
| 505 | + | |
| 506 | + | |
| 507 | + | |
502 | 508 | | |
503 | 509 | | |
504 | 510 | | |
| |||
521 | 527 | | |
522 | 528 | | |
523 | 529 | | |
| 530 | + | |
| 531 | + | |
| 532 | + | |
| 533 | + | |
| 534 | + | |
| 535 | + | |
| 536 | + | |
| 537 | + | |
| 538 | + | |
| 539 | + | |
| 540 | + | |
| 541 | + | |
| 542 | + | |
| 543 | + | |
| 544 | + | |
| 545 | + | |
| 546 | + | |
| 547 | + | |
| 548 | + | |
| 549 | + | |
| 550 | + | |
| 551 | + | |
| 552 | + | |
| 553 | + | |
| 554 | + | |
| 555 | + | |
| 556 | + | |
| 557 | + | |
| 558 | + | |
| 559 | + | |
| 560 | + | |
| 561 | + | |
| 562 | + | |
| 563 | + | |
| 564 | + | |
| 565 | + | |
| 566 | + | |
| 567 | + | |
| 568 | + | |
| 569 | + | |
| 570 | + | |
| 571 | + | |
| 572 | + | |
| 573 | + | |
| 574 | + | |
| 575 | + | |
| 576 | + | |
| 577 | + | |
| 578 | + | |
| 579 | + | |
| 580 | + | |
| 581 | + | |
| 582 | + | |
| 583 | + | |
| 584 | + | |
524 | 585 | | |
| 586 | + | |
| 587 | + | |
525 | 588 | | |
526 | 589 | | |
527 | 590 | | |
528 | | - | |
529 | | - | |
530 | | - | |
531 | | - | |
| 591 | + | |
| 592 | + | |
| 593 | + | |
532 | 594 | | |
533 | 595 | | |
534 | 596 | | |
| |||
0 commit comments