release: v2.4.48 — name the two Insights surfaces on the @hanzo/event base: BriefPanel + InsightsPanel
Rides on the native @hanzo/event telemetry (one client → POST /v1/event). Renames the AI news-brief panel to BriefPanel (it makes the world brief) and the product-analytics panel over /v1/insights/events to InsightsPanel, dropping the Org prefix and the name collision. GTM stays orthogonal. No duplicate telemetry. Claude-Session: https://claude.ai/code/session_01NP4ehjWW2h98FkE4YjUKuJ
This commit is contained in:
+1
-1
@@ -2,7 +2,7 @@
|
||||
"name": "@hanzo/world",
|
||||
"description": "Hanzo World — real-time global intelligence dashboard.",
|
||||
"private": true,
|
||||
"version": "2.4.47",
|
||||
"version": "2.4.48",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"lint:md": "markdownlint-cli2 '**/*.md'",
|
||||
|
||||
+7
-7
@@ -94,7 +94,7 @@ import {
|
||||
TechEventsPanel,
|
||||
ServiceStatusPanel,
|
||||
RuntimeConfigPanel,
|
||||
InsightsPanel,
|
||||
BriefPanel,
|
||||
TechReadinessPanel,
|
||||
MacroSignalsPanel,
|
||||
RotationScannerPanel,
|
||||
@@ -121,7 +121,7 @@ import {
|
||||
MyUsagePanel,
|
||||
LiveActivityPanel,
|
||||
OrgAnalyticsPanel,
|
||||
OrgInsightsPanel,
|
||||
InsightsPanel,
|
||||
CloudServicesPanel,
|
||||
ClusterPanel,
|
||||
QueuePanel,
|
||||
@@ -1793,7 +1793,7 @@ export class App {
|
||||
this.panels['trader-desk'] = new TraderDeskPanel();
|
||||
|
||||
// AI Insights Panel (desktop only - hides itself on mobile)
|
||||
const insightsPanel = new InsightsPanel();
|
||||
const insightsPanel = new BriefPanel();
|
||||
this.panels['insights'] = insightsPanel;
|
||||
|
||||
// AI Analyst — chat with live data + agentic control surface (all variants)
|
||||
@@ -1956,7 +1956,7 @@ export class App {
|
||||
// Per-org event-platform cards — the caller's own analytics + product
|
||||
// insights (api.hanzo.ai /v1/analytics + /v1/insights, org-scoped by bearer).
|
||||
add('org-analytics', () => new OrgAnalyticsPanel());
|
||||
add('org-insights', () => new OrgInsightsPanel());
|
||||
add('org-insights', () => new InsightsPanel());
|
||||
add('my-usage', () => new MyUsagePanel());
|
||||
// Full Hanzo Cloud status page, embedded from status.hanzo.ai (public — NOT admin-gated).
|
||||
add('hanzo-status', () => new HanzoStatusPanel());
|
||||
@@ -3693,7 +3693,7 @@ export class App {
|
||||
{
|
||||
// Clusters from either worker feed the panel; never leave it on the
|
||||
// boot spinner when clustering yields nothing.
|
||||
const insightsPanel = this.panels['insights'] as InsightsPanel | undefined;
|
||||
const insightsPanel = this.panels['insights'] as BriefPanel | undefined;
|
||||
if (this.latestClusters.length > 0) {
|
||||
insightsPanel?.updateInsights(this.latestClusters);
|
||||
} else {
|
||||
@@ -4372,7 +4372,7 @@ export class App {
|
||||
this.map?.updateMilitaryForEscalation(flights, vessels);
|
||||
// Fetch cached postures for banner (posture panel fetches its own data)
|
||||
this.loadCachedPosturesForBanner();
|
||||
const insightsPanel = this.panels['insights'] as InsightsPanel | undefined;
|
||||
const insightsPanel = this.panels['insights'] as BriefPanel | undefined;
|
||||
insightsPanel?.setMilitaryFlights(flights);
|
||||
const hasData = flights.length > 0 || vessels.length > 0;
|
||||
this.map?.setLayerReady('military', hasData);
|
||||
@@ -4432,7 +4432,7 @@ export class App {
|
||||
|
||||
// Fetch cached postures for banner (posture panel fetches its own data)
|
||||
this.loadCachedPosturesForBanner();
|
||||
const insightsPanel = this.panels['insights'] as InsightsPanel | undefined;
|
||||
const insightsPanel = this.panels['insights'] as BriefPanel | undefined;
|
||||
insightsPanel?.setMilitaryFlights(flightData.flights);
|
||||
|
||||
const hasData = flightData.flights.length > 0 || vesselData.vessels.length > 0;
|
||||
|
||||
@@ -0,0 +1,644 @@
|
||||
import { Panel } from './Panel';
|
||||
import { mlWorker } from '@/services/ml-worker';
|
||||
import { generateSummary } from '@/services/summarization';
|
||||
import { parallelAnalysis, type AnalyzedHeadline } from '@/services/parallel-analysis';
|
||||
import { signalAggregator, logSignalSummary, type RegionalConvergence } from '@/services/signal-aggregator';
|
||||
import { focalPointDetector } from '@/services/focal-point-detector';
|
||||
import { ingestNewsForCII } from '@/services/country-instability';
|
||||
import { getTheaterPostureSummaries } from '@/services/military-surge';
|
||||
import { isMobileDevice } from '@/utils';
|
||||
import { escapeHtml, sanitizeUrl } from '@/utils/sanitize';
|
||||
import { getSiteVariant } from '@/config';
|
||||
import { getPersistentCache, setPersistentCache } from '@/services/persistent-cache';
|
||||
import { t } from '@/services/i18n';
|
||||
import type { ClusteredEvent, FocalPoint, MilitaryFlight } from '@/types';
|
||||
|
||||
export class BriefPanel extends Panel {
|
||||
private isHidden = false;
|
||||
private lastBriefUpdate = 0;
|
||||
private cachedBrief: string | null = null;
|
||||
private lastMissedStories: AnalyzedHeadline[] = [];
|
||||
private lastConvergenceZones: RegionalConvergence[] = [];
|
||||
private lastFocalPoints: FocalPoint[] = [];
|
||||
private lastMilitaryFlights: MilitaryFlight[] = [];
|
||||
private static readonly BRIEF_COOLDOWN_MS = 120000; // 2 min cooldown (API has limits)
|
||||
private static readonly BRIEF_RENDER_TIMEOUT_MS = 12000; // render stories even if the AI brief stalls
|
||||
private static readonly BRIEF_CACHE_KEY = 'summary:world-brief';
|
||||
|
||||
constructor() {
|
||||
super({
|
||||
id: 'insights',
|
||||
title: t('panels.insights'),
|
||||
showCount: false,
|
||||
infoTooltip: t('components.insights.infoTooltip'),
|
||||
});
|
||||
|
||||
if (isMobileDevice()) {
|
||||
this.hide();
|
||||
this.isHidden = true;
|
||||
}
|
||||
}
|
||||
|
||||
public setMilitaryFlights(flights: MilitaryFlight[]): void {
|
||||
this.lastMilitaryFlights = flights;
|
||||
}
|
||||
|
||||
private getTheaterPostureContext(): string {
|
||||
if (this.lastMilitaryFlights.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const postures = getTheaterPostureSummaries(this.lastMilitaryFlights);
|
||||
const significant = postures.filter(
|
||||
(p) => p.postureLevel === 'critical' || p.postureLevel === 'elevated' || p.strikeCapable
|
||||
);
|
||||
|
||||
if (significant.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const lines = significant.map((p) => {
|
||||
const parts: string[] = [];
|
||||
parts.push(`${p.theaterName}: ${p.totalAircraft} aircraft`);
|
||||
parts.push(`(${p.postureLevel.toUpperCase()})`);
|
||||
if (p.strikeCapable) parts.push('STRIKE CAPABLE');
|
||||
parts.push(`- ${p.summary}`);
|
||||
if (p.targetNation) parts.push(`Focus: ${p.targetNation}`);
|
||||
return parts.join(' ');
|
||||
});
|
||||
|
||||
return `\n\nCRITICAL MILITARY POSTURE:\n${lines.join('\n')}`;
|
||||
}
|
||||
|
||||
|
||||
private async loadBriefFromCache(): Promise<boolean> {
|
||||
if (this.cachedBrief) return false;
|
||||
const entry = await getPersistentCache<{ summary: string }>(BriefPanel.BRIEF_CACHE_KEY);
|
||||
if (!entry?.data?.summary) return false;
|
||||
this.cachedBrief = entry.data.summary;
|
||||
this.lastBriefUpdate = entry.updatedAt;
|
||||
return true;
|
||||
}
|
||||
// High-priority military/conflict keywords (huge boost)
|
||||
private static readonly MILITARY_KEYWORDS = [
|
||||
'war', 'armada', 'invasion', 'airstrike', 'strike', 'missile', 'troops',
|
||||
'deployed', 'offensive', 'artillery', 'bomb', 'combat', 'fleet', 'warship',
|
||||
'carrier', 'navy', 'airforce', 'deployment', 'mobilization', 'attack',
|
||||
];
|
||||
|
||||
// Violence/casualty keywords (huge boost - human cost stories)
|
||||
private static readonly VIOLENCE_KEYWORDS = [
|
||||
'killed', 'dead', 'death', 'shot', 'blood', 'massacre', 'slaughter',
|
||||
'fatalities', 'casualties', 'wounded', 'injured', 'murdered', 'execution',
|
||||
'crackdown', 'violent', 'clashes', 'gunfire', 'shooting',
|
||||
];
|
||||
|
||||
// Civil unrest keywords (high boost)
|
||||
private static readonly UNREST_KEYWORDS = [
|
||||
'protest', 'protests', 'uprising', 'revolt', 'revolution', 'riot', 'riots',
|
||||
'demonstration', 'unrest', 'dissent', 'rebellion', 'insurgent', 'overthrow',
|
||||
'coup', 'martial law', 'curfew', 'shutdown', 'blackout',
|
||||
];
|
||||
|
||||
// Geopolitical flashpoints (major boost)
|
||||
private static readonly FLASHPOINT_KEYWORDS = [
|
||||
'iran', 'tehran', 'russia', 'moscow', 'china', 'beijing', 'taiwan', 'ukraine', 'kyiv',
|
||||
'north korea', 'pyongyang', 'israel', 'gaza', 'west bank', 'syria', 'damascus',
|
||||
'yemen', 'hezbollah', 'hamas', 'kremlin', 'pentagon', 'nato', 'wagner',
|
||||
];
|
||||
|
||||
// Crisis keywords (moderate boost)
|
||||
private static readonly CRISIS_KEYWORDS = [
|
||||
'crisis', 'emergency', 'catastrophe', 'disaster', 'collapse', 'humanitarian',
|
||||
'sanctions', 'ultimatum', 'threat', 'retaliation', 'escalation', 'tensions',
|
||||
'breaking', 'urgent', 'developing', 'exclusive',
|
||||
];
|
||||
|
||||
// Business/tech context that should REDUCE score (demote business news with military words)
|
||||
private static readonly DEMOTE_KEYWORDS = [
|
||||
'ceo', 'earnings', 'stock', 'startup', 'data center', 'datacenter', 'revenue',
|
||||
'quarterly', 'profit', 'investor', 'ipo', 'funding', 'valuation',
|
||||
];
|
||||
|
||||
private getImportanceScore(cluster: ClusteredEvent): number {
|
||||
let score = 0;
|
||||
const titleLower = cluster.primaryTitle.toLowerCase();
|
||||
|
||||
// Source confirmation (base signal)
|
||||
score += cluster.sourceCount * 10;
|
||||
|
||||
// Violence/casualty keywords: highest priority (+100 base, +25 per match)
|
||||
// "Pools of blood" type stories should always surface
|
||||
const violenceMatches = BriefPanel.VIOLENCE_KEYWORDS.filter(kw => titleLower.includes(kw));
|
||||
if (violenceMatches.length > 0) {
|
||||
score += 100 + (violenceMatches.length * 25);
|
||||
}
|
||||
|
||||
// Military keywords: highest priority (+80 base, +20 per match)
|
||||
const militaryMatches = BriefPanel.MILITARY_KEYWORDS.filter(kw => titleLower.includes(kw));
|
||||
if (militaryMatches.length > 0) {
|
||||
score += 80 + (militaryMatches.length * 20);
|
||||
}
|
||||
|
||||
// Civil unrest: high priority (+70 base, +18 per match)
|
||||
const unrestMatches = BriefPanel.UNREST_KEYWORDS.filter(kw => titleLower.includes(kw));
|
||||
if (unrestMatches.length > 0) {
|
||||
score += 70 + (unrestMatches.length * 18);
|
||||
}
|
||||
|
||||
// Flashpoint keywords: high priority (+60 base, +15 per match)
|
||||
const flashpointMatches = BriefPanel.FLASHPOINT_KEYWORDS.filter(kw => titleLower.includes(kw));
|
||||
if (flashpointMatches.length > 0) {
|
||||
score += 60 + (flashpointMatches.length * 15);
|
||||
}
|
||||
|
||||
// COMBO BONUS: Violence/unrest + flashpoint location = critical story
|
||||
// e.g., "Iran protests" + "blood" = huge boost
|
||||
if ((violenceMatches.length > 0 || unrestMatches.length > 0) && flashpointMatches.length > 0) {
|
||||
score *= 1.5; // 50% bonus for flashpoint unrest
|
||||
}
|
||||
|
||||
// Crisis keywords: moderate priority (+30 base, +10 per match)
|
||||
const crisisMatches = BriefPanel.CRISIS_KEYWORDS.filter(kw => titleLower.includes(kw));
|
||||
if (crisisMatches.length > 0) {
|
||||
score += 30 + (crisisMatches.length * 10);
|
||||
}
|
||||
|
||||
// Demote business/tech news that happens to contain military words
|
||||
const demoteMatches = BriefPanel.DEMOTE_KEYWORDS.filter(kw => titleLower.includes(kw));
|
||||
if (demoteMatches.length > 0) {
|
||||
score *= 0.3; // Heavy penalty for business context
|
||||
}
|
||||
|
||||
// Velocity multiplier
|
||||
const velMultiplier: Record<string, number> = {
|
||||
'viral': 3,
|
||||
'spike': 2.5,
|
||||
'elevated': 1.5,
|
||||
'normal': 1
|
||||
};
|
||||
score *= velMultiplier[cluster.velocity?.level ?? 'normal'] ?? 1;
|
||||
|
||||
// Alert bonus
|
||||
if (cluster.isAlert) score += 50;
|
||||
|
||||
// Recency bonus (decay over 12 hours)
|
||||
const ageMs = Date.now() - cluster.firstSeen.getTime();
|
||||
const ageHours = ageMs / 3600000;
|
||||
const recencyMultiplier = Math.max(0.5, 1 - (ageHours / 12));
|
||||
score *= recencyMultiplier;
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
private selectTopStories(clusters: ClusteredEvent[], maxCount: number): ClusteredEvent[] {
|
||||
// Score ALL clusters first - high-scoring stories override source requirements
|
||||
const allScored = clusters
|
||||
.map(c => ({ cluster: c, score: this.getImportanceScore(c) }));
|
||||
|
||||
// Filter: require at least 2 sources OR alert OR elevated velocity OR high score
|
||||
// High score (>100) means critical keywords were matched - don't require multi-source
|
||||
const candidates = allScored.filter(({ cluster: c, score }) =>
|
||||
c.sourceCount >= 2 ||
|
||||
c.isAlert ||
|
||||
(c.velocity && c.velocity.level !== 'normal') ||
|
||||
score > 100 // Critical stories bypass source requirement
|
||||
);
|
||||
|
||||
// Sort by score
|
||||
const scored = candidates.sort((a, b) => b.score - a.score);
|
||||
|
||||
// Select with source diversity (max 3 from same primary source)
|
||||
const selected: ClusteredEvent[] = [];
|
||||
const sourceCount = new Map<string, number>();
|
||||
const MAX_PER_SOURCE = 3;
|
||||
|
||||
for (const { cluster } of scored) {
|
||||
const source = cluster.primarySource;
|
||||
const count = sourceCount.get(source) || 0;
|
||||
|
||||
if (count < MAX_PER_SOURCE) {
|
||||
selected.push(cluster);
|
||||
sourceCount.set(source, count + 1);
|
||||
}
|
||||
|
||||
if (selected.length >= maxCount) break;
|
||||
}
|
||||
|
||||
return selected;
|
||||
}
|
||||
|
||||
private setProgress(step: number, total: number, message: string): void {
|
||||
const percent = Math.round((step / total) * 100);
|
||||
this.setContent(`
|
||||
<div class="insights-progress">
|
||||
<div class="insights-progress-bar">
|
||||
<div class="insights-progress-fill" style="width: ${percent}%"></div>
|
||||
</div>
|
||||
<div class="insights-progress-info">
|
||||
<span class="insights-progress-step">Step ${step}/${total}</span>
|
||||
<span class="insights-progress-message">${message}</span>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
|
||||
public async updateInsights(clusters: ClusteredEvent[]): Promise<void> {
|
||||
if (this.isHidden) return;
|
||||
|
||||
if (clusters.length === 0) {
|
||||
this.setDataBadge('unavailable');
|
||||
this.setContent('<div class="insights-empty">Waiting for news data...</div>');
|
||||
return;
|
||||
}
|
||||
|
||||
const totalSteps = 4;
|
||||
|
||||
try {
|
||||
// Step 1: Filter and rank stories by composite importance score
|
||||
this.setProgress(1, totalSteps, 'Ranking important stories...');
|
||||
|
||||
const importantClusters = this.selectTopStories(clusters, 8);
|
||||
|
||||
// Run parallel multi-perspective analysis in background (logs to console)
|
||||
// This analyzes ALL clusters, not just the keyword-filtered ones
|
||||
const parallelPromise = parallelAnalysis.analyzeHeadlines(clusters).then(report => {
|
||||
this.lastMissedStories = report.missedByKeywords;
|
||||
const suggestions = parallelAnalysis.getSuggestedImprovements();
|
||||
if (suggestions.length > 0) {
|
||||
console.log('%c💡 Improvement Suggestions:', 'color: #f59e0b; font-weight: bold');
|
||||
suggestions.forEach(s => console.log(` • ${s}`));
|
||||
}
|
||||
}).catch(err => {
|
||||
console.warn('[ParallelAnalysis] Error:', err);
|
||||
});
|
||||
|
||||
// Get geographic signal correlations (geopolitical variant only)
|
||||
// Tech variant focuses on tech news, not military/protest signals
|
||||
let signalSummary: ReturnType<typeof signalAggregator.getSummary>;
|
||||
let focalSummary: ReturnType<typeof focalPointDetector.analyze>;
|
||||
|
||||
if (getSiteVariant() === 'full') {
|
||||
signalSummary = signalAggregator.getSummary();
|
||||
this.lastConvergenceZones = signalSummary.convergenceZones;
|
||||
if (signalSummary.totalSignals > 0) {
|
||||
logSignalSummary();
|
||||
}
|
||||
|
||||
// Run focal point detection (correlates news entities with map signals)
|
||||
focalSummary = focalPointDetector.analyze(clusters, signalSummary);
|
||||
this.lastFocalPoints = focalSummary.focalPoints;
|
||||
if (focalSummary.focalPoints.length > 0) {
|
||||
focalPointDetector.logSummary();
|
||||
// Ingest news for CII BEFORE signaling (so CII has data when it calculates)
|
||||
ingestNewsForCII(clusters);
|
||||
// Signal CII to refresh now that focal points AND news data are available
|
||||
window.dispatchEvent(new CustomEvent('focal-points-ready'));
|
||||
}
|
||||
} else {
|
||||
// Tech variant: no geopolitical signals, just summarize tech news
|
||||
signalSummary = {
|
||||
timestamp: new Date(),
|
||||
totalSignals: 0,
|
||||
byType: {} as Record<string, number>,
|
||||
convergenceZones: [],
|
||||
topCountries: [],
|
||||
aiContext: '',
|
||||
};
|
||||
focalSummary = {
|
||||
focalPoints: [],
|
||||
aiContext: '',
|
||||
timestamp: new Date(),
|
||||
topCountries: [],
|
||||
topCompanies: [],
|
||||
};
|
||||
this.lastConvergenceZones = [];
|
||||
this.lastFocalPoints = [];
|
||||
}
|
||||
|
||||
if (importantClusters.length === 0) {
|
||||
this.setContent('<div class="insights-empty">No breaking or multi-source stories yet</div>');
|
||||
return;
|
||||
}
|
||||
|
||||
const titles = importantClusters.map(c => c.primaryTitle);
|
||||
|
||||
// Step 2: Analyze sentiment (browser-based, fast)
|
||||
this.setProgress(2, totalSteps, 'Analyzing sentiment...');
|
||||
let sentiments: Array<{ label: string; score: number }> | null = null;
|
||||
|
||||
if (mlWorker.isAvailable) {
|
||||
sentiments = await mlWorker.classifySentiment(titles).catch(() => null);
|
||||
}
|
||||
|
||||
// Step 3: Generate World Brief (with cooldown)
|
||||
const loadedFromPersistentCache = await this.loadBriefFromCache();
|
||||
let worldBrief = this.cachedBrief;
|
||||
const now = Date.now();
|
||||
|
||||
let usedCachedBrief = loadedFromPersistentCache;
|
||||
if (!worldBrief || now - this.lastBriefUpdate > BriefPanel.BRIEF_COOLDOWN_MS) {
|
||||
this.setProgress(3, totalSteps, 'Generating world brief...');
|
||||
|
||||
// Pass focal point context + theater posture to AI for correlation-aware summarization
|
||||
// Tech variant: no geopolitical context, just tech news summarization
|
||||
const theaterContext = getSiteVariant() === 'full' ? this.getTheaterPostureContext() : '';
|
||||
const geoContext = getSiteVariant() === 'full'
|
||||
? (focalSummary.aiContext || signalSummary.aiContext) + theaterContext
|
||||
: '';
|
||||
// The AI brief must never hold the panel on a spinner. Race it against
|
||||
// a render deadline: if the summary stalls, we fall through with the
|
||||
// cached (or null) brief and still render the ranked stories.
|
||||
let briefSettled = false;
|
||||
const result = await Promise.race([
|
||||
generateSummary(titles, (_step, _total, msg) => {
|
||||
if (briefSettled) return;
|
||||
this.setProgress(3, totalSteps, `Generating brief: ${msg}`);
|
||||
}, geoContext),
|
||||
new Promise<null>(resolve =>
|
||||
setTimeout(() => resolve(null), BriefPanel.BRIEF_RENDER_TIMEOUT_MS)
|
||||
),
|
||||
]);
|
||||
briefSettled = true;
|
||||
|
||||
if (result) {
|
||||
worldBrief = result.summary;
|
||||
this.cachedBrief = worldBrief;
|
||||
this.lastBriefUpdate = now;
|
||||
usedCachedBrief = false;
|
||||
void setPersistentCache(BriefPanel.BRIEF_CACHE_KEY, { summary: worldBrief });
|
||||
console.log(`[BriefPanel] Brief generated${result.cached ? ' (cached)' : ''}${geoContext ? ' (with geo context)' : ''}`);
|
||||
}
|
||||
} else {
|
||||
usedCachedBrief = true;
|
||||
this.setProgress(3, totalSteps, 'Using cached brief...');
|
||||
}
|
||||
|
||||
this.setDataBadge(worldBrief ? (usedCachedBrief ? 'cached' : 'live') : 'unavailable');
|
||||
|
||||
// Step 4: Wait for parallel analysis to complete
|
||||
this.setProgress(4, totalSteps, 'Multi-perspective analysis...');
|
||||
await parallelPromise;
|
||||
|
||||
this.renderInsights(importantClusters, sentiments, worldBrief);
|
||||
} catch (error) {
|
||||
console.error('[BriefPanel] Error:', error);
|
||||
this.setContent('<div class="insights-error">Analysis failed - retrying...</div>');
|
||||
}
|
||||
}
|
||||
|
||||
private renderInsights(
|
||||
clusters: ClusteredEvent[],
|
||||
sentiments: Array<{ label: string; score: number }> | null,
|
||||
worldBrief: string | null
|
||||
): void {
|
||||
const briefHtml = worldBrief ? this.renderWorldBrief(worldBrief) : '';
|
||||
const focalPointsHtml = this.renderFocalPoints();
|
||||
const convergenceHtml = this.renderConvergenceZones();
|
||||
const sentimentOverview = this.renderSentimentOverview(sentiments);
|
||||
const breakingHtml = this.renderBreakingStories(clusters, sentiments);
|
||||
const statsHtml = this.renderStats(clusters);
|
||||
const missedHtml = this.renderMissedStories();
|
||||
|
||||
this.setContent(`
|
||||
${briefHtml}
|
||||
${focalPointsHtml}
|
||||
${convergenceHtml}
|
||||
${sentimentOverview}
|
||||
${statsHtml}
|
||||
<div class="insights-section">
|
||||
<div class="insights-section-title">BREAKING & CONFIRMED</div>
|
||||
${breakingHtml}
|
||||
</div>
|
||||
${missedHtml}
|
||||
`);
|
||||
}
|
||||
|
||||
private renderWorldBrief(brief: string): string {
|
||||
return `
|
||||
<div class="insights-brief">
|
||||
<div class="insights-section-title">${getSiteVariant() === 'tech' ? '🚀 TECH BRIEF' : '🌍 WORLD BRIEF'}</div>
|
||||
<div class="insights-brief-text">${escapeHtml(brief)}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderBreakingStories(
|
||||
clusters: ClusteredEvent[],
|
||||
sentiments: Array<{ label: string; score: number }> | null
|
||||
): string {
|
||||
return clusters.map((cluster, i) => {
|
||||
const sentiment = sentiments?.[i];
|
||||
const sentimentClass = sentiment?.label === 'negative' ? 'negative' :
|
||||
sentiment?.label === 'positive' ? 'positive' : 'neutral';
|
||||
|
||||
const badges: string[] = [];
|
||||
|
||||
if (cluster.sourceCount >= 3) {
|
||||
badges.push(`<span class="insight-badge confirmed">✓ ${cluster.sourceCount} sources</span>`);
|
||||
} else if (cluster.sourceCount >= 2) {
|
||||
badges.push(`<span class="insight-badge multi">${cluster.sourceCount} sources</span>`);
|
||||
}
|
||||
|
||||
if (cluster.velocity && cluster.velocity.level !== 'normal') {
|
||||
const velIcon = cluster.velocity.trend === 'rising' ? '↑' : '';
|
||||
badges.push(`<span class="insight-badge velocity ${cluster.velocity.level}">${velIcon}+${cluster.velocity.sourcesPerHour}/hr</span>`);
|
||||
}
|
||||
|
||||
if (cluster.isAlert) {
|
||||
badges.push('<span class="insight-badge alert">⚠ ALERT</span>');
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="insight-story">
|
||||
<div class="insight-story-header">
|
||||
<span class="insight-sentiment-dot ${sentimentClass}"></span>
|
||||
<span class="insight-story-title">${escapeHtml(cluster.primaryTitle.slice(0, 100))}${cluster.primaryTitle.length > 100 ? '...' : ''}</span>
|
||||
</div>
|
||||
${badges.length > 0 ? `<div class="insight-badges">${badges.join('')}</div>` : ''}
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
private renderSentimentOverview(sentiments: Array<{ label: string; score: number }> | null): string {
|
||||
if (!sentiments || sentiments.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const negative = sentiments.filter(s => s.label === 'negative').length;
|
||||
const positive = sentiments.filter(s => s.label === 'positive').length;
|
||||
const neutral = sentiments.length - negative - positive;
|
||||
|
||||
const total = sentiments.length;
|
||||
const negPct = Math.round((negative / total) * 100);
|
||||
const neuPct = Math.round((neutral / total) * 100);
|
||||
const posPct = 100 - negPct - neuPct;
|
||||
|
||||
let toneLabel = 'Mixed';
|
||||
let toneClass = 'neutral';
|
||||
if (negative > positive + neutral) {
|
||||
toneLabel = 'Negative';
|
||||
toneClass = 'negative';
|
||||
} else if (positive > negative + neutral) {
|
||||
toneLabel = 'Positive';
|
||||
toneClass = 'positive';
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="insights-sentiment-bar">
|
||||
<div class="sentiment-bar-track">
|
||||
<div class="sentiment-bar-negative" style="width: ${negPct}%"></div>
|
||||
<div class="sentiment-bar-neutral" style="width: ${neuPct}%"></div>
|
||||
<div class="sentiment-bar-positive" style="width: ${posPct}%"></div>
|
||||
</div>
|
||||
<div class="sentiment-bar-labels">
|
||||
<span class="sentiment-label negative">${negative}</span>
|
||||
<span class="sentiment-label neutral">${neutral}</span>
|
||||
<span class="sentiment-label positive">${positive}</span>
|
||||
</div>
|
||||
<div class="sentiment-tone ${toneClass}">Overall: ${toneLabel}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderStats(clusters: ClusteredEvent[]): string {
|
||||
const multiSource = clusters.filter(c => c.sourceCount >= 2).length;
|
||||
const fastMoving = clusters.filter(c => c.velocity && c.velocity.level !== 'normal').length;
|
||||
const alerts = clusters.filter(c => c.isAlert).length;
|
||||
|
||||
return `
|
||||
<div class="insights-stats">
|
||||
<div class="insight-stat">
|
||||
<span class="insight-stat-value">${multiSource}</span>
|
||||
<span class="insight-stat-label">Multi-source</span>
|
||||
</div>
|
||||
<div class="insight-stat">
|
||||
<span class="insight-stat-value">${fastMoving}</span>
|
||||
<span class="insight-stat-label">Fast-moving</span>
|
||||
</div>
|
||||
${alerts > 0 ? `
|
||||
<div class="insight-stat alert">
|
||||
<span class="insight-stat-value">${alerts}</span>
|
||||
<span class="insight-stat-label">Alerts</span>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderMissedStories(): string {
|
||||
if (this.lastMissedStories.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const storiesHtml = this.lastMissedStories.slice(0, 3).map(story => {
|
||||
const topPerspective = story.perspectives
|
||||
.filter(p => p.name !== 'keywords')
|
||||
.sort((a, b) => b.score - a.score)[0];
|
||||
|
||||
const perspectiveName = topPerspective?.name ?? 'ml';
|
||||
const perspectiveScore = topPerspective?.score ?? 0;
|
||||
|
||||
return `
|
||||
<div class="insight-story missed">
|
||||
<div class="insight-story-header">
|
||||
<span class="insight-sentiment-dot ml-flagged"></span>
|
||||
<span class="insight-story-title">${escapeHtml(story.title.slice(0, 80))}${story.title.length > 80 ? '...' : ''}</span>
|
||||
</div>
|
||||
<div class="insight-badges">
|
||||
<span class="insight-badge ml-detected">🔬 ${perspectiveName}: ${(perspectiveScore * 100).toFixed(0)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
return `
|
||||
<div class="insights-section insights-missed">
|
||||
<div class="insights-section-title">🎯 ML DETECTED</div>
|
||||
${storiesHtml}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderConvergenceZones(): string {
|
||||
if (this.lastConvergenceZones.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const zonesHtml = this.lastConvergenceZones.slice(0, 3).map(zone => {
|
||||
const signalIcons: Record<string, string> = {
|
||||
internet_outage: '🌐',
|
||||
military_flight: '✈️',
|
||||
military_vessel: '🚢',
|
||||
protest: '🪧',
|
||||
ais_disruption: '⚓',
|
||||
};
|
||||
|
||||
const icons = zone.signalTypes.map(t => signalIcons[t] || '📍').join('');
|
||||
|
||||
return `
|
||||
<div class="convergence-zone">
|
||||
<div class="convergence-region">${icons} ${escapeHtml(zone.region)}</div>
|
||||
<div class="convergence-description">${escapeHtml(zone.description)}</div>
|
||||
<div class="convergence-stats">${zone.signalTypes.length} signal types • ${zone.totalSignals} events</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
return `
|
||||
<div class="insights-section insights-convergence">
|
||||
<div class="insights-section-title">📍 GEOGRAPHIC CONVERGENCE</div>
|
||||
${zonesHtml}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderFocalPoints(): string {
|
||||
// Only show focal points that have both news AND signals (true correlations)
|
||||
const correlatedFPs = this.lastFocalPoints.filter(
|
||||
fp => fp.newsMentions > 0 && fp.signalCount > 0
|
||||
).slice(0, 5);
|
||||
|
||||
if (correlatedFPs.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const signalIcons: Record<string, string> = {
|
||||
internet_outage: '🌐',
|
||||
military_flight: '✈️',
|
||||
military_vessel: '⚓',
|
||||
protest: '📢',
|
||||
ais_disruption: '🚢',
|
||||
};
|
||||
|
||||
const focalPointsHtml = correlatedFPs.map(fp => {
|
||||
const urgencyClass = fp.urgency;
|
||||
const icons = fp.signalTypes.map(t => signalIcons[t] || '').join(' ');
|
||||
const topHeadline = fp.topHeadlines[0];
|
||||
const headlineText = topHeadline?.title?.slice(0, 60) || '';
|
||||
const headlineUrl = sanitizeUrl(topHeadline?.url || '');
|
||||
|
||||
return `
|
||||
<div class="focal-point ${urgencyClass}">
|
||||
<div class="focal-point-header">
|
||||
<span class="focal-point-name">${escapeHtml(fp.displayName)}</span>
|
||||
<span class="focal-point-urgency ${urgencyClass}">${fp.urgency.toUpperCase()}</span>
|
||||
</div>
|
||||
<div class="focal-point-signals">${icons}</div>
|
||||
<div class="focal-point-stats">
|
||||
${fp.newsMentions} news • ${fp.signalCount} signals
|
||||
</div>
|
||||
${headlineText && headlineUrl ? `<a href="${headlineUrl}" target="_blank" rel="noopener" class="focal-point-headline">"${escapeHtml(headlineText)}..."</a>` : ''}
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
return `
|
||||
<div class="insights-section insights-focal">
|
||||
<div class="insights-section-title">🎯 FOCAL POINTS</div>
|
||||
${focalPointsHtml}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
+117
-614
@@ -1,644 +1,147 @@
|
||||
import { Panel } from './Panel';
|
||||
import { mlWorker } from '@/services/ml-worker';
|
||||
import { generateSummary } from '@/services/summarization';
|
||||
import { parallelAnalysis, type AnalyzedHeadline } from '@/services/parallel-analysis';
|
||||
import { signalAggregator, logSignalSummary, type RegionalConvergence } from '@/services/signal-aggregator';
|
||||
import { focalPointDetector } from '@/services/focal-point-detector';
|
||||
import { ingestNewsForCII } from '@/services/country-instability';
|
||||
import { getTheaterPostureSummaries } from '@/services/military-surge';
|
||||
import { isMobileDevice } from '@/utils';
|
||||
import { escapeHtml, sanitizeUrl } from '@/utils/sanitize';
|
||||
import { getSiteVariant } from '@/config';
|
||||
import { getPersistentCache, setPersistentCache } from '@/services/persistent-cache';
|
||||
import { t } from '@/services/i18n';
|
||||
import type { ClusteredEvent, FocalPoint, MilitaryFlight } from '@/types';
|
||||
import { isAuthenticated, login } from '@/services/iam';
|
||||
import { getInsightsEvents, type InsightsEvent } from '@/services/analytics';
|
||||
import { escapeHtml } from '@/utils/sanitize';
|
||||
import { fmtInt, statTile, sparkline, shareBar, fmtAgo } from '@/utils/cloud-format';
|
||||
|
||||
// Per-org Insights — the caller's OWN product analytics over the native insights
|
||||
// event stream (api.hanzo.ai /v1/insights/events, PostHog-wire compatible). REAL,
|
||||
// org-scoped: the org is pinned server-side from the validated bearer's owner
|
||||
// claim. From the most-recent events it derives the product-analytics signals —
|
||||
// active users (unique distinct_id), sessions, top events by name, and an activity
|
||||
// trend — client-side over the real rows. Honest: an org that has captured nothing
|
||||
// yet gets a clean empty state with the instrument hint, never fabricated numbers.
|
||||
export class InsightsPanel extends Panel {
|
||||
private isHidden = false;
|
||||
private lastBriefUpdate = 0;
|
||||
private cachedBrief: string | null = null;
|
||||
private lastMissedStories: AnalyzedHeadline[] = [];
|
||||
private lastConvergenceZones: RegionalConvergence[] = [];
|
||||
private lastFocalPoints: FocalPoint[] = [];
|
||||
private lastMilitaryFlights: MilitaryFlight[] = [];
|
||||
private static readonly BRIEF_COOLDOWN_MS = 120000; // 2 min cooldown (API has limits)
|
||||
private static readonly BRIEF_RENDER_TIMEOUT_MS = 12000; // render stories even if the AI brief stalls
|
||||
private static readonly BRIEF_CACHE_KEY = 'summary:world-brief';
|
||||
private events: InsightsEvent[] | null = null;
|
||||
private loaded = false;
|
||||
private timer: ReturnType<typeof setInterval> | null = null;
|
||||
private static readonly LIMIT = 200;
|
||||
private static readonly POLL_MS = 45_000;
|
||||
|
||||
constructor() {
|
||||
super({
|
||||
id: 'insights',
|
||||
title: t('panels.insights'),
|
||||
showCount: false,
|
||||
infoTooltip: t('components.insights.infoTooltip'),
|
||||
});
|
||||
|
||||
if (isMobileDevice()) {
|
||||
this.hide();
|
||||
this.isHidden = true;
|
||||
}
|
||||
super({ id: 'org-insights', title: 'Insights', showCount: false, className: 'panel-wide cloud-panel' });
|
||||
void this.fetchData();
|
||||
this.timer = setInterval(() => void this.fetchData(), InsightsPanel.POLL_MS);
|
||||
}
|
||||
|
||||
public setMilitaryFlights(flights: MilitaryFlight[]): void {
|
||||
this.lastMilitaryFlights = flights;
|
||||
public destroy(): void {
|
||||
if (this.timer) { clearInterval(this.timer); this.timer = null; }
|
||||
super.destroy();
|
||||
}
|
||||
|
||||
private getTheaterPostureContext(): string {
|
||||
if (this.lastMilitaryFlights.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const postures = getTheaterPostureSummaries(this.lastMilitaryFlights);
|
||||
const significant = postures.filter(
|
||||
(p) => p.postureLevel === 'critical' || p.postureLevel === 'elevated' || p.strikeCapable
|
||||
);
|
||||
|
||||
if (significant.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const lines = significant.map((p) => {
|
||||
const parts: string[] = [];
|
||||
parts.push(`${p.theaterName}: ${p.totalAircraft} aircraft`);
|
||||
parts.push(`(${p.postureLevel.toUpperCase()})`);
|
||||
if (p.strikeCapable) parts.push('STRIKE CAPABLE');
|
||||
parts.push(`- ${p.summary}`);
|
||||
if (p.targetNation) parts.push(`Focus: ${p.targetNation}`);
|
||||
return parts.join(' ');
|
||||
});
|
||||
|
||||
return `\n\nCRITICAL MILITARY POSTURE:\n${lines.join('\n')}`;
|
||||
private async fetchData(): Promise<void> {
|
||||
if (!isAuthenticated()) { this.loaded = true; this.renderSignedOut(); return; }
|
||||
this.events = await getInsightsEvents(InsightsPanel.LIMIT);
|
||||
this.loaded = true;
|
||||
this.render();
|
||||
}
|
||||
|
||||
|
||||
private async loadBriefFromCache(): Promise<boolean> {
|
||||
if (this.cachedBrief) return false;
|
||||
const entry = await getPersistentCache<{ summary: string }>(InsightsPanel.BRIEF_CACHE_KEY);
|
||||
if (!entry?.data?.summary) return false;
|
||||
this.cachedBrief = entry.data.summary;
|
||||
this.lastBriefUpdate = entry.updatedAt;
|
||||
return true;
|
||||
}
|
||||
// High-priority military/conflict keywords (huge boost)
|
||||
private static readonly MILITARY_KEYWORDS = [
|
||||
'war', 'armada', 'invasion', 'airstrike', 'strike', 'missile', 'troops',
|
||||
'deployed', 'offensive', 'artillery', 'bomb', 'combat', 'fleet', 'warship',
|
||||
'carrier', 'navy', 'airforce', 'deployment', 'mobilization', 'attack',
|
||||
];
|
||||
|
||||
// Violence/casualty keywords (huge boost - human cost stories)
|
||||
private static readonly VIOLENCE_KEYWORDS = [
|
||||
'killed', 'dead', 'death', 'shot', 'blood', 'massacre', 'slaughter',
|
||||
'fatalities', 'casualties', 'wounded', 'injured', 'murdered', 'execution',
|
||||
'crackdown', 'violent', 'clashes', 'gunfire', 'shooting',
|
||||
];
|
||||
|
||||
// Civil unrest keywords (high boost)
|
||||
private static readonly UNREST_KEYWORDS = [
|
||||
'protest', 'protests', 'uprising', 'revolt', 'revolution', 'riot', 'riots',
|
||||
'demonstration', 'unrest', 'dissent', 'rebellion', 'insurgent', 'overthrow',
|
||||
'coup', 'martial law', 'curfew', 'shutdown', 'blackout',
|
||||
];
|
||||
|
||||
// Geopolitical flashpoints (major boost)
|
||||
private static readonly FLASHPOINT_KEYWORDS = [
|
||||
'iran', 'tehran', 'russia', 'moscow', 'china', 'beijing', 'taiwan', 'ukraine', 'kyiv',
|
||||
'north korea', 'pyongyang', 'israel', 'gaza', 'west bank', 'syria', 'damascus',
|
||||
'yemen', 'hezbollah', 'hamas', 'kremlin', 'pentagon', 'nato', 'wagner',
|
||||
];
|
||||
|
||||
// Crisis keywords (moderate boost)
|
||||
private static readonly CRISIS_KEYWORDS = [
|
||||
'crisis', 'emergency', 'catastrophe', 'disaster', 'collapse', 'humanitarian',
|
||||
'sanctions', 'ultimatum', 'threat', 'retaliation', 'escalation', 'tensions',
|
||||
'breaking', 'urgent', 'developing', 'exclusive',
|
||||
];
|
||||
|
||||
// Business/tech context that should REDUCE score (demote business news with military words)
|
||||
private static readonly DEMOTE_KEYWORDS = [
|
||||
'ceo', 'earnings', 'stock', 'startup', 'data center', 'datacenter', 'revenue',
|
||||
'quarterly', 'profit', 'investor', 'ipo', 'funding', 'valuation',
|
||||
];
|
||||
|
||||
private getImportanceScore(cluster: ClusteredEvent): number {
|
||||
let score = 0;
|
||||
const titleLower = cluster.primaryTitle.toLowerCase();
|
||||
|
||||
// Source confirmation (base signal)
|
||||
score += cluster.sourceCount * 10;
|
||||
|
||||
// Violence/casualty keywords: highest priority (+100 base, +25 per match)
|
||||
// "Pools of blood" type stories should always surface
|
||||
const violenceMatches = InsightsPanel.VIOLENCE_KEYWORDS.filter(kw => titleLower.includes(kw));
|
||||
if (violenceMatches.length > 0) {
|
||||
score += 100 + (violenceMatches.length * 25);
|
||||
}
|
||||
|
||||
// Military keywords: highest priority (+80 base, +20 per match)
|
||||
const militaryMatches = InsightsPanel.MILITARY_KEYWORDS.filter(kw => titleLower.includes(kw));
|
||||
if (militaryMatches.length > 0) {
|
||||
score += 80 + (militaryMatches.length * 20);
|
||||
}
|
||||
|
||||
// Civil unrest: high priority (+70 base, +18 per match)
|
||||
const unrestMatches = InsightsPanel.UNREST_KEYWORDS.filter(kw => titleLower.includes(kw));
|
||||
if (unrestMatches.length > 0) {
|
||||
score += 70 + (unrestMatches.length * 18);
|
||||
}
|
||||
|
||||
// Flashpoint keywords: high priority (+60 base, +15 per match)
|
||||
const flashpointMatches = InsightsPanel.FLASHPOINT_KEYWORDS.filter(kw => titleLower.includes(kw));
|
||||
if (flashpointMatches.length > 0) {
|
||||
score += 60 + (flashpointMatches.length * 15);
|
||||
}
|
||||
|
||||
// COMBO BONUS: Violence/unrest + flashpoint location = critical story
|
||||
// e.g., "Iran protests" + "blood" = huge boost
|
||||
if ((violenceMatches.length > 0 || unrestMatches.length > 0) && flashpointMatches.length > 0) {
|
||||
score *= 1.5; // 50% bonus for flashpoint unrest
|
||||
}
|
||||
|
||||
// Crisis keywords: moderate priority (+30 base, +10 per match)
|
||||
const crisisMatches = InsightsPanel.CRISIS_KEYWORDS.filter(kw => titleLower.includes(kw));
|
||||
if (crisisMatches.length > 0) {
|
||||
score += 30 + (crisisMatches.length * 10);
|
||||
}
|
||||
|
||||
// Demote business/tech news that happens to contain military words
|
||||
const demoteMatches = InsightsPanel.DEMOTE_KEYWORDS.filter(kw => titleLower.includes(kw));
|
||||
if (demoteMatches.length > 0) {
|
||||
score *= 0.3; // Heavy penalty for business context
|
||||
}
|
||||
|
||||
// Velocity multiplier
|
||||
const velMultiplier: Record<string, number> = {
|
||||
'viral': 3,
|
||||
'spike': 2.5,
|
||||
'elevated': 1.5,
|
||||
'normal': 1
|
||||
};
|
||||
score *= velMultiplier[cluster.velocity?.level ?? 'normal'] ?? 1;
|
||||
|
||||
// Alert bonus
|
||||
if (cluster.isAlert) score += 50;
|
||||
|
||||
// Recency bonus (decay over 12 hours)
|
||||
const ageMs = Date.now() - cluster.firstSeen.getTime();
|
||||
const ageHours = ageMs / 3600000;
|
||||
const recencyMultiplier = Math.max(0.5, 1 - (ageHours / 12));
|
||||
score *= recencyMultiplier;
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
private selectTopStories(clusters: ClusteredEvent[], maxCount: number): ClusteredEvent[] {
|
||||
// Score ALL clusters first - high-scoring stories override source requirements
|
||||
const allScored = clusters
|
||||
.map(c => ({ cluster: c, score: this.getImportanceScore(c) }));
|
||||
|
||||
// Filter: require at least 2 sources OR alert OR elevated velocity OR high score
|
||||
// High score (>100) means critical keywords were matched - don't require multi-source
|
||||
const candidates = allScored.filter(({ cluster: c, score }) =>
|
||||
c.sourceCount >= 2 ||
|
||||
c.isAlert ||
|
||||
(c.velocity && c.velocity.level !== 'normal') ||
|
||||
score > 100 // Critical stories bypass source requirement
|
||||
);
|
||||
|
||||
// Sort by score
|
||||
const scored = candidates.sort((a, b) => b.score - a.score);
|
||||
|
||||
// Select with source diversity (max 3 from same primary source)
|
||||
const selected: ClusteredEvent[] = [];
|
||||
const sourceCount = new Map<string, number>();
|
||||
const MAX_PER_SOURCE = 3;
|
||||
|
||||
for (const { cluster } of scored) {
|
||||
const source = cluster.primarySource;
|
||||
const count = sourceCount.get(source) || 0;
|
||||
|
||||
if (count < MAX_PER_SOURCE) {
|
||||
selected.push(cluster);
|
||||
sourceCount.set(source, count + 1);
|
||||
}
|
||||
|
||||
if (selected.length >= maxCount) break;
|
||||
}
|
||||
|
||||
return selected;
|
||||
}
|
||||
|
||||
private setProgress(step: number, total: number, message: string): void {
|
||||
const percent = Math.round((step / total) * 100);
|
||||
private renderSignedOut(): void {
|
||||
this.clearDataBadge();
|
||||
this.setContent(`
|
||||
<div class="insights-progress">
|
||||
<div class="insights-progress-bar">
|
||||
<div class="insights-progress-fill" style="width: ${percent}%"></div>
|
||||
</div>
|
||||
<div class="insights-progress-info">
|
||||
<span class="insights-progress-step">Step ${step}/${total}</span>
|
||||
<span class="insights-progress-message">${message}</span>
|
||||
</div>
|
||||
<div class="cloud-signin">
|
||||
<div class="cloud-signin-title">Your product insights</div>
|
||||
<div class="cloud-signin-body">Sign in to see your org's active users, top events and activity trend — scoped to your account.</div>
|
||||
<button type="button" class="cloud-signin-btn" id="orgInsightsSigninBtn">Sign in</button>
|
||||
</div>
|
||||
`);
|
||||
this.content.querySelector('#orgInsightsSigninBtn')?.addEventListener('click', () => void login());
|
||||
}
|
||||
|
||||
public async updateInsights(clusters: ClusteredEvent[]): Promise<void> {
|
||||
if (this.isHidden) return;
|
||||
/** Top events by name (count desc), rendered as labelled share bars. */
|
||||
private topEvents(events: InsightsEvent[]): string {
|
||||
const byName = new Map<string, number>();
|
||||
for (const e of events) {
|
||||
const name = e.event || '(unnamed)';
|
||||
byName.set(name, (byName.get(name) ?? 0) + 1);
|
||||
}
|
||||
const items = [...byName.entries()].sort((a, b) => b[1] - a[1]).slice(0, 8);
|
||||
if (items.length === 0) return '';
|
||||
const max = Math.max(...items.map(([, c]) => c), 1);
|
||||
const rows = items.map(([name, count]) => `<div class="cloud-an-row">
|
||||
<span class="cloud-an-label" title="${escapeHtml(name)}">${escapeHtml(name)}</span>
|
||||
<span class="cloud-an-bar">${shareBar(count / max)}</span>
|
||||
<span class="cloud-an-val">${fmtInt(count)}</span>
|
||||
</div>`).join('');
|
||||
return `<div class="cloud-subhead">Top events</div><div class="cloud-an-col">${rows}</div>`;
|
||||
}
|
||||
|
||||
if (clusters.length === 0) {
|
||||
/** Events-per-bucket counts over the span of the returned events (hour buckets,
|
||||
* degrading to day buckets when the span is wide). Empty when < 2 timestamps. */
|
||||
private trend(events: InsightsEvent[]): number[] {
|
||||
const times = events
|
||||
.map((e) => Date.parse(e.timestamp))
|
||||
.filter((t) => !Number.isNaN(t));
|
||||
if (times.length < 2) return [];
|
||||
const hour = 3_600_000;
|
||||
let step = hour;
|
||||
let min = Math.min(...times);
|
||||
let max = Math.max(...times);
|
||||
if ((max - min) / step > 72) step = 24 * hour; // wide span → day buckets
|
||||
const startB = Math.floor(min / step) * step;
|
||||
const endB = Math.floor(max / step) * step;
|
||||
const n = Math.floor((endB - startB) / step) + 1;
|
||||
const counts: number[] = new Array<number>(n).fill(0);
|
||||
for (const t of times) {
|
||||
const i = Math.floor((Math.floor(t / step) * step - startB) / step);
|
||||
counts[i] = (counts[i] ?? 0) + 1;
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
private render(): void {
|
||||
if (!this.loaded) { this.showLoading('Loading your insights…'); return; }
|
||||
const evs = this.events;
|
||||
if (evs === null) {
|
||||
this.setDataBadge('unavailable');
|
||||
this.setContent('<div class="insights-empty">Waiting for news data...</div>');
|
||||
this.showEmpty('Insights is not available for this account yet.');
|
||||
return;
|
||||
}
|
||||
if (evs.length === 0) {
|
||||
this.setDataBadge('live', 'no events');
|
||||
this.setContent(`
|
||||
<div class="cloud-overview">
|
||||
<div class="cloud-overview-head"><span class="cloud-scope">your org</span></div>
|
||||
${this.emptyStateHtml('No product events captured yet — instrument @hanzo/insights (PostHog-compatible) and your active users, top events and trend will appear here.')}
|
||||
</div>
|
||||
`);
|
||||
return;
|
||||
}
|
||||
|
||||
const totalSteps = 4;
|
||||
this.setDataBadge('live', 'your org');
|
||||
|
||||
try {
|
||||
// Step 1: Filter and rank stories by composite importance score
|
||||
this.setProgress(1, totalSteps, 'Ranking important stories...');
|
||||
const users = new Set(evs.map((e) => e.distinctId).filter(Boolean)).size;
|
||||
const sessions = new Set(evs.map((e) => e.sessionId).filter((s): s is string => !!s)).size;
|
||||
const oldest = evs.reduce((min, e) => {
|
||||
const t = Date.parse(e.timestamp);
|
||||
return !Number.isNaN(t) && t < min ? t : min;
|
||||
}, Date.now());
|
||||
const span = fmtAgo(new Date(oldest).toISOString());
|
||||
|
||||
const importantClusters = this.selectTopStories(clusters, 8);
|
||||
const tiles = [
|
||||
statTile(fmtInt(users), 'active users', `past ${span}`),
|
||||
sessions > 0 ? statTile(fmtInt(sessions), 'sessions') : '',
|
||||
statTile(fmtInt(evs.length), 'events'),
|
||||
].filter(Boolean).join('');
|
||||
|
||||
// Run parallel multi-perspective analysis in background (logs to console)
|
||||
// This analyzes ALL clusters, not just the keyword-filtered ones
|
||||
const parallelPromise = parallelAnalysis.analyzeHeadlines(clusters).then(report => {
|
||||
this.lastMissedStories = report.missedByKeywords;
|
||||
const suggestions = parallelAnalysis.getSuggestedImprovements();
|
||||
if (suggestions.length > 0) {
|
||||
console.log('%c💡 Improvement Suggestions:', 'color: #f59e0b; font-weight: bold');
|
||||
suggestions.forEach(s => console.log(` • ${s}`));
|
||||
}
|
||||
}).catch(err => {
|
||||
console.warn('[ParallelAnalysis] Error:', err);
|
||||
});
|
||||
|
||||
// Get geographic signal correlations (geopolitical variant only)
|
||||
// Tech variant focuses on tech news, not military/protest signals
|
||||
let signalSummary: ReturnType<typeof signalAggregator.getSummary>;
|
||||
let focalSummary: ReturnType<typeof focalPointDetector.analyze>;
|
||||
|
||||
if (getSiteVariant() === 'full') {
|
||||
signalSummary = signalAggregator.getSummary();
|
||||
this.lastConvergenceZones = signalSummary.convergenceZones;
|
||||
if (signalSummary.totalSignals > 0) {
|
||||
logSignalSummary();
|
||||
}
|
||||
|
||||
// Run focal point detection (correlates news entities with map signals)
|
||||
focalSummary = focalPointDetector.analyze(clusters, signalSummary);
|
||||
this.lastFocalPoints = focalSummary.focalPoints;
|
||||
if (focalSummary.focalPoints.length > 0) {
|
||||
focalPointDetector.logSummary();
|
||||
// Ingest news for CII BEFORE signaling (so CII has data when it calculates)
|
||||
ingestNewsForCII(clusters);
|
||||
// Signal CII to refresh now that focal points AND news data are available
|
||||
window.dispatchEvent(new CustomEvent('focal-points-ready'));
|
||||
}
|
||||
} else {
|
||||
// Tech variant: no geopolitical signals, just summarize tech news
|
||||
signalSummary = {
|
||||
timestamp: new Date(),
|
||||
totalSignals: 0,
|
||||
byType: {} as Record<string, number>,
|
||||
convergenceZones: [],
|
||||
topCountries: [],
|
||||
aiContext: '',
|
||||
};
|
||||
focalSummary = {
|
||||
focalPoints: [],
|
||||
aiContext: '',
|
||||
timestamp: new Date(),
|
||||
topCountries: [],
|
||||
topCompanies: [],
|
||||
};
|
||||
this.lastConvergenceZones = [];
|
||||
this.lastFocalPoints = [];
|
||||
}
|
||||
|
||||
if (importantClusters.length === 0) {
|
||||
this.setContent('<div class="insights-empty">No breaking or multi-source stories yet</div>');
|
||||
return;
|
||||
}
|
||||
|
||||
const titles = importantClusters.map(c => c.primaryTitle);
|
||||
|
||||
// Step 2: Analyze sentiment (browser-based, fast)
|
||||
this.setProgress(2, totalSteps, 'Analyzing sentiment...');
|
||||
let sentiments: Array<{ label: string; score: number }> | null = null;
|
||||
|
||||
if (mlWorker.isAvailable) {
|
||||
sentiments = await mlWorker.classifySentiment(titles).catch(() => null);
|
||||
}
|
||||
|
||||
// Step 3: Generate World Brief (with cooldown)
|
||||
const loadedFromPersistentCache = await this.loadBriefFromCache();
|
||||
let worldBrief = this.cachedBrief;
|
||||
const now = Date.now();
|
||||
|
||||
let usedCachedBrief = loadedFromPersistentCache;
|
||||
if (!worldBrief || now - this.lastBriefUpdate > InsightsPanel.BRIEF_COOLDOWN_MS) {
|
||||
this.setProgress(3, totalSteps, 'Generating world brief...');
|
||||
|
||||
// Pass focal point context + theater posture to AI for correlation-aware summarization
|
||||
// Tech variant: no geopolitical context, just tech news summarization
|
||||
const theaterContext = getSiteVariant() === 'full' ? this.getTheaterPostureContext() : '';
|
||||
const geoContext = getSiteVariant() === 'full'
|
||||
? (focalSummary.aiContext || signalSummary.aiContext) + theaterContext
|
||||
: '';
|
||||
// The AI brief must never hold the panel on a spinner. Race it against
|
||||
// a render deadline: if the summary stalls, we fall through with the
|
||||
// cached (or null) brief and still render the ranked stories.
|
||||
let briefSettled = false;
|
||||
const result = await Promise.race([
|
||||
generateSummary(titles, (_step, _total, msg) => {
|
||||
if (briefSettled) return;
|
||||
this.setProgress(3, totalSteps, `Generating brief: ${msg}`);
|
||||
}, geoContext),
|
||||
new Promise<null>(resolve =>
|
||||
setTimeout(() => resolve(null), InsightsPanel.BRIEF_RENDER_TIMEOUT_MS)
|
||||
),
|
||||
]);
|
||||
briefSettled = true;
|
||||
|
||||
if (result) {
|
||||
worldBrief = result.summary;
|
||||
this.cachedBrief = worldBrief;
|
||||
this.lastBriefUpdate = now;
|
||||
usedCachedBrief = false;
|
||||
void setPersistentCache(InsightsPanel.BRIEF_CACHE_KEY, { summary: worldBrief });
|
||||
console.log(`[InsightsPanel] Brief generated${result.cached ? ' (cached)' : ''}${geoContext ? ' (with geo context)' : ''}`);
|
||||
}
|
||||
} else {
|
||||
usedCachedBrief = true;
|
||||
this.setProgress(3, totalSteps, 'Using cached brief...');
|
||||
}
|
||||
|
||||
this.setDataBadge(worldBrief ? (usedCachedBrief ? 'cached' : 'live') : 'unavailable');
|
||||
|
||||
// Step 4: Wait for parallel analysis to complete
|
||||
this.setProgress(4, totalSteps, 'Multi-perspective analysis...');
|
||||
await parallelPromise;
|
||||
|
||||
this.renderInsights(importantClusters, sentiments, worldBrief);
|
||||
} catch (error) {
|
||||
console.error('[InsightsPanel] Error:', error);
|
||||
this.setContent('<div class="insights-error">Analysis failed - retrying...</div>');
|
||||
}
|
||||
}
|
||||
|
||||
private renderInsights(
|
||||
clusters: ClusteredEvent[],
|
||||
sentiments: Array<{ label: string; score: number }> | null,
|
||||
worldBrief: string | null
|
||||
): void {
|
||||
const briefHtml = worldBrief ? this.renderWorldBrief(worldBrief) : '';
|
||||
const focalPointsHtml = this.renderFocalPoints();
|
||||
const convergenceHtml = this.renderConvergenceZones();
|
||||
const sentimentOverview = this.renderSentimentOverview(sentiments);
|
||||
const breakingHtml = this.renderBreakingStories(clusters, sentiments);
|
||||
const statsHtml = this.renderStats(clusters);
|
||||
const missedHtml = this.renderMissedStories();
|
||||
const trend = this.trend(evs);
|
||||
const sparkRow = trend.length >= 2 && trend.some((v) => v > 0)
|
||||
? `<div class="cloud-spark-row">
|
||||
<span class="cloud-spark-label">events · past ${escapeHtml(span)}</span>
|
||||
<span class="cloud-spark-wrap">${sparkline(trend, 220, 30)}</span>
|
||||
</div>`
|
||||
: '';
|
||||
|
||||
this.setContent(`
|
||||
${briefHtml}
|
||||
${focalPointsHtml}
|
||||
${convergenceHtml}
|
||||
${sentimentOverview}
|
||||
${statsHtml}
|
||||
<div class="insights-section">
|
||||
<div class="insights-section-title">BREAKING & CONFIRMED</div>
|
||||
${breakingHtml}
|
||||
<div class="cloud-overview">
|
||||
<div class="cloud-overview-head">
|
||||
<span class="cloud-scope">your org · last ${fmtInt(evs.length)} events</span>
|
||||
<span class="cloud-live-note">past ${escapeHtml(span)}</span>
|
||||
</div>
|
||||
<div class="cloud-stat-grid cloud-stat-grid-3">${tiles}</div>
|
||||
${sparkRow}
|
||||
${this.topEvents(evs)}
|
||||
</div>
|
||||
${missedHtml}
|
||||
`);
|
||||
}
|
||||
|
||||
private renderWorldBrief(brief: string): string {
|
||||
return `
|
||||
<div class="insights-brief">
|
||||
<div class="insights-section-title">${getSiteVariant() === 'tech' ? '🚀 TECH BRIEF' : '🌍 WORLD BRIEF'}</div>
|
||||
<div class="insights-brief-text">${escapeHtml(brief)}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderBreakingStories(
|
||||
clusters: ClusteredEvent[],
|
||||
sentiments: Array<{ label: string; score: number }> | null
|
||||
): string {
|
||||
return clusters.map((cluster, i) => {
|
||||
const sentiment = sentiments?.[i];
|
||||
const sentimentClass = sentiment?.label === 'negative' ? 'negative' :
|
||||
sentiment?.label === 'positive' ? 'positive' : 'neutral';
|
||||
|
||||
const badges: string[] = [];
|
||||
|
||||
if (cluster.sourceCount >= 3) {
|
||||
badges.push(`<span class="insight-badge confirmed">✓ ${cluster.sourceCount} sources</span>`);
|
||||
} else if (cluster.sourceCount >= 2) {
|
||||
badges.push(`<span class="insight-badge multi">${cluster.sourceCount} sources</span>`);
|
||||
}
|
||||
|
||||
if (cluster.velocity && cluster.velocity.level !== 'normal') {
|
||||
const velIcon = cluster.velocity.trend === 'rising' ? '↑' : '';
|
||||
badges.push(`<span class="insight-badge velocity ${cluster.velocity.level}">${velIcon}+${cluster.velocity.sourcesPerHour}/hr</span>`);
|
||||
}
|
||||
|
||||
if (cluster.isAlert) {
|
||||
badges.push('<span class="insight-badge alert">⚠ ALERT</span>');
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="insight-story">
|
||||
<div class="insight-story-header">
|
||||
<span class="insight-sentiment-dot ${sentimentClass}"></span>
|
||||
<span class="insight-story-title">${escapeHtml(cluster.primaryTitle.slice(0, 100))}${cluster.primaryTitle.length > 100 ? '...' : ''}</span>
|
||||
</div>
|
||||
${badges.length > 0 ? `<div class="insight-badges">${badges.join('')}</div>` : ''}
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
private renderSentimentOverview(sentiments: Array<{ label: string; score: number }> | null): string {
|
||||
if (!sentiments || sentiments.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const negative = sentiments.filter(s => s.label === 'negative').length;
|
||||
const positive = sentiments.filter(s => s.label === 'positive').length;
|
||||
const neutral = sentiments.length - negative - positive;
|
||||
|
||||
const total = sentiments.length;
|
||||
const negPct = Math.round((negative / total) * 100);
|
||||
const neuPct = Math.round((neutral / total) * 100);
|
||||
const posPct = 100 - negPct - neuPct;
|
||||
|
||||
let toneLabel = 'Mixed';
|
||||
let toneClass = 'neutral';
|
||||
if (negative > positive + neutral) {
|
||||
toneLabel = 'Negative';
|
||||
toneClass = 'negative';
|
||||
} else if (positive > negative + neutral) {
|
||||
toneLabel = 'Positive';
|
||||
toneClass = 'positive';
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="insights-sentiment-bar">
|
||||
<div class="sentiment-bar-track">
|
||||
<div class="sentiment-bar-negative" style="width: ${negPct}%"></div>
|
||||
<div class="sentiment-bar-neutral" style="width: ${neuPct}%"></div>
|
||||
<div class="sentiment-bar-positive" style="width: ${posPct}%"></div>
|
||||
</div>
|
||||
<div class="sentiment-bar-labels">
|
||||
<span class="sentiment-label negative">${negative}</span>
|
||||
<span class="sentiment-label neutral">${neutral}</span>
|
||||
<span class="sentiment-label positive">${positive}</span>
|
||||
</div>
|
||||
<div class="sentiment-tone ${toneClass}">Overall: ${toneLabel}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderStats(clusters: ClusteredEvent[]): string {
|
||||
const multiSource = clusters.filter(c => c.sourceCount >= 2).length;
|
||||
const fastMoving = clusters.filter(c => c.velocity && c.velocity.level !== 'normal').length;
|
||||
const alerts = clusters.filter(c => c.isAlert).length;
|
||||
|
||||
return `
|
||||
<div class="insights-stats">
|
||||
<div class="insight-stat">
|
||||
<span class="insight-stat-value">${multiSource}</span>
|
||||
<span class="insight-stat-label">Multi-source</span>
|
||||
</div>
|
||||
<div class="insight-stat">
|
||||
<span class="insight-stat-value">${fastMoving}</span>
|
||||
<span class="insight-stat-label">Fast-moving</span>
|
||||
</div>
|
||||
${alerts > 0 ? `
|
||||
<div class="insight-stat alert">
|
||||
<span class="insight-stat-value">${alerts}</span>
|
||||
<span class="insight-stat-label">Alerts</span>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderMissedStories(): string {
|
||||
if (this.lastMissedStories.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const storiesHtml = this.lastMissedStories.slice(0, 3).map(story => {
|
||||
const topPerspective = story.perspectives
|
||||
.filter(p => p.name !== 'keywords')
|
||||
.sort((a, b) => b.score - a.score)[0];
|
||||
|
||||
const perspectiveName = topPerspective?.name ?? 'ml';
|
||||
const perspectiveScore = topPerspective?.score ?? 0;
|
||||
|
||||
return `
|
||||
<div class="insight-story missed">
|
||||
<div class="insight-story-header">
|
||||
<span class="insight-sentiment-dot ml-flagged"></span>
|
||||
<span class="insight-story-title">${escapeHtml(story.title.slice(0, 80))}${story.title.length > 80 ? '...' : ''}</span>
|
||||
</div>
|
||||
<div class="insight-badges">
|
||||
<span class="insight-badge ml-detected">🔬 ${perspectiveName}: ${(perspectiveScore * 100).toFixed(0)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
return `
|
||||
<div class="insights-section insights-missed">
|
||||
<div class="insights-section-title">🎯 ML DETECTED</div>
|
||||
${storiesHtml}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderConvergenceZones(): string {
|
||||
if (this.lastConvergenceZones.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const zonesHtml = this.lastConvergenceZones.slice(0, 3).map(zone => {
|
||||
const signalIcons: Record<string, string> = {
|
||||
internet_outage: '🌐',
|
||||
military_flight: '✈️',
|
||||
military_vessel: '🚢',
|
||||
protest: '🪧',
|
||||
ais_disruption: '⚓',
|
||||
};
|
||||
|
||||
const icons = zone.signalTypes.map(t => signalIcons[t] || '📍').join('');
|
||||
|
||||
return `
|
||||
<div class="convergence-zone">
|
||||
<div class="convergence-region">${icons} ${escapeHtml(zone.region)}</div>
|
||||
<div class="convergence-description">${escapeHtml(zone.description)}</div>
|
||||
<div class="convergence-stats">${zone.signalTypes.length} signal types • ${zone.totalSignals} events</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
return `
|
||||
<div class="insights-section insights-convergence">
|
||||
<div class="insights-section-title">📍 GEOGRAPHIC CONVERGENCE</div>
|
||||
${zonesHtml}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderFocalPoints(): string {
|
||||
// Only show focal points that have both news AND signals (true correlations)
|
||||
const correlatedFPs = this.lastFocalPoints.filter(
|
||||
fp => fp.newsMentions > 0 && fp.signalCount > 0
|
||||
).slice(0, 5);
|
||||
|
||||
if (correlatedFPs.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const signalIcons: Record<string, string> = {
|
||||
internet_outage: '🌐',
|
||||
military_flight: '✈️',
|
||||
military_vessel: '⚓',
|
||||
protest: '📢',
|
||||
ais_disruption: '🚢',
|
||||
};
|
||||
|
||||
const focalPointsHtml = correlatedFPs.map(fp => {
|
||||
const urgencyClass = fp.urgency;
|
||||
const icons = fp.signalTypes.map(t => signalIcons[t] || '').join(' ');
|
||||
const topHeadline = fp.topHeadlines[0];
|
||||
const headlineText = topHeadline?.title?.slice(0, 60) || '';
|
||||
const headlineUrl = sanitizeUrl(topHeadline?.url || '');
|
||||
|
||||
return `
|
||||
<div class="focal-point ${urgencyClass}">
|
||||
<div class="focal-point-header">
|
||||
<span class="focal-point-name">${escapeHtml(fp.displayName)}</span>
|
||||
<span class="focal-point-urgency ${urgencyClass}">${fp.urgency.toUpperCase()}</span>
|
||||
</div>
|
||||
<div class="focal-point-signals">${icons}</div>
|
||||
<div class="focal-point-stats">
|
||||
${fp.newsMentions} news • ${fp.signalCount} signals
|
||||
</div>
|
||||
${headlineText && headlineUrl ? `<a href="${headlineUrl}" target="_blank" rel="noopener" class="focal-point-headline">"${escapeHtml(headlineText)}..."</a>` : ''}
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
return `
|
||||
<div class="insights-section insights-focal">
|
||||
<div class="insights-section-title">🎯 FOCAL POINTS</div>
|
||||
${focalPointsHtml}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,147 +0,0 @@
|
||||
import { Panel } from './Panel';
|
||||
import { isAuthenticated, login } from '@/services/iam';
|
||||
import { getInsightsEvents, type InsightsEvent } from '@/services/analytics';
|
||||
import { escapeHtml } from '@/utils/sanitize';
|
||||
import { fmtInt, statTile, sparkline, shareBar, fmtAgo } from '@/utils/cloud-format';
|
||||
|
||||
// Per-org Insights — the caller's OWN product analytics over the native insights
|
||||
// event stream (api.hanzo.ai /v1/insights/events, PostHog-wire compatible). REAL,
|
||||
// org-scoped: the org is pinned server-side from the validated bearer's owner
|
||||
// claim. From the most-recent events it derives the product-analytics signals —
|
||||
// active users (unique distinct_id), sessions, top events by name, and an activity
|
||||
// trend — client-side over the real rows. Honest: an org that has captured nothing
|
||||
// yet gets a clean empty state with the instrument hint, never fabricated numbers.
|
||||
export class OrgInsightsPanel extends Panel {
|
||||
private events: InsightsEvent[] | null = null;
|
||||
private loaded = false;
|
||||
private timer: ReturnType<typeof setInterval> | null = null;
|
||||
private static readonly LIMIT = 200;
|
||||
private static readonly POLL_MS = 45_000;
|
||||
|
||||
constructor() {
|
||||
super({ id: 'org-insights', title: 'Insights', showCount: false, className: 'panel-wide cloud-panel' });
|
||||
void this.fetchData();
|
||||
this.timer = setInterval(() => void this.fetchData(), OrgInsightsPanel.POLL_MS);
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
if (this.timer) { clearInterval(this.timer); this.timer = null; }
|
||||
super.destroy();
|
||||
}
|
||||
|
||||
private async fetchData(): Promise<void> {
|
||||
if (!isAuthenticated()) { this.loaded = true; this.renderSignedOut(); return; }
|
||||
this.events = await getInsightsEvents(OrgInsightsPanel.LIMIT);
|
||||
this.loaded = true;
|
||||
this.render();
|
||||
}
|
||||
|
||||
private renderSignedOut(): void {
|
||||
this.clearDataBadge();
|
||||
this.setContent(`
|
||||
<div class="cloud-signin">
|
||||
<div class="cloud-signin-title">Your product insights</div>
|
||||
<div class="cloud-signin-body">Sign in to see your org's active users, top events and activity trend — scoped to your account.</div>
|
||||
<button type="button" class="cloud-signin-btn" id="orgInsightsSigninBtn">Sign in</button>
|
||||
</div>
|
||||
`);
|
||||
this.content.querySelector('#orgInsightsSigninBtn')?.addEventListener('click', () => void login());
|
||||
}
|
||||
|
||||
/** Top events by name (count desc), rendered as labelled share bars. */
|
||||
private topEvents(events: InsightsEvent[]): string {
|
||||
const byName = new Map<string, number>();
|
||||
for (const e of events) {
|
||||
const name = e.event || '(unnamed)';
|
||||
byName.set(name, (byName.get(name) ?? 0) + 1);
|
||||
}
|
||||
const items = [...byName.entries()].sort((a, b) => b[1] - a[1]).slice(0, 8);
|
||||
if (items.length === 0) return '';
|
||||
const max = Math.max(...items.map(([, c]) => c), 1);
|
||||
const rows = items.map(([name, count]) => `<div class="cloud-an-row">
|
||||
<span class="cloud-an-label" title="${escapeHtml(name)}">${escapeHtml(name)}</span>
|
||||
<span class="cloud-an-bar">${shareBar(count / max)}</span>
|
||||
<span class="cloud-an-val">${fmtInt(count)}</span>
|
||||
</div>`).join('');
|
||||
return `<div class="cloud-subhead">Top events</div><div class="cloud-an-col">${rows}</div>`;
|
||||
}
|
||||
|
||||
/** Events-per-bucket counts over the span of the returned events (hour buckets,
|
||||
* degrading to day buckets when the span is wide). Empty when < 2 timestamps. */
|
||||
private trend(events: InsightsEvent[]): number[] {
|
||||
const times = events
|
||||
.map((e) => Date.parse(e.timestamp))
|
||||
.filter((t) => !Number.isNaN(t));
|
||||
if (times.length < 2) return [];
|
||||
const hour = 3_600_000;
|
||||
let step = hour;
|
||||
let min = Math.min(...times);
|
||||
let max = Math.max(...times);
|
||||
if ((max - min) / step > 72) step = 24 * hour; // wide span → day buckets
|
||||
const startB = Math.floor(min / step) * step;
|
||||
const endB = Math.floor(max / step) * step;
|
||||
const n = Math.floor((endB - startB) / step) + 1;
|
||||
const counts: number[] = new Array<number>(n).fill(0);
|
||||
for (const t of times) {
|
||||
const i = Math.floor((Math.floor(t / step) * step - startB) / step);
|
||||
counts[i] = (counts[i] ?? 0) + 1;
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
private render(): void {
|
||||
if (!this.loaded) { this.showLoading('Loading your insights…'); return; }
|
||||
const evs = this.events;
|
||||
if (evs === null) {
|
||||
this.setDataBadge('unavailable');
|
||||
this.showEmpty('Insights is not available for this account yet.');
|
||||
return;
|
||||
}
|
||||
if (evs.length === 0) {
|
||||
this.setDataBadge('live', 'no events');
|
||||
this.setContent(`
|
||||
<div class="cloud-overview">
|
||||
<div class="cloud-overview-head"><span class="cloud-scope">your org</span></div>
|
||||
${this.emptyStateHtml('No product events captured yet — instrument @hanzo/insights (PostHog-compatible) and your active users, top events and trend will appear here.')}
|
||||
</div>
|
||||
`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.setDataBadge('live', 'your org');
|
||||
|
||||
const users = new Set(evs.map((e) => e.distinctId).filter(Boolean)).size;
|
||||
const sessions = new Set(evs.map((e) => e.sessionId).filter((s): s is string => !!s)).size;
|
||||
const oldest = evs.reduce((min, e) => {
|
||||
const t = Date.parse(e.timestamp);
|
||||
return !Number.isNaN(t) && t < min ? t : min;
|
||||
}, Date.now());
|
||||
const span = fmtAgo(new Date(oldest).toISOString());
|
||||
|
||||
const tiles = [
|
||||
statTile(fmtInt(users), 'active users', `past ${span}`),
|
||||
sessions > 0 ? statTile(fmtInt(sessions), 'sessions') : '',
|
||||
statTile(fmtInt(evs.length), 'events'),
|
||||
].filter(Boolean).join('');
|
||||
|
||||
const trend = this.trend(evs);
|
||||
const sparkRow = trend.length >= 2 && trend.some((v) => v > 0)
|
||||
? `<div class="cloud-spark-row">
|
||||
<span class="cloud-spark-label">events · past ${escapeHtml(span)}</span>
|
||||
<span class="cloud-spark-wrap">${sparkline(trend, 220, 30)}</span>
|
||||
</div>`
|
||||
: '';
|
||||
|
||||
this.setContent(`
|
||||
<div class="cloud-overview">
|
||||
<div class="cloud-overview-head">
|
||||
<span class="cloud-scope">your org · last ${fmtInt(evs.length)} events</span>
|
||||
<span class="cloud-live-note">past ${escapeHtml(span)}</span>
|
||||
</div>
|
||||
<div class="cloud-stat-grid cloud-stat-grid-3">${tiles}</div>
|
||||
${sparkRow}
|
||||
${this.topEvents(evs)}
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -37,7 +37,7 @@ export * from './IntelligenceGapBadge';
|
||||
export * from './TechEventsPanel';
|
||||
export * from './ServiceStatusPanel';
|
||||
export * from './RuntimeConfigPanel';
|
||||
export * from './InsightsPanel';
|
||||
export * from './BriefPanel';
|
||||
export * from './TechReadinessPanel';
|
||||
export * from './SatelliteFiresPanel';
|
||||
export * from './MacroSignalsPanel';
|
||||
@@ -68,7 +68,7 @@ export * from './MyUsagePanel';
|
||||
export * from './LiveActivityPanel';
|
||||
// Per-org event-platform cards (analytics + insights), org-scoped to the caller.
|
||||
export { OrgAnalyticsPanel } from './OrgAnalyticsPanel';
|
||||
export { OrgInsightsPanel } from './OrgInsightsPanel';
|
||||
export { InsightsPanel } from './InsightsPanel';
|
||||
// AI variant — live Hanzo compute + enso training telemetry
|
||||
export { AiComputePanel } from './AiComputePanel';
|
||||
export { EnsoFlywheelPanel } from './EnsoFlywheelPanel';
|
||||
|
||||
Reference in New Issue
Block a user