From fe1ce20bf6b021395c69465ff1e60d9d09302b67 Mon Sep 17 00:00:00 2001 From: Elie Habib Date: Sun, 25 Jan 2026 11:14:08 +0400 Subject: [PATCH] Redesign AI Insights panel for usefulness - Remove broken T5 summaries (model too weak for abstractive summarization) - Focus on multi-source confirmed stories (2+ sources) - Show fast-moving and alert stories - Add stats bar: multi-source count, fast-moving count, alerts - Improve sentiment visualization with bar and overall tone - Filter out single-source noise --- src/components/InsightsPanel.ts | 239 ++++++++++++++++---------------- src/styles/main.css | 160 +++++++++++++++------ 2 files changed, 234 insertions(+), 165 deletions(-) diff --git a/src/components/InsightsPanel.ts b/src/components/InsightsPanel.ts index 8fac973ba..e8fa4b5db 100644 --- a/src/components/InsightsPanel.ts +++ b/src/components/InsightsPanel.ts @@ -4,12 +4,6 @@ import { isMobileDevice } from '@/utils'; import { escapeHtml } from '@/utils/sanitize'; import type { ClusteredEvent } from '@/types'; -interface NEREntity { - text: string; - type: string; - confidence: number; -} - export class InsightsPanel extends Panel { private isHidden = false; @@ -21,9 +15,9 @@ export class InsightsPanel extends Panel { infoTooltip: ` AI-Powered Analysis
Uses local ML models for:
- • Themes: Top story clusters
- • Entities: People, orgs, locations
+ • Breaking Stories: Multi-source confirmed
Sentiment: News tone analysis
+ • Velocity: Fast-moving stories
Desktop only • Models run in browser `, }); @@ -43,19 +37,39 @@ export class InsightsPanel extends Panel { this.showLoading(); try { - const topClusters = clusters.slice(0, 5); - const titles = topClusters.map(c => c.primaryTitle); + // Filter to only important stories: multi-source OR fast-moving OR alerts + const importantStories = clusters.filter(c => + c.sourceCount >= 2 || + (c.velocity && c.velocity.level !== 'normal') || + c.isAlert + ); - const [summaries, sentiments] = await Promise.all([ - mlWorker.summarize(titles).catch(() => null), - mlWorker.classifySentiment(titles).catch(() => null), - ]); + // Sort by importance: multi-source first, then velocity + const sortedClusters = importantStories.sort((a, b) => { + // Alerts first + if (a.isAlert !== b.isAlert) return a.isAlert ? -1 : 1; + // Then multi-source + if (a.sourceCount !== b.sourceCount) return b.sourceCount - a.sourceCount; + // Then by velocity + const velA = a.velocity?.sourcesPerHour ?? 0; + const velB = b.velocity?.sourcesPerHour ?? 0; + return velB - velA; + }); - const allTitles = clusters.slice(0, 20).map(c => c.primaryTitle).join('. '); - const entitiesResult = await mlWorker.extractEntities([allTitles]).catch(() => null); - const entities = entitiesResult?.[0] ?? []; + // Take top 8 for sentiment analysis + const importantClusters = sortedClusters.slice(0, 8); - this.renderInsights(topClusters, summaries, sentiments, entities); + if (importantClusters.length === 0) { + this.setContent('
No breaking or multi-source stories yet
'); + return; + } + + const titles = importantClusters.map(c => c.primaryTitle); + + // Only get sentiment - skip T5 summarization (too weak for real summaries) + const sentiments = await mlWorker.classifySentiment(titles).catch(() => null); + + this.renderInsights(importantClusters, sentiments); } catch (error) { console.error('[InsightsPanel] Error:', error); this.setContent('
Analysis failed
'); @@ -64,142 +78,127 @@ export class InsightsPanel extends Panel { private renderInsights( clusters: ClusteredEvent[], - summaries: string[] | null, - sentiments: Array<{ label: string; score: number }> | null, - entities: NEREntity[] + sentiments: Array<{ label: string; score: number }> | null ): void { - const themesHtml = this.renderThemes(clusters, summaries, sentiments); - const entitiesHtml = this.renderEntities(entities); const sentimentOverview = this.renderSentimentOverview(sentiments); + const breakingHtml = this.renderBreakingStories(clusters, sentiments); + const statsHtml = this.renderStats(clusters); this.setContent(` ${sentimentOverview} + ${statsHtml}
-
TOP THEMES
- ${themesHtml} -
-
-
KEY ENTITIES
- ${entitiesHtml} +
BREAKING & CONFIRMED
+ ${breakingHtml}
`); } - private renderThemes( + private renderBreakingStories( clusters: ClusteredEvent[], - summaries: string[] | null, sentiments: Array<{ label: string; score: number }> | null ): string { + // Show multi-source and fast-moving stories return clusters.map((cluster, i) => { - const summary = summaries?.[i]; const sentiment = sentiments?.[i]; const sentimentClass = sentiment?.label === 'negative' ? 'negative' : sentiment?.label === 'positive' ? 'positive' : 'neutral'; + const badges: string[] = []; + + // Multi-source badge + if (cluster.sourceCount >= 3) { + badges.push(`✓ ${cluster.sourceCount} sources`); + } else if (cluster.sourceCount >= 2) { + badges.push(`${cluster.sourceCount} sources`); + } + + // Velocity badge + if (cluster.velocity && cluster.velocity.level !== 'normal') { + const velIcon = cluster.velocity.trend === 'rising' ? '↑' : ''; + badges.push(`${velIcon}+${cluster.velocity.sourcesPerHour}/hr`); + } + + // Alert badge + if (cluster.isAlert) { + badges.push('⚠ ALERT'); + } + return ` -
-
+
+
- ${escapeHtml(cluster.primaryTitle.slice(0, 80))}${cluster.primaryTitle.length > 80 ? '...' : ''} + ${escapeHtml(cluster.primaryTitle.slice(0, 100))}${cluster.primaryTitle.length > 100 ? '...' : ''}
- ${summary ? `
${escapeHtml(summary)}
` : ''} -
${cluster.sourceCount} sources
+ ${badges.length > 0 ? `
${badges.join('')}
` : ''}
`; }).join(''); } - private renderEntities(entities: NEREntity[]): string { - if (!entities.length) { - return '
No entities detected
'; - } - - const grouped = this.groupEntities(entities); - const sections: string[] = []; - - if (grouped.PER.length > 0) { - sections.push(` -
- People: - ${grouped.PER.slice(0, 5).map(e => - `${escapeHtml(e.text)}` - ).join('')} -
- `); - } - - if (grouped.ORG.length > 0) { - sections.push(` -
- Organizations: - ${grouped.ORG.slice(0, 5).map(e => - `${escapeHtml(e.text)}` - ).join('')} -
- `); - } - - if (grouped.LOC.length > 0) { - sections.push(` -
- Locations: - ${grouped.LOC.slice(0, 5).map(e => - `${escapeHtml(e.text)}` - ).join('')} -
- `); - } - - return sections.join('') || '
No entities detected
'; - } - - private groupEntities(entities: NEREntity[]): { PER: NEREntity[]; ORG: NEREntity[]; LOC: NEREntity[]; MISC: NEREntity[] } { - const grouped = { PER: [] as NEREntity[], ORG: [] as NEREntity[], LOC: [] as NEREntity[], MISC: [] as NEREntity[] }; - const seen = new Set(); - - for (const entity of entities) { - if (!entity.type || !entity.text || entity.confidence < 0.7) continue; - const key = `${entity.type}:${entity.text.toLowerCase()}`; - if (seen.has(key)) continue; - seen.add(key); - - const type = entity.type.toUpperCase() as keyof typeof grouped; - if (type in grouped) { - grouped[type].push(entity); - } else { - grouped.MISC.push(entity); - } - } - - return grouped; - } - private renderSentimentOverview(sentiments: Array<{ label: string; score: number }> | null): string { - if (!sentiments?.length) return ''; - - const counts = { positive: 0, neutral: 0, negative: 0 }; - for (const s of sentiments) { - if (s.label === 'positive') counts.positive++; - else if (s.label === 'negative') counts.negative++; - else counts.neutral++; + 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 dominant = counts.negative > counts.positive ? 'negative' : - counts.positive > counts.negative ? 'positive' : 'neutral'; + const negPct = Math.round((negative / total) * 100); + const neuPct = Math.round((neutral / total) * 100); + const posPct = 100 - negPct - neuPct; + + // Determine overall tone + let toneLabel = 'Mixed'; + let toneClass = 'neutral'; + if (negative > positive + neutral) { + toneLabel = 'Negative'; + toneClass = 'negative'; + } else if (positive > negative + neutral) { + toneLabel = 'Positive'; + toneClass = 'positive'; + } return ` -
-
-
-
-
+
+
+
+
+
-
- ${counts.negative} - ${counts.neutral} - ${counts.positive} +
+ ${negative} + ${neutral} + ${positive}
+
Overall: ${toneLabel}
+
+ `; + } + + 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 ` +
+
+ ${multiSource} + Multi-source +
+
+ ${fastMoving} + Fast-moving +
+ ${alerts > 0 ? ` +
+ ${alerts} + Alerts +
+ ` : ''}
`; } diff --git a/src/styles/main.css b/src/styles/main.css index 53e2ed9c5..cd5dad436 100644 --- a/src/styles/main.css +++ b/src/styles/main.css @@ -9114,37 +9114,104 @@ body.playback-mode .status-dot { color: var(--red); } -/* Insight Themes */ -.insight-theme { - padding: 6px 0; +/* Insights Panel - Stats */ +.insights-stats { + display: flex; + gap: 12px; + margin-bottom: 12px; + padding: 8px; + background: rgba(255, 255, 255, 0.02); + border-radius: 4px; +} + +.insight-stat { + text-align: center; + flex: 1; +} + +.insight-stat-value { + display: block; + font-size: 18px; + font-weight: 600; + color: var(--text); +} + +.insight-stat-label { + font-size: 9px; + color: var(--text-dim); + text-transform: uppercase; +} + +.insight-stat.alert .insight-stat-value { + color: var(--red); +} + +/* Insights Panel - Stories */ +.insight-story { + padding: 8px 0; border-bottom: 1px solid var(--border); } -.insight-theme:last-child { +.insight-story:last-child { border-bottom: none; } -.insight-theme-title { - font-size: 11px; - color: var(--text); - margin-bottom: 2px; +.insight-story-header { display: flex; align-items: flex-start; gap: 6px; } -.insight-summary { - font-size: 10px; - color: var(--text-dim); +.insight-story-title { + font-size: 11px; + color: var(--text); line-height: 1.4; - padding-left: 14px; } -.insight-meta { +.insight-badges { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-top: 4px; + padding-left: 12px; +} + +.insight-badge { font-size: 9px; + padding: 1px 5px; + border-radius: 3px; + background: var(--surface); color: var(--text-dim); - padding-left: 14px; - margin-top: 2px; +} + +.insight-badge.confirmed { + background: rgba(74, 222, 128, 0.15); + color: var(--green); +} + +.insight-badge.multi { + background: rgba(255, 255, 255, 0.05); + color: var(--text); +} + +.insight-badge.velocity { + background: rgba(251, 191, 36, 0.15); + color: var(--yellow); +} + +.insight-badge.velocity.elevated { + background: rgba(251, 146, 60, 0.15); + color: var(--orange); +} + +.insight-badge.velocity.high { + background: rgba(239, 68, 68, 0.15); + color: var(--red); +} + +.insight-badge.alert { + background: rgba(239, 68, 68, 0.15); + color: var(--red); } /* Sentiment Dots */ @@ -9153,7 +9220,7 @@ body.playback-mode .status-dot { height: 6px; border-radius: 50%; flex-shrink: 0; - margin-top: 4px; + margin-top: 5px; } .insight-sentiment-dot.positive { @@ -9168,64 +9235,67 @@ body.playback-mode .status-dot { background: var(--text-dim); } -/* Entity Groups */ -.entity-group { - margin-bottom: 8px; -} - -.entity-group-label { - font-size: 9px; - color: var(--text-dim); - margin-right: 6px; -} - -/* Sentiment Overview */ -.insights-sentiment-overview { +/* Sentiment Bar */ +.insights-sentiment-bar { margin-bottom: 12px; padding: 8px; background: rgba(255, 255, 255, 0.02); border-radius: 4px; } -.sentiment-bar { +.sentiment-bar-track { display: flex; - height: 4px; - border-radius: 2px; + height: 6px; + border-radius: 3px; overflow: hidden; margin-bottom: 4px; } -.sentiment-segment { - height: 100%; - transition: width 0.3s ease; -} - -.sentiment-segment.negative { +.sentiment-bar-negative { background: var(--red); + height: 100%; } -.sentiment-segment.neutral { +.sentiment-bar-neutral { background: var(--text-dim); + height: 100%; } -.sentiment-segment.positive { +.sentiment-bar-positive { background: var(--green); + height: 100%; } -.sentiment-labels { +.sentiment-bar-labels { display: flex; justify-content: space-between; - font-size: 9px; + font-size: 10px; + font-weight: 500; } -.sentiment-labels .negative { +.sentiment-label.negative { color: var(--red); } -.sentiment-labels .neutral { +.sentiment-label.neutral { color: var(--text-dim); } -.sentiment-labels .positive { +.sentiment-label.positive { + color: var(--green); +} + +.sentiment-tone { + text-align: center; + font-size: 10px; + margin-top: 6px; + color: var(--text-dim); +} + +.sentiment-tone.negative { + color: var(--red); +} + +.sentiment-tone.positive { color: var(--green); }