SPB Git forge

spb/immbot-ai

Public
1commits 1branches 0releases
1.5 MBsize
maindefault branch
20 days agolast push
TypeScript 98.3% CSS 0.9% Shell 0.7%
6.2 KB · 128 lines typescript
Raw Blame History
1// Téléversement de fichiers étudiants : extraction de texte (PDF/DOCX/XLSX/CSV/TXT),2// images passées telles quelles aux modèles vision. Espace isolé par utilisateur/conversation.3import { NextResponse } from "next/server";4import { mkdirSync, writeFileSync } from "node:fs";5import { randomBytes } from "node:crypto";6import { join, resolve, extname } from "node:path";7import { apiError } from "@/lib/api.ts";8import { assertSameOrigin, requireUser } from "@/lib/auth/session.ts";9import { run } from "@/lib/db/index.ts";10import { embedPassages, vecToBlob } from "@/lib/rag/embeddings.ts";11import { splitLong } from "@/lib/rag/latex.ts";1213const MAX_SIZE = 25 * 1024 * 1024;14const ALLOWED: Record<string, string[]> = {15  "application/pdf": [".pdf"],16  "application/vnd.openxmlformats-officedocument.wordprocessingml.document": [".docx"],17  "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": [".xlsx"],18  "text/csv": [".csv"],19  "text/plain": [".txt", ".md", ".tex"],20  "text/markdown": [".md"],21  "image/png": [".png"],22  "image/jpeg": [".jpg", ".jpeg"],23  "image/webp": [".webp"],24};2526const MAGIC: [string, (b: Buffer) => boolean][] = [27  ["application/pdf", (b) => b.subarray(0, 5).toString("latin1") === "%PDF-"],28  ["image/png", (b) => b.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))],29  ["image/jpeg", (b) => b[0] === 0xff && b[1] === 0xd8],30  ["image/webp", (b) => b.subarray(8, 12).toString("latin1") === "WEBP"],31];3233async function extractText(buffer: Buffer, mime: string, filename: string): Promise<string> {34  try {35    if (mime === "application/pdf") {36      const { extractText: pdfText, getDocumentProxy } = await import("unpdf");37      const doc = await getDocumentProxy(new Uint8Array(buffer));38      const { text } = await pdfText(doc, { mergePages: false });39      return (text as string[]).map((p, i) => `[Page ${i + 1}]\n${p}`).join("\n\n");40    }41    if (mime.includes("wordprocessingml")) {42      const mammoth = await import("mammoth");43      const r = await mammoth.extractRawText({ buffer });44      return r.value;45    }46    if (mime.includes("spreadsheetml") || filename.endsWith(".xlsx")) {47      const XLSX = await import("xlsx");48      const wb = XLSX.read(buffer, { type: "buffer" });49      return wb.SheetNames.map((name) => {50        const csv = XLSX.utils.sheet_to_csv(wb.Sheets[name]);51        return `[Feuille : ${name}]\n${csv}`;52      }).join("\n\n");53    }54    if (mime.startsWith("text/") || /\.(txt|md|csv|tex)$/i.test(filename)) {55      return buffer.toString("utf8");56    }57  } catch (e) {58    return `(Extraction impossible : ${e instanceof Error ? e.message : "erreur"})`;59  }60  return "";61}6263export async function POST(req: Request) {64  try {65    await assertSameOrigin();66    const user = await requireUser();67    const form = await req.formData();68    const file = form.get("file");69    const conversationId = parseInt(String(form.get("conversationId") ?? "0"), 10) || null;70    const persistent = String(form.get("persistent") ?? "") === "1";71    if (!(file instanceof File)) return NextResponse.json({ error: "Fichier manquant." }, { status: 400 });72    if (file.size > MAX_SIZE) return NextResponse.json({ error: "Fichier trop volumineux (max 25 Mo)." }, { status: 413 });7374    const ext = extname(file.name).toLowerCase();75    const mime = file.type || "application/octet-stream";76    const allowedExts = ALLOWED[mime];77    if (!allowedExts || !allowedExts.includes(ext)) {78      return NextResponse.json({ error: `Type non pris en charge : ${mime || ext}. Formats acceptés : PDF, DOCX, XLSX, CSV, TXT, Markdown, PNG, JPEG, WebP.` }, { status: 415 });79    }80    const buffer = Buffer.from(await file.arrayBuffer());81    const magic = MAGIC.find(([m]) => m === mime);82    if (magic && !magic[1](buffer)) {83      return NextResponse.json({ error: "Le contenu du fichier ne correspond pas à son type déclaré." }, { status: 415 });84    }8586    const uploadsDir = resolve(process.cwd(), process.env.UPLOADS_PATH || "./data/uploads", String(user.id));87    mkdirSync(uploadsDir, { recursive: true });88    const storedName = `${Date.now()}-${randomBytes(6).toString("hex")}${ext}`;89    const path = join(uploadsDir, storedName);90    writeFileSync(path, buffer);9192    const extracted = mime.startsWith("image/") ? "" : (await extractText(buffer, mime, file.name)).slice(0, 400_000);93    const r = run(94      "INSERT INTO uploads (user_id, conversation_id, filename, mime, size, path, extracted_text, persistent) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",95      user.id, conversationId, file.name.slice(0, 200), mime, file.size, path, extracted, persistent ? 1 : 096    );97    const uploadId = Number(r.lastInsertRowid);9899    // Indexation RAG du texte extrait (espace étudiant isolé) pour la recherche dans la conversation.100    if (extracted && extracted.length > 200 && conversationId) {101      const dr = run(102        `INSERT INTO documents (course_code, space, path, filename, doc_type, title, category, checksum, status, visible_to_students, ingested_at, chunk_count)103         VALUES (NULL, 'student-temporary-upload', ?, ?, 'upload', ?, 'upload', ?, 'ok', 0, datetime('now'), 0)`,104        `upload:${uploadId}`, file.name.slice(0, 200), file.name.slice(0, 200), String(uploadId)105      );106      const docId = Number(dr.lastInsertRowid);107      const parts = splitLong(extracted, 1800).slice(0, 60);108      const embeddings = await embedPassages(parts);109      parts.forEach((p, i) => {110        const cr = run(111          `INSERT INTO chunks (document_id, course_code, space, seq, ref_type, ref_number, ref_label, title, content, display_content, owner_user_id, conversation_id, embedding)112           VALUES (?, NULL, 'student-temporary-upload', ?, 'page', ?, ?, ?, ?, ?, ?, ?, ?)`,113          docId, i, i + 1, `${file.name} — partie ${i + 1}`, file.name.slice(0, 200), p, p, user.id, conversationId, vecToBlob(embeddings[i])114        );115        run("INSERT INTO chunks_fts (rowid, title, content) VALUES (?, ?, ?)", Number(cr.lastInsertRowid), file.name, p);116      });117      run("UPDATE documents SET chunk_count = ? WHERE id = ?", parts.length, docId);118    }119120    return NextResponse.json({121      ok: true,122      upload: { id: uploadId, filename: file.name, mime, size: file.size, hasText: !!extracted },123    });124  } catch (e) {125    return apiError(e);126  }127}128