ingest.js 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273
  1. import { config } from "../src/config/index.js";
  2. import { chunkText } from "../src/services/textChunker.js";
  3. import { embedTexts, visionExtractFromImage } from "../src/services/ollamaClient.js";
  4. import { ensureCollection } from "../src/services/collectionService.js";
  5. import { qdrant } from "../src/services/qdrantClient.js";
  6. import { createHash } from "node:crypto";
  7. import { createRequire } from "node:module";
  8. import mammoth from "mammoth";
  9. const require = createRequire(import.meta.url);
  10. function isVisionRefusal(text) {
  11. const t = String(text ?? "").toLowerCase();
  12. return (
  13. t.includes("desculpe") &&
  14. (t.includes("não posso") || t.includes("nao posso") || t.includes("não posso fornecer") || t.includes("nao posso fornecer"))
  15. );
  16. }
  17. function visionPrompt() {
  18. return [
  19. "Analise a imagem (print de tela / manual interno).",
  20. "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.",
  21. "Não transcreva parágrafos longos; prefira listas curtas e objetivas.",
  22. "Responda em português, em texto puro."
  23. ].join("\n");
  24. }
  25. function stableUuid(seed) {
  26. const hex = createHash("sha1")
  27. .update(String(seed))
  28. .digest("hex")
  29. .slice(0, 32);
  30. const timeLow = hex.slice(0, 8);
  31. const timeMid = hex.slice(8, 12);
  32. let timeHiAndVersion = parseInt(hex.slice(12, 16), 16);
  33. timeHiAndVersion = (timeHiAndVersion & 0x0fff) | 0x5000;
  34. let clockSeqHi = parseInt(hex.slice(16, 18), 16);
  35. clockSeqHi = (clockSeqHi & 0x3f) | 0x80;
  36. const clockSeqLow = hex.slice(18, 20);
  37. const node = hex.slice(20, 32);
  38. return [
  39. timeLow,
  40. timeMid,
  41. timeHiAndVersion.toString(16).padStart(4, "0"),
  42. `${clockSeqHi.toString(16).padStart(2, "0")}${clockSeqLow}`,
  43. node
  44. ].join("-");
  45. }
  46. export async function ingestDocuments(documents) {
  47. const collectionName = config.qdrant.collection;
  48. const allChunks = [];
  49. for (const doc of documents) {
  50. const baseSeed = doc.id ?? `${doc.source ?? "doc"}:${doc.text.slice(0, 64)}`;
  51. const chunks = chunkText(doc.text, {
  52. chunkSize: config.rag.chunkSize,
  53. chunkOverlap: config.rag.chunkOverlap
  54. });
  55. chunks.forEach((chunk, idx) => {
  56. allChunks.push({
  57. id: stableUuid(`${baseSeed}:${idx}`),
  58. source: doc.source ?? null,
  59. metadata: doc.metadata ?? null,
  60. chunkIndex: idx,
  61. text: chunk
  62. });
  63. });
  64. }
  65. if (allChunks.length === 0) return { upserted: 0 };
  66. const vectors = await embedTexts(allChunks.map((c) => c.text));
  67. const vectorSize = vectors[0]?.length ?? 0;
  68. if (!vectorSize) {
  69. const err = new Error("embeddings_empty");
  70. err.statusCode = 502;
  71. throw err;
  72. }
  73. await ensureCollection({ vectorSize });
  74. const points = allChunks.map((c, idx) => ({
  75. id: c.id,
  76. vector: vectors[idx],
  77. payload: {
  78. source: c.source,
  79. chunkIndex: c.chunkIndex,
  80. text: c.text,
  81. metadata: c.metadata
  82. }
  83. }));
  84. await qdrant.upsert(collectionName, {
  85. wait: true,
  86. points
  87. });
  88. return { upserted: points.length };
  89. }
  90. function guessFileKind({ mimeType, filename }) {
  91. const name = String(filename ?? "").toLowerCase();
  92. const mt = String(mimeType ?? "").toLowerCase();
  93. if (mt === "text/plain" || name.endsWith(".txt")) return "txt";
  94. if (mt === "application/pdf" || name.endsWith(".pdf")) return "pdf";
  95. if (
  96. mt === "application/vnd.openxmlformats-officedocument.wordprocessingml.document" ||
  97. name.endsWith(".docx")
  98. )
  99. return "docx";
  100. if (mt.startsWith("image/") || /\.(png|jpe?g|webp)$/i.test(name)) return "image";
  101. return "unknown";
  102. }
  103. export async function extractDocumentsFromUpload({ buffer, filename, mimeType, source }) {
  104. const kind = guessFileKind({ mimeType, filename });
  105. const src = source ?? filename ?? "upload";
  106. const metadata = { filename: filename ?? null, mimeType: mimeType ?? null, kind };
  107. if (kind === "txt") {
  108. const text = buffer.toString("utf8").trim();
  109. return [{ text, source: src, metadata }];
  110. }
  111. if (kind === "pdf") {
  112. let pdfParse;
  113. try {
  114. const mod = await import("pdf-parse");
  115. pdfParse = mod?.default ?? mod;
  116. } catch {
  117. try {
  118. pdfParse = require("pdf-parse");
  119. } catch {
  120. pdfParse = require("pdf-parse/lib/pdf-parse.js");
  121. }
  122. pdfParse = pdfParse?.default ?? pdfParse;
  123. }
  124. if (typeof pdfParse !== "function") {
  125. const err = new Error("pdf_parse_unavailable");
  126. err.statusCode = 500;
  127. throw err;
  128. }
  129. const parsed = await pdfParse(buffer);
  130. const text = String(parsed?.text ?? "").trim();
  131. return [{ text, source: src, metadata }];
  132. }
  133. if (kind === "docx") {
  134. const extracted = await mammoth.extractRawText({ buffer });
  135. const baseText = String(extracted?.value ?? "").trim();
  136. const images = [];
  137. await mammoth.convertToHtml(
  138. { buffer },
  139. {
  140. convertImage: mammoth.images.inline(async (image) => {
  141. const arr = await image.read();
  142. images.push(Buffer.from(arr));
  143. return { src: "" };
  144. })
  145. }
  146. );
  147. const ocrTexts = [];
  148. let visionSkipped = 0;
  149. let visionError = "";
  150. let visionUnavailable = false;
  151. for (let i = 0; i < images.length; i += 1) {
  152. if (visionUnavailable) {
  153. visionSkipped += 1;
  154. continue;
  155. }
  156. const imageBase64 = images[i].toString("base64");
  157. try {
  158. let r = await visionExtractFromImage({ imageBase64, prompt: visionPrompt() });
  159. let t = String(r?.content ?? "").trim();
  160. if (isVisionRefusal(t)) {
  161. r = await visionExtractFromImage({
  162. imageBase64,
  163. prompt: [
  164. "Analise a imagem e descreva somente os elementos de UI e estados selecionados.",
  165. "Liste itens curtos: campos/labels, opções marcadas, botões e mensagens de erro.",
  166. "Não faça transcrição literal de textos longos.",
  167. "Responda em português."
  168. ].join("\n")
  169. });
  170. t = String(r?.content ?? "").trim();
  171. }
  172. if (isVisionRefusal(t)) {
  173. visionError = "vision_refused";
  174. visionSkipped += 1;
  175. continue;
  176. }
  177. if (t) ocrTexts.push(`Imagem ${i + 1}:\n${t}`);
  178. } catch (e) {
  179. const msg = typeof e?.message === "string" ? e.message : "";
  180. visionError = msg || "vision_failed";
  181. if (msg.startsWith("ollama_model_not_found:")) {
  182. visionUnavailable = true;
  183. visionSkipped += images.length - i;
  184. } else {
  185. visionSkipped += 1;
  186. }
  187. }
  188. }
  189. const textParts = [];
  190. if (baseText) textParts.push(baseText);
  191. if (ocrTexts.length) textParts.push(ocrTexts.join("\n\n"));
  192. const text = textParts.join("\n\n").trim();
  193. return [
  194. {
  195. text,
  196. source: src,
  197. metadata: {
  198. ...metadata,
  199. imagesTotal: images.length,
  200. imagesProcessed: images.length - visionSkipped,
  201. imagesSkipped: visionSkipped,
  202. visionError: visionError || null
  203. }
  204. }
  205. ];
  206. }
  207. if (kind === "image") {
  208. const imageBase64 = buffer.toString("base64");
  209. try {
  210. let r = await visionExtractFromImage({ imageBase64, prompt: visionPrompt() });
  211. let text = String(r?.content ?? "").trim();
  212. if (isVisionRefusal(text)) {
  213. r = await visionExtractFromImage({
  214. imageBase64,
  215. prompt: [
  216. "Analise a imagem e descreva somente os elementos de UI e estados selecionados.",
  217. "Liste itens curtos: campos/labels, opções marcadas, botões e mensagens de erro.",
  218. "Não faça transcrição literal de textos longos.",
  219. "Responda em português."
  220. ].join("\n")
  221. });
  222. text = String(r?.content ?? "").trim();
  223. }
  224. if (isVisionRefusal(text)) {
  225. const err = new Error("vision_refused");
  226. err.statusCode = 400;
  227. throw err;
  228. }
  229. return [{ text, source: src, metadata }];
  230. } catch (e) {
  231. const msg = typeof e?.message === "string" ? e.message : "";
  232. if (msg.startsWith("ollama_model_not_found:")) {
  233. const err = new Error(msg);
  234. err.statusCode = 400;
  235. throw err;
  236. }
  237. throw e;
  238. }
  239. }
  240. const err = new Error("unsupported_file_type");
  241. err.statusCode = 400;
  242. throw err;
  243. }