98 lines
3.4 KiB
JavaScript
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));
|
|
});
|