Files
Doormilexpress_console/services/ai/index.js
2026-08-19 17:08:45 +05:30

98 lines
3.4 KiB
JavaScript

import express from 'express';
import cors from 'cors';
import { warmUp } from './embed.js';
import { INTENTS_COLLECTION, DOCS_COLLECTION, query, health } from './collections.js';
import { classify } from './confidence.js';
// ==============================|| Doormile AI — retrieval sidecar ||============================== //
//
// Two jobs, and neither of them is "produce a number":
//
// POST /route text -> which intent is this? (+ how sure)
// POST /ask text -> which documentation passages answer this?
//
// The console then runs its OWN deterministic intent code to produce every
// figure. Nothing this service returns is ever read by an operator as data.
const app = express();
app.use(cors());
app.use(express.json({ limit: '64kb' }));
app.get('/health', async (_req, res) => {
try {
const model = await warmUp();
const store = await health();
res.json({ ok: true, model, ...store });
} catch (err) {
res.status(503).json({ ok: false, error: err.message });
}
});
app.post('/route', async (req, res) => {
const text = String(req.body?.text || '').trim();
if (!text) return res.status(400).json({ error: 'text is required' });
try {
const hits = await query(INTENTS_COLLECTION, text, 5);
const { confidence, score, margin } = classify(hits);
// Distinct intents only — five phrasings of the same intent in the top-5
// is a strong signal, not five alternatives to offer the operator.
const seen = new Set();
const alternatives = [];
for (const h of hits) {
const id = h.metadata?.intentId;
if (!id || seen.has(id)) continue;
seen.add(id);
alternatives.push({ intentId: id, score: h.score, example: h.document });
}
return res.json({
intentId: alternatives[0]?.intentId ?? null,
isWrite: Boolean(hits[0]?.metadata?.isWrite),
confidence,
score: Number(score.toFixed(4)),
margin: Number(margin.toFixed(4)),
matchedExample: hits[0]?.document ?? null,
// Only meaningful when confidence is low — that is when the panel should
// offer these as buttons instead of picking one.
alternatives: alternatives.slice(1, 3)
});
} catch (err) {
return res.status(503).json({ error: err.message });
}
});
app.post('/ask', async (req, res) => {
const text = String(req.body?.text || '').trim();
if (!text) return res.status(400).json({ error: 'text is required' });
try {
const hits = await query(DOCS_COLLECTION, text, 4);
// Passages are returned verbatim with attribution. There is no generation
// step: summarising would need a hosted model (CLAUDE.md §2) and would let
// a paraphrase drift from what the document actually says.
res.json({
chunks: hits
.filter((h) => h.score > 0.35)
.map((h) => ({
text: h.document,
source: h.metadata?.source,
heading: h.metadata?.heading,
score: Number(h.score.toFixed(4))
}))
});
} catch (err) {
res.status(503).json({ error: err.message });
}
});
const port = Number(process.env.PORT) || 8787;
app.listen(port, () => {
console.log(`[doormile-ai] listening on :${port}`);
// Pay the model load at boot rather than on an operator's first question.
warmUp()
.then((m) => console.log(`[doormile-ai] model ready: ${m.model} (${m.dimensions}d)`))
.catch((e) => console.error('[doormile-ai] model failed to load:', e.message));
});