← back to Fleet Rag

server.js

112 lines

'use strict';

const express   = require('express');
const { buildIndex, loadVectors } = require('./lib/index');
const { embed, generate, cosineSim } = require('./lib/embed');

const PORT = process.env.PORT || 9710;
const app  = express();
app.use(express.json());

// ── in-memory vector cache ────────────────────────────────────────────────────
let vectorCache = [];
let indexing    = false;

function refreshCache() {
  vectorCache = loadVectors();
  console.log(`[fleet-rag] cache loaded: ${vectorCache.length} vectors`);
}

// Load on start if vectors file already exists
refreshCache();

// ── routes ────────────────────────────────────────────────────────────────────

app.get('/healthz', (_req, res) => {
  res.json({
    status: 'ok',
    vectors: vectorCache.length,
    indexing,
    port: PORT,
  });
});

app.post('/index', async (_req, res) => {
  if (indexing) {
    return res.status(409).json({ error: 'indexing already in progress' });
  }
  indexing = true;
  const logs = [];
  const log  = msg => { logs.push(msg); process.stdout.write(msg + '\n'); };

  try {
    const result = await buildIndex(log);
    refreshCache();
    res.json({ ok: true, ...result, logs });
  } catch (e) {
    res.status(500).json({ error: e.message, logs });
  } finally {
    indexing = false;
  }
});

app.post('/ask', async (req, res) => {
  const question = (req.body && req.body.question) ? String(req.body.question).trim() : '';
  if (!question) return res.status(400).json({ error: 'question is required' });
  if (vectorCache.length === 0) {
    return res.status(503).json({ error: 'No index loaded. POST /index first.' });
  }

  try {
    // 1. Embed the question
    const qVec = await embed(question);

    // 2. Cosine sim against all vectors — find top 5
    const scored = vectorCache.map(v => ({
      ...v,
      score: cosineSim(qVec, v.embedding),
    }));
    scored.sort((a, b) => b.score - a.score);
    const topK = scored.slice(0, 5);

    // 3. Build context for LLM
    const context = topK.map((c, i) =>
      `[${i + 1}] (${c.source_type} — ${c.source_path})\n${c.chunk_text}`
    ).join('\n\n---\n\n');

    const prompt = `/no_think
You are Fleet-RAG, an expert assistant for Steve's local dev fleet.
Answer the following question using ONLY the context below.
Be concise and direct. Include the service name and its path/port when relevant.
If the answer is not in the context, say "I don't have enough information in the index."

QUESTION: ${question}

CONTEXT:
${context}

ANSWER:`;

    // 4. Generate answer
    const answer = await generate(prompt);

    // 5. Return answer + cited sources
    const sources = topK.map(c => ({
      source_type: c.source_type,
      source_path: c.source_path,
      score: Math.round(c.score * 1000) / 1000,
      preview: c.chunk_text.slice(0, 120),
    }));

    res.json({ answer: answer.trim(), sources });
  } catch (e) {
    res.status(500).json({ error: e.message });
  }
});

// ── start ─────────────────────────────────────────────────────────────────────
app.listen(PORT, () => {
  console.log(`[fleet-rag] listening on http://localhost:${PORT}`);
  console.log(`[fleet-rag] endpoints: GET /healthz  POST /index  POST /ask`);
});