Skip to content

Phase 1 PR2: LLM narrative generator for regional snapshots#2960

Merged
koala73 merged 2 commits into
mainfrom
feat/regional-intelligence-narrative
Apr 11, 2026
Merged

Phase 1 PR2: LLM narrative generator for regional snapshots#2960
koala73 merged 2 commits into
mainfrom
feat/regional-intelligence-narrative

Conversation

@koala73

@koala73 koala73 commented Apr 11, 2026

Copy link
Copy Markdown
Owner

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 }`.

  • 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 and the diff engine still surfaces state changes regardless.
  • 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 exercise the full prompt + parser + evidence-filter chain without touching the network.
  • `response_format: { type: "json_object" }` constrains compatible providers; `parseNarrativeJson()` still 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 sequence:

  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.

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

  • RegionalIntelligenceBoard panel UI — Phase 1 PR3. Depends on this PR so populated narratives are available to render.
  • Mobility v1 population — Phase 2.
  • Weekly briefs + regime drift — Phase 3.

Post-Deploy Monitoring & Validation

  • What to monitor/search
    • Logs: Railway `derived-signals` bundle for `[narrative]` lines per region. Successful runs emit nothing; failures emit provider error + `shipping empty narrative` warn.
    • Metrics: `intelligence:snapshot-by-id:v1:*` payloads should start carrying non-empty `narrative.situation.text` within one cron cycle (~6h) post-merge.
  • Validation checks
    • `redis-cli GET intelligence:snapshot:v1:mena:latest` → fetch snapshot_id → `GET intelligence:snapshot-by-id:v1:` → inspect `narrative.situation.text.length > 0` and `meta.narrative_provider` non-empty.
    • Repeat for `east-asia`, `europe`, `sub-saharan-africa`.
    • `intelligence:snapshot:v1:global:latest` should still have `narrative.situation.text === ""` and `meta.narrative_provider === ""` (global is intentionally skipped).
  • Expected healthy behavior
    • 7 regions (all except global) have populated narratives within one cron cycle.
    • `meta.narrative_provider` ∈ `{groq, openrouter}`.
    • No `[narrative]` warn lines in Railway logs under steady state.
  • Failure signal(s) / rollback trigger
    • All 7 regions return empty narrative for >2 consecutive cycles → LLM provider outage or bad API key. Check `GROQ_API_KEY` / `OPENROUTER_API_KEY` in Railway env. No rollback needed (snapshots still persist with empty narrative).
    • Snapshot persist rate drops (regressed from current baseline). Would indicate the narrative call is throwing despite the try/catch — investigate immediately.
  • Validation window & owner
    • 12 hours post-merge (covers 2 full cron cycles). Owner: @koala73.

PR sequence

…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.
@vercel

vercel Bot commented Apr 11, 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 Apr 11, 2026 7:22pm

Request Review

@greptile-apps

greptile-apps Bot commented Apr 11, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds a single LLM call per region per 6h cycle to populate the narrative field in RegionalSnapshot, completing the Phase 1 PR2 milestone. The pipeline reordering (regime derivation before narrative generation), dependency-injected callLlm, hallucination-filtering evidence ID validation, and ship-empty-on-failure design are all well-executed.

  • P1 – "never throws" contract not fully honoured in generateRegionalNarrative: buildNarrativePrompt and evidence.map are called before the try/catch block. A synchronous error there (e.g., a TypeError from an undefined numeric balance field) would propagate out of the function rather than returning emptyNarrative(). computeSnapshot explicitly skips its own guard because "the narrative generator never throws," so a violation marks the region FAILED and suppresses the seed-meta write, triggering health degradation.

Confidence Score: 4/5

Safe to merge after addressing the try/catch gap in generateRegionalNarrative; all other findings are minor.

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 buildNarrativePrompt relative to the try/catch block in generateRegionalNarrative.

Important Files Changed

Filename Overview
scripts/regional-snapshot/narrative.mjs New LLM narrative generator; main concern is that buildNarrativePrompt is called outside the try/catch in generateRegionalNarrative, breaking the explicit "never throws" contract the caller relies on.
scripts/seed-regional-snapshots.mjs Pipeline reordering is correct (regime derivation before narrative call); narrative result is cleanly spliced in and final meta includes provider/model fields.
tests/regional-snapshot-narrative.test.mjs 23 offline tests cover the happy path, global-skip, null/garbage LLM output, thrown errors, hallucination filtering, and provider capture; no test for a synchronous throw inside buildNarrativePrompt itself (which is the gap the P1 comment addresses).

Sequence Diagram

sequenceDiagram
    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)
Loading

Reviews (1): Last reviewed commit: "feat(intelligence): LLM narrative genera..." | Re-trigger Greptile

Comment thread scripts/regional-snapshot/narrative.mjs Outdated
Comment on lines +325 to +327
const callLlm = opts.callLlm ?? callLlmDefault;
const prompt = buildNarrativePrompt(region, snapshot, evidence);
const validEvidenceIds = evidence.map((e) => e.id);

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.

P1 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.

Suggested change
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: '' };
}

Comment on lines +246 to +254
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 };

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 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.

koala73 added a commit that referenced this pull request Apr 11, 2026
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
@koala73
koala73 merged commit 1367c36 into main Apr 11, 2026
10 checks passed
@koala73
koala73 deleted the feat/regional-intelligence-narrative branch April 11, 2026 19:39
koala73 added a commit that referenced this pull request Apr 11, 2026
* 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
koala73 added a commit that referenced this pull request Apr 12, 2026
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
koala73 added a commit that referenced this pull request Apr 12, 2026
* 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)
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