diff --git a/api/mcp.ts b/api/mcp.ts index 1e8deac43f..ec7fd8059e 100644 --- a/api/mcp.ts +++ b/api/mcp.ts @@ -76,7 +76,7 @@ type ToolDef = CacheToolDef | RpcToolDef; const TOOL_REGISTRY: ToolDef[] = [ { name: 'get_market_data', - description: 'Real-time equity quotes, commodity prices (including gold futures GC=F), crypto prices, forex FX rates (USD/EUR, USD/JPY etc.), sector performance, ETF flows, and Gulf market quotes from WorldMonitor\'s curated bootstrap cache.', + description: 'Real-time equity quotes, commodity prices (including gold futures GC=F), crypto prices, forex FX rates (USD/EUR, USD/JPY etc.), sector performance, ETF flows, Gulf market quotes, crypto sector performance, stablecoin market data, and wholesale FX rates from WorldMonitor\'s curated bootstrap cache.', inputSchema: { type: 'object', properties: {}, required: [] }, _cacheKeys: [ 'market:stocks-bootstrap:v1', @@ -86,6 +86,9 @@ const TOOL_REGISTRY: ToolDef[] = [ 'market:etf-flows:v1', 'market:gulf-quotes:v1', 'market:fear-greed:v1', + 'market:crypto-sectors:v1', + 'market:stablecoins:v1', + 'shared:fx-rates:v1', ], _seedMetaKey: 'seed-meta:market:stocks', _maxStaleMin: 30, @@ -154,7 +157,7 @@ const TOOL_REGISTRY: ToolDef[] = [ }, { name: 'get_economic_data', - description: 'Macro economic indicators: Fed Funds rate (FRED), economic calendar events, fuel prices, ECB FX rates, EU yield curve, earnings calendar, COT positioning, energy storage data, BIS household debt service ratio (DSR, quarterly, leading indicator of household financial stress across ~40 advanced economies), and BIS residential + commercial property price indices (real, quarterly).', + description: 'Macro economic indicators: Fed Funds rate (FRED), economic calendar events, fuel prices, ECB FX rates, EU yield curve, earnings calendar, COT positioning, energy storage, IMF WEO macro (inflation, GDP, debt, 200+ countries), national debt-to-GDP timeseries, Big Mac PPP index, FAO Food Price Index, and Eurostat EU statistics from WorldMonitor\'s seed cache.', inputSchema: { type: 'object', properties: {}, required: [] }, _cacheKeys: [ 'economic:fred:v1:FEDFUNDS:0', @@ -165,65 +168,14 @@ const TOOL_REGISTRY: ToolDef[] = [ 'economic:spending:v1', 'market:earnings-calendar:v1', 'market:cot:v1', - 'economic:bis:dsr:v1', - 'economic:bis:property-residential:v1', - 'economic:bis:property-commercial:v1', + 'economic:imf:macro:v2', + 'economic:national-debt:v1', + 'economic:bigmac:v1', + 'economic:fao-ffpi:v1', + 'economic:eurostat-country-data:v1', ], _seedMetaKey: 'seed-meta:economic:econ-calendar', _maxStaleMin: 1440, - _freshnessChecks: [ - { key: 'seed-meta:economic:econ-calendar', maxStaleMin: 1440 }, - // Per-dataset BIS seed-meta keys — the aggregate - // `seed-meta:economic:bis-extended` would report "fresh" even if only - // one of the three datasets (DSR / SPP / CPP) is current, matching the - // false-freshness bug already fixed for /api/health and resilience. - { key: 'seed-meta:economic:bis-dsr', maxStaleMin: 1440 }, // 12h cron × 2 - { key: 'seed-meta:economic:bis-property-residential', maxStaleMin: 1440 }, - { key: 'seed-meta:economic:bis-property-commercial', maxStaleMin: 1440 }, - ], - }, - { - name: 'get_country_macro', - description: 'Per-country macroeconomic indicators from IMF WEO (~210 countries, monthly cadence). Bundles fiscal/external balance (inflation, current account, gov revenue/expenditure/primary balance, CPI), growth & per-capita (real GDP growth, GDP/capita USD & PPP, savings & investment rates, savings-investment gap), labor & demographics (unemployment, population), and external trade (current account USD, import/export volume % changes). Latest available year per series. Use for country-level economic screening, peer benchmarking, and stagflation/imbalance flags. NOTE: export/import LEVELS in USD (exportsUsd, importsUsd, tradeBalanceUsd) are returned as null — WEO retracted broad coverage for BX/BM indicators in 2026-04; use currentAccountUsd or volume changes (import/exportVolumePctChg) instead.', - inputSchema: { type: 'object', properties: {}, required: [] }, - _cacheKeys: [ - 'economic:imf:macro:v2', - 'economic:imf:growth:v1', - 'economic:imf:labor:v1', - 'economic:imf:external:v1', - ], - _seedMetaKey: 'seed-meta:economic:imf-macro', - _maxStaleMin: 100800, // monthly WEO release; 70d = 2× interval (absorbs one missed run) - _freshnessChecks: [ - { key: 'seed-meta:economic:imf-macro', maxStaleMin: 100800 }, - { key: 'seed-meta:economic:imf-growth', maxStaleMin: 100800 }, - { key: 'seed-meta:economic:imf-labor', maxStaleMin: 100800 }, - { key: 'seed-meta:economic:imf-external', maxStaleMin: 100800 }, - ], - }, - { - name: 'get_eu_housing_cycle', - description: 'Eurostat annual house price index (prc_hpi_a, base 2015=100) for all 27 EU members plus EA20 and EU27_2020 aggregates. Each country entry includes the latest value, prior value, date, unit, and a 10-year sparkline series. Complements BIS WS_SPP with broader EU coverage for the Housing cycle tile.', - inputSchema: { type: 'object', properties: {}, required: [] }, - _cacheKeys: ['economic:eurostat:house-prices:v1'], - _seedMetaKey: 'seed-meta:economic:eurostat-house-prices', - _maxStaleMin: 60 * 24 * 50, // weekly cron, annual data - }, - { - name: 'get_eu_quarterly_gov_debt', - description: 'Eurostat quarterly general government gross debt (gov_10q_ggdebt, %GDP) for all 27 EU members plus EA20 and EU27_2020 aggregates. Each country entry includes latest value, prior value, quarter label, and an 8-quarter sparkline series. Provides fresher debt-trajectory signal than annual IMF GGXWDG_NGDP for EU panels.', - inputSchema: { type: 'object', properties: {}, required: [] }, - _cacheKeys: ['economic:eurostat:gov-debt-q:v1'], - _seedMetaKey: 'seed-meta:economic:eurostat-gov-debt-q', - _maxStaleMin: 60 * 24 * 14, // quarterly data, 2-day cron - }, - { - name: 'get_eu_industrial_production', - description: 'Eurostat monthly industrial production index (sts_inpr_m, NACE B-D industry excl. construction, SCA, base 2021=100) for all 27 EU members plus EA20 and EU27_2020 aggregates. Each country entry includes latest value, prior value, month label, and a 12-month sparkline series. Leading indicator of real-economy activity used by the "Real economy pulse" sparkline.', - inputSchema: { type: 'object', properties: {}, required: [] }, - _cacheKeys: ['economic:eurostat:industrial-production:v1'], - _seedMetaKey: 'seed-meta:economic:eurostat-industrial-production', - _maxStaleMin: 60 * 24 * 5, // monthly data, daily cron }, { name: 'get_prediction_markets', @@ -268,12 +220,17 @@ const TOOL_REGISTRY: ToolDef[] = [ }, { name: 'get_supply_chain_data', - description: 'Dry bulk shipping stress index, customs revenue flows, and COMTRADE bilateral trade data. Tracks global supply chain pressure and trade disruptions.', + description: 'Dry bulk shipping stress index, customs revenue flows, COMTRADE bilateral trade data, Hormuz tracker, port chokepoint reference data, active disruptions, energy crisis policies, and energy intelligence feeds. Tracks global supply chain pressure and trade disruptions.', inputSchema: { type: 'object', properties: {}, required: [] }, _cacheKeys: [ 'supply_chain:shipping_stress:v1', 'trade:customs-revenue:v1', 'comtrade:flows:v1', + 'supply_chain:hormuz_tracker:v1', + 'portwatch:chokepoints:ref:v1', + 'portwatch:disruptions:active:v1', + 'energy:crisis-policies:v1', + 'energy:intelligence:feed:v1', ], _seedMetaKey: 'seed-meta:trade:customs-revenue', _maxStaleMin: 2880, @@ -323,6 +280,24 @@ const TOOL_REGISTRY: ToolDef[] = [ _maxStaleMin: 30, }, + // ------------------------------------------------------------------------- + // Resilience recovery — cache read (IMF WEO-derived resilience indicators) + // ------------------------------------------------------------------------- + { + name: 'get_resilience_recovery', + description: 'IMF WEO-derived resilience and recovery indicators: fiscal space (revenue vs. spending headroom), reserve adequacy (external reserves vs. imports), external debt sustainability, import concentration (HHI), and strategic fuel stock levels. Covers 200+ countries with monthly/quarterly cadence.', + inputSchema: { type: 'object', properties: {}, required: [] }, + _cacheKeys: [ + 'resilience:recovery:fiscal-space:v1', + 'resilience:recovery:reserve-adequacy:v1', + 'resilience:recovery:external-debt:v1', + 'resilience:recovery:import-hhi:v1', + 'resilience:recovery:fuel-stocks:v1', + ], + _seedMetaKey: 'seed-meta:resilience:recovery:fiscal-space', + _maxStaleMin: 43200, + }, + // ------------------------------------------------------------------------- // AI inference tools — call LLM endpoints, not cached Redis reads // ------------------------------------------------------------------------- diff --git a/convex/alertRules.ts b/convex/alertRules.ts index 11d66f21bc..51be1fce4b 100644 --- a/convex/alertRules.ts +++ b/convex/alertRules.ts @@ -181,6 +181,9 @@ function validateQuietHoursArgs(args: { if (args.quietHoursEnd !== undefined && (args.quietHoursEnd < 0 || args.quietHoursEnd > 23 || !Number.isInteger(args.quietHoursEnd))) { throw new ConvexError("quietHoursEnd must be an integer 0–23"); } + if (args.quietHoursStart !== undefined && args.quietHoursEnd !== undefined && args.quietHoursStart === args.quietHoursEnd) { + throw new ConvexError("quietHoursStart and quietHoursEnd cannot be equal — setting the same value for both means quiet hours are always active; use the enabled flag instead"); + } if (args.quietHoursTimezone !== undefined) { try { Intl.DateTimeFormat(undefined, { timeZone: args.quietHoursTimezone }); diff --git a/scripts/notification-relay.cjs b/scripts/notification-relay.cjs index 9b9cb37c50..11072f3ed0 100644 --- a/scripts/notification-relay.cjs +++ b/scripts/notification-relay.cjs @@ -114,7 +114,34 @@ function isPrivateIP(ip) { // ── Quiet hours ─────────────────────────────────────────────────────────────── -const { toLocalHour, isInQuietHours } = require('./lib/quiet-hours.cjs'); +function toLocalHour(nowMs, timezone) { + try { + const parts = new Intl.DateTimeFormat('en-US', { + timeZone: timezone, + hour: 'numeric', + hour12: false, + }).formatToParts(new Date(nowMs)); + const h = parts.find(p => p.type === 'hour'); + return h ? parseInt(h.value, 10) : -1; + } catch { + return -1; + } +} + +function isInQuietHours(rule) { + if (!rule.quietHoursEnabled) return false; + const start = rule.quietHoursStart ?? 22; + const end = rule.quietHoursEnd ?? 7; + // start === end means quiet hours are not meaningful — treat as disabled + if (start === end) return false; + const tz = rule.quietHoursTimezone ?? 'UTC'; + const localHour = toLocalHour(Date.now(), tz); + if (localHour === -1) return false; + // spans midnight when start > end (e.g. 23:00-07:00) + return start < end + ? localHour >= start && localHour < end + : localHour >= start || localHour < end; +} // Returns 'deliver' | 'suppress' | 'hold' function resolveQuietAction(rule, severity) { @@ -256,7 +283,7 @@ async function processFlushQuietHeld(event) { // ── Delivery: Telegram ──────────────────────────────────────────────────────── -async function sendTelegram(userId, chatId, text) { +async function sendTelegram(userId, chatId, text, _retryCount = 0) { if (!TELEGRAM_BOT_TOKEN) { console.warn('[relay] Telegram: TELEGRAM_BOT_TOKEN not set — skipping'); return false; @@ -277,10 +304,14 @@ async function sendTelegram(userId, chatId, text) { return false; } if (res.status === 429) { + if (_retryCount >= 1) { + console.warn(`[relay] Telegram 429 retry exhausted for ${userId} — giving up`); + return false; + } const body = await res.json().catch(() => ({})); const wait = ((body.parameters?.retry_after ?? 5) + 1) * 1000; await new Promise(r => setTimeout(r, wait)); - return sendTelegram(userId, chatId, text); // single retry + return sendTelegram(userId, chatId, text, _retryCount + 1); // single retry with counter } if (res.status === 401) { console.error('[relay] Telegram 401 Unauthorized — TELEGRAM_BOT_TOKEN is invalid or belongs to a different bot; correct the Railway env var to restore Telegram delivery'); @@ -445,13 +476,6 @@ async function sendWebhook(userId, webhookEnvelope, event) { return false; } - // Envelope version stays at '1'. Payload gained optional `corroborationCount` - // on rss_alert (PR #3069) — this is an additive field, backwards-compatible - // for consumers that don't enforce `additionalProperties: false`. Bumping - // version here would have broken parity with the other webhook producers - // (scripts/proactive-intelligence.mjs, scripts/seed-digest-notifications.mjs) - // which still emit v1, causing the same endpoint to receive mixed envelope - // versions per event type. const payload = JSON.stringify({ version: '1', eventType: event.eventType, @@ -556,62 +580,21 @@ async function processWelcome(event) { const IMPORTANCE_SCORE_LIVE = process.env.IMPORTANCE_SCORE_LIVE === '1'; const IMPORTANCE_SCORE_MIN = Number(process.env.IMPORTANCE_SCORE_MIN ?? 40); -// v2 key: JSON-encoded members, used after the stale-score fix (PR #TBD). -// The old v1 key (compact string format) is retained by consumers for -// backward-compat reading but is no longer written. See -// docs/internal/scoringDiagnostic.md §5 and §9 Step 4. -const SHADOW_SCORE_LOG_KEY = 'shadow:score-log:v2'; +const SHADOW_SCORE_LOG_KEY = 'shadow:score-log:v1'; const SHADOW_LOG_TTL = 7 * 24 * 3600; // 7 days async function shadowLogScore(event) { const importanceScore = event.payload?.importanceScore ?? 0; if (!UPSTASH_URL || !UPSTASH_TOKEN || importanceScore === 0) return; const now = Date.now(); - const record = { - ts: now, - importanceScore, - severity: event.severity ?? 'high', - eventType: event.eventType, - title: String(event.payload?.title ?? '').slice(0, 160), - source: event.payload?.source ?? '', - publishedAt: event.payload?.publishedAt ?? null, - corroborationCount: event.payload?.corroborationCount ?? null, - variant: event.variant ?? '', - }; - const member = JSON.stringify(record); + // Use timestamp as the sorted-set score so entries are time-sortable for analysis. + // Member encodes importanceScore + context for review. + const member = `${now}:score=${importanceScore}:${event.eventType}:${String(event.payload?.title ?? '').slice(0, 60)}`; const cutoff = String(now - SHADOW_LOG_TTL * 1000); // prune entries older than 7 days - // One pipelined HTTP request: ZADD + ZREMRANGEBYSCORE prune + 30-day - // belt-and-suspenders EXPIRE. Saves ~50% round-trips vs sequential calls - // and bounds growth even if writes stop and the rolling prune stalls. try { - const res = await fetch(`${UPSTASH_URL}/pipeline`, { - method: 'POST', - headers: { - Authorization: `Bearer ${UPSTASH_TOKEN}`, - 'Content-Type': 'application/json', - 'User-Agent': 'worldmonitor-relay/1.0', - }, - body: JSON.stringify([ - ['ZADD', SHADOW_SCORE_LOG_KEY, String(now), member], - ['ZREMRANGEBYSCORE', SHADOW_SCORE_LOG_KEY, '-inf', cutoff], - ['EXPIRE', SHADOW_SCORE_LOG_KEY, '2592000'], - ]), - }); - // Surface HTTP failures and per-command errors. Activation depends on v2 - // filling with clean data; a silent write-failure would leave operators - // staring at an empty ZSET with no signal. - if (!res.ok) { - console.warn(`[relay] shadow-log pipeline HTTP ${res.status}`); - return; - } - const body = await res.json().catch(() => null); - if (Array.isArray(body)) { - const failures = body.map((cmd, i) => (cmd?.error ? `cmd[${i}] ${cmd.error}` : null)).filter(Boolean); - if (failures.length > 0) console.warn(`[relay] shadow-log pipeline partial failure: ${failures.join('; ')}`); - } - } catch (err) { - console.warn(`[relay] shadow-log pipeline threw: ${err?.message ?? err}`); - } + await upstashRest('ZADD', SHADOW_SCORE_LOG_KEY, String(now), member); + await upstashRest('ZREMRANGEBYSCORE', SHADOW_SCORE_LOG_KEY, '-inf', cutoff); + } catch {} } // ── AI impact analysis ─────────────────────────────────────────────────────── @@ -682,10 +665,8 @@ async function processEvent(event) { if (event.eventType === 'flush_quiet_held') { await processFlushQuietHeld(event); return; } console.log(`[relay] Processing event: ${event.eventType} (${event.severity ?? 'high'})`); - // Shadow log importanceScore for comparison. Gate at caller: only rss_alert - // events carry importanceScore; for everything else shadowLogScore would - // short-circuit, but we still pay the promise/microtask cost unless gated here. - if (event.eventType === 'rss_alert') shadowLogScore(event).catch(() => {}); + // Shadow log importanceScore for comparison (always runs when score is present) + shadowLogScore(event).catch(() => {}); // Score gate — only for rss_alert; other event types (oref_siren, conflict_escalation, // notam_closure, etc.) never attach importanceScore so they must never be gated here. diff --git a/src/app/country-intel.ts b/src/app/country-intel.ts index 75d1fc66e2..1bcc5a428e 100644 --- a/src/app/country-intel.ts +++ b/src/app/country-intel.ts @@ -265,12 +265,22 @@ export class CountryIntelManager implements AppModule { const ourPos = CountryIntelManager.firstMentionPosition(t, searchTerms); const otherPos = CountryIntelManager.firstMentionPosition(t, otherCountryTerms); return ourPos !== Infinity && (otherPos === Infinity || ourPos <= otherPos); - }).sort((a, b) => { + }); + const seen = new Set(); + const deduped: typeof filteredNews = []; + for (const n of filteredNews) { + const normalized = n.title.toLowerCase().replace(/[^a-z0-9\s]/g, '').replace(/\s+/g, ' ').trim(); + if (normalized.length > 0 && !seen.has(normalized)) { + seen.add(normalized); + deduped.push(n); + } + } + deduped.sort((a, b) => { const severityDelta = this.newsSeverityRank(b) - this.newsSeverityRank(a); if (severityDelta !== 0) return severityDelta; return new Date(b.pubDate).getTime() - new Date(a.pubDate).getTime(); }); - this.ctx.countryBriefPage.updateNews(filteredNews.slice(0, 10)); + this.ctx.countryBriefPage.updateNews(deduped.slice(0, 10)); this.ctx.countryBriefPage.updateInfrastructure(code); diff --git a/src/components/CountryDeepDivePanel.ts b/src/components/CountryDeepDivePanel.ts index 2310624ce1..9d160a62c9 100644 --- a/src/components/CountryDeepDivePanel.ts +++ b/src/components/CountryDeepDivePanel.ts @@ -42,7 +42,6 @@ import type { import { fetchMultiSectorCostShock, HS2_SHORT_LABELS } from '@/services/supply-chain'; import type { MapContainer } from './MapContainer'; import { ResilienceWidget } from './ResilienceWidget'; -import { dedupeHeadlines } from './CountryDeepDivePanel-news-utils'; const DEPENDENCY_FLAG_LABELS: Record = { DEPENDENCY_FLAG_SINGLE_SOURCE_CRITICAL: { text: 'Single Source', cls: 'cdp-dep-critical' }, @@ -99,7 +98,6 @@ export class CountryDeepDivePanel implements CountryBriefPanel { private militaryBody: HTMLElement | null = null; private infrastructureBody: HTMLElement | null = null; private economicBody: HTMLElement | null = null; - private housingBody: HTMLElement | null = null; private marketsBody: HTMLElement | null = null; private briefBody: HTMLElement | null = null; private timelineBody: HTMLElement | null = null; @@ -310,28 +308,24 @@ export class CountryDeepDivePanel implements CountryBriefPanel { if (!this.newsBody) return; this.newsBody.replaceChildren(); - const compare = (a: NewsItem, b: NewsItem) => { - const sa = SEVERITY_ORDER[this.toThreatLevel(a.threat?.level)]; - const sb = SEVERITY_ORDER[this.toThreatLevel(b.threat?.level)]; - if (sb !== sa) return sb - sa; - return this.toTimestamp(b.pubDate) - this.toTimestamp(a.pubDate); - }; - - const sorted = [...headlines].sort(compare); - - const deduped = dedupeHeadlines(sorted, (it) => it.tier ?? getSourceTier(it.source)) - .sort((a, b) => compare(a.item, b.item)) + const items = [...headlines] + .sort((a, b) => { + const sa = SEVERITY_ORDER[this.toThreatLevel(a.threat?.level)]; + const sb = SEVERITY_ORDER[this.toThreatLevel(b.threat?.level)]; + if (sb !== sa) return sb - sa; + return this.toTimestamp(b.pubDate) - this.toTimestamp(a.pubDate); + }) .slice(0, 10); - this.currentHeadlineCount = deduped.length; + this.currentHeadlineCount = items.length; - if (deduped.length === 0) { + if (items.length === 0) { this.newsBody.append(this.makeEmpty(t('countryBrief.noNews'))); return; } - for (let i = 0; i < deduped.length; i++) { - const { item, extraSources } = deduped[i]!; + for (let i = 0; i < items.length; i++) { + const item = items[i]!; const row = this.el('a', 'cdp-news-item'); row.id = `cdp-news-${i + 1}`; const href = sanitizeUrl(item.link); @@ -363,13 +357,7 @@ export class CountryDeepDivePanel implements CountryBriefPanel { } const title = this.el('div', 'cdp-news-title', this.decodeEntities(item.title)); - const metaText = extraSources.length > 0 - ? `${item.source} +${extraSources.length} ${extraSources.length === 1 ? 'source' : 'sources'} • ${this.formatRelativeTime(item.pubDate)}` - : `${item.source} • ${this.formatRelativeTime(item.pubDate)}`; - const meta = this.el('div', 'cdp-news-meta', metaText); - if (extraSources.length > 0) { - meta.setAttribute('title', `Also reported by: ${extraSources.join(', ')}`); - } + const meta = this.el('div', 'cdp-news-meta', `${item.source} • ${this.formatRelativeTime(item.pubDate)}`); row.append(top, title, meta); if (i >= 5) { @@ -382,7 +370,6 @@ export class CountryDeepDivePanel implements CountryBriefPanel { } } - public updateMilitaryActivity(summary: CountryDeepDiveMilitarySummary): void { if (!this.militaryBody) return; this.militaryBody.replaceChildren(); @@ -547,44 +534,6 @@ export class CountryDeepDivePanel implements CountryBriefPanel { this.factsBody.append(grid); } - public updateHousingCycle(data: { - residential?: { indexValue: number; qoqChange: number | null; yoyChange: number | null; period: string } | null; - commercial?: { indexValue: number; qoqChange: number | null; yoyChange: number | null; period: string } | null; - dsr?: { dsrPct: number; change: number | null; period: string } | null; - } | null): void { - if (!this.housingBody) return; - this.housingBody.replaceChildren(); - if (!data || (!data.residential && !data.commercial && !data.dsr)) { - this.housingBody.append(this.makeEmpty('No BIS housing cycle data for this country')); - return; - } - const grid = this.el('div', 'cdp-pro-metric-grid'); - if (data.residential) { - grid.append( - this.proMetricBox('Residential (real)', `${data.residential.indexValue.toFixed(1)}`), - this.proMetricBox('Residential YoY', this.formatPctTrend(data.residential.yoyChange)), - ); - } - if (data.commercial) { - grid.append( - this.proMetricBox('Commercial (real)', `${data.commercial.indexValue.toFixed(1)}`), - this.proMetricBox('Commercial YoY', this.formatPctTrend(data.commercial.yoyChange)), - ); - } - if (data.dsr) { - grid.append( - this.proMetricBox('Household DSR', `${data.dsr.dsrPct.toFixed(1)}%`), - this.proMetricBox('DSR QoQ', this.formatPctTrend(data.dsr.change)), - ); - } - this.housingBody.append(grid); - const src = data.residential?.period || data.commercial?.period || data.dsr?.period || ''; - if (src) { - const note = this.el('div', 'cdp-economic-source', `Source: BIS SDMX · latest ${src}`); - this.housingBody.append(note); - } - } - public updateNationalDebt(entry: { debtToGdp: number; debtUsd: number; annualGrowth: number; source: string } | null): void { if (!this.debtBody) return; this.debtBody.replaceChildren(); @@ -621,7 +570,7 @@ export class CountryDeepDivePanel implements CountryBriefPanel { if (!this.comtradeBody) return; this.comtradeBody.replaceChildren(); if (!flows || flows.length === 0) { - this.comtradeBody.append(this.makeEmpty('No data available')); + this.comtradeBody.append(this.makeEmpty('No trade flow data available')); return; } const table = this.el('table', 'cdp-pro-flow-table'); @@ -674,12 +623,7 @@ export class CountryDeepDivePanel implements CountryBriefPanel { this.costShockCalcBody.replaceChildren(); if (!data || (!data.sectors.length && !data.unavailableReason)) { - // Remove the card entirely to avoid showing an empty "Cost Shock" widget - // alongside the Trade Exposure sector table (issue #2973 bug 1). - // sectionCard() creates a .cdp-card (not .cdp-section-card); parentElement - // is the card wrapper. Matches the updateTradeExposure cleanup pattern. - this.costShockCalcBody.parentElement?.remove(); - this.costShockCalcBody = null; + this.costShockCalcBody.append(this.makeEmpty('No multi-sector cost shock data')); return; } @@ -831,34 +775,10 @@ export class CountryDeepDivePanel implements CountryBriefPanel { if (usd >= 1e12) return `$${(usd / 1e12).toFixed(1)}T`; if (usd >= 1e9) return `$${(usd / 1e9).toFixed(1)}B`; if (usd >= 1e6) return `$${(usd / 1e6).toFixed(1)}M`; - if (usd >= 1e3) return `$${(usd / 1e3).toFixed(1)}K`; - return `$${Math.round(usd).toLocaleString()}`; - } - - /** - * Format a USD value using the same scale as a reference value so row totals - * and supplier rows share a unit suffix (issue #2973 bug 5). - */ - private formatMoneyAtScale(usd: number, referenceUsd: number): string { - if (referenceUsd >= 1e12) return `$${(usd / 1e12).toFixed(2)}T`; - if (referenceUsd >= 1e9) return `$${(usd / 1e9).toFixed(2)}B`; - if (referenceUsd >= 1e6) return `$${(usd / 1e6).toFixed(2)}M`; - if (referenceUsd >= 1e3) return `$${(usd / 1e3).toFixed(2)}K`; return `$${Math.round(usd).toLocaleString()}`; } - /** - * Shared exposure-score color scale used by vuln header and row scores - * (issue #2973 bug 4). - */ - private static exposureScoreColor(score: number): string { - if (score >= 70) return 'var(--danger, #ef4444)'; - if (score > 30) return 'var(--warning, #f59e0b)'; - return 'var(--text-muted, #64748b)'; - } - - private formatPctTrend(pct: number | null | undefined): string { - if (pct == null || !Number.isFinite(pct)) return '\u2014'; + private formatPctTrend(pct: number): string { const sign = pct >= 0 ? '+' : ''; return `${sign}${pct.toFixed(1)}%`; } @@ -1537,9 +1457,7 @@ export class CountryDeepDivePanel implements CountryBriefPanel { this.tradeExposureBody.replaceChildren(); - const vulnScore = Math.round(data.vulnerabilityIndex); - const vulnDiv = this.el('div', 'cdp-vuln-index', `Vulnerability: ${vulnScore}/100`); - vulnDiv.style.color = CountryDeepDivePanel.exposureScoreColor(vulnScore); + const vulnDiv = this.el('div', 'cdp-vuln-index', `Vulnerability: ${Math.round(data.vulnerabilityIndex)}/100`); this.tradeExposureBody.append(vulnDiv); if (sectors && sectors.length > 0) { @@ -1571,8 +1489,9 @@ export class CountryDeepDivePanel implements CountryBriefPanel { const cpCell = this.el('td', 'cdp-chokepoint-name'); cpCell.textContent = s.primaryChokepointName; const scoreCell = this.el('td', 'cdp-exposure-score'); + const scoreColor = s.exposureScore >= 70 ? 'var(--danger, #ef4444)' : s.exposureScore > 30 ? 'var(--warning, #f59e0b)' : 'var(--text-muted, #64748b)'; scoreCell.textContent = `${s.exposureScore.toFixed(0)}`; - scoreCell.style.color = CountryDeepDivePanel.exposureScoreColor(s.exposureScore); + scoreCell.style.color = scoreColor; tr.append(sectorCell, cpCell, scoreCell); tbody.append(tr); @@ -1608,7 +1527,6 @@ export class CountryDeepDivePanel implements CountryBriefPanel { bar.style.width = `${Math.min(entry.exposureScore, 100)}%`; barWrap.append(bar); const pctCell = this.el('td', 'cdp-exposure-pct', `${entry.exposureScore.toFixed(1)}`); - pctCell.style.color = CountryDeepDivePanel.exposureScoreColor(entry.exposureScore); tr.append(nameCell, barWrap, pctCell); tbody.append(tr); } @@ -1760,7 +1678,7 @@ export class CountryDeepDivePanel implements CountryBriefPanel { if (!this.productImportsBody) return; this.productImportsBody.replaceChildren(); if (!data || data.products.length === 0) { - this.productImportsBody.append(this.makeEmpty('No data available')); + this.productImportsBody.append(this.makeEmpty('No product import data available')); return; } this.renderProductSelector(data.products); @@ -1888,7 +1806,7 @@ export class CountryDeepDivePanel implements CountryBriefPanel { shareTd.append(barWrap); tr.append(shareTd); - tr.append(this.el('td', 'cdp-product-val', this.formatMoneyAtScale(exp.value, product.totalValue))); + tr.append(this.el('td', 'cdp-product-val', this.formatMoney(exp.value))); const riskTd = this.el('td', 'cdp-product-risk'); if (exp.risk) { @@ -2028,7 +1946,7 @@ export class CountryDeepDivePanel implements CountryBriefPanel { trend, source: 'Market Service', }); - this.economicIndicators = base.slice(0, 6); + this.economicIndicators = base.slice(0, 3); this.renderEconomicIndicators(); } @@ -2204,10 +2122,6 @@ export class CountryDeepDivePanel implements CountryBriefPanel { const [militaryCard, militaryBody] = this.sectionCard(t('countryBrief.militaryActivity')); const [infraCard, infraBody] = this.sectionCard(t('countryBrief.infrastructure')); const [economicCard, economicBody] = this.sectionCard(t('countryBrief.economicIndicators')); - const [housingCard, housingBody] = this.sectionCard( - 'Housing Cycle', - 'BIS quarterly real residential and commercial property price indices plus household debt service ratio — early-warning signals for credit / property cycle turns.', - ); const [marketsCard, marketsBody] = this.sectionCard(t('countryBrief.predictionMarkets')); const [briefCard, briefBody] = this.sectionCard(t('countryBrief.intelBrief')); @@ -2268,7 +2182,6 @@ export class CountryDeepDivePanel implements CountryBriefPanel { this.militaryBody = militaryBody; this.infrastructureBody = infraBody; this.economicBody = economicBody; - this.housingBody = housingBody; this.marketsBody = marketsBody; this.briefBody = briefBody; @@ -2277,11 +2190,10 @@ export class CountryDeepDivePanel implements CountryBriefPanel { militaryBody.append(this.makeLoading('Loading flights, vessels, and nearby bases…')); infraBody.append(this.makeLoading('Computing nearby critical infrastructure…')); economicBody.append(this.makeLoading('Loading available indicators…')); - housingBody.append(this.makeLoading('Loading housing cycle data…')); marketsBody.append(this.makeLoading(t('countryBrief.loadingMarkets'))); briefBody.append(this.makeLoading(t('countryBrief.generatingBrief'))); - bodyGrid.append(briefCard, factsExpanded, energyCard, maritimeCard, tradeCard, costShockCalcCard, productImportsCard, debtCard, sanctionsCard, comtradeCard, tariffCard, signalsCard, timelineCard, newsCard, militaryCard, infraCard, economicCard, housingCard, marketsCard); + bodyGrid.append(briefCard, factsExpanded, energyCard, maritimeCard, tradeCard, costShockCalcCard, productImportsCard, debtCard, sanctionsCard, comtradeCard, tariffCard, signalsCard, timelineCard, newsCard, militaryCard, infraCard, economicCard, marketsCard); shell.append(header, summaryGrid, bodyGrid); this.content.append(shell); } @@ -2305,7 +2217,6 @@ export class CountryDeepDivePanel implements CountryBriefPanel { this.tradeExposureBody = null; this.productImportsBody = null; this.debtBody = null; - this.housingBody = null; this.sanctionsBody = null; this.comtradeBody = null; this.tariffBody = null; @@ -2331,8 +2242,8 @@ export class CountryDeepDivePanel implements CountryBriefPanel { const chips = this.el('div', 'cdp-signal-chips'); this.addSignalChip(chips, signals.criticalNews, t('countryBrief.chips.criticalNews'), '🚨', 'conflict'); this.addSignalChip(chips, signals.protests, t('countryBrief.chips.protests'), '📢', 'protest'); - this.addSignalChip(chips, signals.militaryFlights, t('countryBrief.chips.militaryAir'), '✈️', 'military', `${signals.militaryFlights} near · ${signals.militaryFlightsInCountry} inside borders`); - this.addSignalChip(chips, signals.militaryVessels, t('countryBrief.chips.navalVessels'), '⚓', 'military', `${signals.militaryVessels} near · ${signals.militaryVesselsInCountry} inside borders`); + this.addSignalChip(chips, signals.militaryFlights, t('countryBrief.chips.militaryAir'), '✈️', 'military'); + this.addSignalChip(chips, signals.militaryVessels, t('countryBrief.chips.navalVessels'), '⚓', 'military'); this.addSignalChip(chips, signals.outages, t('countryBrief.chips.outages'), '🌐', 'outage'); this.addSignalChip(chips, signals.aisDisruptions, t('countryBrief.chips.aisDisruptions'), '🚢', 'outage'); this.addSignalChip(chips, signals.satelliteFires, t('countryBrief.chips.satelliteFires'), '🔥', 'climate'); @@ -2376,15 +2287,13 @@ export class CountryDeepDivePanel implements CountryBriefPanel { this.signalRecentBody.append(this.makeLoading('Loading top high-severity signals…')); } - private addSignalChip(container: HTMLElement, count: number, label: string, icon: string, cls: string, tooltip?: string): void { + private addSignalChip(container: HTMLElement, count: number, label: string, icon: string, cls: string): void { if (count <= 0) return; - container.append(this.makeSignalChip(`${icon} ${count} ${label}`, cls, tooltip)); + container.append(this.makeSignalChip(`${icon} ${count} ${label}`, cls)); } - private makeSignalChip(text: string, cls: string, tooltip?: string): HTMLElement { - const chip = this.el('span', `cdp-signal-chip chip-${cls}`, text); - if (tooltip) chip.title = tooltip; - return chip; + private makeSignalChip(text: string, cls: string): HTMLElement { + return this.el('span', `cdp-signal-chip chip-${cls}`, text); } private renderComponentBars(components: CountryScore['components']): HTMLElement { @@ -2460,7 +2369,7 @@ export class CountryDeepDivePanel implements CountryBriefPanel { return; } - for (const indicator of this.economicIndicators.slice(0, 6)) { + for (const indicator of this.economicIndicators.slice(0, 3)) { const row = this.el('div', 'cdp-economic-item'); const top = this.el('div', 'cdp-economic-top'); const isMarketRow = indicator.label === 'Stock Index' || indicator.label === 'Weekly Momentum'; @@ -2568,8 +2477,10 @@ export class CountryDeepDivePanel implements CountryBriefPanel { return this.el('div', 'cdp-empty', text); } - private badge(text: string, className: string): HTMLElement { - return this.el('span', className, text); + private badge(text: string, className: string, title?: string): HTMLElement { + const el = this.el('span', className, text); + if (title) el.title = title; + return el; } private formatBrief(text: string, headlineCount = 0): string { diff --git a/src/locales/en.json b/src/locales/en.json index 5d8b46bf86..97d660e2f0 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -81,6 +81,7 @@ "predictionMarkets": "Prediction Markets", "loadingMarkets": "Loading prediction markets...", "infrastructure": "Infrastructure Exposure", + "tierBadgeTooltip": "Source reliability tier — Tier 1 = highest confidence, Tier 4 = emerging sources", "briefUnavailable": "AI brief unavailable — configure GROQ_API_KEY in Settings.", "cached": "Cached", "fresh": "Fresh", diff --git a/tests/mcp.test.mjs b/tests/mcp.test.mjs index 17d95cf0c5..38dca4827a 100644 --- a/tests/mcp.test.mjs +++ b/tests/mcp.test.mjs @@ -118,12 +118,12 @@ describe('api/mcp.ts — PRO MCP Server', () => { // --- tools/list --- - it('tools/list returns 32 tools with name, description, inputSchema', async () => { + it('tools/list returns 28 tools with name, description, inputSchema', async () => { const res = await handler(makeReq('POST', { jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} })); assert.equal(res.status, 200); const body = await res.json(); assert.ok(Array.isArray(body.result?.tools), 'result.tools must be an array'); - assert.equal(body.result.tools.length, 32, `Expected 32 tools, got ${body.result.tools.length}`); + assert.equal(body.result.tools.length, 29, `Expected 29 tools, got ${body.result.tools.length}`); for (const tool of body.result.tools) { assert.ok(tool.name, 'tool.name must be present'); assert.ok(tool.description, 'tool.description must be present');