← back to AbramsOS

lib/chat-ner.js

117 lines

// Defense-in-depth PII scrub — a LOCAL-Ollama NER pass that runs on EXTERNAL
// lanes only, AFTER the regex/list redactor (lib/chat-redact.js). It closes the
// one gap the list-based redactor cannot: a person NAME sitting inside a
// free-text field (a claim note "spoke with John Smith", a reminder title) that
// was never enumerated in the grounding names list. The model only DETECTS name
// spans; the replacement is done deterministically in code (the model never
// rewrites the outgoing text). The NER call goes to the SAME Ollama base as the
// private lane (Steve's LAN — Mac1 by default), so the text stays inside the
// same trust boundary as the local lane while it is being scrubbed.
//
// FAIL-CLOSED: if the Ollama NER call errors or times out, detectNames() THROWS.
// The caller (routes/chat.js) turns that into a clean 503 and never sends the
// un-NER-scrubbed text to an external provider — failing toward LESS exposure.

const BASE = process.env.OLLAMA_BASE_URL || 'http://192.168.1.133:11434';
const NER_MODEL = process.env.CHAT_NER_MODEL || 'qwen2.5:7b';
const NER_TIMEOUT_MS = parseInt(process.env.CHAT_NER_TIMEOUT_MS || '12000', 10);
const MIN_SCRUB_CHARS = 40; // skip trivially-short spans with no grounded context

const SYSTEM = [
  'You are a strict PII entity detector. You are given TEXT.',
  'Return ONLY the PERSON NAMES (real human first/last/full names) that appear literally in the TEXT.',
  'Do NOT return company names, brands, product names, cities, or generic words.',
  'Respond with JSON only, exactly: {"names": ["<name>", ...]}. Empty array if none.',
].join(' ');

function escapeRegExp(s) {
  return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}

// Pure, deterministic. Replace every occurrence of each detected name with
// [NAME]. Longest names first so "John Smith" is consumed before "John".
function applyNames(text, names) {
  if (!text || !Array.isArray(names) || !names.length) return { text: text || '', redactions: 0 };
  let out = String(text);
  let redactions = 0;
  const ordered = [...new Set(names.map((n) => String(n).trim()).filter(Boolean))]
    .filter((n) => n.length >= 2 && n.length <= 60 && /[A-Za-z]/.test(n))
    .sort((a, b) => b.length - a.length);
  for (const name of ordered) {
    // separator-tolerant (space/NBSP/hyphen/comma) between tokens, case-insensitive
    const pattern = name
      .split(/[\s   ,-]+/)
      .filter(Boolean)
      .map(escapeRegExp)
      .join('[\\s\\u00a0\\u2007\\u202f,-]+');
    if (!pattern) continue;
    const re = new RegExp(pattern, 'gi');
    out = out.replace(re, () => { redactions += 1; return '[NAME]'; });
  }
  return { text: out, redactions };
}

// Call local Ollama to DETECT person-name spans. Throws (fail-closed) on any
// network/timeout/HTTP/parse failure so an external send can be aborted.
async function detectNames(text) {
  const src = String(text || '');
  if (src.trim().length < MIN_SCRUB_CHARS) return [];
  const ctrl = new AbortController();
  const t = setTimeout(() => ctrl.abort(), NER_TIMEOUT_MS);
  try {
    const r = await fetch(`${BASE}/api/chat`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        model: NER_MODEL,
        format: 'json',
        stream: false,
        options: { temperature: 0 },
        messages: [
          { role: 'system', content: SYSTEM },
          { role: 'user', content: `TEXT:\n${src}` },
        ],
      }),
      signal: ctrl.signal,
    });
    if (!r.ok) throw new Error(`ner ollama ${r.status}`);
    const j = await r.json();
    const content = j.message?.content || '';
    let parsed;
    try {
      parsed = JSON.parse(content);
    } catch (_) {
      const m = content.match(/\{[\s\S]*\}/);
      if (!m) throw new Error('ner unparseable response');
      parsed = JSON.parse(m[0]);
    }
    const names = Array.isArray(parsed?.names) ? parsed.names : [];
    return names.map((n) => String(n).trim()).filter(Boolean);
  } catch (err) {
    throw new Error(`ner_unavailable: ${err.message || err}`);
  } finally {
    clearTimeout(t);
  }
}

// Batched: ONE detect call over all spans joined, then deterministic apply to
// each span. Keeps a chat request to a single NER round-trip regardless of how
// many message contents are outgoing. Throws (fail-closed) if detection fails.
async function scrubSpans(spans) {
  const list = (spans || []).map((s) => String(s ?? ''));
  const joined = list.join('\n\n----\n\n');
  if (joined.trim().length < MIN_SCRUB_CHARS) {
    return { spans: list, redactions: 0 };
  }
  const names = await detectNames(joined);
  let redactions = 0;
  const out = list.map((s) => {
    const r = applyNames(s, names);
    redactions += r.redactions;
    return r.text;
  });
  return { spans: out, redactions };
}

module.exports = { detectNames, applyNames, scrubSpans, BASE, NER_MODEL };