Skip to content

feat(e3): story persistence tracking#2620

Merged
koala73 merged 5 commits intomainfrom
feat/story-persistence-tracking
Apr 2, 2026
Merged

feat(e3): story persistence tracking#2620
koala73 merged 5 commits intomainfrom
feat/story-persistence-tracking

Conversation

@koala73
Copy link
Copy Markdown
Owner

@koala73 koala73 commented Apr 2, 2026

Summary

  • Proto: StoryMeta message + StoryPhase enum added to NewsItem (fields 9-11). importanceScore and corroborationCount stubs added for E1.
  • list-feed-digest.ts: Builds a corroboration map across ALL items before truncation (so cross-category mentions of the same story are visible). Batch-reads existing story:track hashes from Redis and attaches StoryMeta to each proto item. Writes HINCRBY/HSET/HSETNX/SADD/EXPIRE per story in 80-story pipeline chunks (~9 commands/story).
  • cache-keys.ts: STORY_TRACK_KEY_PREFIX, STORY_SOURCES_KEY_PREFIX, DIGEST_ACCUMULATOR_KEY_PREFIX, STORY_TRACKING_TTL_S (48h).
  • src/types/index.ts: StoryMeta, StoryPhase types; NewsItem extended with importanceScore?, corroborationCount?, storyMeta?.
  • data-loader.ts: protoItemToNewsItem maps STORY_PHASE_* → client StoryPhase.
  • NewsPanel.ts: Phase badges — BREAKING (red), DEVELOPING ×N (orange), ONGOING (muted).

How story phases work

Phase Condition
BREAKING First appearance (mentionCount = 1)
DEVELOPING 2-5 mentions, < 2h old
SUSTAINED 6+ mentions or > 2h old and still prominent
FADING currentScore < peakScore × 0.5 (activates in E1)

Redis keys per story (48h TTL)

story:track:v1:<hash16>    hash  firstSeen, lastSeen, mentionCount, sourceCount, currentScore, peakScore, severity
story:sources:v1:<hash16>  set   feed names (for cross-source corroboration count)

First request writes the keys. Second request (15 min later, after cache miss) reads them back and populates StoryMeta in the response.

Dependency

This is the Redis foundation E1 (Composite Importance Score) needs. E1 will overwrite currentScore/peakScore in the hash and add ZADD GT on story:peak:v1:<hash> when it ships.

Test plan

  • Verify typecheck + typecheck:api pass (both clean)
  • Verify test:data passes (2748 tests)
  • After deploy: confirm story:track:v1:* keys appear in Upstash within 15 min of first digest build
  • After two digest cycles (30 min): confirm phase badges appear in NewsPanel

Post-Deploy Monitoring & Validation

  • Logs: [list-feed-digest] writeStoryTracking — should be silent on success, warn on Redis failure
  • Redis: KEYS story:track:v1:* count should be 200-500 within one digest cycle
  • Failure signal: If writeStoryTracking failed appears in logs, story tracking is not writing but digest delivery is unaffected (fire-and-forget error path)
  • Rollback: No schema changes, no breaking changes to existing digest response shape. New fields are optional — old clients ignore them.

@vercel
Copy link
Copy Markdown

vercel bot commented Apr 2, 2026

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

Project Deployment Actions Updated (UTC)
worldmonitor Ready Ready Preview, Comment Apr 2, 2026 5:17pm

Request Review

@mintlify
Copy link
Copy Markdown

mintlify bot commented Apr 2, 2026

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated (UTC)
WorldMonitor 🟢 Ready View Preview Apr 2, 2026, 10:31 AM

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Apr 2, 2026

Greptile Summary

This PR introduces E3 story persistence tracking: a Redis-backed pipeline that hashes story titles across digest cycles, accumulates mention/source counts, derives lifecycle phases (BREAKING → DEVELOPING → SUSTAINED → FADING), and surfaces them as StoryMeta on NewsItem. The proto, generated stubs, OpenAPI docs, types, data-loader mapping, and NewsPanel badge rendering are all updated consistently.

Two P1 issues need to be resolved before merging:

  • normalizeTitle uses [^\w\s] without the u flag, which strips every non-ASCII character. All Arabic (and other non-Latin) story titles normalise to "", causing every such story to share one Redis hash and produce completely wrong mentionCount/storyMeta data.
  • The ALERT badge is now gated on !item.storyMeta, so any tracked story with isAlert: true silently loses its alert indicator once it appears in a second digest cycle.

Confidence Score: 4/5

  • Two P1 defects need fixes before merging: Unicode hash collision in normalizeTitle and suppressed ALERT badge for tracked stories.
  • The Redis pipeline architecture, phase derivation, and proto contract are all sound. However, the Unicode normalisation bug would corrupt story tracking for any non-Latin feed (the system explicitly supports lang: ar), and the ALERT badge regression silently drops threat indicators on recurring high-severity stories — both are current-path failures on the changed code.
  • server/worldmonitor/news/v1/list-feed-digest.ts (normalizeTitle regex) and src/components/NewsPanel.ts (ALERT badge guard)

Important Files Changed

Filename Overview
server/worldmonitor/news/v1/list-feed-digest.ts Core story tracking logic added: corroboration map, Redis read/write pipeline, phase derivation — two issues: normalizeTitle strips all Unicode (hash collision for non-Latin titles) and derivePhase ordering makes FADING unreachable until E1
src/components/NewsPanel.ts Phase badges added (BREAKING/DEVELOPING/ONGOING); ALERT badge guard changed to isAlert && !storyMeta, silently suppressing ALERT on any tracked story regardless of threat severity
server/_shared/cache-keys.ts Four new key prefixes and 48h TTL constant added for E3 story tracking; straightforward, no issues
src/app/data-loader.ts PROTO_TO_CLIENT_PHASE map and protoItemToNewsItem extension added; mapping is correct, unspecified phase correctly filtered to undefined
src/types/index.ts StoryPhase, StoryMeta types and optional NewsItem fields added; clean additions, no issues
proto/worldmonitor/news/v1/news_item.proto StoryMeta message and StoryPhase enum added to NewsItem (fields 9-11); field numbering is correct and backward-compatible

Sequence Diagram

sequenceDiagram
    participant Client
    participant buildDigest
    participant sha256Hex
    participant Redis

    Client->>buildDigest: GET /list-feed-digest

    Note over buildDigest: Fetch & parse all RSS feeds
    buildDigest->>sha256Hex: normalizeTitle(item.title) × N items
    sha256Hex-->>buildDigest: titleHash (16-char hex)
    Note over buildDigest: Build corroborationMap<br/>titleHash → Set<sourceName>

    buildDigest->>Redis: HMGET story:track:v1:<hash> × uniqueHashes (pipeline)
    Redis-->>buildDigest: existing StoryTrack data

    Note over buildDigest: Derive phase per track<br/>Attach StoryMeta to proto items<br/>Truncate to MAX_ITEMS_PER_CATEGORY

    buildDigest-->>Client: ListFeedDigestResponse (with StoryMeta)

    Note over buildDigest: Fire-and-forget write (async)
    buildDigest->>Redis: HINCRBY mentionCount 1<br/>HSET lastSeen/sourceCount/severity<br/>HSETNX firstSeen/currentScore/peakScore<br/>SADD story:sources:v1:<hash><br/>EXPIRE ×2 (48h TTL)<br/>per story in 80-item pipeline chunks
Loading

Comments Outside Diff (3)

  1. server/worldmonitor/news/v1/list-feed-digest.ts, line 169-175 (link)

    P1 Unicode titles all collapse to the same hash

    \w in JavaScript (without the u flag) matches only ASCII [A-Za-z0-9_]. Every non-Latin character — Arabic, Chinese, Japanese, Cyrillic, Hebrew — is treated as a "special character" and stripped by [^\w\s]. A title like "الرياض مدينة" normalises to "", and so does every other Arabic-script title. They all produce the same sha256Hex("") key, so every non-Latin story shares one Redis hash: mentionCount, sourceCount, currentScore and peakScore all accumulate against the same entry, and storyMeta returned to clients is completely wrong for all of them.

    The system already accepts lang: 'ar' requests, so this is a current-path failure for any Arabic-language feed. Use a Unicode-aware regex instead:

  2. src/components/NewsPanel.ts, line 389 (link)

    P1 ALERT badge silently suppressed for any tracked story

    The guard !item.storyMeta means that once a story has been seen at least twice (i.e. storyMeta is populated), its ALERT tag is never rendered — even when item.isAlert is true. A critical or high-severity threat that recurs across digest cycles will display only a DEVELOPING/ONGOING phase badge, with no indication that it triggered an alert. This is a regression from the previous behaviour where isAlert always rendered the tag.

    The two signals are orthogonal: phase describes lifecycle; isAlert describes threat severity. They should render independently.

  3. server/worldmonitor/news/v1/list-feed-digest.ts, line 186-192 (link)

    P2 STORY_PHASE_FADING is unreachable in practice

    derivePhase checks FADING after the SUSTAINED catch-all, so the function always returns SUSTAINED before it can return FADING. The if (currentScore < peakScore * 0.5) branch is also guarded by currentScore > 0 && peakScore > 0, but because HSETNX initialises both fields to '0', this condition is never true until E1 ships and overwrites those placeholder zeros. Until then every story ends up as SUSTAINED.

    Consider moving the FADING check before the SUSTAINED return (or documenting the intentional ordering as an E1 activation detail):

Reviews (1): Last reviewed commit: "feat(e3): story persistence tracking" | Re-trigger Greptile

koala73 added 5 commits April 2, 2026 21:03
Adds cross-cycle story tracking layer to the RSS digest pipeline:

- Proto: StoryMeta message + StoryPhase enum on NewsItem (fields 9-11).
  importanceScore and corroborationCount stubs added for E1.
- list-feed-digest.ts: builds corroboration map across ALL items before
  truncation; batch-reads existing story:track hashes from Redis; writes
  HINCRBY/HSET/HSETNX/SADD/EXPIRE per story in 80-story pipeline chunks;
  attaches StoryMeta (firstSeen, mentionCount, sourceCount, phase) to
  each proto item using read-back data.
- cache-keys.ts: STORY_TRACK_KEY_PREFIX, STORY_SOURCES_KEY_PREFIX,
  DIGEST_ACCUMULATOR_KEY_PREFIX, STORY_TRACKING_TTL_S.
- src/types/index.ts: StoryMeta, StoryPhase, NewsItem extended.
- data-loader.ts: protoItemToNewsItem maps STORY_PHASE_* → client phase.
- NewsPanel.ts: BREAKING/DEVELOPING/ONGOING phase badges in item rows.

New story first appearance: phase=BREAKING. After 2 mentions within 2h:
DEVELOPING. After 6+ mentions or >2h: SUSTAINED. If score drops below
50% of peak: FADING (used by E1; defaults to SUSTAINED for now).

Redis keys per story (48h TTL):
  story:track:v1:<hash16>   → hash (firstSeen,lastSeen,mentionCount,...)
  story:sources:v1:<hash16> → set  (feed names, for cross-source count)
P1 — storyMeta was always one cycle behind because storyTracks was read
before writeStoryTracking ran. Fix: keep read-before-write but compute
storyMeta from merged in-memory state (stale.mentionCount + 1, fresh
sourceCount from corroborationMap). New stories get mentionCount=1 and
phase=BREAKING in the same cycle they first appear — no extra Redis
round-trip needed.

P2 — mentionCount incremented once per item occurrence, so a story seen
in 3 sources in its first cycle was immediately stored as mentionCount=3.
Fix: deduplicate by titleHash in writeStoryTracking so each unique story
gets exactly one HINCRBY per digest cycle regardless of source count.
SADD still collects all sources for the set key.
P1 — normalizeTitle used [^\w\s] without the u flag; \w is ASCII-only
so every Arabic/CJK/Cyrillic title stripped to "" and shared one Redis
hash. Fixed: use /[^\p{L}\p{N}\s]/gu (Unicode property escapes require
the u flag).

P1 — ALERT badge was gated on !item.storyMeta, suppressing the indicator
for any tracked story regardless of isAlert. Phase and alert are
orthogonal signals; ALERT now renders unconditionally when isAlert=true.

P2 — FADING branch is intentionally inactive until E1 ships real scores
(currentScore/peakScore placeholder 0 via HSETNX). Added comment to
document the intentional ordering.
…ectBest

Sustained and fading story phases are already well-covered by the feed;
only breaking and developing phases warrant a banner interrupt. Items
without storyMeta (phase unspecified) pass through unchanged.

Fixes gap C from docs/plans/2026-04-02-003-fix-news-alerts-pr-gaps-plan.md
Removes a stray closing brace, duplicate ASCII normalizeTitle
(Unicode-aware version from the fix commit is correct), and
a leftover storyPhase assignment that references a removed field.

All typecheck and typecheck:api pass clean.
@koala73 koala73 force-pushed the feat/story-persistence-tracking branch from 1b58fc2 to 863719f Compare April 2, 2026 17:14
@koala73 koala73 merged commit 6c01799 into main Apr 2, 2026
7 of 8 checks passed
@koala73 koala73 deleted the feat/story-persistence-tracking branch April 2, 2026 17:16
koala73 added a commit that referenced this pull request Apr 2, 2026
…dges

All other PR changes (types, data-loader cast, NewsPanel) are now in main via
E3 (#2620) and E1 (#2621). Only the CSS for .phase-badge.breaking/.developing/.sustained
was missing. Class names corrected to match NewsPanel.ts output (phase-badge + modifier
vs the original phase-breaking/phase-developing selectors).
koala73 added a commit that referenced this pull request Apr 2, 2026
…dges (#2608)

All other PR changes (types, data-loader cast, NewsPanel) are now in main via
E3 (#2620) and E1 (#2621). Only the CSS for .phase-badge.breaking/.developing/.sustained
was missing. Class names corrected to match NewsPanel.ts output (phase-badge + modifier
vs the original phase-breaking/phase-developing selectors).
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