Skip to content

fix(brief): persist category on story:track:v1 so threads card stops showing 8/8 General#3751

Merged
koala73 merged 5 commits into
mainfrom
feat/brief-persist-category
May 17, 2026
Merged

fix(brief): persist category on story:track:v1 so threads card stops showing 8/8 General#3751
koala73 merged 5 commits into
mainfrom
feat/brief-persist-category

Conversation

@koala73

@koala73 koala73 commented May 17, 2026

Copy link
Copy Markdown
Owner

Summary

Closes the May 17 brief item #1 (all 8 threads cards tagged General). Root cause: parseRssXml:485 has been computing meaningful EventCategory values (conflict | health | diplomatic | …) all along, but buildStoryTrackHsetFields was discarding the field before the row hit Redis. By the time filterTopStories read story:track:v1 back, the || 'General' fallback was the only thing left.

PR #3697 (threads-from-walk) made the latent gap visible by reading digest.threads[].category for display. This PR closes the gap PR #3697 exposed.

Depends on: PR #3748 (feelgood-classifier, merged as 2a5bf8436) + PR #3750 (opinion-classifier pathname backport, merged as 8dc087bb1). Both on origin/main.

Implementation Units

Unit Files Tests
U1 — persist category on story:track:v1 HSET + cache-keys.ts cleanup server/worldmonitor/news/v1/list-feed-digest.ts, server/_shared/cache-keys.ts, tests/news-story-track-description-persistence.test.mts T1 happy-path + T2-T4 defensive
U2 — buildDigest pass-through + sweep stale brief-compose comments scripts/seed-digest-notifications.mjs, scripts/lib/brief-compose.mjs, tests/digest-buildDigest-category-passthrough.test.mjs T6 wiring + T7 location
U3 — word-wise titleCase at envelope-build site shared/brief-filter.js, tests/brief-filter.test.mjs T9 + T10 (parameterized 13) + T10b multi-word + T11 + T11b ordering + T12 + T13

Design highlights (Codex 5-round review)

  • Single normalization site at shared/brief-filter.js, not at any display surface. category is rendered by 3 consumers (brief-compose.mjs:812, brief-render.js:653, brief-render.js:1296); normalizing once at the envelope-build site guarantees case-consistency across all three.
  • Word-wise titleCase, not first-letter-only. filterTopStories is shared with composeBriefForRule callers that pass multi-word legacy categories like 'world politics'. First-letter-only would corrupt those to 'World politics'. Word-wise (\b[a-z]) handles both single-word EventCategory enum values AND multi-word legacy values correctly. Idempotent on already-Capitalized inputs.
  • Cap-key (pairKey) stays on canonical raw category. Title-casing happens only at out.push, AFTER pairKey is computed. T11 (behavioral mixed-case differential) + T11b (source-textual ordering) lock this structural invariant.
  • No residue catch / read-time re-classifier. classifyByKeyword(title, variant) IS feasible (_classifier.ts:346), but ingest-time category can be AI-adjusted via enrichWithAiCache (list-feed-digest.ts:678,725). A read-time fallback would silently downgrade AI-adjusted verdicts back to keyword-only. Persistence captures the AI verdict; the rollout-window cost is acceptable.
  • Rollout window (per-rule): buildDigest reads ZRANGEBYSCORE(accKey, windowStartMs, now) where windowStartMs is per-rule. Daily users see residual 'General' for ~24-48h; weekly users for up to ~7d (bounded by STORY_TTL = 7d). DIGEST_ACCUMULATOR_TTL = 48h is whole-key EXPIRE, not member pruning. Acceptable because residue = today's behavior ('General'), not a regression.
  • cache-keys.ts cleanup removes the dead STORY_TRACKING_TTL_S = 172800 export at :15 and corrects the stale "TTL for all: 48h" comment block — the exact confusion that triggered a round-1 plan correction.

Test plan

  • U1: 13 tests in tests/news-story-track-description-persistence.test.mts (3 new: T1 + T2-T4 defensive)
  • U2: 2 source-textual tests in greenfield tests/digest-buildDigest-category-passthrough.test.mjs (T6 wiring, T7 location)
  • U3: 10 new tests in tests/brief-filter.test.mjs (T9, T10 parameterized × 13 enum, T10b multi-word, T11 + T11b ordering invariant, T12 fallback, T13 defensive)
  • Broader sweep: 384 tests across brief-filter + brief-from-digest-stories + brief-llm + brief-composer-rule-dedup + persistence + buildDigest tests — no regressions
  • biome: clean on touched files (pre-existing complexity warning on filterTopStories unchanged — U3 adds 1 helper + 1 line at out.push)
  • npx tsc --noEmit on modified TypeScript file: clean
  • Post-deploy verification: spot-check the next 1-2 daily briefs for Title-Case threads tags + same Title-Case on magazine story pages + public-thread stubs. Weekly users may show residual 'General' for up to ~7 days.

Plan + review trail

  • Plan: docs/plans/2026-05-17-002-fix-persist-story-track-category-plan.md (committed in U1)
  • Codex review: 5 rounds (REVISE × 5; round-5 P2s applied directly without re-review since 5 was the skill cap)
    • R1 HIGH (single-site fix), 2 MEDIUM (TTL, residue rationale)
    • R2 P2 (word-wise titleCase), P3 (cache-keys doc incomplete)
    • R3 P2 (KTD inconsistency, dead constant), P3 (brief-compose.mjs:543 stale)
    • R4 P1 (rollout window mechanics wrong for weekly users)
    • R5 P2 (stale Resolved shorthand, U3-alone-is-safe overclaim, T11 fixture didn't test what it claimed)

🤖 Generated with Claude Code

koala73 added 3 commits May 17, 2026 18:11
…nup (U1)

May 17 brief shipped 8/8 threads cards tagged 'General' — the upstream
EventCategory classifier in parseRssXml has been computing meaningful
values (conflict|health|diplomatic|…) all along, but
buildStoryTrackHsetFields was discarding the field before the row hit
Redis. By the time the composer read story:track:v1 back,
filterTopStories' `|| 'General'` fallback was the only thing left.

Adds 'category' as a sibling HSET write, defensive on missing/non-string
upstream values (empty string falls through filterTopStories' 'General'
default, graceful degradation for pre-PR rows).

cache-keys.ts cleanup in the same change-block:
- Removes dead export STORY_TRACKING_TTL_S = 172800 (zero call sites)
- Corrects stale "TTL for all: 48h" docs — actual TTLs are split:
  STORY_TTL = 7d for the story:track row, DIGEST_ACCUMULATOR_TTL = 48h
  for the accumulator. The 48h-everywhere doc was leftover from before
  the split and caused the round-1 ce-doc-review TTL misreading
  (corrected in the plan; codifying here too).
- Adds 'category' (and the recent isOpinion/isFeelGood) to the
  documented HSET-fields list.

See docs/plans/2026-05-17-002-fix-persist-story-track-category-plan.md
for the full design (5 rounds of codex review + post-5 fixes).

13 tests pass; biome clean (pre-existing warning on unrelated line).
Sibling to U1: stories.push now carries `category: typeof track.category
=== 'string' ? track.category : ''`, matching the defensive shape of the
existing `description` read at the same site. The envelope reaches
filterTopStories with the meaningful category value (no more 'General'
fallback in steady state); pre-PR residue rows missing the field
gracefully degrade to 'General' via filterTopStories' existing
`|| 'General'` fallback.

Also sweeps three stale doc comments in scripts/lib/brief-compose.mjs
(:543, :585-586, :625-627) that documented story:track:v1 as not
carrying category. Post-U2 those statements are wrong; the new comments
describe the actual flow (carried + word-wise Title-Cased once in
shared/brief-filter.js at envelope build).

2 new source-textual tests in greenfield
tests/digest-buildDigest-category-passthrough.test.mjs (T6 wiring shape,
T7 wiring location — must be in stories.push, not in the opinion or
feel-good filter blocks).
…ries (U3)

Normalize once at the envelope-build site (filterTopStories' out.push)
so all three downstream consumers — threads card (brief-compose.mjs:812),
magazine story-page (brief-render.js:653), public-thread fallback
(brief-render.js:1296) — see the same Title-Case form. Fixing only the
threads card (the original plan's first instinct) would have shipped
case-inconsistency: 'Conflict' in threads, 'conflict' on story pages.

Word-wise (`\b[a-z]` regex), not first-letter-only:
- filterTopStories is shared with composeBriefForRule callers that
  pass multi-word legacy categories like 'world politics' (see :294-300
  comment). First-letter-only would corrupt those to 'World politics'.
- Word-wise handles both single-word EventCategory enum values
  ('conflict' → 'Conflict') AND multi-word legacy ('world politics' →
  'World Politics'). Idempotent on already-Capitalized inputs since
  `\b[a-z]` doesn't match uppercase.

Cap-key (pairKey) stays on RAW `category` (computed before titleCase
fires at out.push). T11 + T11b lock this structural ordering:
- T11 (behavioral): mixed-case fixtures 'conflict' + 'Conflict' with
  cap=1 → both survive (different raw keys), both display as 'Conflict'.
  Inverse outcome from a hypothetical buggy cap-on-display.
- T11b (source-textual): asserts `pairKey` computation appears BEFORE
  `titleCase(category)` in source order. Belt-and-suspenders.

10 new test cases in tests/brief-filter.test.mjs:
T9 (lowercase → Title), T10 (all 13 EventCategory enum values
parameterized), T10b (multi-word preservation), T11 + T11b (cap-key
ordering), T12 (General fallback idempotent), T13 (defensive empty /
non-string).

384 tests pass across the touched surfaces (brief-filter + brief-from-digest
+ brief-llm + brief-composer + persistence + buildDigest tests).
Pre-existing biome complexity warning on filterTopStories unchanged.
@mintlify

mintlify Bot commented May 17, 2026

Copy link
Copy Markdown
Contributor

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

Project Status Preview Updated (UTC)
WorldMonitor 🟢 Ready View Preview May 17, 2026, 5:17 PM

💡 Tip: Enable Workflows to automatically generate PRs for you.

@vercel

vercel Bot commented May 17, 2026

Copy link
Copy Markdown

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

Project Deployment Actions Updated (UTC)
worldmonitor Ready Ready Preview, Comment May 17, 2026 6:49pm

Request Review

@greptile-apps

greptile-apps Bot commented May 17, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes the root cause of all 8 threads-card entries displaying "General" by persisting the category field in the story:track:v1 Redis hash, threading it through buildDigest, and applying a word-wise titleCase once at the filterTopStories envelope-build site — a clean three-layer plumbing fix that mirrors the isFeelGood PR #3748 pattern exactly.

  • U1 adds 'category', typeof item.category === 'string' ? item.category : '' to buildStoryTrackHsetFields and removes the dead STORY_TRACKING_TTL_S export with corrected TTL documentation in cache-keys.ts.
  • U2 threads category through buildDigest's stories.push with the same defensive typeof pattern used by description, and sweeps three stale JSDoc comments in brief-compose.mjs.
  • U3 adds a word-wise titleCase helper applied at out.push only — after pairKey is computed on the canonical raw value — so all three downstream consumers receive a consistent Title-Case category while cap grouping remains case-sensitive on stored lowercase enum values.

Confidence Score: 4/5

Safe to merge. The changes are narrow plumbing additions with no schema migrations, no new async paths, and defensive typeof guards at every boundary. Pre-stamp Redis rows degrade cleanly to the existing 'General' default.

The three-layer fix is mechanical and mirrors the isFeelGood PR exactly. All changed call sites are covered by T1–T4, T6–T7, and T9–T13, with a 384-test sweep reported clean. The only comments are a minor JSDoc inaccuracy in titleCase and a fragile indexOf('});') delimiter in T7.

tests/digest-buildDigest-category-passthrough.test.mjs (T7 block-delimiter heuristic) and shared/brief-filter.js (minor JSDoc note) are worth a second glance, but neither blocks the merge.

Important Files Changed

Filename Overview
server/worldmonitor/news/v1/list-feed-digest.ts Adds 'category' field to buildStoryTrackHsetFields with correct defensive typeof guard, mirroring the isFeelGood precedent from PR #3748.
server/_shared/cache-keys.ts Removes the dead STORY_TRACKING_TTL_S export (confirmed zero call sites), updates HSET field documentation to include category, and corrects the stale "TTL for all: 48h" comment.
scripts/seed-digest-notifications.mjs Adds category passthrough in buildDigest's stories.push with the same defensive typeof track.category === 'string' pattern used by description.
shared/brief-filter.js Adds titleCase helper and applies it at out.push after pairKey is computed. Minor: JSDoc @returns {string} is inaccurate for non-string inputs and the guard comment slightly misframes the `
scripts/lib/brief-compose.mjs Documentation-only update: three stale JSDoc blocks saying story:track:v1 carries no category field are corrected.
tests/brief-filter.test.mjs Adds T9–T13 and T11+T11b covering titleCase correctness, multi-word protection, fallback idempotency, and cap-key ordering invariant.
tests/digest-buildDigest-category-passthrough.test.mjs Source-textual T6+T7 tests lock in U2 wiring. T7's indexOf('});') heuristic is fragile if nested objects are ever added to the stories.push literal.
tests/news-story-track-description-persistence.test.mts Adds T1–T4 and corrects the baseItem fixture from 'world' (not in EventCategory enum) to 'general'.

Sequence Diagram

sequenceDiagram
    participant RSS as parseRssXml
    participant HSET as buildStoryTrackHsetFields
    participant Redis as Redis story:track:v1
    participant BD as buildDigest
    participant FTS as filterTopStories
    participant Comp as brief-compose / brief-render

    RSS->>HSET: item.category (classifyByKeyword / enrichWithAiCache)
    Note over HSET: NEW: 'category', typeof item.category === 'string' ? item.category : ''
    HSET->>Redis: HSET … category 'conflict'

    Redis->>BD: "HGETALL → track.category = 'conflict'"
    Note over BD: NEW: category: typeof track.category === 'string' ? track.category : ''
    BD->>FTS: "stories[].category = 'conflict' ('' for pre-stamp residue)"

    Note over FTS: asTrimmedString(raw.category) || 'General'<br/>pairKey = source + KEY_DELIM + category (cap on raw)<br/>NEW: displayCategory = titleCase(category)
    FTS->>Comp: "envelope story.category = 'Conflict'"

    Note over Comp: threads card + story pages +<br/>public-thread stubs all see same Title-Case value
Loading

Reviews (1): Last reviewed commit: "feat(brief): word-wise titleCase on enve..." | Re-trigger Greptile

Comment thread shared/brief-filter.js Outdated
Comment on lines +126 to +130
* @param {string} v
* @returns {string}
*/
function titleCase(v) {
if (typeof v !== 'string' || v.length === 0) return v;

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 The @returns {string} JSDoc tag is inaccurate: when v is not a string or is empty, the function returns v as-is (preserving the original type). In practice the call site in filterTopStories always passes a non-empty string (after || 'General' has already fired), so this never matters at runtime — but the doc and the guard comment ("so the existing || 'General' fallback stays intact") slightly misframe the interaction since || 'General' fires before titleCase is invoked, not after.

Suggested change
* @param {string} v
* @returns {string}
*/
function titleCase(v) {
if (typeof v !== 'string' || v.length === 0) return v;
* @param {string} v
* @returns {string} the input with every word's first letter uppercased;
* non-string or empty inputs are returned unchanged (the `|| 'General'`
* fallback in `filterTopStories` already ensures a non-empty string
* reaches this helper at the normal call site).
*/
function titleCase(v) {
if (typeof v !== 'string' || v.length === 0) return v;

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in 870ab00: @paramunknown, @returnsstring | unknown to reflect the type-preserving non-string/empty branch. Reworded the guard comment to clarify that asTrimmedString(raw.category) || 'General' (line :384) fires BEFORE titleCase is invoked at out.push, so the type-preserving branch is never reached in practice — kept as defense-in-depth for future callers.

Comment on lines +59 to +61
const closeIdx = buildDigestBody.indexOf('});', storiesPushIdx);
assert.ok(closeIdx !== -1, 'stories.push must have a closing `});`');
const pushBlock = buildDigestBody.slice(storiesPushIdx, closeIdx);

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 T7 stories.push block delimiter is fragile

indexOf('});') finds the first }); after stories.push({, which coincides with the real closing delimiter only because the current object literal is flat. If a future change adds a nested call whose result ends in }); inside stories.push (e.g. a .map() or .filter() callback), pushBlock will be truncated and the category inclusion check will silently pass on a wrong slice. Consider asserting the full pattern category: typeof track.category against buildDigestBody with a positional constraint against storiesPushIdx instead.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in 870ab00: replaced indexOf('});') with a brace-depth-tracking scanner that finds the matching close at the same depth. Future nested-object refactors of stories.push (.map() callbacks etc.) will no longer truncate the search slice. Also tightened the per-line assertion from includes substring match to assert.match regex — locks the exact category: typeof track.category === 'string' ? track.category : '' defensive shape, so coercion or alt defensive patterns would fail rather than silently pass.

…ings

Three substantive findings from the 8-reviewer ce-code-review on PR #3751
that survived 5 rounds of codex review:

1. (P2) LLM cache orphan cascade: brief:llm:digest:v6, brief:llm:whymatters:v4,
   and brief:llm:description:v2 all fold `s.category` into their cache-key
   hash via hashBriefStory / hashDigestInput. Pre-PR every story carried
   'General'; post-PR carries per-story Title-Cased EventCategory. Every
   cached row was about to silently stale with no prefix bump. Bumped all
   three: digest v6→v7, whymatters v4→v5, description v2→v3.

2. (P2) Cap relaxation during pre-stamp residue rollout: residue rows
   resolve to 'General' (capital, from the `|| 'General'` fallback in
   filterTopStories) while fresh post-PR rows carry lowercase 'general'
   (from classifyByKeyword's no-match fallback). The cap-key
   `'Reuters\x1fGeneral'` vs `'Reuters\x1fgeneral'` would have produced
   different buckets → cap silently bypassed for the residue subset for
   up to 7d STORY_TTL. That's exactly the editorial-clutter failure mode
   PR #3697 was created to prevent. Fix: case-fold the cap-key with
   `category.toLowerCase()` — titleCase at out.push stays unchanged.
   Updated T11 to reflect the new semantics + added T11c critical-
   regression locking the residue/fresh-general cap-grouping behavior.

3. (P2) Missing end-to-end integration test: added e2e assertion in
   tests/brief-from-digest-stories.test.mjs covering digestStory(category)
   → composeBriefFromDigestStories → envelope.category Title-Cased. The
   3-step inferential chain (U1 lowercase write → U2 source-textual
   wiring → U3 titleCase at out.push) now has a single behavioral lock.

Also: trivial correctness fix — stale `shared/brief-filter.js:365`
reference in the HSET write comment updated to `:384` (the asTrimmedString
fallback line shifted when U3 added the titleCase helper).

386 tests pass.
@koala73

koala73 commented May 17, 2026

Copy link
Copy Markdown
Owner Author

/ce-code-review trail

Ran 8-reviewer review (correctness, testing, maintainability, project-standards, agent-native, learnings-researcher, adversarial, kieran-typescript) at `8dc087bb1..6f40264`. Adversarial caught 3 substantive findings that survived 5 rounds of pre-merge Codex review.

Applied in `e99864465`:

  1. (P2) LLM cache orphan cascade — `brief:llm:digest:v6`, `brief:llm:whymatters:v4`, `brief:llm:description:v2` all fold `s.category` into their cache-key hash via `hashBriefStory` / `hashDigestInput`. Pre-PR every story carried `'General'`; post-PR carries per-story Title-Cased EventCategory. Every cached row would have silently staled on deploy with NO prefix bump. Bumped all three: digest v6→v7, whymatters v4→v5, description v2→v3.

  2. (P2) Cap relaxation during pre-stamp residue rollout — residue rows resolve to `'General'` (capital, from `|| 'General'` fallback) while fresh post-PR rows carry lowercase `'general'` (classifyByKeyword no-match default). The cap-key `'Reuters\x1fGeneral'` vs `'Reuters\x1fgeneral'` produces different cap buckets → cap silently bypassed for residue subset for up to 7d STORY_TTL. Exact editorial-clutter failure mode PR fix(brief): derive On-The-Desk threads from the ordered story walk (F7 / PR E) #3697 was created to prevent. Fix: case-fold the cap-key with `category.toLowerCase()` — titleCase at out.push unchanged. Added T11c critical-regression test locking the residue/fresh-general cap-grouping behavior + updated T11 to reflect new case-insensitive cap semantics.

  3. (P2) Missing end-to-end integration test — added e2e assertion in `tests/brief-from-digest-stories.test.mjs` covering `digestStory(category)` → composer → `envelope.category` Title-Cased. The 3-step inferential chain (U1 lowercase write → U2 source-textual wiring → U3 titleCase at out.push) now has a single behavioral lock.

Plus 1 trivial silent fix (stale `shared/brief-filter.js:365` → `:384` line reference in HSET comment).

Deferred as follow-up issues:

Skipped:

  • A-003 (P3): T11b indexOf brittleness — true belt-and-suspenders; behavioral T11/T11c already locks the cap-key invariant.

Test posture post-fix:

386 tests pass across full touched surface (brief-filter + brief-from-digest-stories + brief-llm + brief-composer + persistence + buildDigest + neighbours). Pre-existing biome warnings unchanged.

Coverage notes:

🤖 Generated by `/ce-code-review`

1. shared/brief-filter.js:115-134 — titleCase JSDoc accuracy.
   The function returns input unchanged when v is non-string or empty
   (preserving type), not always `string`. Updated `@param` to `unknown`
   and `@returns` to `string | unknown`. Clarified the guard-comment
   framing: the `|| 'General'` fallback at :384 runs BEFORE titleCase
   is invoked at out.push, so the type-preserving branch is never
   reached at the current call site — kept as defense-in-depth for
   future callers.

2. tests/digest-buildDigest-category-passthrough.test.mjs:55-78 —
   replaced brittle `indexOf('});')` with a brace-depth-tracking
   scanner that finds the matching close at the same depth. Old
   approach would silently truncate the search slice if a future
   refactor added a nested object literal ending in `});` inside
   stories.push (e.g. a `.map()` callback) — Greptile P2 concern.
   Also tightened the per-line assertion from substring `includes` to
   regex `match` so coercion or alt defensive patterns would fail the
   test rather than silently pass on partial match.

Both nits surfaced by Greptile's review of commit 6f40264 (last
reviewed commit pre-cache-prefix-bump). Test posture: 284 tests pass
across the touched + neighboring surfaces.
@koala73
koala73 merged commit 5d43e76 into main May 17, 2026
12 checks passed
@koala73
koala73 deleted the feat/brief-persist-category branch May 17, 2026 20:38
koala73 added a commit that referenced this pull request May 18, 2026
* fix(brief): forbid weak temporal stitching in digest lead

May 17 brief shipped a lead that stapled the WHO Ebola declaration
and the Israel/Hezbollah escalation into a single sentence with
"This declaration comes as…" — two unrelated top stories joined by
an editorial connective, with no shared actor, causal link, or
geographic theatre.

DIGEST_PROSE_SYSTEM_BASE now:
- Tells the model to lead with ONE primary story and only reference
  a second when there is a substantive link (shared actor, causal
  connection, direct policy consequence, same geographic theatre).
- Carries a dedicated BANNED stitching-phrases section enumerating
  the common offenders ("this comes as", "meanwhile", "in other
  news", "elsewhere", etc.).

Cache prefix bumped v7 → v8 (brief-llm.mjs:23 comment, :962 key)
to invalidate any v7 rows that were generated against the stitching-
tolerant prompt — without the bump, stale stitched leads would
serve for the full 4h TTL after deploy.

Tests:
- New `forbids weak stitching connectives in the lead` test asserts
  the prompt contains the anti-stitching instruction, the BANNED
  section, the enumerated phrases, and the substantive-link
  allowlist.
- Bumped cache-prefix assertion v7 → v8 with an added negative
  assertion that no v7 rows are written.

Pattern reference: PR #3751 followed the same prompt-change → cache-
prefix-bump → assertion-update pattern when category persistence
landed (v6 → v7).

* test(brief): tighten anti-stitching assertion (P3 review fix)

PR #3769 review noted the regression test only checked 4 of the 10
banned phrases and scanned the entire system prompt — so a phrase
removed from the dedicated BANNED section could still pass on a
duplicate mention in the lead-field instructions.

Fix:
- Extract the BANNED stitching phrases section as a bounded substring
  (from the section header up to the next `Threads:` instruction
  bullet).
- Assert all 10 banned phrases appear inside that substring, not
  the broader system prompt. A regression that removes a phrase
  from the section now fires even if the lead instruction still
  mentions it.

102/102 brief-llm tests pass.
koala73 added a commit that referenced this pull request May 18, 2026
…gence (#3752) (#3759)

* docs(brief): document synthesis-prompt vs display category-case divergence (#3752)

Issue #3752, surfaced by /ce-code-review adversarial finding A-004 on
PR #3751: `digestStoryToSynthesisShape` feeds the LLM synthesis prompt
with the canonical lowercase EventCategory enum value (`'conflict'`,
`'health'`, …), while the envelope path goes through filterTopStories'
out.push which Title-Cases for display. Both paths read from the same
upstream `s.category`; the divergence is downstream and intentional but
was previously undocumented.

Option (3) from the issue triage — documentation-only, no code change.

Rationale: synthesis prompts benefit from the canonical lowercase
enum as semantic anchor (LLM training distribution sees category
labels as bare nouns more often than Title-Cased headings); display
surfaces benefit from human-readable Title-Case.

Two annotation sites added, cross-referencing each other:
- scripts/lib/brief-compose.mjs:digestStoryToSynthesisShape JSDoc +
  inline at the `category:` field write
- shared/brief-filter.js:filterTopStories out.push block comment

Both note "if you change the case behavior at one site, audit the
other" so a future refactor that consolidates the two paths is
deliberate rather than accidental.

Options (1)/(2) — apply titleCase at the synthesis path or upstream
at buildDigest — would have changed LLM prompt content for every
digest, every user (non-deterministic prompt drift) AND required a
cache-prefix bump (digest:v7→v8) only one day after the v6→v7 bump
that PR #3751's review-driven fixes already shipped. Doc-only resolves
the audit-trail concern without operational noise.

269 tests pass; biome clean on touched files (pre-existing complexity
warning on filterTopStories unchanged).

Closes #3752.

* docs(brief): clarify both category fallback layers in digestStoryToSynthesisShape (#3759 review)

PR-review finding on #3759: the JSDoc said pre-stamp residue rows
gracefully degrade via filterTopStories' fallback "elsewhere" — but
this function does NOT go through filterTopStories. It has its own
local guard at the category: field write (`typeof s?.category ===
'string' ? s.category : 'General'`). The original wording made the
local guard look redundant when it's actually load-bearing on the
synthesis-prompt path.

Updated the JSDoc to name BOTH fallback layers explicitly:
  1. Local guard here (synthesis-prompt path)
  2. filterTopStories' asTrimmedString || 'General' (envelope/display path)
+ explicit note that removing either guard leaves the corresponding
path exposed to residue rows on deploy.

Doc-only; no behavior change. No new tests needed.
koala73 added a commit that referenced this pull request Jul 6, 2026
…he bump-history comment (#4967 review P3)

The test claimed #4944 U4 "bumped v2->v3 alongside category persistence"
(that was PR #3751) and never seeded a v3 row — so it didn't directly
prove THIS PR's v3->v4 eviction. Now seeds v1+v2+v3 stale rows with the
correct per-era history and asserts none is served while the fresh row
lands under v4.

Claude-Session: https://claude.ai/code/session_01Xs7LsyQQJqngcqjt5oGcpx
koala73 added a commit that referenced this pull request Jul 6, 2026
…he bump-history comment (#4967 review P3)

The test claimed #4944 U4 "bumped v2->v3 alongside category persistence"
(that was PR #3751) and never seeded a v3 row — so it didn't directly
prove THIS PR's v3->v4 eviction. Now seeds v1+v2+v3 stale rows with the
correct per-era history and asserts none is served while the fresh row
lands under v4.

Claude-Session: https://claude.ai/code/session_01Xs7LsyQQJqngcqjt5oGcpx
koala73 added a commit that referenced this pull request Jul 6, 2026
…he bump-history comment (#4967 review P3)

The test claimed #4944 U4 "bumped v2->v3 alongside category persistence"
(that was PR #3751) and never seeded a v3 row — so it didn't directly
prove THIS PR's v3->v4 eviction. Now seeds v1+v2+v3 stale rows with the
correct per-era history and asserts none is served while the fresh row
lands under v4.

Claude-Session: https://claude.ai/code/session_01Xs7LsyQQJqngcqjt5oGcpx
koala73 added a commit that referenced this pull request Jul 6, 2026
…he bump-history comment (#4967 review P3)

The test claimed #4944 U4 "bumped v2->v3 alongside category persistence"
(that was PR #3751) and never seeded a v3 row — so it didn't directly
prove THIS PR's v3->v4 eviction. Now seeds v1+v2+v3 stale rows with the
correct per-era history and asserts none is served while the fresh row
lands under v4.

Claude-Session: https://claude.ai/code/session_01Xs7LsyQQJqngcqjt5oGcpx
koala73 added a commit that referenced this pull request Jul 6, 2026
…he bump-history comment (#4967 review P3)

The test claimed #4944 U4 "bumped v2->v3 alongside category persistence"
(that was PR #3751) and never seeded a v3 row — so it didn't directly
prove THIS PR's v3->v4 eviction. Now seeds v1+v2+v3 stale rows with the
correct per-era history and asserts none is served while the fresh row
lands under v4.

Claude-Session: https://claude.ai/code/session_01Xs7LsyQQJqngcqjt5oGcpx
koala73 added a commit that referenced this pull request Jul 6, 2026
…he bump-history comment (#4967 review P3)

The test claimed #4944 U4 "bumped v2->v3 alongside category persistence"
(that was PR #3751) and never seeded a v3 row — so it didn't directly
prove THIS PR's v3->v4 eviction. Now seeds v1+v2+v3 stale rows with the
correct per-era history and asserts none is served while the fresh row
lands under v4.

Claude-Session: https://claude.ai/code/session_01Xs7LsyQQJqngcqjt5oGcpx
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