feat(forecast): AI Forecasts prediction module (#1579)

* feat(forecast): add AI Forecasts prediction module (Pro-tier)

MiroFish-inspired prediction engine that generates structured forecasts
across 6 domains (conflict, market, supply chain, political, military,
infrastructure) using existing WorldMonitor data streams.

- Proto definitions for ForecastService with GetForecasts RPC
- Dedicated seed script (seed-forecasts.mjs) with 6 domain detectors,
  cross-domain cascade resolver, prediction market calibration, and
  trend detection via prior snapshot comparison
- Premium-gated RPC handler (PREMIUM_RPC_PATHS enforcement)
- Lazy-loaded ForecastPanel with domain filters, probability bars,
  trend arrows, signal evidence, and cascade links
- Health monitoring integration (seed-meta freshness tracking)
- Refresh scheduler with API key guard

* test(forecast): add 47 unit tests for forecast detectors and utilities

Covers forecastId, normalize, resolveCascades, calibrateWithMarkets,
computeTrends, and smoke tests for all 6 domain detectors. Exports
testable functions from seed script with direct-run guard.

* fix(forecast): domain mismatch 'infra' vs 'infrastructure', add panel category

- Seed script used 'infra' but ForecastPanel filtered on 'infrastructure',
  causing Infra tab to show zero results
- Added 'forecast' to intelligence category in PANEL_CATEGORY_MAP

* fix(forecast): move CSS to one-time injection, improve type safety

- P2: Move style block from setContent to one-time document.head injection
  to prevent CSS accumulation on repeated renders
- P3: Replace +toFixed(3) with Math.round for readability in seed script
- P3: Use Forecast type instead of any[] in RPC handler filter

* fix(forecast): handle sebuf proto data shapes from Redis

Detectors now normalize CII scores from server-side proto format
(combinedScore, TREND_DIRECTION_RISING, region) to uniform shape.
Outage severity handles proto enum format (SEVERITY_LEVEL_HIGH).
Added confidence floor of 0.3 for single-source predictions.

Verified against live Redis: 2 predictions generated (Iran infra
shutdown, IL political instability).

* feat(forecast): unlock AI Forecasts on web, lock desktop only (trial)

- Remove forecast RPC from PREMIUM_RPC_PATHS (web access is free)
- Panel locked on desktop only (same as oref-sirens/telegram-intel)
- Remove API key guards from data-loader and refresh scheduler
- Web users get full access during trial period

* chore: regenerate proto types with make generate

Re-ran make generate after rebasing on main. Plugin v0.7.0 dropped
@ts-nocheck from output, added it back to all 50 generated files.
Fixed 4 type errors from proto codegen changes:
- MarketSource enum -> string union type
- TemporalAnomalyProto -> TemporalAnomaly rename
- webcam lastUpdated number -> string

* fix(forecast): use chokepoints v4 key, include ciiContribution in unrest

- P1: Switch chokepoints input from stale v2 to active v4 Redis key,
  matching bootstrap.js and cache-keys.ts
- P2: Add ciiContribution to unrest component fallback chain in
  normalizeCiiEntry so political detector reads the correct sebuf field

* feat(forecast): Phase 2 LLM scenario enrichment + confidence model

MiroFish-inspired enhancements:
- LLM scenario narratives via Groq/OpenRouter (narrative-only, no numeric
  adjustment). Evidence-grounded prompts with mandatory signal citation
  and few-shot examples from MiroFish's SECTION_SYSTEM_PROMPT_TEMPLATE.
- Top-4 predictions batched into single LLM call for cost efficiency.
- News context from newsInsights attached to all predictions for LLM
  prompt grounding (NOT in signals, cannot affect confidence).
- Deterministic confidence model: source diversity via SIGNAL_TO_SOURCE
  mapping (deduplicates cii+cii_delta, theater+indicators) + calibration
  agreement from prediction market drift. Floor 0.2, ceiling 1.0.
- Output validation: rejects scenarios without signal references.
- Truncated JSON repair for small model output.
- Structured JSON logging for LLM calls.
- Redis cache for LLM scenarios (1h TTL).
- 23 new tests (70 total), all passing.
- Live-tested: OpenRouter gemini-2.5-flash produces evidence-grounded
  scenario narratives from real WorldMonitor data.

* feat(forecast): Phase 3 multi-perspective scenarios, projections, data-driven cascades

MiroFish-inspired enhancements:
- Multi-perspective LLM analysis: top-2 predictions get strategic,
  regional, and contrarian viewpoints via combined LLM call
- Probability projections: domain-specific decay curves (h24/d7/d30)
  anchored to timeHorizon so probability equals projections[timeHorizon]
- Data-driven cascade rules: moved from hardcoded array to JSON config
  (scripts/data/cascade-rules.json) with schema validation, named
  predicate evaluators, unknown key rejection, and fallback to defaults
- 4 new cascade paths: infrastructure->supply_chain, infrastructure->market
  (both requiresSeverity:total), conflict->political, political->market
- Proto: added Perspectives and Projections messages to Forecast
- ForecastPanel: renders projections row and conditional perspectives toggle
- 89 tests (19 new), all passing
- Live-tested: OpenRouter produces perspectives from real data

* feat(forecast): Phase 4 data utilization + entity graph

Fixes data gaps that prevented 4 of 6 detectors from firing:
- Input normalizers: chokepoint v4 shape + GPS hexes-to-zones mapping
- Chokepoint warm-ping (production-only, requires WM_API_BASE_URL)
- Lowered CII conflict threshold from 70 to 60, gated on level=high|critical

4 new standalone detectors:
- UCDP conflict zones (10+ events per country)
- Cyber threat concentration (5+ threats per country)
- GPS jamming in maritime shipping zones (5 regions)
- Prediction markets as signals (60-90% probability markets)

Entity-relationship graph (file-based, 38 nodes):
- Countries, theaters, commodities, chokepoints, alliances
- Alias table resolves both ISO codes and display names
- Graph cascade discovery links predictions across entities

Result: 51 predictions (up from 1-2), spanning conflict, infrastructure,
and supply chain domains. 112 tests, all passing.

* fix(forecast): redis cache format, signal source mapping, type safety

Fresh-eyes audit fixes:
- BUG: redisSet used wrong Upstash API format (POST body with {value,ex}
  instead of command array ['SET',key,value,'EX',ttl]). LLM cache writes
  were silently failing, causing fresh LLM calls every run.
- BUG: prediction_market signal type missing from SIGNAL_TO_SOURCE,
  inflating confidence for market-derived predictions.
- CLEANUP: Remove unnecessary (f as any) casts in ForecastPanel since
  generated Forecast type already has projections/perspectives fields.
- CLEANUP: Bump health maxStaleMin from 60 to 90 to avoid false STALE
  alerts when LLM calls add latency to seed runs.

* feat(forecast): headline-entity matching with news corroboration signals

Uses entity graph aliases to match headlines to predictions by
country/theater (excludes commodity/infrastructure nodes to prevent
false positives). Predictions with matching headlines get a
news_corroboration signal visible in the panel.

Also fixes buildUserPrompt to merge unique headlines from ALL
predictions in the LLM batch (was only reading preds[0].newsContext).

Live-tested: 13 of 51 predictions now have corroborating headlines
(Iran, Israel, Syria, Ukraine, etc). 116 tests, all passing.

* feat(forecast): add country-codes.json for headline-entity matching

56 countries with ISO codes, full names, and scoring keywords (extracted
from src/config/countries.ts + UCDP-relevant additions). Used by
attachNewsContext for richer headline matching via getSearchTermsForRegion
which combines country-codes + entity graph + keyword aliases.

14/57 predictions now have news corroboration (limited by headline
coverage, not matching quality: only 8 headlines currently available).

* feat(forecast): read 300 headlines from news digest instead of 8

Read news:digest:v1:full:en (300 headlines across 16 categories) instead
of just news:insights:v1 topStories (8 headlines). Fallback to topStories
if digest is unavailable.

Result: news corroboration jumped from 25% to 64% (38/59 predictions).

* fix(forecast): handle parenthetical country names in headline matching

Strip suffixes like '(Zaire)', '(Burma)', '(Soviet Union)' from UCDP
region names before matching against country-codes.json. Also use
includes() for reverse name lookup to catch partial matches.

Corroboration: 64% -> 69% (41/59). Remaining 18 unmatched are countries
with no current English-language news coverage.

* fix(forecast): cache validated LLM output, add digest test, log cache errors

Fresh-eyes audit fixes:
- Combined LLM cache now stores only validated items (was caching raw
  unvalidated output, serving potentially invalid scenarios on cache hit)
- redisSet logs warnings on failure (was silently swallowing all errors)
- Added digest-based test for attachNewsContext (primary path was untested)
- Fixed test arity: attachNewsContext(preds, news, digest) with 3 params

* fix(forecast): remove dead confidenceFromSources, reduce warm-ping timeout

- P2: Remove confidenceFromSources (dead code, computeConfidence overwrites
  all initial confidence values). Inline the formula in original detectors.
- P3: Reduce warm-ping timeout from 30s to 15s (non-critical step)
- P3: Add trial status comment on forecast panel config

* fix(forecast): resolve ISO codes to country names, fix market detector, safe pre-push

P1 fixes from code review:
- CII ISO codes (IL, IR) now resolved to full country names (Israel, Iran)
  via country-codes.json. Prevents substring false positives (IL matching
  Chile) in event correlation. Uses word-boundary regex for matching.
- Market detector CII-to-theater mapping now uses entity graph traversal
  instead of broken theater-name substring matching. Iran correctly maps
  to Middle East theater via graph links.
- Pre-push hook no longer runs destructive git checkout on proto freshness
  failure. Reports mismatch and exits without modifying worktree.
This commit is contained in:
Elie Habib
2026-03-15 01:42:04 +04:00
committed by GitHub
parent 3f24169a05
commit 45f5e5a457
67 changed files with 7477 additions and 822 deletions

9
api/forecast/v1/[rpc].ts Normal file
View File

@@ -0,0 +1,9 @@
export const config = { runtime: 'edge' };
import { createDomainGateway, serverOptions } from '../../../server/gateway';
import { createForecastServiceRoutes } from '../../../src/generated/server/worldmonitor/forecast/v1/service_server';
import { forecastHandler } from '../../../server/worldmonitor/forecast/v1/handler';
export default createDomainGateway(
createForecastServiceRoutes(forecastHandler, serverOptions),
);

View File

@@ -30,6 +30,7 @@ const BOOTSTRAP_KEYS = {
techEvents: 'research:tech-events-bootstrap:v1',
gdeltIntel: 'intelligence:gdelt-intel:v1',
correlationCards: 'correlation:cards-bootstrap:v1',
forecasts: 'forecast:predictions:v1',
};
const STANDALONE_KEYS = {
@@ -107,6 +108,7 @@ const SEED_META = {
spending: { key: 'seed-meta:economic:spending', maxStaleMin: 120 },
techEvents: { key: 'seed-meta:research:tech-events', maxStaleMin: 420 },
gdeltIntel: { key: 'seed-meta:intelligence:gdelt-intel', maxStaleMin: 120 },
forecasts: { key: 'seed-meta:forecast:predictions', maxStaleMin: 90 },
sectors: { key: 'seed-meta:market:sectors', maxStaleMin: 30 },
techReadiness: { key: 'seed-meta:economic:worldbank-techreadiness:v1', maxStaleMin: 10080 },
progressData: { key: 'seed-meta:economic:worldbank-progress:v1', maxStaleMin: 10080 },

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -182,6 +182,38 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/Error'
/api/conflict/v1/get-humanitarian-summary-batch:
post:
tags:
- ConflictService
summary: GetHumanitarianSummaryBatch
description: GetHumanitarianSummaryBatch retrieves humanitarian summaries for multiple countries in one call.
operationId: GetHumanitarianSummaryBatch
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/GetHumanitarianSummaryBatchRequest'
required: true
responses:
"200":
description: Successful response
content:
application/json:
schema:
$ref: '#/components/schemas/GetHumanitarianSummaryBatchResponse'
"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:
@@ -482,3 +514,40 @@ components:
format: int64
severity:
type: string
GetHumanitarianSummaryBatchRequest:
type: object
properties:
countryCodes:
type: array
items:
type: string
maxItems: 25
minItems: 1
description: ISO 3166-1 alpha-2 country codes (e.g., "YE", "SD"). Max 25.
maxItems: 25
minItems: 1
description: GetHumanitarianSummaryBatchRequest looks up humanitarian summaries for multiple countries.
GetHumanitarianSummaryBatchResponse:
type: object
properties:
results:
type: object
additionalProperties:
$ref: '#/components/schemas/HumanitarianCountrySummary'
description: Map of country_code -> humanitarian summary for found countries.
fetched:
type: integer
format: int32
description: Number of countries successfully fetched.
requested:
type: integer
format: int32
description: Number of countries requested.
description: GetHumanitarianSummaryBatchResponse contains humanitarian summaries for the requested countries.
ResultsEntry:
type: object
properties:
key:
type: string
value:
$ref: '#/components/schemas/HumanitarianCountrySummary'

File diff suppressed because one or more lines are too long

View File

@@ -281,6 +281,38 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/Error'
/api/economic/v1/get-fred-series-batch:
post:
tags:
- EconomicService
summary: GetFredSeriesBatch
description: GetFredSeriesBatch retrieves multiple FRED series in a single call.
operationId: GetFredSeriesBatch
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/GetFredSeriesBatchRequest'
required: true
responses:
"200":
description: Successful response
content:
application/json:
schema:
$ref: '#/components/schemas/GetFredSeriesBatchResponse'
"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:
@@ -813,3 +845,44 @@ components:
type: string
description: Date as YYYY-QN.
description: BisCreditToGdp represents total credit as percentage of GDP from BIS.
GetFredSeriesBatchRequest:
type: object
properties:
seriesIds:
type: array
items:
type: string
maxItems: 10
minItems: 1
description: FRED series IDs (e.g., "WALCL", "FEDFUNDS"). Max 10.
maxItems: 10
minItems: 1
limit:
type: integer
format: int32
description: Maximum number of observations per series. Defaults to 120.
description: GetFredSeriesBatchRequest looks up multiple FRED series in a single call.
GetFredSeriesBatchResponse:
type: object
properties:
results:
type: object
additionalProperties:
$ref: '#/components/schemas/FredSeries'
description: Map of series_id -> FRED series for found series.
fetched:
type: integer
format: int32
description: Number of series successfully fetched.
requested:
type: integer
format: int32
description: Number of series requested.
description: GetFredSeriesBatchResponse contains the requested FRED series data.
ResultsEntry:
type: object
properties:
key:
type: string
value:
$ref: '#/components/schemas/FredSeries'

View File

@@ -0,0 +1 @@
{"components":{"schemas":{"CalibrationInfo":{"properties":{"drift":{"format":"double","type":"number"},"marketPrice":{"format":"double","type":"number"},"marketTitle":{"type":"string"},"source":{"type":"string"}},"type":"object"},"CascadeEffect":{"properties":{"domain":{"type":"string"},"effect":{"type":"string"},"probability":{"format":"double","type":"number"}},"type":"object"},"Error":{"description":"Error is returned when a handler encounters an error. It contains a simple error message that the developer can customize.","properties":{"message":{"description":"Error message (e.g., 'user not found', 'database connection failed')","type":"string"}},"type":"object"},"FieldViolation":{"description":"FieldViolation describes a single validation error for a specific field.","properties":{"description":{"description":"Human-readable description of the validation violation (e.g., 'must be a valid email address', 'required field missing')","type":"string"},"field":{"description":"The field path that failed validation (e.g., 'user.email' for nested fields). For header validation, this will be the header name (e.g., 'X-API-Key')","type":"string"}},"required":["field","description"],"type":"object"},"Forecast":{"properties":{"calibration":{"$ref":"#/components/schemas/CalibrationInfo"},"cascades":{"items":{"$ref":"#/components/schemas/CascadeEffect"},"type":"array"},"confidence":{"format":"double","type":"number"},"createdAt":{"description":"Warning: Values \u003e 2^53 may lose precision in JavaScript","format":"int64","type":"integer"},"domain":{"type":"string"},"id":{"type":"string"},"perspectives":{"$ref":"#/components/schemas/Perspectives"},"priorProbability":{"format":"double","type":"number"},"probability":{"format":"double","type":"number"},"projections":{"$ref":"#/components/schemas/Projections"},"region":{"type":"string"},"scenario":{"type":"string"},"signals":{"items":{"$ref":"#/components/schemas/ForecastSignal"},"type":"array"},"timeHorizon":{"type":"string"},"title":{"type":"string"},"trend":{"type":"string"},"updatedAt":{"description":"Warning: Values \u003e 2^53 may lose precision in JavaScript","format":"int64","type":"integer"}},"type":"object"},"ForecastSignal":{"properties":{"type":{"type":"string"},"value":{"type":"string"},"weight":{"format":"double","type":"number"}},"type":"object"},"GetForecastsRequest":{"properties":{"domain":{"type":"string"},"region":{"type":"string"}},"type":"object"},"GetForecastsResponse":{"properties":{"forecasts":{"items":{"$ref":"#/components/schemas/Forecast"},"type":"array"},"generatedAt":{"description":"Warning: Values \u003e 2^53 may lose precision in JavaScript","format":"int64","type":"integer"}},"type":"object"},"Perspectives":{"properties":{"contrarian":{"type":"string"},"regional":{"type":"string"},"strategic":{"type":"string"}},"type":"object"},"Projections":{"properties":{"d30":{"format":"double","type":"number"},"d7":{"format":"double","type":"number"},"h24":{"format":"double","type":"number"}},"type":"object"},"ValidationError":{"description":"ValidationError is returned when request validation fails. It contains a list of field violations describing what went wrong.","properties":{"violations":{"description":"List of validation violations","items":{"$ref":"#/components/schemas/FieldViolation"},"type":"array"}},"required":["violations"],"type":"object"}}},"info":{"title":"ForecastService API","version":"1.0.0"},"openapi":"3.1.0","paths":{"/api/forecast/v1/get-forecasts":{"get":{"operationId":"GetForecasts","parameters":[{"in":"query","name":"domain","required":false,"schema":{"type":"string"}},{"in":"query","name":"region","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetForecastsResponse"}}},"description":"Successful response"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationError"}}},"description":"Validation error"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Error response"}},"summary":"GetForecasts","tags":["ForecastService"]}}}}

View File

@@ -0,0 +1,194 @@
openapi: 3.1.0
info:
title: ForecastService API
version: 1.0.0
paths:
/api/forecast/v1/get-forecasts:
get:
tags:
- ForecastService
summary: GetForecasts
operationId: GetForecasts
parameters:
- name: domain
in: query
required: false
schema:
type: string
- name: region
in: query
required: false
schema:
type: string
responses:
"200":
description: Successful response
content:
application/json:
schema:
$ref: '#/components/schemas/GetForecastsResponse'
"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:
type: object
properties:
message:
type: string
description: Error message (e.g., 'user not found', 'database connection failed')
description: Error is returned when a handler encounters an error. It contains a simple error message that the developer can customize.
FieldViolation:
type: object
properties:
field:
type: string
description: The field path that failed validation (e.g., 'user.email' for nested fields). For header validation, this will be the header name (e.g., 'X-API-Key')
description:
type: string
description: Human-readable description of the validation violation (e.g., 'must be a valid email address', 'required field missing')
required:
- field
- description
description: FieldViolation describes a single validation error for a specific field.
ValidationError:
type: object
properties:
violations:
type: array
items:
$ref: '#/components/schemas/FieldViolation'
description: List of validation violations
required:
- violations
description: ValidationError is returned when request validation fails. It contains a list of field violations describing what went wrong.
GetForecastsRequest:
type: object
properties:
domain:
type: string
region:
type: string
GetForecastsResponse:
type: object
properties:
forecasts:
type: array
items:
$ref: '#/components/schemas/Forecast'
generatedAt:
type: integer
format: int64
description: 'Warning: Values > 2^53 may lose precision in JavaScript'
Forecast:
type: object
properties:
id:
type: string
domain:
type: string
region:
type: string
title:
type: string
scenario:
type: string
probability:
type: number
format: double
confidence:
type: number
format: double
timeHorizon:
type: string
signals:
type: array
items:
$ref: '#/components/schemas/ForecastSignal'
cascades:
type: array
items:
$ref: '#/components/schemas/CascadeEffect'
trend:
type: string
priorProbability:
type: number
format: double
calibration:
$ref: '#/components/schemas/CalibrationInfo'
createdAt:
type: integer
format: int64
description: 'Warning: Values > 2^53 may lose precision in JavaScript'
updatedAt:
type: integer
format: int64
description: 'Warning: Values > 2^53 may lose precision in JavaScript'
perspectives:
$ref: '#/components/schemas/Perspectives'
projections:
$ref: '#/components/schemas/Projections'
ForecastSignal:
type: object
properties:
type:
type: string
value:
type: string
weight:
type: number
format: double
CascadeEffect:
type: object
properties:
domain:
type: string
effect:
type: string
probability:
type: number
format: double
CalibrationInfo:
type: object
properties:
marketTitle:
type: string
marketPrice:
type: number
format: double
drift:
type: number
format: double
source:
type: string
Perspectives:
type: object
properties:
strategic:
type: string
regional:
type: string
contrarian:
type: string
Projections:
type: object
properties:
h24:
type: number
format: double
d7:
type: number
format: double
d30:
type: number
format: double

View File

@@ -0,0 +1 @@
{"components":{"schemas":{"Error":{"description":"Error is returned when a handler encounters an error. It contains a simple error message that the developer can customize.","properties":{"message":{"description":"Error message (e.g., 'user not found', 'database connection failed')","type":"string"}},"type":"object"},"FieldViolation":{"description":"FieldViolation describes a single validation error for a specific field.","properties":{"description":{"description":"Human-readable description of the validation violation (e.g., 'must be a valid email address', 'required field missing')","type":"string"},"field":{"description":"The field path that failed validation (e.g., 'user.email' for nested fields). For header validation, this will be the header name (e.g., 'X-API-Key')","type":"string"}},"required":["field","description"],"type":"object"},"ImageryScene":{"properties":{"assetUrl":{"type":"string"},"datetime":{"type":"string"},"geometryGeojson":{"type":"string"},"id":{"type":"string"},"mode":{"type":"string"},"previewUrl":{"type":"string"},"resolutionM":{"format":"double","type":"number"},"satellite":{"type":"string"}},"type":"object"},"SearchImageryRequest":{"properties":{"bbox":{"type":"string"},"datetime":{"type":"string"},"limit":{"format":"int32","type":"integer"},"source":{"type":"string"}},"type":"object"},"SearchImageryResponse":{"properties":{"cacheHit":{"type":"boolean"},"scenes":{"items":{"$ref":"#/components/schemas/ImageryScene"},"type":"array"},"totalResults":{"format":"int32","type":"integer"}},"type":"object"},"ValidationError":{"description":"ValidationError is returned when request validation fails. It contains a list of field violations describing what went wrong.","properties":{"violations":{"description":"List of validation violations","items":{"$ref":"#/components/schemas/FieldViolation"},"type":"array"}},"required":["violations"],"type":"object"}}},"info":{"title":"ImageryService API","version":"1.0.0"},"openapi":"3.1.0","paths":{"/api/imagery/v1/search-imagery":{"get":{"operationId":"SearchImagery","parameters":[{"in":"query","name":"bbox","required":false,"schema":{"type":"string"}},{"in":"query","name":"datetime","required":false,"schema":{"type":"string"}},{"in":"query","name":"source","required":false,"schema":{"type":"string"}},{"in":"query","name":"limit","required":false,"schema":{"format":"int32","type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SearchImageryResponse"}}},"description":"Successful response"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationError"}}},"description":"Validation error"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Error response"}},"summary":"SearchImagery","tags":["ImageryService"]}}}}

View File

@@ -0,0 +1,129 @@
openapi: 3.1.0
info:
title: ImageryService API
version: 1.0.0
paths:
/api/imagery/v1/search-imagery:
get:
tags:
- ImageryService
summary: SearchImagery
operationId: SearchImagery
parameters:
- name: bbox
in: query
required: false
schema:
type: string
- name: datetime
in: query
required: false
schema:
type: string
- name: source
in: query
required: false
schema:
type: string
- name: limit
in: query
required: false
schema:
type: integer
format: int32
responses:
"200":
description: Successful response
content:
application/json:
schema:
$ref: '#/components/schemas/SearchImageryResponse'
"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:
type: object
properties:
message:
type: string
description: Error message (e.g., 'user not found', 'database connection failed')
description: Error is returned when a handler encounters an error. It contains a simple error message that the developer can customize.
FieldViolation:
type: object
properties:
field:
type: string
description: The field path that failed validation (e.g., 'user.email' for nested fields). For header validation, this will be the header name (e.g., 'X-API-Key')
description:
type: string
description: Human-readable description of the validation violation (e.g., 'must be a valid email address', 'required field missing')
required:
- field
- description
description: FieldViolation describes a single validation error for a specific field.
ValidationError:
type: object
properties:
violations:
type: array
items:
$ref: '#/components/schemas/FieldViolation'
description: List of validation violations
required:
- violations
description: ValidationError is returned when request validation fails. It contains a list of field violations describing what went wrong.
SearchImageryRequest:
type: object
properties:
bbox:
type: string
datetime:
type: string
source:
type: string
limit:
type: integer
format: int32
SearchImageryResponse:
type: object
properties:
scenes:
type: array
items:
$ref: '#/components/schemas/ImageryScene'
totalResults:
type: integer
format: int32
cacheHit:
type: boolean
ImageryScene:
type: object
properties:
id:
type: string
satellite:
type: string
datetime:
type: string
resolutionM:
type: number
format: double
mode:
type: string
geometryGeojson:
type: string
previewUrl:
type: string
assetUrl:
type: string

File diff suppressed because one or more lines are too long

View File

@@ -200,6 +200,32 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/Error'
/api/infrastructure/v1/list-temporal-anomalies:
get:
tags:
- InfrastructureService
summary: ListTemporalAnomalies
description: ListTemporalAnomalies returns server-computed temporal anomalies for news and satellite_fires.
operationId: ListTemporalAnomalies
responses:
"200":
description: Successful response
content:
application/json:
schema:
$ref: '#/components/schemas/ListTemporalAnomaliesResponse'
"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:
@@ -575,3 +601,41 @@ components:
format: int64
description: 'Evidence timestamp, as Unix epoch milliseconds.. Warning: Values > 2^53 may lose precision in JavaScript'
description: CableHealthEvidence represents a single piece of evidence supporting a health assessment.
ListTemporalAnomaliesRequest:
type: object
ListTemporalAnomaliesResponse:
type: object
properties:
anomalies:
type: array
items:
$ref: '#/components/schemas/TemporalAnomaly'
trackedTypes:
type: array
items:
type: string
computedAt:
type: string
TemporalAnomaly:
type: object
properties:
type:
type: string
region:
type: string
currentCount:
type: integer
format: int32
expectedCount:
type: integer
format: int32
zScore:
type: number
format: double
severity:
type: string
multiplier:
type: number
format: double
message:
type: string

File diff suppressed because one or more lines are too long

View File

@@ -245,6 +245,38 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/Error'
/api/intelligence/v1/get-country-facts:
get:
tags:
- IntelligenceService
summary: GetCountryFacts
description: GetCountryFacts retrieves factual country data from RestCountries and Wikipedia.
operationId: GetCountryFacts
parameters:
- name: country_code
in: query
required: false
schema:
type: string
responses:
"200":
description: Successful response
content:
application/json:
schema:
$ref: '#/components/schemas/GetCountryFactsResponse'
"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:
@@ -727,3 +759,41 @@ components:
type: string
provider:
type: string
GetCountryFactsRequest:
type: object
properties:
countryCode:
type: string
pattern: ^[A-Z]{2}$
required:
- countryCode
GetCountryFactsResponse:
type: object
properties:
headOfState:
type: string
headOfStateTitle:
type: string
wikipediaSummary:
type: string
wikipediaThumbnailUrl:
type: string
population:
type: integer
format: int64
description: 'Warning: Values > 2^53 may lose precision in JavaScript'
capital:
type: string
languages:
type: array
items:
type: string
currencies:
type: array
items:
type: string
areaSqKm:
type: number
format: double
countryName:
type: string

View File

@@ -1 +1 @@
{"components":{"schemas":{"Error":{"description":"Error is returned when a handler encounters an error. It contains a simple error message that the developer can customize.","properties":{"message":{"description":"Error message (e.g., 'user not found', 'database connection failed')","type":"string"}},"type":"object"},"FieldViolation":{"description":"FieldViolation describes a single validation error for a specific field.","properties":{"description":{"description":"Human-readable description of the validation violation (e.g., 'must be a valid email address', 'required field missing')","type":"string"},"field":{"description":"The field path that failed validation (e.g., 'user.email' for nested fields). For header validation, this will be the header name (e.g., 'X-API-Key')","type":"string"}},"required":["field","description"],"type":"object"},"ListPredictionMarketsRequest":{"description":"ListPredictionMarketsRequest specifies filters for retrieving prediction markets.","properties":{"category":{"description":"Optional category filter (e.g., \"Politics\").","type":"string"},"cursor":{"description":"Cursor for next page.","type":"string"},"pageSize":{"description":"Maximum items per page (1-100).","format":"int32","type":"integer"},"query":{"description":"Optional search query for market titles.","type":"string"}},"type":"object"},"ListPredictionMarketsResponse":{"description":"ListPredictionMarketsResponse contains prediction markets matching the request.","properties":{"markets":{"items":{"$ref":"#/components/schemas/PredictionMarket"},"type":"array"},"pagination":{"$ref":"#/components/schemas/PaginationResponse"}},"type":"object"},"PaginationResponse":{"description":"PaginationResponse contains pagination metadata returned alongside list results.","properties":{"nextCursor":{"description":"Cursor for fetching the next page. Empty string indicates no more pages.","type":"string"},"totalCount":{"description":"Total count of items matching the query, if known. Zero if the total is unknown.","format":"int32","type":"integer"}},"type":"object"},"PredictionMarket":{"description":"PredictionMarket represents a prediction market contract from Polymarket.","properties":{"category":{"description":"Market category (e.g., \"Politics\", \"Crypto\", \"Sports\").","type":"string"},"closesAt":{"description":"Market close time, as Unix epoch milliseconds. Zero if no expiry.. Warning: Values \u003e 2^53 may lose precision in JavaScript","format":"int64","type":"integer"},"id":{"description":"Unique market identifier or slug.","minLength":1,"type":"string"},"title":{"description":"Market question or title.","type":"string"},"url":{"description":"URL to the Polymarket market page.","type":"string"},"volume":{"description":"Trading volume in USD.","format":"double","minimum":0,"type":"number"},"yesPrice":{"description":"Current \"Yes\" price (0.0 to 1.0, representing probability).","format":"double","maximum":1,"minimum":0,"type":"number"}},"required":["id"],"type":"object"},"ValidationError":{"description":"ValidationError is returned when request validation fails. It contains a list of field violations describing what went wrong.","properties":{"violations":{"description":"List of validation violations","items":{"$ref":"#/components/schemas/FieldViolation"},"type":"array"}},"required":["violations"],"type":"object"}}},"info":{"title":"PredictionService API","version":"1.0.0"},"openapi":"3.1.0","paths":{"/api/prediction/v1/list-prediction-markets":{"get":{"description":"ListPredictionMarkets retrieves active prediction markets from Polymarket.","operationId":"ListPredictionMarkets","parameters":[{"description":"Maximum items per page (1-100).","in":"query","name":"page_size","required":false,"schema":{"format":"int32","type":"integer"}},{"description":"Cursor for next page.","in":"query","name":"cursor","required":false,"schema":{"type":"string"}},{"description":"Optional category filter (e.g., \"Politics\").","in":"query","name":"category","required":false,"schema":{"type":"string"}},{"description":"Optional search query for market titles.","in":"query","name":"query","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListPredictionMarketsResponse"}}},"description":"Successful response"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationError"}}},"description":"Validation error"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Error response"}},"summary":"ListPredictionMarkets","tags":["PredictionService"]}}}}
{"components":{"schemas":{"Error":{"description":"Error is returned when a handler encounters an error. It contains a simple error message that the developer can customize.","properties":{"message":{"description":"Error message (e.g., 'user not found', 'database connection failed')","type":"string"}},"type":"object"},"FieldViolation":{"description":"FieldViolation describes a single validation error for a specific field.","properties":{"description":{"description":"Human-readable description of the validation violation (e.g., 'must be a valid email address', 'required field missing')","type":"string"},"field":{"description":"The field path that failed validation (e.g., 'user.email' for nested fields). For header validation, this will be the header name (e.g., 'X-API-Key')","type":"string"}},"required":["field","description"],"type":"object"},"ListPredictionMarketsRequest":{"description":"ListPredictionMarketsRequest specifies filters for retrieving prediction markets.","properties":{"category":{"description":"Optional category filter (e.g., \"Politics\").","type":"string"},"cursor":{"description":"Cursor for next page.","type":"string"},"pageSize":{"description":"Maximum items per page (1-100).","format":"int32","type":"integer"},"query":{"description":"Optional search query for market titles.","type":"string"}},"type":"object"},"ListPredictionMarketsResponse":{"description":"ListPredictionMarketsResponse contains prediction markets matching the request.","properties":{"markets":{"items":{"$ref":"#/components/schemas/PredictionMarket"},"type":"array"},"pagination":{"$ref":"#/components/schemas/PaginationResponse"}},"type":"object"},"PaginationResponse":{"description":"PaginationResponse contains pagination metadata returned alongside list results.","properties":{"nextCursor":{"description":"Cursor for fetching the next page. Empty string indicates no more pages.","type":"string"},"totalCount":{"description":"Total count of items matching the query, if known. Zero if the total is unknown.","format":"int32","type":"integer"}},"type":"object"},"PredictionMarket":{"description":"PredictionMarket represents a prediction market contract.","properties":{"category":{"description":"Market category (e.g., \"Politics\", \"Crypto\", \"Sports\").","type":"string"},"closesAt":{"description":"Market close time, as Unix epoch milliseconds. Zero if no expiry.. Warning: Values \u003e 2^53 may lose precision in JavaScript","format":"int64","type":"integer"},"id":{"description":"Unique market identifier or slug.","minLength":1,"type":"string"},"source":{"description":"Source platform for prediction market data.","enum":["MARKET_SOURCE_UNSPECIFIED","MARKET_SOURCE_POLYMARKET","MARKET_SOURCE_KALSHI"],"type":"string"},"title":{"description":"Market question or title.","type":"string"},"url":{"description":"URL to the market page.","type":"string"},"volume":{"description":"Trading volume in USD.","format":"double","minimum":0,"type":"number"},"yesPrice":{"description":"Current \"Yes\" price (0.0 to 1.0, representing probability).","format":"double","maximum":1,"minimum":0,"type":"number"}},"required":["id"],"type":"object"},"ValidationError":{"description":"ValidationError is returned when request validation fails. It contains a list of field violations describing what went wrong.","properties":{"violations":{"description":"List of validation violations","items":{"$ref":"#/components/schemas/FieldViolation"},"type":"array"}},"required":["violations"],"type":"object"}}},"info":{"title":"PredictionService API","version":"1.0.0"},"openapi":"3.1.0","paths":{"/api/prediction/v1/list-prediction-markets":{"get":{"description":"ListPredictionMarkets retrieves active prediction markets from Polymarket.","operationId":"ListPredictionMarkets","parameters":[{"description":"Maximum items per page (1-100).","in":"query","name":"page_size","required":false,"schema":{"format":"int32","type":"integer"}},{"description":"Cursor for next page.","in":"query","name":"cursor","required":false,"schema":{"type":"string"}},{"description":"Optional category filter (e.g., \"Politics\").","in":"query","name":"category","required":false,"schema":{"type":"string"}},{"description":"Optional search query for market titles.","in":"query","name":"query","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListPredictionMarketsResponse"}}},"description":"Successful response"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationError"}}},"description":"Validation error"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Error response"}},"summary":"ListPredictionMarkets","tags":["PredictionService"]}}}}

View File

@@ -138,7 +138,7 @@ components:
description: Trading volume in USD.
url:
type: string
description: URL to the Polymarket market page.
description: URL to the market page.
closesAt:
type: integer
format: int64
@@ -146,9 +146,16 @@ components:
category:
type: string
description: Market category (e.g., "Politics", "Crypto", "Sports").
source:
type: string
enum:
- MARKET_SOURCE_UNSPECIFIED
- MARKET_SOURCE_POLYMARKET
- MARKET_SOURCE_KALSHI
description: Source platform for prediction market data.
required:
- id
description: PredictionMarket represents a prediction market contract from Polymarket.
description: PredictionMarket represents a prediction market contract.
PaginationResponse:
type: object
properties:

File diff suppressed because one or more lines are too long

View File

@@ -209,7 +209,6 @@ components:
type: array
items:
$ref: '#/components/schemas/DirectionalDwt'
deprecated: true
transitSummary:
$ref: '#/components/schemas/TransitSummary'
DirectionalDwt:
@@ -223,23 +222,6 @@ components:
wowChangePct:
type: number
format: double
TransitDayCount:
type: object
properties:
date:
type: string
tanker:
type: integer
format: int32
cargo:
type: integer
format: int32
other:
type: integer
format: int32
total:
type: integer
format: int32
TransitSummary:
type: object
properties:
@@ -270,6 +252,23 @@ components:
disruptionPct:
type: number
format: double
TransitDayCount:
type: object
properties:
date:
type: string
tanker:
type: integer
format: int32
cargo:
type: integer
format: int32
other:
type: integer
format: int32
total:
type: integer
format: int32
GetCriticalMineralsRequest:
type: object
GetCriticalMineralsResponse:

View File

@@ -0,0 +1 @@
{"components":{"schemas":{"Error":{"description":"Error is returned when a handler encounters an error. It contains a simple error message that the developer can customize.","properties":{"message":{"description":"Error message (e.g., 'user not found', 'database connection failed')","type":"string"}},"type":"object"},"FieldViolation":{"description":"FieldViolation describes a single validation error for a specific field.","properties":{"description":{"description":"Human-readable description of the validation violation (e.g., 'must be a valid email address', 'required field missing')","type":"string"},"field":{"description":"The field path that failed validation (e.g., 'user.email' for nested fields). For header validation, this will be the header name (e.g., 'X-API-Key')","type":"string"}},"required":["field","description"],"type":"object"},"GetWebcamImageRequest":{"properties":{"webcamId":{"type":"string"}},"type":"object"},"GetWebcamImageResponse":{"properties":{"error":{"type":"string"},"lastUpdated":{"format":"int64","type":"string"},"playerUrl":{"type":"string"},"thumbnailUrl":{"type":"string"},"title":{"type":"string"},"windyUrl":{"type":"string"}},"type":"object"},"ListWebcamsRequest":{"properties":{"boundE":{"format":"double","type":"number"},"boundN":{"format":"double","type":"number"},"boundS":{"format":"double","type":"number"},"boundW":{"format":"double","type":"number"},"zoom":{"format":"int32","type":"integer"}},"type":"object"},"ListWebcamsResponse":{"properties":{"clusters":{"items":{"$ref":"#/components/schemas/WebcamCluster"},"type":"array"},"totalInView":{"format":"int32","type":"integer"},"webcams":{"items":{"$ref":"#/components/schemas/WebcamEntry"},"type":"array"}},"type":"object"},"ValidationError":{"description":"ValidationError is returned when request validation fails. It contains a list of field violations describing what went wrong.","properties":{"violations":{"description":"List of validation violations","items":{"$ref":"#/components/schemas/FieldViolation"},"type":"array"}},"required":["violations"],"type":"object"},"WebcamCluster":{"properties":{"categories":{"items":{"type":"string"},"type":"array"},"count":{"format":"int32","type":"integer"},"lat":{"format":"double","type":"number"},"lng":{"format":"double","type":"number"}},"type":"object"},"WebcamEntry":{"properties":{"category":{"type":"string"},"country":{"type":"string"},"lat":{"format":"double","type":"number"},"lng":{"format":"double","type":"number"},"title":{"type":"string"},"webcamId":{"type":"string"}},"type":"object"}}},"info":{"title":"WebcamService API","version":"1.0.0"},"openapi":"3.1.0","paths":{"/api/webcam/v1/get-webcam-image":{"get":{"operationId":"GetWebcamImage","parameters":[{"in":"query","name":"webcam_id","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetWebcamImageResponse"}}},"description":"Successful response"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationError"}}},"description":"Validation error"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Error response"}},"summary":"GetWebcamImage","tags":["WebcamService"]}},"/api/webcam/v1/list-webcams":{"get":{"operationId":"ListWebcams","parameters":[{"in":"query","name":"zoom","required":false,"schema":{"format":"int32","type":"integer"}},{"in":"query","name":"bound_w","required":false,"schema":{"format":"double","type":"number"}},{"in":"query","name":"bound_s","required":false,"schema":{"format":"double","type":"number"}},{"in":"query","name":"bound_e","required":false,"schema":{"format":"double","type":"number"}},{"in":"query","name":"bound_n","required":false,"schema":{"format":"double","type":"number"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListWebcamsResponse"}}},"description":"Successful response"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationError"}}},"description":"Validation error"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Error response"}},"summary":"ListWebcams","tags":["WebcamService"]}}}}

View File

@@ -0,0 +1,211 @@
openapi: 3.1.0
info:
title: WebcamService API
version: 1.0.0
paths:
/api/webcam/v1/list-webcams:
get:
tags:
- WebcamService
summary: ListWebcams
operationId: ListWebcams
parameters:
- name: zoom
in: query
required: false
schema:
type: integer
format: int32
- name: bound_w
in: query
required: false
schema:
type: number
format: double
- name: bound_s
in: query
required: false
schema:
type: number
format: double
- name: bound_e
in: query
required: false
schema:
type: number
format: double
- name: bound_n
in: query
required: false
schema:
type: number
format: double
responses:
"200":
description: Successful response
content:
application/json:
schema:
$ref: '#/components/schemas/ListWebcamsResponse'
"400":
description: Validation error
content:
application/json:
schema:
$ref: '#/components/schemas/ValidationError'
default:
description: Error response
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
/api/webcam/v1/get-webcam-image:
get:
tags:
- WebcamService
summary: GetWebcamImage
operationId: GetWebcamImage
parameters:
- name: webcam_id
in: query
required: false
schema:
type: string
responses:
"200":
description: Successful response
content:
application/json:
schema:
$ref: '#/components/schemas/GetWebcamImageResponse'
"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:
type: object
properties:
message:
type: string
description: Error message (e.g., 'user not found', 'database connection failed')
description: Error is returned when a handler encounters an error. It contains a simple error message that the developer can customize.
FieldViolation:
type: object
properties:
field:
type: string
description: The field path that failed validation (e.g., 'user.email' for nested fields). For header validation, this will be the header name (e.g., 'X-API-Key')
description:
type: string
description: Human-readable description of the validation violation (e.g., 'must be a valid email address', 'required field missing')
required:
- field
- description
description: FieldViolation describes a single validation error for a specific field.
ValidationError:
type: object
properties:
violations:
type: array
items:
$ref: '#/components/schemas/FieldViolation'
description: List of validation violations
required:
- violations
description: ValidationError is returned when request validation fails. It contains a list of field violations describing what went wrong.
ListWebcamsRequest:
type: object
properties:
zoom:
type: integer
format: int32
boundW:
type: number
format: double
boundS:
type: number
format: double
boundE:
type: number
format: double
boundN:
type: number
format: double
ListWebcamsResponse:
type: object
properties:
webcams:
type: array
items:
$ref: '#/components/schemas/WebcamEntry'
clusters:
type: array
items:
$ref: '#/components/schemas/WebcamCluster'
totalInView:
type: integer
format: int32
WebcamEntry:
type: object
properties:
webcamId:
type: string
title:
type: string
lat:
type: number
format: double
lng:
type: number
format: double
category:
type: string
country:
type: string
WebcamCluster:
type: object
properties:
lat:
type: number
format: double
lng:
type: number
format: double
count:
type: integer
format: int32
categories:
type: array
items:
type: string
GetWebcamImageRequest:
type: object
properties:
webcamId:
type: string
GetWebcamImageResponse:
type: object
properties:
thumbnailUrl:
type: string
playerUrl:
type: string
title:
type: string
windyUrl:
type: string
lastUpdated:
type: string
format: int64
error:
type: string

View File

@@ -0,0 +1,56 @@
syntax = "proto3";
package worldmonitor.forecast.v1;
import "sebuf/http/annotations.proto";
message ForecastSignal {
string type = 1;
string value = 2;
double weight = 3;
}
message CascadeEffect {
string domain = 1;
string effect = 2;
double probability = 3;
}
message CalibrationInfo {
string market_title = 1;
double market_price = 2;
double drift = 3;
string source = 4;
}
message Perspectives {
string strategic = 1;
string regional = 2;
string contrarian = 3;
}
message Projections {
double h24 = 1;
double d7 = 2;
double d30 = 3;
}
message Forecast {
string id = 1;
string domain = 2;
string region = 3;
string title = 4;
string scenario = 5;
double probability = 6;
double confidence = 7;
string time_horizon = 8;
repeated ForecastSignal signals = 9;
repeated CascadeEffect cascades = 10;
string trend = 11;
double prior_probability = 12;
CalibrationInfo calibration = 13;
int64 created_at = 14 [(sebuf.http.int64_encoding) = INT64_ENCODING_NUMBER];
int64 updated_at = 15 [(sebuf.http.int64_encoding) = INT64_ENCODING_NUMBER];
Perspectives perspectives = 16;
Projections projections = 17;
}

View File

@@ -0,0 +1,16 @@
syntax = "proto3";
package worldmonitor.forecast.v1;
import "sebuf/http/annotations.proto";
import "worldmonitor/forecast/v1/forecast.proto";
message GetForecastsRequest {
string domain = 1 [(sebuf.http.query) = { name: "domain" }];
string region = 2 [(sebuf.http.query) = { name: "region" }];
}
message GetForecastsResponse {
repeated Forecast forecasts = 1;
int64 generated_at = 2 [(sebuf.http.int64_encoding) = INT64_ENCODING_NUMBER];
}

View File

@@ -0,0 +1,14 @@
syntax = "proto3";
package worldmonitor.forecast.v1;
import "sebuf/http/annotations.proto";
import "worldmonitor/forecast/v1/get_forecasts.proto";
service ForecastService {
option (sebuf.http.service_config) = {base_path: "/api/forecast/v1"};
rpc GetForecasts(GetForecastsRequest) returns (GetForecastsResponse) {
option (sebuf.http.config) = {path: "/get-forecasts", method: HTTP_METHOD_GET};
}
}

View File

@@ -0,0 +1,11 @@
[
{"from": "conflict", "to": "supply_chain", "coupling": 0.6, "mechanism": "chokepoint disruption", "requiresChokepoint": true},
{"from": "conflict", "to": "market", "coupling": 0.5, "mechanism": "commodity price shock", "requiresChokepoint": true},
{"from": "political", "to": "conflict", "coupling": 0.4, "mechanism": "instability escalation", "minProbability": 0.6},
{"from": "military", "to": "conflict", "coupling": 0.5, "mechanism": "force deployment", "requiresCriticalPosture": true},
{"from": "supply_chain", "to": "market", "coupling": 0.4, "mechanism": "supply shortage pricing"},
{"from": "infrastructure", "to": "supply_chain", "coupling": 0.35, "mechanism": "infrastructure failure", "requiresSeverity": "total"},
{"from": "infrastructure", "to": "market", "coupling": 0.3, "mechanism": "outage economic impact", "requiresSeverity": "total"},
{"from": "conflict", "to": "political", "coupling": 0.35, "mechanism": "conflict-driven instability", "minProbability": 0.5},
{"from": "political", "to": "market", "coupling": 0.25, "mechanism": "political uncertainty pricing", "minProbability": 0.5}
]

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,85 @@
{
"aliases": {
"IR": "IR", "IL": "IL", "UA": "UA", "RU": "RU", "CN": "CN", "TW": "TW",
"YE": "YE", "SA": "SA", "KP": "KP", "KR": "KR", "US": "US", "SY": "SY",
"MM": "MM", "SD": "SD", "AF": "AF", "IQ": "IQ", "LB": "LB",
"Iran": "IR", "Israel": "IL", "Ukraine": "UA", "Russia": "RU",
"China": "CN", "Taiwan": "TW", "Yemen": "YE", "Syria": "SY",
"Myanmar": "MM", "Sudan": "SD", "Afghanistan": "AF", "Iraq": "IQ",
"Lebanon": "LB", "Saudi Arabia": "SA", "North Korea": "KP",
"Israel/Gaza": "israel-gaza",
"Middle East": "middle-east",
"Western Pacific": "western-pacific",
"Black Sea": "black-sea",
"Red Sea": "red-sea",
"Northern Europe": "baltic",
"Eastern Mediterranean": "east-med",
"Korean Peninsula": "korea",
"South China Sea": "scs",
"Persian Gulf": "middle-east",
"Baltic Sea": "baltic",
"Americas": "americas",
"Europe": "europe",
"Asia-Pacific": "asia-pacific",
"Africa": "africa",
"Latin America": "latam"
},
"nodes": {
"IR": { "name": "Iran", "type": "country", "links": ["oil", "IL", "hormuz", "middle-east", "red-sea", "YE"] },
"IL": { "name": "Israel", "type": "country", "links": ["IR", "israel-gaza", "US", "east-med", "LB"] },
"UA": { "name": "Ukraine", "type": "country", "links": ["RU", "grain", "black-sea"] },
"RU": { "name": "Russia", "type": "country", "links": ["UA", "oil", "gas", "baltic", "black-sea"] },
"CN": { "name": "China", "type": "country", "links": ["TW", "scs", "western-pacific", "semiconductors"] },
"TW": { "name": "Taiwan", "type": "country", "links": ["CN", "semiconductors", "western-pacific"] },
"YE": { "name": "Yemen", "type": "country", "links": ["IR", "red-sea", "shipping"] },
"SA": { "name": "Saudi Arabia", "type": "country", "links": ["IR", "oil", "hormuz", "middle-east"] },
"SY": { "name": "Syria", "type": "country", "links": ["IR", "RU", "middle-east"] },
"KP": { "name": "North Korea", "type": "country", "links": ["korea", "CN"] },
"KR": { "name": "South Korea", "type": "country", "links": ["korea", "US", "semiconductors"] },
"US": { "name": "United States", "type": "country", "links": ["IL", "KR", "TW", "nato", "americas"] },
"MM": { "name": "Myanmar", "type": "country", "links": ["asia-pacific"] },
"SD": { "name": "Sudan", "type": "country", "links": ["africa", "red-sea"] },
"AF": { "name": "Afghanistan", "type": "country", "links": ["middle-east"] },
"IQ": { "name": "Iraq", "type": "country", "links": ["IR", "oil", "middle-east"] },
"LB": { "name": "Lebanon", "type": "country", "links": ["IL", "IR", "east-med"] },
"middle-east": { "name": "Middle East", "type": "theater", "links": ["IR", "IL", "SA", "IQ", "SY", "oil", "hormuz"] },
"black-sea": { "name": "Black Sea", "type": "theater", "links": ["UA", "RU", "grain"] },
"western-pacific": { "name": "Western Pacific", "type": "theater", "links": ["CN", "TW", "semiconductors"] },
"red-sea": { "name": "Red Sea", "type": "theater", "links": ["YE", "IR", "SD", "oil", "shipping"] },
"baltic": { "name": "Baltic", "type": "theater", "links": ["RU", "nato"] },
"east-med": { "name": "Eastern Mediterranean", "type": "theater", "links": ["IL", "LB", "gas"] },
"israel-gaza": { "name": "Israel/Gaza", "type": "theater", "links": ["IL", "IR"] },
"korea": { "name": "Korean Peninsula", "type": "theater", "links": ["KP", "KR", "US"] },
"scs": { "name": "South China Sea", "type": "theater", "links": ["CN", "shipping"] },
"americas": { "name": "Americas", "type": "region", "links": ["US"] },
"europe": { "name": "Europe", "type": "region", "links": ["RU", "UA", "nato", "baltic"] },
"asia-pacific": { "name": "Asia-Pacific", "type": "region", "links": ["CN", "TW", "KP", "KR", "MM"] },
"africa": { "name": "Africa", "type": "region", "links": ["SD"] },
"latam": { "name": "Latin America", "type": "region", "links": [] },
"oil": { "name": "Oil", "type": "commodity", "links": ["IR", "SA", "IQ", "RU", "hormuz", "red-sea", "middle-east"] },
"grain": { "name": "Grain", "type": "commodity", "links": ["UA", "RU", "black-sea"] },
"semiconductors": { "name": "Semiconductors", "type": "commodity", "links": ["TW", "KR", "CN", "western-pacific"] },
"gas": { "name": "Gas", "type": "commodity", "links": ["RU", "east-med"] },
"hormuz": { "name": "Strait of Hormuz", "type": "chokepoint", "links": ["IR", "SA", "oil", "middle-east"] },
"shipping": { "name": "Global Shipping", "type": "infrastructure", "links": ["red-sea", "scs", "hormuz", "YE"] },
"nato": { "name": "NATO", "type": "alliance", "links": ["US", "europe", "baltic"] }
},
"edges": [
{ "from": "IR", "to": "IL", "relation": "adversary", "weight": 0.9 },
{ "from": "IR", "to": "oil", "relation": "producer", "weight": 0.8 },
{ "from": "IR", "to": "YE", "relation": "proxy", "weight": 0.7 },
{ "from": "IR", "to": "LB", "relation": "proxy", "weight": 0.7 },
{ "from": "RU", "to": "UA", "relation": "adversary", "weight": 0.95 },
{ "from": "RU", "to": "oil", "relation": "producer", "weight": 0.7 },
{ "from": "RU", "to": "gas", "relation": "producer", "weight": 0.8 },
{ "from": "CN", "to": "TW", "relation": "adversary", "weight": 0.8 },
{ "from": "TW", "to": "semiconductors", "relation": "producer", "weight": 0.95 },
{ "from": "KP", "to": "KR", "relation": "adversary", "weight": 0.85 },
{ "from": "YE", "to": "shipping", "relation": "disrupts", "weight": 0.8 },
{ "from": "hormuz", "to": "oil", "relation": "trade_route", "weight": 0.9 },
{ "from": "red-sea", "to": "shipping", "relation": "trade_route", "weight": 0.85 },
{ "from": "UA", "to": "grain", "relation": "producer", "weight": 0.8 },
{ "from": "SA", "to": "oil", "relation": "producer", "weight": 0.85 },
{ "from": "IQ", "to": "oil", "relation": "producer", "weight": 0.6 }
]
}

1353
scripts/seed-forecasts.mjs Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,28 @@
import type {
Forecast,
ForecastServiceHandler,
ServerContext,
GetForecastsRequest,
GetForecastsResponse,
} from '../../../../src/generated/server/worldmonitor/forecast/v1/service_server';
import { getCachedJson } from '../../../_shared/redis';
const REDIS_KEY = 'forecast:predictions:v1';
export const getForecasts: ForecastServiceHandler['getForecasts'] = async (
_ctx: ServerContext,
req: GetForecastsRequest,
): Promise<GetForecastsResponse> => {
try {
const data = await getCachedJson(REDIS_KEY) as { predictions: Forecast[]; generatedAt: number } | null;
if (!data?.predictions) return { forecasts: [], generatedAt: 0 };
let forecasts = data.predictions;
if (req.domain) forecasts = forecasts.filter(f => f.domain === req.domain);
if (req.region) forecasts = forecasts.filter(f => f.region.toLowerCase().includes(req.region.toLowerCase()));
return { forecasts, generatedAt: data.generatedAt || 0 };
} catch {
return { forecasts: [], generatedAt: 0 };
}
};

View File

@@ -0,0 +1,4 @@
import type { ForecastServiceHandler } from '../../../../src/generated/server/worldmonitor/forecast/v1/service_server';
import { getForecasts } from './get-forecasts';
export const forecastHandler: ForecastServiceHandler = { getForecasts };

View File

@@ -2,7 +2,7 @@ import type {
ServerContext,
ListTemporalAnomaliesRequest,
ListTemporalAnomaliesResponse,
TemporalAnomalyProto,
TemporalAnomaly as TemporalAnomalyProto,
} from '../../../../src/generated/server/worldmonitor/infrastructure/v1/service_server';
import { getCachedJson, setCachedJson } from '../../../_shared/redis';

View File

@@ -8,7 +8,7 @@
*/
import {
MarketSource,
type MarketSource,
type PredictionServiceHandler,
type ServerContext,
type ListPredictionMarketsRequest,
@@ -137,7 +137,7 @@ function mapEvent(event: GammaEvent, category: string): PredictionMarket {
url: `https://polymarket.com/event/${event.slug}`,
closesAt: Number.isFinite(closesAtMs) ? closesAtMs : 0,
category: category || '',
source: MarketSource.MARKET_SOURCE_POLYMARKET,
source: 'MARKET_SOURCE_POLYMARKET' as MarketSource,
};
}
@@ -153,7 +153,7 @@ function mapMarket(market: GammaMarket): PredictionMarket {
url: `https://polymarket.com/market/${market.slug}`,
closesAt: Number.isFinite(closesAtMs) ? closesAtMs : 0,
category: '',
source: MarketSource.MARKET_SOURCE_POLYMARKET,
source: 'MARKET_SOURCE_POLYMARKET' as MarketSource,
};
}
@@ -170,7 +170,7 @@ function mapKalshiMarket(market: KalshiMarket, category: string, eventTitle?: st
url: `https://kalshi.com/markets/${market.ticker}`,
closesAt: Number.isFinite(closesAtMs) ? closesAtMs : 0,
category: category || '',
source: MarketSource.MARKET_SOURCE_KALSHI,
source: 'MARKET_SOURCE_KALSHI' as MarketSource,
};
}
@@ -249,7 +249,7 @@ export const listPredictionMarkets: PredictionServiceHandler['listPredictionMark
url: m.url || '',
closesAt: m.endDate ? Date.parse(m.endDate) : 0,
category: category || '',
source: m.source === 'kalshi' ? MarketSource.MARKET_SOURCE_KALSHI : MarketSource.MARKET_SOURCE_POLYMARKET,
source: m.source === 'kalshi' ? 'MARKET_SOURCE_KALSHI' as MarketSource : 'MARKET_SOURCE_POLYMARKET' as MarketSource,
}));
return { markets, pagination: undefined };

View File

@@ -11,12 +11,12 @@ export async function getWebcamImage(_ctx: ServerContext, req: GetWebcamImageReq
const windyUrl = `https://www.windy.com/webcams/${encodeURIComponent(webcamId || '')}`;
if (!webcamId || !WEBCAM_ID_RE.test(webcamId)) {
return { thumbnailUrl: '', playerUrl: '', title: '', windyUrl, lastUpdated: 0, error: 'missing webcam_id' };
return { thumbnailUrl: '', playerUrl: '', title: '', windyUrl, lastUpdated: '', error: 'missing webcam_id' };
}
const apiKey = process.env.WINDY_API_KEY;
if (!apiKey) {
return { thumbnailUrl: '', playerUrl: '', title: '', windyUrl, lastUpdated: 0, error: 'unavailable' };
return { thumbnailUrl: '', playerUrl: '', title: '', windyUrl, lastUpdated: '', error: 'unavailable' };
}
const result = await cachedFetchJson<GetWebcamImageResponse>(
@@ -39,11 +39,11 @@ export async function getWebcamImage(_ctx: ServerContext, req: GetWebcamImageReq
playerUrl: urls.player || '',
title: wc.title || '',
windyUrl,
lastUpdated: wc.lastUpdatedOn ? new Date(wc.lastUpdatedOn).getTime() : 0,
lastUpdated: wc.lastUpdatedOn ? new Date(wc.lastUpdatedOn).toISOString() : '',
error: '',
};
},
);
return result ?? { thumbnailUrl: '', playerUrl: '', title: '', windyUrl, lastUpdated: 0, error: 'unavailable' };
return result ?? { thumbnailUrl: '', playerUrl: '', title: '', windyUrl, lastUpdated: '', error: 'unavailable' };
}

View File

@@ -756,6 +756,12 @@ export class App {
intervalMs: REFRESH_INTERVALS.predictions,
condition: () => this.isPanelNearViewport('polymarket'),
},
{
name: 'forecasts',
fn: () => this.dataLoader.loadForecasts(),
intervalMs: REFRESH_INTERVALS.forecasts,
condition: () => this.isPanelNearViewport('forecast'),
},
{ name: 'pizzint', fn: () => this.dataLoader.loadPizzInt(), intervalMs: 10 * 60 * 1000 },
{ name: 'natural', fn: () => this.dataLoader.loadNatural(), intervalMs: 60 * 60 * 1000, condition: () => this.state.mapLayers.natural },
{ name: 'weather', fn: () => this.dataLoader.loadWeatherAlerts(), intervalMs: 10 * 60 * 1000, condition: () => this.state.mapLayers.weather },

View File

@@ -372,6 +372,9 @@ export class DataLoaderManager implements AppModule {
if (shouldLoad('polymarket')) {
tasks.push({ name: 'predictions', task: runGuarded('predictions', () => this.loadPredictions()) });
}
if (shouldLoad('forecast')) {
tasks.push({ name: 'forecasts', task: runGuarded('forecasts', () => this.loadForecasts()) });
}
tasks.push({ name: 'pizzint', task: runGuarded('pizzint', () => this.loadPizzInt()) });
if (shouldLoad('economic')) {
tasks.push({ name: 'fred', task: runGuarded('fred', () => this.loadFredData()) });
@@ -1360,6 +1363,14 @@ export class DataLoaderManager implements AppModule {
}
}
async loadForecasts(): Promise<void> {
try {
const { fetchForecasts } = await import('@/services/forecast');
const forecasts = await fetchForecasts();
this.callPanel('forecast', 'updateForecasts', forecasts);
} catch { /* premium feature, silent fail */ }
}
async loadNatural(): Promise<void> {
const [earthquakeResult, eonetResult] = await Promise.allSettled([
fetchEarthquakes(),

View File

@@ -700,6 +700,12 @@ export class PanelLayoutManager implements AppModule {
!_wmKeyPresent ? ['Pre-market watchlist priorities', 'Action plan for the session', 'Risk watch tied to current finance headlines'] : undefined,
);
this.lazyPanel('forecast', () =>
import('@/components/ForecastPanel').then(m => new m.ForecastPanel()),
undefined,
_lockPanels ? ['AI-powered geopolitical forecasts', 'Cross-domain cascade predictions', 'Prediction market calibration'] : undefined,
);
this.lazyPanel('oref-sirens', () =>
import('@/components/OrefSirensPanel').then(m => new m.OrefSirensPanel()),
undefined,

View File

@@ -0,0 +1,177 @@
import { Panel } from './Panel';
import { escapeHtml } from '@/services/forecast';
import type { Forecast } from '@/services/forecast';
const DOMAINS = ['all', 'conflict', 'market', 'supply_chain', 'political', 'military', 'infrastructure'] as const;
const DOMAIN_LABELS: Record<string, string> = {
all: 'All',
conflict: 'Conflict',
market: 'Market',
supply_chain: 'Supply Chain',
political: 'Political',
military: 'Military',
infrastructure: 'Infra',
};
let _styleInjected = false;
function injectStyles(): void {
if (_styleInjected) return;
_styleInjected = true;
const style = document.createElement('style');
style.textContent = `
.fc-panel { font-size: 12px; }
.fc-filters { display: flex; flex-wrap: wrap; gap: 4px; padding: 6px 8px; border-bottom: 1px solid var(--border-color, #333); }
.fc-filter { background: transparent; border: 1px solid var(--border-color, #444); color: var(--text-secondary, #aaa); padding: 2px 8px; border-radius: 3px; cursor: pointer; font-size: 11px; }
.fc-filter.fc-active { background: var(--accent-color, #3b82f6); color: #fff; border-color: var(--accent-color, #3b82f6); }
.fc-list { padding: 4px 0; }
.fc-card { padding: 6px 10px; border-bottom: 1px solid var(--border-color, #222); }
.fc-card:hover { background: var(--hover-bg, rgba(255,255,255,0.03)); }
.fc-header { display: flex; justify-content: space-between; align-items: center; }
.fc-title { font-weight: 600; color: var(--text-primary, #eee); }
.fc-prob { font-weight: 700; font-size: 14px; }
.fc-prob.high { color: #ef4444; }
.fc-prob.medium { color: #f59e0b; }
.fc-prob.low { color: #22c55e; }
.fc-meta { color: var(--text-secondary, #888); font-size: 11px; margin-top: 2px; }
.fc-trend-rising { color: #ef4444; }
.fc-trend-falling { color: #22c55e; }
.fc-trend-stable { color: var(--text-secondary, #888); }
.fc-signals { margin-top: 4px; }
.fc-signal { color: var(--text-secondary, #999); font-size: 11px; padding: 1px 0; }
.fc-signal::before { content: ''; display: inline-block; width: 6px; height: 1px; background: var(--text-secondary, #666); margin-right: 6px; vertical-align: middle; }
.fc-cascade { font-size: 11px; color: var(--accent-color, #3b82f6); margin-top: 3px; }
.fc-scenario { font-size: 11px; color: var(--text-primary, #ccc); margin: 4px 0; font-style: italic; }
.fc-hidden { display: none; }
.fc-toggle { cursor: pointer; color: var(--text-secondary, #888); font-size: 11px; }
.fc-toggle:hover { color: var(--text-primary, #eee); }
.fc-calibration { font-size: 10px; color: var(--text-secondary, #777); margin-top: 2px; }
.fc-bar { height: 3px; border-radius: 1.5px; margin-top: 3px; background: var(--border-color, #333); }
.fc-bar-fill { height: 100%; border-radius: 1.5px; }
.fc-empty { padding: 20px; text-align: center; color: var(--text-secondary, #888); }
.fc-projections { font-size: 10px; color: var(--text-secondary, #777); margin-top: 3px; font-variant-numeric: tabular-nums; }
.fc-perspectives { margin-top: 4px; }
.fc-perspective { font-size: 11px; color: var(--text-secondary, #999); padding: 2px 0; line-height: 1.4; }
.fc-perspective strong { color: var(--text-primary, #ccc); font-weight: 600; }
`;
document.head.appendChild(style);
}
export class ForecastPanel extends Panel {
private forecasts: Forecast[] = [];
private activeDomain: string = 'all';
constructor() {
super({ id: 'forecast', title: 'AI Forecasts', showCount: true });
injectStyles();
this.content.addEventListener('click', (e) => {
const target = e.target as HTMLElement;
const filterBtn = target.closest('[data-fc-domain]') as HTMLElement;
if (filterBtn) {
this.activeDomain = filterBtn.dataset.fcDomain || 'all';
this.render();
return;
}
const toggle = target.closest('[data-fc-toggle]') as HTMLElement;
if (toggle) {
const details = toggle.nextElementSibling as HTMLElement;
if (details) details.classList.toggle('fc-hidden');
return;
}
});
}
updateForecasts(forecasts: Forecast[]): void {
this.forecasts = forecasts;
this.setCount(forecasts.length);
this.setDataBadge(forecasts.length > 0 ? 'live' : 'unavailable');
this.render();
}
private render(): void {
if (this.forecasts.length === 0) {
this.setContent('<div class="fc-empty">No forecasts available</div>');
return;
}
const filtered = this.activeDomain === 'all'
? this.forecasts
: this.forecasts.filter(f => f.domain === this.activeDomain);
const sorted = [...filtered].sort((a, b) =>
(b.probability * b.confidence) - (a.probability * a.confidence)
);
const filtersHtml = DOMAINS.map(d =>
`<button class="fc-filter${d === this.activeDomain ? ' fc-active' : ''}" data-fc-domain="${d}">${DOMAIN_LABELS[d]}</button>`
).join('');
const cardsHtml = sorted.map(f => this.renderCard(f)).join('');
this.setContent(`
<div class="fc-panel">
<div class="fc-filters">${filtersHtml}</div>
<div class="fc-list">${cardsHtml}</div>
</div>
`);
}
private renderCard(f: Forecast): string {
const pct = Math.round((f.probability || 0) * 100);
const probClass = pct > 60 ? 'high' : pct > 35 ? 'medium' : 'low';
const probColor = pct > 60 ? '#ef4444' : pct > 35 ? '#f59e0b' : '#22c55e';
const trendIcon = f.trend === 'rising' ? '&#x25B2;' : f.trend === 'falling' ? '&#x25BC;' : '&#x2500;';
const trendClass = `fc-trend-${f.trend || 'stable'}`;
const signalsHtml = (f.signals || []).map(s =>
`<div class="fc-signal">${escapeHtml(s.value)}</div>`
).join('');
const cascadesHtml = (f.cascades || []).length > 0
? `<div class="fc-cascade">Cascades: ${f.cascades.map(c => escapeHtml(c.domain)).join(', ')}</div>`
: '';
const scenarioHtml = f.scenario
? `<div class="fc-scenario">${escapeHtml(f.scenario)}</div>`
: '';
const calibrationHtml = f.calibration?.marketTitle
? `<div class="fc-calibration">Market: ${escapeHtml(f.calibration.marketTitle)} (${Math.round((f.calibration.marketPrice || 0) * 100)}%)</div>`
: '';
const proj = f.projections;
const projectionsHtml = proj
? `<div class="fc-projections">24h: ${Math.round(proj.h24 * 100)}% | 7d: ${Math.round(proj.d7 * 100)}% | 30d: ${Math.round(proj.d30 * 100)}%</div>`
: '';
const persp = f.perspectives;
const perspectivesHtml = persp?.strategic
? `<span class="fc-toggle" data-fc-toggle>Perspectives</span>
<div class="fc-perspectives fc-hidden">
<div class="fc-perspective"><strong>Strategic:</strong> ${escapeHtml(persp.strategic)}</div>
<div class="fc-perspective"><strong>Regional:</strong> ${escapeHtml(persp.regional || '')}</div>
<div class="fc-perspective"><strong>Contrarian:</strong> ${escapeHtml(persp.contrarian || '')}</div>
</div>`
: '';
return `
<div class="fc-card">
<div class="fc-header">
<span class="fc-title"><span class="${trendClass}">${trendIcon}</span> ${escapeHtml(f.title)}</span>
<span class="fc-prob ${probClass}">${pct}%</span>
</div>
<div class="fc-bar"><div class="fc-bar-fill" style="width:${pct}%;background:${probColor}"></div></div>
${projectionsHtml}
<div class="fc-meta">${escapeHtml(f.region)} | ${escapeHtml(f.timeHorizon || '7d')} | <span class="${trendClass}">${f.trend || 'stable'}</span></div>
${scenarioHtml}
${perspectivesHtml}
<span class="fc-toggle" data-fc-toggle>Signals (${(f.signals || []).length})</span>
<div class="fc-signals fc-hidden">${signalsHtml}</div>
${cascadesHtml}
${calibrationHtml}
</div>
`;
}
}

View File

@@ -17,6 +17,7 @@ const FULL_PANELS: Record<string, PanelConfig> = {
'windy-webcams': { name: 'Windy Live Webcam', enabled: false, priority: 2 },
insights: { name: 'AI Insights', enabled: true, priority: 1 },
'strategic-posture': { name: 'AI Strategic Posture', enabled: true, priority: 1 },
forecast: { name: 'AI Forecasts', enabled: true, priority: 1, ...(_desktop && { premium: 'locked' as const }) }, // trial: unlocked on web, locked on desktop
cii: { name: 'Country Instability', enabled: true, priority: 1, ...(_desktop && { premium: 'enhanced' as const }) },
'strategic-risk': { name: 'Strategic Risk Overview', enabled: true, priority: 1, ...(_desktop && { premium: 'enhanced' as const }) },
intel: { name: 'Intel Feed', enabled: true, priority: 1 },
@@ -864,7 +865,7 @@ export const PANEL_CATEGORY_MAP: Record<string, { labelKey: string; panelKeys: s
// Full (geopolitical) variant
intelligence: {
labelKey: 'header.panelCatIntelligence',
panelKeys: ['cii', 'strategic-risk', 'intel', 'gdelt-intel', 'cascade', 'telegram-intel'],
panelKeys: ['cii', 'strategic-risk', 'intel', 'gdelt-intel', 'cascade', 'telegram-intel', 'forecast'],
variants: ['full'],
},
correlation: {

View File

@@ -15,6 +15,7 @@ export const REFRESH_INTERVALS = {
markets: 12 * 60 * 1000,
crypto: 12 * 60 * 1000,
predictions: 15 * 60 * 1000,
forecasts: 30 * 60 * 1000,
ais: 15 * 60 * 1000,
};

View File

@@ -2,8 +2,6 @@
// Code generated by protoc-gen-ts-client. DO NOT EDIT.
// source: worldmonitor/aviation/v1/service.proto
// ---- Existing types ----
export interface ListAirportDelaysRequest {
pageSize: number;
cursor: string;
@@ -46,39 +44,43 @@ export interface PaginationResponse {
totalCount: number;
}
export type AirportRegion = "AIRPORT_REGION_UNSPECIFIED" | "AIRPORT_REGION_AMERICAS" | "AIRPORT_REGION_EUROPE" | "AIRPORT_REGION_APAC" | "AIRPORT_REGION_MENA" | "AIRPORT_REGION_AFRICA";
export type FlightDelaySeverity = "FLIGHT_DELAY_SEVERITY_UNSPECIFIED" | "FLIGHT_DELAY_SEVERITY_NORMAL" | "FLIGHT_DELAY_SEVERITY_MINOR" | "FLIGHT_DELAY_SEVERITY_MODERATE" | "FLIGHT_DELAY_SEVERITY_MAJOR" | "FLIGHT_DELAY_SEVERITY_SEVERE";
export type FlightDelaySource = "FLIGHT_DELAY_SOURCE_UNSPECIFIED" | "FLIGHT_DELAY_SOURCE_FAA" | "FLIGHT_DELAY_SOURCE_EUROCONTROL" | "FLIGHT_DELAY_SOURCE_COMPUTED" | "FLIGHT_DELAY_SOURCE_AVIATIONSTACK" | "FLIGHT_DELAY_SOURCE_NOTAM";
export type FlightDelayType = "FLIGHT_DELAY_TYPE_UNSPECIFIED" | "FLIGHT_DELAY_TYPE_GROUND_STOP" | "FLIGHT_DELAY_TYPE_GROUND_DELAY" | "FLIGHT_DELAY_TYPE_DEPARTURE_DELAY" | "FLIGHT_DELAY_TYPE_ARRIVAL_DELAY" | "FLIGHT_DELAY_TYPE_GENERAL" | "FLIGHT_DELAY_TYPE_CLOSURE";
// ---- New entity types ----
export interface Carrier {
iataCode: string;
icaoCode: string;
name: string;
export interface GetAirportOpsSummaryRequest {
airports: string[];
}
export type FlightInstanceStatus =
| "FLIGHT_INSTANCE_STATUS_UNSPECIFIED"
| "FLIGHT_INSTANCE_STATUS_SCHEDULED"
| "FLIGHT_INSTANCE_STATUS_BOARDING"
| "FLIGHT_INSTANCE_STATUS_DEPARTED"
| "FLIGHT_INSTANCE_STATUS_AIRBORNE"
| "FLIGHT_INSTANCE_STATUS_LANDED"
| "FLIGHT_INSTANCE_STATUS_ARRIVED"
| "FLIGHT_INSTANCE_STATUS_CANCELLED"
| "FLIGHT_INSTANCE_STATUS_DIVERTED"
| "FLIGHT_INSTANCE_STATUS_UNKNOWN";
export interface GetAirportOpsSummaryResponse {
summaries: AirportOpsSummary[];
cacheHit: boolean;
}
export interface AirportRef {
export interface AirportOpsSummary {
iata: string;
icao: string;
name: string;
timezone: string;
delayPct: number;
avgDelayMinutes: number;
cancellationRate: number;
totalFlights: number;
closureStatus: boolean;
notamFlags: string[];
severity: FlightDelaySeverity;
topDelayReasons: string[];
source: string;
updatedAt: number;
}
export interface ListAirportFlightsRequest {
airport: string;
direction: FlightDirection;
limit: number;
}
export interface ListAirportFlightsResponse {
flights: FlightInstance[];
totalAvailable: number;
source: string;
updatedAt: number;
}
export interface FlightInstance {
@@ -106,96 +108,17 @@ export interface FlightInstance {
updatedAt: number;
}
export type PositionSource = "POSITION_SOURCE_UNSPECIFIED" | "POSITION_SOURCE_OPENSKY" | "POSITION_SOURCE_WINGBITS" | "POSITION_SOURCE_SIMULATED";
export interface PositionSample {
icao24: string;
callsign: string;
lat: number;
lon: number;
altitudeM: number;
groundSpeedKts: number;
trackDeg: number;
verticalRate: number;
onGround: boolean;
source: PositionSource;
observedAt: number;
export interface Carrier {
iataCode: string;
icaoCode: string;
name: string;
}
export type CabinClass = "CABIN_CLASS_UNSPECIFIED" | "CABIN_CLASS_ECONOMY" | "CABIN_CLASS_PREMIUM_ECONOMY" | "CABIN_CLASS_BUSINESS" | "CABIN_CLASS_FIRST";
export interface PriceQuote {
id: string;
origin: string;
destination: string;
departureDate: string;
returnDate: string;
carrier?: Carrier;
priceAmount: number;
currency: string;
cabin: CabinClass;
stops: number;
durationMinutes: number;
bookingUrl: string;
checkoutRef: string;
provider: string;
isIndicative: boolean;
observedAt: number;
expiresAt: number;
}
export interface AviationNewsItem {
id: string;
title: string;
url: string;
sourceName: string;
publishedAt: number;
snippet: string;
matchedEntities: string[];
imageUrl: string;
}
// ---- New RPC types ----
export interface GetAirportOpsSummaryRequest {
airports: string[];
}
export interface AirportOpsSummary {
export interface AirportRef {
iata: string;
icao: string;
name: string;
timezone: string;
delayPct: number;
avgDelayMinutes: number;
cancellationRate: number;
totalFlights: number;
closureStatus: boolean;
notamFlags: string[];
severity: FlightDelaySeverity;
topDelayReasons: string[];
source: string;
updatedAt: number;
}
export interface GetAirportOpsSummaryResponse {
summaries: AirportOpsSummary[];
cacheHit: boolean;
}
export type FlightDirection = "FLIGHT_DIRECTION_UNSPECIFIED" | "FLIGHT_DIRECTION_DEPARTURE" | "FLIGHT_DIRECTION_ARRIVAL" | "FLIGHT_DIRECTION_BOTH";
export interface ListAirportFlightsRequest {
airport: string;
direction: FlightDirection;
limit: number;
}
export interface ListAirportFlightsResponse {
flights: FlightInstance[];
totalAvailable: number;
source: string;
updatedAt: number;
}
export interface GetCarrierOpsRequest {
@@ -203,6 +126,12 @@ export interface GetCarrierOpsRequest {
minFlights: number;
}
export interface GetCarrierOpsResponse {
carriers: CarrierOpsSummary[];
source: string;
updatedAt: number;
}
export interface CarrierOpsSummary {
carrier?: Carrier;
airport: string;
@@ -215,12 +144,6 @@ export interface CarrierOpsSummary {
updatedAt: number;
}
export interface GetCarrierOpsResponse {
carriers: CarrierOpsSummary[];
source: string;
updatedAt: number;
}
export interface GetFlightStatusRequest {
flightNumber: string;
date: string;
@@ -248,6 +171,20 @@ export interface TrackAircraftResponse {
updatedAt: number;
}
export interface PositionSample {
icao24: string;
callsign: string;
lat: number;
lon: number;
altitudeM: number;
groundSpeedKts: number;
trackDeg: number;
verticalRate: number;
onGround: boolean;
source: PositionSource;
observedAt: number;
}
export interface SearchFlightPricesRequest {
origin: string;
destination: string;
@@ -265,8 +202,28 @@ export interface SearchFlightPricesResponse {
quotes: PriceQuote[];
provider: string;
isDemoMode: boolean;
isIndicative: boolean;
updatedAt: number;
isIndicative: boolean;
}
export interface PriceQuote {
id: string;
origin: string;
destination: string;
departureDate: string;
returnDate: string;
carrier?: Carrier;
priceAmount: number;
currency: string;
cabin: CabinClass;
stops: number;
durationMinutes: number;
bookingUrl: string;
provider: string;
isIndicative: boolean;
observedAt: number;
checkoutRef: string;
expiresAt: number;
}
export interface ListAviationNewsRequest {
@@ -281,7 +238,32 @@ export interface ListAviationNewsResponse {
updatedAt: number;
}
// ---- Framework types ----
export interface AviationNewsItem {
id: string;
title: string;
url: string;
sourceName: string;
publishedAt: number;
snippet: string;
matchedEntities: string[];
imageUrl: string;
}
export type AirportRegion = "AIRPORT_REGION_UNSPECIFIED" | "AIRPORT_REGION_AMERICAS" | "AIRPORT_REGION_EUROPE" | "AIRPORT_REGION_APAC" | "AIRPORT_REGION_MENA" | "AIRPORT_REGION_AFRICA";
export type CabinClass = "CABIN_CLASS_UNSPECIFIED" | "CABIN_CLASS_ECONOMY" | "CABIN_CLASS_PREMIUM_ECONOMY" | "CABIN_CLASS_BUSINESS" | "CABIN_CLASS_FIRST";
export type FlightDelaySeverity = "FLIGHT_DELAY_SEVERITY_UNSPECIFIED" | "FLIGHT_DELAY_SEVERITY_NORMAL" | "FLIGHT_DELAY_SEVERITY_MINOR" | "FLIGHT_DELAY_SEVERITY_MODERATE" | "FLIGHT_DELAY_SEVERITY_MAJOR" | "FLIGHT_DELAY_SEVERITY_SEVERE";
export type FlightDelaySource = "FLIGHT_DELAY_SOURCE_UNSPECIFIED" | "FLIGHT_DELAY_SOURCE_FAA" | "FLIGHT_DELAY_SOURCE_EUROCONTROL" | "FLIGHT_DELAY_SOURCE_COMPUTED" | "FLIGHT_DELAY_SOURCE_AVIATIONSTACK" | "FLIGHT_DELAY_SOURCE_NOTAM";
export type FlightDelayType = "FLIGHT_DELAY_TYPE_UNSPECIFIED" | "FLIGHT_DELAY_TYPE_GROUND_STOP" | "FLIGHT_DELAY_TYPE_GROUND_DELAY" | "FLIGHT_DELAY_TYPE_DEPARTURE_DELAY" | "FLIGHT_DELAY_TYPE_ARRIVAL_DELAY" | "FLIGHT_DELAY_TYPE_GENERAL" | "FLIGHT_DELAY_TYPE_CLOSURE";
export type FlightDirection = "FLIGHT_DIRECTION_UNSPECIFIED" | "FLIGHT_DIRECTION_DEPARTURE" | "FLIGHT_DIRECTION_ARRIVAL" | "FLIGHT_DIRECTION_BOTH";
export type FlightInstanceStatus = "FLIGHT_INSTANCE_STATUS_UNSPECIFIED" | "FLIGHT_INSTANCE_STATUS_SCHEDULED" | "FLIGHT_INSTANCE_STATUS_BOARDING" | "FLIGHT_INSTANCE_STATUS_DEPARTED" | "FLIGHT_INSTANCE_STATUS_AIRBORNE" | "FLIGHT_INSTANCE_STATUS_LANDED" | "FLIGHT_INSTANCE_STATUS_ARRIVED" | "FLIGHT_INSTANCE_STATUS_CANCELLED" | "FLIGHT_INSTANCE_STATUS_DIVERTED" | "FLIGHT_INSTANCE_STATUS_UNKNOWN";
export type PositionSource = "POSITION_SOURCE_UNSPECIFIED" | "POSITION_SOURCE_OPENSKY" | "POSITION_SOURCE_WINGBITS" | "POSITION_SOURCE_SIMULATED";
export interface FieldViolation {
field: string;
@@ -331,86 +313,228 @@ export class AviationServiceClient {
this.defaultHeaders = { ...options?.defaultHeaders };
}
private async get<T>(path: string, params: URLSearchParams, options?: AviationServiceCallOptions): Promise<T> {
async listAirportDelays(req: ListAirportDelaysRequest, options?: AviationServiceCallOptions): Promise<ListAirportDelaysResponse> {
let path = "/api/aviation/v1/list-airport-delays";
const params = new URLSearchParams();
if (req.pageSize != null && req.pageSize !== 0) params.set("page_size", String(req.pageSize));
if (req.cursor != null && req.cursor !== "") params.set("cursor", String(req.cursor));
if (req.region != null && req.region !== "") params.set("region", String(req.region));
if (req.minSeverity != null && req.minSeverity !== "") params.set("min_severity", String(req.minSeverity));
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 resp.json() as Promise<T>;
}
async listAirportDelays(req: ListAirportDelaysRequest, options?: AviationServiceCallOptions): Promise<ListAirportDelaysResponse> {
const p = new URLSearchParams();
if (req.pageSize) p.set("page_size", String(req.pageSize));
if (req.cursor) p.set("cursor", req.cursor);
if (req.region) p.set("region", req.region);
if (req.minSeverity) p.set("min_severity", req.minSeverity);
return this.get("/api/aviation/v1/list-airport-delays", p, options);
const resp = await this.fetchFn(url, {
method: "GET",
headers,
signal: options?.signal,
});
if (!resp.ok) {
return this.handleError(resp);
}
return await resp.json() as ListAirportDelaysResponse;
}
async getAirportOpsSummary(req: GetAirportOpsSummaryRequest, options?: AviationServiceCallOptions): Promise<GetAirportOpsSummaryResponse> {
const p = new URLSearchParams();
for (const a of req.airports ?? []) p.append("airports", a);
return this.get("/api/aviation/v1/get-airport-ops-summary", p, options);
let path = "/api/aviation/v1/get-airport-ops-summary";
const params = new URLSearchParams();
if (req.airports != null && req.airports !== "") params.set("airports", String(req.airports));
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 GetAirportOpsSummaryResponse;
}
async listAirportFlights(req: ListAirportFlightsRequest, options?: AviationServiceCallOptions): Promise<ListAirportFlightsResponse> {
const p = new URLSearchParams();
if (req.airport) p.set("airport", req.airport);
if (req.direction) p.set("direction", req.direction);
if (req.limit) p.set("limit", String(req.limit));
return this.get("/api/aviation/v1/list-airport-flights", p, options);
let path = "/api/aviation/v1/list-airport-flights";
const params = new URLSearchParams();
if (req.airport != null && req.airport !== "") params.set("airport", String(req.airport));
if (req.direction != null && req.direction !== "") params.set("direction", String(req.direction));
if (req.limit != null && req.limit !== 0) params.set("limit", String(req.limit));
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 ListAirportFlightsResponse;
}
async getCarrierOps(req: GetCarrierOpsRequest, options?: AviationServiceCallOptions): Promise<GetCarrierOpsResponse> {
const p = new URLSearchParams();
for (const a of req.airports ?? []) p.append("airports", a);
if (req.minFlights) p.set("min_flights", String(req.minFlights));
return this.get("/api/aviation/v1/get-carrier-ops", p, options);
let path = "/api/aviation/v1/get-carrier-ops";
const params = new URLSearchParams();
if (req.airports != null && req.airports !== "") params.set("airports", String(req.airports));
if (req.minFlights != null && req.minFlights !== 0) params.set("min_flights", String(req.minFlights));
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 GetCarrierOpsResponse;
}
async getFlightStatus(req: GetFlightStatusRequest, options?: AviationServiceCallOptions): Promise<GetFlightStatusResponse> {
const p = new URLSearchParams();
if (req.flightNumber) p.set("flight_number", req.flightNumber);
if (req.date) p.set("date", req.date);
if (req.origin) p.set("origin", req.origin);
return this.get("/api/aviation/v1/get-flight-status", p, options);
let path = "/api/aviation/v1/get-flight-status";
const params = new URLSearchParams();
if (req.flightNumber != null && req.flightNumber !== "") params.set("flight_number", String(req.flightNumber));
if (req.date != null && req.date !== "") params.set("date", String(req.date));
if (req.origin != null && req.origin !== "") params.set("origin", String(req.origin));
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 GetFlightStatusResponse;
}
async trackAircraft(req: TrackAircraftRequest, options?: AviationServiceCallOptions): Promise<TrackAircraftResponse> {
const p = new URLSearchParams();
if (req.icao24) p.set("icao24", req.icao24);
if (req.callsign) p.set("callsign", req.callsign);
if (req.swLat) p.set("sw_lat", String(req.swLat));
if (req.swLon) p.set("sw_lon", String(req.swLon));
if (req.neLat) p.set("ne_lat", String(req.neLat));
if (req.neLon) p.set("ne_lon", String(req.neLon));
return this.get("/api/aviation/v1/track-aircraft", p, options);
let path = "/api/aviation/v1/track-aircraft";
const params = new URLSearchParams();
if (req.icao24 != null && req.icao24 !== "") params.set("icao24", String(req.icao24));
if (req.callsign != null && req.callsign !== "") params.set("callsign", String(req.callsign));
if (req.swLat != null && req.swLat !== 0) params.set("sw_lat", String(req.swLat));
if (req.swLon != null && req.swLon !== 0) params.set("sw_lon", String(req.swLon));
if (req.neLat != null && req.neLat !== 0) params.set("ne_lat", String(req.neLat));
if (req.neLon != null && req.neLon !== 0) params.set("ne_lon", String(req.neLon));
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 TrackAircraftResponse;
}
async searchFlightPrices(req: SearchFlightPricesRequest, options?: AviationServiceCallOptions): Promise<SearchFlightPricesResponse> {
const p = new URLSearchParams();
if (req.origin) p.set("origin", req.origin);
if (req.destination) p.set("destination", req.destination);
if (req.departureDate) p.set("departure_date", req.departureDate);
if (req.returnDate) p.set("return_date", req.returnDate);
if (req.adults) p.set("adults", String(req.adults));
if (req.cabin) p.set("cabin", req.cabin);
if (req.nonstopOnly) p.set("nonstop_only", "true");
if (req.maxResults) p.set("max_results", String(req.maxResults));
return this.get("/api/aviation/v1/search-flight-prices", p, options);
let path = "/api/aviation/v1/search-flight-prices";
const params = new URLSearchParams();
if (req.origin != null && req.origin !== "") params.set("origin", String(req.origin));
if (req.destination != null && req.destination !== "") params.set("destination", String(req.destination));
if (req.departureDate != null && req.departureDate !== "") params.set("departure_date", String(req.departureDate));
if (req.returnDate != null && req.returnDate !== "") params.set("return_date", String(req.returnDate));
if (req.adults != null && req.adults !== 0) params.set("adults", String(req.adults));
if (req.cabin != null && req.cabin !== "") params.set("cabin", String(req.cabin));
if (req.nonstopOnly) params.set("nonstop_only", String(req.nonstopOnly));
if (req.maxResults != null && req.maxResults !== 0) params.set("max_results", String(req.maxResults));
if (req.currency != null && req.currency !== "") params.set("currency", String(req.currency));
if (req.market != null && req.market !== "") params.set("market", String(req.market));
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 SearchFlightPricesResponse;
}
async listAviationNews(req: ListAviationNewsRequest, options?: AviationServiceCallOptions): Promise<ListAviationNewsResponse> {
const p = new URLSearchParams();
for (const e of req.entities ?? []) p.append("entities", e);
if (req.windowHours) p.set("window_hours", String(req.windowHours));
if (req.maxItems) p.set("max_items", String(req.maxItems));
return this.get("/api/aviation/v1/list-aviation-news", p, options);
let path = "/api/aviation/v1/list-aviation-news";
const params = new URLSearchParams();
if (req.entities != null && req.entities !== "") params.set("entities", String(req.entities));
if (req.windowHours != null && req.windowHours !== 0) params.set("window_hours", String(req.windowHours));
if (req.maxItems != null && req.maxItems !== 0) params.set("max_items", String(req.maxItems));
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 ListAviationNewsResponse;
}
private async handleError(resp: Response): Promise<never> {
@@ -418,7 +542,9 @@ export class AviationServiceClient {
if (resp.status === 400) {
try {
const parsed = JSON.parse(body);
if (parsed.violations) throw new ValidationError(parsed.violations);
if (parsed.violations) {
throw new ValidationError(parsed.violations);
}
} catch (e) {
if (e instanceof ValidationError) throw e;
}
@@ -426,3 +552,4 @@ export class AviationServiceClient {
throw new ApiError(resp.status, `Request failed with status ${resp.status}`, body);
}
}

View File

@@ -84,16 +84,6 @@ export interface HumanitarianCountrySummary {
updatedAt: number;
}
export interface GetHumanitarianSummaryBatchRequest {
countryCodes: string[];
}
export interface GetHumanitarianSummaryBatchResponse {
results: Record<string, HumanitarianCountrySummary>;
fetched: number;
requested: number;
}
export interface ListIranEventsRequest {
}
@@ -114,6 +104,16 @@ export interface IranEvent {
severity: string;
}
export interface GetHumanitarianSummaryBatchRequest {
countryCodes: string[];
}
export interface GetHumanitarianSummaryBatchResponse {
results: Record<string, HumanitarianCountrySummary>;
fetched: number;
requested: number;
}
export type UcdpViolenceType = "UCDP_VIOLENCE_TYPE_UNSPECIFIED" | "UCDP_VIOLENCE_TYPE_STATE_BASED" | "UCDP_VIOLENCE_TYPE_NON_STATE" | "UCDP_VIOLENCE_TYPE_ONE_SIDED";
export interface FieldViolation {
@@ -247,30 +247,6 @@ export class ConflictServiceClient {
return await resp.json() as GetHumanitarianSummaryResponse;
}
async getHumanitarianSummaryBatch(req: GetHumanitarianSummaryBatchRequest, options?: ConflictServiceCallOptions): Promise<GetHumanitarianSummaryBatchResponse> {
let path = "/api/conflict/v1/get-humanitarian-summary-batch";
const url = this.baseURL + path;
const headers: Record<string, string> = {
"Content-Type": "application/json",
...this.defaultHeaders,
...options?.headers,
};
const resp = await this.fetchFn(url, {
method: "POST",
headers,
body: JSON.stringify({ country_codes: req.countryCodes }),
signal: options?.signal,
});
if (!resp.ok) {
return this.handleError(resp);
}
return await resp.json() as GetHumanitarianSummaryBatchResponse;
}
async listIranEvents(req: ListIranEventsRequest, options?: ConflictServiceCallOptions): Promise<ListIranEventsResponse> {
let path = "/api/conflict/v1/list-iran-events";
const url = this.baseURL + path;
@@ -294,6 +270,30 @@ export class ConflictServiceClient {
return await resp.json() as ListIranEventsResponse;
}
async getHumanitarianSummaryBatch(req: GetHumanitarianSummaryBatchRequest, options?: ConflictServiceCallOptions): Promise<GetHumanitarianSummaryBatchResponse> {
let path = "/api/conflict/v1/get-humanitarian-summary-batch";
const url = this.baseURL + path;
const headers: Record<string, string> = {
"Content-Type": "application/json",
...this.defaultHeaders,
...options?.headers,
};
const resp = await this.fetchFn(url, {
method: "POST",
headers,
body: JSON.stringify(req),
signal: options?.signal,
});
if (!resp.ok) {
return this.handleError(resp);
}
return await resp.json() as GetHumanitarianSummaryBatchResponse;
}
private async handleError(resp: Response): Promise<never> {
const body = await resp.text();
if (resp.status === 400) {

View File

@@ -24,17 +24,6 @@ export interface FredObservation {
value: number;
}
export interface GetFredSeriesBatchRequest {
seriesIds: string[];
limit: number;
}
export interface GetFredSeriesBatchResponse {
results: Record<string, FredSeries>;
fetched: number;
requested: number;
}
export interface ListWorldBankIndicatorsRequest {
indicatorCode: string;
countryCode: string;
@@ -221,6 +210,17 @@ export interface BisCreditToGdp {
date: string;
}
export interface GetFredSeriesBatchRequest {
seriesIds: string[];
limit: number;
}
export interface GetFredSeriesBatchResponse {
results: Record<string, FredSeries>;
fetched: number;
requested: number;
}
export interface FieldViolation {
field: string;
description: string;
@@ -295,30 +295,6 @@ export class EconomicServiceClient {
return await resp.json() as GetFredSeriesResponse;
}
async getFredSeriesBatch(req: GetFredSeriesBatchRequest, options?: EconomicServiceCallOptions): Promise<GetFredSeriesBatchResponse> {
let path = "/api/economic/v1/get-fred-series-batch";
const url = this.baseURL + path;
const headers: Record<string, string> = {
"Content-Type": "application/json",
...this.defaultHeaders,
...options?.headers,
};
const resp = await this.fetchFn(url, {
method: "POST",
headers,
body: JSON.stringify({ series_ids: req.seriesIds, limit: req.limit }),
signal: options?.signal,
});
if (!resp.ok) {
return this.handleError(resp);
}
return await resp.json() as GetFredSeriesBatchResponse;
}
async listWorldBankIndicators(req: ListWorldBankIndicatorsRequest, options?: EconomicServiceCallOptions): Promise<ListWorldBankIndicatorsResponse> {
let path = "/api/economic/v1/list-world-bank-indicators";
const params = new URLSearchParams();
@@ -491,6 +467,30 @@ export class EconomicServiceClient {
return await resp.json() as GetBisCreditResponse;
}
async getFredSeriesBatch(req: GetFredSeriesBatchRequest, options?: EconomicServiceCallOptions): Promise<GetFredSeriesBatchResponse> {
let path = "/api/economic/v1/get-fred-series-batch";
const url = this.baseURL + path;
const headers: Record<string, string> = {
"Content-Type": "application/json",
...this.defaultHeaders,
...options?.headers,
};
const resp = await this.fetchFn(url, {
method: "POST",
headers,
body: JSON.stringify(req),
signal: options?.signal,
});
if (!resp.ok) {
return this.handleError(resp);
}
return await resp.json() as GetFredSeriesBatchResponse;
}
private async handleError(resp: Response): Promise<never> {
const body = await resp.text();
if (resp.status === 400) {

View File

@@ -0,0 +1,155 @@
// @ts-nocheck
// Code generated by protoc-gen-ts-client. DO NOT EDIT.
// source: worldmonitor/forecast/v1/service.proto
export interface GetForecastsRequest {
domain: string;
region: string;
}
export interface GetForecastsResponse {
forecasts: Forecast[];
generatedAt: number;
}
export interface Forecast {
id: string;
domain: string;
region: string;
title: string;
scenario: string;
probability: number;
confidence: number;
timeHorizon: string;
signals: ForecastSignal[];
cascades: CascadeEffect[];
trend: string;
priorProbability: number;
calibration?: CalibrationInfo;
createdAt: number;
updatedAt: number;
perspectives?: Perspectives;
projections?: Projections;
}
export interface ForecastSignal {
type: string;
value: string;
weight: number;
}
export interface CascadeEffect {
domain: string;
effect: string;
probability: number;
}
export interface CalibrationInfo {
marketTitle: string;
marketPrice: number;
drift: number;
source: string;
}
export interface Perspectives {
strategic: string;
regional: string;
contrarian: string;
}
export interface Projections {
h24: number;
d7: number;
d30: number;
}
export interface FieldViolation {
field: string;
description: string;
}
export class ValidationError extends Error {
violations: FieldViolation[];
constructor(violations: FieldViolation[]) {
super("Validation failed");
this.name = "ValidationError";
this.violations = violations;
}
}
export class ApiError extends Error {
statusCode: number;
body: string;
constructor(statusCode: number, message: string, body: string) {
super(message);
this.name = "ApiError";
this.statusCode = statusCode;
this.body = body;
}
}
export interface ForecastServiceClientOptions {
fetch?: typeof fetch;
defaultHeaders?: Record<string, string>;
}
export interface ForecastServiceCallOptions {
headers?: Record<string, string>;
signal?: AbortSignal;
}
export class ForecastServiceClient {
private baseURL: string;
private fetchFn: typeof fetch;
private defaultHeaders: Record<string, string>;
constructor(baseURL: string, options?: ForecastServiceClientOptions) {
this.baseURL = baseURL.replace(/\/+$/, "");
this.fetchFn = options?.fetch ?? globalThis.fetch;
this.defaultHeaders = { ...options?.defaultHeaders };
}
async getForecasts(req: GetForecastsRequest, options?: ForecastServiceCallOptions): Promise<GetForecastsResponse> {
let path = "/api/forecast/v1/get-forecasts";
const params = new URLSearchParams();
if (req.domain != null && req.domain !== "") params.set("domain", String(req.domain));
if (req.region != null && req.region !== "") params.set("region", String(req.region));
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 GetForecastsResponse;
}
private async handleError(resp: Response): Promise<never> {
const body = await resp.text();
if (resp.status === 400) {
try {
const parsed = JSON.parse(body);
if (parsed.violations) {
throw new ValidationError(parsed.violations);
}
} catch (e) {
if (e instanceof ValidationError) throw e;
}
}
throw new ApiError(resp.status, `Request failed with status ${resp.status}`, body);
}
}

View File

@@ -0,0 +1,120 @@
// @ts-nocheck
// Code generated by protoc-gen-ts-client. DO NOT EDIT.
// source: worldmonitor/imagery/v1/service.proto
export interface SearchImageryRequest {
bbox: string;
datetime: string;
source: string;
limit: number;
}
export interface SearchImageryResponse {
scenes: ImageryScene[];
totalResults: number;
cacheHit: boolean;
}
export interface ImageryScene {
id: string;
satellite: string;
datetime: string;
resolutionM: number;
mode: string;
geometryGeojson: string;
previewUrl: string;
assetUrl: string;
}
export interface FieldViolation {
field: string;
description: string;
}
export class ValidationError extends Error {
violations: FieldViolation[];
constructor(violations: FieldViolation[]) {
super("Validation failed");
this.name = "ValidationError";
this.violations = violations;
}
}
export class ApiError extends Error {
statusCode: number;
body: string;
constructor(statusCode: number, message: string, body: string) {
super(message);
this.name = "ApiError";
this.statusCode = statusCode;
this.body = body;
}
}
export interface ImageryServiceClientOptions {
fetch?: typeof fetch;
defaultHeaders?: Record<string, string>;
}
export interface ImageryServiceCallOptions {
headers?: Record<string, string>;
signal?: AbortSignal;
}
export class ImageryServiceClient {
private baseURL: string;
private fetchFn: typeof fetch;
private defaultHeaders: Record<string, string>;
constructor(baseURL: string, options?: ImageryServiceClientOptions) {
this.baseURL = baseURL.replace(/\/+$/, "");
this.fetchFn = options?.fetch ?? globalThis.fetch;
this.defaultHeaders = { ...options?.defaultHeaders };
}
async searchImagery(req: SearchImageryRequest, options?: ImageryServiceCallOptions): Promise<SearchImageryResponse> {
let path = "/api/imagery/v1/search-imagery";
const params = new URLSearchParams();
if (req.bbox != null && req.bbox !== "") params.set("bbox", String(req.bbox));
if (req.datetime != null && req.datetime !== "") params.set("datetime", String(req.datetime));
if (req.source != null && req.source !== "") params.set("source", String(req.source));
if (req.limit != null && req.limit !== 0) params.set("limit", String(req.limit));
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 SearchImageryResponse;
}
private async handleError(resp: Response): Promise<never> {
const body = await resp.text();
if (resp.status === 400) {
try {
const parsed = JSON.parse(body);
if (parsed.violations) {
throw new ValidationError(parsed.violations);
}
} catch (e) {
if (e instanceof ValidationError) throw e;
}
}
throw new ApiError(resp.status, `Request failed with status ${resp.status}`, body);
}
}

View File

@@ -101,26 +101,6 @@ export interface RecordBaselineSnapshotResponse {
error: string;
}
export interface ListTemporalAnomaliesRequest {
}
export interface ListTemporalAnomaliesResponse {
anomalies: TemporalAnomalyProto[];
trackedTypes: string[];
computedAt: string;
}
export interface TemporalAnomalyProto {
type: string;
region: string;
currentCount: number;
expectedCount: number;
zScore: number;
severity: string;
multiplier: number;
message: string;
}
export interface GetCableHealthRequest {
}
@@ -143,6 +123,26 @@ export interface CableHealthEvidence {
ts: number;
}
export interface ListTemporalAnomaliesRequest {
}
export interface ListTemporalAnomaliesResponse {
anomalies: TemporalAnomaly[];
trackedTypes: string[];
computedAt: string;
}
export interface TemporalAnomaly {
type: string;
region: string;
currentCount: number;
expectedCount: number;
zScore: number;
severity: string;
multiplier: number;
message: string;
}
export type CableHealthStatus = "CABLE_HEALTH_STATUS_UNSPECIFIED" | "CABLE_HEALTH_STATUS_OK" | "CABLE_HEALTH_STATUS_DEGRADED" | "CABLE_HEALTH_STATUS_FAULT";
export type OutageSeverity = "OUTAGE_SEVERITY_UNSPECIFIED" | "OUTAGE_SEVERITY_PARTIAL" | "OUTAGE_SEVERITY_MAJOR" | "OUTAGE_SEVERITY_TOTAL";

View File

@@ -659,3 +659,4 @@ export class MarketServiceClient {
throw new ApiError(resp.status, `Request failed with status ${resp.status}`, body);
}
}

View File

@@ -1,3 +1,4 @@
// @ts-nocheck
// Code generated by protoc-gen-ts-client. DO NOT EDIT.
// source: worldmonitor/natural/v1/service.proto

View File

@@ -14,12 +14,6 @@ export interface ListPredictionMarketsResponse {
pagination?: PaginationResponse;
}
export enum MarketSource {
MARKET_SOURCE_UNSPECIFIED = "MARKET_SOURCE_UNSPECIFIED",
MARKET_SOURCE_POLYMARKET = "MARKET_SOURCE_POLYMARKET",
MARKET_SOURCE_KALSHI = "MARKET_SOURCE_KALSHI",
}
export interface PredictionMarket {
id: string;
title: string;
@@ -36,6 +30,8 @@ export interface PaginationResponse {
totalCount: number;
}
export type MarketSource = "MARKET_SOURCE_UNSPECIFIED" | "MARKET_SOURCE_POLYMARKET" | "MARKET_SOURCE_KALSHI";
export interface FieldViolation {
field: string;
description: string;

View File

@@ -50,7 +50,7 @@ export interface ChokepointInfo {
aisDisruptions: number;
directions: string[];
directionalDwt: DirectionalDwt[];
transitSummary: TransitSummary;
transitSummary?: TransitSummary;
}
export interface DirectionalDwt {
@@ -59,14 +59,6 @@ export interface DirectionalDwt {
wowChangePct: number;
}
export interface TransitDayCount {
date: string;
tanker: number;
cargo: number;
other: number;
total: number;
}
export interface TransitSummary {
todayTotal: number;
todayTanker: number;
@@ -79,6 +71,14 @@ export interface TransitSummary {
disruptionPct: number;
}
export interface TransitDayCount {
date: string;
tanker: number;
cargo: number;
other: number;
total: number;
}
export interface GetCriticalMineralsRequest {
}
@@ -236,3 +236,4 @@ export class SupplyChainServiceClient {
throw new ApiError(resp.status, `Request failed with status ${resp.status}`, body);
}
}

View File

@@ -10,6 +10,12 @@ export interface ListWebcamsRequest {
boundN: number;
}
export interface ListWebcamsResponse {
webcams: WebcamEntry[];
clusters: WebcamCluster[];
totalInView: number;
}
export interface WebcamEntry {
webcamId: string;
title: string;
@@ -26,12 +32,6 @@ export interface WebcamCluster {
categories: string[];
}
export interface ListWebcamsResponse {
webcams: WebcamEntry[];
clusters: WebcamCluster[];
totalInView: number;
}
export interface GetWebcamImageRequest {
webcamId: string;
}
@@ -41,7 +41,7 @@ export interface GetWebcamImageResponse {
playerUrl: string;
title: string;
windyUrl: string;
lastUpdated: number;
lastUpdated: string;
error: string;
}
@@ -162,3 +162,4 @@ export class WebcamServiceClient {
throw new ApiError(resp.status, `Request failed with status ${resp.status}`, body);
}
}

View File

@@ -2,8 +2,6 @@
// Code generated by protoc-gen-ts-server. DO NOT EDIT.
// source: worldmonitor/aviation/v1/service.proto
// ---- Existing types ----
export interface ListAirportDelaysRequest {
pageSize: number;
cursor: string;
@@ -46,39 +44,43 @@ export interface PaginationResponse {
totalCount: number;
}
export type AirportRegion = "AIRPORT_REGION_UNSPECIFIED" | "AIRPORT_REGION_AMERICAS" | "AIRPORT_REGION_EUROPE" | "AIRPORT_REGION_APAC" | "AIRPORT_REGION_MENA" | "AIRPORT_REGION_AFRICA";
export type FlightDelaySeverity = "FLIGHT_DELAY_SEVERITY_UNSPECIFIED" | "FLIGHT_DELAY_SEVERITY_NORMAL" | "FLIGHT_DELAY_SEVERITY_MINOR" | "FLIGHT_DELAY_SEVERITY_MODERATE" | "FLIGHT_DELAY_SEVERITY_MAJOR" | "FLIGHT_DELAY_SEVERITY_SEVERE";
export type FlightDelaySource = "FLIGHT_DELAY_SOURCE_UNSPECIFIED" | "FLIGHT_DELAY_SOURCE_FAA" | "FLIGHT_DELAY_SOURCE_EUROCONTROL" | "FLIGHT_DELAY_SOURCE_COMPUTED" | "FLIGHT_DELAY_SOURCE_AVIATIONSTACK" | "FLIGHT_DELAY_SOURCE_NOTAM";
export type FlightDelayType = "FLIGHT_DELAY_TYPE_UNSPECIFIED" | "FLIGHT_DELAY_TYPE_GROUND_STOP" | "FLIGHT_DELAY_TYPE_GROUND_DELAY" | "FLIGHT_DELAY_TYPE_DEPARTURE_DELAY" | "FLIGHT_DELAY_TYPE_ARRIVAL_DELAY" | "FLIGHT_DELAY_TYPE_GENERAL" | "FLIGHT_DELAY_TYPE_CLOSURE";
// ---- New entity types ----
export interface Carrier {
iataCode: string;
icaoCode: string;
name: string;
export interface GetAirportOpsSummaryRequest {
airports: string[];
}
export type FlightInstanceStatus =
| "FLIGHT_INSTANCE_STATUS_UNSPECIFIED"
| "FLIGHT_INSTANCE_STATUS_SCHEDULED"
| "FLIGHT_INSTANCE_STATUS_BOARDING"
| "FLIGHT_INSTANCE_STATUS_DEPARTED"
| "FLIGHT_INSTANCE_STATUS_AIRBORNE"
| "FLIGHT_INSTANCE_STATUS_LANDED"
| "FLIGHT_INSTANCE_STATUS_ARRIVED"
| "FLIGHT_INSTANCE_STATUS_CANCELLED"
| "FLIGHT_INSTANCE_STATUS_DIVERTED"
| "FLIGHT_INSTANCE_STATUS_UNKNOWN";
export interface GetAirportOpsSummaryResponse {
summaries: AirportOpsSummary[];
cacheHit: boolean;
}
export interface AirportRef {
export interface AirportOpsSummary {
iata: string;
icao: string;
name: string;
timezone: string;
delayPct: number;
avgDelayMinutes: number;
cancellationRate: number;
totalFlights: number;
closureStatus: boolean;
notamFlags: string[];
severity: FlightDelaySeverity;
topDelayReasons: string[];
source: string;
updatedAt: number;
}
export interface ListAirportFlightsRequest {
airport: string;
direction: FlightDirection;
limit: number;
}
export interface ListAirportFlightsResponse {
flights: FlightInstance[];
totalAvailable: number;
source: string;
updatedAt: number;
}
export interface FlightInstance {
@@ -106,96 +108,17 @@ export interface FlightInstance {
updatedAt: number;
}
export type PositionSource = "POSITION_SOURCE_UNSPECIFIED" | "POSITION_SOURCE_OPENSKY" | "POSITION_SOURCE_WINGBITS" | "POSITION_SOURCE_SIMULATED";
export interface PositionSample {
icao24: string;
callsign: string;
lat: number;
lon: number;
altitudeM: number;
groundSpeedKts: number;
trackDeg: number;
verticalRate: number;
onGround: boolean;
source: PositionSource;
observedAt: number;
export interface Carrier {
iataCode: string;
icaoCode: string;
name: string;
}
export type CabinClass = "CABIN_CLASS_UNSPECIFIED" | "CABIN_CLASS_ECONOMY" | "CABIN_CLASS_PREMIUM_ECONOMY" | "CABIN_CLASS_BUSINESS" | "CABIN_CLASS_FIRST";
export interface PriceQuote {
id: string;
origin: string;
destination: string;
departureDate: string;
returnDate: string;
carrier?: Carrier;
priceAmount: number;
currency: string;
cabin: CabinClass;
stops: number;
durationMinutes: number;
bookingUrl: string; // keep empty for cached/demo providers
checkoutRef: string; // opaque ref for future click-only checkout flow
provider: string;
isIndicative: boolean;
observedAt: number;
expiresAt: number; // ms UTC (0 if unknown)
}
export interface AviationNewsItem {
id: string;
title: string;
url: string;
sourceName: string;
publishedAt: number;
snippet: string;
matchedEntities: string[];
imageUrl: string;
}
// ---- New RPC request/response types ----
export interface GetAirportOpsSummaryRequest {
airports: string[];
}
export interface AirportOpsSummary {
export interface AirportRef {
iata: string;
icao: string;
name: string;
timezone: string;
delayPct: number;
avgDelayMinutes: number;
cancellationRate: number;
totalFlights: number;
closureStatus: boolean;
notamFlags: string[];
severity: FlightDelaySeverity;
topDelayReasons: string[];
source: string;
updatedAt: number;
}
export interface GetAirportOpsSummaryResponse {
summaries: AirportOpsSummary[];
cacheHit: boolean;
}
export type FlightDirection = "FLIGHT_DIRECTION_UNSPECIFIED" | "FLIGHT_DIRECTION_DEPARTURE" | "FLIGHT_DIRECTION_ARRIVAL" | "FLIGHT_DIRECTION_BOTH";
export interface ListAirportFlightsRequest {
airport: string;
direction: FlightDirection;
limit: number;
}
export interface ListAirportFlightsResponse {
flights: FlightInstance[];
totalAvailable: number;
source: string;
updatedAt: number;
}
export interface GetCarrierOpsRequest {
@@ -203,6 +126,12 @@ export interface GetCarrierOpsRequest {
minFlights: number;
}
export interface GetCarrierOpsResponse {
carriers: CarrierOpsSummary[];
source: string;
updatedAt: number;
}
export interface CarrierOpsSummary {
carrier?: Carrier;
airport: string;
@@ -215,12 +144,6 @@ export interface CarrierOpsSummary {
updatedAt: number;
}
export interface GetCarrierOpsResponse {
carriers: CarrierOpsSummary[];
source: string;
updatedAt: number;
}
export interface GetFlightStatusRequest {
flightNumber: string;
date: string;
@@ -248,6 +171,20 @@ export interface TrackAircraftResponse {
updatedAt: number;
}
export interface PositionSample {
icao24: string;
callsign: string;
lat: number;
lon: number;
altitudeM: number;
groundSpeedKts: number;
trackDeg: number;
verticalRate: number;
onGround: boolean;
source: PositionSource;
observedAt: number;
}
export interface SearchFlightPricesRequest {
origin: string;
destination: string;
@@ -257,16 +194,36 @@ export interface SearchFlightPricesRequest {
cabin: CabinClass;
nonstopOnly: boolean;
maxResults: number;
currency: string; // optional, default "usd"
market: string; // optional, inferred by origin
currency: string;
market: string;
}
export interface SearchFlightPricesResponse {
quotes: PriceQuote[];
provider: string;
isDemoMode: boolean;
isIndicative: boolean; // always true for this RPC
updatedAt: number;
isIndicative: boolean;
}
export interface PriceQuote {
id: string;
origin: string;
destination: string;
departureDate: string;
returnDate: string;
carrier?: Carrier;
priceAmount: number;
currency: string;
cabin: CabinClass;
stops: number;
durationMinutes: number;
bookingUrl: string;
provider: string;
isIndicative: boolean;
observedAt: number;
checkoutRef: string;
expiresAt: number;
}
export interface ListAviationNewsRequest {
@@ -281,7 +238,32 @@ export interface ListAviationNewsResponse {
updatedAt: number;
}
// ---- Framework types ----
export interface AviationNewsItem {
id: string;
title: string;
url: string;
sourceName: string;
publishedAt: number;
snippet: string;
matchedEntities: string[];
imageUrl: string;
}
export type AirportRegion = "AIRPORT_REGION_UNSPECIFIED" | "AIRPORT_REGION_AMERICAS" | "AIRPORT_REGION_EUROPE" | "AIRPORT_REGION_APAC" | "AIRPORT_REGION_MENA" | "AIRPORT_REGION_AFRICA";
export type CabinClass = "CABIN_CLASS_UNSPECIFIED" | "CABIN_CLASS_ECONOMY" | "CABIN_CLASS_PREMIUM_ECONOMY" | "CABIN_CLASS_BUSINESS" | "CABIN_CLASS_FIRST";
export type FlightDelaySeverity = "FLIGHT_DELAY_SEVERITY_UNSPECIFIED" | "FLIGHT_DELAY_SEVERITY_NORMAL" | "FLIGHT_DELAY_SEVERITY_MINOR" | "FLIGHT_DELAY_SEVERITY_MODERATE" | "FLIGHT_DELAY_SEVERITY_MAJOR" | "FLIGHT_DELAY_SEVERITY_SEVERE";
export type FlightDelaySource = "FLIGHT_DELAY_SOURCE_UNSPECIFIED" | "FLIGHT_DELAY_SOURCE_FAA" | "FLIGHT_DELAY_SOURCE_EUROCONTROL" | "FLIGHT_DELAY_SOURCE_COMPUTED" | "FLIGHT_DELAY_SOURCE_AVIATIONSTACK" | "FLIGHT_DELAY_SOURCE_NOTAM";
export type FlightDelayType = "FLIGHT_DELAY_TYPE_UNSPECIFIED" | "FLIGHT_DELAY_TYPE_GROUND_STOP" | "FLIGHT_DELAY_TYPE_GROUND_DELAY" | "FLIGHT_DELAY_TYPE_DEPARTURE_DELAY" | "FLIGHT_DELAY_TYPE_ARRIVAL_DELAY" | "FLIGHT_DELAY_TYPE_GENERAL" | "FLIGHT_DELAY_TYPE_CLOSURE";
export type FlightDirection = "FLIGHT_DIRECTION_UNSPECIFIED" | "FLIGHT_DIRECTION_DEPARTURE" | "FLIGHT_DIRECTION_ARRIVAL" | "FLIGHT_DIRECTION_BOTH";
export type FlightInstanceStatus = "FLIGHT_INSTANCE_STATUS_UNSPECIFIED" | "FLIGHT_INSTANCE_STATUS_SCHEDULED" | "FLIGHT_INSTANCE_STATUS_BOARDING" | "FLIGHT_INSTANCE_STATUS_DEPARTED" | "FLIGHT_INSTANCE_STATUS_AIRBORNE" | "FLIGHT_INSTANCE_STATUS_LANDED" | "FLIGHT_INSTANCE_STATUS_ARRIVED" | "FLIGHT_INSTANCE_STATUS_CANCELLED" | "FLIGHT_INSTANCE_STATUS_DIVERTED" | "FLIGHT_INSTANCE_STATUS_UNKNOWN";
export type PositionSource = "POSITION_SOURCE_UNSPECIFIED" | "POSITION_SOURCE_OPENSKY" | "POSITION_SOURCE_WINGBITS" | "POSITION_SOURCE_SIMULATED";
export interface FieldViolation {
field: string;
@@ -338,152 +320,411 @@ export interface AviationServiceHandler {
listAviationNews(ctx: ServerContext, req: ListAviationNewsRequest): Promise<ListAviationNewsResponse>;
}
function makeHandler<Req, Res>(
methodName: string,
path: string,
parseReq: (params: URLSearchParams) => Req,
handlerFn: (ctx: ServerContext, req: Req) => Promise<Res>,
options?: ServerOptions,
): RouteDescriptor {
return {
method: "GET",
path,
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 = parseReq(params);
if (options?.validateRequest) {
const violations = options.validateRequest(methodName, body);
if (violations) throw new ValidationError(violations);
}
const ctx: ServerContext = {
request: req,
pathParams,
headers: Object.fromEntries(req.headers.entries()),
};
const result = await handlerFn(ctx, body);
return new Response(JSON.stringify(result), {
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" },
});
}
},
};
}
export function createAviationServiceRoutes(
handler: AviationServiceHandler,
options?: ServerOptions,
): RouteDescriptor[] {
return [
makeHandler(
"listAirportDelays",
"/api/aviation/v1/list-airport-delays",
(p) => ({
pageSize: Number(p.get("page_size") ?? "0"),
cursor: p.get("cursor") ?? "",
region: p.get("region") ?? "",
minSeverity: p.get("min_severity") ?? "",
}),
handler.listAirportDelays.bind(handler),
options,
),
makeHandler(
"getAirportOpsSummary",
"/api/aviation/v1/get-airport-ops-summary",
(p) => ({
airports: p.getAll("airports"),
}),
handler.getAirportOpsSummary.bind(handler),
options,
),
makeHandler(
"listAirportFlights",
"/api/aviation/v1/list-airport-flights",
(p) => ({
airport: p.get("airport") ?? "",
direction: p.get("direction") ?? "FLIGHT_DIRECTION_BOTH",
limit: Number(p.get("limit") ?? "30"),
}),
handler.listAirportFlights.bind(handler),
options,
),
makeHandler(
"getCarrierOps",
"/api/aviation/v1/get-carrier-ops",
(p) => ({
airports: p.getAll("airports"),
minFlights: Number(p.get("min_flights") ?? "3"),
}),
handler.getCarrierOps.bind(handler),
options,
),
makeHandler(
"getFlightStatus",
"/api/aviation/v1/get-flight-status",
(p) => ({
flightNumber: p.get("flight_number") ?? "",
date: p.get("date") ?? "",
origin: p.get("origin") ?? "",
}),
handler.getFlightStatus.bind(handler),
options,
),
makeHandler(
"trackAircraft",
"/api/aviation/v1/track-aircraft",
(p) => ({
icao24: p.get("icao24") ?? "",
callsign: p.get("callsign") ?? "",
swLat: Number(p.get("sw_lat") ?? "0"),
swLon: Number(p.get("sw_lon") ?? "0"),
neLat: Number(p.get("ne_lat") ?? "0"),
neLon: Number(p.get("ne_lon") ?? "0"),
}),
handler.trackAircraft.bind(handler),
options,
),
makeHandler(
"searchFlightPrices",
"/api/aviation/v1/search-flight-prices",
(p) => ({
origin: p.get("origin") ?? "",
destination: p.get("destination") ?? "",
departureDate: p.get("departure_date") ?? "",
returnDate: p.get("return_date") ?? "",
adults: Number(p.get("adults") ?? "1"),
cabin: p.get("cabin") ?? "CABIN_CLASS_ECONOMY",
nonstopOnly: p.get("nonstop_only") === "true",
maxResults: Number(p.get("max_results") ?? "10"),
}),
handler.searchFlightPrices.bind(handler),
options,
),
makeHandler(
"listAviationNews",
"/api/aviation/v1/list-aviation-news",
(p) => ({
entities: p.getAll("entities"),
windowHours: Number(p.get("window_hours") ?? "24"),
maxItems: Number(p.get("max_items") ?? "20"),
}),
handler.listAviationNews.bind(handler),
options,
),
{
method: "GET",
path: "/api/aviation/v1/list-airport-delays",
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: ListAirportDelaysRequest = {
pageSize: Number(params.get("page_size") ?? "0"),
cursor: params.get("cursor") ?? "",
region: params.get("region") ?? "",
minSeverity: params.get("min_severity") ?? "",
};
if (options?.validateRequest) {
const bodyViolations = options.validateRequest("listAirportDelays", body);
if (bodyViolations) {
throw new ValidationError(bodyViolations);
}
}
const ctx: ServerContext = {
request: req,
pathParams,
headers: Object.fromEntries(req.headers.entries()),
};
const result = await handler.listAirportDelays(ctx, body);
return new Response(JSON.stringify(result as ListAirportDelaysResponse), {
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" },
});
}
},
},
{
method: "GET",
path: "/api/aviation/v1/get-airport-ops-summary",
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: GetAirportOpsSummaryRequest = {
airports: params.get("airports") ?? "",
};
if (options?.validateRequest) {
const bodyViolations = options.validateRequest("getAirportOpsSummary", body);
if (bodyViolations) {
throw new ValidationError(bodyViolations);
}
}
const ctx: ServerContext = {
request: req,
pathParams,
headers: Object.fromEntries(req.headers.entries()),
};
const result = await handler.getAirportOpsSummary(ctx, body);
return new Response(JSON.stringify(result as GetAirportOpsSummaryResponse), {
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" },
});
}
},
},
{
method: "GET",
path: "/api/aviation/v1/list-airport-flights",
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: ListAirportFlightsRequest = {
airport: params.get("airport") ?? "",
direction: params.get("direction") ?? "",
limit: Number(params.get("limit") ?? "0"),
};
if (options?.validateRequest) {
const bodyViolations = options.validateRequest("listAirportFlights", body);
if (bodyViolations) {
throw new ValidationError(bodyViolations);
}
}
const ctx: ServerContext = {
request: req,
pathParams,
headers: Object.fromEntries(req.headers.entries()),
};
const result = await handler.listAirportFlights(ctx, body);
return new Response(JSON.stringify(result as ListAirportFlightsResponse), {
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" },
});
}
},
},
{
method: "GET",
path: "/api/aviation/v1/get-carrier-ops",
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: GetCarrierOpsRequest = {
airports: params.get("airports") ?? "",
minFlights: Number(params.get("min_flights") ?? "0"),
};
if (options?.validateRequest) {
const bodyViolations = options.validateRequest("getCarrierOps", body);
if (bodyViolations) {
throw new ValidationError(bodyViolations);
}
}
const ctx: ServerContext = {
request: req,
pathParams,
headers: Object.fromEntries(req.headers.entries()),
};
const result = await handler.getCarrierOps(ctx, body);
return new Response(JSON.stringify(result as GetCarrierOpsResponse), {
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" },
});
}
},
},
{
method: "GET",
path: "/api/aviation/v1/get-flight-status",
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: GetFlightStatusRequest = {
flightNumber: params.get("flight_number") ?? "",
date: params.get("date") ?? "",
origin: params.get("origin") ?? "",
};
if (options?.validateRequest) {
const bodyViolations = options.validateRequest("getFlightStatus", body);
if (bodyViolations) {
throw new ValidationError(bodyViolations);
}
}
const ctx: ServerContext = {
request: req,
pathParams,
headers: Object.fromEntries(req.headers.entries()),
};
const result = await handler.getFlightStatus(ctx, body);
return new Response(JSON.stringify(result as GetFlightStatusResponse), {
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" },
});
}
},
},
{
method: "GET",
path: "/api/aviation/v1/track-aircraft",
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: TrackAircraftRequest = {
icao24: params.get("icao24") ?? "",
callsign: params.get("callsign") ?? "",
swLat: Number(params.get("sw_lat") ?? "0"),
swLon: Number(params.get("sw_lon") ?? "0"),
neLat: Number(params.get("ne_lat") ?? "0"),
neLon: Number(params.get("ne_lon") ?? "0"),
};
if (options?.validateRequest) {
const bodyViolations = options.validateRequest("trackAircraft", body);
if (bodyViolations) {
throw new ValidationError(bodyViolations);
}
}
const ctx: ServerContext = {
request: req,
pathParams,
headers: Object.fromEntries(req.headers.entries()),
};
const result = await handler.trackAircraft(ctx, body);
return new Response(JSON.stringify(result as TrackAircraftResponse), {
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" },
});
}
},
},
{
method: "GET",
path: "/api/aviation/v1/search-flight-prices",
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: SearchFlightPricesRequest = {
origin: params.get("origin") ?? "",
destination: params.get("destination") ?? "",
departureDate: params.get("departure_date") ?? "",
returnDate: params.get("return_date") ?? "",
adults: Number(params.get("adults") ?? "0"),
cabin: params.get("cabin") ?? "",
nonstopOnly: params.get("nonstop_only") === "true",
maxResults: Number(params.get("max_results") ?? "0"),
currency: params.get("currency") ?? "",
market: params.get("market") ?? "",
};
if (options?.validateRequest) {
const bodyViolations = options.validateRequest("searchFlightPrices", body);
if (bodyViolations) {
throw new ValidationError(bodyViolations);
}
}
const ctx: ServerContext = {
request: req,
pathParams,
headers: Object.fromEntries(req.headers.entries()),
};
const result = await handler.searchFlightPrices(ctx, body);
return new Response(JSON.stringify(result as SearchFlightPricesResponse), {
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" },
});
}
},
},
{
method: "GET",
path: "/api/aviation/v1/list-aviation-news",
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: ListAviationNewsRequest = {
entities: params.get("entities") ?? "",
windowHours: Number(params.get("window_hours") ?? "0"),
maxItems: Number(params.get("max_items") ?? "0"),
};
if (options?.validateRequest) {
const bodyViolations = options.validateRequest("listAviationNews", body);
if (bodyViolations) {
throw new ValidationError(bodyViolations);
}
}
const ctx: ServerContext = {
request: req,
pathParams,
headers: Object.fromEntries(req.headers.entries()),
};
const result = await handler.listAviationNews(ctx, body);
return new Response(JSON.stringify(result as ListAviationNewsResponse), {
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" },
});
}
},
},
];
}

View File

@@ -84,16 +84,6 @@ export interface HumanitarianCountrySummary {
updatedAt: number;
}
export interface GetHumanitarianSummaryBatchRequest {
countryCodes: string[];
}
export interface GetHumanitarianSummaryBatchResponse {
results: Record<string, HumanitarianCountrySummary>;
fetched: number;
requested: number;
}
export interface ListIranEventsRequest {
}
@@ -114,6 +104,16 @@ export interface IranEvent {
severity: string;
}
export interface GetHumanitarianSummaryBatchRequest {
countryCodes: string[];
}
export interface GetHumanitarianSummaryBatchResponse {
results: Record<string, HumanitarianCountrySummary>;
fetched: number;
requested: number;
}
export type UcdpViolenceType = "UCDP_VIOLENCE_TYPE_UNSPECIFIED" | "UCDP_VIOLENCE_TYPE_STATE_BASED" | "UCDP_VIOLENCE_TYPE_NON_STATE" | "UCDP_VIOLENCE_TYPE_ONE_SIDED";
export interface FieldViolation {
@@ -164,8 +164,8 @@ export interface ConflictServiceHandler {
listAcledEvents(ctx: ServerContext, req: ListAcledEventsRequest): Promise<ListAcledEventsResponse>;
listUcdpEvents(ctx: ServerContext, req: ListUcdpEventsRequest): Promise<ListUcdpEventsResponse>;
getHumanitarianSummary(ctx: ServerContext, req: GetHumanitarianSummaryRequest): Promise<GetHumanitarianSummaryResponse>;
getHumanitarianSummaryBatch(ctx: ServerContext, req: GetHumanitarianSummaryBatchRequest): Promise<GetHumanitarianSummaryBatchResponse>;
listIranEvents(ctx: ServerContext, req: ListIranEventsRequest): Promise<ListIranEventsResponse>;
getHumanitarianSummaryBatch(ctx: ServerContext, req: GetHumanitarianSummaryBatchRequest): Promise<GetHumanitarianSummaryBatchResponse>;
}
export function createConflictServiceRoutes(
@@ -322,52 +322,6 @@ export function createConflictServiceRoutes(
}
},
},
{
method: "POST",
path: "/api/conflict/v1/get-humanitarian-summary-batch",
handler: async (req: Request): Promise<Response> => {
try {
const pathParams: Record<string, string> = {};
const rawBody = await req.json() as { country_codes?: string[] };
const body: GetHumanitarianSummaryBatchRequest = {
countryCodes: rawBody.country_codes ?? [],
};
if (options?.validateRequest) {
const bodyViolations = options.validateRequest("getHumanitarianSummaryBatch", body);
if (bodyViolations) {
throw new ValidationError(bodyViolations);
}
}
const ctx: ServerContext = {
request: req,
pathParams,
headers: Object.fromEntries(req.headers.entries()),
};
const result = await handler.getHumanitarianSummaryBatch(ctx, body);
return new Response(JSON.stringify(result as GetHumanitarianSummaryBatchResponse), {
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" },
});
}
},
},
{
method: "GET",
path: "/api/conflict/v1/list-iran-events",
@@ -405,6 +359,49 @@ export function createConflictServiceRoutes(
}
},
},
{
method: "POST",
path: "/api/conflict/v1/get-humanitarian-summary-batch",
handler: async (req: Request): Promise<Response> => {
try {
const pathParams: Record<string, string> = {};
const body = await req.json() as GetHumanitarianSummaryBatchRequest;
if (options?.validateRequest) {
const bodyViolations = options.validateRequest("getHumanitarianSummaryBatch", body);
if (bodyViolations) {
throw new ValidationError(bodyViolations);
}
}
const ctx: ServerContext = {
request: req,
pathParams,
headers: Object.fromEntries(req.headers.entries()),
};
const result = await handler.getHumanitarianSummaryBatch(ctx, body);
return new Response(JSON.stringify(result as GetHumanitarianSummaryBatchResponse), {
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" },
});
}
},
},
];
}

View File

@@ -24,17 +24,6 @@ export interface FredObservation {
value: number;
}
export interface GetFredSeriesBatchRequest {
seriesIds: string[];
limit: number;
}
export interface GetFredSeriesBatchResponse {
results: Record<string, FredSeries>;
fetched: number;
requested: number;
}
export interface ListWorldBankIndicatorsRequest {
indicatorCode: string;
countryCode: string;
@@ -221,6 +210,17 @@ export interface BisCreditToGdp {
date: string;
}
export interface GetFredSeriesBatchRequest {
seriesIds: string[];
limit: number;
}
export interface GetFredSeriesBatchResponse {
results: Record<string, FredSeries>;
fetched: number;
requested: number;
}
export interface FieldViolation {
field: string;
description: string;
@@ -267,7 +267,6 @@ export interface RouteDescriptor {
export interface EconomicServiceHandler {
getFredSeries(ctx: ServerContext, req: GetFredSeriesRequest): Promise<GetFredSeriesResponse>;
getFredSeriesBatch(ctx: ServerContext, req: GetFredSeriesBatchRequest): Promise<GetFredSeriesBatchResponse>;
listWorldBankIndicators(ctx: ServerContext, req: ListWorldBankIndicatorsRequest): Promise<ListWorldBankIndicatorsResponse>;
getEnergyPrices(ctx: ServerContext, req: GetEnergyPricesRequest): Promise<GetEnergyPricesResponse>;
getMacroSignals(ctx: ServerContext, req: GetMacroSignalsRequest): Promise<GetMacroSignalsResponse>;
@@ -275,6 +274,7 @@ export interface EconomicServiceHandler {
getBisPolicyRates(ctx: ServerContext, req: GetBisPolicyRatesRequest): Promise<GetBisPolicyRatesResponse>;
getBisExchangeRates(ctx: ServerContext, req: GetBisExchangeRatesRequest): Promise<GetBisExchangeRatesResponse>;
getBisCredit(ctx: ServerContext, req: GetBisCreditRequest): Promise<GetBisCreditResponse>;
getFredSeriesBatch(ctx: ServerContext, req: GetFredSeriesBatchRequest): Promise<GetFredSeriesBatchResponse>;
}
export function createEconomicServiceRoutes(
@@ -330,53 +330,6 @@ export function createEconomicServiceRoutes(
}
},
},
{
method: "POST",
path: "/api/economic/v1/get-fred-series-batch",
handler: async (req: Request): Promise<Response> => {
try {
const pathParams: Record<string, string> = {};
const rawBody = await req.json() as { series_ids?: string[]; limit?: number };
const body: GetFredSeriesBatchRequest = {
seriesIds: rawBody.series_ids ?? [],
limit: rawBody.limit ?? 0,
};
if (options?.validateRequest) {
const bodyViolations = options.validateRequest("getFredSeriesBatch", body);
if (bodyViolations) {
throw new ValidationError(bodyViolations);
}
}
const ctx: ServerContext = {
request: req,
pathParams,
headers: Object.fromEntries(req.headers.entries()),
};
const result = await handler.getFredSeriesBatch(ctx, body);
return new Response(JSON.stringify(result as GetFredSeriesBatchResponse), {
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" },
});
}
},
},
{
method: "GET",
path: "/api/economic/v1/list-world-bank-indicators",
@@ -671,6 +624,49 @@ export function createEconomicServiceRoutes(
}
},
},
{
method: "POST",
path: "/api/economic/v1/get-fred-series-batch",
handler: async (req: Request): Promise<Response> => {
try {
const pathParams: Record<string, string> = {};
const body = await req.json() as GetFredSeriesBatchRequest;
if (options?.validateRequest) {
const bodyViolations = options.validateRequest("getFredSeriesBatch", body);
if (bodyViolations) {
throw new ValidationError(bodyViolations);
}
}
const ctx: ServerContext = {
request: req,
pathParams,
headers: Object.fromEntries(req.headers.entries()),
};
const result = await handler.getFredSeriesBatch(ctx, body);
return new Response(JSON.stringify(result as GetFredSeriesBatchResponse), {
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" },
});
}
},
},
];
}

View File

@@ -0,0 +1,169 @@
// @ts-nocheck
// Code generated by protoc-gen-ts-server. DO NOT EDIT.
// source: worldmonitor/forecast/v1/service.proto
export interface GetForecastsRequest {
domain: string;
region: string;
}
export interface GetForecastsResponse {
forecasts: Forecast[];
generatedAt: number;
}
export interface Forecast {
id: string;
domain: string;
region: string;
title: string;
scenario: string;
probability: number;
confidence: number;
timeHorizon: string;
signals: ForecastSignal[];
cascades: CascadeEffect[];
trend: string;
priorProbability: number;
calibration?: CalibrationInfo;
createdAt: number;
updatedAt: number;
perspectives?: Perspectives;
projections?: Projections;
}
export interface ForecastSignal {
type: string;
value: string;
weight: number;
}
export interface CascadeEffect {
domain: string;
effect: string;
probability: number;
}
export interface CalibrationInfo {
marketTitle: string;
marketPrice: number;
drift: number;
source: string;
}
export interface Perspectives {
strategic: string;
regional: string;
contrarian: string;
}
export interface Projections {
h24: number;
d7: number;
d30: number;
}
export interface FieldViolation {
field: string;
description: string;
}
export class ValidationError extends Error {
violations: FieldViolation[];
constructor(violations: FieldViolation[]) {
super("Validation failed");
this.name = "ValidationError";
this.violations = violations;
}
}
export class ApiError extends Error {
statusCode: number;
body: string;
constructor(statusCode: number, message: string, body: string) {
super(message);
this.name = "ApiError";
this.statusCode = statusCode;
this.body = body;
}
}
export interface ServerContext {
request: Request;
pathParams: Record<string, string>;
headers: Record<string, string>;
}
export interface ServerOptions {
onError?: (error: unknown, req: Request) => Response | Promise<Response>;
validateRequest?: (methodName: string, body: unknown) => FieldViolation[] | undefined;
}
export interface RouteDescriptor {
method: string;
path: string;
handler: (req: Request) => Promise<Response>;
}
export interface ForecastServiceHandler {
getForecasts(ctx: ServerContext, req: GetForecastsRequest): Promise<GetForecastsResponse>;
}
export function createForecastServiceRoutes(
handler: ForecastServiceHandler,
options?: ServerOptions,
): RouteDescriptor[] {
return [
{
method: "GET",
path: "/api/forecast/v1/get-forecasts",
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: GetForecastsRequest = {
domain: params.get("domain") ?? "",
region: params.get("region") ?? "",
};
if (options?.validateRequest) {
const bodyViolations = options.validateRequest("getForecasts", body);
if (bodyViolations) {
throw new ValidationError(bodyViolations);
}
}
const ctx: ServerContext = {
request: req,
pathParams,
headers: Object.fromEntries(req.headers.entries()),
};
const result = await handler.getForecasts(ctx, body);
return new Response(JSON.stringify(result as GetForecastsResponse), {
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" },
});
}
},
},
];
}

View File

@@ -9,6 +9,12 @@ export interface SearchImageryRequest {
limit: number;
}
export interface SearchImageryResponse {
scenes: ImageryScene[];
totalResults: number;
cacheHit: boolean;
}
export interface ImageryScene {
id: string;
satellite: string;
@@ -20,14 +26,6 @@ export interface ImageryScene {
assetUrl: string;
}
export interface SearchImageryResponse {
scenes: ImageryScene[];
totalResults: number;
cacheHit: boolean;
}
// ---- Framework types ----
export interface FieldViolation {
field: string;
description: string;
@@ -76,70 +74,61 @@ export interface ImageryServiceHandler {
searchImagery(ctx: ServerContext, req: SearchImageryRequest): Promise<SearchImageryResponse>;
}
function makeHandler<Req, Res>(
methodName: string,
path: string,
parseReq: (params: URLSearchParams) => Req,
handlerFn: (ctx: ServerContext, req: Req) => Promise<Res>,
options?: ServerOptions,
): RouteDescriptor {
return {
method: "GET",
path,
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 = parseReq(params);
if (options?.validateRequest) {
const violations = options.validateRequest(methodName, body);
if (violations) throw new ValidationError(violations);
}
const ctx: ServerContext = {
request: req,
pathParams,
headers: Object.fromEntries(req.headers.entries()),
};
const result = await handlerFn(ctx, body);
return new Response(JSON.stringify(result), {
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" },
});
}
},
};
}
export function createImageryServiceRoutes(
handler: ImageryServiceHandler,
options?: ServerOptions,
): RouteDescriptor[] {
return [
makeHandler(
"searchImagery",
"/api/imagery/v1/search-imagery",
(p) => ({
bbox: p.get("bbox") ?? "",
datetime: p.get("datetime") ?? "",
source: p.get("source") ?? "",
limit: Number(p.get("limit") ?? "10"),
}),
handler.searchImagery.bind(handler),
options,
),
{
method: "GET",
path: "/api/imagery/v1/search-imagery",
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: SearchImageryRequest = {
bbox: params.get("bbox") ?? "",
datetime: params.get("datetime") ?? "",
source: params.get("source") ?? "",
limit: Number(params.get("limit") ?? "0"),
};
if (options?.validateRequest) {
const bodyViolations = options.validateRequest("searchImagery", body);
if (bodyViolations) {
throw new ValidationError(bodyViolations);
}
}
const ctx: ServerContext = {
request: req,
pathParams,
headers: Object.fromEntries(req.headers.entries()),
};
const result = await handler.searchImagery(ctx, body);
return new Response(JSON.stringify(result as SearchImageryResponse), {
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" },
});
}
},
},
];
}

View File

@@ -101,26 +101,6 @@ export interface RecordBaselineSnapshotResponse {
error: string;
}
export interface ListTemporalAnomaliesRequest {
}
export interface ListTemporalAnomaliesResponse {
anomalies: TemporalAnomalyProto[];
trackedTypes: string[];
computedAt: string;
}
export interface TemporalAnomalyProto {
type: string;
region: string;
currentCount: number;
expectedCount: number;
zScore: number;
severity: string;
multiplier: number;
message: string;
}
export interface GetCableHealthRequest {
}
@@ -143,6 +123,26 @@ export interface CableHealthEvidence {
ts: number;
}
export interface ListTemporalAnomaliesRequest {
}
export interface ListTemporalAnomaliesResponse {
anomalies: TemporalAnomaly[];
trackedTypes: string[];
computedAt: string;
}
export interface TemporalAnomaly {
type: string;
region: string;
currentCount: number;
expectedCount: number;
zScore: number;
severity: string;
multiplier: number;
message: string;
}
export type CableHealthStatus = "CABLE_HEALTH_STATUS_UNSPECIFIED" | "CABLE_HEALTH_STATUS_OK" | "CABLE_HEALTH_STATUS_DEGRADED" | "CABLE_HEALTH_STATUS_FAULT";
export type OutageSeverity = "OUTAGE_SEVERITY_UNSPECIFIED" | "OUTAGE_SEVERITY_PARTIAL" | "OUTAGE_SEVERITY_MAJOR" | "OUTAGE_SEVERITY_TOTAL";

View File

@@ -910,3 +910,4 @@ export function createMarketServiceRoutes(
},
];
}

View File

@@ -1,3 +1,4 @@
// @ts-nocheck
// Code generated by protoc-gen-ts-server. DO NOT EDIT.
// source: worldmonitor/natural/v1/service.proto

View File

@@ -14,12 +14,6 @@ export interface ListPredictionMarketsResponse {
pagination?: PaginationResponse;
}
export enum MarketSource {
MARKET_SOURCE_UNSPECIFIED = "MARKET_SOURCE_UNSPECIFIED",
MARKET_SOURCE_POLYMARKET = "MARKET_SOURCE_POLYMARKET",
MARKET_SOURCE_KALSHI = "MARKET_SOURCE_KALSHI",
}
export interface PredictionMarket {
id: string;
title: string;
@@ -36,6 +30,8 @@ export interface PaginationResponse {
totalCount: number;
}
export type MarketSource = "MARKET_SOURCE_UNSPECIFIED" | "MARKET_SOURCE_POLYMARKET" | "MARKET_SOURCE_KALSHI";
export interface FieldViolation {
field: string;
description: string;

View File

@@ -50,7 +50,7 @@ export interface ChokepointInfo {
aisDisruptions: number;
directions: string[];
directionalDwt: DirectionalDwt[];
transitSummary: TransitSummary;
transitSummary?: TransitSummary;
}
export interface DirectionalDwt {
@@ -59,14 +59,6 @@ export interface DirectionalDwt {
wowChangePct: number;
}
export interface TransitDayCount {
date: string;
tanker: number;
cargo: number;
other: number;
total: number;
}
export interface TransitSummary {
todayTotal: number;
todayTanker: number;
@@ -79,6 +71,14 @@ export interface TransitSummary {
disruptionPct: number;
}
export interface TransitDayCount {
date: string;
tanker: number;
cargo: number;
other: number;
total: number;
}
export interface GetCriticalMineralsRequest {
}

View File

@@ -10,6 +10,12 @@ export interface ListWebcamsRequest {
boundN: number;
}
export interface ListWebcamsResponse {
webcams: WebcamEntry[];
clusters: WebcamCluster[];
totalInView: number;
}
export interface WebcamEntry {
webcamId: string;
title: string;
@@ -26,12 +32,6 @@ export interface WebcamCluster {
categories: string[];
}
export interface ListWebcamsResponse {
webcams: WebcamEntry[];
clusters: WebcamCluster[];
totalInView: number;
}
export interface GetWebcamImageRequest {
webcamId: string;
}
@@ -41,7 +41,7 @@ export interface GetWebcamImageResponse {
playerUrl: string;
title: string;
windyUrl: string;
lastUpdated: number;
lastUpdated: string;
error: string;
}
@@ -108,11 +108,11 @@ export function createWebcamServiceRoutes(
const url = new URL(req.url, "http://localhost");
const params = url.searchParams;
const body: ListWebcamsRequest = {
zoom: Number(params.get("zoom") ?? "3"),
boundW: Number(params.get("bound_w") ?? "-180"),
boundS: Number(params.get("bound_s") ?? "-90"),
boundE: Number(params.get("bound_e") ?? "180"),
boundN: Number(params.get("bound_n") ?? "90"),
zoom: Number(params.get("zoom") ?? "0"),
boundW: Number(params.get("bound_w") ?? "0"),
boundS: Number(params.get("bound_s") ?? "0"),
boundE: Number(params.get("bound_e") ?? "0"),
boundN: Number(params.get("bound_n") ?? "0"),
};
if (options?.validateRequest) {
const bodyViolations = options.validateRequest("listWebcams", body);
@@ -157,8 +157,9 @@ export function createWebcamServiceRoutes(
try {
const pathParams: Record<string, string> = {};
const url = new URL(req.url, "http://localhost");
const params = url.searchParams;
const body: GetWebcamImageRequest = {
webcamId: url.searchParams.get("webcam_id") ?? "",
webcamId: params.get("webcam_id") ?? "",
};
if (options?.validateRequest) {
const bodyViolations = options.validateRequest("getWebcamImage", body);
@@ -198,3 +199,4 @@ export function createWebcamServiceRoutes(
},
];
}

23
src/services/forecast.ts Normal file
View File

@@ -0,0 +1,23 @@
import { ForecastServiceClient } from '@/generated/client/worldmonitor/forecast/v1/service_client';
import type { Forecast } from '@/generated/client/worldmonitor/forecast/v1/service_client';
import { getRpcBaseUrl } from '@/services/rpc-client';
export type { Forecast };
export { escapeHtml } from '@/utils/sanitize';
let _client: ForecastServiceClient | null = null;
function getClient(): ForecastServiceClient {
if (!_client) {
_client = new ForecastServiceClient(getRpcBaseUrl(), {
fetch: (...args: Parameters<typeof fetch>) => globalThis.fetch(...args),
});
}
return _client;
}
export async function fetchForecasts(domain?: string, region?: string): Promise<Forecast[]> {
const resp = await getClient().getForecasts({ domain: domain || '', region: region || '' });
return resp.forecasts || [];
}

View File

@@ -1,4 +1,4 @@
import { PredictionServiceClient, MarketSource } from '@/generated/client/worldmonitor/prediction/v1/service_client';
import { PredictionServiceClient } from '@/generated/client/worldmonitor/prediction/v1/service_client';
import { getRpcBaseUrl } from '@/services/rpc-client';
import { createCircuitBreaker } from '@/utils';
import { SITE_VARIANT } from '@/config';
@@ -60,7 +60,7 @@ function protoToMarket(m: { title: string; yesPrice: number; volume: number; url
volume: m.volume,
url: m.url || undefined,
endDate: m.closesAt ? new Date(m.closesAt).toISOString() : undefined,
source: m.source === MarketSource.MARKET_SOURCE_KALSHI ? 'kalshi' : 'polymarket',
source: m.source === 'MARKET_SOURCE_KALSHI' ? 'kalshi' : 'polymarket',
regions: tagRegions(m.title),
};
}

View File

@@ -1,4 +1,4 @@
import { InfrastructureServiceClient, type TemporalAnomalyProto } from '@/generated/client/worldmonitor/infrastructure/v1/service_client';
import { InfrastructureServiceClient, type TemporalAnomaly as TemporalAnomalyProto } from '@/generated/client/worldmonitor/infrastructure/v1/service_client';
import { getRpcBaseUrl } from '@/services/rpc-client';
import { getHydratedData } from '@/services/bootstrap';

View File

@@ -56,7 +56,7 @@ export async function fetchWebcamImage(webcamId: string): Promise<GetWebcamImage
return {
thumbnailUrl: '', playerUrl: '', title: '',
windyUrl: `https://www.windy.com/webcams/${webcamId}`,
lastUpdated: 0, error: 'unavailable',
lastUpdated: '', error: 'unavailable',
};
}
}

View File

@@ -0,0 +1,989 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import {
forecastId,
normalize,
makePrediction,
resolveCascades,
calibrateWithMarkets,
computeTrends,
detectConflictScenarios,
detectMarketScenarios,
detectSupplyChainScenarios,
detectPoliticalScenarios,
detectMilitaryScenarios,
detectInfraScenarios,
detectUcdpConflictZones,
detectCyberScenarios,
detectGpsJammingScenarios,
detectFromPredictionMarkets,
normalizeChokepoints,
normalizeGpsJamming,
loadEntityGraph,
discoverGraphCascades,
attachNewsContext,
computeConfidence,
sanitizeForPrompt,
parseLLMScenarios,
validateScenarios,
validatePerspectives,
computeProjections,
loadCascadeRules,
evaluateRuleConditions,
SIGNAL_TO_SOURCE,
PREDICATE_EVALUATORS,
DEFAULT_CASCADE_RULES,
PROJECTION_CURVES,
} from '../scripts/seed-forecasts.mjs';
describe('forecastId', () => {
it('same inputs produce same ID', () => {
const a = forecastId('conflict', 'Iran', 'Escalation risk');
const b = forecastId('conflict', 'Iran', 'Escalation risk');
assert.equal(a, b);
});
it('different inputs produce different IDs', () => {
const a = forecastId('conflict', 'Iran', 'Escalation risk');
const b = forecastId('market', 'Iran', 'Oil price shock');
assert.notEqual(a, b);
});
it('ID format is fc-{domain}-{8char_hex}', () => {
const id = forecastId('conflict', 'Middle East', 'Theater escalation');
assert.match(id, /^fc-conflict-[0-9a-f]{8}$/);
});
it('domain is embedded in the ID', () => {
const id = forecastId('market', 'Red Sea', 'Oil disruption');
assert.ok(id.startsWith('fc-market-'));
});
});
describe('normalize', () => {
it('value at min returns 0', () => {
assert.equal(normalize(50, 50, 100), 0);
});
it('value at max returns 1', () => {
assert.equal(normalize(100, 50, 100), 1);
});
it('midpoint returns 0.5', () => {
assert.equal(normalize(75, 50, 100), 0.5);
});
it('value below min clamps to 0', () => {
assert.equal(normalize(10, 50, 100), 0);
});
it('value above max clamps to 1', () => {
assert.equal(normalize(200, 50, 100), 1);
});
it('min === max returns 0', () => {
assert.equal(normalize(50, 50, 50), 0);
});
it('min > max returns 0', () => {
assert.equal(normalize(50, 100, 50), 0);
});
});
describe('resolveCascades', () => {
it('conflict near chokepoint creates supply_chain and market cascades', () => {
const pred = makePrediction(
'conflict', 'Middle East', 'Escalation risk: Iran',
0.7, 0.6, '7d', [{ type: 'cii', value: 'Iran CII 85', weight: 0.4 }],
);
const predictions = [pred];
resolveCascades(predictions, DEFAULT_CASCADE_RULES);
const domains = pred.cascades.map(c => c.domain);
assert.ok(domains.includes('supply_chain'), 'should have supply_chain cascade');
assert.ok(domains.includes('market'), 'should have market cascade');
});
it('cascade probabilities capped at 0.8', () => {
const pred = makePrediction(
'conflict', 'Middle East', 'Escalation risk: Iran',
0.99, 0.9, '7d', [{ type: 'cii', value: 'high', weight: 0.4 }],
);
resolveCascades([pred], DEFAULT_CASCADE_RULES);
for (const c of pred.cascades) {
assert.ok(c.probability <= 0.8, `cascade probability ${c.probability} should be <= 0.8`);
}
});
it('deduplication within a single call: same rule does not fire twice for same source', () => {
const pred = makePrediction(
'conflict', 'Middle East', 'Escalation risk: Iran',
0.7, 0.6, '7d', [{ type: 'cii', value: 'test', weight: 0.4 }],
);
resolveCascades([pred], DEFAULT_CASCADE_RULES);
const keys = pred.cascades.map(c => `${c.domain}:${c.effect}`);
const unique = new Set(keys);
assert.equal(keys.length, unique.size, 'no duplicate cascade entries within one resolution');
});
it('no self-edges: cascade domain differs from source domain', () => {
const pred = makePrediction(
'conflict', 'Middle East', 'Escalation',
0.7, 0.6, '7d', [{ type: 'cii', value: 'test', weight: 0.4 }],
);
resolveCascades([pred], DEFAULT_CASCADE_RULES);
for (const c of pred.cascades) {
assert.notEqual(c.domain, pred.domain, `cascade domain ${c.domain} should differ from source ${pred.domain}`);
}
});
it('political > 0.6 creates conflict cascade', () => {
const pred = makePrediction(
'political', 'Iran', 'Political instability',
0.65, 0.5, '30d', [{ type: 'unrest', value: 'unrest', weight: 0.4 }],
);
resolveCascades([pred], DEFAULT_CASCADE_RULES);
const domains = pred.cascades.map(c => c.domain);
assert.ok(domains.includes('conflict'), 'political instability should cascade to conflict');
});
it('political <= 0.6 does not cascade to conflict', () => {
const pred = makePrediction(
'political', 'Iran', 'Political instability',
0.5, 0.5, '30d', [{ type: 'unrest', value: 'unrest', weight: 0.4 }],
);
resolveCascades([pred], DEFAULT_CASCADE_RULES);
assert.equal(pred.cascades.length, 0);
});
});
describe('calibrateWithMarkets', () => {
it('matching market adjusts probability with 40/60 blend', () => {
const pred = makePrediction(
'conflict', 'Middle East', 'Escalation',
0.7, 0.6, '7d', [],
);
pred.region = 'Middle East';
const markets = {
geopolitical: [{ title: 'Will Iran conflict escalate in MENA?', yesPrice: 30, source: 'polymarket' }],
};
calibrateWithMarkets([pred], markets);
const expected = +(0.4 * 0.3 + 0.6 * 0.7).toFixed(3);
assert.equal(pred.probability, expected);
assert.ok(pred.calibration !== null);
assert.equal(pred.calibration.source, 'polymarket');
});
it('no match leaves probability unchanged', () => {
const pred = makePrediction(
'conflict', 'Korean Peninsula', 'Korea escalation',
0.6, 0.5, '7d', [],
);
const originalProb = pred.probability;
const markets = {
geopolitical: [{ title: 'Will EU inflation drop?', yesPrice: 50 }],
};
calibrateWithMarkets([pred], markets);
assert.equal(pred.probability, originalProb);
assert.equal(pred.calibration, null);
});
it('drift calculated correctly', () => {
const pred = makePrediction(
'conflict', 'Middle East', 'Escalation',
0.7, 0.6, '7d', [],
);
const markets = {
geopolitical: [{ title: 'Iran MENA conflict?', yesPrice: 40 }],
};
calibrateWithMarkets([pred], markets);
assert.equal(pred.calibration.drift, +(0.7 - 0.4).toFixed(3));
});
it('null markets handled gracefully', () => {
const pred = makePrediction('conflict', 'Middle East', 'Test', 0.5, 0.5, '7d', []);
calibrateWithMarkets([pred], null);
assert.equal(pred.calibration, null);
});
it('empty markets handled gracefully', () => {
const pred = makePrediction('conflict', 'Middle East', 'Test', 0.5, 0.5, '7d', []);
calibrateWithMarkets([pred], {});
assert.equal(pred.calibration, null);
});
it('markets without geopolitical key handled gracefully', () => {
const pred = makePrediction('conflict', 'Middle East', 'Test', 0.5, 0.5, '7d', []);
calibrateWithMarkets([pred], { crypto: [] });
assert.equal(pred.calibration, null);
});
});
describe('computeTrends', () => {
it('no prior: all trends set to stable', () => {
const pred = makePrediction('conflict', 'Iran', 'Test', 0.6, 0.5, '7d', []);
computeTrends([pred], null);
assert.equal(pred.trend, 'stable');
assert.equal(pred.priorProbability, pred.probability);
});
it('rising: delta > 0.05', () => {
const pred = makePrediction('conflict', 'Iran', 'Test', 0.7, 0.5, '7d', []);
const prior = { predictions: [{ id: pred.id, probability: 0.5 }] };
computeTrends([pred], prior);
assert.equal(pred.trend, 'rising');
assert.equal(pred.priorProbability, 0.5);
});
it('falling: delta < -0.05', () => {
const pred = makePrediction('conflict', 'Iran', 'Test', 0.3, 0.5, '7d', []);
const prior = { predictions: [{ id: pred.id, probability: 0.5 }] };
computeTrends([pred], prior);
assert.equal(pred.trend, 'falling');
});
it('stable: delta within +/- 0.05', () => {
const pred = makePrediction('conflict', 'Iran', 'Test', 0.52, 0.5, '7d', []);
const prior = { predictions: [{ id: pred.id, probability: 0.5 }] };
computeTrends([pred], prior);
assert.equal(pred.trend, 'stable');
});
it('new prediction (no prior match): stable', () => {
const pred = makePrediction('conflict', 'Iran', 'Brand new', 0.6, 0.5, '7d', []);
const prior = { predictions: [{ id: 'fc-conflict-00000000', probability: 0.5 }] };
computeTrends([pred], prior);
assert.equal(pred.trend, 'stable');
assert.equal(pred.priorProbability, pred.probability);
});
it('prior with empty predictions array: all stable', () => {
const pred = makePrediction('conflict', 'Iran', 'Test', 0.6, 0.5, '7d', []);
computeTrends([pred], { predictions: [] });
assert.equal(pred.trend, 'stable');
});
it('just above +0.05 threshold: rising', () => {
const pred = makePrediction('conflict', 'Iran', 'Test', 0.56, 0.5, '7d', []);
const prior = { predictions: [{ id: pred.id, probability: 0.5 }] };
computeTrends([pred], prior);
assert.equal(pred.trend, 'rising');
});
it('just below -0.05 threshold: falling', () => {
const pred = makePrediction('conflict', 'Iran', 'Test', 0.44, 0.5, '7d', []);
const prior = { predictions: [{ id: pred.id, probability: 0.5 }] };
computeTrends([pred], prior);
assert.equal(pred.trend, 'falling');
});
it('delta exactly at boundary: uses strict comparison (> 0.05)', () => {
const pred = makePrediction('conflict', 'Iran', 'Test', 0.549, 0.5, '7d', []);
const prior = { predictions: [{ id: pred.id, probability: 0.5 }] };
computeTrends([pred], prior);
assert.equal(pred.trend, 'stable');
});
});
describe('detector smoke tests: null/empty inputs', () => {
it('detectConflictScenarios({}) returns []', () => {
assert.deepEqual(detectConflictScenarios({}), []);
});
it('detectMarketScenarios({}) returns []', () => {
assert.deepEqual(detectMarketScenarios({}), []);
});
it('detectSupplyChainScenarios({}) returns []', () => {
assert.deepEqual(detectSupplyChainScenarios({}), []);
});
it('detectPoliticalScenarios({}) returns []', () => {
assert.deepEqual(detectPoliticalScenarios({}), []);
});
it('detectMilitaryScenarios({}) returns []', () => {
assert.deepEqual(detectMilitaryScenarios({}), []);
});
it('detectInfraScenarios({}) returns []', () => {
assert.deepEqual(detectInfraScenarios({}), []);
});
it('detectors handle null arrays gracefully', () => {
const inputs = {
ciiScores: null,
temporalAnomalies: null,
theaterPosture: null,
chokepoints: null,
iranEvents: null,
ucdpEvents: null,
unrestEvents: null,
outages: null,
cyberThreats: null,
gpsJamming: null,
};
assert.deepEqual(detectConflictScenarios(inputs), []);
assert.deepEqual(detectMarketScenarios(inputs), []);
assert.deepEqual(detectSupplyChainScenarios(inputs), []);
assert.deepEqual(detectPoliticalScenarios(inputs), []);
assert.deepEqual(detectMilitaryScenarios(inputs), []);
assert.deepEqual(detectInfraScenarios(inputs), []);
});
});
describe('detectConflictScenarios', () => {
it('high CII rising score produces conflict prediction', () => {
const inputs = {
ciiScores: [{ code: 'IRN', name: 'Iran', score: 85, level: 'high', trend: 'rising' }],
theaterPosture: { theaters: [] },
iranEvents: [],
ucdpEvents: [],
};
const result = detectConflictScenarios(inputs);
assert.ok(result.length >= 1);
assert.equal(result[0].domain, 'conflict');
assert.ok(result[0].probability > 0);
assert.ok(result[0].probability <= 0.9);
});
it('low CII score is ignored', () => {
const inputs = {
ciiScores: [{ code: 'CHE', name: 'Switzerland', score: 30, level: 'low', trend: 'stable' }],
theaterPosture: { theaters: [] },
iranEvents: [],
ucdpEvents: [],
};
assert.deepEqual(detectConflictScenarios(inputs), []);
});
it('critical theater posture produces prediction', () => {
const inputs = {
ciiScores: [],
theaterPosture: { theaters: [{ id: 'iran-theater', name: 'Iran Theater', postureLevel: 'critical' }] },
iranEvents: [],
ucdpEvents: [],
};
const result = detectConflictScenarios(inputs);
assert.ok(result.length >= 1);
assert.equal(result[0].region, 'Middle East');
});
});
describe('detectMarketScenarios', () => {
it('high-risk chokepoint with known commodity produces market prediction', () => {
const inputs = {
chokepoints: { routes: [{ region: 'Middle East', riskLevel: 'critical', riskScore: 85 }] },
ciiScores: [],
};
const result = detectMarketScenarios(inputs);
assert.ok(result.length >= 1);
assert.equal(result[0].domain, 'market');
assert.ok(result[0].title.includes('Oil'));
});
it('low-risk chokepoint is ignored', () => {
const inputs = {
chokepoints: { routes: [{ region: 'Middle East', riskLevel: 'low', riskScore: 30 }] },
ciiScores: [],
};
assert.deepEqual(detectMarketScenarios(inputs), []);
});
});
describe('detectInfraScenarios', () => {
it('major outage produces infra prediction', () => {
const inputs = {
outages: [{ country: 'Syria', severity: 'major' }],
cyberThreats: [],
gpsJamming: [],
};
const result = detectInfraScenarios(inputs);
assert.ok(result.length >= 1);
assert.equal(result[0].domain, 'infrastructure');
assert.ok(result[0].title.includes('Syria'));
});
it('minor outage is ignored', () => {
const inputs = {
outages: [{ country: 'Test', severity: 'minor' }],
cyberThreats: [],
gpsJamming: [],
};
assert.deepEqual(detectInfraScenarios(inputs), []);
});
it('cyber threats boost probability', () => {
const base = {
outages: [{ country: 'Syria', severity: 'total' }],
cyberThreats: [],
gpsJamming: [],
};
const withCyber = {
outages: [{ country: 'Syria', severity: 'total' }],
cyberThreats: [{ country: 'Syria', type: 'ddos' }],
gpsJamming: [],
};
const baseResult = detectInfraScenarios(base);
const cyberResult = detectInfraScenarios(withCyber);
assert.ok(cyberResult[0].probability > baseResult[0].probability,
'cyber threats should boost probability');
});
});
// ── Phase 2 Tests ──────────────────────────────────────────
describe('attachNewsContext', () => {
it('matches headlines mentioning prediction region', () => {
const preds = [makePrediction('conflict', 'Iran', 'test', 0.5, 0.5, '7d', [])];
const news = { topStories: [
{ primaryTitle: 'Iran tensions escalate after military action' },
{ primaryTitle: 'Stock market rallies on tech earnings' },
{ primaryTitle: 'Iran nuclear deal negotiations resume' },
]};
attachNewsContext(preds, news);
assert.equal(preds[0].newsContext.length, 2); // only Iran headlines
assert.ok(preds[0].newsContext[0].includes('Iran'));
});
it('adds news_corroboration signal when headlines match', () => {
const preds = [makePrediction('conflict', 'Iran', 'test', 0.5, 0.5, '7d', [])];
const news = { topStories: [{ primaryTitle: 'Iran military strikes reported' }] };
attachNewsContext(preds, news);
const corr = preds[0].signals.find(s => s.type === 'news_corroboration');
assert.ok(corr, 'should have news_corroboration signal');
assert.equal(corr.weight, 0.15);
});
it('does NOT add signal when no headlines match', () => {
const preds = [makePrediction('conflict', 'Iran', 'test', 0.5, 0.5, '7d', [])];
const news = { topStories: [{ primaryTitle: 'Local weather forecast sunny' }] };
attachNewsContext(preds, news);
const corr = preds[0].signals.find(s => s.type === 'news_corroboration');
assert.equal(corr, undefined);
});
it('falls back to generic headlines when no match', () => {
const preds = [makePrediction('conflict', 'Iran', 'test', 0.5, 0.5, '7d', [])];
const news = { topStories: [
{ primaryTitle: 'Unrelated headline about sports' },
{ primaryTitle: 'Another unrelated story' },
{ primaryTitle: 'Third unrelated story' },
{ primaryTitle: 'Fourth unrelated story' },
]};
attachNewsContext(preds, news);
assert.equal(preds[0].newsContext.length, 3); // fallback top-3
});
it('excludes commodity node names from matching (no false positives)', () => {
// Iran links to "Oil" in entity graph, but "Oil" should NOT match headlines
const preds = [makePrediction('conflict', 'Iran', 'test', 0.5, 0.5, '7d', [])];
const news = { topStories: [{ primaryTitle: 'Oil prices rise on global demand' }] };
attachNewsContext(preds, news);
// "Oil" is a commodity node, not country/theater, so should NOT match
const corr = preds[0].signals.find(s => s.type === 'news_corroboration');
assert.equal(corr, undefined, 'commodity names should not trigger corroboration');
});
it('reads headlines from digest categories (primary path)', () => {
const preds = [makePrediction('conflict', 'Iran', 'test', 0.5, 0.5, '7d', [])];
const digest = { categories: {
middleeast: { items: [{ title: 'Iran launches missile test' }, { title: 'Saudi oil output stable' }] },
europe: { items: [{ title: 'EU summit concludes' }] },
}};
attachNewsContext(preds, null, digest);
assert.ok(preds[0].newsContext.length >= 1);
assert.ok(preds[0].newsContext[0].includes('Iran'));
const corr = preds[0].signals.find(s => s.type === 'news_corroboration');
assert.ok(corr, 'should have corroboration from digest headlines');
});
it('handles null newsInsights and null digest', () => {
const preds = [makePrediction('conflict', 'Iran', 'test', 0.5, 0.5, '7d', [])];
attachNewsContext(preds, null, null);
assert.equal(preds[0].newsContext, undefined);
});
it('handles empty topStories with no digest', () => {
const preds = [makePrediction('conflict', 'Iran', 'test', 0.5, 0.5, '7d', [])];
attachNewsContext(preds, { topStories: [] }, null);
assert.equal(preds[0].newsContext, undefined);
});
});
describe('computeConfidence', () => {
it('higher source diversity = higher confidence', () => {
const p1 = makePrediction('conflict', 'Iran', 'a', 0.5, 0, '7d', [
{ type: 'cii', value: 'test', weight: 0.4 },
]);
const p2 = makePrediction('conflict', 'Iran', 'b', 0.5, 0, '7d', [
{ type: 'cii', value: 'test', weight: 0.4 },
{ type: 'theater', value: 'test', weight: 0.3 },
{ type: 'ucdp', value: 'test', weight: 0.2 },
]);
computeConfidence([p1, p2]);
assert.ok(p2.confidence > p1.confidence);
});
it('cii and cii_delta count as one source', () => {
const p = makePrediction('conflict', 'Iran', 'a', 0.5, 0, '7d', [
{ type: 'cii', value: 'test', weight: 0.4 },
{ type: 'cii_delta', value: 'test', weight: 0.2 },
]);
const pSingle = makePrediction('conflict', 'Iran', 'b', 0.5, 0, '7d', [
{ type: 'cii', value: 'test', weight: 0.4 },
]);
computeConfidence([p, pSingle]);
assert.equal(p.confidence, pSingle.confidence);
});
it('low calibration drift = higher confidence than high drift', () => {
const pLow = makePrediction('conflict', 'Iran', 'a', 0.5, 0, '7d', [
{ type: 'cii', value: 'test', weight: 0.4 },
]);
pLow.calibration = { marketTitle: 'test', marketPrice: 0.5, drift: 0.01, source: 'polymarket' };
const pHigh = makePrediction('conflict', 'Iran', 'b', 0.5, 0, '7d', [
{ type: 'cii', value: 'test', weight: 0.4 },
]);
pHigh.calibration = { marketTitle: 'test', marketPrice: 0.5, drift: 0.4, source: 'polymarket' };
computeConfidence([pLow, pHigh]);
assert.ok(pLow.confidence > pHigh.confidence);
});
it('high calibration drift = lower confidence', () => {
const p = makePrediction('conflict', 'Iran', 'a', 0.5, 0, '7d', [
{ type: 'cii', value: 'test', weight: 0.4 },
]);
p.calibration = { marketTitle: 'test', marketPrice: 0.5, drift: 0.4, source: 'polymarket' };
computeConfidence([p]);
assert.ok(p.confidence <= 0.5);
});
it('floors at 0.2', () => {
const p = makePrediction('conflict', 'Iran', 'a', 0.5, 0, '7d', []);
p.calibration = { marketTitle: 'test', marketPrice: 0.5, drift: 0.5, source: 'polymarket' };
computeConfidence([p]);
assert.ok(p.confidence >= 0.2);
});
});
describe('sanitizeForPrompt', () => {
it('strips HTML tags', () => {
assert.equal(sanitizeForPrompt('<script>alert("xss")</script>hello'), 'scriptalert("xss")/scripthello');
});
it('strips newlines', () => {
assert.equal(sanitizeForPrompt('line1\nline2\rline3'), 'line1 line2 line3');
});
it('truncates to 200 chars', () => {
const long = 'x'.repeat(300);
assert.equal(sanitizeForPrompt(long).length, 200);
});
it('handles null/undefined', () => {
assert.equal(sanitizeForPrompt(null), '');
assert.equal(sanitizeForPrompt(undefined), '');
});
});
describe('parseLLMScenarios', () => {
it('parses valid JSON array', () => {
const result = parseLLMScenarios('[{"index": 0, "scenario": "Test scenario"}]');
assert.equal(result.length, 1);
assert.equal(result[0].index, 0);
});
it('returns null for invalid JSON', () => {
assert.equal(parseLLMScenarios('not json at all'), null);
});
it('strips thinking tags before parsing', () => {
const result = parseLLMScenarios('<think>reasoning here</think>[{"index": 0, "scenario": "Test"}]');
assert.equal(result.length, 1);
});
it('repairs truncated JSON array', () => {
const result = parseLLMScenarios('[{"index": 0, "scenario": "Test scenario"');
assert.ok(result !== null);
assert.equal(result[0].index, 0);
});
it('extracts JSON from surrounding text', () => {
const result = parseLLMScenarios('Here is my analysis:\n[{"index": 0, "scenario": "Test"}]\nDone.');
assert.equal(result.length, 1);
});
});
describe('validateScenarios', () => {
const preds = [
makePrediction('conflict', 'Iran', 'test', 0.5, 0.5, '7d', [
{ type: 'cii', value: 'Iran CII 87 critical', weight: 0.4 },
]),
];
it('accepts scenario with signal reference', () => {
const scenarios = [{ index: 0, scenario: 'The Iran CII score of 87 indicates critical instability in the region, driven by ongoing military activity.' }];
const valid = validateScenarios(scenarios, preds);
assert.equal(valid.length, 1);
});
it('rejects scenario without signal reference', () => {
const scenarios = [{ index: 0, scenario: 'Tensions continue to rise in the region due to various geopolitical factors and ongoing disputes.' }];
const valid = validateScenarios(scenarios, preds);
assert.equal(valid.length, 0);
});
it('rejects too-short scenario', () => {
const scenarios = [{ index: 0, scenario: 'Short.' }];
const valid = validateScenarios(scenarios, preds);
assert.equal(valid.length, 0);
});
it('rejects out-of-bounds index', () => {
const scenarios = [{ index: 5, scenario: 'Iran CII 87 indicates critical instability in the region.' }];
const valid = validateScenarios(scenarios, preds);
assert.equal(valid.length, 0);
});
it('strips HTML from scenario', () => {
const scenarios = [{ index: 0, scenario: 'The Iran CII score of 87 <b>critical</b> indicates instability in the conflict zone region.' }];
const valid = validateScenarios(scenarios, preds);
assert.equal(valid.length, 1);
assert.ok(!valid[0].scenario.includes('<b>'));
});
it('handles null/non-array input', () => {
assert.deepEqual(validateScenarios(null, preds), []);
assert.deepEqual(validateScenarios('not array', preds), []);
});
});
// ── Phase 3 Tests ──────────────────────────────────────────
describe('computeProjections', () => {
it('anchors projection to timeHorizon', () => {
const p = makePrediction('conflict', 'Iran', 'test', 0.5, 0.5, '7d', []);
computeProjections([p]);
assert.ok(p.projections);
// probability should equal the d7 projection (anchored to 7d)
assert.equal(p.projections.d7, p.probability);
});
it('different domains produce different curves', () => {
const conflict = makePrediction('conflict', 'A', 'a', 0.5, 0.5, '7d', []);
const infra = makePrediction('infrastructure', 'B', 'b', 0.5, 0.5, '24h', []);
computeProjections([conflict, infra]);
assert.notEqual(conflict.projections.d30, infra.projections.d30);
});
it('caps at 0.95', () => {
const p = makePrediction('conflict', 'Iran', 'test', 0.9, 0.5, '7d', []);
computeProjections([p]);
assert.ok(p.projections.h24 <= 0.95);
assert.ok(p.projections.d7 <= 0.95);
assert.ok(p.projections.d30 <= 0.95);
});
it('floors at 0.01', () => {
const p = makePrediction('infrastructure', 'A', 'test', 0.02, 0.5, '24h', []);
computeProjections([p]);
assert.ok(p.projections.d30 >= 0.01);
});
it('unknown domain defaults to multiplier 1', () => {
const p = makePrediction('unknown_domain', 'X', 'test', 0.5, 0.5, '7d', []);
computeProjections([p]);
assert.equal(p.projections.h24, 0.5);
assert.equal(p.projections.d7, 0.5);
assert.equal(p.projections.d30, 0.5);
});
});
describe('validatePerspectives', () => {
const preds = [makePrediction('conflict', 'Iran', 'test', 0.5, 0.5, '7d', [
{ type: 'cii', value: 'Iran CII 87', weight: 0.4 },
])];
it('accepts valid perspectives', () => {
const items = [{
index: 0,
strategic: 'The CII data shows critical instability with a score of 87 in the conflict region.',
regional: 'Regional actors face mounting pressure from the elevated CII threat level.',
contrarian: 'Despite CII readings, diplomatic channels remain open and could defuse tensions.',
}];
const valid = validatePerspectives(items, preds);
assert.equal(valid.length, 1);
});
it('rejects too-short perspectives', () => {
const items = [{ index: 0, strategic: 'Short.', regional: 'Also short.', contrarian: 'Nope.' }];
assert.equal(validatePerspectives(items, preds).length, 0);
});
it('strips HTML before length check', () => {
const items = [{
index: 0,
strategic: '<b><i><span>x</span></i></b>',
regional: 'Valid regional perspective with enough characters here.',
contrarian: 'Valid contrarian perspective with enough characters here.',
}];
assert.equal(validatePerspectives(items, preds).length, 0);
});
it('handles null input', () => {
assert.deepEqual(validatePerspectives(null, preds), []);
});
it('rejects out-of-bounds index', () => {
const items = [{
index: 5,
strategic: 'Valid strategic perspective with sufficient length.',
regional: 'Valid regional perspective with sufficient length too.',
contrarian: 'Valid contrarian perspective with sufficient length too.',
}];
assert.equal(validatePerspectives(items, preds).length, 0);
});
});
describe('loadCascadeRules', () => {
it('loads rules from JSON file', () => {
const rules = loadCascadeRules();
assert.ok(Array.isArray(rules));
assert.ok(rules.length >= 5);
});
it('each rule has required fields', () => {
const rules = loadCascadeRules();
for (const r of rules) {
assert.ok(r.from, 'missing from');
assert.ok(r.to, 'missing to');
assert.ok(typeof r.coupling === 'number', 'coupling must be number');
assert.ok(r.mechanism, 'missing mechanism');
}
});
it('includes new Phase 3 rules', () => {
const rules = loadCascadeRules();
const infraToSupply = rules.find(r => r.from === 'infrastructure' && r.to === 'supply_chain');
assert.ok(infraToSupply, 'infrastructure -> supply_chain rule missing');
assert.equal(infraToSupply.requiresSeverity, 'total');
});
});
describe('evaluateRuleConditions', () => {
it('requiresChokepoint passes for chokepoint region', () => {
const pred = makePrediction('conflict', 'Middle East', 'test', 0.5, 0.5, '7d', []);
assert.ok(evaluateRuleConditions({ requiresChokepoint: true }, pred));
});
it('requiresChokepoint fails for non-chokepoint region', () => {
const pred = makePrediction('conflict', 'Northern Europe', 'test', 0.5, 0.5, '7d', []);
assert.ok(!evaluateRuleConditions({ requiresChokepoint: true }, pred));
});
it('minProbability passes when above threshold', () => {
const pred = makePrediction('political', 'Iran', 'test', 0.7, 0.5, '7d', []);
assert.ok(evaluateRuleConditions({ minProbability: 0.6 }, pred));
});
it('minProbability fails when below threshold', () => {
const pred = makePrediction('political', 'Iran', 'test', 0.3, 0.5, '7d', []);
assert.ok(!evaluateRuleConditions({ minProbability: 0.6 }, pred));
});
it('requiresSeverity checks outage signal value', () => {
const pred = makePrediction('infrastructure', 'Iran', 'test', 0.5, 0.5, '24h', [
{ type: 'outage', value: 'Iran total outage', weight: 0.4 },
]);
assert.ok(evaluateRuleConditions({ requiresSeverity: 'total' }, pred));
});
it('requiresSeverity fails for non-matching severity', () => {
const pred = makePrediction('infrastructure', 'Iran', 'test', 0.5, 0.5, '24h', [
{ type: 'outage', value: 'Iran minor outage', weight: 0.4 },
]);
assert.ok(!evaluateRuleConditions({ requiresSeverity: 'total' }, pred));
});
});
// ── Phase 4 Tests ──────────────────────────────────────────
describe('normalizeChokepoints', () => {
it('maps v4 shape to v2 fields', () => {
const v4 = { chokepoints: [{ name: 'Suez Canal', disruptionScore: 75, status: 'yellow' }] };
const result = normalizeChokepoints(v4);
assert.equal(result.chokepoints[0].region, 'Suez Canal');
assert.equal(result.chokepoints[0].riskScore, 75);
assert.equal(result.chokepoints[0].riskLevel, 'high');
assert.equal(result.chokepoints[0].disrupted, false);
});
it('maps red status to critical + disrupted', () => {
const v4 = { chokepoints: [{ name: 'Hormuz', status: 'red' }] };
const result = normalizeChokepoints(v4);
assert.equal(result.chokepoints[0].riskLevel, 'critical');
assert.equal(result.chokepoints[0].disrupted, true);
});
it('handles null', () => {
assert.equal(normalizeChokepoints(null), null);
});
});
describe('normalizeGpsJamming', () => {
it('maps hexes to zones', () => {
const raw = { hexes: [{ lat: 35, lon: 30 }] };
const result = normalizeGpsJamming(raw);
assert.ok(result.zones);
assert.equal(result.zones[0].lat, 35);
});
it('preserves existing zones', () => {
const raw = { zones: [{ lat: 10, lon: 20 }] };
const result = normalizeGpsJamming(raw);
assert.equal(result.zones[0].lat, 10);
});
it('handles null', () => {
assert.equal(normalizeGpsJamming(null), null);
});
});
describe('detectUcdpConflictZones', () => {
it('generates prediction for 10+ events in one country', () => {
const events = Array.from({ length: 15 }, () => ({ country: 'Syria' }));
const result = detectUcdpConflictZones({ ucdpEvents: { events } });
assert.equal(result.length, 1);
assert.equal(result[0].domain, 'conflict');
assert.equal(result[0].region, 'Syria');
});
it('skips countries with < 10 events', () => {
const events = Array.from({ length: 5 }, () => ({ country: 'Jordan' }));
assert.equal(detectUcdpConflictZones({ ucdpEvents: { events } }).length, 0);
});
it('handles empty input', () => {
assert.equal(detectUcdpConflictZones({}).length, 0);
});
});
describe('detectCyberScenarios', () => {
it('generates prediction for 5+ threats in one country', () => {
const threats = Array.from({ length: 8 }, () => ({ country: 'US', type: 'malware' }));
const result = detectCyberScenarios({ cyberThreats: { threats } });
assert.equal(result.length, 1);
assert.equal(result[0].domain, 'infrastructure');
});
it('skips countries with < 5 threats', () => {
const threats = Array.from({ length: 3 }, () => ({ country: 'CH', type: 'phishing' }));
assert.equal(detectCyberScenarios({ cyberThreats: { threats } }).length, 0);
});
it('handles empty input', () => {
assert.equal(detectCyberScenarios({}).length, 0);
});
});
describe('detectGpsJammingScenarios', () => {
it('generates prediction for hexes in maritime region', () => {
const zones = Array.from({ length: 5 }, () => ({ lat: 35, lon: 30 })); // Eastern Med
const result = detectGpsJammingScenarios({ gpsJamming: { zones } });
assert.equal(result.length, 1);
assert.equal(result[0].domain, 'supply_chain');
assert.equal(result[0].region, 'Eastern Mediterranean');
});
it('skips hexes outside maritime regions', () => {
const zones = [{ lat: 0, lon: 0 }, { lat: 1, lon: 1 }, { lat: 2, lon: 2 }];
assert.equal(detectGpsJammingScenarios({ gpsJamming: { zones } }).length, 0);
});
});
describe('detectFromPredictionMarkets', () => {
it('generates from 60-90% markets with region', () => {
const markets = { geopolitical: [{ title: 'Will Iran strike Israel?', yesPrice: 70, source: 'polymarket' }] };
const result = detectFromPredictionMarkets({ predictionMarkets: markets });
assert.equal(result.length, 1);
assert.equal(result[0].domain, 'conflict');
assert.equal(result[0].region, 'Middle East');
});
it('skips markets below 60%', () => {
const markets = { geopolitical: [{ title: 'Will US enter recession?', yesPrice: 30 }] };
assert.equal(detectFromPredictionMarkets({ predictionMarkets: markets }).length, 0);
});
it('caps at 5 predictions', () => {
const markets = { geopolitical: Array.from({ length: 10 }, (_, i) => ({
title: `Will Europe face crisis ${i}?`, yesPrice: 70,
})) };
assert.ok(detectFromPredictionMarkets({ predictionMarkets: markets }).length <= 5);
});
});
describe('lowered CII conflict threshold', () => {
it('CII score 67 (high level) now triggers conflict', () => {
const result = detectConflictScenarios({
ciiScores: { ciiScores: [{ region: 'IL', combinedScore: 67, trend: 'TREND_DIRECTION_STABLE', components: {} }] },
theaterPosture: { theaters: [] },
iranEvents: { events: [] },
ucdpEvents: { events: [] },
});
assert.ok(result.length >= 1, 'should trigger at score 67');
});
it('CII score 62 (elevated level) does NOT trigger conflict', () => {
const result = detectConflictScenarios({
ciiScores: { ciiScores: [{ region: 'JO', combinedScore: 62, trend: 'TREND_DIRECTION_RISING', components: {} }] },
theaterPosture: { theaters: [] },
iranEvents: { events: [] },
ucdpEvents: { events: [] },
});
assert.equal(result.length, 0, 'should NOT trigger at score 62 (elevated)');
});
});
describe('loadEntityGraph', () => {
it('loads graph from JSON', () => {
const graph = loadEntityGraph();
assert.ok(graph.nodes);
assert.ok(graph.aliases);
assert.ok(graph.edges);
assert.ok(Object.keys(graph.nodes).length > 10);
});
it('aliases resolve country codes', () => {
const graph = loadEntityGraph();
assert.equal(graph.aliases['IR'], 'IR');
assert.equal(graph.aliases['Iran'], 'IR');
assert.equal(graph.aliases['Middle East'], 'middle-east');
});
});
describe('discoverGraphCascades', () => {
it('finds linked predictions via graph', () => {
const graph = loadEntityGraph();
const preds = [
makePrediction('conflict', 'IR', 'Iran conflict', 0.6, 0.5, '7d', []),
makePrediction('market', 'Middle East', 'Oil impact', 0.4, 0.5, '30d', []),
];
discoverGraphCascades(preds, graph);
// IR links to middle-east theater, which has Oil impact prediction
const irCascades = preds[0].cascades.filter(c => c.effect.includes('graph:'));
assert.ok(irCascades.length > 0 || preds[1].cascades.length > 0, 'should find graph cascade between Iran and Middle East');
});
it('skips same-domain predictions', () => {
const graph = loadEntityGraph();
const preds = [
makePrediction('conflict', 'IR', 'a', 0.6, 0.5, '7d', []),
makePrediction('conflict', 'Middle East', 'b', 0.5, 0.5, '7d', []),
];
discoverGraphCascades(preds, graph);
const graphCascades = preds[0].cascades.filter(c => c.effect.includes('graph:'));
assert.equal(graphCascades.length, 0, 'same domain should not cascade');
});
});