Skip to content

fix(insights): trust cluster rank, stop LLM from re-picking top story#3358

Merged
koala73 merged 3 commits into
mainfrom
fix/insights-brief-ranking
Apr 24, 2026
Merged

fix(insights): trust cluster rank, stop LLM from re-picking top story#3358
koala73 merged 3 commits into
mainfrom
fix/insights-brief-ranking

Conversation

@koala73

@koala73 koala73 commented Apr 23, 2026

Copy link
Copy Markdown
Owner

Summary

Two compounding issues produced a user-visible hallucination in the WORLD BRIEF panel. This PR fixes both, with a corroboration gate as the safety backstop — and locks both behaviors with regression tests.

1. The LLM was double-ranking. scripts/seed-insights.mjs sent all 10 clustered headlines to gemini-2.5-flash with "pick the ONE most significant and summarize." Shrink that to phrasing-only — pass a single chosen headline and instruct the model to rewrite using only facts present in it.

2. The upstream cluster ranker is keyword-heavy, not objective. scoreImportance() in scripts/_clustering.mjs awards sourceCount * 10 but +100+25 per violence keyword, +60+75 for flashpoint/military, and a x1.5 multiplier when both hit — a single-source sensational rumor routinely outranks a 2-source lead. So blindly trusting topStories[0] still produces bad briefs. Add a corroboration gate: walk topStories for the first cluster with sourceCount >= 2; if none qualifies, publish status: 'degraded' with no brief. This gate only affects the brief paragraph — the topStories list itself continues to render single-source clusters.

3. Prompt contradiction. "Use ONLY facts present in the headline" conflicted with "Lead with WHAT happened and WHERE" for headlines with no explicit location — pushing the model toward inferred-place hallucination. Replaced with "Include a location, person, or organization ONLY if it appears in the headline."

4. Regression tests. Selection logic + prompt invariants extracted into scripts/_insights-brief.mjs (pure module, no side effects at import time) and locked by tests/seed-insights-brief.test.mjs — 12 cases including an explicit News24-Iran-scenario test that fails if future code ever briefs a single-source cluster over a multi-sourced lead.

Also: temperature 0.3 -> 0.1 (factual summary), dead MAX_HEADLINES const removed. CACHE_TTL unchanged at 10800 (3h) — a one-cron-cadence TTL would reintroduce the cache-drop outage the old comment warned against (/api/bootstrap reads the key directly). Bad content is gated at brief-selection time now, so the LKG window does not need to be sacrificed.

Payload shape unchanged; frontend untouched.

Why (user-visible bug)

WORLD BRIEF panel published for ~3h on 2026-04-23:

"Iran's new supreme leader was seriously wounded, leading him to delegate power to the Revolutionary Guards. This development comes amid an ongoing war with Israel."

Payload receipt: briefProvider: openrouter, briefModel: google/gemini-2.5-flash. An earlier generation also confabulated the name "Ayatollah Ali Khamenei" (not in any input headline — gemini filled it in from its training prior).

Input top stories (cluster rank):

  1. Lebanon leaders accuse Israel of war crime after journalist killed (2 sources)
  2. Lebanon accuses Israel of targeting journalist killed in air strike (same cluster)
  3. News24 | Iran's new supreme leader seriously wounded, delegates power to Revolutionary Guards (1 source)
  4. Navy Secretary John Phelan fired amid tensions with Pete Hegseth as Iran war rages
  5. A well-known secret: inside Toronto's violent tow truck wars

Old prompt told gemini to pick the most significant and summarize it. Gemini picked #3 (single-source rumor) over #1 (multi-sourced lead) and embellished with the "ongoing war with Israel" frame pulled from #4. After this PR: #3 is never eligible for the brief (sourceCount < 2); #1 is briefed using only its own headline text.

Test plan

  • npm run typecheck — PASS (22s)
  • npm run typecheck:api — PASS (8s)
  • npm run lint — PASS (no new warnings beyond 202-warning / 23-info baseline)
  • npm run test:data — PASS (6657/6657 — +12 new regression tests)
  • node --test tests/seed-insights-brief.test.mjs — 12/12 PASS including the News24-Iran regression scenario
  • node --test tests/edge-functions.test.mjs — PASS
  • Edge bundle check across api/*.js — PASS
  • npm run lint:md — PASS (0 errors)
  • npm run version:check — PASS
  • CJS syntax across scripts/*.cjs — PASS
  • node --check scripts/seed-insights.mjs + node --check scripts/_insights-brief.mjs
  • After merge: DEL news:insights:v1 in Upstash so the next cron tick regenerates immediately (current bad brief otherwise persists until its existing TTL expires)
  • Verify next /api/bootstrap payload: insights.worldBrief summarizes a sourceCount >= 2 cluster and contains no proper noun absent from that cluster's primaryTitle; or insights.status === 'degraded' with empty worldBrief

WORLD BRIEF panel published "Iran's new supreme leader was seriously
wounded, leading him to delegate power to the Revolutionary Guards. This
development comes amid an ongoing war with Israel." to every visitor for
3h. Payload: openrouter / gemini-2.5-flash.

Root cause: callLLM sent all 10 clustered headlines with "pick the ONE
most significant and summarize ONLY that story". Clustering ranked
Lebanon journalist killing #1 (2 corroborating sources); News24 Iran
rumor ranked #3 (1 source). Gemini overrode the rank, picked #3, and
embellished with war framing from story #4. Objective rank (sourceCount,
velocity, isAlert) lost to model vibe.

Shrink the LLM's job to phrasing. Clustering already ranks — pass only
topStories[0].primaryTitle and instruct the model to rewrite it using
ONLY facts from the headline. No name/place/context invention.

Also:
- temperature 0.3 -> 0.1 (factual summary, not creative)
- CACHE_TTL 3h -> 30m so a bad brief ages out in one cron cycle
- Drop dead MAX_HEADLINES const

Payload shape unchanged; frontend untouched.
@vercel

vercel Bot commented Apr 23, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
worldmonitor Ignored Ignored Preview Apr 24, 2026 3:18am

Request Review

@greptile-apps

greptile-apps Bot commented Apr 23, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes a real confabulation bug by narrowing the LLM's job from "rank 10 headlines and pick one" to "rewrite the already-ranked top headline," preventing small models from overriding the objective clustering score. The prompt, temperature, and CACHE_TTL are all updated accordingly. The payload shape is unchanged and the frontend is untouched.

One tradeoff worth tracking: the CACHE_TTL drops from 3h back to 30m (matching the cron interval exactly), which was the configuration the previous developer explicitly changed to avoid a missed-run gap.

Confidence Score: 5/5

Safe to merge — the fix is well-targeted, payload shape is unchanged, and the only finding is a P2 tradeoff the author is aware of.

No P0 or P1 issues found. The topStories[0] access is guarded, the LLM fallback chain is intact, and LKG preservation still works for the degraded path. The single P2 note (CACHE_TTL regression) is a conscious engineering tradeoff documented in the PR description.

scripts/seed-insights.mjs — CACHE_TTL change worth re-evaluating before merge

Important Files Changed

Filename Overview
scripts/seed-insights.mjs Narrows LLM job to single-headline rewrite; removes MAX_HEADLINES, lowers temperature to 0.1, reduces CACHE_TTL from 3h to 30m — the TTL change reintroduces the missed-run vulnerability the old 3h TTL was protecting against.

Sequence Diagram

sequenceDiagram
    participant Cron
    participant fetchInsights
    participant clusterItems
    participant selectTopStories
    participant callLLM
    participant Redis

    Cron->>fetchInsights: run seed
    fetchInsights->>Redis: GET news:digest:v1:full:en
    Redis-->>fetchInsights: digest items
    fetchInsights->>clusterItems: normalizedItems[]
    clusterItems-->>fetchInsights: clusters[] (ranked by sourceCount + velocity + isAlert)
    fetchInsights->>selectTopStories: clusters, 8
    selectTopStories-->>fetchInsights: topStories[] (pre-ranked)
    Note over fetchInsights: topHeadline = topStories[0].primaryTitle (trust cluster rank)
    fetchInsights->>callLLM: topHeadline (single string)
    Note over callLLM: OLD: array of 10, LLM picks + summarizes
    Note over callLLM: NEW: single headline, LLM only rewrites
    callLLM-->>fetchInsights: { text, model, provider }
    fetchInsights->>Redis: SET news:insights:v1 (TTL 1800s)
Loading

Reviews (1): Last reviewed commit: "fix(insights): trust cluster rank, stop ..." | Re-trigger Greptile

Comment thread scripts/seed-insights.mjs Outdated

const CACHE_TTL = 10800; // 3h — 6x the 30 min cron interval (was 1x = key expired on any missed run)
const MAX_HEADLINES = 10;
const CACHE_TTL = 1800; // 30m — matches cron interval; bad briefs age out in one cycle instead of persisting 3h

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 TTL now matches cron interval — reintroduces missed-run gap

The previous CACHE_TTL of 3h was explicitly set to be 6× the cron interval with the comment "was 1x = key expired on any missed run." Resetting to 30m (1×) means a single failed cron execution (transient LLM timeout, Redis hiccup, digest not ready) leaves users with no worldBrief until the next cycle completes successfully. The LKG preservation at line 336 only activates when status === 'degraded' (no LLM available), not when the run throws before reaching that point. Consider a slightly longer TTL (e.g. 2700s = 1.5× cron) to stay resilient while still evicting a bad brief within two cycles.

…HERE

Follow-up to review feedback on the ranking contract, TTL, and prompt:

1. Corroboration gate (P1a). scoreImportance() in scripts/_clustering.mjs
   is keyword-heavy (violence +125 on a single word, flashpoint +75, ^1.5
   multiplier when both hit), so a single-source sensational rumor can
   outrank a 2-source lead purely on lexical signals. Blindly trusting
   topStories[0] would let the ranker's keyword bias still pick bad
   stories. Walk topStories for sourceCount >= 2 instead — corroboration
   becomes a hard requirement, not a tiebreaker. If no cluster qualifies,
   publish status=degraded with no brief (frontend already handles this).

2. CACHE_TTL back to 10800 (P1b). 30m TTL == one cron cadence means the
   key expires on any missed or delayed run and /api/bootstrap loses
   insights entirely (api/bootstrap.js reads news:insights:v1 directly,
   no LKG across TTL-gap). The short TTL was defense-in-depth for bad
   content; the real safety is now upstream (corroboration gate + grounded
   prompt), so the LKG window doesn't need to be sacrificed for it.

3. Prompt: location conditional (P2). "Use ONLY facts present" + "Lead
   with WHAT happened and WHERE" conflicted for headlines without an
   explicit location and pushed the model toward inferred-place
   hallucination. Replaced with "Include a location, person, or
   organization ONLY if it appears in the headline."
Review P2: the corroboration gate and the prompt's no-invention rules
had no tests, so future edits to selectTopStories() ordering or prompt
text could silently reintroduce the original hallucination.

Extract the brief-selection helper and prompt builders into a pure
module (scripts/_insights-brief.mjs) so tests can import them without
triggering seed-insights.mjs's top-level runSeed() call:

- pickBriefCluster(topStories) returns first sourceCount>=2 cluster
- briefSystemPrompt(dateISO) returns the system prompt
- briefUserPrompt(headline) returns the user prompt

Regression tests (tests/seed-insights-brief.test.mjs, 12 cases) lock:
- pickBriefCluster skips single-source rumors even when ranked above a
  multi-sourced lead (explicit regression: News24 Iran supreme leader
  2026-04-23 scenario with realistic scores)
- pickBriefCluster tolerates missing/null entries
- briefSystemPrompt forbids invented facts and proper nouns
- briefSystemPrompt's "location" rule is conditional (no unconditional
  "Lead with WHAT and WHERE" directive that would push the model toward
  place-inference when the headline has no location)
- briefSystemPrompt does not contain "pick the most important" style
  language (ranking is done by pickBriefCluster upstream)
- briefUserPrompt passes the headline verbatim and instructs
  "only facts from this headline"

Also fix a misleading comment on CACHE_TTL: corroboration is gated at
brief-selection time, not on the topStories payload itself (which still
includes single-source clusters rendered as the headline list).

test:data: 6657/6657 pass (was 6645; +12).
@koala73
koala73 merged commit 5cec1b8 into main Apr 24, 2026
10 checks passed
@koala73
koala73 deleted the fix/insights-brief-ranking branch April 24, 2026 03:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant