// An /ask ANSWER → `report.json` (kind "sweep"). // // The export's /ask writes an answer (or a running report) in markdown that // cites its sources with numbered markers — `[n]`, or `[n @ mm:ss]` for a // moment (also `h:mm:ss`, stray spaces tolerated) — where `n` indexes the // answer's source list, the retrieved records (export/app/ask/citations.tsx // parses the same markers; export/app/lib/askRetrieval.ts `RetrievedVideo` is // a source). Accepted as input, all JSON: // // { "answer": "", "sources": […], "question"?: "…" } // a saved chat ({ "messages": […], "report"?, "reportSources"? }): its // report and report sources when it has them, else its last answer and // that answer's sources, with the question before it // one assistant message ({ "role": "assistant", "content", "sources" }) // // Each marker becomes `[label](cite:)`: a post source a post citation // (its words the quote), any other source a span at the marked second — // snapped to the nearest second the source's excerpt lines carry, as the // /ask page does, so a slightly-off model time lands on a real line — or at // its first excerpt line when the marker has no time; its quote is that // line's text. Then the answer is structured like a sweep (./convertShared.ts // `structureMarkdown`): paragraphs and list items that cite are claims. import { CitationRegistry, emptyReport, finish, reportIdOf, structureMarkdown, type ConvertContext, type Converted, } from "./convertShared"; export type AskSource = { // `/`: the record (or post) the source is. key: string; title?: string; uploadDate?: string; snippets?: { clock?: string; seconds: number; text: string }[]; isPost?: boolean; }; export type AskAnswer = { answer: string; sources: AskSource[]; question?: string }; export type AskConvertOptions = ConvertContext & { id?: string; // Default: the question, else "Answer". title?: string; sectionTitle?: string; }; const isObj = (v: unknown): v is Record => !!v && typeof v === "object" && !Array.isArray(v); function sourcesOf(v: unknown): AskSource[] | null { if (!Array.isArray(v)) return null; return v.filter((s): s is AskSource => isObj(s) && typeof s.key === "string"); } // The answer, its sources and its question out of any accepted shape; null for // JSON that is none of them. export function readAskAnswer(raw: unknown): AskAnswer | null { if (!isObj(raw)) return null; const question = typeof raw.question === "string" ? raw.question : undefined; for (const [text, list] of [ ["answer", "sources"], ["report", "reportSources"], ] as const) { const sources = sourcesOf(raw[list]); if (typeof raw[text] === "string" && sources) return { answer: raw[text] as string, sources, question }; } if (raw.role === "assistant" && typeof raw.content === "string") { return { answer: raw.content, sources: sourcesOf(raw.sources) ?? [], question }; } if (Array.isArray(raw.messages)) { const messages = raw.messages.filter(isObj); for (let i = messages.length - 1; i >= 0; i--) { const m = messages[i]; if (m.role !== "assistant" || typeof m.content !== "string" || !m.content.trim()) continue; const asked = messages.slice(0, i).reverse().find((u) => u.role === "user" && typeof u.content === "string"); return { answer: m.content, sources: sourcesOf(m.sources) ?? [], question: question ?? (asked?.content as string | undefined) }; } } return null; } // `mm:ss` or `h:mm:ss` as whole seconds; null when malformed. export function parseClock(s: string): number | null { const parts = s.split(":"); if (parts.length < 2 || parts.length > 3) return null; let total = 0; for (const p of parts) { if (!/^\d+$/.test(p)) return null; total = total * 60 + Number(p); } return total; } // One citation marker: a source number, an optional `@ mm:ss`, stray spaces // tolerated — not one already followed by a link target. const MARKER_RE = /\[\s*(\d+)\s*(?:@\s*(\d{1,2}(?::\d{2}){1,2})\s*)?\](?!\()/g; // The excerpt line a marker cites: the one nearest the marked second, else // the first. function snippetAt(src: AskSource, seconds: number | null): { seconds: number; text: string } | null { const lines = src.snippets ?? []; if (!lines.length) return null; if (seconds === null) return lines[0]; return lines.reduce((best, s) => (Math.abs(s.seconds - seconds) < Math.abs(best.seconds - seconds) ? s : best)); } const dateOfUpload = (d: string | undefined) => d && /^\d{8}$/.test(d) ? `${d.slice(0, 4)}-${d.slice(4, 6)}-${d.slice(6, 8)}` : undefined; export async function askToReport(input: AskAnswer, opts: AskConvertOptions = {}): Promise { const warnings: string[] = []; const registry = new CitationRegistry(opts, warnings); // The markers, outside code, as `[label](cite:)`. const segments = input.answer.split(/(```[\s\S]*?```|`[^`]*`)/g); for (let i = 0; i < segments.length; i += 2) { let out = ""; let last = 0; for (const m of segments[i].matchAll(MARKER_RE)) { out += segments[i].slice(last, m.index); last = m.index + m[0].length; const n = Number(m[1]); const src = input.sources[n - 1]; if (!src) { warnings.push(`[${m[1]}${m[2] ? ` @ ${m[2]}` : ""}] names no source (the answer has ${input.sources.length})`); out += m[0]; continue; } const slash = src.key.indexOf("/"); if (slash <= 0) { warnings.push(`source ${n} (${src.key}) is not a / key — left as it is`); out += m[0]; continue; } const channel = src.key.slice(0, slash); const id = src.key.slice(slash + 1); const marked = m[2] ? parseClock(m[2]) : null; let cid: string; if (src.isPost) { const words = (src.snippets ?? []).map((s) => s.text).join("\n").trim() || src.title || id; cid = registry.post(channel, id, words, { date: dateOfUpload(src.uploadDate) }); } else { const line = snippetAt(src, marked); if (!line) warnings.push(`source ${n} (${src.key}) has no excerpt lines — its quote is its title`); cid = await registry.span(channel, id, line?.seconds ?? marked ?? 0, line?.text.trim() || src.title || id, { label: src.title, }); } out += `[${m[0].slice(1, -1).trim()}](cite:${cid})`; } segments[i] = out + segments[i].slice(last); } const doc = await structureMarkdown( segments.join(""), async ({ href }) => (href.startsWith("cite:") ? href.slice("cite:".length) : null), { defaultSection: opts.sectionTitle ?? "Answer" }, ); const question = input.question?.replace(/\s+/g, " ").trim(); const title = opts.title ?? doc.title ?? question ?? "Answer"; const report = emptyReport(reportIdOf(opts.id, title), "sweep", title); if (question && question !== title) report.subtitle = question; if (doc.summary) report.summary = doc.summary; report.citations = registry.citations; report.sections = doc.sections; if (!Object.keys(registry.citations).length) warnings.push("the answer cites no source"); return finish(report, warnings); }