Skip to content

Commit 24700b6

Browse files
authored
feat(export): complete non-AI data export with LLM threat sanitization (koala73#2058)
Expands the pro export panel to include all non-AI signal data: - news (raw items + ML clusters + by-category breakdown) - intelligence cache: flight delays, aircraft positions, protests, military flights/vessels, earthquakes, USNI fleet, Iran events, Oref alerts, advisories, sanctions, radiation, imagery scenes, thermal escalation - cyber threats (Feodo/URLhaus/OTX indicators) - GPS jamming hexes (via cached sync accessor) - correlation engine convergence cards (algorithmic, no LLM assessment) - user monitors Strips LLM-derived threat annotations from all exported NewsItem and ClusteredEvent records so AI output does not feed back into itself. Keyword and local-ML classifications are retained. Adds meta.note documenting disabled-source omission when applicable. CSV export now covers all sections with full rows where data is flat, summary stats for complex nested types, and an explicit header note that CSV is a structured summary (JSON is full-fidelity).
1 parent 6546f3d commit 24700b6

File tree

4 files changed

+272
-38
lines changed

4 files changed

+272
-38
lines changed

src/app/event-handlers.ts

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ import {
5555
import { detectPlatform, allButtons, buttonsForPlatform } from '@/components/DownloadBanner';
5656
import type { Platform } from '@/components/DownloadBanner';
5757
import { invokeTauri } from '@/services/tauri-bridge';
58+
import { getCachedGpsInterference } from '@/services/gps-interference';
5859
import { dataFreshness } from '@/services/data-freshness';
5960
import { mlWorker } from '@/services/ml-worker';
6061
import { UnifiedSettings } from '@/components/UnifiedSettings';
@@ -922,12 +923,29 @@ export class EventHandlerManager implements AppModule {
922923

923924
setupExportPanel(): void {
924925
if (!isProUser()) return;
925-
this.ctx.exportPanel = new ExportPanel(() => ({
926-
news: this.ctx.latestClusters.length > 0 ? this.ctx.latestClusters : this.ctx.allNews,
927-
markets: this.ctx.latestMarkets,
928-
predictions: this.ctx.latestPredictions,
929-
timestamp: Date.now(),
930-
}));
926+
this.ctx.exportPanel = new ExportPanel(() => {
927+
const allCards = this.ctx.correlationEngine?.getAllCards() ?? [];
928+
const disabledCount = this.ctx.disabledSources.size;
929+
return {
930+
meta: {
931+
exportedAt: new Date().toISOString(),
932+
note: disabledCount > 0
933+
? `Export reflects currently enabled sources only. ${disabledCount} source(s) are disabled and not included.`
934+
: 'Export reflects all active sources.',
935+
},
936+
timestamp: Date.now(),
937+
news: this.ctx.allNews,
938+
newsClusters: this.ctx.latestClusters.length > 0 ? this.ctx.latestClusters : undefined,
939+
newsByCategory: this.ctx.newsByCategory,
940+
markets: this.ctx.latestMarkets,
941+
predictions: this.ctx.latestPredictions,
942+
intelligence: this.ctx.intelligenceCache,
943+
cyberThreats: this.ctx.cyberThreatsCache ?? undefined,
944+
gpsJamming: getCachedGpsInterference() ?? undefined,
945+
convergenceCards: allCards.map(({ assessment: _a, ...card }) => card),
946+
monitors: this.ctx.monitors.length > 0 ? this.ctx.monitors : undefined,
947+
};
948+
});
931949

932950
const headerRight = this.ctx.container.querySelector('.header-right');
933951
if (headerRight) {

src/services/correlation-engine/engine.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,10 @@ export class CorrelationEngine {
8585
return this.cards.get(domain) ?? [];
8686
}
8787

88+
getAllCards(): ConvergenceCard[] {
89+
return Array.from(this.cards.values()).flat();
90+
}
91+
8892
// ── Clustering ──────────────────────────────────────────────
8993

9094
private clusterSignals(

src/services/gps-interference.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ let cachedData: GpsJamData | null = null;
2525
let cachedAt = 0;
2626
const CACHE_TTL = 5 * 60 * 1000;
2727

28+
export function getCachedGpsInterference(): GpsJamData | null {
29+
return cachedData;
30+
}
31+
2832
export async function fetchGpsInterference(): Promise<GpsJamData | null> {
2933
const now = Date.now();
3034
if (cachedData && now - cachedAt < CACHE_TTL) return cachedData;

src/utils/export.ts

Lines changed: 240 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,70 +1,278 @@
1-
import type { NewsItem, ClusteredEvent, MarketData } from '@/types';
1+
import type { NewsItem, ClusteredEvent, MarketData, CyberThreat, Monitor } from '@/types';
22
import type { PredictionMarket } from '@/services/prediction';
3+
import type { IntelligenceCache } from '@/app/app-context';
4+
import type { GpsJamData } from '@/services/gps-interference';
5+
import type { ConvergenceCard } from '@/services/correlation-engine';
36
import { t } from '@/services/i18n';
47

58
type ExportFormat = 'json' | 'csv';
69

7-
interface ExportData {
8-
news?: NewsItem[] | ClusteredEvent[];
10+
export interface ExportMeta {
11+
exportedAt: string;
12+
note: string;
13+
}
14+
15+
export interface ExportData {
16+
meta?: ExportMeta;
17+
timestamp: number;
18+
news?: NewsItem[];
19+
newsClusters?: ClusteredEvent[];
20+
newsByCategory?: Record<string, NewsItem[]>;
921
markets?: MarketData[];
1022
predictions?: PredictionMarket[];
11-
signals?: unknown[];
12-
timestamp: number;
23+
intelligence?: IntelligenceCache;
24+
cyberThreats?: CyberThreat[];
25+
gpsJamming?: GpsJamData;
26+
convergenceCards?: Omit<ConvergenceCard, 'assessment'>[];
27+
monitors?: Monitor[];
28+
}
29+
30+
// Strip LLM-derived threat annotations so AI does not feed back into itself.
31+
// Keyword and ML (local model) classifications are retained.
32+
function sanitizeNewsItem(item: NewsItem): NewsItem {
33+
if (item.threat?.source !== 'llm') return item;
34+
const { threat: _t, ...rest } = item;
35+
return rest as NewsItem;
36+
}
37+
38+
function sanitizeCluster(cluster: ClusteredEvent): ClusteredEvent {
39+
return {
40+
...cluster,
41+
threat: cluster.threat?.source === 'llm' ? undefined : cluster.threat,
42+
allItems: cluster.allItems.map(sanitizeNewsItem),
43+
};
44+
}
45+
46+
function sanitizeData(data: ExportData): ExportData {
47+
return {
48+
...data,
49+
news: data.news?.map(sanitizeNewsItem),
50+
newsClusters: data.newsClusters?.map(sanitizeCluster),
51+
newsByCategory: data.newsByCategory
52+
? Object.fromEntries(
53+
Object.entries(data.newsByCategory).map(([k, items]) => [k, items.map(sanitizeNewsItem)]),
54+
)
55+
: undefined,
56+
};
1357
}
1458

1559
export function exportToJSON(data: ExportData, filename = 'worldmonitor-export'): void {
16-
const jsonStr = JSON.stringify(data, null, 2);
60+
const jsonStr = JSON.stringify(sanitizeData(data), null, 2);
1761
downloadFile(jsonStr, `${filename}.json`, 'application/json');
1862
}
1963

2064
export function exportToCSV(data: ExportData, filename = 'worldmonitor-export'): void {
65+
const clean = sanitizeData(data);
2166
const lines: string[] = [];
2267

23-
if (data.news && data.news.length > 0) {
68+
lines.push(`# WorldMonitor Export — ${new Date(clean.timestamp).toISOString()}`);
69+
lines.push('# Note: CSV is a structured summary. Use JSON export for full fidelity.');
70+
if (clean.meta?.note) lines.push(`# ${clean.meta.note}`);
71+
lines.push('');
72+
73+
// News — prefer raw items over clusters; clusters lose individual sources
74+
const newsItems = clean.news ?? [];
75+
if (newsItems.length > 0) {
2476
lines.push('=== NEWS ===');
25-
lines.push('Title,Source,Link,Published,IsAlert');
26-
data.news.forEach(item => {
27-
if ('primaryTitle' in item) {
28-
const cluster = item as ClusteredEvent;
29-
lines.push(csvRow([
30-
cluster.primaryTitle,
31-
cluster.primarySource,
32-
cluster.primaryLink,
33-
cluster.lastUpdated.toISOString(),
34-
String(cluster.isAlert),
35-
]));
36-
} else {
37-
const news = item as NewsItem;
38-
lines.push(csvRow([
39-
news.title,
40-
news.source,
41-
news.link,
42-
news.pubDate?.toISOString() || '',
43-
String(news.isAlert),
44-
]));
45-
}
77+
lines.push('Title,Source,Link,Published,IsAlert,ThreatLevel,ThreatCategory');
78+
newsItems.forEach(item => {
79+
lines.push(csvRow([
80+
item.title,
81+
item.source,
82+
item.link,
83+
item.pubDate?.toISOString() || '',
84+
String(item.isAlert),
85+
item.threat?.level ?? '',
86+
item.threat?.category ?? '',
87+
]));
4688
});
4789
lines.push('');
4890
}
4991

50-
if (data.markets && data.markets.length > 0) {
92+
if (clean.markets && clean.markets.length > 0) {
5193
lines.push('=== MARKETS ===');
5294
lines.push('Symbol,Name,Price,Change');
53-
data.markets.forEach(m => {
95+
clean.markets.forEach(m => {
5496
lines.push(csvRow([m.symbol, m.name, String(m.price ?? ''), String(m.change ?? '')]));
5597
});
5698
lines.push('');
5799
}
58100

59-
if (data.predictions && data.predictions.length > 0) {
101+
if (clean.predictions && clean.predictions.length > 0) {
60102
lines.push('=== PREDICTIONS ===');
61103
lines.push('Title,Yes Price,Volume');
62-
data.predictions.forEach(p => {
104+
clean.predictions.forEach(p => {
63105
lines.push(csvRow([p.title, String(p.yesPrice), String(p.volume ?? '')]));
64106
});
65107
lines.push('');
66108
}
67109

110+
const intel = clean.intelligence;
111+
if (intel) {
112+
if (intel.protests?.events && intel.protests.events.length > 0) {
113+
lines.push('=== PROTESTS ===');
114+
lines.push('Title,Country,EventType,Severity,Time');
115+
intel.protests.events.forEach(e => {
116+
lines.push(csvRow([e.title, e.country, e.eventType, e.severity, e.time.toISOString()]));
117+
});
118+
lines.push('');
119+
}
120+
121+
if (intel.earthquakes && intel.earthquakes.length > 0) {
122+
lines.push('=== EARTHQUAKES ===');
123+
lines.push('Place,Magnitude,DepthKm,OccurredAt,URL');
124+
intel.earthquakes.forEach(e => {
125+
lines.push(csvRow([e.place, String(e.magnitude), String(e.depthKm), new Date(e.occurredAt * 1000).toISOString(), e.sourceUrl]));
126+
});
127+
lines.push('');
128+
}
129+
130+
if (intel.outages && intel.outages.length > 0) {
131+
lines.push('=== INTERNET OUTAGES ===');
132+
lines.push('Title,Country,Severity,PubDate,Link');
133+
intel.outages.forEach(o => {
134+
lines.push(csvRow([o.title, o.country, o.severity, o.pubDate.toISOString(), o.link]));
135+
});
136+
lines.push('');
137+
}
138+
139+
if (intel.flightDelays && intel.flightDelays.length > 0) {
140+
lines.push('=== FLIGHT DELAYS ===');
141+
lines.push('Airport,IATA,City,Country,DelayType,Severity,AvgDelayMin,Source');
142+
intel.flightDelays.forEach(d => {
143+
lines.push(csvRow([d.name, d.iata, d.city, d.country, d.delayType, d.severity, String(d.avgDelayMinutes), d.source]));
144+
});
145+
lines.push('');
146+
}
147+
148+
if (intel.military?.flights && intel.military.flights.length > 0) {
149+
lines.push('=== MILITARY FLIGHTS ===');
150+
lines.push('Callsign,HexCode,AircraftType,Operator,Country,Lat,Lon');
151+
intel.military.flights.forEach(f => {
152+
lines.push(csvRow([f.callsign, f.hexCode, f.aircraftType, f.operator, f.operatorCountry, String(f.lat), String(f.lon)]));
153+
});
154+
lines.push('');
155+
}
156+
157+
if (intel.military?.vessels && intel.military.vessels.length > 0) {
158+
lines.push('=== MILITARY VESSELS ===');
159+
lines.push('Name,MMSI,Country,VesselType,Lat,Lon');
160+
intel.military.vessels.forEach(v => {
161+
lines.push(csvRow([v.name, v.mmsi, v.operatorCountry, v.vesselType, String(v.lat), String(v.lon)]));
162+
});
163+
lines.push('');
164+
}
165+
166+
if (intel.iranEvents && intel.iranEvents.length > 0) {
167+
lines.push('=== IRAN EVENTS ===');
168+
lines.push('Title,Category,Location,Severity,Timestamp');
169+
intel.iranEvents.forEach(e => {
170+
lines.push(csvRow([e.title, e.category, e.locationName, e.severity, e.timestamp]));
171+
});
172+
lines.push('');
173+
}
174+
175+
if (intel.orefAlerts) {
176+
lines.push('=== OREF ALERTS ===');
177+
lines.push('ActiveAlerts,History24h');
178+
lines.push(csvRow([String(intel.orefAlerts.alertCount), String(intel.orefAlerts.historyCount24h)]));
179+
lines.push('');
180+
}
181+
182+
if (intel.advisories && intel.advisories.length > 0) {
183+
lines.push('=== SECURITY ADVISORIES ===');
184+
lines.push('Title,Source,Level,Country,PubDate,Link');
185+
intel.advisories.forEach(a => {
186+
lines.push(csvRow([a.title, a.source, a.level ?? '', a.country ?? '', a.pubDate.toISOString(), a.link]));
187+
});
188+
lines.push('');
189+
}
190+
191+
if (intel.radiation?.observations && intel.radiation.observations.length > 0) {
192+
lines.push('=== RADIATION MONITORING ===');
193+
lines.push('Location,Country,Value,Unit,ObservedAt');
194+
intel.radiation.observations.forEach(s => {
195+
lines.push(csvRow([s.location, s.country, String(s.value), s.unit, s.observedAt.toISOString()]));
196+
});
197+
lines.push('');
198+
}
199+
200+
if (intel.imageryScenes && intel.imageryScenes.length > 0) {
201+
lines.push('=== SATELLITE IMAGERY ===');
202+
lines.push('ID,Satellite,DateTime,ResolutionM,Mode');
203+
intel.imageryScenes.forEach(s => {
204+
lines.push(csvRow([s.id, s.satellite, s.datetime, String(s.resolutionM), s.mode]));
205+
});
206+
lines.push('');
207+
}
208+
209+
if (intel.sanctions) {
210+
lines.push('=== SANCTIONS ===');
211+
lines.push('# See JSON export for full sanctions data');
212+
lines.push(`TotalCount,${intel.sanctions.totalCount}`);
213+
lines.push(`SDNCount,${intel.sanctions.sdnCount}`);
214+
lines.push(`NewEntries,${intel.sanctions.newEntryCount}`);
215+
lines.push('');
216+
}
217+
218+
if (intel.thermalEscalation) {
219+
lines.push('=== THERMAL ESCALATION ===');
220+
lines.push('# See JSON export for full thermal data');
221+
lines.push(`ClusterCount,${intel.thermalEscalation.summary.clusterCount}`);
222+
lines.push(`ElevatedCount,${intel.thermalEscalation.summary.elevatedCount}`);
223+
lines.push('');
224+
}
225+
226+
if (intel.usniFleet) {
227+
lines.push('=== USNI FLEET ===');
228+
lines.push('# See JSON export for full fleet data');
229+
lines.push(`Vessels,${intel.usniFleet.vessels?.length ?? 0}`);
230+
lines.push('');
231+
}
232+
233+
if (intel.aircraftPositions && intel.aircraftPositions.length > 0) {
234+
lines.push('=== AIRCRAFT POSITIONS ===');
235+
lines.push(`# ${intel.aircraftPositions.length} positions — see JSON for full data`);
236+
lines.push('');
237+
}
238+
}
239+
240+
if (clean.cyberThreats && clean.cyberThreats.length > 0) {
241+
lines.push('=== CYBER THREATS ===');
242+
lines.push('Indicator,Type,Severity,Country,Source,FirstSeen');
243+
clean.cyberThreats.forEach(c => {
244+
lines.push(csvRow([c.indicator, c.indicatorType, String(c.severity), c.country ?? '', c.source, c.firstSeen ?? '']));
245+
});
246+
lines.push('');
247+
}
248+
249+
if (clean.gpsJamming) {
250+
lines.push('=== GPS JAMMING ===');
251+
lines.push('FetchedAt,TotalHexes,HighCount,MediumCount');
252+
const s = clean.gpsJamming.stats;
253+
lines.push(csvRow([clean.gpsJamming.fetchedAt, String(s.totalHexes), String(s.highCount), String(s.mediumCount)]));
254+
lines.push('# Per-hex data available in JSON export');
255+
lines.push('');
256+
}
257+
258+
if (clean.convergenceCards && clean.convergenceCards.length > 0) {
259+
lines.push('=== SIGNAL CONVERGENCE ===');
260+
lines.push('Domain,Title,Score,Trend,Countries');
261+
clean.convergenceCards.forEach(c => {
262+
lines.push(csvRow([c.domain, c.title, String(c.score), c.trend, c.countries.join(';')]));
263+
});
264+
lines.push('');
265+
}
266+
267+
if (clean.monitors && clean.monitors.length > 0) {
268+
lines.push('=== MONITORS ===');
269+
lines.push('Name,Keywords,Color');
270+
clean.monitors.forEach(m => {
271+
lines.push(csvRow([m.name ?? '', m.keywords.join(';'), m.color]));
272+
});
273+
lines.push('');
274+
}
275+
68276
downloadFile(lines.join('\n'), `${filename}.csv`, 'text/csv');
69277
}
70278

0 commit comments

Comments
 (0)