diff --git a/docs/api/AviationService.openapi.yaml b/docs/api/AviationService.openapi.yaml index 0b67a32e4..dc56d1e25 100644 --- a/docs/api/AviationService.openapi.yaml +++ b/docs/api/AviationService.openapi.yaml @@ -174,6 +174,7 @@ components: - FLIGHT_DELAY_TYPE_DEPARTURE_DELAY - FLIGHT_DELAY_TYPE_ARRIVAL_DELAY - FLIGHT_DELAY_TYPE_GENERAL + - FLIGHT_DELAY_TYPE_CLOSURE description: FlightDelayType represents the type of flight delay. severity: type: string diff --git a/docs/api/ConflictService.openapi.yaml b/docs/api/ConflictService.openapi.yaml index 4fd75b7b3..1dc68d292 100644 --- a/docs/api/ConflictService.openapi.yaml +++ b/docs/api/ConflictService.openapi.yaml @@ -156,6 +156,32 @@ paths: application/json: schema: $ref: '#/components/schemas/Error' + /api/conflict/v1/list-iran-events: + get: + tags: + - ConflictService + summary: ListIranEvents + description: ListIranEvents retrieves scraped conflict events from LiveUAMap Iran. + operationId: ListIranEvents + responses: + "200": + description: Successful response + content: + application/json: + schema: + $ref: '#/components/schemas/ListIranEventsResponse' + "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: @@ -422,3 +448,39 @@ components: format: int64 description: 'Last data update time, as Unix epoch milliseconds.. Warning: Values > 2^53 may lose precision in JavaScript' description: HumanitarianCountrySummary represents HAPI conflict event counts for a country. + ListIranEventsRequest: + type: object + ListIranEventsResponse: + type: object + properties: + events: + type: array + items: + $ref: '#/components/schemas/IranEvent' + scrapedAt: + type: string + format: int64 + IranEvent: + type: object + properties: + id: + type: string + title: + type: string + category: + type: string + sourceUrl: + type: string + latitude: + type: number + format: double + longitude: + type: number + format: double + locationName: + type: string + timestamp: + type: string + format: int64 + severity: + type: string diff --git a/docs/api/IntelligenceService.openapi.yaml b/docs/api/IntelligenceService.openapi.yaml index a96735cd2..bbc53c4dc 100644 --- a/docs/api/IntelligenceService.openapi.yaml +++ b/docs/api/IntelligenceService.openapi.yaml @@ -194,6 +194,38 @@ paths: application/json: schema: $ref: '#/components/schemas/Error' + /api/intelligence/v1/deduct-situation: + post: + tags: + - IntelligenceService + summary: DeductSituation + description: DeductSituation deducts the future situation based on query and context using an LLM. + operationId: DeductSituation + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/DeductSituationRequest' + required: true + responses: + "200": + description: Successful response + content: + application/json: + schema: + $ref: '#/components/schemas/DeductSituationResponse' + "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: @@ -660,3 +692,19 @@ components: format: double description: GDELT tone score (negative = negative tone, positive = positive tone). description: GdeltArticle represents a single article from the GDELT document API. + DeductSituationRequest: + type: object + properties: + query: + type: string + geoContext: + type: string + DeductSituationResponse: + type: object + properties: + analysis: + type: string + model: + type: string + provider: + type: string diff --git a/e2e/deduct-situation.spec.ts b/e2e/deduct-situation.spec.ts new file mode 100644 index 000000000..390d29af1 --- /dev/null +++ b/e2e/deduct-situation.spec.ts @@ -0,0 +1,50 @@ +import { expect, test } from '@playwright/test'; + +test.describe('Deduct Situation Panel Options', () => { + test('It successfully requests deduction from the intelligence API', async ({ page }) => { + await page.goto('/?view=global'); + + // MOCK the backend deduct-situation RPC response UNLESS testing real LLM flows + if (!process.env.TEST_REAL_LLM) { + await page.route('**/api/intelligence/v1/deduct-situation', async (route) => { + const json = { + analysis: '### Mocked AI Analysis\n- This is a simulated response.\n- Situation is stable.', + model: 'mocked-e2e-model', + provider: 'groq', + }; + await route.fulfill({ json }); + }); + } + + // Open CMD palette and search for deduction panel + await page.keyboard.press('ControlOrMeta+k'); + await page.waitForSelector('.command-palette'); + await page.fill('.command-palette input', 'deduct'); + await page.click('text="Jump to Deduct Situation"'); + + // Ensure the panel is visible and ready + const panel = page.locator('.wm-panel', { hasText: 'DEDUCT SITUATION' }); + await expect(panel).toBeVisible(); + + // Fill in the text area query + const textarea = panel.locator('textarea').first(); + await textarea.fill('What is the geopolitical status of the Pacific?'); + + // Click analyze + const analyzeBtn = panel.locator('button', { hasText: 'Analyze' }); + await analyzeBtn.click(); + + // Verify loading state + await expect(panel.locator('text="Analyzing timeline and impact..."')).toBeVisible(); + + // Verify the resolved output is rendered + if (!process.env.TEST_REAL_LLM) { + await expect(panel.locator('text="Mocked AI Analysis"')).toBeVisible({ timeout: 10000 }); + await expect(panel.locator('text="Situation is stable."')).toBeVisible(); + } else { + // If testing against a real local LLM or cloud, just expect some markdown output block to appear + // The API might take a while depending on local hardware / provider limits + await expect(panel.locator('.deduction-result')).not.toBeEmpty({ timeout: 30000 }); + } + }); +}); diff --git a/package-lock.json b/package-lock.json index 5c34c0018..375e87167 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,10 +23,12 @@ "convex": "^1.32.0", "d3": "^7.9.0", "deck.gl": "^9.2.6", + "dompurify": "^3.1.7", "fast-xml-parser": "^5.3.7", "i18next": "^25.8.10", "i18next-browser-languagedetector": "^8.2.1", "maplibre-gl": "^5.16.0", + "marked": "^17.0.3", "onnxruntime-web": "^1.23.2", "papaparse": "^5.5.3", "telegram": "^2.26.22", @@ -39,7 +41,9 @@ "@tauri-apps/cli": "^2.10.0", "@types/canvas-confetti": "^1.9.0", "@types/d3": "^7.4.3", + "@types/dompurify": "^3.0.5", "@types/maplibre-gl": "^1.13.2", + "@types/marked": "^5.0.2", "@types/papaparse": "^5.5.2", "@types/topojson-client": "^3.1.5", "@types/topojson-specification": "^1.0.5", @@ -130,6 +134,19 @@ "tslib": "^2.8.1" } }, + "node_modules/@arcgis/core/node_modules/marked": { + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/marked/-/marked-16.3.0.tgz", + "integrity": "sha512-K3UxuKu6l6bmA5FUwYho8CfJBlsUWAooKtdGgMcERSpF7gcBUrCGsLH7wDaaNOzwq18JzSUDyoEb/YsrqMac3w==", + "license": "MIT", + "peer": true, + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/@arcgis/lumina": { "version": "4.34.9", "resolved": "https://registry.npmjs.org/@arcgis/lumina/-/lumina-4.34.9.tgz", @@ -4596,6 +4613,16 @@ "@types/ms": "*" } }, + "node_modules/@types/dompurify": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz", + "integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/trusted-types": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -4647,6 +4674,13 @@ "@types/geojson": "*" } }, + "node_modules/@types/marked": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@types/marked/-/marked-5.0.2.tgz", + "integrity": "sha512-OucS4KMHhFzhz27KxmWg7J+kIYqyqoW5kdIEI319hqARQQUTqhao3M/F+uFnDXD0Rg72iDDZxZNxq5gvctmLlg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/ms": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", @@ -7333,6 +7367,20 @@ "url": "https://github.com/fb55/domutils?sponsor=1" } }, + "node_modules/domutils": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz", + "integrity": "sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^1.0.1", + "domelementtype": "^2.2.0", + "domhandler": "^4.2.0" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, "node_modules/draco3d": { "version": "1.5.7", "resolved": "https://registry.npmjs.org/draco3d/-/draco3d-1.5.7.tgz", @@ -9569,11 +9617,10 @@ } }, "node_modules/marked": { - "version": "16.3.0", - "resolved": "https://registry.npmjs.org/marked/-/marked-16.3.0.tgz", - "integrity": "sha512-K3UxuKu6l6bmA5FUwYho8CfJBlsUWAooKtdGgMcERSpF7gcBUrCGsLH7wDaaNOzwq18JzSUDyoEb/YsrqMac3w==", + "version": "17.0.3", + "resolved": "https://registry.npmjs.org/marked/-/marked-17.0.3.tgz", + "integrity": "sha512-jt1v2ObpyOKR8p4XaUJVk3YWRJ5n+i4+rjQopxvV32rSndTJXvIzuUdWWIy/1pFQMkQmvTXawzDNqOH/CUmx6A==", "license": "MIT", - "peer": true, "bin": { "marked": "bin/marked.js" }, diff --git a/package.json b/package.json index 832040d7b..4486e052e 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,8 @@ "@tauri-apps/cli": "^2.10.0", "@types/canvas-confetti": "^1.9.0", "@types/d3": "^7.4.3", + "@types/dompurify": "^3.0.5", + "@types/marked": "^5.0.2", "@types/maplibre-gl": "^1.13.2", "@types/papaparse": "^5.5.2", "@types/topojson-client": "^3.1.5", @@ -85,10 +87,12 @@ "convex": "^1.32.0", "d3": "^7.9.0", "deck.gl": "^9.2.6", + "dompurify": "^3.1.7", "fast-xml-parser": "^5.3.7", "i18next": "^25.8.10", "i18next-browser-languagedetector": "^8.2.1", "maplibre-gl": "^5.16.0", + "marked": "^17.0.3", "onnxruntime-web": "^1.23.2", "papaparse": "^5.5.3", "telegram": "^2.26.22", diff --git a/proto/worldmonitor/intelligence/v1/deduct_situation.proto b/proto/worldmonitor/intelligence/v1/deduct_situation.proto new file mode 100644 index 000000000..d0eac1d52 --- /dev/null +++ b/proto/worldmonitor/intelligence/v1/deduct_situation.proto @@ -0,0 +1,14 @@ +syntax = "proto3"; + +package worldmonitor.intelligence.v1; + +message DeductSituationRequest { + string query = 1; + string geo_context = 2; +} + +message DeductSituationResponse { + string analysis = 1; + string model = 2; + string provider = 3; +} diff --git a/proto/worldmonitor/intelligence/v1/service.proto b/proto/worldmonitor/intelligence/v1/service.proto index 466e0b8c0..6c920af9f 100644 --- a/proto/worldmonitor/intelligence/v1/service.proto +++ b/proto/worldmonitor/intelligence/v1/service.proto @@ -8,6 +8,7 @@ import "worldmonitor/intelligence/v1/get_pizzint_status.proto"; import "worldmonitor/intelligence/v1/classify_event.proto"; import "worldmonitor/intelligence/v1/get_country_intel_brief.proto"; import "worldmonitor/intelligence/v1/search_gdelt_documents.proto"; +import "worldmonitor/intelligence/v1/deduct_situation.proto"; // IntelligenceService provides APIs for cross-domain intelligence synthesis including // risk scores, PizzINT monitoring, GDELT tension analysis, and AI-powered classification. diff --git a/server/worldmonitor/intelligence/v1/deduct-situation.ts b/server/worldmonitor/intelligence/v1/deduct-situation.ts new file mode 100644 index 000000000..bc8e2111d --- /dev/null +++ b/server/worldmonitor/intelligence/v1/deduct-situation.ts @@ -0,0 +1,106 @@ +declare const process: { env: Record }; + +import type { + ServerContext, + DeductSituationRequest, + DeductSituationResponse, +} from '../../../../src/generated/server/worldmonitor/intelligence/v1/service_server'; + +import { cachedFetchJson } from '../../../_shared/redis'; +import { hashString } from './_shared'; +import { CHROME_UA } from '../../../_shared/constants'; + +const DEDUCT_TIMEOUT_MS = 120_000; +const DEDUCT_CACHE_TTL = 3600; +const DEFAULT_API_URL = 'https://api.groq.com/openai/v1/chat/completions'; +const DEFAULT_MODEL = 'llama-3.1-8b-instant'; + +export async function deductSituation( + _ctx: ServerContext, + req: DeductSituationRequest, +): Promise { + const apiKey = process.env.LLM_API_KEY || process.env.GROQ_API_KEY; + const apiUrl = process.env.LLM_API_URL || DEFAULT_API_URL; + const model = process.env.LLM_MODEL || DEFAULT_MODEL; + + if (!apiKey) { + return { analysis: '', model: '', provider: 'skipped' }; + } + + const MAX_QUERY_LEN = 500; + const MAX_GEO_LEN = 2000; + + const query = typeof req.query === 'string' ? req.query.slice(0, MAX_QUERY_LEN).trim() : ''; + const geoContext = typeof req.geoContext === 'string' ? req.geoContext.slice(0, MAX_GEO_LEN).trim() : ''; + + if (!query) return { analysis: '', model: '', provider: 'skipped' }; + + const cacheKey = `deduct:situation:v1:${hashString(query.toLowerCase() + '|' + geoContext.toLowerCase())}`; + + const cached = await cachedFetchJson<{ analysis: string; model: string; provider: string }>( + cacheKey, + DEDUCT_CACHE_TTL, + async () => { + try { + const systemPrompt = `You are a senior geopolitical intelligence analyst and forecaster. +Your task is to DEDUCT the situation in a near timeline (e.g. 24 hours to a few months) based on the user's query. +- Use any provided geographic or intelligence context. +- Be highly analytical, pragmatic, and objective. +- Identify the most likely outcomes, timelines, and second-order impacts. +- Do NOT use typical AI preambles (e.g., "Here is the deduction", "Let me see"). +- Format your response in clean markdown with concise bullet points where appropriate.`; + + let userPrompt = query; + if (geoContext) { + userPrompt += `\n\n### Current Intelligence Context\n${geoContext}`; + } + + const resp = await fetch(apiUrl, { + method: 'POST', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + 'User-Agent': CHROME_UA + }, + body: JSON.stringify({ + model, + messages: [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: userPrompt }, + ], + temperature: 0.3, + max_tokens: 1500, + }), + signal: AbortSignal.timeout(DEDUCT_TIMEOUT_MS), + }); + + if (!resp.ok) return null; + const data = (await resp.json()) as { choices?: Array<{ message?: { content?: string } }> }; + const firstChoice = data.choices?.[0]; + + const content = firstChoice?.message?.content?.trim(); + const reasoning = (firstChoice?.message as any)?.reasoning?.trim(); + + let raw = content || reasoning; + if (!raw) return null; + + raw = raw.replace(/[\s\S]*?<\/think>/gi, '').trim(); + + return { analysis: raw, model, provider: 'groq' }; + } catch (err) { + console.error('[DeductSituation] Error calling LLM:', err); + return null; + } + } + ); + + if (!cached?.analysis) { + return { analysis: '', model: '', provider: 'error' }; + } + + return { + analysis: cached.analysis, + model: cached.model, + provider: cached.provider, + }; +} diff --git a/server/worldmonitor/intelligence/v1/handler.ts b/server/worldmonitor/intelligence/v1/handler.ts index 391599f77..297a4b67e 100644 --- a/server/worldmonitor/intelligence/v1/handler.ts +++ b/server/worldmonitor/intelligence/v1/handler.ts @@ -5,6 +5,7 @@ import { getPizzintStatus } from './get-pizzint-status'; import { classifyEvent } from './classify-event'; import { getCountryIntelBrief } from './get-country-intel-brief'; import { searchGdeltDocuments } from './search-gdelt-documents'; +import { deductSituation } from './deduct-situation'; export const intelligenceHandler: IntelligenceServiceHandler = { getRiskScores, @@ -12,4 +13,5 @@ export const intelligenceHandler: IntelligenceServiceHandler = { classifyEvent, getCountryIntelBrief, searchGdeltDocuments, + deductSituation, }; diff --git a/server/worldmonitor/positive-events/v1/list-positive-geo-events.ts b/server/worldmonitor/positive-events/v1/list-positive-geo-events.ts index f57a819c7..eb0620148 100644 --- a/server/worldmonitor/positive-events/v1/list-positive-geo-events.ts +++ b/server/worldmonitor/positive-events/v1/list-positive-geo-events.ts @@ -12,6 +12,7 @@ import type { import { classifyNewsItem } from '../../../../src/services/positive-classifier'; import { cachedFetchJson } from '../../../_shared/redis'; +import { markNoCacheResponse } from '../../../_shared/response-headers'; const GDELT_GEO_URL = 'https://api.gdeltproject.org/api/v2/geo/geo'; @@ -85,7 +86,7 @@ async function fetchGdeltGeoPositive(query: string): Promise } export async function listPositiveGeoEvents( - _ctx: ServerContext, + ctx: ServerContext, _req: ListPositiveGeoEventsRequest, ): Promise { try { @@ -117,6 +118,7 @@ export async function listPositiveGeoEvents( }); return result || { events: [] }; } catch { + markNoCacheResponse(ctx.request); return { events: [] }; } } diff --git a/src/app/country-intel.ts b/src/app/country-intel.ts index 5576f80d4..0ce5f6877 100644 --- a/src/app/country-intel.ts +++ b/src/app/country-intel.ts @@ -423,7 +423,8 @@ export class CountryIntelManager implements AppModule { } for (const e of this.getCountryStrikes(code, hasGeoShape)) { - const ts = e.timestamp < 1e12 ? e.timestamp * 1000 : e.timestamp; + const rawTs = Number(e.timestamp) || 0; + const ts = rawTs < 1e12 ? rawTs * 1000 : rawTs; events.push({ timestamp: ts, lane: 'conflict', diff --git a/src/app/data-loader.ts b/src/app/data-loader.ts index 5c51831e6..a9b0dccf8 100644 --- a/src/app/data-loader.ts +++ b/src/app/data-loader.ts @@ -605,7 +605,50 @@ export class DataLoaderManager implements AppModule { return items; } - // Fallback path when digest is unavailable: stale-first, then limited per-feed fan-out. + // Digest branch: server already aggregated feeds — map proto items to client types + if (digest?.categories && category in digest.categories) { + const enabledNames = new Set(enabledFeeds.map(f => f.name)); + let items = (digest.categories[category]?.items ?? []) + .map(protoItemToNewsItem) + .filter(i => enabledNames.has(i.source)); + + ingestHeadlines(items.map(i => ({ title: i.title, pubDate: i.pubDate, source: i.source, link: i.link }))); + + const aiCandidates = items + .filter(i => i.threat?.source === 'keyword') + .sort((a, b) => b.pubDate.getTime() - a.pubDate.getTime()) + .slice(0, AI_CLASSIFY_MAX_PER_FEED); + for (const item of aiCandidates) { + if (!canQueueAiClassification(item.title)) continue; + classifyWithAI(item.title, SITE_VARIANT).then(ai => { + if (ai && item.threat && ai.confidence > item.threat.confidence) { + item.threat = ai; + item.isAlert = ai.level === 'critical' || ai.level === 'high'; + } + }).catch(() => {}); + } + + checkBatchForBreakingAlerts(items); + this.flashMapForNews(items); + this.renderNewsForCategory(category, items); + + this.ctx.statusPanel?.updateFeed(category.charAt(0).toUpperCase() + category.slice(1), { + status: 'ok', + itemCount: items.length, + }); + + if (panel) { + try { + const baseline = await updateBaseline(`news:${category}`, items.length); + const deviation = calculateDeviation(items.length, baseline); + panel.setDeviation(deviation.zScore, deviation.percentChange, deviation.level); + } catch (e) { console.warn(`[Baseline] news:${category} write failed:`, e); } + } + + return items; + } + + // Per-feed fallback: fetch each feed individually (first load or digest unavailable) const renderIntervalMs = 100; let lastRenderTime = 0; let renderTimeout: ReturnType | null = null; @@ -1458,8 +1501,9 @@ export class DataLoaderManager implements AppModule { this.ctx.intelligenceCache.iranEvents = events; this.ctx.map?.setIranEvents(events); this.ctx.map?.setLayerReady('iranAttacks', events.length > 0); - signalAggregator.ingestConflictEvents(events); - ingestStrikesForCII(events); + const coerced = events.map(e => ({ ...e, timestamp: Number(e.timestamp) || 0 })); + signalAggregator.ingestConflictEvents(coerced); + ingestStrikesForCII(coerced); (this.ctx.panels['cii'] as CIIPanel)?.refresh(); } catch { this.ctx.map?.setLayerReady('iranAttacks', false); diff --git a/src/app/panel-layout.ts b/src/app/panel-layout.ts index 0d093ba27..bb677d58e 100644 --- a/src/app/panel-layout.ts +++ b/src/app/panel-layout.ts @@ -43,6 +43,7 @@ import { PositiveNewsFeedPanel } from '@/components/PositiveNewsFeedPanel'; import { CountersPanel } from '@/components/CountersPanel'; import { ProgressChartsPanel } from '@/components/ProgressChartsPanel'; import { BreakthroughsTickerPanel } from '@/components/BreakthroughsTickerPanel'; +import { DeductionPanel } from '@/components/DeductionPanel'; import { HeroSpotlightPanel } from '@/components/HeroSpotlightPanel'; import { GoodThingsDigestPanel } from '@/components/GoodThingsDigestPanel'; import { SpeciesComebackPanel } from '@/components/SpeciesComebackPanel'; @@ -500,6 +501,11 @@ export class PanelLayoutManager implements AppModule { const gdeltIntelPanel = new GdeltIntelPanel(); this.ctx.panels['gdelt-intel'] = gdeltIntelPanel; + if (this.ctx.isDesktopApp) { + const deductionPanel = new DeductionPanel(() => this.ctx.allNews); + this.ctx.panels['deduction'] = deductionPanel; + } + const ciiPanel = new CIIPanel(); ciiPanel.setShareStoryHandler((code, name) => { this.callbacks.openCountryStory(code, name); @@ -518,7 +524,7 @@ export class PanelLayoutManager implements AppModule { }); this.ctx.panels['strategic-risk'] = strategicRiskPanel; - const strategicPosturePanel = new StrategicPosturePanel(); + const strategicPosturePanel = new StrategicPosturePanel(() => this.ctx.allNews); strategicPosturePanel.setLocationClickHandler((lat, lon) => { console.log('[App] StrategicPosture handler called:', { lat, lon, hasMap: !!this.ctx.map }); this.ctx.map?.setCenter(lat, lon, 4); @@ -581,7 +587,7 @@ export class PanelLayoutManager implements AppModule { const liveWebcamsPanel = new LiveWebcamsPanel(); this.ctx.panels['live-webcams'] = liveWebcamsPanel; - this.ctx.panels['events'] = new TechEventsPanel('events'); + this.ctx.panels['events'] = new TechEventsPanel('events', () => this.ctx.allNews); const serviceStatusPanel = new ServiceStatusPanel(); this.ctx.panels['service-status'] = serviceStatusPanel; diff --git a/src/components/DeckGLMap.ts b/src/components/DeckGLMap.ts index 94d315df3..9140e97de 100644 --- a/src/components/DeckGLMap.ts +++ b/src/components/DeckGLMap.ts @@ -1015,6 +1015,15 @@ export class DeckGLMap { this.layerCache.delete('day-night-layer'); } + // Day/night overlay (rendered first as background) + if (mapLayers.dayNight) { + if (!this.dayNightIntervalId) this.startDayNightTimer(); + layers.push(this.createDayNightLayer()); + } else { + if (this.dayNightIntervalId) this.stopDayNightTimer(); + this.layerCache.delete('day-night-layer'); + } + // Undersea cables layer if (mapLayers.cables) { layers.push(this.createCablesLayer()); @@ -3089,7 +3098,7 @@ export class DeckGLMap { const normalizedLoc = data.locationName.trim().toLowerCase(); const related = this.iranEvents .filter(e => e.id !== clickedId && e.locationName && e.locationName.trim().toLowerCase() === normalizedLoc) - .sort((a, b) => (b.timestamp || 0) - (a.timestamp || 0)) + .sort((a, b) => (Number(b.timestamp) || 0) - (Number(a.timestamp) || 0)) .slice(0, 5); data = { ...data, relatedEvents: related }; } diff --git a/src/components/DeductionPanel.ts b/src/components/DeductionPanel.ts new file mode 100644 index 000000000..e72ae765e --- /dev/null +++ b/src/components/DeductionPanel.ts @@ -0,0 +1,165 @@ +import { Panel } from './Panel'; +import { IntelligenceServiceClient } from '@/generated/client/worldmonitor/intelligence/v1/service_client'; +import { h, replaceChildren } from '@/utils/dom-utils'; +import { marked } from 'marked'; +import DOMPurify from 'dompurify'; +import type { NewsItem, DeductContextDetail } from '@/types'; +import { buildNewsContext } from '@/utils/news-context'; + +const client = new IntelligenceServiceClient('', { fetch: (...args) => globalThis.fetch(...args) }); + +const COOLDOWN_MS = 5_000; + +export class DeductionPanel extends Panel { + private formEl: HTMLFormElement; + private inputEl: HTMLTextAreaElement; + private geoInputEl: HTMLInputElement; + private resultContainer: HTMLElement; + private submitBtn: HTMLButtonElement; + private isSubmitting = false; + private getLatestNews?: () => NewsItem[]; + private contextHandler: EventListener; + + constructor(getLatestNews?: () => NewsItem[]) { + super({ + id: 'deduction', + title: 'Deduct Situation', + infoTooltip: 'Use AI intelligence to deduct the timeline and impact of a hypothetical or current event.', + }); + + this.getLatestNews = getLatestNews; + + this.inputEl = h('textarea', { + className: 'deduction-input', + placeholder: 'E.g., What will possibly happen in the next 24 hours in Middle East?', + required: true, + rows: 3, + }) as HTMLTextAreaElement; + + this.geoInputEl = h('input', { + className: 'deduction-geo-input', + type: 'text', + placeholder: 'Optional geographic or situation context...', + }) as HTMLInputElement; + + this.submitBtn = h('button', { + className: 'deduction-submit-btn', + type: 'submit', + }, 'Analyze') as HTMLButtonElement; + + this.formEl = h('form', { className: 'deduction-form' }, + this.inputEl, + this.geoInputEl, + this.submitBtn + ) as HTMLFormElement; + + this.formEl.addEventListener('submit', this.handleSubmit.bind(this)); + + this.resultContainer = h('div', { className: 'deduction-result' }); + + const container = h('div', { className: 'deduction-panel-content' }, + this.formEl, + this.resultContainer + ); + + replaceChildren(this.content, container); + + if (!document.getElementById('deduction-panel-styles')) { + const style = document.createElement('style'); + style.id = 'deduction-panel-styles'; + style.textContent = ` + .deduction-panel-content { display: flex; flex-direction: column; gap: 12px; padding: 8px; height: 100%; overflow-y: auto; } + .deduction-form { display: flex; flex-direction: column; gap: 8px; } + .deduction-input, .deduction-geo-input { width: 100%; padding: 8px; background: var(--bg-secondary, #2a2a2a); border: 1px solid var(--border-color, #444); color: var(--text-primary, #fff); border-radius: 4px; font-family: inherit; resize: vertical; } + .deduction-submit-btn { padding: 8px 16px; background: var(--accent-color, #3b82f6); color: white; border: none; border-radius: 4px; cursor: pointer; align-self: flex-end; font-weight: 500; } + .deduction-submit-btn:hover { background: var(--accent-hover, #2563eb); } + .deduction-submit-btn:disabled { opacity: 0.5; cursor: not-allowed; } + .deduction-result { flex: 1; margin-top: 8px; line-height: 1.5; font-size: 0.9em; color: var(--text-primary, #ddd); } + .deduction-result.loading { opacity: 0.7; font-style: italic; } + .deduction-result.error { color: var(--semantic-critical, #ef4444); } + .deduction-result h3 { margin-top: 12px; margin-bottom: 4px; font-size: 1.1em; color: var(--text-bright, #fff); } + .deduction-result ul { padding-left: 20px; margin-top: 4px; } + .deduction-result li { margin-bottom: 4px; } + `; + document.head.appendChild(style); + } + + this.contextHandler = ((e: CustomEvent) => { + const { query, geoContext, autoSubmit } = e.detail; + + if (query) { + this.inputEl.value = query; + } + if (geoContext) { + this.geoInputEl.value = geoContext; + } + + this.show(); + + this.element.animate([ + { backgroundColor: 'var(--accent-hover, #2563eb)' }, + { backgroundColor: 'transparent' } + ], { duration: 800, easing: 'ease-out' }); + + if (autoSubmit && this.inputEl.value && !this.submitBtn.disabled) { + this.formEl.requestSubmit(); + } + }) as EventListener; + document.addEventListener('wm:deduct-context', this.contextHandler); + } + + public override destroy(): void { + document.removeEventListener('wm:deduct-context', this.contextHandler); + super.destroy(); + } + + private async handleSubmit(e: Event) { + e.preventDefault(); + if (this.isSubmitting) return; + + const query = this.inputEl.value.trim(); + if (!query) return; + + let geoContext = this.geoInputEl.value.trim(); + + if (this.getLatestNews && !geoContext.includes('Recent News:')) { + const newsCtx = buildNewsContext(this.getLatestNews); + if (newsCtx) { + geoContext = geoContext ? `${geoContext}\n\n${newsCtx}` : newsCtx; + } + } + + this.isSubmitting = true; + this.submitBtn.disabled = true; + + this.resultContainer.className = 'deduction-result loading'; + this.resultContainer.textContent = 'Analyzing timeline and impact...'; + + try { + const resp = await client.deductSituation({ + query, + geoContext, + }); + + this.resultContainer.className = 'deduction-result'; + if (resp.analysis) { + const parsed = await marked.parse(resp.analysis); + this.resultContainer.innerHTML = DOMPurify.sanitize(parsed); + + const meta = h('div', { style: 'margin-top: 12px; font-size: 0.75em; color: #888;' }, + `Generated by ${resp.provider || 'AI'}${resp.model ? ` (${resp.model})` : ''}` + ); + this.resultContainer.appendChild(meta); + } else { + this.resultContainer.textContent = 'No analysis available for this query.'; + } + } catch (err) { + console.error('[DeductionPanel] Error:', err); + this.resultContainer.className = 'deduction-result error'; + this.resultContainer.textContent = 'An error occurred while analyzing the situation.'; + } finally { + this.isSubmitting = false; + setTimeout(() => { this.submitBtn.disabled = false; }, COOLDOWN_MS); + } + } +} diff --git a/src/components/MapPopup.ts b/src/components/MapPopup.ts index 8b452bb25..922ca6a84 100644 --- a/src/components/MapPopup.ts +++ b/src/components/MapPopup.ts @@ -68,7 +68,7 @@ interface IranEventPopupData { latitude: number; longitude: number; locationName: string; - timestamp: number; + timestamp: string | number; severity: string; relatedEvents?: IranEventPopupData[]; } diff --git a/src/components/StrategicPosturePanel.ts b/src/components/StrategicPosturePanel.ts index c66156d6c..e005d167b 100644 --- a/src/components/StrategicPosturePanel.ts +++ b/src/components/StrategicPosturePanel.ts @@ -3,7 +3,10 @@ import { escapeHtml } from '@/utils/sanitize'; import { fetchCachedTheaterPosture, type CachedTheaterPosture } from '@/services/cached-theater-posture'; import { fetchMilitaryVessels } from '@/services/military-vessels'; import { recalcPostureWithVessels, type TheaterPostureSummary } from '@/services/military-surge'; +import { isDesktopRuntime } from '@/services/runtime'; import { t } from '../services/i18n'; +import type { NewsItem, DeductContextDetail } from '@/types'; +import { buildNewsContext } from '@/utils/news-context'; export class StrategicPosturePanel extends Panel { private postures: TheaterPostureSummary[] = []; @@ -14,7 +17,7 @@ export class StrategicPosturePanel extends Panel { private lastTimestamp: string = ''; private isStale: boolean = false; - constructor() { + constructor(private getLatestNews?: () => NewsItem[]) { super({ id: 'strategic-posture', title: t('panels.strategicPosture'), @@ -432,6 +435,7 @@ export class StrategicPosturePanel extends Panel { ${p.strikeCapable ? `⚡ ${t('components.strategicPosture.strike')}` : ''} ${this.getTrendIcon(p.trend, p.changePercent)} ${p.targetNation ? `→ ${escapeHtml(p.targetNation)}` : ''} + ${isDesktopRuntime() ? `` : ''} `; @@ -475,7 +479,12 @@ export class StrategicPosturePanel extends Panel { const theaters = this.content.querySelectorAll('.posture-theater'); theaters.forEach((el) => { - el.addEventListener('click', () => { + el.addEventListener('click', (e) => { + // Prevent click if we clicked the deduce button specifically + if ((e.target as HTMLElement).closest('.posture-deduce-btn')) { + return; + } + const lat = parseFloat((el as HTMLElement).dataset.lat || '0'); const lon = parseFloat((el as HTMLElement).dataset.lon || '0'); console.log('[StrategicPosturePanel] Theater clicked:', { @@ -498,6 +507,31 @@ export class StrategicPosturePanel extends Panel { } }); }); + + const deduceBtns = this.content.querySelectorAll('.posture-deduce-btn'); + deduceBtns.forEach((btn) => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + try { + const theaterDataStr = (btn as HTMLElement).dataset.theater; + if (!theaterDataStr) return; + + const p = JSON.parse(theaterDataStr); + const query = `What is the expected strategic impact of the current military posture in the ${p.shortName} theater?`; + let geoContext = `Theater: ${p.shortName} (${p.theaterName}). Military Assets: ${p.totalAircraft} aircraft, ${p.totalVessels} naval vessels. Readiness Level: ${p.postureLevel}. Assets breakdown: ${p.fighters} fighters, ${p.bombers} bombers, ${p.carriers} carriers, ${p.submarines} submarines. Focus/Target: ${p.targetNation || 'Unknown'}.`; + + if (this.getLatestNews) { + const newsCtx = buildNewsContext(this.getLatestNews); + if (newsCtx) geoContext += `\n\n${newsCtx}`; + } + + const detail: DeductContextDetail = { query, geoContext, autoSubmit: true }; + document.dispatchEvent(new CustomEvent('wm:deduct-context', { detail })); + } catch (err) { + console.error('[StrategicPosturePanel] Failed to dispatch deduction event', err); + } + }); + }); } public setLocationClickHandler(handler: (lat: number, lon: number) => void): void { diff --git a/src/components/TechEventsPanel.ts b/src/components/TechEventsPanel.ts index 2f9670e49..9e1856a8b 100644 --- a/src/components/TechEventsPanel.ts +++ b/src/components/TechEventsPanel.ts @@ -2,8 +2,11 @@ import { Panel } from './Panel'; import { t } from '@/services/i18n'; import { sanitizeUrl } from '@/utils/sanitize'; import { h, replaceChildren } from '@/utils/dom-utils'; +import { isDesktopRuntime } from '@/services/runtime'; import { ResearchServiceClient } from '@/generated/client/worldmonitor/research/v1/service_client'; import type { TechEvent } from '@/generated/client/worldmonitor/research/v1/service_client'; +import type { NewsItem, DeductContextDetail } from '@/types'; +import { buildNewsContext } from '@/utils/news-context'; type ViewMode = 'upcoming' | 'conferences' | 'earnings' | 'all'; @@ -15,7 +18,7 @@ export class TechEventsPanel extends Panel { private loading = true; private error: string | null = null; - constructor(id: string) { + constructor(id: string, private getLatestNews?: () => NewsItem[]) { super({ id, title: t('panels.events'), showCount: true }); this.element.classList.add('panel-tall'); void this.fetchEvents(); @@ -202,6 +205,29 @@ export class TechEventsPanel extends Panel { event.location ? h('span', { className: 'event-location' }, event.location) : false, + isDesktopRuntime() ? h('button', { + className: 'event-deduce-link', + title: 'Deduce Situation with AI', + style: 'background: none; border: none; cursor: pointer; opacity: 0.7; font-size: 1.1em; transition: opacity 0.2s; margin-left: auto; padding-right: 4px;', + onClick: (e: Event) => { + e.preventDefault(); + e.stopPropagation(); + + let geoContext = `Event details: ${event.title} (${event.type}) taking place from ${dateStr}${endDateStr}. Location: ${event.location || 'Unknown/Virtual'}.`; + + if (this.getLatestNews) { + const newsCtx = buildNewsContext(this.getLatestNews); + if (newsCtx) geoContext += `\n\n${newsCtx}`; + } + + const detail: DeductContextDetail = { + query: `What is the expected impact of the tech event: ${event.title}?`, + geoContext, + autoSubmit: true, + }; + document.dispatchEvent(new CustomEvent('wm:deduct-context', { detail })); + }, + }, '\u{1F9E0}') : false, event.coords && !event.coords.virtual ? h('button', { className: 'event-map-link', diff --git a/src/config/commands.ts b/src/config/commands.ts index a211bbd5d..a48dd50fe 100644 --- a/src/config/commands.ts +++ b/src/config/commands.ts @@ -65,6 +65,7 @@ export const COMMANDS: Command[] = [ { id: 'panel:live-news', keywords: ['news', 'live news', 'headlines'], label: 'Jump to Live News', icon: '\u{1F4F0}', category: 'panels' }, { id: 'panel:intel', keywords: ['intel', 'intel feed'], label: 'Jump to Intel Feed', icon: '\u{1F50E}', category: 'panels' }, { id: 'panel:gdelt-intel', keywords: ['gdelt', 'intelligence feed'], label: 'Jump to Live Intelligence', icon: '\u{1F50D}', category: 'panels' }, + { id: 'panel:deduction', keywords: ['deduction', 'future', 'what if'], label: 'Jump to Deduct Situation', icon: '\u{1F9E0}', category: 'panels' }, { id: 'panel:cii', keywords: ['cii', 'instability', 'country risk'], label: 'Jump to Country Instability', icon: '\u{1F3AF}', category: 'panels' }, { id: 'panel:cascade', keywords: ['cascade', 'infrastructure cascade'], label: 'Jump to Infrastructure Cascade', icon: '\u{1F517}', category: 'panels' }, { id: 'panel:strategic-risk', keywords: ['risk', 'strategic risk', 'threat level'], label: 'Jump to Strategic Risk', icon: '\u26A0\uFE0F', category: 'panels' }, @@ -115,19 +116,19 @@ function toFlagEmoji(code: string): string { // All ISO 3166-1 alpha-2 codes — Intl.DisplayNames resolves human-readable names at runtime const ISO_CODES = [ - 'AD','AE','AF','AG','AL','AM','AO','AR','AT','AU','AZ','BA','BB','BD','BE','BF', - 'BG','BH','BI','BJ','BN','BO','BR','BS','BT','BW','BY','BZ','CA','CD','CF','CG', - 'CH','CI','CL','CM','CN','CO','CR','CU','CV','CY','CZ','DE','DJ','DK','DM','DO', - 'DZ','EC','EE','EG','ER','ES','ET','FI','FJ','FM','FR','GA','GB','GD','GE','GH', - 'GM','GN','GQ','GR','GT','GW','GY','HN','HR','HT','HU','ID','IE','IL','IN','IQ', - 'IR','IS','IT','JM','JO','JP','KE','KG','KH','KI','KM','KN','KP','KR','KW','KZ', - 'LA','LB','LC','LI','LK','LR','LS','LT','LU','LV','LY','MA','MC','MD','ME','MG', - 'MH','MK','ML','MM','MN','MR','MT','MU','MV','MW','MX','MY','MZ','NA','NE','NG', - 'NI','NL','NO','NP','NR','NZ','OM','PA','PE','PG','PH','PK','PL','PS','PT','PW', - 'PY','QA','RO','RS','RU','RW','SA','SB','SC','SD','SE','SG','SI','SK','SL','SM', - 'SN','SO','SR','SS','ST','SV','SY','SZ','TD','TG','TH','TJ','TL','TM','TN','TO', - 'TR','TT','TV','TW','TZ','UA','UG','US','UY','UZ','VA','VC','VE','VN','VU','WS', - 'YE','ZA','ZM','ZW', + 'AD', 'AE', 'AF', 'AG', 'AL', 'AM', 'AO', 'AR', 'AT', 'AU', 'AZ', 'BA', 'BB', 'BD', 'BE', 'BF', + 'BG', 'BH', 'BI', 'BJ', 'BN', 'BO', 'BR', 'BS', 'BT', 'BW', 'BY', 'BZ', 'CA', 'CD', 'CF', 'CG', + 'CH', 'CI', 'CL', 'CM', 'CN', 'CO', 'CR', 'CU', 'CV', 'CY', 'CZ', 'DE', 'DJ', 'DK', 'DM', 'DO', + 'DZ', 'EC', 'EE', 'EG', 'ER', 'ES', 'ET', 'FI', 'FJ', 'FM', 'FR', 'GA', 'GB', 'GD', 'GE', 'GH', + 'GM', 'GN', 'GQ', 'GR', 'GT', 'GW', 'GY', 'HN', 'HR', 'HT', 'HU', 'ID', 'IE', 'IL', 'IN', 'IQ', + 'IR', 'IS', 'IT', 'JM', 'JO', 'JP', 'KE', 'KG', 'KH', 'KI', 'KM', 'KN', 'KP', 'KR', 'KW', 'KZ', + 'LA', 'LB', 'LC', 'LI', 'LK', 'LR', 'LS', 'LT', 'LU', 'LV', 'LY', 'MA', 'MC', 'MD', 'ME', 'MG', + 'MH', 'MK', 'ML', 'MM', 'MN', 'MR', 'MT', 'MU', 'MV', 'MW', 'MX', 'MY', 'MZ', 'NA', 'NE', 'NG', + 'NI', 'NL', 'NO', 'NP', 'NR', 'NZ', 'OM', 'PA', 'PE', 'PG', 'PH', 'PK', 'PL', 'PS', 'PT', 'PW', + 'PY', 'QA', 'RO', 'RS', 'RU', 'RW', 'SA', 'SB', 'SC', 'SD', 'SE', 'SG', 'SI', 'SK', 'SL', 'SM', + 'SN', 'SO', 'SR', 'SS', 'ST', 'SV', 'SY', 'SZ', 'TD', 'TG', 'TH', 'TJ', 'TL', 'TM', 'TN', 'TO', + 'TR', 'TT', 'TV', 'TW', 'TZ', 'UA', 'UG', 'US', 'UY', 'UZ', 'VA', 'VC', 'VE', 'VN', 'VU', 'WS', + 'YE', 'ZA', 'ZM', 'ZW', ]; const displayNames = new Intl.DisplayNames(['en'], { type: 'region' }); diff --git a/src/generated/client/worldmonitor/intelligence/v1/service_client.ts b/src/generated/client/worldmonitor/intelligence/v1/service_client.ts index 56465120a..a4a84d851 100644 --- a/src/generated/client/worldmonitor/intelligence/v1/service_client.ts +++ b/src/generated/client/worldmonitor/intelligence/v1/service_client.ts @@ -138,6 +138,17 @@ export interface GdeltArticle { tone: number; } +export interface DeductSituationRequest { + query: string; + geoContext: string; +} + +export interface DeductSituationResponse { + analysis: string; + model: string; + provider: string; +} + export type SeverityLevel = "SEVERITY_LEVEL_UNSPECIFIED" | "SEVERITY_LEVEL_LOW" | "SEVERITY_LEVEL_MEDIUM" | "SEVERITY_LEVEL_HIGH"; export type TrendDirection = "TREND_DIRECTION_UNSPECIFIED" | "TREND_DIRECTION_RISING" | "TREND_DIRECTION_STABLE" | "TREND_DIRECTION_FALLING"; @@ -320,6 +331,30 @@ export class IntelligenceServiceClient { return await resp.json() as SearchGdeltDocumentsResponse; } + async deductSituation(req: DeductSituationRequest, options?: IntelligenceServiceCallOptions): Promise { + let path = "/api/intelligence/v1/deduct-situation"; + const url = this.baseURL + path; + + const headers: Record = { + "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 DeductSituationResponse; + } + private async handleError(resp: Response): Promise { const body = await resp.text(); if (resp.status === 400) { diff --git a/src/generated/server/worldmonitor/intelligence/v1/service_server.ts b/src/generated/server/worldmonitor/intelligence/v1/service_server.ts index c661ed6ad..1ee7edb0f 100644 --- a/src/generated/server/worldmonitor/intelligence/v1/service_server.ts +++ b/src/generated/server/worldmonitor/intelligence/v1/service_server.ts @@ -138,6 +138,17 @@ export interface GdeltArticle { tone: number; } +export interface DeductSituationRequest { + query: string; + geoContext: string; +} + +export interface DeductSituationResponse { + analysis: string; + model: string; + provider: string; +} + export type SeverityLevel = "SEVERITY_LEVEL_UNSPECIFIED" | "SEVERITY_LEVEL_LOW" | "SEVERITY_LEVEL_MEDIUM" | "SEVERITY_LEVEL_HIGH"; export type TrendDirection = "TREND_DIRECTION_UNSPECIFIED" | "TREND_DIRECTION_RISING" | "TREND_DIRECTION_STABLE" | "TREND_DIRECTION_FALLING"; @@ -194,6 +205,7 @@ export interface IntelligenceServiceHandler { classifyEvent(ctx: ServerContext, req: ClassifyEventRequest): Promise; getCountryIntelBrief(ctx: ServerContext, req: GetCountryIntelBriefRequest): Promise; searchGdeltDocuments(ctx: ServerContext, req: SearchGdeltDocumentsRequest): Promise; + deductSituation(ctx: ServerContext, req: DeductSituationRequest): Promise; } export function createIntelligenceServiceRoutes( @@ -431,6 +443,49 @@ export function createIntelligenceServiceRoutes( } }, }, + { + method: "POST", + path: "/api/intelligence/v1/deduct-situation", + handler: async (req: Request): Promise => { + try { + const pathParams: Record = {}; + const body = await req.json() as DeductSituationRequest; + if (options?.validateRequest) { + const bodyViolations = options.validateRequest("deductSituation", body); + if (bodyViolations) { + throw new ValidationError(bodyViolations); + } + } + + const ctx: ServerContext = { + request: req, + pathParams, + headers: Object.fromEntries(req.headers.entries()), + }; + + const result = await handler.deductSituation(ctx, body); + return new Response(JSON.stringify(result as DeductSituationResponse), { + 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" }, + }); + } + }, + }, ]; } diff --git a/src/types/index.ts b/src/types/index.ts index 7653aa849..6a17e8721 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,3 +1,9 @@ +export interface DeductContextDetail { + query?: string; + geoContext: string; + autoSubmit?: boolean; +} + export type PropagandaRisk = 'low' | 'medium' | 'high'; export interface Feed { diff --git a/src/utils/news-context.ts b/src/utils/news-context.ts new file mode 100644 index 000000000..d0f90e9f9 --- /dev/null +++ b/src/utils/news-context.ts @@ -0,0 +1,7 @@ +import type { NewsItem } from '@/types'; + +export function buildNewsContext(getLatestNews: () => NewsItem[], limit = 15): string { + const news = getLatestNews().slice(0, limit); + if (news.length === 0) return ''; + return 'Recent News:\n' + news.map(n => `- ${n.title} (${n.source})`).join('\n'); +}