← 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 };