Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion server/worldmonitor/news/v1/_shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,11 +159,13 @@ export function getProviderCredentials(provider: string): ProviderCredentials |
if (apiKey) {
headers['Authorization'] = `Bearer ${apiKey}`;
}
const rawMax = parseInt(process.env.OLLAMA_MAX_TOKENS || '300', 10);
const ollamaMaxTokens = Number.isFinite(rawMax) ? Math.min(Math.max(rawMax, 50), 2000) : 300;
return {
apiUrl: new URL('/v1/chat/completions', baseUrl).toString(),
model: process.env.OLLAMA_MODEL || 'llama3.1:8b',
headers,
extraBody: { think: false },
extraBody: { think: false, max_tokens: ollamaMaxTokens },
};
}

Expand Down
9 changes: 8 additions & 1 deletion server/worldmonitor/news/v1/summarize-article.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import { CHROME_UA } from '../../../_shared/constants';
// Reasoning preamble detection
// ======================================================================

export const TASK_NARRATION = /^(we need to|i need to|let me|i'll |i should|i will |the task is|the instructions|according to the rules|so we need to|okay[,.]\s*(i'll|let me|so|we need|the task|i should|i will)|sure[,.]\s*(i'll|let me|so|we need|the task|i should|i will|here))/i;
export const TASK_NARRATION = /^(we need to|i need to|let me|i'll |i should|i will |the task is|the instructions|according to the rules|so we need to|okay[,.]\s*(i'll|let me|so|we need|the task|i should|i will)|sure[,.]\s*(i'll|let me|so|we need|the task|i should|i will|here)|first[, ]+(i|we|let)|to summarize (the headlines|the task|this)|my task (is|was|:)|step \d)/i;
export const PROMPT_ECHO = /^(summarize the top story|summarize the key|rules:|here are the rules|the top story is likely)/i;

export function hasReasoningPreamble(text: string): boolean {
Expand Down Expand Up @@ -137,6 +137,7 @@ export async function summarizeArticle(
.replace(/<\|thinking\|>[\s\S]*?<\|\/thinking\|>/gi, '')
.replace(/<reasoning>[\s\S]*?<\/reasoning>/gi, '')
.replace(/<reflection>[\s\S]*?<\/reflection>/gi, '')
.replace(/<\|begin_of_thought\|>[\s\S]*?<\|end_of_thought\|>/gi, '')
.trim();

// Strip unterminated thinking blocks (no closing tag)
Expand All @@ -145,8 +146,14 @@ export async function summarizeArticle(
.replace(/<\|thinking\|>[\s\S]*/gi, '')
.replace(/<reasoning>[\s\S]*/gi, '')
.replace(/<reflection>[\s\S]*/gi, '')
.replace(/<\|begin_of_thought\|>[\s\S]*/gi, '')
.trim();

if (['brief', 'analysis'].includes(mode) && rawContent.length < 20) {
console.warn(`[SummarizeArticle:${provider}] Output too short after stripping (${rawContent.length} chars), rejecting`);
return null;
}

if (['brief', 'analysis'].includes(mode) && hasReasoningPreamble(rawContent)) {
console.warn(`[SummarizeArticle:${provider}] Reasoning preamble detected, rejecting`);
return null;
Expand Down
47 changes: 37 additions & 10 deletions src/components/NewsPanel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export class NewsPanel extends Panel {
private summaryBtn: HTMLButtonElement | null = null;
private summaryContainer: HTMLElement | null = null;
private currentHeadlines: string[] = [];
private lastHeadlineSignature = '';
private isSummarizing = false;

constructor(id: string, title: string) {
Expand Down Expand Up @@ -153,8 +154,14 @@ export class NewsPanel extends Panel {
this.summaryContainer.style.display = 'block';
this.summaryContainer.innerHTML = `<div class="panel-summary-loading">${t('components.newsPanel.generatingSummary')}</div>`;

const sigAtStart = this.lastHeadlineSignature;

try {
const result = await generateSummary(this.currentHeadlines.slice(0, 8), undefined, this.panelId, currentLang);
if (this.lastHeadlineSignature !== sigAtStart) {
this.hideSummary();
return;
}
if (result?.summary) {
this.setCachedSummary(cacheKey, result.summary);
this.showSummary(result.summary);
Expand Down Expand Up @@ -223,27 +230,42 @@ export class NewsPanel extends Panel {
this.summaryContainer.innerHTML = '';
}

private getHeadlineSignature(): string {
return JSON.stringify(this.currentHeadlines.slice(0, 5).sort());
}

private updateHeadlineSignature(): void {
const newSig = this.getHeadlineSignature();
if (newSig !== this.lastHeadlineSignature) {
this.lastHeadlineSignature = newSig;
if (this.summaryContainer?.style.display === 'block') {
this.hideSummary();
}
}
}

private getCachedSummary(key: string): string | null {
try {
const cached = localStorage.getItem(key);
if (!cached) return null;
const { summary, timestamp } = JSON.parse(cached);
if (Date.now() - timestamp > SUMMARY_CACHE_TTL) {
localStorage.removeItem(key);
return null;
}
return summary;
const parsed = JSON.parse(cached);
if (!parsed.headlineSignature) { localStorage.removeItem(key); return null; }
if (parsed.headlineSignature !== this.lastHeadlineSignature) return null;
if (Date.now() - parsed.timestamp > SUMMARY_CACHE_TTL) { localStorage.removeItem(key); return null; }
return parsed.summary;
} catch {
return null;
}
}

private setCachedSummary(key: string, summary: string): void {
try {
localStorage.setItem(key, JSON.stringify({ summary, timestamp: Date.now() }));
} catch {
// Storage full, ignore
}
localStorage.setItem(key, JSON.stringify({
headlineSignature: this.lastHeadlineSignature,
summary,
timestamp: Date.now(),
}));
} catch { /* storage full */ }
}

public setDeviation(zScore: number, percentChange: number, level: DeviationLevel): void {
Expand Down Expand Up @@ -287,6 +309,7 @@ export class NewsPanel extends Panel {
this.setCount(0);
this.relatedAssetContext.clear();
this.currentHeadlines = [];
this.updateHeadlineSignature();
this.setContent(`<div class="panel-empty">${escapeHtml(message)}</div>`);
}

Expand All @@ -312,6 +335,8 @@ export class NewsPanel extends Panel {
.map(item => item.title)
.filter((title): title is string => typeof title === 'string' && title.trim().length > 0);

this.updateHeadlineSignature();

const html = items
.map(
(item) => `
Expand Down Expand Up @@ -350,6 +375,8 @@ export class NewsPanel extends Panel {
// Store headlines for summarization (cap at 5 to reduce entity conflation in small models)
this.currentHeadlines = sorted.slice(0, 5).map(c => c.primaryTitle);

this.updateHeadlineSignature();

const clusterIds = sorted.map(c => c.id);
let newItemIds: Set<string>;

Expand Down
53 changes: 52 additions & 1 deletion tests/summarize-reasoning.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
* - Multiple thinking tag formats are stripped (Fix 2)
* - Plain-text reasoning preambles are detected (Fix 3)
* - Mode guard only applies to brief/analysis (Fix 3)
* - Cache version bumped to v4 (Fix 4)
* - Cache version bumped to v5 (Fix 4)
*/

import { describe, it } from 'node:test';
Expand Down Expand Up @@ -90,6 +90,17 @@ describe('Fix 2: thinking tag stripping formats', () => {
const sectionSlice = lines.slice(unterminatedSection, unterminatedSection + 8).join('\n');
assert.ok(sectionSlice.includes('<reflection>'), 'Should strip unterminated <reflection>');
});

it('strips <|begin_of_thought|> tags (terminated)', () => {
assert.ok(src.includes('begin_of_thought'), 'Should handle <|begin_of_thought|> tags');
});

it('strips <|begin_of_thought|> tags (unterminated)', () => {
const lines = src.split('\n');
const unterminatedSection = lines.findIndex(l => l.includes('Strip unterminated'));
const sectionSlice = lines.slice(unterminatedSection, unterminatedSection + 10).join('\n');
assert.ok(sectionSlice.includes('begin_of_thought'), 'Should strip unterminated <|begin_of_thought|>');
});
});

// ========================================================================
Expand Down Expand Up @@ -182,6 +193,40 @@ describe('Fix 3: hasReasoningPreamble', () => {
assert.ok(!hasReasoningPreamble('Russia has escalated its nuclear rhetoric.'));
});

// New patterns: first/to summarize/my task/step
it('detects "First, I need to..."', () => {
assert.ok(hasReasoningPreamble('First, I need to identify the most important headline.'));
});

it('detects "First, let me..."', () => {
assert.ok(hasReasoningPreamble('First, let me analyze these headlines.'));
});

it('detects "To summarize the headlines..."', () => {
assert.ok(hasReasoningPreamble('To summarize the headlines, we look at the key events.'));
});

it('detects "My task is to..."', () => {
assert.ok(hasReasoningPreamble('My task is to summarize the top story.'));
});

it('detects "Step 1: analyze..."', () => {
assert.ok(hasReasoningPreamble('Step 1: analyze the most important headline.'));
});

// Negative cases — legitimate summaries that start similarly
it('passes "First responders arrived..."', () => {
assert.ok(!hasReasoningPreamble('First responders arrived at the scene of the earthquake.'));
});

it('passes "To summarize, the summit concluded..."', () => {
assert.ok(!hasReasoningPreamble('To summarize, the summit concluded with a joint statement.'));
});

it('passes "My task force deployed..."', () => {
assert.ok(!hasReasoningPreamble('My task force deployed to the border region.'));
});

// Mode guard
it('is gated to brief and analysis modes in source', () => {
assert.match(src, /\['brief',\s*'analysis'\]\.includes\(mode\)/,
Expand All @@ -192,6 +237,12 @@ describe('Fix 3: hasReasoningPreamble', () => {
assert.doesNotMatch(src, /mode\s*!==\s*'translate'.*hasReasoningPreamble/,
'Should NOT use negation-based mode guard');
});

// Min-length gate
it('has mode-scoped min-length gate for brief/analysis', () => {
assert.match(src, /\['brief',\s*'analysis'\]\.includes\(mode\)\s*&&\s*rawContent\.length\s*<\s*20/,
'Should reject outputs shorter than 20 chars in brief/analysis modes');
});
});

// ========================================================================
Expand Down