From cc5a0bbba6ced01e9897b53f5c6ae7fb54dd68bc Mon Sep 17 00:00:00 2001 From: Elie Habib Date: Fri, 27 Feb 2026 10:02:04 +0400 Subject: [PATCH 1/2] fix(ollama): strip thinking tokens, raise max_tokens, fix panel summary cache (#450) - Add OLLAMA_MAX_TOKENS env var (clamped 50-2000, default 300) so thinking models have enough budget for actual summaries instead of truncated reasoning - Strip <|begin_of_thought|>/<|end_of_thought|> tags (terminated + unterminated) - Add mode-scoped min-length gate: reject <20 char outputs for brief/analysis - Extend TASK_NARRATION regex with first/step/my-task/to-summarize patterns - Fix client-side summary cache: store headline signature in value, validate on read, auto-dismiss stale summaries on headline change, discard in-flight results when headlines change during generation - Add tests for new patterns and negative cases (39/39 pass) --- server/worldmonitor/news/v1/_shared.ts | 4 +- .../worldmonitor/news/v1/summarize-article.ts | 9 +++- src/components/NewsPanel.ts | 46 +++++++++++++---- tests/summarize-reasoning.test.mjs | 51 +++++++++++++++++++ 4 files changed, 98 insertions(+), 12 deletions(-) diff --git a/server/worldmonitor/news/v1/_shared.ts b/server/worldmonitor/news/v1/_shared.ts index 6ed4ae1d0..43c71f75a 100644 --- a/server/worldmonitor/news/v1/_shared.ts +++ b/server/worldmonitor/news/v1/_shared.ts @@ -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 }, }; } diff --git a/server/worldmonitor/news/v1/summarize-article.ts b/server/worldmonitor/news/v1/summarize-article.ts index 27d3fd5e0..8f7f97e94 100644 --- a/server/worldmonitor/news/v1/summarize-article.ts +++ b/server/worldmonitor/news/v1/summarize-article.ts @@ -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 { @@ -137,6 +137,7 @@ export async function summarizeArticle( .replace(/<\|thinking\|>[\s\S]*?<\|\/thinking\|>/gi, '') .replace(/[\s\S]*?<\/reasoning>/gi, '') .replace(/[\s\S]*?<\/reflection>/gi, '') + .replace(/<\|begin_of_thought\|>[\s\S]*?<\|end_of_thought\|>/gi, '') .trim(); // Strip unterminated thinking blocks (no closing tag) @@ -145,8 +146,14 @@ export async function summarizeArticle( .replace(/<\|thinking\|>[\s\S]*/gi, '') .replace(/[\s\S]*/gi, '') .replace(/[\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; diff --git a/src/components/NewsPanel.ts b/src/components/NewsPanel.ts index beb13adf8..85ba7cb74 100644 --- a/src/components/NewsPanel.ts +++ b/src/components/NewsPanel.ts @@ -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) { @@ -153,8 +154,13 @@ export class NewsPanel extends Panel { this.summaryContainer.style.display = 'block'; this.summaryContainer.innerHTML = `
${t('components.newsPanel.generatingSummary')}
`; + const sigAtStart = this.lastHeadlineSignature; + try { const result = await generateSummary(this.currentHeadlines.slice(0, 8), undefined, this.panelId, currentLang); + if (this.lastHeadlineSignature !== sigAtStart) { + return; + } if (result?.summary) { this.setCachedSummary(cacheKey, result.summary); this.showSummary(result.summary); @@ -223,16 +229,29 @@ 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; } @@ -240,10 +259,12 @@ export class NewsPanel extends Panel { 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 { @@ -287,6 +308,7 @@ export class NewsPanel extends Panel { this.setCount(0); this.relatedAssetContext.clear(); this.currentHeadlines = []; + this.updateHeadlineSignature(); this.setContent(`
${escapeHtml(message)}
`); } @@ -312,6 +334,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) => ` @@ -350,6 +374,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; diff --git a/tests/summarize-reasoning.test.mjs b/tests/summarize-reasoning.test.mjs index 53f85aecf..06646f1ee 100644 --- a/tests/summarize-reasoning.test.mjs +++ b/tests/summarize-reasoning.test.mjs @@ -90,6 +90,17 @@ describe('Fix 2: thinking tag stripping formats', () => { const sectionSlice = lines.slice(unterminatedSection, unterminatedSection + 8).join('\n'); assert.ok(sectionSlice.includes(''), 'Should strip unterminated '); }); + + 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|>'); + }); }); // ======================================================================== @@ -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\)/, @@ -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'); + }); }); // ======================================================================== From 49de7bf0abd2c01a8a82170ead409a71270dad73 Mon Sep 17 00:00:00 2001 From: Elie Habib Date: Fri, 27 Feb 2026 14:39:55 +0400 Subject: [PATCH 2/2] fix: hide summary container on stale in-flight discard, fix comment - Add hideSummary() call when headline signature changes mid-generation, preventing a stuck "Generating summary..." overlay - Fix stale comment: cache version is v5, not v4 --- src/components/NewsPanel.ts | 1 + tests/summarize-reasoning.test.mjs | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/NewsPanel.ts b/src/components/NewsPanel.ts index 85ba7cb74..09bf9bd90 100644 --- a/src/components/NewsPanel.ts +++ b/src/components/NewsPanel.ts @@ -159,6 +159,7 @@ export class NewsPanel extends Panel { try { const result = await generateSummary(this.currentHeadlines.slice(0, 8), undefined, this.panelId, currentLang); if (this.lastHeadlineSignature !== sigAtStart) { + this.hideSummary(); return; } if (result?.summary) { diff --git a/tests/summarize-reasoning.test.mjs b/tests/summarize-reasoning.test.mjs index 06646f1ee..19e9b06b6 100644 --- a/tests/summarize-reasoning.test.mjs +++ b/tests/summarize-reasoning.test.mjs @@ -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';