// Contrat de citations : le modèle ne peut citer que les balises [Sx] réellement fournies. // Toute balise inconnue est neutralisée et comptée. Les balises valides sont résolues en // objets cliquables transmis au client. import type { ContextBlock, RetrievedChunk } from "./search.ts"; export type ResolvedCitation = { tag: string; // S1 index: number; // 1 courseCode: string | null; docTitle: string; filename: string; path: string; refLabel: string; // « Séance 4 — Diapositive 18 » refType: string; refNumber: number | null; title: string; excerpt: string; chunkId: number; documentId: number; }; export function resolveCitations(text: string, context: ContextBlock): { cleaned: string; citations: ResolvedCitation[]; invalidCount: number; } { const valid = new Map(context.sources.map((s) => [s.tag, s.chunk])); const used = new Map(); let invalidCount = 0; // Normalise [S1, S3] → [S1][S3], puis vérifie chaque balise. let cleaned = text.replace(/\[(S\d+(?:\s*,\s*S\d+)+)\]/g, (_m, group: string) => group .split(/\s*,\s*/) .map((t: string) => `[${t}]`) .join("") ); cleaned = cleaned.replace(/\[S(\d+)\]/g, (m, n: string) => { const tag = `S${n}`; const chunk = valid.get(tag); if (!chunk) { invalidCount++; return ""; } if (!used.has(tag)) used.set(tag, chunk); return m; }); const citations: ResolvedCitation[] = [...used.entries()].map(([tag, c]) => ({ tag, index: parseInt(tag.slice(1), 10), courseCode: c.course_code, docTitle: c.doc_title, filename: c.filename, path: c.doc_path, refLabel: c.ref_label, refType: c.ref_type, refNumber: c.ref_number, title: c.title, excerpt: c.content.slice(0, 700), chunkId: c.id, documentId: c.document_id, })); return { cleaned, citations, invalidCount }; }