Browse Source

mudando para back

GabrielRamison 3 tháng trước cách đây
commit
3ab3ea9873

+ 7 - 0
.env

@@ -0,0 +1,7 @@
+PORT=3001
+CORS_ORIGIN=http://localhost:5173
+QDRANT_URL=http://localhost:6333
+QDRANT_COLLECTION=empresa_docs
+OLLAMA_URL=http://localhost:11434
+OLLAMA_EMBEDDINGS_MODEL=nomic-embed-text
+OLLAMA_CHAT_MODEL=llama3.1

+ 34 - 0
app.js

@@ -0,0 +1,34 @@
+import express from "express";
+import cors from "cors";
+import helmet from "helmet";
+import morgan from "morgan";
+import { config } from "./config/index.js";
+import { authMiddleware } from "./middleware/auth.js";
+import { rateLimitMiddleware } from "./middleware/rateLimit.js";
+import { errorHandler } from "./middleware/errorHandler.js";
+import { apiRouter } from "./routes/index.js";
+
+export function createApp() {
+  const app = express();
+
+  app.disable("x-powered-by");
+  app.use(helmet());
+  app.use(
+    cors({
+      origin: config.corsOrigin,
+      credentials: true
+    })
+  );
+  app.use(express.json({ limit: "10mb" }));
+  app.use(morgan("dev"));
+
+  app.get("/health", (_req, res) => {
+    res.json({ ok: true });
+  });
+
+  app.use("/api", rateLimitMiddleware, authMiddleware, apiRouter);
+
+  app.use(errorHandler);
+
+  return app;
+}

+ 77 - 0
chat/chatChain.js

@@ -0,0 +1,77 @@
+import { config } from "../config/index.js";
+import { chatCompletion } from "../services/ollamaClient.js";
+import { searchDocs } from "./searchChat.js";
+import fs from "node:fs";
+
+function buildContextBlock(hits) {
+  const lines = [];
+  hits.forEach((h, i) => {
+    const header = `# Fonte ${i + 1}${h.source ? ` (${h.source})` : ""}`;
+    lines.push(header);
+    lines.push(h.text);
+    lines.push("");
+  });
+  return lines.join("\n").trim();
+}
+
+export async function answerWithContext({ message, sessionId }) {
+  const hits = await searchDocs({ query: message, topK: config.rag.topK });
+  const context = buildContextBlock(hits);
+  // #region debug-point D:image-rag-context
+  (() => {
+    let u = "http://127.0.0.1:7777/event";
+    let s = "image-rag-miss";
+    try {
+      const e = fs.readFileSync(".dbg/image-rag-miss.env", "utf8");
+      u = e.match(/DEBUG_SERVER_URL=(.+)/)?.[1] || u;
+      s = e.match(/DEBUG_SESSION_ID=(.+)/)?.[1] || s;
+    } catch {}
+    fetch(u, {
+      method: "POST",
+      headers: { "content-type": "application/json" },
+      body: JSON.stringify({
+        sessionId: s,
+        runId: "post",
+        hypothesisId: "D",
+        location: "api/chat/chatChain.js",
+        msg: "[DEBUG] answerWithContext context built",
+        data: {
+          questionHead: String(message ?? "").slice(0, 140),
+          hits: Array.isArray(hits) ? hits.length : null,
+          contextChars: context.length,
+          topScore: hits?.[0]?.score ?? null,
+          topSource: hits?.[0]?.source ?? null
+        },
+        ts: Date.now()
+      })
+    }).catch(() => {});
+  })();
+  // #endregion
+
+  const system = [
+    "Você é um assistente de atendimento interno de uma empresa.",
+    "Responda apenas com base no CONTEXTO quando ele estiver disponível.",
+    "Se o CONTEXTO não tiver a informação, diga que não encontrou na base da empresa e sugira o que solicitar ao time responsável.",
+    "Seja direto e em português."
+  ].join("\n");
+
+  const messages = [
+    { role: "system", content: system },
+    ...(context ? [{ role: "system", content: `CONTEXTO:\n${context}` }] : []),
+    ...(sessionId ? [{ role: "system", content: `session_id:${sessionId}` }] : []),
+    { role: "user", content: message }
+  ];
+
+  const completion = await chatCompletion({ messages });
+
+  return {
+    answer: completion.content,
+    sources: hits.map((h) => ({
+      id: h.id,
+      source: h.source,
+      score: h.score,
+      chunkIndex: h.chunkIndex,
+      metadata: h.metadata
+    }))
+  };
+}

+ 484 - 0
chat/ingest.js

@@ -0,0 +1,484 @@
+import { config } from "../config/index.js";
+import { chunkText } from "../services/textChunker.js";
+import { embedTexts, visionExtractFromImage } from "../services/ollamaClient.js";
+import { ensureCollection } from "../services/collectionService.js";
+import { qdrant } from "../services/qdrantClient.js";
+import { createHash } from "node:crypto";
+import { createRequire } from "node:module";
+import fs from "node:fs";
+import mammoth from "mammoth";
+
+const require = createRequire(import.meta.url);
+
+function isVisionRefusal(text) {
+  const t = String(text ?? "").toLowerCase();
+  return (
+    t.includes("desculpe") &&
+    (t.includes("não posso") || t.includes("nao posso") || t.includes("não posso fornecer") || t.includes("nao posso fornecer"))
+  );
+}
+
+function visionPrompt() {
+  return [
+    "Analise a imagem (print de tela / manual interno).",
+    "Extraia apenas informações úteis para busca: nomes de campos, rótulos, valores exibidos, opções selecionadas (checkbox/radio/dropdown), botões, mensagens de erro e códigos.",
+    "Não transcreva parágrafos longos; prefira listas curtas e objetivas.",
+    "Responda em português, em texto puro."
+  ].join("\n");
+}
+
+function stableUuid(seed) {
+  const hex = createHash("sha1")
+    .update(String(seed))
+    .digest("hex")
+    .slice(0, 32);
+
+  const timeLow = hex.slice(0, 8);
+  const timeMid = hex.slice(8, 12);
+  let timeHiAndVersion = parseInt(hex.slice(12, 16), 16);
+  timeHiAndVersion = (timeHiAndVersion & 0x0fff) | 0x5000;
+
+  let clockSeqHi = parseInt(hex.slice(16, 18), 16);
+  clockSeqHi = (clockSeqHi & 0x3f) | 0x80;
+  const clockSeqLow = hex.slice(18, 20);
+
+  const node = hex.slice(20, 32);
+
+  return [
+    timeLow,
+    timeMid,
+    timeHiAndVersion.toString(16).padStart(4, "0"),
+    `${clockSeqHi.toString(16).padStart(2, "0")}${clockSeqLow}`,
+    node
+  ].join("-");
+}
+
+export async function ingestDocuments(documents) {
+  const collectionName = config.qdrant.collection;
+  const allChunks = [];
+
+  for (const doc of documents) {
+    const baseSeed = doc.id ?? `${doc.source ?? "doc"}:${doc.text.slice(0, 64)}`;
+    const chunks = chunkText(doc.text, {
+      chunkSize: config.rag.chunkSize,
+      chunkOverlap: config.rag.chunkOverlap
+    });
+    chunks.forEach((chunk, idx) => {
+      allChunks.push({
+        id: stableUuid(`${baseSeed}:${idx}`),
+        source: doc.source ?? null,
+        metadata: doc.metadata ?? null,
+        chunkIndex: idx,
+        text: chunk
+      });
+    });
+  }
+
+  if (allChunks.length === 0) return { upserted: 0 };
+
+  // #region debug-point B:ingest-chunks
+  (() => {
+    let u = "http://127.0.0.1:7777/event";
+    let s = "image-rag-miss";
+    try {
+      const e = fs.readFileSync(".dbg/image-rag-miss.env", "utf8");
+      u = e.match(/DEBUG_SERVER_URL=(.+)/)?.[1] || u;
+      s = e.match(/DEBUG_SESSION_ID=(.+)/)?.[1] || s;
+    } catch {}
+    fetch(u, {
+      method: "POST",
+      headers: { "content-type": "application/json" },
+      body: JSON.stringify({
+        sessionId: s,
+        runId: "post",
+        hypothesisId: "B",
+        location: "api/chat/ingest.js",
+        msg: "[DEBUG] ingestDocuments chunks built",
+        data: {
+          collection: collectionName,
+          documents: Array.isArray(documents) ? documents.length : null,
+          chunks: allChunks.length,
+          avgChunkLen: allChunks.length ? Math.round(allChunks.reduce((a, c) => a + (c?.text?.length ?? 0), 0) / allChunks.length) : 0,
+          sample0: allChunks[0]?.text ? String(allChunks[0].text).slice(0, 160) : ""
+        },
+        ts: Date.now()
+      })
+    }).catch(() => {});
+  })();
+  // #endregion
+
+  const vectors = await embedTexts(allChunks.map((c) => c.text));
+  const vectorSize = vectors[0]?.length ?? 0;
+  if (!vectorSize) {
+    const err = new Error("embeddings_empty");
+    err.statusCode = 502;
+    throw err;
+  }
+
+  await ensureCollection({ vectorSize });
+
+  const points = allChunks.map((c, idx) => ({
+    id: c.id,
+    vector: vectors[idx],
+    payload: {
+      source: c.source,
+      chunkIndex: c.chunkIndex,
+      text: c.text,
+      metadata: c.metadata
+    }
+  }));
+
+  // #region debug-point B:ingest-upsert
+  (() => {
+    let u = "http://127.0.0.1:7777/event";
+    let s = "image-rag-miss";
+    try {
+      const e = fs.readFileSync(".dbg/image-rag-miss.env", "utf8");
+      u = e.match(/DEBUG_SERVER_URL=(.+)/)?.[1] || u;
+      s = e.match(/DEBUG_SESSION_ID=(.+)/)?.[1] || s;
+    } catch {}
+    fetch(u, {
+      method: "POST",
+      headers: { "content-type": "application/json" },
+      body: JSON.stringify({
+        sessionId: s,
+        runId: "post",
+        hypothesisId: "B",
+        location: "api/chat/ingest.js",
+        msg: "[DEBUG] ingestDocuments upsert start",
+        data: { collection: collectionName, points: points.length, vectorSize },
+        ts: Date.now()
+      })
+    }).catch(() => {});
+  })();
+  // #endregion
+
+  await qdrant.upsert(collectionName, {
+    wait: true,
+    points
+  });
+
+  // #region debug-point B:ingest-upsert-done
+  (() => {
+    let u = "http://127.0.0.1:7777/event";
+    let s = "image-rag-miss";
+    try {
+      const e = fs.readFileSync(".dbg/image-rag-miss.env", "utf8");
+      u = e.match(/DEBUG_SERVER_URL=(.+)/)?.[1] || u;
+      s = e.match(/DEBUG_SESSION_ID=(.+)/)?.[1] || s;
+    } catch {}
+    fetch(u, {
+      method: "POST",
+      headers: { "content-type": "application/json" },
+      body: JSON.stringify({
+        sessionId: s,
+        runId: "post",
+        hypothesisId: "B",
+        location: "api/chat/ingest.js",
+        msg: "[DEBUG] ingestDocuments upsert done",
+        data: { collection: collectionName, points: points.length },
+        ts: Date.now()
+      })
+    }).catch(() => {});
+  })();
+  // #endregion
+
+  return { upserted: points.length };
+}
+
+function guessFileKind({ mimeType, filename }) {
+  const name = String(filename ?? "").toLowerCase();
+  const mt = String(mimeType ?? "").toLowerCase();
+
+  if (mt === "text/plain" || name.endsWith(".txt")) return "txt";
+  if (mt === "application/pdf" || name.endsWith(".pdf")) return "pdf";
+  if (
+    mt === "application/vnd.openxmlformats-officedocument.wordprocessingml.document" ||
+    name.endsWith(".docx")
+  )
+    return "docx";
+  if (mt.startsWith("image/") || /\.(png|jpe?g|webp)$/i.test(name)) return "image";
+  return "unknown";
+}
+
+export async function extractDocumentsFromUpload({ buffer, filename, mimeType, source }) {
+  const kind = guessFileKind({ mimeType, filename });
+  const src = source ?? filename ?? "upload";
+  const metadata = { filename: filename ?? null, mimeType: mimeType ?? null, kind };
+
+  // #region debug-point A:extract-start
+  (() => {
+    let u = "http://127.0.0.1:7777/event";
+    let s = "image-rag-miss";
+    try {
+      const e = fs.readFileSync(".dbg/image-rag-miss.env", "utf8");
+      u = e.match(/DEBUG_SERVER_URL=(.+)/)?.[1] || u;
+      s = e.match(/DEBUG_SESSION_ID=(.+)/)?.[1] || s;
+    } catch {}
+    fetch(u, {
+      method: "POST",
+      headers: { "content-type": "application/json" },
+      body: JSON.stringify({
+        sessionId: s,
+        runId: "post",
+        hypothesisId: "A",
+        location: "api/chat/ingest.js",
+        msg: "[DEBUG] extractDocumentsFromUpload start",
+        data: { kind, filename: filename ?? null, mimeType: mimeType ?? null, bytes: buffer?.length ?? null, source: src },
+        ts: Date.now()
+      })
+    }).catch(() => {});
+  })();
+  // #endregion
+
+  if (kind === "txt") {
+    const text = buffer.toString("utf8").trim();
+    // #region debug-point A:extract-txt
+    (() => {
+      let u = "http://127.0.0.1:7777/event";
+      let s = "image-rag-miss";
+      try {
+        const e = fs.readFileSync(".dbg/image-rag-miss.env", "utf8");
+        u = e.match(/DEBUG_SERVER_URL=(.+)/)?.[1] || u;
+        s = e.match(/DEBUG_SESSION_ID=(.+)/)?.[1] || s;
+      } catch {}
+      fetch(u, {
+        method: "POST",
+        headers: { "content-type": "application/json" },
+        body: JSON.stringify({
+          sessionId: s,
+          runId: "post",
+          hypothesisId: "A",
+          location: "api/chat/ingest.js",
+          msg: "[DEBUG] extract txt done",
+          data: { chars: text.length, head: text.slice(0, 160) },
+          ts: Date.now()
+        })
+      }).catch(() => {});
+    })();
+    // #endregion
+    return [{ text, source: src, metadata }];
+  }
+
+  if (kind === "pdf") {
+    let pdfParse;
+    try {
+      const mod = await import("pdf-parse");
+      pdfParse = mod?.default ?? mod;
+    } catch {
+      try {
+        pdfParse = require("pdf-parse");
+      } catch {
+        pdfParse = require("pdf-parse/lib/pdf-parse.js");
+      }
+      pdfParse = pdfParse?.default ?? pdfParse;
+    }
+
+    if (typeof pdfParse !== "function") {
+      const err = new Error("pdf_parse_unavailable");
+      err.statusCode = 500;
+      throw err;
+    }
+
+    const parsed = await pdfParse(buffer);
+    const text = String(parsed?.text ?? "").trim();
+    // #region debug-point A:extract-pdf
+    (() => {
+      let u = "http://127.0.0.1:7777/event";
+      let s = "image-rag-miss";
+      try {
+        const e = fs.readFileSync(".dbg/image-rag-miss.env", "utf8");
+        u = e.match(/DEBUG_SERVER_URL=(.+)/)?.[1] || u;
+        s = e.match(/DEBUG_SESSION_ID=(.+)/)?.[1] || s;
+      } catch {}
+      fetch(u, {
+        method: "POST",
+        headers: { "content-type": "application/json" },
+        body: JSON.stringify({
+          sessionId: s,
+          runId: "post",
+          hypothesisId: "A",
+          location: "api/chat/ingest.js",
+          msg: "[DEBUG] extract pdf done",
+          data: { chars: text.length, head: text.slice(0, 160) },
+          ts: Date.now()
+        })
+      }).catch(() => {});
+    })();
+    // #endregion
+    return [{ text, source: src, metadata }];
+  }
+
+  if (kind === "docx") {
+    const extracted = await mammoth.extractRawText({ buffer });
+    const baseText = String(extracted?.value ?? "").trim();
+
+    const images = [];
+    await mammoth.convertToHtml(
+      { buffer },
+      {
+        convertImage: mammoth.images.inline(async (image) => {
+          const arr = await image.read();
+          images.push(Buffer.from(arr));
+          return { src: "" };
+        })
+      }
+    );
+
+    const ocrTexts = [];
+    let visionSkipped = 0;
+    let visionError = "";
+    let visionUnavailable = false;
+    for (let i = 0; i < images.length; i += 1) {
+      if (visionUnavailable) {
+        visionSkipped += 1;
+        continue;
+      }
+      const imageBase64 = images[i].toString("base64");
+      try {
+        let r = await visionExtractFromImage({ imageBase64, prompt: visionPrompt() });
+        let t = String(r?.content ?? "").trim();
+        if (isVisionRefusal(t)) {
+          r = await visionExtractFromImage({
+            imageBase64,
+            prompt: [
+              "Analise a imagem e descreva somente os elementos de UI e estados selecionados.",
+              "Liste itens curtos: campos/labels, opções marcadas, botões e mensagens de erro.",
+              "Não faça transcrição literal de textos longos.",
+              "Responda em português."
+            ].join("\n")
+          });
+          t = String(r?.content ?? "").trim();
+        }
+        if (isVisionRefusal(t)) {
+          visionError = "vision_refused";
+          visionSkipped += 1;
+          continue;
+        }
+        if (t) ocrTexts.push(`Imagem ${i + 1}:\n${t}`);
+      } catch (e) {
+        const msg = typeof e?.message === "string" ? e.message : "";
+        visionError = msg || "vision_failed";
+        if (msg.startsWith("ollama_model_not_found:")) {
+          visionUnavailable = true;
+          visionSkipped += images.length - i;
+        } else {
+          visionSkipped += 1;
+        }
+      }
+    }
+
+    const textParts = [];
+    if (baseText) textParts.push(baseText);
+    if (ocrTexts.length) textParts.push(ocrTexts.join("\n\n"));
+
+    const text = textParts.join("\n\n").trim();
+    // #region debug-point A:extract-docx
+    (() => {
+      let u = "http://127.0.0.1:7777/event";
+      let s = "image-rag-miss";
+      try {
+        const e = fs.readFileSync(".dbg/image-rag-miss.env", "utf8");
+        u = e.match(/DEBUG_SERVER_URL=(.+)/)?.[1] || u;
+        s = e.match(/DEBUG_SESSION_ID=(.+)/)?.[1] || s;
+      } catch {}
+      fetch(u, {
+        method: "POST",
+        headers: { "content-type": "application/json" },
+        body: JSON.stringify({
+          sessionId: s,
+          runId: "post",
+          hypothesisId: "A",
+          location: "api/chat/ingest.js",
+          msg: "[DEBUG] extract docx done",
+          data: {
+            baseChars: baseText.length,
+            totalChars: text.length,
+            imagesTotal: images.length,
+            imagesProcessed: images.length - visionSkipped,
+            imagesSkipped: visionSkipped,
+            visionError: visionError || null,
+            head: text.slice(0, 160)
+          },
+          ts: Date.now()
+        })
+      }).catch(() => {});
+    })();
+    // #endregion
+    return [
+      {
+        text,
+        source: src,
+        metadata: {
+          ...metadata,
+          imagesTotal: images.length,
+          imagesProcessed: images.length - visionSkipped,
+          imagesSkipped: visionSkipped,
+          visionError: visionError || null
+        }
+      }
+    ];
+  }
+
+  if (kind === "image") {
+    const imageBase64 = buffer.toString("base64");
+    try {
+      let r = await visionExtractFromImage({ imageBase64, prompt: visionPrompt() });
+      let text = String(r?.content ?? "").trim();
+      if (isVisionRefusal(text)) {
+        r = await visionExtractFromImage({
+          imageBase64,
+          prompt: [
+            "Analise a imagem e descreva somente os elementos de UI e estados selecionados.",
+            "Liste itens curtos: campos/labels, opções marcadas, botões e mensagens de erro.",
+            "Não faça transcrição literal de textos longos.",
+            "Responda em português."
+          ].join("\n")
+        });
+        text = String(r?.content ?? "").trim();
+      }
+      // #region debug-point A:extract-image
+      (() => {
+        let u = "http://127.0.0.1:7777/event";
+        let s = "image-rag-miss";
+        try {
+          const e = fs.readFileSync(".dbg/image-rag-miss.env", "utf8");
+          u = e.match(/DEBUG_SERVER_URL=(.+)/)?.[1] || u;
+          s = e.match(/DEBUG_SESSION_ID=(.+)/)?.[1] || s;
+        } catch {}
+        fetch(u, {
+          method: "POST",
+          headers: { "content-type": "application/json" },
+          body: JSON.stringify({
+            sessionId: s,
+            runId: "pre",
+            hypothesisId: "A",
+            location: "api/chat/ingest.js",
+            msg: "[DEBUG] extract image done",
+            data: { chars: text.length, head: text.slice(0, 160) },
+            ts: Date.now()
+          })
+        }).catch(() => {});
+      })();
+      // #endregion
+      if (isVisionRefusal(text)) {
+        const err = new Error("vision_refused");
+        err.statusCode = 400;
+        throw err;
+      }
+      return [{ text, source: src, metadata }];
+    } catch (e) {
+      const msg = typeof e?.message === "string" ? e.message : "";
+      if (msg.startsWith("ollama_model_not_found:")) {
+        const err = new Error(msg);
+        err.statusCode = 400;
+        throw err;
+      }
+      throw e;
+    }
+  }
+
+  const err = new Error("unsupported_file_type");
+  err.statusCode = 400;
+  throw err;
+}

+ 144 - 0
chat/searchChat.js

@@ -0,0 +1,144 @@
+import { config } from "../config/index.js";
+import { embedTexts } from "../services/ollamaClient.js";
+import { qdrant } from "../services/qdrantClient.js";
+import fs from "node:fs";
+
+function isRefusalText(text) {
+  const t = String(text ?? "").toLowerCase();
+  return (
+    t.includes("desculpe") &&
+    (t.includes("não posso") || t.includes("nao posso") || t.includes("não posso fornecer") || t.includes("nao posso fornecer"))
+  );
+}
+
+export async function searchDocs({ query, topK }) {
+  const collectionName = config.qdrant.collection;
+  // #region debug-point C:image-rag-search-start
+  (() => {
+    let u = "http://127.0.0.1:7777/event";
+    let s = "image-rag-miss";
+    try {
+      const e = fs.readFileSync(".dbg/image-rag-miss.env", "utf8");
+      u = e.match(/DEBUG_SERVER_URL=(.+)/)?.[1] || u;
+      s = e.match(/DEBUG_SESSION_ID=(.+)/)?.[1] || s;
+    } catch {}
+    fetch(u, {
+      method: "POST",
+      headers: { "content-type": "application/json" },
+      body: JSON.stringify({
+        sessionId: s,
+        runId: "post",
+        hypothesisId: "C",
+        location: "api/chat/searchChat.js",
+        msg: "[DEBUG] searchDocs start",
+        data: { collection: collectionName, topK: topK ?? config.rag.topK, queryHead: String(query ?? "").slice(0, 140) },
+        ts: Date.now()
+      })
+    }).catch(() => {});
+  })();
+  // #endregion
+  // #region debug-point B:search-start
+  (() => {
+    let u = "http://127.0.0.1:7777/event";
+    let s = "chat-502-gateway";
+    try {
+      const e = fs.readFileSync(".dbg/chat-502-gateway.env", "utf8");
+      u = e.match(/DEBUG_SERVER_URL=(.+)/)?.[1] || u;
+      s = e.match(/DEBUG_SESSION_ID=(.+)/)?.[1] || s;
+    } catch {}
+    fetch(u, {
+      method: "POST",
+      headers: { "content-type": "application/json" },
+      body: JSON.stringify({
+        sessionId: s,
+        runId: "post-fix",
+        hypothesisId: "B",
+        location: "api/chat/searchChat.js",
+        msg: "[DEBUG] searchDocs start",
+        data: {
+          collection: collectionName,
+          topK: topK ?? config.rag.topK,
+          queryLen: typeof query === "string" ? query.length : null,
+          qdrantUrl: config.qdrant.url,
+          ollamaUrl: config.ollama.url
+        },
+        ts: Date.now()
+      })
+    }).catch(() => {});
+  })();
+  // #endregion
+  const [vector] = await embedTexts([query]);
+
+  const result = await qdrant.search(collectionName, {
+    vector,
+    limit: topK ?? config.rag.topK,
+    with_payload: true,
+    with_vector: false
+  });
+
+  // #region debug-point C:image-rag-search-result
+  (() => {
+    let u = "http://127.0.0.1:7777/event";
+    let s = "image-rag-miss";
+    try {
+      const e = fs.readFileSync(".dbg/image-rag-miss.env", "utf8");
+      u = e.match(/DEBUG_SERVER_URL=(.+)/)?.[1] || u;
+      s = e.match(/DEBUG_SESSION_ID=(.+)/)?.[1] || s;
+    } catch {}
+    const top = Array.isArray(result) && result.length ? result[0] : null;
+    fetch(u, {
+      method: "POST",
+      headers: { "content-type": "application/json" },
+      body: JSON.stringify({
+        sessionId: s,
+        runId: "post",
+        hypothesisId: "C",
+        location: "api/chat/searchChat.js",
+        msg: "[DEBUG] qdrant.search result",
+        data: {
+          count: Array.isArray(result) ? result.length : null,
+          topScore: top?.score ?? null,
+          topSource: top?.payload?.source ?? null,
+          topTextHead: typeof top?.payload?.text === "string" ? top.payload.text.slice(0, 160) : ""
+        },
+        ts: Date.now()
+      })
+    }).catch(() => {});
+  })();
+  // #endregion
+
+  // #region debug-point B:search-result
+  (() => {
+    let u = "http://127.0.0.1:7777/event";
+    let s = "chat-502-gateway";
+    try {
+      const e = fs.readFileSync(".dbg/chat-502-gateway.env", "utf8");
+      u = e.match(/DEBUG_SERVER_URL=(.+)/)?.[1] || u;
+      s = e.match(/DEBUG_SESSION_ID=(.+)/)?.[1] || s;
+    } catch {}
+    fetch(u, {
+      method: "POST",
+      headers: { "content-type": "application/json" },
+      body: JSON.stringify({
+        sessionId: s,
+        runId: "post-fix",
+        hypothesisId: "B",
+        location: "api/chat/searchChat.js",
+        msg: "[DEBUG] qdrant.search ok",
+        data: { count: Array.isArray(result) ? result.length : null },
+        ts: Date.now()
+      })
+    }).catch(() => {});
+  })();
+  // #endregion
+  return (result ?? [])
+    .map((r) => ({
+      score: r.score,
+      id: r.id,
+      text: r.payload?.text ?? "",
+      source: r.payload?.source ?? null,
+      chunkIndex: r.payload?.chunkIndex ?? null,
+      metadata: r.payload?.metadata ?? null
+    }))
+    .filter((h) => !isRefusalText(h.text));
+}

+ 5 - 0
config/collections.js

@@ -0,0 +1,5 @@
+import { config } from "./index.js";
+
+export const collections = {
+  docs: config.qdrant.collection
+};

+ 25 - 0
config/index.js

@@ -0,0 +1,25 @@
+import dotenv from "dotenv";
+
+dotenv.config();
+
+export const config = {
+  port: Number(process.env.PORT ?? 3001),
+  corsOrigin: process.env.CORS_ORIGIN ?? "http://localhost:5173",
+  apiKey: process.env.API_KEY ?? "",
+  qdrant: {
+    url: process.env.QDRANT_URL ?? "http://localhost:6333",
+    apiKey: process.env.QDRANT_API_KEY ?? "",
+    collection: process.env.QDRANT_COLLECTION ?? "empresa_docs"
+  },
+  ollama: {
+    url: process.env.OLLAMA_URL ?? "http://localhost:11434",
+    embeddingsModel: process.env.OLLAMA_EMBEDDINGS_MODEL ?? "nomic-embed-text",
+    chatModel: process.env.OLLAMA_CHAT_MODEL ?? "llama3.1",
+    visionModel: process.env.OLLAMA_VISION_MODEL ?? "llava:latest"
+  },
+  rag: {
+    topK: Number(process.env.RAG_TOP_K ?? 6),
+    chunkSize: Number(process.env.RAG_CHUNK_SIZE ?? 900),
+    chunkOverlap: Number(process.env.RAG_CHUNK_OVERLAP ?? 150)
+  }
+};

+ 11 - 0
infra/docker/docker-compose.yml

@@ -0,0 +1,11 @@
+services:
+  qdrant:
+    image: qdrant/qdrant:v1.11.3
+    ports:
+      - "6333:6333"
+      - "6334:6334"
+    volumes:
+      - qdrant_storage:/qdrant/storage
+
+volumes:
+  qdrant_storage:

+ 5 - 0
loaders/cloudDrive.js

@@ -0,0 +1,5 @@
+export async function syncCloudDrive() {
+  const err = new Error("cloud_drive_sync_not_configured");
+  err.statusCode = 501;
+  throw err;
+}

+ 5 - 0
loaders/driveLoader.js

@@ -0,0 +1,5 @@
+export async function syncGoogleDrive() {
+  const err = new Error("google_drive_sync_not_configured");
+  err.statusCode = 501;
+  throw err;
+}

+ 5 - 0
loaders/pdfLoader.js

@@ -0,0 +1,5 @@
+export async function loadPdf() {
+  const err = new Error("pdf_loader_not_configured");
+  err.statusCode = 501;
+  throw err;
+}

+ 12 - 0
loaders/txtLoader.js

@@ -0,0 +1,12 @@
+export async function loadTxtFromString(text, { source, metadata } = {}) {
+  const normalized = String(text ?? "").trim();
+  if (!normalized) return [];
+
+  return [
+    {
+      text: normalized,
+      source: source ?? "txt",
+      metadata: metadata ?? null
+    }
+  ];
+}

+ 15 - 0
middleware/auth.js

@@ -0,0 +1,15 @@
+import { config } from "../config/index.js";
+
+export function authMiddleware(req, res, next) {
+  if (!config.apiKey) return next();
+
+  const header = req.header("authorization") ?? "";
+  const token = header.startsWith("Bearer ") ? header.slice("Bearer ".length) : "";
+
+  if (token !== config.apiKey) {
+    res.status(401).json({ error: "unauthorized" });
+    return;
+  }
+
+  next();
+}

+ 8 - 0
middleware/errorHandler.js

@@ -0,0 +1,8 @@
+export function errorHandler(err, _req, res, _next) {
+  const status = Number(err?.statusCode ?? err?.status ?? 500);
+  const message = typeof err?.message === "string" ? err.message : "internal_error";
+
+  res.status(status).json({
+    error: message
+  });
+}

+ 26 - 0
middleware/rateLimit.js

@@ -0,0 +1,26 @@
+const buckets = new Map();
+
+export function rateLimitMiddleware(req, res, next) {
+  const limitPerMinute = Number(process.env.RATE_LIMIT_PER_MINUTE ?? 0);
+  if (!limitPerMinute) return next();
+
+  const key = req.ip ?? "unknown";
+  const now = Date.now();
+  const windowMs = 60_000;
+
+  const bucket = buckets.get(key) ?? { count: 0, resetAt: now + windowMs };
+  if (now > bucket.resetAt) {
+    bucket.count = 0;
+    bucket.resetAt = now + windowMs;
+  }
+
+  bucket.count += 1;
+  buckets.set(key, bucket);
+
+  if (bucket.count > limitPerMinute) {
+    res.status(429).json({ error: "rate_limited" });
+    return;
+  }
+
+  next();
+}

+ 26 - 0
package.json

@@ -0,0 +1,26 @@
+{
+  "name": "ia-empresa-api",
+  "private": true,
+  "type": "module",
+  "scripts": {
+    "dev": "node --watch server.js",
+    "start": "node server.js",
+    "lint": "node -c server.js && node -c app.js",
+    "build": "node -c server.js"
+  },
+  "dependencies": {
+    "@qdrant/js-client-rest": "^1.11.0",
+    "cors": "^2.8.5",
+    "dotenv": "^16.4.5",
+    "express": "^4.19.2",
+    "helmet": "^7.1.0",
+    "mammoth": "^1.8.0",
+    "morgan": "^1.10.0",
+    "multer": "^2.0.0",
+    "pdf-parse": "^1.1.1",
+    "zod": "^3.23.8"
+  },
+  "engines": {
+    "node": ">=18"
+  }
+}

+ 92 - 0
routes/chat.js

@@ -0,0 +1,92 @@
+import { Router } from "express";
+import { z } from "zod";
+import { answerWithContext } from "../chat/chatChain.js";
+import fs from "node:fs";
+
+export const chatRouter = Router();
+
+const chatBodySchema = z.object({
+  message: z.string().min(1),
+  sessionId: z.string().min(1).optional(),
+  model: z.string().min(1).optional(),
+  options: z
+    .object({
+      temperature: z.number().min(0).max(2).optional(),
+      top_p: z.number().min(0).max(1).optional()
+    })
+    .optional()
+});
+
+chatRouter.post("/", async (req, res, next) => {
+  try {
+    // #region debug-point A:chat-start
+    (() => {
+      let u = "http://127.0.0.1:7777/event";
+      let s = "chat-502-gateway";
+      try {
+        const e = fs.readFileSync(".dbg/chat-502-gateway.env", "utf8");
+        u = e.match(/DEBUG_SERVER_URL=(.+)/)?.[1] || u;
+        s = e.match(/DEBUG_SESSION_ID=(.+)/)?.[1] || s;
+      } catch {}
+      fetch(u, {
+        method: "POST",
+        headers: { "content-type": "application/json" },
+        body: JSON.stringify({
+          sessionId: s,
+          runId: "post-fix",
+          hypothesisId: "A",
+          location: "api/routes/chat.js",
+          msg: "[DEBUG] /api/chat received",
+          data: {
+            method: req.method,
+            path: req.originalUrl,
+            contentType: req.header("content-type") ?? "",
+            bodyType: typeof req.body,
+            hasBody: Boolean(req.body),
+            messageLen: typeof req.body?.message === "string" ? req.body.message.length : null
+          },
+          ts: Date.now()
+        })
+      }).catch(() => {});
+    })();
+    // #endregion
+    const body = chatBodySchema.parse(req.body);
+    const result = await answerWithContext({
+      message: body.message,
+      sessionId: body.sessionId,
+      model: body.model,
+      options: body.options
+    });
+    res.json(result);
+  } catch (err) {
+    // #region debug-point D:chat-error
+    (() => {
+      let u = "http://127.0.0.1:7777/event";
+      let s = "chat-502-gateway";
+      try {
+        const e = fs.readFileSync(".dbg/chat-502-gateway.env", "utf8");
+        u = e.match(/DEBUG_SERVER_URL=(.+)/)?.[1] || u;
+        s = e.match(/DEBUG_SESSION_ID=(.+)/)?.[1] || s;
+      } catch {}
+      fetch(u, {
+        method: "POST",
+        headers: { "content-type": "application/json" },
+        body: JSON.stringify({
+          sessionId: s,
+          runId: "post-fix",
+          hypothesisId: "D",
+          location: "api/routes/chat.js",
+          msg: "[DEBUG] /api/chat error",
+          data: {
+            name: err?.name ?? null,
+            message: typeof err?.message === "string" ? err.message : null,
+            statusCode: err?.statusCode ?? err?.status ?? null
+          },
+          ts: Date.now()
+        })
+      }).catch(() => {});
+    })();
+    // #endregion
+    next(err);
+  }
+});

+ 17 - 0
routes/documents.js

@@ -0,0 +1,17 @@
+import { Router } from "express";
+import { z } from "zod";
+import { listDocuments } from "../services/documentsService.js";
+
+export const documentsRouter = Router();
+
+documentsRouter.get("/", async (req, res, next) => {
+  try {
+    const limit = z.coerce.number().int().positive().max(200).catch(50).parse(req.query.limit);
+    const offsetRaw = z.string().optional().parse(req.query.offset);
+    const offset = offsetRaw && offsetRaw !== "0" ? offsetRaw : undefined;
+    const result = await listDocuments({ limit, offset });
+    res.json(result);
+  } catch (err) {
+    next(err);
+  }
+});

+ 12 - 0
routes/index.js

@@ -0,0 +1,12 @@
+import { Router } from "express";
+import { chatRouter } from "./chat.js";
+import { searchRouter } from "./search.js";
+import { ingestRouter } from "./ingest.js";
+import { documentsRouter } from "./documents.js";
+
+export const apiRouter = Router();
+
+apiRouter.use("/chat", chatRouter);
+apiRouter.use("/search", searchRouter);
+apiRouter.use("/ingest", ingestRouter);
+apiRouter.use("/documents", documentsRouter);

+ 110 - 0
routes/ingest.js

@@ -0,0 +1,110 @@
+import { Router } from "express";
+import { z } from "zod";
+import multer from "multer";
+import { extractDocumentsFromUpload, ingestDocuments } from "../chat/ingest.js";
+import fs from "node:fs";
+
+export const ingestRouter = Router();
+
+const upload = multer({
+  storage: multer.memoryStorage(),
+  limits: { fileSize: 25 * 1024 * 1024 }
+});
+
+const docSchema = z.object({
+  id: z.string().min(1).optional(),
+  text: z.string().min(1),
+  source: z.string().min(1).optional(),
+  metadata: z.record(z.any()).optional()
+});
+
+const ingestBodySchema = z.object({
+  documents: z.array(docSchema).min(1)
+});
+
+ingestRouter.post("/", async (req, res, next) => {
+  try {
+    const body = ingestBodySchema.parse(req.body);
+    const result = await ingestDocuments(body.documents);
+    res.json(result);
+  } catch (err) {
+    next(err);
+  }
+});
+
+ingestRouter.post("/file", upload.single("file"), async (req, res, next) => {
+  try {
+    const f = req.file;
+    if (!f?.buffer) {
+      res.status(400).json({ error: "file_required" });
+      return;
+    }
+
+    const source = typeof req.body?.source === "string" && req.body.source.trim() ? req.body.source.trim() : undefined;
+    // #region debug-point A:ingest-file-start
+    (() => {
+      let u = "http://127.0.0.1:7777/event";
+      let s = "image-rag-miss";
+      try {
+        const e = fs.readFileSync(".dbg/image-rag-miss.env", "utf8");
+        u = e.match(/DEBUG_SERVER_URL=(.+)/)?.[1] || u;
+        s = e.match(/DEBUG_SESSION_ID=(.+)/)?.[1] || s;
+      } catch {}
+      fetch(u, {
+        method: "POST",
+        headers: { "content-type": "application/json" },
+        body: JSON.stringify({
+          sessionId: s,
+          runId: "post",
+          hypothesisId: "A",
+          location: "api/routes/ingest.js",
+          msg: "[DEBUG] /api/ingest/file received",
+          data: { filename: f.originalname, mimeType: f.mimetype, bytes: f.size ?? null, source: source ?? null },
+          ts: Date.now()
+        })
+      }).catch(() => {});
+    })();
+    // #endregion
+    const docs = await extractDocumentsFromUpload({
+      buffer: f.buffer,
+      filename: f.originalname,
+      mimeType: f.mimetype,
+      source
+    });
+
+    const extractedChars = docs.reduce((acc, d) => acc + (d?.text?.length ?? 0), 0);
+    // #region debug-point A:ingest-file-extracted
+    (() => {
+      let u = "http://127.0.0.1:7777/event";
+      let s = "image-rag-miss";
+      try {
+        const e = fs.readFileSync(".dbg/image-rag-miss.env", "utf8");
+        u = e.match(/DEBUG_SERVER_URL=(.+)/)?.[1] || u;
+        s = e.match(/DEBUG_SESSION_ID=(.+)/)?.[1] || s;
+      } catch {}
+      fetch(u, {
+        method: "POST",
+        headers: { "content-type": "application/json" },
+        body: JSON.stringify({
+          sessionId: s,
+          runId: "post",
+          hypothesisId: "A",
+          location: "api/routes/ingest.js",
+          msg: "[DEBUG] /api/ingest/file extracted",
+          data: { documents: docs.length, extractedChars, head: String(docs?.[0]?.text ?? "").slice(0, 160) },
+          ts: Date.now()
+        })
+      }).catch(() => {});
+    })();
+    // #endregion
+    if (!extractedChars) {
+      res.status(400).json({ error: "empty_extracted_text" });
+      return;
+    }
+
+    const result = await ingestDocuments(docs);
+    res.json({ ...result, documents: docs.length, extractedChars });
+  } catch (err) {
+    next(err);
+  }
+});

+ 23 - 0
routes/search.js

@@ -0,0 +1,23 @@
+import { Router } from "express";
+import { z } from "zod";
+import { searchDocs } from "../chat/searchChat.js";
+
+export const searchRouter = Router();
+
+const searchBodySchema = z.object({
+  query: z.string().min(1),
+  topK: z.number().int().positive().optional()
+});
+
+searchRouter.post("/", async (req, res, next) => {
+  try {
+    const body = searchBodySchema.parse(req.body);
+    const results = await searchDocs({
+      query: body.query,
+      topK: body.topK
+    });
+    res.json({ results });
+  } catch (err) {
+    next(err);
+  }
+});

+ 8 - 0
server.js

@@ -0,0 +1,8 @@
+import { createApp } from "./app.js";
+import { config } from "./config/index.js";
+
+const app = createApp();
+
+app.listen(config.port, () => {
+  process.stdout.write(`API listening on http://localhost:${config.port}\n`);
+});

+ 17 - 0
services/collectionService.js

@@ -0,0 +1,17 @@
+import { config } from "../config/index.js";
+import { qdrant } from "./qdrantClient.js";
+
+export async function ensureCollection({ vectorSize }) {
+  const collectionName = config.qdrant.collection;
+  const existing = await qdrant.getCollections();
+  const has = (existing?.collections ?? []).some((c) => c.name === collectionName);
+
+  if (has) return;
+
+  await qdrant.createCollection(collectionName, {
+    vectors: {
+      size: vectorSize,
+      distance: "Cosine"
+    }
+  });
+}

+ 32 - 0
services/documentsService.js

@@ -0,0 +1,32 @@
+import { config } from "../config/index.js";
+import { qdrant } from "./qdrantClient.js";
+
+export async function listDocuments({ limit, offset }) {
+  const collectionName = config.qdrant.collection;
+
+  let points;
+  try {
+    points = await qdrant.scroll(collectionName, {
+      limit,
+      offset: offset || undefined,
+      with_payload: true,
+      with_vector: false
+    });
+  } catch (err) {
+    const status = err?.status ?? err?.statusCode ?? err?.response?.status ?? null;
+    if (Number(status) === 404) return { items: [], nextOffset: null };
+    throw err;
+  }
+
+  const items = (points?.points ?? []).map((p) => ({
+    id: p.id,
+    source: p.payload?.source ?? null,
+    title: p.payload?.title ?? null,
+    text: p.payload?.text ?? null,
+    chunkIndex: p.payload?.chunkIndex ?? null,
+    metadata: p.payload?.metadata ?? null
+  }));
+
+  const nextOffset = points?.next_page_offset ?? points?.nextPageOffset ?? null;
+  return { items, nextOffset };
+}

+ 1 - 0
services/loader.js

@@ -0,0 +1 @@
+export { chunkText } from "./textChunker.js";

+ 1 - 0
services/ollama.js

@@ -0,0 +1 @@
+export { embedTexts, chatCompletion } from "./ollamaClient.js";

+ 150 - 0
services/ollamaClient.js

@@ -0,0 +1,150 @@
+import { config } from "../config/index.js";
+import fs from "node:fs";
+
+async function ollamaFetch(path, body) {
+  let res;
+  try {
+    res = await fetch(`${config.ollama.url}${path}`, {
+      method: "POST",
+      headers: { "content-type": "application/json" },
+      body: JSON.stringify(body)
+    });
+  } catch (err) {
+    // #region debug-point C:ollama-fetch-throw
+    (() => {
+      let u = "http://127.0.0.1:7777/event";
+      let s = "chat-502-gateway";
+      try {
+        const e = fs.readFileSync(".dbg/chat-502-gateway.env", "utf8");
+        u = e.match(/DEBUG_SERVER_URL=(.+)/)?.[1] || u;
+        s = e.match(/DEBUG_SESSION_ID=(.+)/)?.[1] || s;
+      } catch {}
+      fetch(u, {
+        method: "POST",
+        headers: { "content-type": "application/json" },
+        body: JSON.stringify({
+          sessionId: s,
+          runId: "post-fix",
+          hypothesisId: "C",
+          location: "api/services/ollamaClient.js",
+          msg: "[DEBUG] ollama fetch threw",
+          data: {
+            url: config.ollama.url,
+            path,
+            model: body?.model ?? null,
+            promptLen: typeof body?.prompt === "string" ? body.prompt.length : null,
+            messagesCount: Array.isArray(body?.messages) ? body.messages.length : null,
+            name: err?.name ?? null,
+            message: typeof err?.message === "string" ? err.message : null
+          },
+          ts: Date.now()
+        })
+      }).catch(() => {});
+    })();
+    // #endregion
+    throw err;
+  }
+
+  if (!res.ok) {
+    const text = await res.text().catch(() => "");
+    // #region debug-point C:ollama-non-200
+    (() => {
+      let u = "http://127.0.0.1:7777/event";
+      let s = "chat-502-gateway";
+      try {
+        const e = fs.readFileSync(".dbg/chat-502-gateway.env", "utf8");
+        u = e.match(/DEBUG_SERVER_URL=(.+)/)?.[1] || u;
+        s = e.match(/DEBUG_SESSION_ID=(.+)/)?.[1] || s;
+      } catch {}
+      fetch(u, {
+        method: "POST",
+        headers: { "content-type": "application/json" },
+        body: JSON.stringify({
+          sessionId: s,
+          runId: "post-fix",
+          hypothesisId: "C",
+          location: "api/services/ollamaClient.js",
+          msg: "[DEBUG] ollama non-200",
+          data: {
+            url: config.ollama.url,
+            path,
+            status: res.status,
+            statusText: res.statusText,
+            model: body?.model ?? null,
+            promptLen: typeof body?.prompt === "string" ? body.prompt.length : null,
+            messagesCount: Array.isArray(body?.messages) ? body.messages.length : null,
+            responseSnippet: typeof text === "string" ? text.slice(0, 300) : ""
+          },
+          ts: Date.now()
+        })
+      }).catch(() => {});
+    })();
+    // #endregion
+    let msg = `ollama_error:${res.status}:${text || res.statusText}`;
+    if (
+      res.status === 404 &&
+      typeof body?.model === "string" &&
+      typeof text === "string" &&
+      text.toLowerCase().includes("model") &&
+      text.toLowerCase().includes("not found")
+    ) {
+      let installed = [];
+      try {
+        const tagsRes = await fetch(`${config.ollama.url}/api/tags`);
+        const tags = await tagsRes.json().catch(() => ({}));
+        installed = Array.isArray(tags?.models) ? tags.models.map((m) => m?.name).filter(Boolean) : [];
+      } catch {}
+      const installedList = installed.length ? installed.slice(0, 10).join(",") : "none";
+      msg = `ollama_model_not_found:${body.model}:installed=${installedList}:hint=ollama pull ${body.model}`;
+    }
+    const err = new Error(msg);
+    err.statusCode = 502;
+    throw err;
+  }
+
+  return res.json();
+}
+
+export async function embedTexts(texts) {
+  const embeddings = [];
+
+  for (const text of texts) {
+    const data = await ollamaFetch("/api/embeddings", {
+      model: config.ollama.embeddingsModel,
+      prompt: text
+    });
+    embeddings.push(data.embedding);
+  }
+
+  return embeddings;
+}
+
+export async function chatCompletion({ messages }) {
+  const data = await ollamaFetch("/api/chat", {
+    model: config.ollama.chatModel,
+    messages,
+    stream: false
+  });
+
+  return {
+    content: data?.message?.content ?? ""
+  };
+}
+
+export async function visionExtractFromImage({ imageBase64, prompt }) {
+  const data = await ollamaFetch("/api/chat", {
+    model: config.ollama.visionModel,
+    messages: [
+      {
+        role: "user",
+        content: prompt,
+        images: [imageBase64]
+      }
+    ],
+    stream: false
+  });
+
+  return {
+    content: data?.message?.content ?? ""
+  };
+}

+ 1 - 0
services/qdrant.js

@@ -0,0 +1 @@
+export { qdrant } from "./qdrantClient.js";

+ 8 - 0
services/qdrantClient.js

@@ -0,0 +1,8 @@
+import { QdrantClient } from "@qdrant/js-client-rest";
+import { config } from "../config/index.js";
+
+export const qdrant = new QdrantClient({
+  url: config.qdrant.url,
+  apiKey: config.qdrant.apiKey || undefined,
+  checkCompatibility: false
+});

+ 17 - 0
services/textChunker.js

@@ -0,0 +1,17 @@
+export function chunkText(text, { chunkSize, chunkOverlap }) {
+  const normalized = String(text ?? "").replace(/\r\n/g, "\n").trim();
+  if (!normalized) return [];
+
+  const chunks = [];
+  let start = 0;
+
+  while (start < normalized.length) {
+    const end = Math.min(start + chunkSize, normalized.length);
+    const chunk = normalized.slice(start, end).trim();
+    if (chunk) chunks.push(chunk);
+    if (end >= normalized.length) break;
+    start = Math.max(0, end - chunkOverlap);
+  }
+
+  return chunks;
+}