import type { ResearchBundleV1, ResearchSourceV1, WebCitationV1 } from "./types"; /** * Web citation handling (§30, §31). Keeps web citations separate from document * citations. The action model cites source IDs inline as [S]. The * server accepts only IDs from the validated bundle and builds final citation * objects from server-owned source metadata. Model-generated URLs are ignored. */ const MARKER_RE = /\[S(\d+)\]/g; /** Extract the ordered, de-duplicated source IDs actually referenced in text. */ export function extractReferencedSourceIds(text: string): string[] { const out: string[] = []; if (!text) return out; const seen = new Set(); let m: RegExpExecArray | null; MARKER_RE.lastIndex = 0; while ((m = MARKER_RE.exec(text)) !== null) { const id = `S${m[1]}`; if (!seen.has(id)) { seen.add(id); out.push(id); } } return out; } /** Build web citation objects only for referenced, validated sources. */ export function buildWebCitations( text: string, bundle: ResearchBundleV1 | null, ): WebCitationV1[] { if (!bundle) return []; const referenced = new Set(extractReferencedSourceIds(text)); const byId = new Map(); for (const s of bundle.sources) byId.set(s.sourceId, s); const citations: WebCitationV1[] = []; for (const id of referenced) { const src = byId.get(id); if (!src) continue; // unknown ID ignored citations.push({ sourceId: src.sourceId, title: src.title, url: src.url, publisher: src.publisher, publishedAt: src.publishedAt, sourceType: src.sourceType, }); } return citations; } /** Unknown/referenced-but-missing IDs (for observability), never thrown. */ export function findUnknownSourceIds(text: string, bundle: ResearchBundleV1 | null): string[] { const referenced = extractReferencedSourceIds(text); if (!bundle) return referenced; const known = new Set(bundle.sources.map((s) => s.sourceId)); return referenced.filter((id) => !known.has(id)); }