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