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
This commit is contained in:
Elie Habib
2026-01-25 11:14:08 +04:00
parent 28240d4c94
commit fe1ce20bf6
2 changed files with 234 additions and 165 deletions

View File

@@ -4,12 +4,6 @@ import { isMobileDevice } from '@/utils';
import { escapeHtml } from '@/utils/sanitize'; import { escapeHtml } from '@/utils/sanitize';
import type { ClusteredEvent } from '@/types'; import type { ClusteredEvent } from '@/types';
interface NEREntity {
text: string;
type: string;
confidence: number;
}
export class InsightsPanel extends Panel { export class InsightsPanel extends Panel {
private isHidden = false; private isHidden = false;
@@ -21,9 +15,9 @@ export class InsightsPanel extends Panel {
infoTooltip: ` infoTooltip: `
<strong>AI-Powered Analysis</strong><br> <strong>AI-Powered Analysis</strong><br>
Uses local ML models for:<br> Uses local ML models for:<br>
• <strong>Themes</strong>: Top story clusters<br> • <strong>Breaking Stories</strong>: Multi-source confirmed<br>
• <strong>Entities</strong>: People, orgs, locations<br>
• <strong>Sentiment</strong>: News tone analysis<br> • <strong>Sentiment</strong>: News tone analysis<br>
• <strong>Velocity</strong>: Fast-moving stories<br>
<em>Desktop only • Models run in browser</em> <em>Desktop only • Models run in browser</em>
`, `,
}); });
@@ -43,19 +37,39 @@ export class InsightsPanel extends Panel {
this.showLoading(); this.showLoading();
try { try {
const topClusters = clusters.slice(0, 5); // Filter to only important stories: multi-source OR fast-moving OR alerts
const titles = topClusters.map(c => c.primaryTitle); const importantStories = clusters.filter(c =>
c.sourceCount >= 2 ||
(c.velocity && c.velocity.level !== 'normal') ||
c.isAlert
);
const [summaries, sentiments] = await Promise.all([ // Sort by importance: multi-source first, then velocity
mlWorker.summarize(titles).catch(() => null), const sortedClusters = importantStories.sort((a, b) => {
mlWorker.classifySentiment(titles).catch(() => null), // 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('. '); // Take top 8 for sentiment analysis
const entitiesResult = await mlWorker.extractEntities([allTitles]).catch(() => null); const importantClusters = sortedClusters.slice(0, 8);
const entities = entitiesResult?.[0] ?? [];
this.renderInsights(topClusters, summaries, sentiments, entities); 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);
// 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) { } catch (error) {
console.error('[InsightsPanel] Error:', error); console.error('[InsightsPanel] Error:', error);
this.setContent('<div class="insights-error">Analysis failed</div>'); this.setContent('<div class="insights-error">Analysis failed</div>');
@@ -64,142 +78,127 @@ export class InsightsPanel extends Panel {
private renderInsights( private renderInsights(
clusters: ClusteredEvent[], clusters: ClusteredEvent[],
summaries: string[] | null, sentiments: Array<{ label: string; score: number }> | null
sentiments: Array<{ label: string; score: number }> | null,
entities: NEREntity[]
): void { ): void {
const themesHtml = this.renderThemes(clusters, summaries, sentiments);
const entitiesHtml = this.renderEntities(entities);
const sentimentOverview = this.renderSentimentOverview(sentiments); const sentimentOverview = this.renderSentimentOverview(sentiments);
const breakingHtml = this.renderBreakingStories(clusters, sentiments);
const statsHtml = this.renderStats(clusters);
this.setContent(` this.setContent(`
${sentimentOverview} ${sentimentOverview}
${statsHtml}
<div class="insights-section"> <div class="insights-section">
<div class="insights-section-title">TOP THEMES</div> <div class="insights-section-title">BREAKING & CONFIRMED</div>
${themesHtml} ${breakingHtml}
</div>
<div class="insights-section">
<div class="insights-section-title">KEY ENTITIES</div>
${entitiesHtml}
</div> </div>
`); `);
} }
private renderThemes( private renderBreakingStories(
clusters: ClusteredEvent[], clusters: ClusteredEvent[],
summaries: string[] | null,
sentiments: Array<{ label: string; score: number }> | null sentiments: Array<{ label: string; score: number }> | null
): string { ): string {
// Show multi-source and fast-moving stories
return clusters.map((cluster, i) => { return clusters.map((cluster, i) => {
const summary = summaries?.[i];
const sentiment = sentiments?.[i]; const sentiment = sentiments?.[i];
const sentimentClass = sentiment?.label === 'negative' ? 'negative' : const sentimentClass = sentiment?.label === 'negative' ? 'negative' :
sentiment?.label === 'positive' ? 'positive' : 'neutral'; sentiment?.label === 'positive' ? 'positive' : 'neutral';
const badges: string[] = [];
// Multi-source badge
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>`);
}
// Velocity badge
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>`);
}
// Alert badge
if (cluster.isAlert) {
badges.push('<span class="insight-badge alert">⚠ ALERT</span>');
}
return ` return `
<div class="insight-theme"> <div class="insight-story">
<div class="insight-theme-title"> <div class="insight-story-header">
<span class="insight-sentiment-dot ${sentimentClass}"></span> <span class="insight-sentiment-dot ${sentimentClass}"></span>
${escapeHtml(cluster.primaryTitle.slice(0, 80))}${cluster.primaryTitle.length > 80 ? '...' : ''} <span class="insight-story-title">${escapeHtml(cluster.primaryTitle.slice(0, 100))}${cluster.primaryTitle.length > 100 ? '...' : ''}</span>
</div> </div>
${summary ? `<div class="insight-summary">${escapeHtml(summary)}</div>` : ''} ${badges.length > 0 ? `<div class="insight-badges">${badges.join('')}</div>` : ''}
<div class="insight-meta">${cluster.sourceCount} sources</div>
</div> </div>
`; `;
}).join(''); }).join('');
} }
private renderEntities(entities: NEREntity[]): string {
if (!entities.length) {
return '<div class="insights-empty">No entities detected</div>';
}
const grouped = this.groupEntities(entities);
const sections: string[] = [];
if (grouped.PER.length > 0) {
sections.push(`
<div class="entity-group">
<span class="entity-group-label">People:</span>
${grouped.PER.slice(0, 5).map(e =>
`<span class="entity-pill person">${escapeHtml(e.text)}</span>`
).join('')}
</div>
`);
}
if (grouped.ORG.length > 0) {
sections.push(`
<div class="entity-group">
<span class="entity-group-label">Organizations:</span>
${grouped.ORG.slice(0, 5).map(e =>
`<span class="entity-pill organization">${escapeHtml(e.text)}</span>`
).join('')}
</div>
`);
}
if (grouped.LOC.length > 0) {
sections.push(`
<div class="entity-group">
<span class="entity-group-label">Locations:</span>
${grouped.LOC.slice(0, 5).map(e =>
`<span class="entity-pill location">${escapeHtml(e.text)}</span>`
).join('')}
</div>
`);
}
return sections.join('') || '<div class="insights-empty">No entities detected</div>';
}
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<string>();
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 { private renderSentimentOverview(sentiments: Array<{ label: string; score: number }> | null): string {
if (!sentiments?.length) return ''; if (!sentiments || sentiments.length === 0) {
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++;
} }
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 total = sentiments.length;
const dominant = counts.negative > counts.positive ? 'negative' : const negPct = Math.round((negative / total) * 100);
counts.positive > counts.negative ? 'positive' : 'neutral'; 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 ` return `
<div class="insights-sentiment-overview ${dominant}"> <div class="insights-sentiment-bar">
<div class="sentiment-bar"> <div class="sentiment-bar-track">
<div class="sentiment-segment negative" style="width: ${(counts.negative / total) * 100}%"></div> <div class="sentiment-bar-negative" style="width: ${negPct}%"></div>
<div class="sentiment-segment neutral" style="width: ${(counts.neutral / total) * 100}%"></div> <div class="sentiment-bar-neutral" style="width: ${neuPct}%"></div>
<div class="sentiment-segment positive" style="width: ${(counts.positive / total) * 100}%"></div> <div class="sentiment-bar-positive" style="width: ${posPct}%"></div>
</div> </div>
<div class="sentiment-labels"> <div class="sentiment-bar-labels">
<span class="negative">${counts.negative}</span> <span class="sentiment-label negative">${negative}</span>
<span class="neutral">${counts.neutral}</span> <span class="sentiment-label neutral">${neutral}</span>
<span class="positive">${counts.positive}</span> <span class="sentiment-label positive">${positive}</span>
</div> </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> </div>
`; `;
} }

View File

@@ -9114,37 +9114,104 @@ body.playback-mode .status-dot {
color: var(--red); color: var(--red);
} }
/* Insight Themes */ /* Insights Panel - Stats */
.insight-theme { .insights-stats {
padding: 6px 0; 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); border-bottom: 1px solid var(--border);
} }
.insight-theme:last-child { .insight-story:last-child {
border-bottom: none; border-bottom: none;
} }
.insight-theme-title { .insight-story-header {
font-size: 11px;
color: var(--text);
margin-bottom: 2px;
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
gap: 6px; gap: 6px;
} }
.insight-summary { .insight-story-title {
font-size: 10px; font-size: 11px;
color: var(--text-dim); color: var(--text);
line-height: 1.4; 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; font-size: 9px;
padding: 1px 5px;
border-radius: 3px;
background: var(--surface);
color: var(--text-dim); 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 */ /* Sentiment Dots */
@@ -9153,7 +9220,7 @@ body.playback-mode .status-dot {
height: 6px; height: 6px;
border-radius: 50%; border-radius: 50%;
flex-shrink: 0; flex-shrink: 0;
margin-top: 4px; margin-top: 5px;
} }
.insight-sentiment-dot.positive { .insight-sentiment-dot.positive {
@@ -9168,64 +9235,67 @@ body.playback-mode .status-dot {
background: var(--text-dim); background: var(--text-dim);
} }
/* Entity Groups */ /* Sentiment Bar */
.entity-group { .insights-sentiment-bar {
margin-bottom: 8px;
}
.entity-group-label {
font-size: 9px;
color: var(--text-dim);
margin-right: 6px;
}
/* Sentiment Overview */
.insights-sentiment-overview {
margin-bottom: 12px; margin-bottom: 12px;
padding: 8px; padding: 8px;
background: rgba(255, 255, 255, 0.02); background: rgba(255, 255, 255, 0.02);
border-radius: 4px; border-radius: 4px;
} }
.sentiment-bar { .sentiment-bar-track {
display: flex; display: flex;
height: 4px; height: 6px;
border-radius: 2px; border-radius: 3px;
overflow: hidden; overflow: hidden;
margin-bottom: 4px; margin-bottom: 4px;
} }
.sentiment-segment { .sentiment-bar-negative {
height: 100%;
transition: width 0.3s ease;
}
.sentiment-segment.negative {
background: var(--red); background: var(--red);
height: 100%;
} }
.sentiment-segment.neutral { .sentiment-bar-neutral {
background: var(--text-dim); background: var(--text-dim);
height: 100%;
} }
.sentiment-segment.positive { .sentiment-bar-positive {
background: var(--green); background: var(--green);
height: 100%;
} }
.sentiment-labels { .sentiment-bar-labels {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
font-size: 9px; font-size: 10px;
font-weight: 500;
} }
.sentiment-labels .negative { .sentiment-label.negative {
color: var(--red); color: var(--red);
} }
.sentiment-labels .neutral { .sentiment-label.neutral {
color: var(--text-dim); 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); color: var(--green);
} }