mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
Phase 1 PR1: RegionalSnapshot proto + RPC handler (#2951)
* feat(intelligence): add RegionalSnapshot proto definition
Defines the canonical RegionalSnapshot wire format for Phase 1 of the
Regional Intelligence Model. Mirrors the TypeScript contract in
shared/regions.types.d.ts that Phase 0 landed with.
New proto file: proto/worldmonitor/intelligence/v1/get_regional_snapshot.proto
Messages:
- RegionalSnapshot (13 top-level fields matching the spec)
- SnapshotMeta (11 fields including snapshot_id, narrative_provider,
narrative_model, trigger_reason, snapshot_confidence, missing_inputs,
stale_inputs, valid_until, versions)
- RegimeState (label + transition history)
- BalanceVector (7 axes: 4 pressures + 3 buffers + net_balance + decomposed
drivers)
- BalanceDriver (axis, magnitude, evidence_ids, orientation)
- ActorState (leverage_score, role, domains, delta, evidence_ids)
- LeverageEdge (actor-to-actor directed influence)
- ScenarioSet + ScenarioLane (per-horizon distribution normalizing to 1.0)
- TransmissionPath (typed fields: severity, confidence, latency_hours,
magnitude range, asset class, template provenance)
- TriggerLadder + Trigger + TriggerThreshold (structured operator/value/
window/baseline)
- MobilityState + AirspaceStatus + FlightCorridorStress + AirportNodeStatus
- EvidenceItem (typed origin for the trust trail)
- RegionalNarrative + NarrativeSection (LLM-synthesized text with
evidence_ids on every section)
RPC: GetRegionalSnapshot(GetRegionalSnapshotRequest) -> GetRegionalSnapshotResponse
- GET /api/intelligence/v1/get-regional-snapshot
- region_id validated as lowercase kebab via buf.validate regex
- No other parameters; the handler reads canonical state
Generated code committed alongside:
- src/generated/client/worldmonitor/intelligence/v1/service_client.ts
- src/generated/server/worldmonitor/intelligence/v1/service_server.ts
- docs/api/IntelligenceService.openapi.{json,yaml}
The generated TypeScript types use camelCase per standard buf codegen, while
Phase 0 persists snapshots in Redis using the snake_case shape from
shared/regions.types.d.ts. The handler lands in a follow-up commit with a
localized snake_case -> camelCase adapter so Phase 0 code stays frozen.
Spec: docs/internal/pro-regional-intelligence-upgrade.md
* feat(intelligence): get-regional-snapshot RPC handler
Reads canonical persisted RegionalSnapshot for a region via the two-hop
lookup pattern established by the Phase 0 persist layer:
1. GET intelligence:snapshot:v1:{region}:latest -> snapshot_id
2. GET intelligence:snapshot-by-id:v1:{snapshot_id} -> full snapshot JSON
Returns empty response (snapshot omitted) when:
- No latest pointer exists (seed has never run or unknown region)
- Latest pointer references a pruned or TTL-expired snapshot
- Snapshot JSON is malformed
The handler does NOT recompute on miss. One writer (the seed bundle),
canonical reads. Matches the architecture commitment in the spec.
Includes a full snake_case -> camelCase adapter so the persisted Phase 0
shape (shared/regions.types.d.ts) maps cleanly onto the camelCase proto
wire format generated by buf. The adapter is the single bridge between
the two shapes; Phase 0 code stays frozen. Adapter handles every nested
message: SnapshotMeta, RegimeState, BalanceVector (+pressures/buffers
drivers), ActorState, LeverageEdge, ScenarioSet (+lanes +transmissions),
TransmissionPath, TriggerLadder (+triggers +thresholds), MobilityState
(+airspace +flight corridors +airports), EvidenceItem, RegionalNarrative
(+5 sections +watch items).
Wiring:
- Registered on intelligenceHandler in handler.ts
- Added to PREMIUM_RPC_PATHS (src/shared/premium-paths.ts) so the
gateway enforces Pro subscription or API key
- Added to RPC_CACHE_TIER with 'slow' tier (300s browser, 1800s edge)
matching similar premium intelligence RPCs
Not in this PR:
- LLM narrative generator (follow-up PR2, wires into snapshot writer)
- RegionalIntelligenceBoard panel UI (follow-up PR3)
- ENDPOINT_ENTITLEMENTS tier-specific enforcement (PREMIUM_RPC_PATHS
alone is the Pro gate; only stock-analysis endpoints currently use
tier-specific enforcement)
* test(intelligence): unit tests for get-regional-snapshot adapter + structural checks
29 tests across 5 suites covering:
adaptSnapshot (18 tests): real unit tests of the snake_case -> camelCase
adapter with synthetic persisted snapshots. Covers every nested message
(SnapshotMeta, RegimeState, BalanceVector with 7 axes + decomposed drivers,
ActorState, LeverageEdge, ScenarioSet with nested lanes and transmissions,
TriggerLadder with all 3 buckets + TriggerThreshold, MobilityState with
airspace/flights/airports, EvidenceItem, RegionalNarrative with all 5
sections + watch_items). Also asserts empty-default behavior when
nested fields are missing.
Handler structural checks (8 tests): validates import of getCachedJson,
canonical key prefixes, two-hop lookup ordering, empty-response fallbacks
on missing pointer or malformed snapshot, and export signature matching
the service interface.
Registration (2 tests): confirms getRegionalSnapshot is imported and
registered on the intelligenceHandler object.
Security wiring (2 tests): confirms the endpoint is in PREMIUM_RPC_PATHS
and RPC_CACHE_TIER with 'slow' tier.
Proto definition (3 tests): confirms the RPC method declaration, region_id
validation regex, RegionalSnapshot top-level field layout, and
BalanceVector 7-axis declaration.
* fix(intelligence): address Greptile P2 review findings on #2951
Two P2 findings from Greptile on the RegionalSnapshot proto+RPC PR.
1) region_id regex permitted trailing and consecutive hyphens
Old: ^[a-z][a-z0-9-]*$ — accepted "mena-", "east-asia-", "foo--bar"
New: ^[a-z][a-z0-9]*(-[a-z0-9]+)*$ — strict kebab-case, every hyphen must be
followed by at least one alphanumeric character. Regenerated openapi JSON/YAML
via `make generate`. Test assertion updated to match.
2) RPC_CACHE_TIER entry looked like dead code for premium paths
Greptile flagged that `isPremium` short-circuits the tier lookup to
'slow-browser' before RPC_CACHE_TIER is consulted, so the entry is never read
at runtime. Kept the entry because `tests/route-cache-tier.test.mjs` enforces
a parity contract requiring every generated GET route to have an explicit
tier. Added a NOTE comment in gateway.ts explaining the policy, and updated
the security-wiring test with a rationale comment so future maintainers know
the entry is intentional documentation, not a stale wire.
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -790,6 +790,45 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Error'
|
||||
/api/intelligence/v1/get-regional-snapshot:
|
||||
get:
|
||||
tags:
|
||||
- IntelligenceService
|
||||
summary: GetRegionalSnapshot
|
||||
description: |-
|
||||
GetRegionalSnapshot returns the latest persisted RegionalSnapshot for a
|
||||
region. The snapshot is written every 6h by scripts/seed-regional-snapshots.mjs;
|
||||
this handler only reads canonical state. Premium-gated.
|
||||
operationId: GetRegionalSnapshot
|
||||
parameters:
|
||||
- name: region_id
|
||||
in: query
|
||||
description: |-
|
||||
Display region id (e.g. "mena", "east-asia", "europe"). See shared/geography.js.
|
||||
Kebab-case: lowercase alphanumeric groups separated by single hyphens, no
|
||||
trailing or consecutive hyphens.
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: Successful response
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/GetRegionalSnapshotResponse'
|
||||
"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:
|
||||
@@ -2414,3 +2453,490 @@ components:
|
||||
format: double
|
||||
anomalySignal:
|
||||
type: boolean
|
||||
GetRegionalSnapshotRequest:
|
||||
type: object
|
||||
properties:
|
||||
regionId:
|
||||
type: string
|
||||
maxLength: 32
|
||||
minLength: 1
|
||||
pattern: ^[a-z][a-z0-9]*(-[a-z0-9]+)*$
|
||||
description: |-
|
||||
Display region id (e.g. "mena", "east-asia", "europe"). See shared/geography.js.
|
||||
Kebab-case: lowercase alphanumeric groups separated by single hyphens, no
|
||||
trailing or consecutive hyphens.
|
||||
required:
|
||||
- regionId
|
||||
description: |-
|
||||
GetRegionalSnapshotRequest asks for the latest persisted RegionalSnapshot
|
||||
for a given region. See shared/geography.ts for the canonical region ids.
|
||||
GetRegionalSnapshotResponse:
|
||||
type: object
|
||||
properties:
|
||||
snapshot:
|
||||
$ref: '#/components/schemas/RegionalSnapshot'
|
||||
description: |-
|
||||
GetRegionalSnapshotResponse returns the latest RegionalSnapshot for the
|
||||
requested region. The snapshot is written by scripts/seed-regional-snapshots.mjs
|
||||
on a 6h cron; this handler only reads canonical state.
|
||||
RegionalSnapshot:
|
||||
type: object
|
||||
properties:
|
||||
regionId:
|
||||
type: string
|
||||
generatedAt:
|
||||
type: integer
|
||||
format: int64
|
||||
description: 'Warning: Values > 2^53 may lose precision in JavaScript'
|
||||
meta:
|
||||
$ref: '#/components/schemas/SnapshotMeta'
|
||||
regime:
|
||||
$ref: '#/components/schemas/RegimeState'
|
||||
balance:
|
||||
$ref: '#/components/schemas/BalanceVector'
|
||||
actors:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/ActorState'
|
||||
leverageEdges:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/LeverageEdge'
|
||||
scenarioSets:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/ScenarioSet'
|
||||
transmissionPaths:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/TransmissionPath'
|
||||
triggers:
|
||||
$ref: '#/components/schemas/TriggerLadder'
|
||||
mobility:
|
||||
$ref: '#/components/schemas/MobilityState'
|
||||
evidence:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/EvidenceItem'
|
||||
narrative:
|
||||
$ref: '#/components/schemas/RegionalNarrative'
|
||||
description: |-
|
||||
RegionalSnapshot is the canonical intelligence object for one region.
|
||||
See docs/internal/pro-regional-intelligence-upgrade.md for the full spec
|
||||
and shared/regions.types.d.ts for the authoritative TypeScript contract.
|
||||
SnapshotMeta:
|
||||
type: object
|
||||
properties:
|
||||
snapshotId:
|
||||
type: string
|
||||
modelVersion:
|
||||
type: string
|
||||
scoringVersion:
|
||||
type: string
|
||||
geographyVersion:
|
||||
type: string
|
||||
snapshotConfidence:
|
||||
type: number
|
||||
format: double
|
||||
missingInputs:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
staleInputs:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
validUntil:
|
||||
type: integer
|
||||
format: int64
|
||||
description: 'Warning: Values > 2^53 may lose precision in JavaScript'
|
||||
triggerReason:
|
||||
type: string
|
||||
description: |-
|
||||
trigger_reason: scheduled_6h | regime_shift | trigger_activation |
|
||||
corridor_break | leverage_shift
|
||||
narrativeProvider:
|
||||
type: string
|
||||
narrativeModel:
|
||||
type: string
|
||||
description: |-
|
||||
SnapshotMeta carries the trust trail (versions, confidence, input freshness,
|
||||
narrative provenance, idempotency id).
|
||||
RegimeState:
|
||||
type: object
|
||||
properties:
|
||||
label:
|
||||
type: string
|
||||
description: |-
|
||||
label: calm | stressed_equilibrium | coercive_stalemate |
|
||||
fragmentation_risk | managed_deescalation | escalation_ladder
|
||||
previousLabel:
|
||||
type: string
|
||||
transitionedAt:
|
||||
type: integer
|
||||
format: int64
|
||||
description: 'Warning: Values > 2^53 may lose precision in JavaScript'
|
||||
transitionDriver:
|
||||
type: string
|
||||
description: RegimeState captures the current regime label and transition history.
|
||||
BalanceVector:
|
||||
type: object
|
||||
properties:
|
||||
coercivePressure:
|
||||
type: number
|
||||
format: double
|
||||
description: Pressures (high = bad)
|
||||
domesticFragility:
|
||||
type: number
|
||||
format: double
|
||||
capitalStress:
|
||||
type: number
|
||||
format: double
|
||||
energyVulnerability:
|
||||
type: number
|
||||
format: double
|
||||
allianceCohesion:
|
||||
type: number
|
||||
format: double
|
||||
description: Buffers (high = good)
|
||||
maritimeAccess:
|
||||
type: number
|
||||
format: double
|
||||
energyLeverage:
|
||||
type: number
|
||||
format: double
|
||||
netBalance:
|
||||
type: number
|
||||
format: double
|
||||
description: 'Derived: mean(buffers) - mean(pressures), range [-1, +1]'
|
||||
pressures:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/BalanceDriver'
|
||||
buffers:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/BalanceDriver'
|
||||
description: |-
|
||||
BalanceVector is the 7-axis regional balance score with pressures/buffers
|
||||
split. See docs/internal/pro-regional-intelligence-appendix-scoring.md for
|
||||
the per-axis formulas.
|
||||
BalanceDriver:
|
||||
type: object
|
||||
properties:
|
||||
axis:
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
magnitude:
|
||||
type: number
|
||||
format: double
|
||||
evidenceIds:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
orientation:
|
||||
type: string
|
||||
description: 'orientation: "pressure" | "buffer"'
|
||||
description: BalanceDriver is one contributor to an axis score. Links back to evidence.
|
||||
ActorState:
|
||||
type: object
|
||||
properties:
|
||||
actorId:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
role:
|
||||
type: string
|
||||
description: 'role: aggressor | stabilizer | swing | broker'
|
||||
leverageDomains:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: 'leverage_domains: energy | military | diplomatic | economic | maritime'
|
||||
leverageScore:
|
||||
type: number
|
||||
format: double
|
||||
delta:
|
||||
type: number
|
||||
format: double
|
||||
evidenceIds:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: ActorState is one geopolitical actor's leverage score in the region.
|
||||
LeverageEdge:
|
||||
type: object
|
||||
properties:
|
||||
fromActorId:
|
||||
type: string
|
||||
toActorId:
|
||||
type: string
|
||||
mechanism:
|
||||
type: string
|
||||
description: 'mechanism: sanctions | naval_posture | energy_supply | alliance_shift | trade_friction'
|
||||
strength:
|
||||
type: number
|
||||
format: double
|
||||
evidenceIds:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: LeverageEdge is a directed influence relationship between two actors.
|
||||
ScenarioSet:
|
||||
type: object
|
||||
properties:
|
||||
horizon:
|
||||
type: string
|
||||
description: 'horizon: 24h | 7d | 30d'
|
||||
lanes:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/ScenarioLane'
|
||||
description: |-
|
||||
ScenarioSet bundles scenario lanes for one time horizon. Lane probabilities
|
||||
sum to 1.0 within a set.
|
||||
ScenarioLane:
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
description: 'name: base | escalation | containment | fragmentation'
|
||||
probability:
|
||||
type: number
|
||||
format: double
|
||||
triggerIds:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
consequences:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
transmissions:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/TransmissionPath'
|
||||
description: ScenarioLane is one outcome branch within a horizon set.
|
||||
TransmissionPath:
|
||||
type: object
|
||||
properties:
|
||||
start:
|
||||
type: string
|
||||
mechanism:
|
||||
type: string
|
||||
end:
|
||||
type: string
|
||||
severity:
|
||||
type: string
|
||||
description: 'severity: critical | high | medium | low'
|
||||
corridorId:
|
||||
type: string
|
||||
confidence:
|
||||
type: number
|
||||
format: double
|
||||
latencyHours:
|
||||
type: integer
|
||||
format: int32
|
||||
impactedAssetClass:
|
||||
type: string
|
||||
description: 'impacted_asset_class: crude | lng | container | fx | equity | agri | metals | ...'
|
||||
impactedRegions:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
magnitudeLow:
|
||||
type: number
|
||||
format: double
|
||||
magnitudeHigh:
|
||||
type: number
|
||||
format: double
|
||||
magnitudeUnit:
|
||||
type: string
|
||||
description: 'magnitude_unit: usd_bbl | pct | usd_teu | basis_points | ...'
|
||||
templateId:
|
||||
type: string
|
||||
templateVersion:
|
||||
type: string
|
||||
description: |-
|
||||
TransmissionPath describes how a regional event propagates to markets,
|
||||
logistics, mobility, or other domains. Typed for ranking and calibration.
|
||||
TriggerLadder:
|
||||
type: object
|
||||
properties:
|
||||
active:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/Trigger'
|
||||
watching:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/Trigger'
|
||||
dormant:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/Trigger'
|
||||
description: TriggerLadder buckets triggers by activation state.
|
||||
Trigger:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
threshold:
|
||||
$ref: '#/components/schemas/TriggerThreshold'
|
||||
activated:
|
||||
type: boolean
|
||||
activatedAt:
|
||||
type: integer
|
||||
format: int64
|
||||
description: 'Warning: Values > 2^53 may lose precision in JavaScript'
|
||||
scenarioLane:
|
||||
type: string
|
||||
description: 'scenario_lane: base | escalation | containment | fragmentation'
|
||||
evidenceIds:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: Trigger is a structured threshold assertion against a named metric.
|
||||
TriggerThreshold:
|
||||
type: object
|
||||
properties:
|
||||
metric:
|
||||
type: string
|
||||
operator:
|
||||
type: string
|
||||
description: 'operator: gt | gte | lt | lte | delta_gt | delta_lt'
|
||||
value:
|
||||
type: number
|
||||
format: double
|
||||
windowMinutes:
|
||||
type: integer
|
||||
format: int32
|
||||
baseline:
|
||||
type: string
|
||||
description: 'baseline: trailing_7d | trailing_30d | fixed'
|
||||
description: |-
|
||||
TriggerThreshold defines the metric/operator/value/window/baseline for
|
||||
deterministic trigger evaluation.
|
||||
MobilityState:
|
||||
type: object
|
||||
properties:
|
||||
airspace:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/AirspaceStatus'
|
||||
flightCorridors:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/FlightCorridorStress'
|
||||
airports:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/AirportNodeStatus'
|
||||
rerouteIntensity:
|
||||
type: number
|
||||
format: double
|
||||
notamClosures:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: |-
|
||||
MobilityState captures airspace, flight corridor, and airport node status
|
||||
for the region. Phase 0 ships empty; Phase 2 wires the data plane.
|
||||
AirspaceStatus:
|
||||
type: object
|
||||
properties:
|
||||
airspaceId:
|
||||
type: string
|
||||
status:
|
||||
type: string
|
||||
description: 'status: open | restricted | closed'
|
||||
reason:
|
||||
type: string
|
||||
description: AirspaceStatus captures FIR-level airspace state.
|
||||
FlightCorridorStress:
|
||||
type: object
|
||||
properties:
|
||||
corridor:
|
||||
type: string
|
||||
stressLevel:
|
||||
type: number
|
||||
format: double
|
||||
reroutedFlights24h:
|
||||
type: integer
|
||||
format: int32
|
||||
description: FlightCorridorStress captures per-corridor reroute intensity.
|
||||
AirportNodeStatus:
|
||||
type: object
|
||||
properties:
|
||||
icao:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
status:
|
||||
type: string
|
||||
description: 'status: normal | disrupted | closed'
|
||||
disruptionReason:
|
||||
type: string
|
||||
description: AirportNodeStatus captures airport-level disruption state.
|
||||
EvidenceItem:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
type:
|
||||
type: string
|
||||
description: |-
|
||||
type: vessel_track | flight_surge | news_headline | cii_spike |
|
||||
chokepoint_status | sanctions_move | market_signal | mobility_disruption
|
||||
source:
|
||||
type: string
|
||||
description: 'source: AIS | ADSB | GDELT | ACLED | Yahoo | OREF | NOTAM | ...'
|
||||
summary:
|
||||
type: string
|
||||
confidence:
|
||||
type: number
|
||||
format: double
|
||||
observedAt:
|
||||
type: integer
|
||||
format: int64
|
||||
description: 'Warning: Values > 2^53 may lose precision in JavaScript'
|
||||
theater:
|
||||
type: string
|
||||
corridor:
|
||||
type: string
|
||||
description: |-
|
||||
EvidenceItem is one upstream data point linked from balance drivers,
|
||||
narrative sections, and triggers.
|
||||
RegionalNarrative:
|
||||
type: object
|
||||
properties:
|
||||
situation:
|
||||
$ref: '#/components/schemas/NarrativeSection'
|
||||
balanceAssessment:
|
||||
$ref: '#/components/schemas/NarrativeSection'
|
||||
outlook24h:
|
||||
$ref: '#/components/schemas/NarrativeSection'
|
||||
outlook7d:
|
||||
$ref: '#/components/schemas/NarrativeSection'
|
||||
outlook30d:
|
||||
$ref: '#/components/schemas/NarrativeSection'
|
||||
watchItems:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/NarrativeSection'
|
||||
description: |-
|
||||
RegionalNarrative is the LLM-synthesized narrative layer. Every section
|
||||
links back to evidence via evidence_ids.
|
||||
NarrativeSection:
|
||||
type: object
|
||||
properties:
|
||||
text:
|
||||
type: string
|
||||
evidenceIds:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: NarrativeSection is one block of narrative text plus its supporting evidence.
|
||||
|
||||
266
proto/worldmonitor/intelligence/v1/get_regional_snapshot.proto
Normal file
266
proto/worldmonitor/intelligence/v1/get_regional_snapshot.proto
Normal file
@@ -0,0 +1,266 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package worldmonitor.intelligence.v1;
|
||||
|
||||
import "buf/validate/validate.proto";
|
||||
import "sebuf/http/annotations.proto";
|
||||
|
||||
// GetRegionalSnapshotRequest asks for the latest persisted RegionalSnapshot
|
||||
// for a given region. See shared/geography.ts for the canonical region ids.
|
||||
message GetRegionalSnapshotRequest {
|
||||
// Display region id (e.g. "mena", "east-asia", "europe"). See shared/geography.js.
|
||||
// Kebab-case: lowercase alphanumeric groups separated by single hyphens, no
|
||||
// trailing or consecutive hyphens.
|
||||
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"}
|
||||
];
|
||||
}
|
||||
|
||||
// GetRegionalSnapshotResponse returns the latest RegionalSnapshot for the
|
||||
// requested region. The snapshot is written by scripts/seed-regional-snapshots.mjs
|
||||
// on a 6h cron; this handler only reads canonical state.
|
||||
message GetRegionalSnapshotResponse {
|
||||
RegionalSnapshot snapshot = 1;
|
||||
}
|
||||
|
||||
// RegionalSnapshot is the canonical intelligence object for one region.
|
||||
// See docs/internal/pro-regional-intelligence-upgrade.md for the full spec
|
||||
// and shared/regions.types.d.ts for the authoritative TypeScript contract.
|
||||
message RegionalSnapshot {
|
||||
string region_id = 1;
|
||||
int64 generated_at = 2 [(sebuf.http.int64_encoding) = INT64_ENCODING_NUMBER];
|
||||
SnapshotMeta meta = 3;
|
||||
RegimeState regime = 4;
|
||||
BalanceVector balance = 5;
|
||||
repeated ActorState actors = 6;
|
||||
repeated LeverageEdge leverage_edges = 7;
|
||||
repeated ScenarioSet scenario_sets = 8;
|
||||
repeated TransmissionPath transmission_paths = 9;
|
||||
TriggerLadder triggers = 10;
|
||||
MobilityState mobility = 11;
|
||||
repeated EvidenceItem evidence = 12;
|
||||
RegionalNarrative narrative = 13;
|
||||
}
|
||||
|
||||
// SnapshotMeta carries the trust trail (versions, confidence, input freshness,
|
||||
// narrative provenance, idempotency id).
|
||||
message SnapshotMeta {
|
||||
string snapshot_id = 1;
|
||||
string model_version = 2;
|
||||
string scoring_version = 3;
|
||||
string geography_version = 4;
|
||||
double snapshot_confidence = 5;
|
||||
repeated string missing_inputs = 6;
|
||||
repeated string stale_inputs = 7;
|
||||
int64 valid_until = 8 [(sebuf.http.int64_encoding) = INT64_ENCODING_NUMBER];
|
||||
// trigger_reason: scheduled_6h | regime_shift | trigger_activation |
|
||||
// corridor_break | leverage_shift
|
||||
string trigger_reason = 9;
|
||||
string narrative_provider = 10;
|
||||
string narrative_model = 11;
|
||||
}
|
||||
|
||||
// RegimeState captures the current regime label and transition history.
|
||||
message RegimeState {
|
||||
// label: calm | stressed_equilibrium | coercive_stalemate |
|
||||
// fragmentation_risk | managed_deescalation | escalation_ladder
|
||||
string label = 1;
|
||||
string previous_label = 2;
|
||||
int64 transitioned_at = 3 [(sebuf.http.int64_encoding) = INT64_ENCODING_NUMBER];
|
||||
string transition_driver = 4;
|
||||
}
|
||||
|
||||
// BalanceVector is the 7-axis regional balance score with pressures/buffers
|
||||
// split. See docs/internal/pro-regional-intelligence-appendix-scoring.md for
|
||||
// the per-axis formulas.
|
||||
message BalanceVector {
|
||||
// Pressures (high = bad)
|
||||
double coercive_pressure = 1;
|
||||
double domestic_fragility = 2;
|
||||
double capital_stress = 3;
|
||||
double energy_vulnerability = 4;
|
||||
// Buffers (high = good)
|
||||
double alliance_cohesion = 5;
|
||||
double maritime_access = 6;
|
||||
double energy_leverage = 7;
|
||||
// Derived: mean(buffers) - mean(pressures), range [-1, +1]
|
||||
double net_balance = 8;
|
||||
// Decomposition
|
||||
repeated BalanceDriver pressures = 9;
|
||||
repeated BalanceDriver buffers = 10;
|
||||
}
|
||||
|
||||
// BalanceDriver is one contributor to an axis score. Links back to evidence.
|
||||
message BalanceDriver {
|
||||
string axis = 1;
|
||||
string description = 2;
|
||||
double magnitude = 3;
|
||||
repeated string evidence_ids = 4;
|
||||
// orientation: "pressure" | "buffer"
|
||||
string orientation = 5;
|
||||
}
|
||||
|
||||
// ActorState is one geopolitical actor's leverage score in the region.
|
||||
message ActorState {
|
||||
string actor_id = 1;
|
||||
string name = 2;
|
||||
// role: aggressor | stabilizer | swing | broker
|
||||
string role = 3;
|
||||
// leverage_domains: energy | military | diplomatic | economic | maritime
|
||||
repeated string leverage_domains = 4;
|
||||
double leverage_score = 5;
|
||||
double delta = 6;
|
||||
repeated string evidence_ids = 7;
|
||||
}
|
||||
|
||||
// LeverageEdge is a directed influence relationship between two actors.
|
||||
message LeverageEdge {
|
||||
string from_actor_id = 1;
|
||||
string to_actor_id = 2;
|
||||
// mechanism: sanctions | naval_posture | energy_supply | alliance_shift | trade_friction
|
||||
string mechanism = 3;
|
||||
double strength = 4;
|
||||
repeated string evidence_ids = 5;
|
||||
}
|
||||
|
||||
// ScenarioSet bundles scenario lanes for one time horizon. Lane probabilities
|
||||
// sum to 1.0 within a set.
|
||||
message ScenarioSet {
|
||||
// horizon: 24h | 7d | 30d
|
||||
string horizon = 1;
|
||||
repeated ScenarioLane lanes = 2;
|
||||
}
|
||||
|
||||
// ScenarioLane is one outcome branch within a horizon set.
|
||||
message ScenarioLane {
|
||||
// name: base | escalation | containment | fragmentation
|
||||
string name = 1;
|
||||
double probability = 2;
|
||||
repeated string trigger_ids = 3;
|
||||
repeated string consequences = 4;
|
||||
repeated TransmissionPath transmissions = 5;
|
||||
}
|
||||
|
||||
// TransmissionPath describes how a regional event propagates to markets,
|
||||
// logistics, mobility, or other domains. Typed for ranking and calibration.
|
||||
message TransmissionPath {
|
||||
string start = 1;
|
||||
string mechanism = 2;
|
||||
string end = 3;
|
||||
// severity: critical | high | medium | low
|
||||
string severity = 4;
|
||||
string corridor_id = 5;
|
||||
double confidence = 6;
|
||||
int32 latency_hours = 7;
|
||||
// impacted_asset_class: crude | lng | container | fx | equity | agri | metals | ...
|
||||
string impacted_asset_class = 8;
|
||||
repeated string impacted_regions = 9;
|
||||
double magnitude_low = 10;
|
||||
double magnitude_high = 11;
|
||||
// magnitude_unit: usd_bbl | pct | usd_teu | basis_points | ...
|
||||
string magnitude_unit = 12;
|
||||
string template_id = 13;
|
||||
string template_version = 14;
|
||||
}
|
||||
|
||||
// TriggerLadder buckets triggers by activation state.
|
||||
message TriggerLadder {
|
||||
repeated Trigger active = 1;
|
||||
repeated Trigger watching = 2;
|
||||
repeated Trigger dormant = 3;
|
||||
}
|
||||
|
||||
// Trigger is a structured threshold assertion against a named metric.
|
||||
message Trigger {
|
||||
string id = 1;
|
||||
string description = 2;
|
||||
TriggerThreshold threshold = 3;
|
||||
bool activated = 4;
|
||||
int64 activated_at = 5 [(sebuf.http.int64_encoding) = INT64_ENCODING_NUMBER];
|
||||
// scenario_lane: base | escalation | containment | fragmentation
|
||||
string scenario_lane = 6;
|
||||
repeated string evidence_ids = 7;
|
||||
}
|
||||
|
||||
// TriggerThreshold defines the metric/operator/value/window/baseline for
|
||||
// deterministic trigger evaluation.
|
||||
message TriggerThreshold {
|
||||
string metric = 1;
|
||||
// operator: gt | gte | lt | lte | delta_gt | delta_lt
|
||||
string operator = 2;
|
||||
double value = 3;
|
||||
int32 window_minutes = 4;
|
||||
// baseline: trailing_7d | trailing_30d | fixed
|
||||
string baseline = 5;
|
||||
}
|
||||
|
||||
// MobilityState captures airspace, flight corridor, and airport node status
|
||||
// for the region. Phase 0 ships empty; Phase 2 wires the data plane.
|
||||
message MobilityState {
|
||||
repeated AirspaceStatus airspace = 1;
|
||||
repeated FlightCorridorStress flight_corridors = 2;
|
||||
repeated AirportNodeStatus airports = 3;
|
||||
double reroute_intensity = 4;
|
||||
repeated string notam_closures = 5;
|
||||
}
|
||||
|
||||
// AirspaceStatus captures FIR-level airspace state.
|
||||
message AirspaceStatus {
|
||||
string airspace_id = 1;
|
||||
// status: open | restricted | closed
|
||||
string status = 2;
|
||||
string reason = 3;
|
||||
}
|
||||
|
||||
// FlightCorridorStress captures per-corridor reroute intensity.
|
||||
message FlightCorridorStress {
|
||||
string corridor = 1;
|
||||
double stress_level = 2;
|
||||
int32 rerouted_flights_24h = 3;
|
||||
}
|
||||
|
||||
// AirportNodeStatus captures airport-level disruption state.
|
||||
message AirportNodeStatus {
|
||||
string icao = 1;
|
||||
string name = 2;
|
||||
// status: normal | disrupted | closed
|
||||
string status = 3;
|
||||
string disruption_reason = 4;
|
||||
}
|
||||
|
||||
// EvidenceItem is one upstream data point linked from balance drivers,
|
||||
// narrative sections, and triggers.
|
||||
message EvidenceItem {
|
||||
string id = 1;
|
||||
// type: vessel_track | flight_surge | news_headline | cii_spike |
|
||||
// chokepoint_status | sanctions_move | market_signal | mobility_disruption
|
||||
string type = 2;
|
||||
// source: AIS | ADSB | GDELT | ACLED | Yahoo | OREF | NOTAM | ...
|
||||
string source = 3;
|
||||
string summary = 4;
|
||||
double confidence = 5;
|
||||
int64 observed_at = 6 [(sebuf.http.int64_encoding) = INT64_ENCODING_NUMBER];
|
||||
string theater = 7;
|
||||
string corridor = 8;
|
||||
}
|
||||
|
||||
// RegionalNarrative is the LLM-synthesized narrative layer. Every section
|
||||
// links back to evidence via evidence_ids.
|
||||
message RegionalNarrative {
|
||||
NarrativeSection situation = 1;
|
||||
NarrativeSection balance_assessment = 2;
|
||||
NarrativeSection outlook_24h = 3;
|
||||
NarrativeSection outlook_7d = 4;
|
||||
NarrativeSection outlook_30d = 5;
|
||||
repeated NarrativeSection watch_items = 6;
|
||||
}
|
||||
|
||||
// NarrativeSection is one block of narrative text plus its supporting evidence.
|
||||
message NarrativeSection {
|
||||
string text = 1;
|
||||
repeated string evidence_ids = 2;
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import "worldmonitor/intelligence/v1/get_social_velocity.proto";
|
||||
import "worldmonitor/intelligence/v1/get_country_energy_profile.proto";
|
||||
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";
|
||||
|
||||
// IntelligenceService provides APIs for technical and strategic intelligence.
|
||||
service IntelligenceService {
|
||||
@@ -171,4 +172,11 @@ service IntelligenceService {
|
||||
rpc GetCountryPortActivity(GetCountryPortActivityRequest) returns (CountryPortActivityResponse) {
|
||||
option (sebuf.http.config) = {path: "/get-country-port-activity", method: HTTP_METHOD_GET};
|
||||
}
|
||||
|
||||
// GetRegionalSnapshot returns the latest persisted RegionalSnapshot for a
|
||||
// region. The snapshot is written every 6h by scripts/seed-regional-snapshots.mjs;
|
||||
// this handler only reads canonical state. Premium-gated.
|
||||
rpc GetRegionalSnapshot(GetRegionalSnapshotRequest) returns (GetRegionalSnapshotResponse) {
|
||||
option (sebuf.http.config) = {path: "/get-regional-snapshot", method: HTTP_METHOD_GET};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -220,6 +220,12 @@ const RPC_CACHE_TIER: Record<string, CacheTier> = {
|
||||
'/api/intelligence/v1/get-country-energy-profile': 'slow',
|
||||
'/api/intelligence/v1/compute-energy-shock': 'fast',
|
||||
'/api/intelligence/v1/get-country-port-activity': 'slow',
|
||||
// NOTE: get-regional-snapshot is premium-gated via PREMIUM_RPC_PATHS; the
|
||||
// gateway short-circuits to 'slow-browser' before consulting this map. The
|
||||
// entry below exists to satisfy the parity contract enforced by
|
||||
// tests/route-cache-tier.test.mjs (every generated GET route needs a tier)
|
||||
// and documents the intended tier if the endpoint ever becomes non-premium.
|
||||
'/api/intelligence/v1/get-regional-snapshot': 'slow',
|
||||
'/api/resilience/v1/get-resilience-score': 'slow',
|
||||
'/api/resilience/v1/get-resilience-ranking': 'slow',
|
||||
};
|
||||
|
||||
515
server/worldmonitor/intelligence/v1/get-regional-snapshot.ts
Normal file
515
server/worldmonitor/intelligence/v1/get-regional-snapshot.ts
Normal file
@@ -0,0 +1,515 @@
|
||||
import type {
|
||||
IntelligenceServiceHandler,
|
||||
ServerContext,
|
||||
GetRegionalSnapshotRequest,
|
||||
GetRegionalSnapshotResponse,
|
||||
RegionalSnapshot,
|
||||
SnapshotMeta,
|
||||
RegimeState,
|
||||
BalanceVector,
|
||||
BalanceDriver,
|
||||
ActorState,
|
||||
LeverageEdge,
|
||||
ScenarioSet,
|
||||
ScenarioLane,
|
||||
TransmissionPath,
|
||||
TriggerLadder,
|
||||
Trigger,
|
||||
TriggerThreshold,
|
||||
MobilityState,
|
||||
AirspaceStatus,
|
||||
FlightCorridorStress,
|
||||
AirportNodeStatus,
|
||||
EvidenceItem,
|
||||
RegionalNarrative,
|
||||
NarrativeSection,
|
||||
} from '../../../../src/generated/server/worldmonitor/intelligence/v1/service_server';
|
||||
import { getCachedJson } from '../../../_shared/redis';
|
||||
|
||||
const LATEST_KEY_PREFIX = 'intelligence:snapshot:v1:';
|
||||
const BY_ID_KEY_PREFIX = 'intelligence:snapshot-by-id:v1:';
|
||||
|
||||
// The set of valid region ids is defined in shared/geography.js. We don't
|
||||
// import the shared module here to avoid cross-directory ESM-in-TS friction;
|
||||
// the server handler accepts any lowercase kebab id and lets Redis return
|
||||
// null for unknown regions. Validation happens at the proto layer via the
|
||||
// regex pattern on region_id.
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phase 0 snake_case shape (as persisted by scripts/seed-regional-snapshots.mjs)
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// The seed writer in scripts/regional-snapshot/ constructs snapshots with
|
||||
// snake_case field names matching shared/regions.types.d.ts. The proto layer
|
||||
// uses camelCase per standard buf codegen. This handler reads the snake_case
|
||||
// payload from Redis and translates to the camelCase wire format on the way
|
||||
// out. The adapter is the single source of truth for the field mapping.
|
||||
|
||||
/** Phase 0 persisted shape. Only the fields we consume are typed. */
|
||||
interface PersistedSnapshot {
|
||||
region_id?: string;
|
||||
generated_at?: number;
|
||||
meta?: PersistedMeta;
|
||||
regime?: PersistedRegime;
|
||||
balance?: PersistedBalance;
|
||||
actors?: PersistedActor[];
|
||||
leverage_edges?: PersistedLeverageEdge[];
|
||||
scenario_sets?: PersistedScenarioSet[];
|
||||
transmission_paths?: PersistedTransmissionPath[];
|
||||
triggers?: PersistedTriggerLadder;
|
||||
mobility?: PersistedMobility;
|
||||
evidence?: PersistedEvidence[];
|
||||
narrative?: PersistedNarrative;
|
||||
}
|
||||
|
||||
interface PersistedMeta {
|
||||
snapshot_id?: string;
|
||||
model_version?: string;
|
||||
scoring_version?: string;
|
||||
geography_version?: string;
|
||||
snapshot_confidence?: number;
|
||||
missing_inputs?: string[];
|
||||
stale_inputs?: string[];
|
||||
valid_until?: number;
|
||||
trigger_reason?: string;
|
||||
narrative_provider?: string;
|
||||
narrative_model?: string;
|
||||
}
|
||||
|
||||
interface PersistedRegime {
|
||||
label?: string;
|
||||
previous_label?: string;
|
||||
transitioned_at?: number;
|
||||
transition_driver?: string;
|
||||
}
|
||||
|
||||
interface PersistedBalance {
|
||||
coercive_pressure?: number;
|
||||
domestic_fragility?: number;
|
||||
capital_stress?: number;
|
||||
energy_vulnerability?: number;
|
||||
alliance_cohesion?: number;
|
||||
maritime_access?: number;
|
||||
energy_leverage?: number;
|
||||
net_balance?: number;
|
||||
pressures?: PersistedBalanceDriver[];
|
||||
buffers?: PersistedBalanceDriver[];
|
||||
}
|
||||
|
||||
interface PersistedBalanceDriver {
|
||||
axis?: string;
|
||||
description?: string;
|
||||
magnitude?: number;
|
||||
evidence_ids?: string[];
|
||||
orientation?: string;
|
||||
}
|
||||
|
||||
interface PersistedActor {
|
||||
actor_id?: string;
|
||||
name?: string;
|
||||
role?: string;
|
||||
leverage_domains?: string[];
|
||||
leverage_score?: number;
|
||||
delta?: number;
|
||||
evidence_ids?: string[];
|
||||
}
|
||||
|
||||
interface PersistedLeverageEdge {
|
||||
from_actor_id?: string;
|
||||
to_actor_id?: string;
|
||||
mechanism?: string;
|
||||
strength?: number;
|
||||
evidence_ids?: string[];
|
||||
}
|
||||
|
||||
interface PersistedScenarioSet {
|
||||
horizon?: string;
|
||||
lanes?: PersistedScenarioLane[];
|
||||
}
|
||||
|
||||
interface PersistedScenarioLane {
|
||||
name?: string;
|
||||
probability?: number;
|
||||
trigger_ids?: string[];
|
||||
consequences?: string[];
|
||||
transmissions?: PersistedTransmissionPath[];
|
||||
}
|
||||
|
||||
interface PersistedTransmissionPath {
|
||||
start?: string;
|
||||
mechanism?: string;
|
||||
end?: string;
|
||||
severity?: string;
|
||||
corridor_id?: string;
|
||||
confidence?: number;
|
||||
latency_hours?: number;
|
||||
impacted_asset_class?: string;
|
||||
impacted_regions?: string[];
|
||||
magnitude_low?: number;
|
||||
magnitude_high?: number;
|
||||
magnitude_unit?: string;
|
||||
template_id?: string;
|
||||
template_version?: string;
|
||||
}
|
||||
|
||||
interface PersistedTriggerLadder {
|
||||
active?: PersistedTrigger[];
|
||||
watching?: PersistedTrigger[];
|
||||
dormant?: PersistedTrigger[];
|
||||
}
|
||||
|
||||
interface PersistedTrigger {
|
||||
id?: string;
|
||||
description?: string;
|
||||
threshold?: PersistedTriggerThreshold;
|
||||
activated?: boolean;
|
||||
activated_at?: number;
|
||||
scenario_lane?: string;
|
||||
evidence_ids?: string[];
|
||||
}
|
||||
|
||||
interface PersistedTriggerThreshold {
|
||||
metric?: string;
|
||||
operator?: string;
|
||||
value?: number;
|
||||
window_minutes?: number;
|
||||
baseline?: string;
|
||||
}
|
||||
|
||||
interface PersistedMobility {
|
||||
airspace?: PersistedAirspace[];
|
||||
flight_corridors?: PersistedFlightCorridor[];
|
||||
airports?: PersistedAirport[];
|
||||
reroute_intensity?: number;
|
||||
notam_closures?: string[];
|
||||
}
|
||||
|
||||
interface PersistedAirspace {
|
||||
airspace_id?: string;
|
||||
status?: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
interface PersistedFlightCorridor {
|
||||
corridor?: string;
|
||||
stress_level?: number;
|
||||
rerouted_flights_24h?: number;
|
||||
}
|
||||
|
||||
interface PersistedAirport {
|
||||
icao?: string;
|
||||
name?: string;
|
||||
status?: string;
|
||||
disruption_reason?: string;
|
||||
}
|
||||
|
||||
interface PersistedEvidence {
|
||||
id?: string;
|
||||
type?: string;
|
||||
source?: string;
|
||||
summary?: string;
|
||||
confidence?: number;
|
||||
observed_at?: number;
|
||||
theater?: string;
|
||||
corridor?: string;
|
||||
}
|
||||
|
||||
interface PersistedNarrative {
|
||||
situation?: PersistedNarrativeSection;
|
||||
balance_assessment?: PersistedNarrativeSection;
|
||||
outlook_24h?: PersistedNarrativeSection;
|
||||
outlook_7d?: PersistedNarrativeSection;
|
||||
outlook_30d?: PersistedNarrativeSection;
|
||||
watch_items?: PersistedNarrativeSection[];
|
||||
}
|
||||
|
||||
interface PersistedNarrativeSection {
|
||||
text?: string;
|
||||
evidence_ids?: string[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Adapters: snake_case persisted shape -> camelCase proto shape
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function adaptMeta(raw: PersistedMeta | undefined): SnapshotMeta {
|
||||
return {
|
||||
snapshotId: raw?.snapshot_id ?? '',
|
||||
modelVersion: raw?.model_version ?? '',
|
||||
scoringVersion: raw?.scoring_version ?? '',
|
||||
geographyVersion: raw?.geography_version ?? '',
|
||||
snapshotConfidence: raw?.snapshot_confidence ?? 0,
|
||||
missingInputs: raw?.missing_inputs ?? [],
|
||||
staleInputs: raw?.stale_inputs ?? [],
|
||||
validUntil: raw?.valid_until ?? 0,
|
||||
triggerReason: raw?.trigger_reason ?? '',
|
||||
narrativeProvider: raw?.narrative_provider ?? '',
|
||||
narrativeModel: raw?.narrative_model ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
function adaptRegime(raw: PersistedRegime | undefined): RegimeState {
|
||||
return {
|
||||
label: raw?.label ?? '',
|
||||
previousLabel: raw?.previous_label ?? '',
|
||||
transitionedAt: raw?.transitioned_at ?? 0,
|
||||
transitionDriver: raw?.transition_driver ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
function adaptBalanceDriver(raw: PersistedBalanceDriver): BalanceDriver {
|
||||
return {
|
||||
axis: raw.axis ?? '',
|
||||
description: raw.description ?? '',
|
||||
magnitude: raw.magnitude ?? 0,
|
||||
evidenceIds: raw.evidence_ids ?? [],
|
||||
orientation: raw.orientation ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
function adaptBalance(raw: PersistedBalance | undefined): BalanceVector {
|
||||
return {
|
||||
coercivePressure: raw?.coercive_pressure ?? 0,
|
||||
domesticFragility: raw?.domestic_fragility ?? 0,
|
||||
capitalStress: raw?.capital_stress ?? 0,
|
||||
energyVulnerability: raw?.energy_vulnerability ?? 0,
|
||||
allianceCohesion: raw?.alliance_cohesion ?? 0,
|
||||
maritimeAccess: raw?.maritime_access ?? 0,
|
||||
energyLeverage: raw?.energy_leverage ?? 0,
|
||||
netBalance: raw?.net_balance ?? 0,
|
||||
pressures: (raw?.pressures ?? []).map(adaptBalanceDriver),
|
||||
buffers: (raw?.buffers ?? []).map(adaptBalanceDriver),
|
||||
};
|
||||
}
|
||||
|
||||
function adaptActor(raw: PersistedActor): ActorState {
|
||||
return {
|
||||
actorId: raw.actor_id ?? '',
|
||||
name: raw.name ?? '',
|
||||
role: raw.role ?? '',
|
||||
leverageDomains: raw.leverage_domains ?? [],
|
||||
leverageScore: raw.leverage_score ?? 0,
|
||||
delta: raw.delta ?? 0,
|
||||
evidenceIds: raw.evidence_ids ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
function adaptLeverageEdge(raw: PersistedLeverageEdge): LeverageEdge {
|
||||
return {
|
||||
fromActorId: raw.from_actor_id ?? '',
|
||||
toActorId: raw.to_actor_id ?? '',
|
||||
mechanism: raw.mechanism ?? '',
|
||||
strength: raw.strength ?? 0,
|
||||
evidenceIds: raw.evidence_ids ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
function adaptTransmissionPath(raw: PersistedTransmissionPath): TransmissionPath {
|
||||
return {
|
||||
start: raw.start ?? '',
|
||||
mechanism: raw.mechanism ?? '',
|
||||
end: raw.end ?? '',
|
||||
severity: raw.severity ?? '',
|
||||
corridorId: raw.corridor_id ?? '',
|
||||
confidence: raw.confidence ?? 0,
|
||||
latencyHours: raw.latency_hours ?? 0,
|
||||
impactedAssetClass: raw.impacted_asset_class ?? '',
|
||||
impactedRegions: raw.impacted_regions ?? [],
|
||||
magnitudeLow: raw.magnitude_low ?? 0,
|
||||
magnitudeHigh: raw.magnitude_high ?? 0,
|
||||
magnitudeUnit: raw.magnitude_unit ?? '',
|
||||
templateId: raw.template_id ?? '',
|
||||
templateVersion: raw.template_version ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
function adaptScenarioLane(raw: PersistedScenarioLane): ScenarioLane {
|
||||
return {
|
||||
name: raw.name ?? '',
|
||||
probability: raw.probability ?? 0,
|
||||
triggerIds: raw.trigger_ids ?? [],
|
||||
consequences: raw.consequences ?? [],
|
||||
transmissions: (raw.transmissions ?? []).map(adaptTransmissionPath),
|
||||
};
|
||||
}
|
||||
|
||||
function adaptScenarioSet(raw: PersistedScenarioSet): ScenarioSet {
|
||||
return {
|
||||
horizon: raw.horizon ?? '',
|
||||
lanes: (raw.lanes ?? []).map(adaptScenarioLane),
|
||||
};
|
||||
}
|
||||
|
||||
function adaptTriggerThreshold(raw: PersistedTriggerThreshold | undefined): TriggerThreshold {
|
||||
return {
|
||||
metric: raw?.metric ?? '',
|
||||
operator: raw?.operator ?? '',
|
||||
value: raw?.value ?? 0,
|
||||
windowMinutes: raw?.window_minutes ?? 0,
|
||||
baseline: raw?.baseline ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
function adaptTrigger(raw: PersistedTrigger): Trigger {
|
||||
return {
|
||||
id: raw.id ?? '',
|
||||
description: raw.description ?? '',
|
||||
threshold: adaptTriggerThreshold(raw.threshold),
|
||||
activated: raw.activated ?? false,
|
||||
activatedAt: raw.activated_at ?? 0,
|
||||
scenarioLane: raw.scenario_lane ?? '',
|
||||
evidenceIds: raw.evidence_ids ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
function adaptTriggerLadder(raw: PersistedTriggerLadder | undefined): TriggerLadder {
|
||||
return {
|
||||
active: (raw?.active ?? []).map(adaptTrigger),
|
||||
watching: (raw?.watching ?? []).map(adaptTrigger),
|
||||
dormant: (raw?.dormant ?? []).map(adaptTrigger),
|
||||
};
|
||||
}
|
||||
|
||||
function adaptAirspace(raw: PersistedAirspace): AirspaceStatus {
|
||||
return {
|
||||
airspaceId: raw.airspace_id ?? '',
|
||||
status: raw.status ?? '',
|
||||
reason: raw.reason ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
function adaptFlightCorridor(raw: PersistedFlightCorridor): FlightCorridorStress {
|
||||
return {
|
||||
corridor: raw.corridor ?? '',
|
||||
stressLevel: raw.stress_level ?? 0,
|
||||
reroutedFlights24h: raw.rerouted_flights_24h ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
function adaptAirport(raw: PersistedAirport): AirportNodeStatus {
|
||||
return {
|
||||
icao: raw.icao ?? '',
|
||||
name: raw.name ?? '',
|
||||
status: raw.status ?? '',
|
||||
disruptionReason: raw.disruption_reason ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
function adaptMobility(raw: PersistedMobility | undefined): MobilityState {
|
||||
return {
|
||||
airspace: (raw?.airspace ?? []).map(adaptAirspace),
|
||||
flightCorridors: (raw?.flight_corridors ?? []).map(adaptFlightCorridor),
|
||||
airports: (raw?.airports ?? []).map(adaptAirport),
|
||||
rerouteIntensity: raw?.reroute_intensity ?? 0,
|
||||
notamClosures: raw?.notam_closures ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
function adaptEvidence(raw: PersistedEvidence): EvidenceItem {
|
||||
return {
|
||||
id: raw.id ?? '',
|
||||
type: raw.type ?? '',
|
||||
source: raw.source ?? '',
|
||||
summary: raw.summary ?? '',
|
||||
confidence: raw.confidence ?? 0,
|
||||
observedAt: raw.observed_at ?? 0,
|
||||
theater: raw.theater ?? '',
|
||||
corridor: raw.corridor ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
function adaptNarrativeSection(raw: PersistedNarrativeSection | undefined): NarrativeSection {
|
||||
return {
|
||||
text: raw?.text ?? '',
|
||||
evidenceIds: raw?.evidence_ids ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
function adaptNarrative(raw: PersistedNarrative | undefined): RegionalNarrative {
|
||||
return {
|
||||
situation: adaptNarrativeSection(raw?.situation),
|
||||
balanceAssessment: adaptNarrativeSection(raw?.balance_assessment),
|
||||
outlook24h: adaptNarrativeSection(raw?.outlook_24h),
|
||||
outlook7d: adaptNarrativeSection(raw?.outlook_7d),
|
||||
outlook30d: adaptNarrativeSection(raw?.outlook_30d),
|
||||
watchItems: (raw?.watch_items ?? []).map((section) => adaptNarrativeSection(section)),
|
||||
};
|
||||
}
|
||||
|
||||
/** Full snake_case -> camelCase adapter for RegionalSnapshot. */
|
||||
export function adaptSnapshot(raw: PersistedSnapshot): RegionalSnapshot {
|
||||
return {
|
||||
regionId: raw.region_id ?? '',
|
||||
generatedAt: raw.generated_at ?? 0,
|
||||
meta: adaptMeta(raw.meta),
|
||||
regime: adaptRegime(raw.regime),
|
||||
balance: adaptBalance(raw.balance),
|
||||
actors: (raw.actors ?? []).map(adaptActor),
|
||||
leverageEdges: (raw.leverage_edges ?? []).map(adaptLeverageEdge),
|
||||
scenarioSets: (raw.scenario_sets ?? []).map(adaptScenarioSet),
|
||||
transmissionPaths: (raw.transmission_paths ?? []).map(adaptTransmissionPath),
|
||||
triggers: adaptTriggerLadder(raw.triggers),
|
||||
mobility: adaptMobility(raw.mobility),
|
||||
evidence: (raw.evidence ?? []).map(adaptEvidence),
|
||||
narrative: adaptNarrative(raw.narrative),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Handler
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Reads the latest persisted RegionalSnapshot for a region.
|
||||
*
|
||||
* Pipeline:
|
||||
* 1. Read `intelligence:snapshot:v1:{region}:latest` -> snapshot_id (string)
|
||||
* 2. Read `intelligence:snapshot-by-id:v1:{snapshot_id}` -> full snapshot (JSON)
|
||||
* 3. Adapt snake_case persisted shape to camelCase proto shape
|
||||
* 4. Return
|
||||
*
|
||||
* Returns an empty response (snapshot omitted) when:
|
||||
* - No `:latest` pointer exists (seed has never run or region is unknown)
|
||||
* - The `:latest` pointer references a snapshot that was pruned or TTL'd
|
||||
* - The snapshot JSON is malformed
|
||||
*
|
||||
* This handler is premium-gated at the gateway layer (see
|
||||
* src/shared/premium-paths.ts and server/gateway.ts RPC_CACHE_TIER).
|
||||
*/
|
||||
export const getRegionalSnapshot: IntelligenceServiceHandler['getRegionalSnapshot'] = async (
|
||||
_ctx: ServerContext,
|
||||
req: GetRegionalSnapshotRequest,
|
||||
): Promise<GetRegionalSnapshotResponse> => {
|
||||
const regionId = req.regionId;
|
||||
if (!regionId || typeof regionId !== 'string') {
|
||||
return {};
|
||||
}
|
||||
|
||||
// Step 1: resolve latest pointer -> snapshot_id
|
||||
const latestKey = `${LATEST_KEY_PREFIX}${regionId}:latest`;
|
||||
const latestRaw = await getCachedJson(latestKey, true);
|
||||
// The seed writer stores the id as a bare string (JSON-encoded). getCachedJson
|
||||
// returns whatever the JSON parser produced, so we handle both shapes.
|
||||
let snapshotId: string | null = null;
|
||||
if (typeof latestRaw === 'string') {
|
||||
snapshotId = latestRaw;
|
||||
} else if (latestRaw && typeof latestRaw === 'object' && 'snapshot_id' in latestRaw) {
|
||||
const candidate = (latestRaw as { snapshot_id?: unknown }).snapshot_id;
|
||||
if (typeof candidate === 'string') snapshotId = candidate;
|
||||
}
|
||||
if (!snapshotId) {
|
||||
return {};
|
||||
}
|
||||
|
||||
// Step 2: resolve snapshot_id -> full snapshot
|
||||
const snapKey = `${BY_ID_KEY_PREFIX}${snapshotId}`;
|
||||
const persisted = await getCachedJson(snapKey, true) as PersistedSnapshot | null;
|
||||
if (!persisted || typeof persisted !== 'object') {
|
||||
return {};
|
||||
}
|
||||
|
||||
// Step 3: adapt snake_case -> camelCase
|
||||
const snapshot = adaptSnapshot(persisted);
|
||||
|
||||
return { snapshot };
|
||||
};
|
||||
@@ -22,6 +22,7 @@ import { getSocialVelocity } from './get-social-velocity';
|
||||
import { getCountryEnergyProfile } from './get-country-energy-profile';
|
||||
import { computeEnergyShockScenario } from './compute-energy-shock';
|
||||
import { getCountryPortActivity } from './get-country-port-activity';
|
||||
import { getRegionalSnapshot } from './get-regional-snapshot';
|
||||
|
||||
export const intelligenceHandler: IntelligenceServiceHandler = {
|
||||
getRiskScores,
|
||||
@@ -46,4 +47,5 @@ export const intelligenceHandler: IntelligenceServiceHandler = {
|
||||
getCountryEnergyProfile,
|
||||
computeEnergyShockScenario,
|
||||
getCountryPortActivity,
|
||||
getRegionalSnapshot,
|
||||
};
|
||||
|
||||
@@ -628,6 +628,196 @@ export interface PortActivityEntry {
|
||||
anomalySignal: boolean;
|
||||
}
|
||||
|
||||
export interface GetRegionalSnapshotRequest {
|
||||
regionId: string;
|
||||
}
|
||||
|
||||
export interface GetRegionalSnapshotResponse {
|
||||
snapshot?: RegionalSnapshot;
|
||||
}
|
||||
|
||||
export interface RegionalSnapshot {
|
||||
regionId: string;
|
||||
generatedAt: number;
|
||||
meta?: SnapshotMeta;
|
||||
regime?: RegimeState;
|
||||
balance?: BalanceVector;
|
||||
actors: ActorState[];
|
||||
leverageEdges: LeverageEdge[];
|
||||
scenarioSets: ScenarioSet[];
|
||||
transmissionPaths: TransmissionPath[];
|
||||
triggers?: TriggerLadder;
|
||||
mobility?: MobilityState;
|
||||
evidence: EvidenceItem[];
|
||||
narrative?: RegionalNarrative;
|
||||
}
|
||||
|
||||
export interface SnapshotMeta {
|
||||
snapshotId: string;
|
||||
modelVersion: string;
|
||||
scoringVersion: string;
|
||||
geographyVersion: string;
|
||||
snapshotConfidence: number;
|
||||
missingInputs: string[];
|
||||
staleInputs: string[];
|
||||
validUntil: number;
|
||||
triggerReason: string;
|
||||
narrativeProvider: string;
|
||||
narrativeModel: string;
|
||||
}
|
||||
|
||||
export interface RegimeState {
|
||||
label: string;
|
||||
previousLabel: string;
|
||||
transitionedAt: number;
|
||||
transitionDriver: string;
|
||||
}
|
||||
|
||||
export interface BalanceVector {
|
||||
coercivePressure: number;
|
||||
domesticFragility: number;
|
||||
capitalStress: number;
|
||||
energyVulnerability: number;
|
||||
allianceCohesion: number;
|
||||
maritimeAccess: number;
|
||||
energyLeverage: number;
|
||||
netBalance: number;
|
||||
pressures: BalanceDriver[];
|
||||
buffers: BalanceDriver[];
|
||||
}
|
||||
|
||||
export interface BalanceDriver {
|
||||
axis: string;
|
||||
description: string;
|
||||
magnitude: number;
|
||||
evidenceIds: string[];
|
||||
orientation: string;
|
||||
}
|
||||
|
||||
export interface ActorState {
|
||||
actorId: string;
|
||||
name: string;
|
||||
role: string;
|
||||
leverageDomains: string[];
|
||||
leverageScore: number;
|
||||
delta: number;
|
||||
evidenceIds: string[];
|
||||
}
|
||||
|
||||
export interface LeverageEdge {
|
||||
fromActorId: string;
|
||||
toActorId: string;
|
||||
mechanism: string;
|
||||
strength: number;
|
||||
evidenceIds: string[];
|
||||
}
|
||||
|
||||
export interface ScenarioSet {
|
||||
horizon: string;
|
||||
lanes: ScenarioLane[];
|
||||
}
|
||||
|
||||
export interface ScenarioLane {
|
||||
name: string;
|
||||
probability: number;
|
||||
triggerIds: string[];
|
||||
consequences: string[];
|
||||
transmissions: TransmissionPath[];
|
||||
}
|
||||
|
||||
export interface TransmissionPath {
|
||||
start: string;
|
||||
mechanism: string;
|
||||
end: string;
|
||||
severity: string;
|
||||
corridorId: string;
|
||||
confidence: number;
|
||||
latencyHours: number;
|
||||
impactedAssetClass: string;
|
||||
impactedRegions: string[];
|
||||
magnitudeLow: number;
|
||||
magnitudeHigh: number;
|
||||
magnitudeUnit: string;
|
||||
templateId: string;
|
||||
templateVersion: string;
|
||||
}
|
||||
|
||||
export interface TriggerLadder {
|
||||
active: Trigger[];
|
||||
watching: Trigger[];
|
||||
dormant: Trigger[];
|
||||
}
|
||||
|
||||
export interface Trigger {
|
||||
id: string;
|
||||
description: string;
|
||||
threshold?: TriggerThreshold;
|
||||
activated: boolean;
|
||||
activatedAt: number;
|
||||
scenarioLane: string;
|
||||
evidenceIds: string[];
|
||||
}
|
||||
|
||||
export interface TriggerThreshold {
|
||||
metric: string;
|
||||
operator: string;
|
||||
value: number;
|
||||
windowMinutes: number;
|
||||
baseline: string;
|
||||
}
|
||||
|
||||
export interface MobilityState {
|
||||
airspace: AirspaceStatus[];
|
||||
flightCorridors: FlightCorridorStress[];
|
||||
airports: AirportNodeStatus[];
|
||||
rerouteIntensity: number;
|
||||
notamClosures: string[];
|
||||
}
|
||||
|
||||
export interface AirspaceStatus {
|
||||
airspaceId: string;
|
||||
status: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface FlightCorridorStress {
|
||||
corridor: string;
|
||||
stressLevel: number;
|
||||
reroutedFlights24h: number;
|
||||
}
|
||||
|
||||
export interface AirportNodeStatus {
|
||||
icao: string;
|
||||
name: string;
|
||||
status: string;
|
||||
disruptionReason: string;
|
||||
}
|
||||
|
||||
export interface EvidenceItem {
|
||||
id: string;
|
||||
type: string;
|
||||
source: string;
|
||||
summary: string;
|
||||
confidence: number;
|
||||
observedAt: number;
|
||||
theater: string;
|
||||
corridor: string;
|
||||
}
|
||||
|
||||
export interface RegionalNarrative {
|
||||
situation?: NarrativeSection;
|
||||
balanceAssessment?: NarrativeSection;
|
||||
outlook24h?: NarrativeSection;
|
||||
outlook7d?: NarrativeSection;
|
||||
outlook30d?: NarrativeSection;
|
||||
watchItems: NarrativeSection[];
|
||||
}
|
||||
|
||||
export interface NarrativeSection {
|
||||
text: string;
|
||||
evidenceIds: 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";
|
||||
@@ -1248,6 +1438,31 @@ export class IntelligenceServiceClient {
|
||||
return await resp.json() as CountryPortActivityResponse;
|
||||
}
|
||||
|
||||
async getRegionalSnapshot(req: GetRegionalSnapshotRequest, options?: IntelligenceServiceCallOptions): Promise<GetRegionalSnapshotResponse> {
|
||||
let path = "/api/intelligence/v1/get-regional-snapshot";
|
||||
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 GetRegionalSnapshotResponse;
|
||||
}
|
||||
|
||||
private async handleError(resp: Response): Promise<never> {
|
||||
const body = await resp.text();
|
||||
if (resp.status === 400) {
|
||||
|
||||
@@ -628,6 +628,196 @@ export interface PortActivityEntry {
|
||||
anomalySignal: boolean;
|
||||
}
|
||||
|
||||
export interface GetRegionalSnapshotRequest {
|
||||
regionId: string;
|
||||
}
|
||||
|
||||
export interface GetRegionalSnapshotResponse {
|
||||
snapshot?: RegionalSnapshot;
|
||||
}
|
||||
|
||||
export interface RegionalSnapshot {
|
||||
regionId: string;
|
||||
generatedAt: number;
|
||||
meta?: SnapshotMeta;
|
||||
regime?: RegimeState;
|
||||
balance?: BalanceVector;
|
||||
actors: ActorState[];
|
||||
leverageEdges: LeverageEdge[];
|
||||
scenarioSets: ScenarioSet[];
|
||||
transmissionPaths: TransmissionPath[];
|
||||
triggers?: TriggerLadder;
|
||||
mobility?: MobilityState;
|
||||
evidence: EvidenceItem[];
|
||||
narrative?: RegionalNarrative;
|
||||
}
|
||||
|
||||
export interface SnapshotMeta {
|
||||
snapshotId: string;
|
||||
modelVersion: string;
|
||||
scoringVersion: string;
|
||||
geographyVersion: string;
|
||||
snapshotConfidence: number;
|
||||
missingInputs: string[];
|
||||
staleInputs: string[];
|
||||
validUntil: number;
|
||||
triggerReason: string;
|
||||
narrativeProvider: string;
|
||||
narrativeModel: string;
|
||||
}
|
||||
|
||||
export interface RegimeState {
|
||||
label: string;
|
||||
previousLabel: string;
|
||||
transitionedAt: number;
|
||||
transitionDriver: string;
|
||||
}
|
||||
|
||||
export interface BalanceVector {
|
||||
coercivePressure: number;
|
||||
domesticFragility: number;
|
||||
capitalStress: number;
|
||||
energyVulnerability: number;
|
||||
allianceCohesion: number;
|
||||
maritimeAccess: number;
|
||||
energyLeverage: number;
|
||||
netBalance: number;
|
||||
pressures: BalanceDriver[];
|
||||
buffers: BalanceDriver[];
|
||||
}
|
||||
|
||||
export interface BalanceDriver {
|
||||
axis: string;
|
||||
description: string;
|
||||
magnitude: number;
|
||||
evidenceIds: string[];
|
||||
orientation: string;
|
||||
}
|
||||
|
||||
export interface ActorState {
|
||||
actorId: string;
|
||||
name: string;
|
||||
role: string;
|
||||
leverageDomains: string[];
|
||||
leverageScore: number;
|
||||
delta: number;
|
||||
evidenceIds: string[];
|
||||
}
|
||||
|
||||
export interface LeverageEdge {
|
||||
fromActorId: string;
|
||||
toActorId: string;
|
||||
mechanism: string;
|
||||
strength: number;
|
||||
evidenceIds: string[];
|
||||
}
|
||||
|
||||
export interface ScenarioSet {
|
||||
horizon: string;
|
||||
lanes: ScenarioLane[];
|
||||
}
|
||||
|
||||
export interface ScenarioLane {
|
||||
name: string;
|
||||
probability: number;
|
||||
triggerIds: string[];
|
||||
consequences: string[];
|
||||
transmissions: TransmissionPath[];
|
||||
}
|
||||
|
||||
export interface TransmissionPath {
|
||||
start: string;
|
||||
mechanism: string;
|
||||
end: string;
|
||||
severity: string;
|
||||
corridorId: string;
|
||||
confidence: number;
|
||||
latencyHours: number;
|
||||
impactedAssetClass: string;
|
||||
impactedRegions: string[];
|
||||
magnitudeLow: number;
|
||||
magnitudeHigh: number;
|
||||
magnitudeUnit: string;
|
||||
templateId: string;
|
||||
templateVersion: string;
|
||||
}
|
||||
|
||||
export interface TriggerLadder {
|
||||
active: Trigger[];
|
||||
watching: Trigger[];
|
||||
dormant: Trigger[];
|
||||
}
|
||||
|
||||
export interface Trigger {
|
||||
id: string;
|
||||
description: string;
|
||||
threshold?: TriggerThreshold;
|
||||
activated: boolean;
|
||||
activatedAt: number;
|
||||
scenarioLane: string;
|
||||
evidenceIds: string[];
|
||||
}
|
||||
|
||||
export interface TriggerThreshold {
|
||||
metric: string;
|
||||
operator: string;
|
||||
value: number;
|
||||
windowMinutes: number;
|
||||
baseline: string;
|
||||
}
|
||||
|
||||
export interface MobilityState {
|
||||
airspace: AirspaceStatus[];
|
||||
flightCorridors: FlightCorridorStress[];
|
||||
airports: AirportNodeStatus[];
|
||||
rerouteIntensity: number;
|
||||
notamClosures: string[];
|
||||
}
|
||||
|
||||
export interface AirspaceStatus {
|
||||
airspaceId: string;
|
||||
status: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface FlightCorridorStress {
|
||||
corridor: string;
|
||||
stressLevel: number;
|
||||
reroutedFlights24h: number;
|
||||
}
|
||||
|
||||
export interface AirportNodeStatus {
|
||||
icao: string;
|
||||
name: string;
|
||||
status: string;
|
||||
disruptionReason: string;
|
||||
}
|
||||
|
||||
export interface EvidenceItem {
|
||||
id: string;
|
||||
type: string;
|
||||
source: string;
|
||||
summary: string;
|
||||
confidence: number;
|
||||
observedAt: number;
|
||||
theater: string;
|
||||
corridor: string;
|
||||
}
|
||||
|
||||
export interface RegionalNarrative {
|
||||
situation?: NarrativeSection;
|
||||
balanceAssessment?: NarrativeSection;
|
||||
outlook24h?: NarrativeSection;
|
||||
outlook7d?: NarrativeSection;
|
||||
outlook30d?: NarrativeSection;
|
||||
watchItems: NarrativeSection[];
|
||||
}
|
||||
|
||||
export interface NarrativeSection {
|
||||
text: string;
|
||||
evidenceIds: 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";
|
||||
@@ -709,6 +899,7 @@ export interface IntelligenceServiceHandler {
|
||||
getCountryEnergyProfile(ctx: ServerContext, req: GetCountryEnergyProfileRequest): Promise<GetCountryEnergyProfileResponse>;
|
||||
computeEnergyShockScenario(ctx: ServerContext, req: ComputeEnergyShockScenarioRequest): Promise<ComputeEnergyShockScenarioResponse>;
|
||||
getCountryPortActivity(ctx: ServerContext, req: GetCountryPortActivityRequest): Promise<CountryPortActivityResponse>;
|
||||
getRegionalSnapshot(ctx: ServerContext, req: GetRegionalSnapshotRequest): Promise<GetRegionalSnapshotResponse>;
|
||||
}
|
||||
|
||||
export function createIntelligenceServiceRoutes(
|
||||
@@ -1731,6 +1922,53 @@ export function createIntelligenceServiceRoutes(
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
method: "GET",
|
||||
path: "/api/intelligence/v1/get-regional-snapshot",
|
||||
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: GetRegionalSnapshotRequest = {
|
||||
regionId: params.get("region_id") ?? "",
|
||||
};
|
||||
if (options?.validateRequest) {
|
||||
const bodyViolations = options.validateRequest("getRegionalSnapshot", body);
|
||||
if (bodyViolations) {
|
||||
throw new ValidationError(bodyViolations);
|
||||
}
|
||||
}
|
||||
|
||||
const ctx: ServerContext = {
|
||||
request: req,
|
||||
pathParams,
|
||||
headers: Object.fromEntries(req.headers.entries()),
|
||||
};
|
||||
|
||||
const result = await handler.getRegionalSnapshot(ctx, body);
|
||||
return new Response(JSON.stringify(result as GetRegionalSnapshotResponse), {
|
||||
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" },
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ export const PREMIUM_RPC_PATHS = new Set<string>([
|
||||
'/api/market/v1/list-stored-stock-backtests',
|
||||
'/api/intelligence/v1/deduct-situation',
|
||||
'/api/intelligence/v1/list-market-implications',
|
||||
'/api/intelligence/v1/get-regional-snapshot',
|
||||
'/api/resilience/v1/get-resilience-score',
|
||||
'/api/resilience/v1/get-resilience-ranking',
|
||||
'/api/supply-chain/v1/get-country-chokepoint-index',
|
||||
|
||||
495
tests/get-regional-snapshot.test.mts
Normal file
495
tests/get-regional-snapshot.test.mts
Normal file
@@ -0,0 +1,495 @@
|
||||
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 { adaptSnapshot } from '../server/worldmonitor/intelligence/v1/get-regional-snapshot';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const root = resolve(__dirname, '..');
|
||||
|
||||
const handlerSrc = readFileSync(
|
||||
resolve(root, 'server/worldmonitor/intelligence/v1/get-regional-snapshot.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_snapshot.proto'),
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// adaptSnapshot: snake_case -> camelCase adapter (the substantive logic)
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('adaptSnapshot', () => {
|
||||
it('maps all top-level region fields', () => {
|
||||
const result = adaptSnapshot({
|
||||
region_id: 'mena',
|
||||
generated_at: 1_700_000_000_000,
|
||||
});
|
||||
assert.equal(result.regionId, 'mena');
|
||||
assert.equal(result.generatedAt, 1_700_000_000_000);
|
||||
});
|
||||
|
||||
it('defaults missing top-level fields to empty values', () => {
|
||||
const result = adaptSnapshot({});
|
||||
assert.equal(result.regionId, '');
|
||||
assert.equal(result.generatedAt, 0);
|
||||
assert.deepEqual(result.actors, []);
|
||||
assert.deepEqual(result.leverageEdges, []);
|
||||
assert.deepEqual(result.scenarioSets, []);
|
||||
assert.deepEqual(result.transmissionPaths, []);
|
||||
assert.deepEqual(result.evidence, []);
|
||||
});
|
||||
|
||||
it('adapts SnapshotMeta fields', () => {
|
||||
const result = adaptSnapshot({
|
||||
meta: {
|
||||
snapshot_id: 'abc123',
|
||||
model_version: '0.1.0',
|
||||
scoring_version: '1.0.0',
|
||||
geography_version: '1.0.0',
|
||||
snapshot_confidence: 0.85,
|
||||
missing_inputs: ['forecast:predictions:v2'],
|
||||
stale_inputs: [],
|
||||
valid_until: 1_700_021_600_000,
|
||||
trigger_reason: 'scheduled_6h',
|
||||
narrative_provider: 'groq',
|
||||
narrative_model: 'mixtral-8x7b',
|
||||
},
|
||||
});
|
||||
assert.ok(result.meta);
|
||||
assert.equal(result.meta.snapshotId, 'abc123');
|
||||
assert.equal(result.meta.modelVersion, '0.1.0');
|
||||
assert.equal(result.meta.scoringVersion, '1.0.0');
|
||||
assert.equal(result.meta.snapshotConfidence, 0.85);
|
||||
assert.deepEqual(result.meta.missingInputs, ['forecast:predictions:v2']);
|
||||
assert.equal(result.meta.validUntil, 1_700_021_600_000);
|
||||
assert.equal(result.meta.triggerReason, 'scheduled_6h');
|
||||
assert.equal(result.meta.narrativeProvider, 'groq');
|
||||
assert.equal(result.meta.narrativeModel, 'mixtral-8x7b');
|
||||
});
|
||||
|
||||
it('adapts RegimeState', () => {
|
||||
const result = adaptSnapshot({
|
||||
regime: {
|
||||
label: 'stressed_equilibrium',
|
||||
previous_label: 'calm',
|
||||
transitioned_at: 1_700_000_000_000,
|
||||
transition_driver: 'diff-engine',
|
||||
},
|
||||
});
|
||||
assert.ok(result.regime);
|
||||
assert.equal(result.regime.label, 'stressed_equilibrium');
|
||||
assert.equal(result.regime.previousLabel, 'calm');
|
||||
assert.equal(result.regime.transitionedAt, 1_700_000_000_000);
|
||||
assert.equal(result.regime.transitionDriver, 'diff-engine');
|
||||
});
|
||||
|
||||
it('adapts BalanceVector with all 7 axes', () => {
|
||||
const result = adaptSnapshot({
|
||||
balance: {
|
||||
coercive_pressure: 0.72,
|
||||
domestic_fragility: 0.58,
|
||||
capital_stress: 0.45,
|
||||
energy_vulnerability: 0.3,
|
||||
alliance_cohesion: 0.62,
|
||||
maritime_access: 0.55,
|
||||
energy_leverage: 0.8,
|
||||
net_balance: 0.12,
|
||||
pressures: [
|
||||
{ axis: 'coercive_pressure', description: 'IRGC naval', magnitude: 0.72, evidence_ids: ['xss:1'], orientation: 'pressure' },
|
||||
],
|
||||
buffers: [
|
||||
{ axis: 'energy_leverage', description: '6 producers', magnitude: 1.0, evidence_ids: [], orientation: 'buffer' },
|
||||
],
|
||||
},
|
||||
});
|
||||
assert.ok(result.balance);
|
||||
assert.equal(result.balance.coercivePressure, 0.72);
|
||||
assert.equal(result.balance.domesticFragility, 0.58);
|
||||
assert.equal(result.balance.capitalStress, 0.45);
|
||||
assert.equal(result.balance.energyVulnerability, 0.3);
|
||||
assert.equal(result.balance.allianceCohesion, 0.62);
|
||||
assert.equal(result.balance.maritimeAccess, 0.55);
|
||||
assert.equal(result.balance.energyLeverage, 0.8);
|
||||
assert.equal(result.balance.netBalance, 0.12);
|
||||
assert.equal(result.balance.pressures.length, 1);
|
||||
assert.equal(result.balance.pressures[0]?.axis, 'coercive_pressure');
|
||||
assert.deepEqual(result.balance.pressures[0]?.evidenceIds, ['xss:1']);
|
||||
assert.equal(result.balance.buffers.length, 1);
|
||||
});
|
||||
|
||||
it('adapts ActorState array', () => {
|
||||
const result = adaptSnapshot({
|
||||
actors: [
|
||||
{
|
||||
actor_id: 'iran',
|
||||
name: 'Iran',
|
||||
role: 'aggressor',
|
||||
leverage_domains: ['military', 'energy'],
|
||||
leverage_score: 0.68,
|
||||
delta: 0,
|
||||
evidence_ids: ['forecast:f1'],
|
||||
},
|
||||
],
|
||||
});
|
||||
assert.equal(result.actors.length, 1);
|
||||
const iran = result.actors[0];
|
||||
assert.ok(iran);
|
||||
assert.equal(iran.actorId, 'iran');
|
||||
assert.equal(iran.name, 'Iran');
|
||||
assert.equal(iran.role, 'aggressor');
|
||||
assert.deepEqual(iran.leverageDomains, ['military', 'energy']);
|
||||
assert.equal(iran.leverageScore, 0.68);
|
||||
assert.deepEqual(iran.evidenceIds, ['forecast:f1']);
|
||||
});
|
||||
|
||||
it('adapts LeverageEdge array', () => {
|
||||
const result = adaptSnapshot({
|
||||
leverage_edges: [
|
||||
{
|
||||
from_actor_id: 'russia',
|
||||
to_actor_id: 'germany',
|
||||
mechanism: 'energy_supply',
|
||||
strength: 0.75,
|
||||
evidence_ids: ['e1'],
|
||||
},
|
||||
],
|
||||
});
|
||||
assert.equal(result.leverageEdges.length, 1);
|
||||
const edge = result.leverageEdges[0];
|
||||
assert.ok(edge);
|
||||
assert.equal(edge.fromActorId, 'russia');
|
||||
assert.equal(edge.toActorId, 'germany');
|
||||
assert.equal(edge.mechanism, 'energy_supply');
|
||||
assert.equal(edge.strength, 0.75);
|
||||
});
|
||||
|
||||
it('adapts ScenarioSet with nested lanes and transmissions', () => {
|
||||
const result = adaptSnapshot({
|
||||
scenario_sets: [
|
||||
{
|
||||
horizon: '24h',
|
||||
lanes: [
|
||||
{
|
||||
name: 'escalation',
|
||||
probability: 0.45,
|
||||
trigger_ids: ['t1', 't2'],
|
||||
consequences: ['price spike'],
|
||||
transmissions: [
|
||||
{
|
||||
start: 'Hormuz threat',
|
||||
mechanism: 'insurance spike',
|
||||
end: 'Brent +$10',
|
||||
severity: 'critical',
|
||||
corridor_id: 'hormuz',
|
||||
confidence: 0.85,
|
||||
latency_hours: 24,
|
||||
impacted_asset_class: 'crude',
|
||||
impacted_regions: ['mena', 'east-asia'],
|
||||
magnitude_low: 10,
|
||||
magnitude_high: 25,
|
||||
magnitude_unit: 'usd_bbl',
|
||||
template_id: 'hormuz_blockade',
|
||||
template_version: '1.0.0',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
assert.equal(result.scenarioSets.length, 1);
|
||||
const set = result.scenarioSets[0];
|
||||
assert.ok(set);
|
||||
assert.equal(set.horizon, '24h');
|
||||
assert.equal(set.lanes.length, 1);
|
||||
const lane = set.lanes[0];
|
||||
assert.ok(lane);
|
||||
assert.equal(lane.name, 'escalation');
|
||||
assert.equal(lane.probability, 0.45);
|
||||
assert.deepEqual(lane.triggerIds, ['t1', 't2']);
|
||||
assert.equal(lane.transmissions.length, 1);
|
||||
const trans = lane.transmissions[0];
|
||||
assert.ok(trans);
|
||||
assert.equal(trans.corridorId, 'hormuz');
|
||||
assert.equal(trans.latencyHours, 24);
|
||||
assert.equal(trans.impactedAssetClass, 'crude');
|
||||
assert.deepEqual(trans.impactedRegions, ['mena', 'east-asia']);
|
||||
assert.equal(trans.magnitudeLow, 10);
|
||||
assert.equal(trans.magnitudeHigh, 25);
|
||||
assert.equal(trans.templateId, 'hormuz_blockade');
|
||||
assert.equal(trans.templateVersion, '1.0.0');
|
||||
});
|
||||
|
||||
it('adapts TriggerLadder with all three buckets and nested TriggerThreshold', () => {
|
||||
const result = adaptSnapshot({
|
||||
triggers: {
|
||||
active: [
|
||||
{
|
||||
id: 'hormuz_transit_drop',
|
||||
description: 'Hormuz transit drops',
|
||||
threshold: {
|
||||
metric: 'chokepoint:hormuz:transit_count',
|
||||
operator: 'delta_lt',
|
||||
value: -0.20,
|
||||
window_minutes: 1440,
|
||||
baseline: 'trailing_7d',
|
||||
},
|
||||
activated: true,
|
||||
activated_at: 1_700_000_000_000,
|
||||
scenario_lane: 'escalation',
|
||||
evidence_ids: ['e1'],
|
||||
},
|
||||
],
|
||||
watching: [],
|
||||
dormant: [],
|
||||
},
|
||||
});
|
||||
assert.ok(result.triggers);
|
||||
assert.equal(result.triggers.active.length, 1);
|
||||
assert.equal(result.triggers.watching.length, 0);
|
||||
assert.equal(result.triggers.dormant.length, 0);
|
||||
const trigger = result.triggers.active[0];
|
||||
assert.ok(trigger);
|
||||
assert.equal(trigger.id, 'hormuz_transit_drop');
|
||||
assert.equal(trigger.activated, true);
|
||||
assert.equal(trigger.activatedAt, 1_700_000_000_000);
|
||||
assert.equal(trigger.scenarioLane, 'escalation');
|
||||
assert.ok(trigger.threshold);
|
||||
assert.equal(trigger.threshold.metric, 'chokepoint:hormuz:transit_count');
|
||||
assert.equal(trigger.threshold.operator, 'delta_lt');
|
||||
assert.equal(trigger.threshold.value, -0.20);
|
||||
assert.equal(trigger.threshold.windowMinutes, 1440);
|
||||
assert.equal(trigger.threshold.baseline, 'trailing_7d');
|
||||
});
|
||||
|
||||
it('adapts MobilityState with nested airspace/flight/airport arrays', () => {
|
||||
const result = adaptSnapshot({
|
||||
mobility: {
|
||||
airspace: [{ airspace_id: 'LLLL', status: 'restricted', reason: 'conflict' }],
|
||||
flight_corridors: [{ corridor: 'Tehran-Baghdad', stress_level: 0.8, rerouted_flights_24h: 42 }],
|
||||
airports: [{ icao: 'OIIE', name: 'Imam Khomeini', status: 'disrupted', disruption_reason: 'drills' }],
|
||||
reroute_intensity: 0.35,
|
||||
notam_closures: ['OIIX-A0042'],
|
||||
},
|
||||
});
|
||||
assert.ok(result.mobility);
|
||||
assert.equal(result.mobility.airspace.length, 1);
|
||||
const airspace = result.mobility.airspace[0];
|
||||
assert.ok(airspace);
|
||||
assert.equal(airspace.airspaceId, 'LLLL');
|
||||
assert.equal(result.mobility.flightCorridors.length, 1);
|
||||
const corr = result.mobility.flightCorridors[0];
|
||||
assert.ok(corr);
|
||||
assert.equal(corr.reroutedFlights24h, 42);
|
||||
assert.equal(result.mobility.airports.length, 1);
|
||||
const airport = result.mobility.airports[0];
|
||||
assert.ok(airport);
|
||||
assert.equal(airport.disruptionReason, 'drills');
|
||||
assert.equal(result.mobility.rerouteIntensity, 0.35);
|
||||
assert.deepEqual(result.mobility.notamClosures, ['OIIX-A0042']);
|
||||
});
|
||||
|
||||
it('adapts EvidenceItem array', () => {
|
||||
const result = adaptSnapshot({
|
||||
evidence: [
|
||||
{
|
||||
id: 'cii:IR',
|
||||
type: 'cii_spike',
|
||||
source: 'risk-scores',
|
||||
summary: 'IR CII 78 (UP)',
|
||||
confidence: 0.9,
|
||||
observed_at: 1_700_000_000_000,
|
||||
theater: '',
|
||||
corridor: '',
|
||||
},
|
||||
],
|
||||
});
|
||||
assert.equal(result.evidence.length, 1);
|
||||
const ev = result.evidence[0];
|
||||
assert.ok(ev);
|
||||
assert.equal(ev.id, 'cii:IR');
|
||||
assert.equal(ev.type, 'cii_spike');
|
||||
assert.equal(ev.source, 'risk-scores');
|
||||
assert.equal(ev.observedAt, 1_700_000_000_000);
|
||||
});
|
||||
|
||||
it('adapts RegionalNarrative with all 5 sections plus watch_items', () => {
|
||||
const result = adaptSnapshot({
|
||||
narrative: {
|
||||
situation: { text: 'Situation text', evidence_ids: ['s1'] },
|
||||
balance_assessment: { text: 'Balance text', evidence_ids: ['b1'] },
|
||||
outlook_24h: { text: '24h outlook', evidence_ids: ['o24'] },
|
||||
outlook_7d: { text: '7d outlook', evidence_ids: ['o7'] },
|
||||
outlook_30d: { text: '30d outlook', evidence_ids: ['o30'] },
|
||||
watch_items: [
|
||||
{ text: 'Item 1', evidence_ids: ['w1'] },
|
||||
{ text: 'Item 2', evidence_ids: ['w2'] },
|
||||
],
|
||||
},
|
||||
});
|
||||
assert.ok(result.narrative);
|
||||
assert.equal(result.narrative.situation?.text, 'Situation text');
|
||||
assert.deepEqual(result.narrative.situation?.evidenceIds, ['s1']);
|
||||
assert.equal(result.narrative.balanceAssessment?.text, 'Balance text');
|
||||
assert.equal(result.narrative.outlook24h?.text, '24h outlook');
|
||||
assert.equal(result.narrative.outlook7d?.text, '7d outlook');
|
||||
assert.equal(result.narrative.outlook30d?.text, '30d outlook');
|
||||
assert.equal(result.narrative.watchItems.length, 2);
|
||||
assert.equal(result.narrative.watchItems[0]?.text, 'Item 1');
|
||||
});
|
||||
|
||||
it('is robust to missing nested fields (empty array defaults)', () => {
|
||||
const result = adaptSnapshot({
|
||||
region_id: 'mena',
|
||||
generated_at: 1_700_000_000_000,
|
||||
balance: {},
|
||||
triggers: {},
|
||||
mobility: {},
|
||||
narrative: {},
|
||||
});
|
||||
assert.ok(result.balance);
|
||||
assert.equal(result.balance.coercivePressure, 0);
|
||||
assert.deepEqual(result.balance.pressures, []);
|
||||
assert.ok(result.triggers);
|
||||
assert.deepEqual(result.triggers.active, []);
|
||||
assert.ok(result.mobility);
|
||||
assert.deepEqual(result.mobility.airspace, []);
|
||||
assert.ok(result.narrative);
|
||||
assert.deepEqual(result.narrative.watchItems, []);
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Handler structural checks (static analysis)
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('get-regional-snapshot handler: structural checks', () => {
|
||||
it('imports getCachedJson from redis helpers', () => {
|
||||
assert.match(handlerSrc, /import\s*\{\s*getCachedJson\s*\}\s*from\s*'\.\.\/\.\.\/\.\.\/_shared\/redis'/);
|
||||
});
|
||||
|
||||
it('uses the canonical :latest key prefix', () => {
|
||||
assert.match(handlerSrc, /'intelligence:snapshot:v1:'/);
|
||||
});
|
||||
|
||||
it('uses the canonical snapshot-by-id key prefix', () => {
|
||||
assert.match(handlerSrc, /'intelligence:snapshot-by-id:v1:'/);
|
||||
});
|
||||
|
||||
it('reads latest pointer then snapshot-by-id (two-hop lookup)', () => {
|
||||
// latest resolved before snapKey construction
|
||||
const latestIdx = handlerSrc.indexOf('latestKey');
|
||||
const snapKeyIdx = handlerSrc.indexOf('snapKey');
|
||||
assert.ok(latestIdx > 0 && snapKeyIdx > latestIdx, 'latest must resolve before snap lookup');
|
||||
});
|
||||
|
||||
it('returns empty response on missing snapshot id', () => {
|
||||
assert.match(handlerSrc, /if \(!snapshotId\) \{\s*return \{\}/);
|
||||
});
|
||||
|
||||
it('returns empty response on missing persisted snapshot', () => {
|
||||
assert.match(handlerSrc, /if \(!persisted \|\| typeof persisted !== 'object'\) \{\s*return \{\}/);
|
||||
});
|
||||
|
||||
it('calls adaptSnapshot to produce the wire shape', () => {
|
||||
assert.match(handlerSrc, /adaptSnapshot\(persisted\)/);
|
||||
});
|
||||
|
||||
it('exports getRegionalSnapshot handler matching the service interface', () => {
|
||||
assert.match(handlerSrc, /export const getRegionalSnapshot: IntelligenceServiceHandler\['getRegionalSnapshot'\]/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('intelligence handler registration', () => {
|
||||
it('imports getRegionalSnapshot from get-regional-snapshot module', () => {
|
||||
assert.match(handlerIndexSrc, /import \{ getRegionalSnapshot \} from '\.\/get-regional-snapshot'/);
|
||||
});
|
||||
|
||||
it('registers getRegionalSnapshot on the handler object', () => {
|
||||
assert.match(handlerIndexSrc, /\s+getRegionalSnapshot,/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('security wiring', () => {
|
||||
it('adds the endpoint to PREMIUM_RPC_PATHS', () => {
|
||||
assert.match(premiumPathsSrc, /'\/api\/intelligence\/v1\/get-regional-snapshot'/);
|
||||
});
|
||||
|
||||
it('has a RPC_CACHE_TIER entry for route-parity (even though premium paths bypass it)', () => {
|
||||
// At runtime the gateway checks PREMIUM_RPC_PATHS first and short-circuits
|
||||
// to 'slow-browser' regardless of RPC_CACHE_TIER. The entry exists to satisfy
|
||||
// tests/route-cache-tier.test.mjs which enforces that every generated GET
|
||||
// route has an explicit tier, and documents the intended tier if the endpoint
|
||||
// ever becomes non-premium.
|
||||
assert.match(gatewaySrc, /'\/api\/intelligence\/v1\/get-regional-snapshot':\s*'slow'/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('proto definition', () => {
|
||||
it('declares the GetRegionalSnapshot RPC method', () => {
|
||||
const serviceProtoSrc = readFileSync(
|
||||
resolve(root, 'proto/worldmonitor/intelligence/v1/service.proto'),
|
||||
'utf-8',
|
||||
);
|
||||
assert.match(serviceProtoSrc, /rpc GetRegionalSnapshot\(GetRegionalSnapshotRequest\) returns \(GetRegionalSnapshotResponse\)/);
|
||||
});
|
||||
|
||||
it('validates region_id as strict lowercase kebab pattern (no trailing or consecutive hyphens)', () => {
|
||||
// Pattern: ^[a-z][a-z0-9]*(-[a-z0-9]+)*$
|
||||
// - Starts with a lowercase letter
|
||||
// - Each hyphen must be followed by at least one alphanumeric character
|
||||
// - Rejects "mena-", "east-asia-", "foo--bar"
|
||||
assert.match(
|
||||
protoSrc,
|
||||
/buf\.validate\.field\)\.string\.pattern = "\^\[a-z\]\[a-z0-9\]\*\(-\[a-z0-9\]\+\)\*\$"/,
|
||||
);
|
||||
});
|
||||
|
||||
it('defines RegionalSnapshot with all 13 top-level fields', () => {
|
||||
assert.match(protoSrc, /message RegionalSnapshot \{/);
|
||||
assert.match(protoSrc, /string region_id = 1/);
|
||||
assert.match(protoSrc, /int64 generated_at = 2/);
|
||||
assert.match(protoSrc, /SnapshotMeta meta = 3/);
|
||||
assert.match(protoSrc, /RegimeState regime = 4/);
|
||||
assert.match(protoSrc, /BalanceVector balance = 5/);
|
||||
assert.match(protoSrc, /repeated ActorState actors = 6/);
|
||||
assert.match(protoSrc, /repeated LeverageEdge leverage_edges = 7/);
|
||||
assert.match(protoSrc, /repeated ScenarioSet scenario_sets = 8/);
|
||||
assert.match(protoSrc, /repeated TransmissionPath transmission_paths = 9/);
|
||||
assert.match(protoSrc, /TriggerLadder triggers = 10/);
|
||||
assert.match(protoSrc, /MobilityState mobility = 11/);
|
||||
assert.match(protoSrc, /repeated EvidenceItem evidence = 12/);
|
||||
assert.match(protoSrc, /RegionalNarrative narrative = 13/);
|
||||
});
|
||||
|
||||
it('defines BalanceVector with all 7 axes plus net_balance and drivers', () => {
|
||||
assert.match(protoSrc, /double coercive_pressure = 1/);
|
||||
assert.match(protoSrc, /double domestic_fragility = 2/);
|
||||
assert.match(protoSrc, /double capital_stress = 3/);
|
||||
assert.match(protoSrc, /double energy_vulnerability = 4/);
|
||||
assert.match(protoSrc, /double alliance_cohesion = 5/);
|
||||
assert.match(protoSrc, /double maritime_access = 6/);
|
||||
assert.match(protoSrc, /double energy_leverage = 7/);
|
||||
assert.match(protoSrc, /double net_balance = 8/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user