← back to AbramsOS
routes/chat.js
143 lines
// Chat route. POST /api/chat grounds on the agent's data-scope, redacts PII for
// external lanes, dispatches to the selected provider, writes one audit_log row,
// and returns the reply. v1 is READ/ANSWER ONLY — it performs no state-changing
// or external action (no send, no file). CSRF-exempt by /api/* convention;
// requireAuth is applied by server.js before this router is mounted.
const express = require('express');
const registry = require('../lib/chat-registry');
const grounding = require('../lib/chat-grounding');
const { redact } = require('../lib/chat-redact');
const ner = require('../lib/chat-ner');
const providers = require('../lib/chat-providers');
const audit = require('../lib/audit');
const router = express.Router();
const MAX_HISTORY = 10;
const MAX_MESSAGE_CHARS = 8_000;
router.get('/api/chat/registry', async (_req, res) => {
let ollamaAvailable = [];
try {
ollamaAvailable = await providers.availableModels();
} catch (_) {
ollamaAvailable = [];
}
const models = registry.MODELS.map((m) => ({
...m,
available: m.provider === 'ollama'
? ollamaAvailable.includes(m.providerModel)
: true,
}));
res.json({
models,
agents: registry.AGENTS,
defaults: { modelId: registry.DEFAULT_MODEL_ID, agentId: registry.DEFAULT_AGENT_ID },
});
});
router.post('/api/chat', async (req, res) => {
try {
const { message, modelId, agentId } = req.body || {};
if (!message || typeof message !== 'string') {
return res.status(400).json({ error: 'message required' });
}
const model = registry.MODEL_BY_ID[modelId];
const agent = registry.AGENT_BY_ID[agentId];
if (!model) return res.status(400).json({ error: 'unknown modelId' });
if (!agent) return res.status(400).json({ error: 'unknown agentId' });
// Fail CLOSED: never fall back to a hardcoded user. No authenticated user
// → 401. requireAuth already gates the mount, but this is the last line of
// defense so a grounding query can never run as someone else's identity.
const userId = req.userId;
if (!userId) return res.status(401).json({ error: 'unauthenticated' });
const isExternal = model.visibility === 'external';
// Session-scoped history (client-supplied), sanitized + capped.
const history = Array.isArray(req.body.history)
? req.body.history
.filter((h) => h && (h.role === 'user' || h.role === 'assistant') && typeof h.content === 'string')
.slice(-MAX_HISTORY)
.map((h) => ({ role: h.role, content: h.content.slice(0, MAX_MESSAGE_CHARS) }))
: [];
// Ground on the agent's data-scope (read-only).
const { context, names } = await grounding.ground(agent.scope, userId);
const userTurn = context
? `${context}\n\nQuestion: ${message.slice(0, MAX_MESSAGE_CHARS)}`
: message.slice(0, MAX_MESSAGE_CHARS);
let messages = [...history, { role: 'user', content: userTurn }];
let system = agent.system;
let redactions = 0;
let nerRedactions = 0;
if (isExternal) {
// Layer 1 — regex/list redactor: strip EVERY outgoing span.
const sys = redact(system, { names });
system = sys.text;
redactions += sys.redactions;
messages = messages.map((m) => {
const r = redact(m.content, { names });
redactions += r.redactions;
return { role: m.role, content: r.text };
});
// Layer 2 — local-Ollama NER scrub (defense in depth): catch person names
// sitting in free-text fields that the list-based redactor never enumerated.
// ONE batched detect over all external-bound spans; deterministic replace.
// FAIL-CLOSED: if the local NER is unavailable, do NOT send un-scrubbed text
// to the external provider — return a clean 503 that steers to a local model.
try {
const spans = [system, ...messages.map((m) => m.content)];
const scrubbed = await ner.scrubSpans(spans);
system = scrubbed.spans[0];
messages = messages.map((m, i) => ({ role: m.role, content: scrubbed.spans[i + 1] }));
nerRedactions = scrubbed.redactions;
redactions += nerRedactions;
} catch (nerErr) {
return res.status(503).json({
error: 'Local PII scrub (NER) is unavailable, so this external model was not called. Pick a local/private model (Ollama) to ask about your records right now.',
lane: 'external',
nerUnavailable: true,
});
}
}
const { text: reply } = await providers.dispatch(model, { system, messages });
// One audit_log row per chat call. No message text / no PII in metadata.
await audit.log({
actorType: 'user',
actorId: userId,
objectType: 'chat',
objectId: null,
eventType: 'chat_message',
metadata: {
modelId: model.id,
agentId: agent.id,
provider: model.provider,
visibility: model.visibility,
redaction_applied: isExternal,
redactions,
ner_redactions: nerRedactions,
},
}).catch(() => {});
res.json({
reply,
lane: model.visibility,
redactionApplied: isExternal,
redactions,
modelId: model.id,
agentId: agent.id,
});
} catch (err) {
res.status(502).json({ error: err.message || 'chat failed' });
}
});
module.exports = router;