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