ingestService.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364
  1. import { config } from "../config/index.js";
  2. import { chunkText } from "./textChunker.js";
  3. import { embedTexts, visionExtractFromImage } from "./ollamaClient.js";
  4. import { ensureCollection } from "./collectionService.js";
  5. import { deleteDocumentsBySource } from "./documentsService.js";
  6. import { qdrant } from "./qdrantClient.js";
  7. import { isRefusal } from "../utils/isRefusal.js";
  8. import { createHash } from "node:crypto";
  9. import { createRequire } from "node:module";
  10. import mammoth from "mammoth";
  11. const require = createRequire(import.meta.url);
  12. function visionPrompt() {
  13. return [
  14. "Analise a imagem (print de tela / manual interno).",
  15. "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.",
  16. "Não transcreva parágrafos longos; prefira listas curtas e objetivas.",
  17. "Responda em português, em texto puro."
  18. ].join("\n");
  19. }
  20. function stableUuid(seed) {
  21. const hex = createHash("sha1")
  22. .update(String(seed))
  23. .digest("hex")
  24. .slice(0, 32);
  25. const timeLow = hex.slice(0, 8);
  26. const timeMid = hex.slice(8, 12);
  27. let timeHiAndVersion = parseInt(hex.slice(12, 16), 16);
  28. timeHiAndVersion = (timeHiAndVersion & 0x0fff) | 0x5000;
  29. let clockSeqHi = parseInt(hex.slice(16, 18), 16);
  30. clockSeqHi = (clockSeqHi & 0x3f) | 0x80;
  31. const clockSeqLow = hex.slice(18, 20);
  32. const node = hex.slice(20, 32);
  33. return [
  34. timeLow,
  35. timeMid,
  36. timeHiAndVersion.toString(16).padStart(4, "0"),
  37. `${clockSeqHi.toString(16).padStart(2, "0")}${clockSeqLow}`,
  38. node
  39. ].join("-");
  40. }
  41. export async function ingestDocuments(documents, {
  42. collectionName = config.qdrant.collection,
  43. chunkSize = config.rag.chunkSize,
  44. chunkOverlap = config.rag.chunkOverlap,
  45. embeddingsModel
  46. } = {}) {
  47. const allChunks = [];
  48. const ingestedAt = new Date().toISOString();
  49. for (const doc of documents) {
  50. const docHash = createHash("sha256").update(doc.text).digest("hex");
  51. const baseSeed = doc.id ?? docHash;
  52. const chunks = chunkText(doc.text, {
  53. chunkSize,
  54. chunkOverlap
  55. });
  56. chunks.forEach((chunk, idx) => {
  57. allChunks.push({
  58. id: stableUuid(`${baseSeed}:${idx}`),
  59. source: doc.source ?? null,
  60. title: doc.title ?? doc.source ?? null,
  61. metadata: doc.metadata ?? null,
  62. chunkIndex: idx,
  63. text: chunk,
  64. documentHash: docHash
  65. });
  66. });
  67. }
  68. if (allChunks.length === 0) return { upserted: 0 };
  69. const vectors = await embedTexts(allChunks.map((c) => c.text), { role: "passage", model: embeddingsModel });
  70. const vectorSize = vectors[0]?.length ?? 0;
  71. if (!vectorSize) {
  72. const err = new Error("embeddings_empty");
  73. err.statusCode = 502;
  74. throw err;
  75. }
  76. await ensureCollection({ vectorSize, collectionName });
  77. const sources = [...new Set(documents.map((d) => d.source).filter(Boolean))];
  78. for (const source of sources) {
  79. await deleteDocumentsBySource(source, { collectionName });
  80. }
  81. const points = allChunks.map((c, idx) => ({
  82. id: c.id,
  83. vector: vectors[idx],
  84. payload: {
  85. source: c.source,
  86. title: c.title,
  87. chunkIndex: c.chunkIndex,
  88. text: c.text,
  89. metadata: c.metadata,
  90. documentHash: c.documentHash,
  91. ingestedAt
  92. }
  93. }));
  94. await qdrant.upsert(collectionName, {
  95. wait: true,
  96. points
  97. });
  98. return { upserted: points.length };
  99. }
  100. function isPrivateUrl(urlStr) {
  101. try {
  102. const { hostname } = new URL(urlStr);
  103. return (
  104. hostname === "localhost" ||
  105. /^127\./.test(hostname) ||
  106. /^10\./.test(hostname) ||
  107. /^192\.168\./.test(hostname) ||
  108. /^172\.(1[6-9]|2\d|3[01])\./.test(hostname) ||
  109. /^169\.254\./.test(hostname) ||
  110. hostname === "0.0.0.0" ||
  111. hostname.includes(":") ||
  112. hostname.endsWith(".local")
  113. );
  114. } catch {
  115. return true;
  116. }
  117. }
  118. export function assertPublicHttpsUrl(urlStr) {
  119. if (!urlStr.startsWith("https://")) {
  120. const err = new Error("url_must_be_https");
  121. err.statusCode = 400;
  122. throw err;
  123. }
  124. if (isPrivateUrl(urlStr)) {
  125. const err = new Error("url_private_not_allowed");
  126. err.statusCode = 400;
  127. throw err;
  128. }
  129. }
  130. const MAX_REDIRECTS = 5;
  131. export async function fetchUrlText(urlStr) {
  132. let currentUrl = urlStr;
  133. let res;
  134. for (let hop = 0; ; hop += 1) {
  135. assertPublicHttpsUrl(currentUrl);
  136. res = await fetch(currentUrl, {
  137. headers: { "User-Agent": "Mozilla/5.0 star-oraculo/1.0" },
  138. redirect: "manual",
  139. signal: AbortSignal.timeout(15_000)
  140. });
  141. if (res.status < 300 || res.status >= 400) break;
  142. const location = res.headers.get("location");
  143. if (!location || hop >= MAX_REDIRECTS) {
  144. const err = new Error(`url_fetch_error:${res.status}`);
  145. err.statusCode = 502;
  146. throw err;
  147. }
  148. currentUrl = new URL(location, currentUrl).toString();
  149. }
  150. if (!res.ok) {
  151. const err = new Error(`url_fetch_error:${res.status}`);
  152. err.statusCode = 502;
  153. throw err;
  154. }
  155. const html = await res.text();
  156. const text = html
  157. .replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, " ")
  158. .replace(/<style\b[^<]*(?:(?!<\/style>)<[^<]*)*<\/style>/gi, " ")
  159. .replace(/<[^>]+>/g, " ")
  160. .replace(/&nbsp;/g, " ")
  161. .replace(/&amp;/g, "&")
  162. .replace(/&lt;/g, "<")
  163. .replace(/&gt;/g, ">")
  164. .replace(/&quot;/g, '"')
  165. .replace(/&#39;/g, "'")
  166. .replace(/\s+/g, " ")
  167. .trim();
  168. return text;
  169. }
  170. function guessFileKind({ mimeType, filename }) {
  171. const name = String(filename ?? "").toLowerCase();
  172. const mt = String(mimeType ?? "").toLowerCase();
  173. if (mt === "text/plain" || name.endsWith(".txt")) return "txt";
  174. if (mt === "application/pdf" || name.endsWith(".pdf")) return "pdf";
  175. if (
  176. mt === "application/vnd.openxmlformats-officedocument.wordprocessingml.document" ||
  177. name.endsWith(".docx")
  178. )
  179. return "docx";
  180. if (mt.startsWith("image/") || /\.(png|jpe?g|webp)$/i.test(name)) return "image";
  181. return "unknown";
  182. }
  183. export async function extractDocumentsFromUpload({ buffer, filename, mimeType, source }) {
  184. const kind = guessFileKind({ mimeType, filename });
  185. const src = source ?? filename ?? "upload";
  186. const metadata = { filename: filename ?? null, mimeType: mimeType ?? null, kind };
  187. if (kind === "txt") {
  188. const text = buffer.toString("utf8").trim();
  189. return [{ text, source: src, metadata }];
  190. }
  191. if (kind === "pdf") {
  192. let pdfParse;
  193. try {
  194. const mod = await import("pdf-parse");
  195. pdfParse = mod?.default ?? mod;
  196. } catch {
  197. try {
  198. pdfParse = require("pdf-parse");
  199. } catch {
  200. pdfParse = require("pdf-parse/lib/pdf-parse.js");
  201. }
  202. pdfParse = pdfParse?.default ?? pdfParse;
  203. }
  204. if (typeof pdfParse !== "function") {
  205. const err = new Error("pdf_parse_unavailable");
  206. err.statusCode = 500;
  207. throw err;
  208. }
  209. const parsed = await pdfParse(buffer);
  210. const text = String(parsed?.text ?? "").trim();
  211. return [{ text, source: src, metadata }];
  212. }
  213. if (kind === "docx") {
  214. const extracted = await mammoth.extractRawText({ buffer });
  215. const baseText = String(extracted?.value ?? "").trim();
  216. const images = [];
  217. await mammoth.convertToHtml(
  218. { buffer },
  219. {
  220. convertImage: mammoth.images.inline(async (image) => {
  221. const arr = await image.read();
  222. images.push(Buffer.from(arr));
  223. return { src: "" };
  224. })
  225. }
  226. );
  227. const ocrTexts = [];
  228. let visionSkipped = 0;
  229. let visionError = "";
  230. let visionUnavailable = false;
  231. for (let i = 0; i < images.length; i += 1) {
  232. if (visionUnavailable) {
  233. visionSkipped += 1;
  234. continue;
  235. }
  236. const imageBase64 = images[i].toString("base64");
  237. try {
  238. let r = await visionExtractFromImage({ imageBase64, prompt: visionPrompt() });
  239. let t = String(r?.content ?? "").trim();
  240. if (isRefusal(t)) {
  241. r = await visionExtractFromImage({
  242. imageBase64,
  243. prompt: [
  244. "Analise a imagem e descreva somente os elementos de UI e estados selecionados.",
  245. "Liste itens curtos: campos/labels, opções marcadas, botões e mensagens de erro.",
  246. "Não faça transcrição literal de textos longos.",
  247. "Responda em português."
  248. ].join("\n")
  249. });
  250. t = String(r?.content ?? "").trim();
  251. }
  252. if (isRefusal(t)) {
  253. visionError = "vision_refused";
  254. visionSkipped += 1;
  255. continue;
  256. }
  257. if (t) ocrTexts.push(`Imagem ${i + 1}:\n${t}`);
  258. } catch (e) {
  259. const msg = typeof e?.message === "string" ? e.message : "";
  260. visionError = msg || "vision_failed";
  261. if (msg.startsWith("ollama_model_not_found:")) {
  262. visionUnavailable = true;
  263. visionSkipped += images.length - i;
  264. } else {
  265. visionSkipped += 1;
  266. }
  267. }
  268. }
  269. const textParts = [];
  270. if (baseText) textParts.push(baseText);
  271. if (ocrTexts.length) textParts.push(ocrTexts.join("\n\n"));
  272. const text = textParts.join("\n\n").trim();
  273. return [
  274. {
  275. text,
  276. source: src,
  277. metadata: {
  278. ...metadata,
  279. imagesTotal: images.length,
  280. imagesProcessed: images.length - visionSkipped,
  281. imagesSkipped: visionSkipped,
  282. visionError: visionError || null
  283. }
  284. }
  285. ];
  286. }
  287. if (kind === "image") {
  288. const imageBase64 = buffer.toString("base64");
  289. try {
  290. let r = await visionExtractFromImage({ imageBase64, prompt: visionPrompt() });
  291. let text = String(r?.content ?? "").trim();
  292. if (isRefusal(text)) {
  293. r = await visionExtractFromImage({
  294. imageBase64,
  295. prompt: [
  296. "Analise a imagem e descreva somente os elementos de UI e estados selecionados.",
  297. "Liste itens curtos: campos/labels, opções marcadas, botões e mensagens de erro.",
  298. "Não faça transcrição literal de textos longos.",
  299. "Responda em português."
  300. ].join("\n")
  301. });
  302. text = String(r?.content ?? "").trim();
  303. }
  304. if (isRefusal(text)) {
  305. const err = new Error("vision_refused");
  306. err.statusCode = 400;
  307. throw err;
  308. }
  309. return [{ text, source: src, metadata }];
  310. } catch (e) {
  311. const msg = typeof e?.message === "string" ? e.message : "";
  312. if (msg.startsWith("ollama_model_not_found:")) {
  313. const err = new Error(msg);
  314. err.statusCode = 400;
  315. throw err;
  316. }
  317. throw e;
  318. }
  319. }
  320. const err = new Error("unsupported_file_type");
  321. err.statusCode = 400;
  322. throw err;
  323. }