mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
Phase 3 PR2: Weekly regional briefs (LLM seeder + RPC) (#2989)
* 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)
This commit is contained in:
@@ -158,6 +158,7 @@ const STANDALONE_KEYS = {
|
||||
resilienceIntervals: 'resilience:intervals:v1:US',
|
||||
sprPolicies: 'energy:spr-policies:v1',
|
||||
regionalSnapshots: 'intelligence:regional-snapshots:summary:v1',
|
||||
regionalBriefs: 'intelligence:regional-briefs:summary:v1',
|
||||
};
|
||||
|
||||
const SEED_META = {
|
||||
@@ -227,6 +228,7 @@ const SEED_META = {
|
||||
sanctionsPressure: { key: 'seed-meta:sanctions:pressure', maxStaleMin: 720 },
|
||||
crossSourceSignals: { key: 'seed-meta:intelligence:cross-source-signals', maxStaleMin: 30 }, // 15min cron; 30min = 2x interval
|
||||
regionalSnapshots: { key: 'seed-meta:intelligence:regional-snapshots', maxStaleMin: 720 }, // 6h cron via seed-bundle-derived-signals; 720min = 12h = 2x interval
|
||||
regionalBriefs: { key: 'seed-meta:intelligence:regional-briefs', maxStaleMin: 20160 }, // weekly cron; 20160min = 14 days = 2x interval
|
||||
sanctionsEntities: { key: 'seed-meta:sanctions:entities', maxStaleMin: 1440 }, // 12h cron; 1440min = 24h = 2x interval
|
||||
radiationWatch: { key: 'seed-meta:radiation:observations', maxStaleMin: 30 },
|
||||
groceryBasket: { key: 'seed-meta:economic:grocery-basket', maxStaleMin: 10080 }, // weekly seed; 10080 = 7 days
|
||||
|
||||
@@ -78,6 +78,7 @@ const SEED_DOMAINS = {
|
||||
'energy:ember': { key: 'seed-meta:energy:ember', intervalMin: 1440 }, // daily cron (0 8 * * *); intervalMin = maxStaleMin / 2 (2880 / 2)
|
||||
'energy:spr-policies': { key: 'seed-meta:energy:spr-policies', intervalMin: 288000 }, // annual static registry; intervalMin = health.js maxStaleMin / 2 (576000 / 2)
|
||||
'market:aaii-sentiment': { key: 'seed-meta:market:aaii-sentiment', intervalMin: 10080 }, // weekly cron; intervalMin = maxStaleMin / 2 (20160 / 2)
|
||||
'intelligence:regional-briefs': { key: 'seed-meta:intelligence:regional-briefs', intervalMin: 10080 }, // weekly cron; intervalMin = health.js maxStaleMin / 2 (20160 / 2)
|
||||
};
|
||||
|
||||
async function getMetaBatch(keys) {
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -878,6 +878,40 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Error'
|
||||
/api/intelligence/v1/get-regional-brief:
|
||||
get:
|
||||
tags:
|
||||
- IntelligenceService
|
||||
summary: GetRegionalBrief
|
||||
description: |-
|
||||
GetRegionalBrief returns the latest weekly intelligence brief for a region.
|
||||
Written by scripts/seed-regional-briefs.mjs on a weekly cron. Premium-gated.
|
||||
operationId: GetRegionalBrief
|
||||
parameters:
|
||||
- name: region_id
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: Successful response
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/GetRegionalBriefResponse'
|
||||
"400":
|
||||
description: Validation error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ValidationError'
|
||||
default:
|
||||
description: Error response
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Error'
|
||||
components:
|
||||
schemas:
|
||||
Error:
|
||||
@@ -3061,3 +3095,52 @@ components:
|
||||
description: |-
|
||||
RegimeTransition is a single recorded regime change moment. One of these
|
||||
lands in the log each time diffRegionalSnapshot() reports regime_changed.
|
||||
GetRegionalBriefRequest:
|
||||
type: object
|
||||
properties:
|
||||
regionId:
|
||||
type: string
|
||||
maxLength: 32
|
||||
minLength: 1
|
||||
pattern: ^[a-z][a-z0-9]*(-[a-z0-9]+)*$
|
||||
required:
|
||||
- regionId
|
||||
GetRegionalBriefResponse:
|
||||
type: object
|
||||
properties:
|
||||
brief:
|
||||
$ref: '#/components/schemas/RegionalBrief'
|
||||
RegionalBrief:
|
||||
type: object
|
||||
properties:
|
||||
regionId:
|
||||
type: string
|
||||
generatedAt:
|
||||
type: integer
|
||||
format: int64
|
||||
description: 'Warning: Values > 2^53 may lose precision in JavaScript'
|
||||
periodStart:
|
||||
type: integer
|
||||
format: int64
|
||||
description: 'Warning: Values > 2^53 may lose precision in JavaScript'
|
||||
periodEnd:
|
||||
type: integer
|
||||
format: int64
|
||||
description: 'Warning: Values > 2^53 may lose precision in JavaScript'
|
||||
situationRecap:
|
||||
type: string
|
||||
regimeTrajectory:
|
||||
type: string
|
||||
keyDevelopments:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
riskOutlook:
|
||||
type: string
|
||||
provider:
|
||||
type: string
|
||||
model:
|
||||
type: string
|
||||
description: |-
|
||||
RegionalBrief is a weekly LLM-synthesized intelligence summary for one
|
||||
region. Written by scripts/seed-regional-briefs.mjs on a weekly cron.
|
||||
|
||||
35
proto/worldmonitor/intelligence/v1/get_regional_brief.proto
Normal file
35
proto/worldmonitor/intelligence/v1/get_regional_brief.proto
Normal file
@@ -0,0 +1,35 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package worldmonitor.intelligence.v1;
|
||||
|
||||
import "buf/validate/validate.proto";
|
||||
import "sebuf/http/annotations.proto";
|
||||
|
||||
message GetRegionalBriefRequest {
|
||||
string region_id = 1 [
|
||||
(buf.validate.field).required = true,
|
||||
(buf.validate.field).string.min_len = 1,
|
||||
(buf.validate.field).string.max_len = 32,
|
||||
(buf.validate.field).string.pattern = "^[a-z][a-z0-9]*(-[a-z0-9]+)*$",
|
||||
(sebuf.http.query) = {name: "region_id"}
|
||||
];
|
||||
}
|
||||
|
||||
message GetRegionalBriefResponse {
|
||||
RegionalBrief brief = 1;
|
||||
}
|
||||
|
||||
// RegionalBrief is a weekly LLM-synthesized intelligence summary for one
|
||||
// region. Written by scripts/seed-regional-briefs.mjs on a weekly cron.
|
||||
message RegionalBrief {
|
||||
string region_id = 1;
|
||||
int64 generated_at = 2 [(sebuf.http.int64_encoding) = INT64_ENCODING_NUMBER];
|
||||
int64 period_start = 3 [(sebuf.http.int64_encoding) = INT64_ENCODING_NUMBER];
|
||||
int64 period_end = 4 [(sebuf.http.int64_encoding) = INT64_ENCODING_NUMBER];
|
||||
string situation_recap = 5;
|
||||
string regime_trajectory = 6;
|
||||
repeated string key_developments = 7;
|
||||
string risk_outlook = 8;
|
||||
string provider = 9;
|
||||
string model = 10;
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import "worldmonitor/intelligence/v1/compute_energy_shock.proto";
|
||||
import "worldmonitor/intelligence/v1/get_country_port_activity.proto";
|
||||
import "worldmonitor/intelligence/v1/get_regional_snapshot.proto";
|
||||
import "worldmonitor/intelligence/v1/get_regime_history.proto";
|
||||
import "worldmonitor/intelligence/v1/get_regional_brief.proto";
|
||||
|
||||
// IntelligenceService provides APIs for technical and strategic intelligence.
|
||||
service IntelligenceService {
|
||||
@@ -187,4 +188,10 @@ service IntelligenceService {
|
||||
rpc GetRegimeHistory(GetRegimeHistoryRequest) returns (GetRegimeHistoryResponse) {
|
||||
option (sebuf.http.config) = {path: "/get-regime-history", method: HTTP_METHOD_GET};
|
||||
}
|
||||
|
||||
// GetRegionalBrief returns the latest weekly intelligence brief for a region.
|
||||
// Written by scripts/seed-regional-briefs.mjs on a weekly cron. Premium-gated.
|
||||
rpc GetRegionalBrief(GetRegionalBriefRequest) returns (GetRegionalBriefResponse) {
|
||||
option (sebuf.http.config) = {path: "/get-regional-brief", method: HTTP_METHOD_GET};
|
||||
}
|
||||
}
|
||||
|
||||
309
scripts/regional-snapshot/weekly-brief.mjs
Normal file
309
scripts/regional-snapshot/weekly-brief.mjs
Normal file
@@ -0,0 +1,309 @@
|
||||
// @ts-check
|
||||
// Weekly regional brief generator. Phase 3 PR2.
|
||||
//
|
||||
// One structured-JSON LLM call per region per week. Reads the latest
|
||||
// snapshot + recent regime transitions and synthesizes a ~500-word brief.
|
||||
//
|
||||
// Output shape (persisted to Redis as JSON):
|
||||
//
|
||||
// { region_id, generated_at, period_start, period_end,
|
||||
// situation_recap, regime_trajectory, key_developments: string[],
|
||||
// risk_outlook, provider, model }
|
||||
//
|
||||
// Same provider chain + injectable-callLlm pattern as narrative.mjs.
|
||||
|
||||
import { extractFirstJsonObject, cleanJsonText } from '../_llm-json.mjs';
|
||||
|
||||
const CHROME_UA = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
|
||||
|
||||
const BRIEF_MAX_TOKENS = 1200;
|
||||
const BRIEF_TEMPERATURE = 0.3;
|
||||
const MAX_TRANSITIONS_IN_PROMPT = 10;
|
||||
const MAX_KEY_DEVELOPMENTS = 5;
|
||||
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
const DEFAULT_PROVIDERS = [
|
||||
{
|
||||
name: 'groq',
|
||||
envKey: 'GROQ_API_KEY',
|
||||
apiUrl: 'https://api.groq.com/openai/v1/chat/completions',
|
||||
model: 'llama-3.3-70b-versatile',
|
||||
timeout: 25_000,
|
||||
headers: (key) => ({
|
||||
Authorization: `Bearer ${key}`,
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': CHROME_UA,
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: 'openrouter',
|
||||
envKey: 'OPENROUTER_API_KEY',
|
||||
apiUrl: 'https://openrouter.ai/api/v1/chat/completions',
|
||||
model: 'google/gemini-2.5-flash',
|
||||
timeout: 35_000,
|
||||
headers: (key) => ({
|
||||
Authorization: `Bearer ${key}`,
|
||||
'Content-Type': 'application/json',
|
||||
'HTTP-Referer': 'https://worldmonitor.app',
|
||||
'X-Title': 'World Monitor',
|
||||
'User-Agent': CHROME_UA,
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Canonical empty brief. Matches the persisted shape.
|
||||
* @param {string} regionId
|
||||
* @returns {object}
|
||||
*/
|
||||
export function emptyBrief(regionId) {
|
||||
return {
|
||||
region_id: regionId,
|
||||
generated_at: 0,
|
||||
period_start: 0,
|
||||
period_end: 0,
|
||||
situation_recap: '',
|
||||
regime_trajectory: '',
|
||||
key_developments: [],
|
||||
risk_outlook: '',
|
||||
provider: '',
|
||||
model: '',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the prompt for the weekly brief. Pure.
|
||||
*
|
||||
* @param {{id: string, label: string}} region
|
||||
* @param {object} snapshot - latest RegionalSnapshot
|
||||
* @param {object[]} transitions - regime history entries (newest first)
|
||||
* @returns {{ systemPrompt: string, userPrompt: string }}
|
||||
*/
|
||||
export function buildBriefPrompt(region, snapshot, transitions) {
|
||||
const balance = snapshot?.balance ?? {};
|
||||
const balanceLine = [
|
||||
`coercive=${num(balance.coercive_pressure)}`,
|
||||
`fragility=${num(balance.domestic_fragility)}`,
|
||||
`capital=${num(balance.capital_stress)}`,
|
||||
`energy_vuln=${num(balance.energy_vulnerability)}`,
|
||||
`alliance=${num(balance.alliance_cohesion)}`,
|
||||
`maritime=${num(balance.maritime_access)}`,
|
||||
`energy_lev=${num(balance.energy_leverage)}`,
|
||||
`net=${num(balance.net_balance)}`,
|
||||
].join(' ');
|
||||
|
||||
const regimeLabel = snapshot?.regime?.label ?? 'unknown';
|
||||
const activeTriggers = (snapshot?.triggers?.active ?? [])
|
||||
.map((t) => t.id || t.description)
|
||||
.filter(Boolean)
|
||||
.join(', ');
|
||||
|
||||
const transitionLines = (transitions ?? [])
|
||||
.slice(0, MAX_TRANSITIONS_IN_PROMPT)
|
||||
.map((t) => {
|
||||
const date = t.transitioned_at
|
||||
? new Date(t.transitioned_at).toISOString().split('T')[0]
|
||||
: '?';
|
||||
return `- ${date}: ${t.previous_label || 'none'} → ${t.label}${t.transition_driver ? ` (${t.transition_driver})` : ''}`;
|
||||
});
|
||||
const transitionBlock = transitionLines.length > 0
|
||||
? transitionLines.join('\n')
|
||||
: '(no regime transitions in the past 7 days)';
|
||||
|
||||
const narrativeSituation = snapshot?.narrative?.situation?.text ?? '';
|
||||
const narrativeOutlook7d = snapshot?.narrative?.outlook_7d?.text ?? '';
|
||||
|
||||
const systemPrompt = [
|
||||
`You are a senior geopolitical analyst producing a weekly intelligence brief.`,
|
||||
`Today is ${new Date().toISOString().split('T')[0]}.`,
|
||||
``,
|
||||
`HARD RULES:`,
|
||||
`- Output ONLY a single JSON object matching the schema below.`,
|
||||
`- situation_recap: 2-3 sentences summarizing the week's developments.`,
|
||||
`- regime_trajectory: 1 sentence describing how the regime label evolved (stable, shifted, oscillated).`,
|
||||
`- key_developments: up to ${MAX_KEY_DEVELOPMENTS} bullet strings, each under 100 chars. Most impactful first.`,
|
||||
`- risk_outlook: 1-2 sentences on what to watch in the coming week.`,
|
||||
`- Neutral, analytical tone. No dramatization, no policy prescriptions.`,
|
||||
`- Ground claims in the data provided. Do not speculate beyond it.`,
|
||||
``,
|
||||
`SCHEMA:`,
|
||||
`{`,
|
||||
` "situation_recap": "...",`,
|
||||
` "regime_trajectory": "...",`,
|
||||
` "key_developments": ["...", "..."],`,
|
||||
` "risk_outlook": "..."`,
|
||||
`}`,
|
||||
].join('\n');
|
||||
|
||||
const userPrompt = [
|
||||
`REGION: ${region.label} (${region.id})`,
|
||||
``,
|
||||
`CURRENT REGIME: ${regimeLabel}`,
|
||||
`BALANCE: ${balanceLine}`,
|
||||
`ACTIVE TRIGGERS: ${activeTriggers || '(none)'}`,
|
||||
``,
|
||||
`REGIME TRANSITIONS (last 7 days):`,
|
||||
transitionBlock,
|
||||
``,
|
||||
narrativeSituation ? `LATEST SITUATION NARRATIVE: ${narrativeSituation}` : '',
|
||||
narrativeOutlook7d ? `LATEST 7d OUTLOOK: ${narrativeOutlook7d}` : '',
|
||||
``,
|
||||
`Produce the JSON object now.`,
|
||||
].filter(Boolean).join('\n');
|
||||
|
||||
return { systemPrompt, userPrompt };
|
||||
}
|
||||
|
||||
function num(v) {
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? n.toFixed(2) : '0.00';
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the LLM JSON response into the brief fields.
|
||||
*
|
||||
* @param {string} text
|
||||
* @returns {{ brief: { situation_recap: string, regime_trajectory: string, key_developments: string[], risk_outlook: string }, valid: boolean }}
|
||||
*/
|
||||
export function parseBriefJson(text) {
|
||||
const empty = { situation_recap: '', regime_trajectory: '', key_developments: [], risk_outlook: '' };
|
||||
if (!text || typeof text !== 'string') return { brief: empty, valid: false };
|
||||
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(cleanJsonText(text));
|
||||
} catch {
|
||||
const extracted = extractFirstJsonObject(text);
|
||||
if (!extracted) return { brief: empty, valid: false };
|
||||
try {
|
||||
parsed = JSON.parse(extracted);
|
||||
} catch {
|
||||
return { brief: empty, valid: false };
|
||||
}
|
||||
}
|
||||
|
||||
if (!parsed || typeof parsed !== 'object') return { brief: empty, valid: false };
|
||||
|
||||
const p = /** @type {Record<string, unknown>} */ (parsed);
|
||||
const situation_recap = typeof p.situation_recap === 'string' ? p.situation_recap.trim() : '';
|
||||
const regime_trajectory = typeof p.regime_trajectory === 'string' ? p.regime_trajectory.trim() : '';
|
||||
const key_developments = Array.isArray(p.key_developments)
|
||||
? p.key_developments.filter((d) => typeof d === 'string' && d.trim().length > 0).slice(0, MAX_KEY_DEVELOPMENTS).map((d) => String(d).trim())
|
||||
: [];
|
||||
const risk_outlook = typeof p.risk_outlook === 'string' ? p.risk_outlook.trim() : '';
|
||||
|
||||
// Require situation_recap to be non-empty — this aligns with the seeder's
|
||||
// gate (which checks brief.situation_recap before writing). Without this,
|
||||
// a brief with only key_developments would pass parseBriefJson but be
|
||||
// silently dropped by the seeder, creating a mismatch. PR #2989 review.
|
||||
const valid = situation_recap.length > 0;
|
||||
return {
|
||||
brief: { situation_recap, regime_trajectory, key_developments, risk_outlook },
|
||||
valid,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Default provider-chain caller. Same pattern as narrative.mjs.
|
||||
*
|
||||
* @param {{ systemPrompt: string, userPrompt: string }} prompt
|
||||
* @param {{ validate?: (text: string) => boolean }} [opts]
|
||||
* @returns {Promise<{ text: string, provider: string, model: string } | null>}
|
||||
*/
|
||||
async function callLlmDefault({ systemPrompt, userPrompt }, opts = {}) {
|
||||
const validate = opts.validate;
|
||||
for (const provider of DEFAULT_PROVIDERS) {
|
||||
const envVal = process.env[provider.envKey];
|
||||
if (!envVal) continue;
|
||||
try {
|
||||
const resp = await fetch(provider.apiUrl, {
|
||||
method: 'POST',
|
||||
headers: provider.headers(envVal),
|
||||
body: JSON.stringify({
|
||||
model: provider.model,
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{ role: 'user', content: userPrompt },
|
||||
],
|
||||
max_tokens: BRIEF_MAX_TOKENS,
|
||||
temperature: BRIEF_TEMPERATURE,
|
||||
response_format: { type: 'json_object' },
|
||||
}),
|
||||
signal: AbortSignal.timeout(provider.timeout),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
console.warn(`[weekly-brief] ${provider.name}: HTTP ${resp.status}`);
|
||||
continue;
|
||||
}
|
||||
const json = /** @type {any} */ (await resp.json());
|
||||
const text = json?.choices?.[0]?.message?.content;
|
||||
if (typeof text !== 'string' || text.trim().length === 0) {
|
||||
console.warn(`[weekly-brief] ${provider.name}: empty response`);
|
||||
continue;
|
||||
}
|
||||
const trimmed = text.trim();
|
||||
if (validate && !validate(trimmed)) {
|
||||
console.warn(`[weekly-brief] ${provider.name}: response failed validation, trying next`);
|
||||
continue;
|
||||
}
|
||||
const actualModel = typeof json?.model === 'string' && json.model.length > 0
|
||||
? json.model
|
||||
: provider.model;
|
||||
return { text: trimmed, provider: provider.name, model: actualModel };
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.warn(`[weekly-brief] ${provider.name}: ${msg}`);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a weekly brief for one region.
|
||||
*
|
||||
* @param {{id: string, label: string}} region
|
||||
* @param {object} snapshot - latest RegionalSnapshot (snake_case persisted shape)
|
||||
* @param {object[]} transitions - recent regime history entries (newest first)
|
||||
* @param {{ callLlm?: (prompt: { systemPrompt: string, userPrompt: string }, opts?: { validate?: (text: string) => boolean }) => Promise<{ text: string, provider: string, model: string } | null> }} [opts]
|
||||
* @returns {Promise<object>} The brief object ready for Redis persist.
|
||||
*/
|
||||
export async function generateWeeklyBrief(region, snapshot, transitions, opts = {}) {
|
||||
if (!region || region.id === 'global') {
|
||||
return emptyBrief(region?.id ?? 'global');
|
||||
}
|
||||
|
||||
const callLlm = opts.callLlm ?? callLlmDefault;
|
||||
const prompt = buildBriefPrompt(region, snapshot, transitions);
|
||||
const validate = (text) => parseBriefJson(text).valid;
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = await callLlm(prompt, { validate });
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.warn(`[weekly-brief] ${region.id}: callLlm threw: ${msg}`);
|
||||
return emptyBrief(region.id);
|
||||
}
|
||||
|
||||
if (!result) {
|
||||
console.warn(`[weekly-brief] ${region.id}: all providers failed, shipping empty brief`);
|
||||
return emptyBrief(region.id);
|
||||
}
|
||||
|
||||
const { brief, valid } = parseBriefJson(result.text);
|
||||
if (!valid) {
|
||||
console.warn(`[weekly-brief] ${region.id}: parse invalid, shipping empty brief`);
|
||||
return emptyBrief(region.id);
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
return {
|
||||
region_id: region.id,
|
||||
generated_at: now,
|
||||
period_start: now - SEVEN_DAYS_MS,
|
||||
period_end: now,
|
||||
...brief,
|
||||
provider: result.provider,
|
||||
model: result.model,
|
||||
};
|
||||
}
|
||||
212
scripts/seed-regional-briefs.mjs
Normal file
212
scripts/seed-regional-briefs.mjs
Normal file
@@ -0,0 +1,212 @@
|
||||
#!/usr/bin/env node
|
||||
// @ts-check
|
||||
/**
|
||||
* Weekly Regional Intelligence brief seeder. Phase 3 PR2.
|
||||
*
|
||||
* Reads the latest snapshot + regime history per region, calls the LLM to
|
||||
* synthesize a weekly brief, and writes to Redis. Designed to run on a
|
||||
* weekly Railway cron (e.g. Sunday 00:00 UTC) or manually via:
|
||||
*
|
||||
* node scripts/seed-regional-briefs.mjs
|
||||
*
|
||||
* Does NOT run as part of the 6h derived-signals bundle — briefs are
|
||||
* weekly, not per-snapshot.
|
||||
*/
|
||||
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
import { loadEnvFile, getRedisCredentials, writeExtraKeyWithMeta } from './_seed-utils.mjs';
|
||||
import { REGIONS } from './shared/geography.js';
|
||||
import { generateWeeklyBrief } from './regional-snapshot/weekly-brief.mjs';
|
||||
|
||||
loadEnvFile(import.meta.url);
|
||||
|
||||
const BRIEF_KEY_PREFIX = 'intelligence:regional-briefs:v1:weekly:';
|
||||
const BRIEF_TTL = 15 * 24 * 60 * 60; // 15 days — survives one full missed weekly cycle within the 14-day health budget
|
||||
const SEED_META_KEY = 'intelligence:regional-briefs';
|
||||
const REGIME_HISTORY_KEY_PREFIX = 'intelligence:regime-history:v1:';
|
||||
const SNAPSHOT_LATEST_KEY_PREFIX = 'intelligence:snapshot:v1:';
|
||||
const SNAPSHOT_BY_ID_KEY_PREFIX = 'intelligence:snapshot-by-id:v1:';
|
||||
|
||||
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Read the latest snapshot for a region from Redis.
|
||||
* @param {string} url
|
||||
* @param {string} token
|
||||
* @param {string} regionId
|
||||
* @returns {Promise<object | null>}
|
||||
*/
|
||||
async function readLatestSnapshot(url, token, regionId) {
|
||||
try {
|
||||
const latestKey = `${SNAPSHOT_LATEST_KEY_PREFIX}${regionId}:latest`;
|
||||
const latestResp = await fetch(`${url}/get/${encodeURIComponent(latestKey)}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
signal: AbortSignal.timeout(5_000),
|
||||
});
|
||||
if (!latestResp.ok) return null;
|
||||
const latestData = await latestResp.json();
|
||||
let snapshotId = latestData?.result;
|
||||
if (!snapshotId) return null;
|
||||
if (typeof snapshotId === 'string') {
|
||||
try { snapshotId = JSON.parse(snapshotId); } catch { /* bare string is fine */ }
|
||||
}
|
||||
if (typeof snapshotId === 'object' && snapshotId?.snapshot_id) {
|
||||
snapshotId = snapshotId.snapshot_id;
|
||||
}
|
||||
if (typeof snapshotId !== 'string') return null;
|
||||
|
||||
const snapKey = `${SNAPSHOT_BY_ID_KEY_PREFIX}${snapshotId}`;
|
||||
const snapResp = await fetch(`${url}/get/${encodeURIComponent(snapKey)}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
signal: AbortSignal.timeout(5_000),
|
||||
});
|
||||
if (!snapResp.ok) return null;
|
||||
const snapData = await snapResp.json();
|
||||
return snapData?.result ? JSON.parse(snapData.result) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the regime history for a region (last 7 days).
|
||||
* Returns null on Redis/network failure so the caller can distinguish a
|
||||
* genuinely quiet week (empty array) from a broken upstream (null).
|
||||
* PR #2989 review: collapsing failure to [] would fabricate a false
|
||||
* "no transitions" history for the LLM prompt.
|
||||
*
|
||||
* @param {string} url
|
||||
* @param {string} token
|
||||
* @param {string} regionId
|
||||
* @returns {Promise<object[] | null>} null = upstream failure, [] = genuinely no transitions
|
||||
*/
|
||||
async function readRecentTransitions(url, token, regionId) {
|
||||
try {
|
||||
const key = `${REGIME_HISTORY_KEY_PREFIX}${regionId}`;
|
||||
const resp = await fetch(`${url}/lrange/${encodeURIComponent(key)}/0/49`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
signal: AbortSignal.timeout(5_000),
|
||||
});
|
||||
if (!resp.ok) return null;
|
||||
const data = await resp.json();
|
||||
if (!Array.isArray(data?.result)) return null;
|
||||
const cutoff = Date.now() - SEVEN_DAYS_MS;
|
||||
return data.result
|
||||
.map((raw) => { try { return JSON.parse(raw); } catch { return null; } })
|
||||
.filter((t) => t && typeof t === 'object' && (t.transitioned_at ?? 0) >= cutoff);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a brief to Redis.
|
||||
* @param {string} url
|
||||
* @param {string} token
|
||||
* @param {string} regionId
|
||||
* @param {object} brief
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async function writeBrief(url, token, regionId, brief) {
|
||||
const key = `${BRIEF_KEY_PREFIX}${regionId}`;
|
||||
const payload = JSON.stringify(brief);
|
||||
try {
|
||||
// TTL via path segment, NOT query string. Upstash REST ignores query
|
||||
// params for SET options — ?EX=N would silently produce keys that
|
||||
// never expire. Greptile P1 on PR #2989.
|
||||
const resp = await fetch(`${url}/set/${encodeURIComponent(key)}/${encodeURIComponent(payload)}/EX/${BRIEF_TTL}`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
signal: AbortSignal.timeout(5_000),
|
||||
});
|
||||
return resp.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const t0 = Date.now();
|
||||
const { url, token } = getRedisCredentials();
|
||||
console.log(`[regional-briefs] Starting weekly brief generation for ${REGIONS.length} regions`);
|
||||
|
||||
let generated = 0;
|
||||
let skipped = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (const region of REGIONS) {
|
||||
if (region.id === 'global') {
|
||||
skipped += 1;
|
||||
console.log(`[${region.id}] skipped (global)`);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const snapshot = await readLatestSnapshot(url, token, region.id);
|
||||
if (!snapshot) {
|
||||
skipped += 1;
|
||||
console.log(`[${region.id}] skipped (no snapshot available)`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const transitions = await readRecentTransitions(url, token, region.id);
|
||||
if (transitions === null) {
|
||||
skipped += 1;
|
||||
console.log(`[${region.id}] skipped (regime-history Redis unavailable — cannot produce reliable brief)`);
|
||||
continue;
|
||||
}
|
||||
const brief = await generateWeeklyBrief(region, snapshot, transitions);
|
||||
|
||||
if (!brief.situation_recap) {
|
||||
skipped += 1;
|
||||
console.log(`[${region.id}] skipped (LLM returned empty brief)`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const ok = await writeBrief(url, token, region.id, brief);
|
||||
if (ok) {
|
||||
generated += 1;
|
||||
console.log(`[${region.id}] brief written (${brief.key_developments?.length ?? 0} developments, provider=${brief.provider})`);
|
||||
} else {
|
||||
failed += 1;
|
||||
console.warn(`[${region.id}] Redis write failed`);
|
||||
}
|
||||
} catch (err) {
|
||||
failed += 1;
|
||||
const msg = /** @type {any} */ (err)?.message ?? err;
|
||||
console.error(`[${region.id}] FAILED: ${msg}`);
|
||||
}
|
||||
}
|
||||
|
||||
const elapsed = ((Date.now() - t0) / 1000).toFixed(1);
|
||||
// Total non-global regions expected to generate. If generated is well
|
||||
// below this, writing seed-meta with a positive count would hide broad
|
||||
// coverage loss from /api/health (which treats any positive recordCount
|
||||
// as healthy). PR #2989 review P2.
|
||||
const expectedRegions = REGIONS.filter((r) => r.id !== 'global').length;
|
||||
const coverageOk = generated >= expectedRegions - 1; // at most 1 region can fail silently
|
||||
|
||||
// Always write seed-meta when failed===0 so health confirms the seeder
|
||||
// ran. But set recordCount to 0 when coverage is below threshold — that
|
||||
// makes /api/health report EMPTY_DATA instead of hiding partial failure.
|
||||
if (failed === 0) {
|
||||
const recordCount = coverageOk ? generated : 0;
|
||||
await writeExtraKeyWithMeta(
|
||||
`intelligence:regional-briefs:summary:v1`,
|
||||
{ generatedAt: Date.now(), regionsGenerated: generated, regionsSkipped: skipped, coverageOk },
|
||||
BRIEF_TTL,
|
||||
recordCount,
|
||||
`seed-meta:${SEED_META_KEY}`,
|
||||
BRIEF_TTL,
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`[regional-briefs] Done in ${elapsed}s: generated=${generated} skipped=${skipped} failed=${failed}`);
|
||||
if (failed > 0) process.exit(1);
|
||||
}
|
||||
|
||||
const isMain = import.meta.url === pathToFileURL(process.argv[1]).href;
|
||||
if (isMain) main().catch((err) => { console.error(err); process.exit(1); });
|
||||
|
||||
export { main, readLatestSnapshot, readRecentTransitions };
|
||||
@@ -231,6 +231,8 @@ const RPC_CACHE_TIER: Record<string, CacheTier> = {
|
||||
// entry is required by tests/route-cache-tier.test.mjs even though the
|
||||
// gateway short-circuits premium paths to slow-browser.
|
||||
'/api/intelligence/v1/get-regime-history': 'slow',
|
||||
// get-regional-brief is premium-gated; slow-browser in practice, slow entry for route-parity.
|
||||
'/api/intelligence/v1/get-regional-brief': 'slow',
|
||||
'/api/resilience/v1/get-resilience-score': 'slow',
|
||||
'/api/resilience/v1/get-resilience-ranking': 'slow',
|
||||
};
|
||||
|
||||
74
server/worldmonitor/intelligence/v1/get-regional-brief.ts
Normal file
74
server/worldmonitor/intelligence/v1/get-regional-brief.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import type {
|
||||
IntelligenceServiceHandler,
|
||||
ServerContext,
|
||||
GetRegionalBriefRequest,
|
||||
GetRegionalBriefResponse,
|
||||
RegionalBrief,
|
||||
} from '../../../../src/generated/server/worldmonitor/intelligence/v1/service_server';
|
||||
import { getRawJson } from '../../../_shared/redis';
|
||||
|
||||
const KEY_PREFIX = 'intelligence:regional-briefs:v1:weekly:';
|
||||
|
||||
interface PersistedBrief {
|
||||
region_id?: string;
|
||||
generated_at?: number;
|
||||
period_start?: number;
|
||||
period_end?: number;
|
||||
situation_recap?: string;
|
||||
regime_trajectory?: string;
|
||||
key_developments?: string[];
|
||||
risk_outlook?: string;
|
||||
provider?: string;
|
||||
model?: string;
|
||||
}
|
||||
|
||||
export function adaptBrief(raw: PersistedBrief): RegionalBrief {
|
||||
return {
|
||||
regionId: raw.region_id ?? '',
|
||||
generatedAt: typeof raw.generated_at === 'number' ? raw.generated_at : 0,
|
||||
periodStart: typeof raw.period_start === 'number' ? raw.period_start : 0,
|
||||
periodEnd: typeof raw.period_end === 'number' ? raw.period_end : 0,
|
||||
situationRecap: raw.situation_recap ?? '',
|
||||
regimeTrajectory: raw.regime_trajectory ?? '',
|
||||
keyDevelopments: Array.isArray(raw.key_developments) ? raw.key_developments.filter((d) => typeof d === 'string') : [],
|
||||
riskOutlook: raw.risk_outlook ?? '',
|
||||
provider: raw.provider ?? '',
|
||||
model: raw.model ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
export const getRegionalBrief: IntelligenceServiceHandler['getRegionalBrief'] = async (
|
||||
_ctx: ServerContext,
|
||||
req: GetRegionalBriefRequest,
|
||||
): Promise<GetRegionalBriefResponse> => {
|
||||
const regionId = req.regionId;
|
||||
if (!regionId || typeof regionId !== 'string') {
|
||||
return {};
|
||||
}
|
||||
|
||||
const key = `${KEY_PREFIX}${regionId}`;
|
||||
|
||||
// Use getRawJson (throws on Redis error) instead of getCachedJson (returns
|
||||
// null for both missing key AND Redis failure). This lets us distinguish:
|
||||
// - null return = key genuinely missing (no brief yet) → clean empty 200
|
||||
// - thrown error = Redis/network failure → upstreamUnavailable so gateway
|
||||
// skips caching the failure response
|
||||
// PR #2989 review: getCachedJson collapsed both cases into null, which
|
||||
// falsely advertised an outage before the first weekly seed ran.
|
||||
let raw: PersistedBrief | null;
|
||||
try {
|
||||
raw = await getRawJson(key) as PersistedBrief | null;
|
||||
} catch {
|
||||
return { upstreamUnavailable: true } as GetRegionalBriefResponse & { upstreamUnavailable: boolean };
|
||||
}
|
||||
|
||||
if (!raw || typeof raw !== 'object') {
|
||||
// Key genuinely missing — no brief written yet for this region.
|
||||
// Return a clean empty response (no upstreamUnavailable) so the
|
||||
// gateway can cache this as a valid "no brief" result.
|
||||
return {};
|
||||
}
|
||||
|
||||
const brief = adaptBrief(raw);
|
||||
return { brief };
|
||||
};
|
||||
@@ -24,6 +24,7 @@ import { computeEnergyShockScenario } from './compute-energy-shock';
|
||||
import { getCountryPortActivity } from './get-country-port-activity';
|
||||
import { getRegionalSnapshot } from './get-regional-snapshot';
|
||||
import { getRegimeHistory } from './get-regime-history';
|
||||
import { getRegionalBrief } from './get-regional-brief';
|
||||
|
||||
export const intelligenceHandler: IntelligenceServiceHandler = {
|
||||
getRiskScores,
|
||||
@@ -50,4 +51,5 @@ export const intelligenceHandler: IntelligenceServiceHandler = {
|
||||
getCountryPortActivity,
|
||||
getRegionalSnapshot,
|
||||
getRegimeHistory,
|
||||
getRegionalBrief,
|
||||
};
|
||||
|
||||
@@ -836,6 +836,27 @@ export interface RegimeTransition {
|
||||
snapshotId: string;
|
||||
}
|
||||
|
||||
export interface GetRegionalBriefRequest {
|
||||
regionId: string;
|
||||
}
|
||||
|
||||
export interface GetRegionalBriefResponse {
|
||||
brief?: RegionalBrief;
|
||||
}
|
||||
|
||||
export interface RegionalBrief {
|
||||
regionId: string;
|
||||
generatedAt: number;
|
||||
periodStart: number;
|
||||
periodEnd: number;
|
||||
situationRecap: string;
|
||||
regimeTrajectory: string;
|
||||
keyDevelopments: string[];
|
||||
riskOutlook: string;
|
||||
provider: string;
|
||||
model: string;
|
||||
}
|
||||
|
||||
export type SeverityLevel = "SEVERITY_LEVEL_UNSPECIFIED" | "SEVERITY_LEVEL_LOW" | "SEVERITY_LEVEL_MEDIUM" | "SEVERITY_LEVEL_HIGH";
|
||||
|
||||
export type TrendDirection = "TREND_DIRECTION_UNSPECIFIED" | "TREND_DIRECTION_RISING" | "TREND_DIRECTION_STABLE" | "TREND_DIRECTION_FALLING";
|
||||
@@ -1507,6 +1528,31 @@ export class IntelligenceServiceClient {
|
||||
return await resp.json() as GetRegimeHistoryResponse;
|
||||
}
|
||||
|
||||
async getRegionalBrief(req: GetRegionalBriefRequest, options?: IntelligenceServiceCallOptions): Promise<GetRegionalBriefResponse> {
|
||||
let path = "/api/intelligence/v1/get-regional-brief";
|
||||
const params = new URLSearchParams();
|
||||
if (req.regionId != null && req.regionId !== "") params.set("region_id", String(req.regionId));
|
||||
const url = this.baseURL + path + (params.toString() ? "?" + params.toString() : "");
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
...this.defaultHeaders,
|
||||
...options?.headers,
|
||||
};
|
||||
|
||||
const resp = await this.fetchFn(url, {
|
||||
method: "GET",
|
||||
headers,
|
||||
signal: options?.signal,
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
return this.handleError(resp);
|
||||
}
|
||||
|
||||
return await resp.json() as GetRegionalBriefResponse;
|
||||
}
|
||||
|
||||
private async handleError(resp: Response): Promise<never> {
|
||||
const body = await resp.text();
|
||||
if (resp.status === 400) {
|
||||
|
||||
@@ -836,6 +836,27 @@ export interface RegimeTransition {
|
||||
snapshotId: string;
|
||||
}
|
||||
|
||||
export interface GetRegionalBriefRequest {
|
||||
regionId: string;
|
||||
}
|
||||
|
||||
export interface GetRegionalBriefResponse {
|
||||
brief?: RegionalBrief;
|
||||
}
|
||||
|
||||
export interface RegionalBrief {
|
||||
regionId: string;
|
||||
generatedAt: number;
|
||||
periodStart: number;
|
||||
periodEnd: number;
|
||||
situationRecap: string;
|
||||
regimeTrajectory: string;
|
||||
keyDevelopments: string[];
|
||||
riskOutlook: string;
|
||||
provider: string;
|
||||
model: string;
|
||||
}
|
||||
|
||||
export type SeverityLevel = "SEVERITY_LEVEL_UNSPECIFIED" | "SEVERITY_LEVEL_LOW" | "SEVERITY_LEVEL_MEDIUM" | "SEVERITY_LEVEL_HIGH";
|
||||
|
||||
export type TrendDirection = "TREND_DIRECTION_UNSPECIFIED" | "TREND_DIRECTION_RISING" | "TREND_DIRECTION_STABLE" | "TREND_DIRECTION_FALLING";
|
||||
@@ -919,6 +940,7 @@ export interface IntelligenceServiceHandler {
|
||||
getCountryPortActivity(ctx: ServerContext, req: GetCountryPortActivityRequest): Promise<CountryPortActivityResponse>;
|
||||
getRegionalSnapshot(ctx: ServerContext, req: GetRegionalSnapshotRequest): Promise<GetRegionalSnapshotResponse>;
|
||||
getRegimeHistory(ctx: ServerContext, req: GetRegimeHistoryRequest): Promise<GetRegimeHistoryResponse>;
|
||||
getRegionalBrief(ctx: ServerContext, req: GetRegionalBriefRequest): Promise<GetRegionalBriefResponse>;
|
||||
}
|
||||
|
||||
export function createIntelligenceServiceRoutes(
|
||||
@@ -2036,6 +2058,53 @@ export function createIntelligenceServiceRoutes(
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
method: "GET",
|
||||
path: "/api/intelligence/v1/get-regional-brief",
|
||||
handler: async (req: Request): Promise<Response> => {
|
||||
try {
|
||||
const pathParams: Record<string, string> = {};
|
||||
const url = new URL(req.url, "http://localhost");
|
||||
const params = url.searchParams;
|
||||
const body: GetRegionalBriefRequest = {
|
||||
regionId: params.get("region_id") ?? "",
|
||||
};
|
||||
if (options?.validateRequest) {
|
||||
const bodyViolations = options.validateRequest("getRegionalBrief", body);
|
||||
if (bodyViolations) {
|
||||
throw new ValidationError(bodyViolations);
|
||||
}
|
||||
}
|
||||
|
||||
const ctx: ServerContext = {
|
||||
request: req,
|
||||
pathParams,
|
||||
headers: Object.fromEntries(req.headers.entries()),
|
||||
};
|
||||
|
||||
const result = await handler.getRegionalBrief(ctx, body);
|
||||
return new Response(JSON.stringify(result as GetRegionalBriefResponse), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ValidationError) {
|
||||
return new Response(JSON.stringify({ violations: err.violations }), {
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
if (options?.onError) {
|
||||
return options.onError(err, req);
|
||||
}
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return new Response(JSON.stringify({ message }), {
|
||||
status: 500,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ export const PREMIUM_RPC_PATHS = new Set<string>([
|
||||
'/api/intelligence/v1/list-market-implications',
|
||||
'/api/intelligence/v1/get-regional-snapshot',
|
||||
'/api/intelligence/v1/get-regime-history',
|
||||
'/api/intelligence/v1/get-regional-brief',
|
||||
'/api/resilience/v1/get-resilience-score',
|
||||
'/api/resilience/v1/get-resilience-ranking',
|
||||
'/api/supply-chain/v1/get-country-chokepoint-index',
|
||||
|
||||
244
tests/regional-snapshot-weekly-brief.test.mjs
Normal file
244
tests/regional-snapshot-weekly-brief.test.mjs
Normal file
@@ -0,0 +1,244 @@
|
||||
// Tests for the Regional Intelligence weekly brief generator (Phase 3 PR2).
|
||||
// Pure-function + injectable-LLM unit tests; no network. Run via:
|
||||
// npm run test:data
|
||||
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import {
|
||||
generateWeeklyBrief,
|
||||
buildBriefPrompt,
|
||||
parseBriefJson,
|
||||
emptyBrief,
|
||||
} from '../scripts/regional-snapshot/weekly-brief.mjs';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const root = resolve(__dirname, '..');
|
||||
|
||||
const handlerSrc = readFileSync(resolve(root, 'server/worldmonitor/intelligence/v1/get-regional-brief.ts'), 'utf-8');
|
||||
const handlerIndexSrc = readFileSync(resolve(root, 'server/worldmonitor/intelligence/v1/handler.ts'), 'utf-8');
|
||||
const premiumPathsSrc = readFileSync(resolve(root, 'src/shared/premium-paths.ts'), 'utf-8');
|
||||
const gatewaySrc = readFileSync(resolve(root, 'server/gateway.ts'), 'utf-8');
|
||||
const protoSrc = readFileSync(resolve(root, 'proto/worldmonitor/intelligence/v1/get_regional_brief.proto'), 'utf-8');
|
||||
const serviceProtoSrc = readFileSync(resolve(root, 'proto/worldmonitor/intelligence/v1/service.proto'), 'utf-8');
|
||||
|
||||
// ── Fixtures ────────────────────────────────────────────────────────────────
|
||||
|
||||
const mena = { id: 'mena', label: 'Middle East & North Africa' };
|
||||
const globalRegion = { id: 'global', label: 'Global' };
|
||||
|
||||
const snapshotFixture = {
|
||||
regime: { label: 'coercive_stalemate', previous_label: 'calm', transitioned_at: 0, transition_driver: 'regime_shift' },
|
||||
balance: { coercive_pressure: 0.72, domestic_fragility: 0.55, capital_stress: 0.40, energy_vulnerability: 0.30, alliance_cohesion: 0.60, maritime_access: 0.70, energy_leverage: 0.80, net_balance: 0.03 },
|
||||
triggers: { active: [{ id: 'mena_coercive_high', description: 'Coercive > 0.7' }], watching: [], dormant: [] },
|
||||
narrative: { situation: { text: 'Iran flexes near Hormuz.', evidence_ids: [] }, outlook_7d: { text: 'Escalation risk persists.', evidence_ids: [] } },
|
||||
};
|
||||
|
||||
const transitionsFixture = [
|
||||
{ region_id: 'mena', label: 'coercive_stalemate', previous_label: 'calm', transitioned_at: Date.now() - 3 * 86_400_000, transition_driver: 'regime_shift', snapshot_id: 's1' },
|
||||
];
|
||||
|
||||
const validPayload = JSON.stringify({
|
||||
situation_recap: 'Iran increased naval posture near Hormuz.',
|
||||
regime_trajectory: 'Shifted from calm to coercive stalemate mid-week.',
|
||||
key_developments: ['Hormuz transit volume dropped 15%', 'CII spike for Iran'],
|
||||
risk_outlook: 'Escalation risk remains elevated into next week.',
|
||||
});
|
||||
|
||||
// ── buildBriefPrompt ────────────────────────────────────────────────────────
|
||||
|
||||
describe('buildBriefPrompt', () => {
|
||||
it('includes region label and balance axes in the prompt', () => {
|
||||
const { userPrompt } = buildBriefPrompt(mena, snapshotFixture, transitionsFixture);
|
||||
assert.match(userPrompt, /Middle East/);
|
||||
assert.match(userPrompt, /coercive=0\.72/);
|
||||
});
|
||||
|
||||
it('includes regime transitions', () => {
|
||||
const { userPrompt } = buildBriefPrompt(mena, snapshotFixture, transitionsFixture);
|
||||
assert.match(userPrompt, /calm → coercive_stalemate/);
|
||||
});
|
||||
|
||||
it('includes narrative situation when available', () => {
|
||||
const { userPrompt } = buildBriefPrompt(mena, snapshotFixture, transitionsFixture);
|
||||
assert.match(userPrompt, /Iran flexes near Hormuz/);
|
||||
});
|
||||
|
||||
it('handles empty transitions', () => {
|
||||
const { userPrompt } = buildBriefPrompt(mena, snapshotFixture, []);
|
||||
assert.match(userPrompt, /no regime transitions/i);
|
||||
});
|
||||
|
||||
it('tolerates missing snapshot fields', () => {
|
||||
assert.doesNotThrow(() => buildBriefPrompt(mena, {}, []));
|
||||
});
|
||||
});
|
||||
|
||||
// ── parseBriefJson ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('parseBriefJson', () => {
|
||||
it('parses a valid JSON brief', () => {
|
||||
const { brief, valid } = parseBriefJson(validPayload);
|
||||
assert.equal(valid, true);
|
||||
assert.equal(brief.situation_recap, 'Iran increased naval posture near Hormuz.');
|
||||
assert.equal(brief.key_developments.length, 2);
|
||||
assert.ok(brief.risk_outlook.length > 0);
|
||||
});
|
||||
|
||||
it('returns valid=false on empty/garbage input', () => {
|
||||
assert.equal(parseBriefJson('').valid, false);
|
||||
assert.equal(parseBriefJson('not json').valid, false);
|
||||
assert.equal(parseBriefJson(null).valid, false);
|
||||
});
|
||||
|
||||
it('returns valid=false on all-empty fields', () => {
|
||||
const { valid } = parseBriefJson(JSON.stringify({
|
||||
situation_recap: '', regime_trajectory: '', key_developments: [], risk_outlook: '',
|
||||
}));
|
||||
assert.equal(valid, false);
|
||||
});
|
||||
|
||||
it('caps key_developments at 5', () => {
|
||||
const payload = JSON.stringify({
|
||||
situation_recap: 'x',
|
||||
key_developments: ['a', 'b', 'c', 'd', 'e', 'f', 'g'],
|
||||
});
|
||||
const { brief } = parseBriefJson(payload);
|
||||
assert.ok(brief.key_developments.length <= 5);
|
||||
});
|
||||
|
||||
it('extracts JSON from prose-wrapped output', () => {
|
||||
const text = 'Here is the brief:\n```json\n' + validPayload + '\n```';
|
||||
const { valid } = parseBriefJson(text);
|
||||
assert.equal(valid, true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── generateWeeklyBrief ─────────────────────────────────────────────────────
|
||||
|
||||
describe('generateWeeklyBrief', () => {
|
||||
function mockCall(text, provider = 'groq', model = 'llama-3.3-70b-versatile') {
|
||||
return async () => ({ text, provider, model });
|
||||
}
|
||||
|
||||
it('returns a populated brief on LLM success', async () => {
|
||||
const brief = await generateWeeklyBrief(mena, snapshotFixture, transitionsFixture, {
|
||||
callLlm: mockCall(validPayload),
|
||||
});
|
||||
assert.equal(brief.region_id, 'mena');
|
||||
assert.ok(brief.generated_at > 0);
|
||||
assert.ok(brief.period_start > 0);
|
||||
assert.equal(brief.situation_recap, 'Iran increased naval posture near Hormuz.');
|
||||
assert.equal(brief.provider, 'groq');
|
||||
});
|
||||
|
||||
it('skips global region', async () => {
|
||||
const brief = await generateWeeklyBrief(globalRegion, snapshotFixture, [], {
|
||||
callLlm: mockCall(validPayload),
|
||||
});
|
||||
assert.equal(brief.region_id, 'global');
|
||||
assert.equal(brief.situation_recap, '');
|
||||
});
|
||||
|
||||
it('returns empty brief when LLM fails', async () => {
|
||||
const brief = await generateWeeklyBrief(mena, snapshotFixture, transitionsFixture, {
|
||||
callLlm: async () => null,
|
||||
});
|
||||
assert.equal(brief.situation_recap, '');
|
||||
assert.equal(brief.provider, '');
|
||||
});
|
||||
|
||||
it('returns empty brief when LLM returns garbage', async () => {
|
||||
const brief = await generateWeeklyBrief(mena, snapshotFixture, transitionsFixture, {
|
||||
callLlm: mockCall('not json at all'),
|
||||
});
|
||||
assert.equal(brief.situation_recap, '');
|
||||
});
|
||||
|
||||
it('swallows callLlm exceptions', async () => {
|
||||
const brief = await generateWeeklyBrief(mena, snapshotFixture, transitionsFixture, {
|
||||
callLlm: async () => { throw new Error('network blown up'); },
|
||||
});
|
||||
assert.equal(brief.situation_recap, '');
|
||||
});
|
||||
|
||||
it('records period_start as 7 days before period_end', async () => {
|
||||
const brief = await generateWeeklyBrief(mena, snapshotFixture, transitionsFixture, {
|
||||
callLlm: mockCall(validPayload),
|
||||
});
|
||||
const diff = brief.period_end - brief.period_start;
|
||||
assert.ok(Math.abs(diff - 7 * 24 * 60 * 60 * 1000) < 1000);
|
||||
});
|
||||
});
|
||||
|
||||
// ── emptyBrief ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('emptyBrief', () => {
|
||||
it('carries the region_id', () => {
|
||||
assert.equal(emptyBrief('mena').region_id, 'mena');
|
||||
});
|
||||
|
||||
it('has empty fields', () => {
|
||||
const b = emptyBrief('x');
|
||||
assert.equal(b.situation_recap, '');
|
||||
assert.deepEqual(b.key_developments, []);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Handler structural + registration ───────────────────────────────────────
|
||||
|
||||
describe('get-regional-brief handler', () => {
|
||||
it('reads from the canonical weekly brief key prefix', () => {
|
||||
assert.match(handlerSrc, /intelligence:regional-briefs:v1:weekly:/);
|
||||
});
|
||||
|
||||
it('exports adaptBrief for unit testing', () => {
|
||||
assert.match(handlerSrc, /export function adaptBrief/);
|
||||
});
|
||||
|
||||
it('signals upstreamUnavailable on Redis miss', () => {
|
||||
assert.match(handlerSrc, /upstreamUnavailable:\s*true/);
|
||||
});
|
||||
|
||||
it('is registered in handler.ts', () => {
|
||||
assert.match(handlerIndexSrc, /import \{ getRegionalBrief \} from '\.\/get-regional-brief'/);
|
||||
assert.match(handlerIndexSrc, /\s+getRegionalBrief,/);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Security wiring ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('security wiring', () => {
|
||||
it('adds the endpoint to PREMIUM_RPC_PATHS', () => {
|
||||
assert.match(premiumPathsSrc, /'\/api\/intelligence\/v1\/get-regional-brief'/);
|
||||
});
|
||||
|
||||
it('has a RPC_CACHE_TIER entry for route-parity', () => {
|
||||
assert.match(gatewaySrc, /'\/api\/intelligence\/v1\/get-regional-brief':\s*'slow'/);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Proto definition ────────────────────────────────────────────────────────
|
||||
|
||||
describe('proto definition', () => {
|
||||
it('declares GetRegionalBrief RPC in service.proto', () => {
|
||||
assert.match(serviceProtoSrc, /rpc GetRegionalBrief\(GetRegionalBriefRequest\) returns \(GetRegionalBriefResponse\)/);
|
||||
});
|
||||
|
||||
it('imports the proto file from service.proto', () => {
|
||||
assert.match(serviceProtoSrc, /import "worldmonitor\/intelligence\/v1\/get_regional_brief\.proto"/);
|
||||
});
|
||||
|
||||
it('defines RegionalBrief with all fields', () => {
|
||||
assert.match(protoSrc, /message RegionalBrief/);
|
||||
assert.match(protoSrc, /string situation_recap = 5/);
|
||||
assert.match(protoSrc, /string regime_trajectory = 6/);
|
||||
assert.match(protoSrc, /repeated string key_developments = 7/);
|
||||
assert.match(protoSrc, /string risk_outlook = 8/);
|
||||
assert.match(protoSrc, /string provider = 9/);
|
||||
assert.match(protoSrc, /string model = 10/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user