← back to Allnewsdaily
lib/paraphrase.js
115 lines
'use strict';
// Rewrite a news headline into ONE original topic sentence (same story, different words).
// $0 local via Ollama (hermes3:8b). Cached by URL hash so each story is rewritten once.
// Deterministic fallback keeps the page working if Ollama is unreachable.
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const OLLAMA = process.env.OLLAMA_URL || 'http://127.0.0.1:11434';
const MODEL = process.env.PARAPHRASE_MODEL || 'qwen3:14b';
const CACHE_PATH = path.join(__dirname, '..', 'data', 'paraphrase-cache.json');
const MAX_CACHE = 5000;
let cache = {};
try { cache = JSON.parse(fs.readFileSync(CACHE_PATH, 'utf8')); } catch (_) { cache = {}; }
let dirty = false;
function key(link, title) {
return crypto.createHash('sha1').update((link || '') + '|' + (title || '')).digest('hex').slice(0, 16);
}
function saveCache() {
if (!dirty) return;
try {
// trim oldest if oversized (object insertion order ~ age)
const keys = Object.keys(cache);
if (keys.length > MAX_CACHE) {
const trimmed = {};
for (const k of keys.slice(keys.length - MAX_CACHE)) trimmed[k] = cache[k];
cache = trimmed;
}
fs.writeFileSync(CACHE_PATH, JSON.stringify(cache));
dirty = false;
} catch (e) { console.error('[paraphrase] cache save failed', e.message); }
}
// Light, deterministic rewrite used only when the model is unavailable.
// It is NOT the headline verbatim — it de-headline-ifies and reframes as a topic line.
function fallbackRewrite(title, outlet) {
let t = String(title || '').trim().replace(/\s+/g, ' ');
t = t.replace(/\s*[-–—|:]\s*[^-–—|:]{1,40}$/, ''); // drop trailing " - Outlet" style tails
if (!t) return '';
const lower = t.charAt(0).toLowerCase() + t.slice(1);
return `Report: ${lower}`.slice(0, 180);
}
async function callOllama(title, summary, { timeoutMs = 20000 } = {}) {
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), timeoutMs);
const prompt =
'/no_think You rewrite a news headline into ONE plain factual sentence in your own words. ' +
'STRICT RULES: use ONLY information in the headline (and context if given); add NO new facts, names, numbers, dates, or adjectives; ' +
'do NOT copy the headline wording verbatim; ban hype words like groundbreaking, highly anticipated, stunning, revolutionary, shocking; ' +
'neutral declarative tone; max 160 characters; output ONLY the sentence, no quotes, no prefix.\n\n' +
`Headline: ${title}\n` +
(summary ? `Context: ${summary.slice(0, 200)}\n` : '') +
'Sentence:';
try {
const res = await fetch(`${OLLAMA}/api/generate`, {
method: 'POST',
signal: ctrl.signal,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: MODEL, prompt, stream: false, think: false, options: { temperature: 0.2, num_predict: 90 } })
});
if (!res.ok) return null;
const j = await res.json();
let out = (j.response || '').replace(/<think>[\s\S]*?<\/think>/gi, '').trim();
out = out.replace(/^["'\s]+|["'\s]+$/g, '').replace(/^Sentence:\s*/i, '').replace(/\s+/g, ' ');
if (out.length < 12) return null;
if (out.length > 200) out = out.slice(0, 197).replace(/\s\S*$/, '') + '…';
return out;
} catch (_) {
return null;
} finally {
clearTimeout(t);
}
}
// Rewrite one item, using cache. Returns { text, model } and never throws.
async function rewriteOne(item) {
const k = key(item.link, item.title);
if (cache[k] && cache[k].text) return { text: cache[k].text, cached: true };
const modelOut = await callOllama(item.title, item.summary);
const text = modelOut || fallbackRewrite(item.title, item.outlet);
if (text) {
cache[k] = { text, model: modelOut ? MODEL : 'fallback', ts: Date.now() };
dirty = true;
}
return { text, cached: false, model: modelOut ? MODEL : 'fallback' };
}
// Rewrite a list with bounded concurrency; persists cache once at the end.
async function rewriteAll(items, { concurrency = 4 } = {}) {
const out = new Array(items.length);
let i = 0;
async function worker() {
while (i < items.length) {
const idx = i++;
const r = await rewriteOne(items[idx]);
out[idx] = { ...items[idx], topic: r.text, rewriteModel: r.model || (cache[key(items[idx].link, items[idx].title)] || {}).model };
}
}
await Promise.all(Array.from({ length: Math.min(concurrency, items.length || 1) }, worker));
saveCache();
return out;
}
// Synchronous read: topic if already cached, else null (used for instant first paint).
function cachedTopic(item) {
const k = key(item.link, item.title);
return cache[k] && cache[k].text ? cache[k].text : null;
}
module.exports = { rewriteAll, rewriteOne, cachedTopic, fallbackRewrite };