Phase 1 PR2: LLM narrative generator for regional snapshots#2960
Conversation
…hase 1 PR2)
Phase 1 PR2 of the Regional Intelligence Model. Fills in the `narrative`
field that Phase 0 left as empty stubs, populating all 6 sections
(situation, balance_assessment, outlook 24h/7d/30d, watch_items) and
writing narrative_provider / narrative_model onto SnapshotMeta.
## New module: scripts/regional-snapshot/narrative.mjs
Single public entry point:
generateRegionalNarrative(region, snapshot, evidence, opts?)
-> { narrative, provider, model }
Design:
- One structured-JSON call per region (cheaper + more coherent than 6
per-section calls). ~32 calls/day across 7 regions on the 6h cadence.
- 'global' region is skipped entirely — the catch-all is too broad for
a meaningful narrative.
- Ship-empty on any failure. The generator never throws; network errors,
JSON parse failures, and all-empty LLM responses all resolve to the
canonical emptyNarrative() shape. The snapshot remains valuable without
the narrative layer and the diff engine still surfaces state changes.
- Evidence-grounded: each section's evidence_ids must be a subset of
the IDs collectEvidence() produced. Hallucinated IDs are silently
filtered by parseNarrativeJson().
- Provider chain: Groq (llama-3.3-70b-versatile) → OpenRouter
(google/gemini-2.5-flash). Mirrors seed-insights.mjs's inline-provider
pattern; Ollama skipped because Railway has no local model.
- `callLlm` is dependency-injected via opts.callLlm so unit tests can
exercise the full prompt + parser + evidence-filter chain without
touching the network.
- response_format: { type: "json_object" } constrains compatible
providers; parseNarrativeJson() tolerates prose-wrapped output via
extractFirstJsonObject() for providers that don't enforce JSON mode.
## seed-regional-snapshots.mjs compute order
Step 10 (previously an empty-stub placeholder) becomes a real LLM call.
The pipeline was reordered so regime derivation happens BEFORE narrative
generation — the prompt consumes the regime label. Final step list:
1. read sources (caller)
2. pre_meta
3. balance vector
4. actors
5. triggers
6. scenarios
7. transmissions
8. mobility (still empty; Phase 2)
9. evidence
10. snapshot_id
11. read previous + derive regime
12. build snapshot-for-prompt
13. generateRegionalNarrative (ship-empty on failure)
14. splice narrative into tentative snapshot
15. diff → trigger_reason
16. final_meta with narrative_provider / narrative_model
buildFinalMeta already accepted these fields from Phase 0 — only the
seed writer needed to pass them through.
## Tests: 23 new unit tests
- buildNarrativePrompt (7): balance/actors/regime/evidence are rendered,
empty-evidence fallback, missing optional fields don't throw.
- parseNarrativeJson (7): clean JSON, hallucinated-ID filtering, prose
extraction, all-empty invalid, garbage invalid, null/empty input,
watch_items cap.
- generateRegionalNarrative (7): success path, global-region skip (asserts
callLlm is never called), null result, garbage text, thrown error
doesn't propagate, end-to-end evidence filtering, provider name captured.
- emptyNarrative (2): shape and no-shared-state.
Full suite passes 4375/4375; typecheck + typecheck:api + biome lint clean.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Greptile SummaryThis PR adds a single LLM call per region per 6h cycle to populate the
Confidence Score: 4/5Safe to merge after addressing the try/catch gap in One P1 finding: the synchronous prompt-building code outside the try/catch breaks the explicit "never throws" contract that the caller depends on for graceful degradation. While unlikely to trigger on current production inputs, the gap is a real defect in the error boundary and the fix is a single try/catch block. scripts/regional-snapshot/narrative.mjs — specifically the ordering of Important Files Changed
Sequence DiagramsequenceDiagram
participant Seed as seed-regional-snapshots.mjs
participant Gen as generateRegionalNarrative
participant Prompt as buildNarrativePrompt
participant LLM as callLlm (Groq → OpenRouter)
participant Parser as parseNarrativeJson
participant Redis as persistSnapshot
Seed->>Seed: computeBalanceVector / scoreActors / collectEvidence
Seed->>Seed: readLatestSnapshot + buildRegimeState
Seed->>Gen: generateRegionalNarrative(region, snapshotForPrompt, evidence)
alt region.id === 'global'
Gen-->>Seed: { emptyNarrative(), provider:'', model:'' }
else non-global
Gen->>Prompt: buildNarrativePrompt(region, snapshot, evidence)
Prompt-->>Gen: { systemPrompt, userPrompt }
Gen->>LLM: callLlm(prompt)
alt LLM success
LLM-->>Gen: { text, provider, model }
Gen->>Parser: parseNarrativeJson(text, validEvidenceIds)
Parser-->>Gen: { narrative, valid }
alt valid
Gen-->>Seed: { narrative, provider, model }
else invalid / empty
Gen-->>Seed: { emptyNarrative(), provider:'', model:'' }
end
else LLM error / null
Gen-->>Seed: { emptyNarrative(), provider:'', model:'' }
end
end
Seed->>Seed: splice narrative + diffSnapshot + buildFinalMeta
Seed->>Redis: persistSnapshot(snapshot)
Reviews (1): Last reviewed commit: "feat(intelligence): LLM narrative genera..." | Re-trigger Greptile |
| const callLlm = opts.callLlm ?? callLlmDefault; | ||
| const prompt = buildNarrativePrompt(region, snapshot, evidence); | ||
| const validEvidenceIds = evidence.map((e) => e.id); |
There was a problem hiding this comment.
buildNarrativePrompt outside try/catch breaks "never-throws" contract
buildNarrativePrompt (and evidence.map) are invoked before the try/catch, so any synchronous error they raise — e.g. TypeError: Cannot read properties of undefined (reading 'toFixed') if any numeric balance field is undefined — propagates out of generateRegionalNarrative as an unhandled exception. computeSnapshot omits its own try/catch around this call specifically because the inline comment says "the narrative generator itself never throws." If the contract is violated, the region is marked FAILED instead of getting an empty narrative, which suppresses the seed-meta write and triggers health degradation after 12h.
| const callLlm = opts.callLlm ?? callLlmDefault; | |
| const prompt = buildNarrativePrompt(region, snapshot, evidence); | |
| const validEvidenceIds = evidence.map((e) => e.id); | |
| const callLlm = opts.callLlm ?? callLlmDefault; | |
| let prompt; | |
| let validEvidenceIds; | |
| try { | |
| prompt = buildNarrativePrompt(region, snapshot, evidence); | |
| validEvidenceIds = evidence.map((e) => e.id); | |
| } catch (err) { | |
| const msg = err instanceof Error ? err.message : String(err); | |
| console.warn(`[narrative] ${region.id}: prompt build threw: ${msg}`); | |
| return { narrative: emptyNarrative(), provider: '', model: '' }; | |
| } |
| const hasAnyText = | ||
| narrative.situation.text.length > 0 || | ||
| narrative.balance_assessment.text.length > 0 || | ||
| narrative.outlook_24h.text.length > 0 || | ||
| narrative.outlook_7d.text.length > 0 || | ||
| narrative.outlook_30d.text.length > 0 || | ||
| narrative.watch_items.some((w) => w.text.length > 0); | ||
|
|
||
| return { narrative, valid: hasAnyText }; |
There was a problem hiding this comment.
hasAnyText allows watch-items-only "valid" narrative
The hasAnyText validity check includes narrative.watch_items.some(w => w.text.length > 0), which means a response where all five main sections are empty but at least one watch_item has text is considered valid: true. Such a snapshot would persist with an almost entirely empty narrative (no situation, balance_assessment, or any outlook). Consider requiring at least one of the five primary sections to have text before accepting the result as valid.
Phase 1 PR3 of the Regional Intelligence Model. New premium panel that
renders a canonical RegionalSnapshot with 6 structured blocks plus the
LLM narrative sections from Phase 1 PR2.
## What landed
### New panel: RegionalIntelligenceBoard
src/components/RegionalIntelligenceBoard.ts (Panel wrapper)
src/components/regional-intelligence-board-utils.ts (pure HTML builders)
Region dropdown (7 non-global regions) → on change calls
IntelligenceServiceClient.getRegionalSnapshot → renders buildBoardHtml().
Layout (top to bottom):
- Narrative sections (situation, balance_assessment, outlook 24h/7d/30d)
— each section hidden when its text is empty, evidence IDs shown as pills
- Regime block — current label, previous label, transition driver
- Balance Vector — 4 pressure axes + 3 buffer axes with horizontal bars,
plus a centered net_balance bar
- Actors — top 5 by leverage_score with role, domains, and colored delta
- Scenarios — 3 horizon columns (24h/7d/30d) × 4 lanes (base/escalation/
containment/fragmentation), sorted by probability within each horizon
- Transmission Paths — top 5 by confidence with mechanism, corridor,
severity, and latency
- Watchlist — active triggers + narrative watch_items
- Meta footer — generated timestamp, confidence, scoring/geo versions,
narrative provider/model
### Pure builders split for test isolation
All HTML builders live in regional-intelligence-board-utils.ts. The Panel
class in RegionalIntelligenceBoard.ts is a thin wrapper that calls them
and inserts the result via setContent. This split matches the existing
resilience-widget-utils pattern and lets node:test runners import the
builders directly without pulling in Vite-only services like
@/services/i18n (which fails with `import.meta.glob is not a function`).
### PRO gating + registration
- src/config/panels.ts: added 'regional-intelligence' to FULL_PANELS with
premium: 'locked', enabled: false, plus isPanelEntitled API-key list
- src/app/panel-layout.ts: async dynamic import mounts the panel after
the DeductionPanel block, reusing the same async-mount + gating pattern
- src/config/commands.ts: CMD+K entry with 🌍 icon and keywords
- tests/panel-config-guardrails.test.mjs: regional-intelligence added to
the allowedContexts allowlist for the ungated direct-assignment check
(the panel is intentionally premium-gated via WEB_PREMIUM_PANELS and
async-mounted, matching DeductionPanel)
## Tests — 38 new unit tests
tests/regional-intelligence-board.test.mts exercises the pure builders:
- BOARD_REGIONS (2): 7 non-global regions, correct IDs
- buildRegimeBlock (4): current label rendered, "Was:" shown on change,
hidden when unchanged, unknown fallback
- buildBalanceBlock (4): all 7 axes rendered, net_balance shown,
unavailable fallback, value clamping for >1 inputs
- buildActorsBlock (4): top-5 cap, sort by leverage_score, delta colors,
empty state
- buildScenariosBlock (4): horizon order (24h/7d/30d), percentage
rendering, in-horizon probability sort, empty state
- buildTransmissionBlock (4): content rendering, confidence sort,
top-5 cap, empty state
- buildWatchlistBlock (5): triggers + watch items, triggers-only,
watch-items-only, empty-text filtering, all-empty state
- buildNarrativeHtml (5): all sections, empty-section hiding, all-empty
returns '', undefined returns '', evidence ID pills
- buildMetaFooter (3): content, "no narrative" when provider empty,
missing-meta returns ''
- buildBoardHtml (3): all 6 block titles + footer, HTML escaping,
mostly-empty snapshot renders without throwing
## Verification
- npm run test:data: 4390/4390 pass
- npm run typecheck: clean
- npm run typecheck:api: clean
- biome lint on touched files: clean (pre-existing panel-layout.ts
complexity warning unchanged)
## Dependency on PR2
This PR renders whatever narrative the snapshot carries. Phase 1 PR2
(#2960) populates the narrative; without PR2 merged the narrative
section just stays hidden (empty sections are filtered out in
buildNarrativeHtml). The UI ships safely in either order.
Three review findings from PR #2960, all in scripts/regional-snapshot/narrative.mjs. ## P2 - provider fallback on malformed responses callLlmDefault() previously returned on the first non-empty response regardless of whether it parsed. Groq returning prose, truncated JSON, or an all-empty object would short-circuit the whole chain so OpenRouter never got a chance — the most common LLM failure mode slipped past the backup. Fix: callLlmDefault now accepts an optional `validate(text)` callback and continues to the next provider when a response fails validation. generateRegionalNarrative wires up a validator that runs parseNarrativeJson against the prompt-visible evidence set — so any provider returning prose, truncated JSON, or all-empty sections falls through to the next provider. ## P2 - evidence validator scoped to prompt-visible slice The prompt rendered only the first 15 evidence items, but the parser's valid-evidence-ID whitelist was built from the full evidence array. That weakened the grounding guarantee: the LLM could cite IDs it never actually saw and the parser would accept them. Fix: added selectPromptEvidence(evidence) helper that slices once. generateRegionalNarrative calls it, then passes the SAME slice into both buildNarrativePrompt and the validEvidenceIds set used by the parser. Removed the duplicate slice that used to live inside buildNarrativePrompt — callers are now responsible for pre-slicing. This also simplified buildNarrativePrompt's contract (no hidden cap). ## P3 - narrative_model records the actual model callLlmDefault returned the requested provider.model regardless of what the API resolved it to. Some providers (OpenRouter routing, model aliases) return a different concrete model via json.model, and the persisted narrative_model metadata was misreporting reality. Fix: return `json?.model || provider.model` so the SnapshotMeta field reflects what actually ran. ## Tests — 10 new regression tests selectPromptEvidence (3): - caps at MAX_EVIDENCE_IN_PROMPT (15) - handles non-array input - returns full array when under cap provider fallback on malformed response (4): - prose from first provider -> falls through to second - truncated JSON from first provider -> falls through to second - all-empty JSON object from first provider -> falls through - all providers malformed -> empty narrative evidence validator scope (2): - hallucinated citation to ev16 (beyond 15-item window) stripped - citations to ev0 and ev14 (at window edges) preserved narrative_model actual output (1): - json.model value flows through to result.model ## Verification - npm run test:data: 4385/4385 pass (was 4375; +10 regression tests) - npm run typecheck: clean - npm run typecheck:api: clean - biome lint on touched files: clean
* feat(intelligence): RegionalIntelligenceBoard panel UI (Phase 1 PR3)
Phase 1 PR3 of the Regional Intelligence Model. New premium panel that
renders a canonical RegionalSnapshot with 6 structured blocks plus the
LLM narrative sections from Phase 1 PR2.
## What landed
### New panel: RegionalIntelligenceBoard
src/components/RegionalIntelligenceBoard.ts (Panel wrapper)
src/components/regional-intelligence-board-utils.ts (pure HTML builders)
Region dropdown (7 non-global regions) → on change calls
IntelligenceServiceClient.getRegionalSnapshot → renders buildBoardHtml().
Layout (top to bottom):
- Narrative sections (situation, balance_assessment, outlook 24h/7d/30d)
— each section hidden when its text is empty, evidence IDs shown as pills
- Regime block — current label, previous label, transition driver
- Balance Vector — 4 pressure axes + 3 buffer axes with horizontal bars,
plus a centered net_balance bar
- Actors — top 5 by leverage_score with role, domains, and colored delta
- Scenarios — 3 horizon columns (24h/7d/30d) × 4 lanes (base/escalation/
containment/fragmentation), sorted by probability within each horizon
- Transmission Paths — top 5 by confidence with mechanism, corridor,
severity, and latency
- Watchlist — active triggers + narrative watch_items
- Meta footer — generated timestamp, confidence, scoring/geo versions,
narrative provider/model
### Pure builders split for test isolation
All HTML builders live in regional-intelligence-board-utils.ts. The Panel
class in RegionalIntelligenceBoard.ts is a thin wrapper that calls them
and inserts the result via setContent. This split matches the existing
resilience-widget-utils pattern and lets node:test runners import the
builders directly without pulling in Vite-only services like
@/services/i18n (which fails with `import.meta.glob is not a function`).
### PRO gating + registration
- src/config/panels.ts: added 'regional-intelligence' to FULL_PANELS with
premium: 'locked', enabled: false, plus isPanelEntitled API-key list
- src/app/panel-layout.ts: async dynamic import mounts the panel after
the DeductionPanel block, reusing the same async-mount + gating pattern
- src/config/commands.ts: CMD+K entry with 🌍 icon and keywords
- tests/panel-config-guardrails.test.mjs: regional-intelligence added to
the allowedContexts allowlist for the ungated direct-assignment check
(the panel is intentionally premium-gated via WEB_PREMIUM_PANELS and
async-mounted, matching DeductionPanel)
## Tests — 38 new unit tests
tests/regional-intelligence-board.test.mts exercises the pure builders:
- BOARD_REGIONS (2): 7 non-global regions, correct IDs
- buildRegimeBlock (4): current label rendered, "Was:" shown on change,
hidden when unchanged, unknown fallback
- buildBalanceBlock (4): all 7 axes rendered, net_balance shown,
unavailable fallback, value clamping for >1 inputs
- buildActorsBlock (4): top-5 cap, sort by leverage_score, delta colors,
empty state
- buildScenariosBlock (4): horizon order (24h/7d/30d), percentage
rendering, in-horizon probability sort, empty state
- buildTransmissionBlock (4): content rendering, confidence sort,
top-5 cap, empty state
- buildWatchlistBlock (5): triggers + watch items, triggers-only,
watch-items-only, empty-text filtering, all-empty state
- buildNarrativeHtml (5): all sections, empty-section hiding, all-empty
returns '', undefined returns '', evidence ID pills
- buildMetaFooter (3): content, "no narrative" when provider empty,
missing-meta returns ''
- buildBoardHtml (3): all 6 block titles + footer, HTML escaping,
mostly-empty snapshot renders without throwing
## Verification
- npm run test:data: 4390/4390 pass
- npm run typecheck: clean
- npm run typecheck:api: clean
- biome lint on touched files: clean (pre-existing panel-layout.ts
complexity warning unchanged)
## Dependency on PR2
This PR renders whatever narrative the snapshot carries. Phase 1 PR2
(#2960) populates the narrative; without PR2 merged the narrative
section just stays hidden (empty sections are filtered out in
buildNarrativeHtml). The UI ships safely in either order.
* fix(intelligence): request-sequence cancellation in RegionalIntelligenceBoard (PR #2963 review)
P2 review finding on PR #2963. loadCurrent() used a naive `loading`
boolean that DROPPED any region change while a fetch was in flight, so
a fast dropdown switch would leave the panel rendering the previous
region indefinitely until the user changed it a third time.
Fix: replaced the boolean with a monotonic `latestSequence` counter.
Each load claims a sequence before awaiting the RPC and only renders
its response when mySequence still matches latestSequence on return.
Earlier in-flight responses are silently discarded. Latest selection
always wins.
## Pure arbitrator helper
Added isLatestSequence(mySequence, latestSequence) to
regional-intelligence-board-utils.ts. The helper is trivially pure,
but exporting it makes the arbitration semantics testable without
instantiating the Panel class (which can't be imported by node:test
due to import.meta.glob in @/services/i18n — see the
feedback_panel_utils_split_for_node_test memory).
## Tests — 7 new regression tests
isLatestSequence (3):
- matching sequences return true
- newer sequences return false
- defensive: mine > latest also returns false
loadCurrent race simulation (4): each test mimics the real
loadCurrent() sequence-claim-and-arbitrate flow with controllable
deferred promises so the resolution order can be exercised directly:
- earlier load resolves AFTER the later one → discarded
- earlier load resolves BEFORE the later one → still discarded
- three rapid switches, scrambled resolution order → only last renders
- single load (no race) still renders normally
## Verification
- npx tsx --test tests/regional-intelligence-board.test.mts: 45/45 pass
- npm run test:data: 4393/4393 pass
- npm run typecheck: clean
- biome lint on touched files: clean
Phase 3 PR2 of the Regional Intelligence Model. Adds LLM-powered weekly intelligence briefs per region, completing the core feature set. ## New seeder: scripts/seed-regional-briefs.mjs Standalone weekly cron script (not part of the 6h derived-signals bundle). For each non-global region: 1. Read the latest snapshot via two-hop Redis read 2. Read recent regime transitions from the history log (#2981) 3. Call the LLM once per region with regime trajectory + balance + triggers + narrative context 4. Write structured brief to intelligence:regional-briefs:v1:weekly:{region} with 8-day TTL (survives one missed weekly run) Reuses the same injectable-callLlm + parse-validation + provider-chain pattern from narrative.mjs and weekly-brief.mjs. ## New module: scripts/regional-snapshot/weekly-brief.mjs generateWeeklyBrief(region, snapshot, transitions, opts?) -> { region_id, generated_at, period_start, period_end, situation_recap, regime_trajectory, key_developments[], risk_outlook, provider, model } buildBriefPrompt() — pure prompt builder parseBriefJson() — JSON parser with prose-extraction fallback emptyBrief() — canonical empty shape Global region is skipped. Provider chain: Groq -> OpenRouter. Validate callback ensures only parseable responses pass (narrative.mjs PR #2960 review fix pattern). ## Proto + RPC: GetRegionalBrief proto/worldmonitor/intelligence/v1/get_regional_brief.proto - GetRegionalBriefRequest { region_id } - GetRegionalBriefResponse { brief: RegionalBrief } - RegionalBrief { region_id, generated_at, period_start, period_end, situation_recap, regime_trajectory, key_developments[], risk_outlook, provider, model } ## Server handler server/worldmonitor/intelligence/v1/get-regional-brief.ts Simple getCachedJson read + adaptBrief snake->camel adapter. Returns upstreamUnavailable: true on Redis failure so the gateway skips caching (matching the get-regime-history pattern from #2981). ## Premium gating + cache tier src/shared/premium-paths.ts + server/gateway.ts RPC_CACHE_TIER ## Tests — 27 new unit tests buildBriefPrompt (5): region/balance/transitions/narrative rendered, empty transitions handled, missing fields tolerated parseBriefJson (5): valid JSON, garbage, all-empty, cap at 5, prose extraction generateWeeklyBrief (6): success, global skip, LLM fail, garbage, exception, period_start/end delta emptyBrief (2): region_id + empty fields handler (4): key prefix, adapter export, upstreamUnavailable, registration security (2): premium path + cache tier proto (3): RPC declared, import wired, RegionalBrief fields ## Verification - npm run test:data: 4651/4651 pass - npm run typecheck + typecheck:api: clean - biome lint: clean
* feat(intelligence): weekly regional briefs (Phase 3 PR2) Phase 3 PR2 of the Regional Intelligence Model. Adds LLM-powered weekly intelligence briefs per region, completing the core feature set. ## New seeder: scripts/seed-regional-briefs.mjs Standalone weekly cron script (not part of the 6h derived-signals bundle). For each non-global region: 1. Read the latest snapshot via two-hop Redis read 2. Read recent regime transitions from the history log (#2981) 3. Call the LLM once per region with regime trajectory + balance + triggers + narrative context 4. Write structured brief to intelligence:regional-briefs:v1:weekly:{region} with 8-day TTL (survives one missed weekly run) Reuses the same injectable-callLlm + parse-validation + provider-chain pattern from narrative.mjs and weekly-brief.mjs. ## New module: scripts/regional-snapshot/weekly-brief.mjs generateWeeklyBrief(region, snapshot, transitions, opts?) -> { region_id, generated_at, period_start, period_end, situation_recap, regime_trajectory, key_developments[], risk_outlook, provider, model } buildBriefPrompt() — pure prompt builder parseBriefJson() — JSON parser with prose-extraction fallback emptyBrief() — canonical empty shape Global region is skipped. Provider chain: Groq -> OpenRouter. Validate callback ensures only parseable responses pass (narrative.mjs PR #2960 review fix pattern). ## Proto + RPC: GetRegionalBrief proto/worldmonitor/intelligence/v1/get_regional_brief.proto - GetRegionalBriefRequest { region_id } - GetRegionalBriefResponse { brief: RegionalBrief } - RegionalBrief { region_id, generated_at, period_start, period_end, situation_recap, regime_trajectory, key_developments[], risk_outlook, provider, model } ## Server handler server/worldmonitor/intelligence/v1/get-regional-brief.ts Simple getCachedJson read + adaptBrief snake->camel adapter. Returns upstreamUnavailable: true on Redis failure so the gateway skips caching (matching the get-regime-history pattern from #2981). ## Premium gating + cache tier src/shared/premium-paths.ts + server/gateway.ts RPC_CACHE_TIER ## Tests — 27 new unit tests buildBriefPrompt (5): region/balance/transitions/narrative rendered, empty transitions handled, missing fields tolerated parseBriefJson (5): valid JSON, garbage, all-empty, cap at 5, prose extraction generateWeeklyBrief (6): success, global skip, LLM fail, garbage, exception, period_start/end delta emptyBrief (2): region_id + empty fields handler (4): key prefix, adapter export, upstreamUnavailable, registration security (2): premium path + cache tier proto (3): RPC declared, import wired, RegionalBrief fields ## Verification - npm run test:data: 4651/4651 pass - npm run typecheck + typecheck:api: clean - biome lint: clean * fix(intelligence): address 3 review findings on #2989 P2 #1 — no consumer surface for GetRegionalBrief Acknowledged. The consumer is the RegionalIntelligenceBoard panel, which will call GetRegionalBrief and render a weekly brief block. This wiring is Phase 3 PR3 (UI) scope — the RPC + Redis key are the delivery mechanism, not the end surface. No code change in this commit; the RPC is ready for the panel to consume. P2 #2 — readRecentTransitions collapses failure to [] readRecentTransitions returned [] on Redis/network failure, which is indistinguishable from a genuinely quiet week. The LLM then generates a brief claiming "no regime transitions" when in reality the upstream is down — fabricating false input. Fix: return null on failure. The seeder skips the region with a clear log message when transitions is null, so the brief is never written with unreliable input. Empty array [] now only means genuinely no transitions in the 7-day window. P2 #3 — parseBriefJson accepts briefs the seeder rejects parseBriefJson treated non-empty key_developments as valid even if situation_recap was empty. The seeder gate only writes when brief.situation_recap is truthy. That mismatch means the validator pass + provider-fallback logic could accept a response that the seeder then silently drops. Fix: require situation_recap in parseBriefJson for valid=true, matching the seeder gate. Now both checks agree on what constitutes a usable brief, and the provider-fallback chain correctly falls through when a provider returns a brief with developments but no recap. * fix(intelligence): TTL path-segment fix + seed-meta always-write (Greptile P1+P2 on #2989) P1 — TTL silently not applied (briefs never expire) Upstash REST ignores query-string SET options (?EX=N). The correct form is path-segment: /set/{key}/{value}/EX/{seconds}. Without this fix every brief persists indefinitely and Redis storage grows unboundedly across weekly runs. P2 — seed-meta not written when all regions skipped writeExtraKeyWithMeta was gated on generated > 0. If every region was skipped (no snapshot yet, or LLM failed), seed-meta was never written, making the seeder indistinguishable from "never ran" in health tooling. Now writes seed-meta whenever failed === 0, carrying regionsSkipped count. P2 #3 (validate gate) — already fixed in previous commit (parseBriefJson now requires situation_recap for valid=true). * fix(intelligence): register regional-briefs in health.js SEED_META + STANDALONE_KEYS (review P2 on #2989) * fix(intelligence): register regional-briefs in api/seed-health.js (review P2 on #2989) * fix(intelligence): raise brief TTL to 15 days to cover missed weekly cycle (review P2 on #2989) * fix(intelligence): distinguish missing-key from Redis-error + coverage-gated health (review P2s on #2989) P2 #1 — false upstreamUnavailable before first seed getCachedJson returns null for both "key missing" and "Redis failed", so the handler was advertising an outage for every region before the first weekly seed ran. Switched to getRawJson (throws on Redis errors) so null = genuinely missing key → clean empty 200, and thrown error = upstream failure → upstreamUnavailable: true for gateway no-store. P2 #2 — partial run hides coverage loss in health The seed-meta was written with generated count even if only 1 of 7 regions produced a brief. /api/health treats any positive recordCount as healthy, so broad regional failure was invisible to operators. Fix: recordCount is set to 0 when generated < ceil(expectedRegions/2). This makes /api/health report EMPTY_DATA for severely partial runs while still writing seed-meta (so the seeder is confirmed to have run). coverageOk flag in the summary payload lets operators drill into the exact coverage state. * fix(intelligence): tighten coverage gate to expectedRegions-1 (review P2 on #2989)
Summary
Phase 1 PR2 of the Regional Intelligence Model. Fills in the `narrative` field that Phase 0 left as empty stubs and writes `narrative_provider` / `narrative_model` onto `SnapshotMeta`. Server-only; no UI changes.
What landed
New module: `scripts/regional-snapshot/narrative.mjs`
Single entry point `generateRegionalNarrative(region, snapshot, evidence, opts?)` returning `{ narrative, provider, model }`.
`seed-regional-snapshots.mjs` compute order
Step 10 (previously an empty-stub placeholder) becomes a real LLM call. The pipeline was reordered so regime derivation happens BEFORE narrative generation — the prompt consumes the regime label. Final sequence:
`buildFinalMeta` already accepted these fields from Phase 0 — only the seed writer needed to pass them through.
Testing
23 new unit tests (`tests/regional-snapshot-narrative.test.mjs`), all running offline via the injected `callLlm`:
`buildNarrativePrompt` (7): balance/actors/regime/evidence are rendered, empty-evidence fallback, missing optional fields don't throw.
`parseNarrativeJson` (7): clean JSON, hallucinated-ID filtering, prose extraction, all-empty invalid, garbage invalid, null/empty input, `watch_items` cap.
`generateRegionalNarrative` (7): success path, global-region skip (asserts `callLlm` is never called), null result, garbage text, thrown error doesn't propagate, end-to-end evidence filtering, provider name captured.
`emptyNarrative` (2): shape and no-shared-state.
`npm run test:data`: 4375/4375 pass
`npm run typecheck` + `typecheck:api`: clean
`biome lint` on touched files: clean
What's deliberately NOT in this PR
Post-Deploy Monitoring & Validation
PR sequence