Skip to content

Commit f37216d

Browse files
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/_classifier.ts

Lines changed: 112 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,14 @@ export interface ClassificationResult {
1717
level: ThreatLevel;
1818
category: EventCategory;
1919
confidence: number;
20-
source: 'keyword';
20+
// 'keyword' = pure keyword match (CRITICAL/HIGH/MEDIUM/LOW lists or
21+
// info-level no-match fallback). 'keyword-historical-downgrade' = a
22+
// CRITICAL/HIGH keyword matched, but the headline contained a historical
23+
// retrospective marker (e.g. "Science history:", "April 26, 1986",
24+
// "5 years ago"), so the level was forced to info. Distinct source tag
25+
// lets downstream consumers + telemetry distinguish "no-match info"
26+
// from "downgraded-from-critical info".
27+
source: 'keyword' | 'keyword-historical-downgrade';
2128
}
2229

2330
type KeywordMap = Record<string, EventCategory>;
@@ -212,6 +219,91 @@ function matchKeywords(
212219
return null;
213220
}
214221

222+
/**
223+
* Headline-shape patterns that flip a CRITICAL/HIGH keyword classification
224+
* into a historical retrospective. Triggered ONLY after a critical/high
225+
* keyword has already matched — i.e. these patterns alone don't downgrade
226+
* unrelated content; they downgrade content where the trigger word
227+
* (`meltdown`, `invasion`, `genocide`, …) appears in a historically-framed
228+
* headline. Examples that tripped the prior classifier:
229+
* - "Science history: Chernobyl nuclear power plant melts down — April 26, 1986"
230+
* - "On this day: Iraq invasion 5 years ago"
231+
* Both contain a CRITICAL keyword AND an unmistakable retrospective marker.
232+
*/
233+
// Highly-specific retrospective prefixes — bare "Today in" / "This day in"
234+
// were intentionally REMOVED after PR #3429 review (round 2). Both have
235+
// legitimate current-event uses ("Today in Ukraine: Russian missile strikes
236+
// Kyiv") that would have falsely downgraded real critical alerts. Only
237+
// patterns whose retrospective intent is unambiguous remain:
238+
// - "Science history:" — Live Science series tag, never current.
239+
// - "Throwback" / "Flashback" — always retrospective by definition.
240+
const HISTORICAL_PREFIX_RE = /^(?:science history|throwback|flashback)\s*:?/i;
241+
242+
// "On this day in YYYY" requires a YEAR after the prefix — narrows out
243+
// "On this day, Iran fires missile" (current event) while keeping
244+
// "On this day in 1986, Chernobyl..." (retrospective).
245+
const HISTORICAL_PREFIX_WITH_YEAR_RE = /^on this day in\s+(?:19|20)\d{2}\b/i;
246+
247+
// "This day in history" — specific phrasing, not the bare "This day in"
248+
// (which could prefix a current-event headline).
249+
const THIS_DAY_IN_HISTORY_RE = /^this day in history\b/i;
250+
251+
const HISTORICAL_PHRASE_RE =
252+
/\b(?:\d+\s+(?:years?|decades?|months?)\s+(?:ago|after|later)|anniversary|in memoriam|remembering|remembered|commemorat(?:e|es|ed|ion)|retrospective)\b/i;
253+
254+
// Full date in the headline. The year must be ≥ 2 years in the past for
255+
// the date to count as retrospective — "April 26, 2026" (current year) or
256+
// "April 26, 2025" (last year) appear in plenty of current-event headlines
257+
// (court rulings, regulatory deadlines, scheduled events). Only dates from
258+
// 2024-and-earlier (in 2026) are unambiguously retrospective.
259+
const FULL_DATE_RE =
260+
/\b(?:january|february|march|april|may|june|july|august|september|october|november|december)\s+\d{1,2},?\s+((?:19|20)\d{2})\b/i;
261+
262+
const ISO_DATE_RE = /\b((?:19|20)\d{2})-\d{1,2}-\d{1,2}\b/;
263+
264+
/**
265+
* Year is "past" for retrospective purposes when it's at least 2 years
266+
* older than the current calendar year. Conservative cutoff: a 1-year-old
267+
* date is often current-context (last year's court ruling, last year's
268+
* outbreak) and we don't want to falsely downgrade those.
269+
*/
270+
function isPastRetrospectiveYear(year: number, nowMs: number): boolean {
271+
const currentYear = new Date(nowMs).getUTCFullYear();
272+
return year < currentYear - 1;
273+
}
274+
275+
/**
276+
* Returns true if the title looks like a historical retrospective.
277+
* Used by classifyByKeyword to downgrade CRITICAL/HIGH keyword matches
278+
* (e.g. "meltdown") that appear in a backward-looking headline, AND by
279+
* enrichWithAiCache as a defense-in-depth check on LLM-promoted levels.
280+
*
281+
* The `nowMs` parameter is exposed for unit testability (so tests can pin
282+
* the "current year" without depending on wall-clock time). Production
283+
* callers omit it and get `Date.now()`.
284+
*
285+
* Exported for test coverage — DO NOT call from production code paths
286+
* other than classifyByKeyword and enrichWithAiCache.
287+
*/
288+
export function hasHistoricalMarker(title: string, nowMs: number = Date.now()): boolean {
289+
if (HISTORICAL_PREFIX_RE.test(title)) return true;
290+
if (HISTORICAL_PREFIX_WITH_YEAR_RE.test(title)) return true;
291+
if (THIS_DAY_IN_HISTORY_RE.test(title)) return true;
292+
if (HISTORICAL_PHRASE_RE.test(title)) return true;
293+
294+
const fullDateMatch = title.match(FULL_DATE_RE);
295+
if (fullDateMatch && isPastRetrospectiveYear(parseInt(fullDateMatch[1]!, 10), nowMs)) {
296+
return true;
297+
}
298+
299+
const isoDateMatch = title.match(ISO_DATE_RE);
300+
if (isoDateMatch && isPastRetrospectiveYear(parseInt(isoDateMatch[1]!, 10), nowMs)) {
301+
return true;
302+
}
303+
304+
return false;
305+
}
306+
215307
export function classifyByKeyword(title: string, variant?: string): ClassificationResult {
216308
const lower = title.toLowerCase();
217309

@@ -220,12 +312,29 @@ export function classifyByKeyword(title: string, variant?: string): Classificati
220312
}
221313

222314
const isTech = variant === 'tech';
315+
// Historical-retrospective downgrade applies only to CRITICAL/HIGH
316+
// keyword matches — those are the levels that score high enough to
317+
// ship in briefs, and those are where the false-positive cost is
318+
// highest (an anniversary listicle ranking like a current crisis).
319+
// LOW/MEDIUM matches are left alone since they don't clear thresholds
320+
// anyway, and the downgrade-to-info would be over-aggressive there.
321+
const isRetrospective = hasHistoricalMarker(title);
223322

224323
let match = matchKeywords(lower, CRITICAL_KEYWORDS);
225-
if (match) return { level: 'critical', category: match.category, confidence: 0.9, source: 'keyword' };
324+
if (match) {
325+
if (isRetrospective) {
326+
return { level: 'info', category: 'general', confidence: 0.85, source: 'keyword-historical-downgrade' };
327+
}
328+
return { level: 'critical', category: match.category, confidence: 0.9, source: 'keyword' };
329+
}
226330

227331
match = matchKeywords(lower, HIGH_KEYWORDS);
228-
if (match) return { level: 'high', category: match.category, confidence: 0.8, source: 'keyword' };
332+
if (match) {
333+
if (isRetrospective) {
334+
return { level: 'info', category: 'general', confidence: 0.85, source: 'keyword-historical-downgrade' };
335+
}
336+
return { level: 'high', category: match.category, confidence: 0.8, source: 'keyword' };
337+
}
229338

230339
if (isTech) {
231340
match = matchKeywords(lower, TECH_HIGH_KEYWORDS);

server/worldmonitor/news/v1/list-feed-digest.ts

Lines changed: 70 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import { markNoCacheResponse } from '../../../_shared/response-headers';
1313
import { sha256Hex } from '../../../_shared/hash';
1414
import { CHROME_UA } from '../../../_shared/constants';
1515
import { VARIANT_FEEDS, INTEL_SOURCES, type ServerFeed } from './_feeds';
16-
import { classifyByKeyword, type ThreatLevel } from './_classifier';
16+
import { classifyByKeyword, hasHistoricalMarker, type ThreatLevel } from './_classifier';
1717
import { buildClassifyCacheKey } from '../../intelligence/v1/_shared';
1818
import { getSourceTier } from '../../../_shared/source-tiers';
1919
import {
@@ -129,7 +129,7 @@ interface ParsedItem {
129129
level: ThreatLevel;
130130
category: string;
131131
confidence: number;
132-
classSource: 'keyword' | 'llm';
132+
classSource: 'keyword' | 'keyword-historical-downgrade' | 'llm';
133133
importanceScore: number;
134134
corroborationCount: number;
135135
titleHash?: string;
@@ -348,7 +348,7 @@ function parseRssXml(xml: string, feed: ServerFeed, variant: string): ParseResul
348348
level: threat.level,
349349
category: threat.category,
350350
confidence: threat.confidence,
351-
classSource: 'keyword',
351+
classSource: threat.source,
352352
importanceScore: 0,
353353
corroborationCount: 1,
354354
lang: feed.lang ?? 'en',
@@ -498,7 +498,13 @@ function decodeXmlEntities(s: string): string {
498498
}
499499

500500
async function enrichWithAiCache(items: ParsedItem[]): Promise<void> {
501-
const candidates = items.filter(i => i.classSource === 'keyword');
501+
// Apply the LLM cache to BOTH 'keyword' and 'keyword-historical-downgrade'
502+
// sources. The historical-downgrade path forced an info level based on a
503+
// headline-shape heuristic; the LLM cache (when warmed) is a stronger
504+
// signal and should be allowed to either confirm or override.
505+
const candidates = items.filter(
506+
i => i.classSource === 'keyword' || i.classSource === 'keyword-historical-downgrade',
507+
);
502508
if (candidates.length === 0) return;
503509

504510
// Use the canonical buildClassifyCacheKey from intelligence/v1/_shared
@@ -521,14 +527,70 @@ async function enrichWithAiCache(items: ParsedItem[]): Promise<void> {
521527
if (!hit || hit.level === '_skip' || !hit.level || !hit.category) continue;
522528

523529
for (const item of relatedItems) {
530+
// L3 defense-in-depth runs FIRST, BEFORE capLlmUpgrade. If the
531+
// title carries a historical-retrospective marker, force info
532+
// regardless of what the LLM cache claimed — retrospective content
533+
// should never ship at any non-info level.
534+
//
535+
// Why before the cap (P1 fix on PR #3429 round 3): when keyword=info
536+
// and hit=critical, capLlmUpgrade returns medium (info+2=medium).
537+
// A post-cap check on `cappedLevel === 'critical' || === 'high'`
538+
// would miss this — `medium` doesn't match — so the brief 2026-04-
539+
// 26-1302 Chernobyl-style title would have shipped at MEDIUM (which
540+
// still passes 'all' sensitivity briefs). Running the marker check
541+
// on the original hit and forcing info — not on cappedLevel — closes
542+
// that gap.
543+
//
544+
// Why force info unconditionally (not just critical/high): retro-
545+
// spective markers should suppress the LLM verdict at every non-info
546+
// level, including medium and low. A medium-level retrospective would
547+
// still ship in 'all'-sensitivity briefs; the goal of this guard is
548+
// "retrospective content NEVER ships, regardless of LLM verdict."
549+
if (hasHistoricalMarker(item.title)) {
550+
console.warn(
551+
`[classify] LLM hit forced to info by historical marker: ` +
552+
`keyword=${item.level} llm=${hit.level} title="${item.title.slice(0, 60)}"`,
553+
);
554+
item.level = 'info';
555+
item.category = hit.category;
556+
item.confidence = 0.9;
557+
item.classSource = 'llm';
558+
item.isAlert = false;
559+
continue;
560+
}
561+
562+
// Skip the LLM cache for high-confidence keyword=critical matches
563+
// (confidence 0.9). Without this skip, capLlmUpgrade is a Math.min
564+
// — a stale or wrong LLM cache entry saying 'info' would silently
565+
// demote a genuine current critical event to info via min(critical,
566+
// info) = info, with no remaining safeguard.
567+
//
568+
// The retrospective case the prior PR #3424 wanted to handle here
569+
// is already handled UPSTREAM: a keyword=critical title with a
570+
// historical marker becomes classSource='keyword-historical-
571+
// downgrade' (confidence 0.85, level=info) inside classifyByKeyword
572+
// BEFORE reaching this function, so the L3 marker check above
573+
// catches it via the historical-downgrade source. Items reaching
574+
// here at confidence 0.9 are by construction items where the
575+
// keyword classifier saw a critical match AND saw no marker —
576+
// the safer default for those is to trust the keyword verdict.
577+
//
578+
// The L3 marker check above intentionally runs BEFORE this skip so
579+
// that keyword=info (confidence 0.3, no-match) titles with a
580+
// marker — the brief 2026-04-26-1302 "Science history: melts
581+
// down…" shape — still get forced to info via the cache hit.
582+
// Belt-and-suspenders for substring-keyword-miss contamination.
583+
//
584+
// P1 fix on PR #3429 round 4 (Greptile review on commit 96d3c12d7).
524585
if (0.9 <= item.confidence) continue;
586+
587+
//
525588
// Cap the LLM upgrade at +2 tiers above the keyword classification
526589
// so a poisoned cache entry (e.g., "About Section 508" → high) can't
527590
// promote an info-keyword item past medium (info+2=medium). Legitimate
528-
// medium→critical upgrades (medium+2=critical) remain reachable; the
529-
// bounded loss is keyword=low → LLM=critical, which caps at high
530-
// (low+2=high) and is logged below. See LEVEL_RANK doc + R4 for the
531-
// full per-keyword cap table.
591+
// medium→critical upgrades (medium+2=critical) remain reachable.
592+
// capLlmUpgrade is a Math.min so downgrades pass through freely.
593+
// See LEVEL_RANK doc + R4 for the full per-keyword cap table.
532594
const cappedLevel = capLlmUpgrade(item.level, hit.level);
533595
if (cappedLevel !== hit.level) {
534596
console.warn(

0 commit comments

Comments
 (0)