Skip to content

Commit c223ec0

Browse files
koala73e
andauthored
feat(export): add country evidence bundles (#4283)
* feat(export): add country evidence bundles * fix(export): harden evidence bundle markdown * fix(export): strengthen evidence bundle redaction * fix(export): redact source URL secrets * fix(export): avoid redacting ordinary colon prose --------- Co-authored-by: e <e@e.co>
1 parent 0e219e4 commit c223ec0

7 files changed

Lines changed: 906 additions & 10 deletions

File tree

docs/ai-intelligence.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ Clicking any country on the map opens a full-page intelligence dossier — a sin
6969

7070
**Headline relevance filtering**: each country has an alias map (e.g., `US → ["united states", "american", "washington", "pentagon", "biden", "trump"]`). Headlines are filtered using a negative-match algorithm — if another country's alias appears earlier in the headline title than the target country's alias, the headline is excluded. This prevents cross-contamination (e.g., a headline about Venezuela mentioning "Washington sanctions" appearing in the US brief).
7171

72-
**Export options**: briefs are exportable as JSON (structured data with all scores, signals, and headlines), CSV (flattened tabular format), or PNG image. A print button triggers the browser's native print dialog for PDF export.
72+
**Export options**: briefs are exportable as JSON (structured data with all scores, signals, and headlines), CSV (flattened tabular format), PNG image, or a portable evidence Markdown bundle with selected signals, cited source links when available, freshness notes, and provenance disclaimers. A print button triggers the browser's native print dialog for PDF export.
7373

7474
### Local-First Country Detection
7575

docs/features.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,7 @@ The full panel inventory lives in the app itself — Cmd+K surfaces what's avail
274274
## Data Export
275275

276276
- CSV and JSON export of current dashboard state
277+
- Country dossier evidence bundles as portable Markdown with source links and freshness notes where available
277278
- Historical playback from snapshots
278279

279280
---

src/components/CountryBriefPage.ts

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@ import type { CountryBriefPanel, CountryIntelData, StockIndexData } from '@/comp
1111
import { getNearbyInfrastructure, haversineDistanceKm } from '@/services/related-assets';
1212
import { PORTS } from '@/config/ports';
1313
import type { Port } from '@/types';
14-
import { exportCountryBriefJSON, exportCountryBriefCSV } from '@/utils/export';
15-
import type { CountryBriefExport } from '@/utils/export';
14+
import { exportCountryBriefJSON, exportCountryBriefCSV, exportCountryEvidenceMarkdown } from '@/utils/export';
15+
import type { CountryBriefExport, CountryEvidenceBundleInput } from '@/utils/export';
1616
import { ME_STRIKE_BOUNDS } from '@/services/country-geometry';
1717
import { toFlagEmoji } from '@/utils/country-flag';
1818
import { setTrustedHtml, trustedHtml } from '@/utils/dom-utils';
@@ -60,6 +60,8 @@ export class CountryBriefPage implements CountryBriefPanel {
6060
private currentScore: CountryScore | null = null;
6161
private currentSignals: CountryBriefSignals | null = null;
6262
private currentBrief: string | null = null;
63+
private currentBriefGeneratedAt: string | null = null;
64+
private currentBriefCached: boolean | null = null;
6365
private currentHeadlines: NewsItem[] = [];
6466
private onCloseCallback?: () => void;
6567
private onShareStory?: (code: string, name: string) => void;
@@ -133,7 +135,7 @@ export class CountryBriefPage implements CountryBriefPanel {
133135
}
134136
} else if (format === 'pdf') {
135137
this.exportPdf();
136-
} else if (format === 'json' || format === 'csv') {
138+
} else if (format === 'json' || format === 'csv' || format === 'evidence-md') {
137139
this.exportBrief(format);
138140
}
139141
const exportMenu = this.overlay.querySelector('.cb-export-menu');
@@ -375,6 +377,8 @@ export class CountryBriefPage implements CountryBriefPanel {
375377
this.currentScore = score;
376378
this.currentSignals = signals;
377379
this.currentBrief = null;
380+
this.currentBriefGeneratedAt = null;
381+
this.currentBriefCached = null;
378382
this.currentHeadlines = [];
379383
this.currentHeadlineCount = 0;
380384
const flag = this.countryFlag(code);
@@ -412,6 +416,7 @@ export class CountryBriefPage implements CountryBriefPanel {
412416
<button class="cb-export-option" data-format="pdf">${t('common.exportPdf')}</button>
413417
<button class="cb-export-option" data-format="json">${t('common.exportJson')}</button>
414418
<button class="cb-export-option" data-format="csv">${t('common.exportCsv')}</button>
419+
<button class="cb-export-option" data-format="evidence-md">Evidence Markdown</button>
415420
</div>
416421
</div>
417422
<button class="cb-close" aria-label="${t('components.newsPanel.close')}">×</button>
@@ -505,6 +510,8 @@ export class CountryBriefPage implements CountryBriefPanel {
505510
}
506511

507512
this.currentBrief = data.brief;
513+
this.currentBriefGeneratedAt = data.generatedAt ?? null;
514+
this.currentBriefCached = data.cached === true;
508515
const formatted = this.formatBrief(data.brief, this.currentHeadlineCount);
509516
setTrustedHtml(section, trustedHtml(`
510517
<div class="cb-brief-text">${formatted}</div>
@@ -664,12 +671,15 @@ export class CountryBriefPage implements CountryBriefPanel {
664671
return formatIntelBrief(text, headlineCount > 0 ? { count: headlineCount, hrefPrefix: '#cb-news-' } : undefined);
665672
}
666673

667-
private exportBrief(format: 'json' | 'csv'): void {
674+
private exportBrief(format: 'json' | 'csv' | 'evidence-md'): void {
668675
if (!this.currentCode || !this.currentName) return;
669-
const data: CountryBriefExport = {
676+
const exportedAt = new Date().toISOString();
677+
const data: CountryBriefExport & CountryEvidenceBundleInput = {
670678
country: this.currentName,
671679
code: this.currentCode,
672-
generatedAt: new Date().toISOString(),
680+
context: 'Country dossier',
681+
generatedAt: exportedAt,
682+
exportedAt,
673683
};
674684
if (this.currentScore) {
675685
data.score = this.currentScore.score;
@@ -703,6 +713,8 @@ export class CountryBriefPage implements CountryBriefPanel {
703713
};
704714
}
705715
if (this.currentBrief) data.brief = this.currentBrief;
716+
if (this.currentBriefGeneratedAt) data.briefGeneratedAt = this.currentBriefGeneratedAt;
717+
if (this.currentBriefCached != null) data.briefCached = this.currentBriefCached;
706718
if (this.currentHeadlines.length > 0) {
707719
data.headlines = this.currentHeadlines.map(h => ({
708720
title: h.title,
@@ -711,7 +723,8 @@ export class CountryBriefPage implements CountryBriefPanel {
711723
pubDate: h.pubDate ? new Date(h.pubDate).toISOString() : undefined,
712724
}));
713725
}
714-
if (format === 'json') exportCountryBriefJSON(data);
726+
if (format === 'evidence-md') exportCountryEvidenceMarkdown(data);
727+
else if (format === 'json') exportCountryBriefJSON(data);
715728
else exportCountryBriefCSV(data);
716729
}
717730

src/components/CountryDeepDivePanel.ts

Lines changed: 98 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ import type { MapContainer } from './MapContainer';
4444
import { dedupeHeadlines } from './CountryDeepDivePanel-news-utils';
4545
import { renderFollowButton } from '@/utils/follow-button';
4646
import { renderNotifyCountryLink } from '@/utils/notify-country-link';
47+
import { exportCountryEvidenceMarkdown } from '@/utils/export';
48+
import type { CountryEvidenceBundleInput } from '@/utils/export';
4749

4850
const DEPENDENCY_FLAG_LABELS: Record<string, { text: string; cls: string }> = {
4951
DEPENDENCY_FLAG_SINGLE_SOURCE_CRITICAL: { text: 'Single Source', cls: 'cdp-dep-critical' },
@@ -96,6 +98,12 @@ export class CountryDeepDivePanel implements CountryBriefPanel {
9698
private closeButton: HTMLButtonElement;
9799
private currentCode: string | null = null;
98100
private currentName: string | null = null;
101+
private currentScore: CountryScore | null = null;
102+
private currentSignals: CountryBriefSignals | null = null;
103+
private currentBrief: string | null = null;
104+
private currentBriefGeneratedAt: string | null = null;
105+
private currentBriefCached: boolean | null = null;
106+
private currentHeadlines: NewsItem[] = [];
99107
private isMaximizedState = false;
100108
private onCloseCallback?: () => void;
101109
private onStateChangeCallback?: (state: { visible: boolean; maximized: boolean }) => void;
@@ -261,6 +269,13 @@ export class CountryDeepDivePanel implements CountryBriefPanel {
261269
this.abortController = new AbortController();
262270
this.currentCode = code;
263271
this.currentName = country;
272+
this.currentScore = score;
273+
this.currentSignals = signals;
274+
this.currentBrief = null;
275+
this.currentBriefGeneratedAt = null;
276+
this.currentBriefCached = null;
277+
this.currentHeadlines = [];
278+
this.currentHeadlineCount = 0;
264279
this.economicIndicators = [];
265280
this.infrastructureByType.clear();
266281
this.renderSkeleton(country, code, score, signals);
@@ -280,6 +295,12 @@ export class CountryDeepDivePanel implements CountryBriefPanel {
280295
this.close();
281296
this.currentCode = null;
282297
this.currentName = null;
298+
this.currentScore = null;
299+
this.currentSignals = null;
300+
this.currentBrief = null;
301+
this.currentBriefGeneratedAt = null;
302+
this.currentBriefCached = null;
303+
this.currentHeadlines = [];
283304
this.onCloseCallback?.();
284305
this.onStateChangeCallback?.({ visible: false, maximized: false });
285306
}
@@ -337,6 +358,7 @@ export class CountryDeepDivePanel implements CountryBriefPanel {
337358
public updateNews(headlines: NewsItem[]): void {
338359
if (!this.newsBody) return;
339360
this.newsBody.replaceChildren();
361+
this.currentHeadlines = [];
340362

341363
const compare = (a: NewsItem, b: NewsItem) => {
342364
const sa = SEVERITY_ORDER[this.toThreatLevel(a.threat?.level)];
@@ -352,6 +374,7 @@ export class CountryDeepDivePanel implements CountryBriefPanel {
352374
.slice(0, 10);
353375

354376
this.currentHeadlineCount = deduped.length;
377+
this.currentHeadlines = deduped.map(({ item }) => item);
355378

356379
if (deduped.length === 0) {
357380
this.newsBody.append(this.makeEmpty(t('countryBrief.noNews')));
@@ -2309,10 +2332,17 @@ export class CountryDeepDivePanel implements CountryBriefPanel {
23092332
this.briefBody.replaceChildren();
23102333

23112334
if (data.error || data.skipped || !data.brief) {
2335+
this.currentBrief = null;
2336+
this.currentBriefGeneratedAt = null;
2337+
this.currentBriefCached = null;
23122338
this.briefBody.append(this.makeEmpty(data.error || data.reason || t('countryBrief.assessmentUnavailable')));
23132339
return;
23142340
}
23152341

2342+
this.currentBrief = data.brief;
2343+
this.currentBriefGeneratedAt = data.generatedAt ?? null;
2344+
this.currentBriefCached = data.cached === true;
2345+
23162346
const summaryHtml = this.formatBrief(this.summarizeBrief(data.brief), 0);
23172347
const text = this.el('div', 'cdp-assessment-text cdp-summary-only');
23182348
setTrustedHtml(text, trustedHtml(summaryHtml, "legacy direct innerHTML migration"));
@@ -2424,7 +2454,13 @@ export class CountryDeepDivePanel implements CountryBriefPanel {
24242454
this.onExportImage(this.currentCode, this.currentName);
24252455
}
24262456
});
2427-
right.append(shareBtn, maxBtn, storyButton, exportButton);
2457+
const evidenceButton = this.el('button', 'cdp-action-btn cdp-evidence-export-btn', 'Evidence') as HTMLButtonElement;
2458+
evidenceButton.setAttribute('type', 'button');
2459+
evidenceButton.setAttribute('title', 'Export evidence bundle as Markdown');
2460+
evidenceButton.addEventListener('click', () => {
2461+
this.exportEvidenceBundle();
2462+
});
2463+
right.append(shareBtn, maxBtn, storyButton, exportButton, evidenceButton);
24282464
header.append(left, right);
24292465

24302466
const scoreCard = this.el('section', 'cdp-card cdp-score-card');
@@ -2918,6 +2954,67 @@ export class CountryDeepDivePanel implements CountryBriefPanel {
29182954
return formatIntelBrief(text, headlineCount > 0 ? { count: headlineCount, hrefPrefix: '#cdp-news-' } : undefined);
29192955
}
29202956

2957+
private exportEvidenceBundle(): void {
2958+
if (!this.currentCode || !this.currentName) return;
2959+
const exportedAt = new Date().toISOString();
2960+
const data: CountryEvidenceBundleInput = {
2961+
country: this.currentName,
2962+
code: this.currentCode,
2963+
context: 'Country dossier',
2964+
generatedAt: exportedAt,
2965+
exportedAt,
2966+
};
2967+
2968+
if (this.currentScore) {
2969+
data.score = this.currentScore.score;
2970+
data.level = this.currentScore.level;
2971+
data.trend = this.currentScore.trend;
2972+
data.components = this.currentScore.components;
2973+
}
2974+
if (this.currentSignals) {
2975+
data.signals = {
2976+
criticalNews: this.currentSignals.criticalNews,
2977+
protests: this.currentSignals.protests,
2978+
militaryFlights: this.currentSignals.militaryFlights,
2979+
militaryVessels: this.currentSignals.militaryVessels,
2980+
militaryFlightsInCountry: this.currentSignals.militaryFlightsInCountry,
2981+
militaryVesselsInCountry: this.currentSignals.militaryVesselsInCountry,
2982+
outages: this.currentSignals.outages,
2983+
aisDisruptions: this.currentSignals.aisDisruptions,
2984+
satelliteFires: this.currentSignals.satelliteFires,
2985+
radiationAnomalies: this.currentSignals.radiationAnomalies,
2986+
temporalAnomalies: this.currentSignals.temporalAnomalies,
2987+
cyberThreats: this.currentSignals.cyberThreats,
2988+
earthquakes: this.currentSignals.earthquakes,
2989+
displacementOutflow: this.currentSignals.displacementOutflow,
2990+
climateStress: this.currentSignals.climateStress,
2991+
conflictEvents: this.currentSignals.conflictEvents,
2992+
activeStrikes: this.currentSignals.activeStrikes,
2993+
orefSirens: this.currentSignals.orefSirens,
2994+
orefHistory24h: this.currentSignals.orefHistory24h,
2995+
aviationDisruptions: this.currentSignals.aviationDisruptions,
2996+
travelAdvisories: this.currentSignals.travelAdvisories,
2997+
travelAdvisoryMaxLevel: this.currentSignals.travelAdvisoryMaxLevel,
2998+
gpsJammingHexes: this.currentSignals.gpsJammingHexes,
2999+
thermalEscalations: this.currentSignals.thermalEscalations,
3000+
sanctionsDesignations: this.currentSignals.sanctionsDesignations,
3001+
sanctionsNewDesignations: this.currentSignals.sanctionsNewDesignations,
3002+
};
3003+
}
3004+
if (this.currentBrief) data.brief = this.currentBrief;
3005+
if (this.currentBriefGeneratedAt) data.briefGeneratedAt = this.currentBriefGeneratedAt;
3006+
if (this.currentBriefCached != null) data.briefCached = this.currentBriefCached;
3007+
if (this.currentHeadlines.length > 0) {
3008+
data.headlines = this.currentHeadlines.map((headline) => ({
3009+
title: headline.title,
3010+
source: headline.source,
3011+
link: headline.link,
3012+
pubDate: headline.pubDate ? new Date(headline.pubDate).toISOString() : undefined,
3013+
}));
3014+
}
3015+
exportCountryEvidenceMarkdown(data);
3016+
}
3017+
29213018
private summarizeBrief(brief: string): string {
29223019
const stripped = brief.replace(/\*\*(.*?)\*\*/g, '$1');
29233020
const lines = stripped.split('\n').map((l) => l.trim()).filter((l) => l.length > 0);

0 commit comments

Comments
 (0)