diff --git a/README.md b/README.md index 73c930dc..959d2b83 100644 --- a/README.md +++ b/README.md @@ -31,13 +31,21 @@ We believe AI citations should follow **show, don't tell**; they should prove th DeepCitation turns model citations into deterministic, inspectable proof. -## Install +## Agent Skills + +The fastest way to use DeepCitation — verify citations directly from your AI coding agent with the `/verify` skill. No app code, no API integration. Works with Claude Code, Cursor, Windsurf, and other agents that support skills. + +Install from [**DeepCitation/skills**](https://github.com/DeepCitation/skills). + +## Building your own integration? + +Install the package: ```sh npm install deepcitation # or bun add / yarn add / pnpm add ``` -## Quick Start +### Quick Start ```typescript import { DeepCitation, extractVisibleText, wrapCitationPrompt } from "deepcitation"; @@ -119,13 +127,6 @@ npm install && npm run dev - [AG-UI Chat](./examples/agui-chat) — [Live Demo](https://agui-chat-deepcitation.vercel.app/) - [URL Citations](./examples/url-example) - -## Agent Skills - -Verify citations directly from your AI coding agent with the `/verify` skill — no app code needed. Works with Claude Code, Cursor, Windsurf, and other agents that support skills. - -Install from [**DeepCitation/skills**](https://github.com/DeepCitation/skills). - ## Development ### Running Tests diff --git a/docs/agents/engineering-rules.md b/docs/agents/engineering-rules.md index fa1d335b..3eb6820e 100644 --- a/docs/agents/engineering-rules.md +++ b/docs/agents/engineering-rules.md @@ -33,6 +33,14 @@ Do **not** flag these shared fields as "semantically document-only" in code revi > All citations are potentially verifiable against a page-indexed document. The `type` discriminator indicates the *source* of the citation, not whether page/line fields will be populated. +## Test Failure Policy + +When tests fail, fix them. Do not investigate attribution. + +- **Never use `git stash` to check whether a failure is "pre-existing"** — stashing is unsafe in shared workspaces where multiple developers and agents operate on the same working tree. +- **"I think those failures were pre-existing" is not acceptable** without an actual fix. It signals time spent on blame instead of solutions. +- If a failing test is genuinely out of scope for your current PR, note it explicitly in the PR description and open a separate tracking issue — but do not leave the suite red. + ## Testing Rules - Tests must validate implemented behavior, not aspirational behavior. diff --git a/examples/README.md b/examples/README.md index 76e0485e..d8bf5518 100644 --- a/examples/README.md +++ b/examples/README.md @@ -9,6 +9,7 @@ Complete, runnable examples demonstrating DeepCitation integration patterns. | [**basic-verification**](./basic-verification) | Core 3-step workflow with OpenAI/Anthropic | Learning the basics, quick integration | — | | [**langchain-rag-chat**](./langchain-rag-chat) | Next.js + LangChain.js RAG app with DeepCitation verification | RAG pipelines, retrieval + proof UI | [Live Demo](https://langchain-rag-chat-deepcitation.vercel.app/) | | [**mastra-rag-chat**](./mastra-rag-chat) | Next.js + Mastra RAG app with DeepCitation verification | Mastra framework, TypeScript-native RAG | [Live Demo](https://mastra-rag-deepcitation.vercel.app/) | +| [**qmd-local-search**](./qmd-local-search) | CLI example using [qmd](https://github.com/tobi/qmd) as an on-device markdown index with DeepCitation verification | Local-first RAG, privacy-sensitive corpora, offline retrieval | — | | [**nextjs-ai-sdk**](./nextjs-ai-sdk) | Next.js chat app with Vercel AI SDK | Full-stack apps, streaming UI | [Live Demo](https://nextjs-ai-sdk-deepcitation.vercel.app/) | | [**agui-chat**](./agui-chat) | AG-UI protocol chat with SSE streaming | AG-UI integration, protocol-level control | [Live Demo](https://agui-chat-deepcitation.vercel.app/) | | [**static-html**](./static-html) | CDN popover in plain HTML, no build step | Static sites, CDN integration | — | @@ -137,6 +138,42 @@ npm run dev # Open http://localhost:3000 ``` +### qmd Local Search + +CLI example using [qmd](https://github.com/tobi/qmd) as an on-device markdown +index. Retrieval is local (BM25 + vector + LLM rerank); DeepCitation verifies +the resulting citations against a parallel PDF corpus keyed by filename stem. + +```typescript +import { createStore } from "@tobilu/qmd"; + +const store = await createStore({ + dbPath: "./.qmd-index.sqlite", + config: { collections: { corpus: { path: "./corpus/md", pattern: "**/*.md" } } }, +}); + +await store.update(); +await store.embed(); // first run downloads a GGUF embedding model + +const hits = await store.search({ query: question, collection: "corpus", limit: 6 }); + +// Bridge: each hit.file → corpus/pdf/.pdf +const pdfUploads = [...new Set(hits.map(h => h.file))].map(mdFile => { + const pdfPath = mdFileToPdfPath(mdFile); + return { file: readFileSync(pdfPath), filename: basename(pdfPath) }; +}); + +const { fileDataParts, deepTextPagesByAttachmentId } = await dc.prepareAttachments(pdfUploads); +``` + +```bash +# Run the qmd example +cd qmd-local-search +bun install # auto-builds corpus/pdf from corpus/md +cp .env.example .env # add DEEPCITATION_API_KEY + OPENAI_API_KEY +bun run start # interactive picker +``` + ## More Resources - [Full Documentation](https://docs.deepcitation.com/) diff --git a/examples/basic-verification/src/curl.ts b/examples/basic-verification/src/curl.ts index f804e571..019d0524 100644 --- a/examples/basic-verification/src/curl.ts +++ b/examples/basic-verification/src/curl.ts @@ -119,7 +119,7 @@ async function prepareUrlAttachment(url: string): Promise<{ */ async function verifyCitations( attachmentId: string, - citations: Record, + citations: Record, ): Promise<{ verifications: Record< string, @@ -275,7 +275,7 @@ provided documents accurately and cite your sources.`; console.log(`📋 Parsed ${citationCount} citation(s) from LLM output`); for (const [key, citation] of Object.entries(parsedCitations)) { - console.log(` [${key}]: "${citation.fullPhrase?.slice(0, 50)}..."`); + console.log(` [${key}]: "${citation.sourceContext?.slice(0, 50)}..."`); } console.log(); @@ -321,9 +321,9 @@ provided documents accurately and cite your sources.`; console.log(`${"─".repeat(60)}`); const originalCitation = parsedCitations[key]; - if (originalCitation?.fullPhrase) { + if (originalCitation?.sourceContext) { console.log( - ` 📝 Claimed: "${originalCitation.fullPhrase.slice(0, 100)}${originalCitation.fullPhrase.length > 100 ? "..." : ""}"`, + ` 📝 Claimed: "${originalCitation.sourceContext.slice(0, 100)}${originalCitation.sourceContext.length > 100 ? "..." : ""}"`, ); } @@ -423,7 +423,7 @@ async function main() { console.log(" -d '{"); console.log(' "data": {'); console.log(' "attachmentId": "",'); - console.log(' "citations": { "1": { "fullPhrase": "...", "pageNumber": 1 } },'); + console.log(' "citations": { "1": { "sourceContext": "...", "pageNumber": 1 } },'); console.log(' "outputImageFormat": "avif"'); console.log(" }"); console.log(" }'"); diff --git a/examples/basic-verification/src/fixture-to-html.ts b/examples/basic-verification/src/fixture-to-html.ts index dcb185ac..b29aa036 100644 --- a/examples/basic-verification/src/fixture-to-html.ts +++ b/examples/basic-verification/src/fixture-to-html.ts @@ -45,7 +45,7 @@ function convertFixture(provider: string) { // Debug: show what we got for (const [hash, citation] of Object.entries(parsedCitations)) { console.log( - ` [${citation.citationNumber}] hash=${hash.slice(0, 8)}… anchor="${citation.anchorText?.slice(0, 30)}"`, + ` [${citation.citationNumber}] hash=${hash.slice(0, 8)}… match="${citation.sourceMatch?.slice(0, 30)}"`, ); } @@ -56,16 +56,16 @@ function convertFixture(provider: string) { for (const [hash, citation] of Object.entries(parsedCitations)) { stubVerifications[hash] = { status: "found", - label: citation.anchorText || `Citation ${citation.citationNumber}`, + label: citation.sourceMatch || `Citation ${citation.citationNumber}`, attachmentId: citation.attachmentId || "fixture", - verifiedFullPhrase: citation.fullPhrase, - verifiedAnchorText: citation.anchorText, - verifiedMatchSnippet: citation.fullPhrase?.slice(0, 80), + verifiedSourceContext: citation.sourceContext, + verifiedSourceMatch: citation.sourceMatch, + verifiedMatchSnippet: citation.sourceContext?.slice(0, 80), citation: { pageNumber: citation.pageNumber, lineIds: citation.lineIds, - fullPhrase: citation.fullPhrase, - anchorText: citation.anchorText, + sourceContext: citation.sourceContext, + sourceMatch: citation.sourceMatch, }, document: { verifiedPageNumber: citation.pageNumber, diff --git a/examples/basic-verification/src/shared.ts b/examples/basic-verification/src/shared.ts index ab4a210d..22ed0ec5 100644 --- a/examples/basic-verification/src/shared.ts +++ b/examples/basic-verification/src/shared.ts @@ -16,20 +16,17 @@ import { DeepCitation } from "deepcitation/client"; import { type AttachmentAssets, type CitationRecord, + type Verification, extractVisibleText, getAllCitationsFromLlmOutput, - getCitationStatus, - getVerificationTextIndicator, - replaceCitationMarkers, + renderVerifiedHtml, } from "deepcitation"; import { wrapCitationPrompt } from "deepcitation/prompts"; import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"; import { basename, dirname, resolve } from "path"; import { createInterface } from "readline"; import { fileURLToPath } from "url"; -import { execFileSync } from "child_process"; -import { generateHtmlReport } from "./html-report.js"; // Get current directory for loading sample files const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -132,6 +129,12 @@ export interface Step3Result { llmResponse: string; } + +export const DEFAULT_OUT_DIR = resolve(__dirname, "../../output"); + +// ─── Step types and functions (used by step-runner.ts) ───────────────────── + + export interface Step4Result { parsedCitations: CitationRecord; visibleText: string; @@ -139,7 +142,7 @@ export interface Step4Result { } export interface Step5Result { - verifications: Record; + verifications: Record; attachments?: Record; } @@ -148,69 +151,6 @@ export interface Step6Result { snapshotPath: string; } -// ─── Step functions (silent — no console output) ─────────────────────────── - -export async function stepUpload(dc: DeepCitation, source: Source): Promise { - const sourceLabel = source.type === "url" ? source.url : "filename" in source ? source.filename : source.label; - - if (source.type === "url") { - const result = await dc.prepareUrl({ url: source.url }); - return { attachmentId: result.attachmentId, deepTextPages: result.deepTextPages, sourceLabel }; - } - - const fileBuffer = readFileSync(source.path); - const { fileDataParts, deepTextPagesByAttachmentId } = await dc.prepareAttachments([ - { file: fileBuffer, filename: source.filename }, - ]); - - const attachmentId = fileDataParts[0].attachmentId; - const deepTextPages = deepTextPagesByAttachmentId[attachmentId] ?? []; - - return { - attachmentId, - deepTextPages, - imageBase64: source.type === "image" ? fileBuffer.toString("base64") : undefined, - sourceLabel, - }; -} - -export function stepWrapPrompts( - step1: Pick, - opts?: { systemPrompt?: string; userPrompt?: string }, -): Step2Result { - const systemPrompt = - opts?.systemPrompt ?? - process.env.SYSTEM_PROMPT ?? - `You are a helpful assistant. Answer questions about the -provided documents accurately and cite your sources.`; - - const userPrompt = - opts?.userPrompt ?? - process.env.USER_PROMPT ?? - "Summarize the key information shown in this document."; - - const { enhancedSystemPrompt, enhancedUserPrompt } = wrapCitationPrompt({ - systemPrompt, - userPrompt, - deepTextPagesByAttachmentId: { [step1.attachmentId]: step1.deepTextPages }, - }); - - return { enhancedSystemPrompt, enhancedUserPrompt, systemPrompt, userPrompt }; -} - -export async function stepCallLlm( - streamLlm: StreamLlmFn, - prompts: Step2Result, - imageBase64?: string, -): Promise { - const llmResponse = await streamLlm({ - enhancedSystemPrompt: prompts.enhancedSystemPrompt, - enhancedUserPrompt: prompts.enhancedUserPrompt, - imageBase64, - }); - return { llmResponse }; -} - export function stepParseCitations(llmResponse: string): Step4Result { const parsedCitations = getAllCitationsFromLlmOutput(llmResponse); const visibleText = extractVisibleText(llmResponse); @@ -223,10 +163,7 @@ export async function stepVerify( parsedCitations: CitationRecord, ): Promise { const result = await dc.verifyAttachment(attachmentId, parsedCitations); - return { - verifications: result.verifications, - attachments: result.attachments, - }; + return { verifications: result.verifications, attachments: result.attachments }; } export function stepGenerateHtml( @@ -236,27 +173,12 @@ export function stepGenerateHtml( outDir: string, ): Step6Result { if (!existsSync(outDir)) mkdirSync(outDir, { recursive: true }); - - const html = generateHtmlReport({ - visibleText: step4.visibleText, - parsedCitations: step4.parsedCitations, - verifications: step5.verifications, - title: sourceLabel, - attachments: step5.attachments, - }); - const safeName = toSafeName(sourceLabel); const htmlPath = resolve(outDir, `${safeName}-verified.html`); + const html = renderVerifiedHtml(step4.visibleText, step4.parsedCitations, step5.verifications, step5.attachments, { title: sourceLabel }); writeFileSync(htmlPath, html); - const snapshotPath = resolve(outDir, `${safeName}-snapshot.json`); - writeFileSync(snapshotPath, JSON.stringify({ - llmResponse: undefined, // caller can override if available - verifications: step5.verifications, - attachments: step5.attachments, - title: sourceLabel, - }, null, 2)); - + writeFileSync(snapshotPath, JSON.stringify({ verifications: step5.verifications, title: sourceLabel }, null, 2)); return { htmlPath, snapshotPath }; } @@ -264,7 +186,6 @@ export function toSafeName(label: string): string { return label.replace(/[^a-zA-Z0-9.-]/g, "_").slice(0, 50); } -export const DEFAULT_OUT_DIR = resolve(__dirname, "../../output"); // ─── Workflow (uses step functions with logging) ─────────────────────────── @@ -327,130 +248,38 @@ async function runSingleSource( console.log("\n" + separator + "\n"); - // ── Step 4: Parse Citations ── - console.log("🔍 Step 3: Parsing citations and extracting visible text...\n"); + // ── Step 3: Create report (server-side: parse → verify → render → store) ── + console.log("\n🔍 Step 3: Creating verification report...\n"); - const s4 = stepParseCitations(s3.llmResponse); - - console.log(`📋 Parsed ${s4.citationCount} citation(s) from LLM output`); - for (const [key, citation] of Object.entries(s4.parsedCitations)) { - console.log(` [${key}]: "${citation.fullPhrase?.slice(0, 50)}..."`); - } - console.log(); - - console.log("📖 Visible Text (citation data block stripped):"); - console.log(separator); - console.log(s4.visibleText); - console.log(separator + "\n"); - - if (s4.citationCount === 0) { - console.log("⚠️ No citations found in the LLM response.\n"); + let report: Awaited>; + try { + report = await deepcitation.createReport(s1.attachmentId, s3.llmResponse, { + title: s1.sourceLabel, + visibility: "private", + }); + } catch (err) { + console.error(`❌ Report creation failed: ${err instanceof Error ? err.message : String(err)}`); return; } - // ── Step 5: Verify ── - console.log("🔍 Step 4: Verifying citations against source document...\n"); - - const s5 = await stepVerify(deepcitation, s1.attachmentId, s4.parsedCitations); - - // ── Display Results ── - console.log("✨ Step 5: Verification Results\n"); - - const verifications = Object.entries(s5.verifications) as [string, any][]; + console.log(`✅ Report created`); + console.log(` id: ${report.id}`); + console.log(` shareUrl: ${report.shareUrl}`); + console.log(` citations: ${report.citationCount ?? "—"}`); + console.log(` verified: ${report.verifiedCount ?? "—"}`); + console.log(` partial: ${report.partialCount ?? "—"}`); + console.log(` not found: ${report.notFoundCount ?? "—"}`); - if (verifications.length === 0) { - console.log("⚠️ No citations found in the response.\n"); - } else { - console.log(`Found ${verifications.length} citation(s):\n`); - - // verifiedMatchSnippet is the legacy field name (renamed to verifiedSourceContext) - type LegacyVerification = (typeof verifications)[number][1] & { verifiedMatchSnippet?: string }; - - for (const [key, verification] of verifications) { - const statusIndicator = getVerificationTextIndicator(verification); - - console.log(wideSeparator); - console.log(`Citation [${key}]: ${statusIndicator} ${verification.status} | Page: ${verification.document?.verifiedPageNumber ?? "N/A"}`); - console.log(wideSubSeparator); - - const fullPhrase = (s4.parsedCitations[key] || verification.citation)?.fullPhrase; - if (fullPhrase) { - console.log( - ` 📝 Claimed: "${fullPhrase.slice(0, 100)}${fullPhrase.length > 100 ? "..." : ""}"`, - ); - } - - const foundSnippet = verification.verifiedSourceContext - || (verification as LegacyVerification).verifiedMatchSnippet; - if (foundSnippet) { - console.log( - ` 🔍 Found: "${foundSnippet.slice(0, 100)}${foundSnippet.length > 100 ? "..." : ""}"`, - ); - } else { - const lineInfo = verification.citation?.lineIds?.length - ? ` and ${verification.citation.lineIds.length > 1 ? "lines" : "line"} ${verification.citation.lineIds.join(",")}` - : ""; - console.log(` Expected on page ${verification.citation?.pageNumber ?? "N/A"}${lineInfo}`); - } - - - console.log(); - } - console.log(wideSeparator + "\n"); - } - - // Clean response - console.log("📖 Clean Response (for display):"); - console.log(separator); - console.log( - replaceCitationMarkers(s4.visibleText), - ); - console.log(separator + "\n"); - - // Summary statistics - const verified = verifications.filter(([, h]) => getCitationStatus(h).isVerified).length; - const partial = verifications.filter(([, h]) => getCitationStatus(h).isPartialMatch).length; - const missed = verifications.filter(([, h]) => getCitationStatus(h).isMiss).length; - - console.log("📊 Summary:"); - console.log(` Total citations: ${verifications.length}`); - if (verifications.length > 0) { - console.log(` Verified: ${verified} (${((verified / verifications.length) * 100).toFixed(0)}%)`); - console.log(` Partial: ${partial} (${((partial / verifications.length) * 100).toFixed(0)}%)`); - console.log(` Not found: ${missed}`); - } - - // ── Step 6: Generate HTML ── - console.log("\n📄 Step 6: Generating HTML report...\n"); - - const sourceLabel = s1.sourceLabel; - // Use a provider-specific subdirectory so concurrent runs don't clobber each other - const providerSlug = providerName.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, ""); - const outDir = resolve(DEFAULT_OUT_DIR, providerSlug); - const s6 = stepGenerateHtml(s4, s5, sourceLabel, outDir); - - // Overwrite snapshot with llmResponse included - writeFileSync(s6.snapshotPath, JSON.stringify({ - llmResponse: s3.llmResponse, - verifications: s5.verifications, - attachments: s5.attachments, - title: sourceLabel, - }, null, 2)); - - console.log(` Snapshot: ${s6.snapshotPath}`); - console.log(` Written: ${s6.htmlPath}`); - console.log(` Citations: ${s4.citationCount}, Verifications: ${Object.keys(s5.verifications).length}`); - - // Open in browser (WSL → Linux → macOS — silent on failure) + // Open the report URL in the browser try { - const winPath = execFileSync("wslpath", ["-w", s6.htmlPath], { encoding: "utf-8" }).trim(); - execFileSync("explorer.exe", [winPath], { stdio: "ignore", timeout: 5000 }); - } catch { - try { execFileSync("xdg-open", [s6.htmlPath], { stdio: "ignore", timeout: 5000 }); } - catch { try { execFileSync("open", [s6.htmlPath], { stdio: "ignore", timeout: 5000 }); } catch { /* manual open */ } } - } - - console.log(` Open: ${s6.htmlPath}\n`); + const { execFileSync } = await import("child_process"); + try { + execFileSync("explorer.exe", [report.shareUrl], { stdio: "ignore", timeout: 5000 }); + } catch { + try { execFileSync("xdg-open", [report.shareUrl], { stdio: "ignore", timeout: 5000 }); } + catch { try { execFileSync("open", [report.shareUrl], { stdio: "ignore", timeout: 5000 }); } catch { /* manual */ } } + } + } catch { /* dynamic import failed, skip */ } } /** diff --git a/examples/basic-verification/src/step-runner.ts b/examples/basic-verification/src/step-runner.ts index 1afa4262..f35fb064 100644 --- a/examples/basic-verification/src/step-runner.ts +++ b/examples/basic-verification/src/step-runner.ts @@ -336,7 +336,7 @@ if (from <= 4 && to >= 4) { console.log(` Citations: ${s4.citationCount}`); console.log(` Visible text: ${s4.visibleText.length} chars`); for (const [key, c] of Object.entries(s4.parsedCitations)) { - console.log(` [${key}]: "${c.fullPhrase?.slice(0, 60)}..."`); + console.log(` [${key}]: "${c.sourceContext?.slice(0, 60)}..."`); } saveStep(cacheDir, safeName, 4, s4); console.log(); diff --git a/examples/qmd-local-search/.env.example b/examples/qmd-local-search/.env.example new file mode 100644 index 00000000..e26f51ae --- /dev/null +++ b/examples/qmd-local-search/.env.example @@ -0,0 +1,5 @@ +# Get your free API key at https://deepcitation.com/playground +DEEPCITATION_API_KEY=sk-dc-your_api_key_here + +# https://platform.openai.com/settings/organization/api-keys +OPENAI_API_KEY=sk-your-openai-key diff --git a/examples/qmd-local-search/.gitignore b/examples/qmd-local-search/.gitignore new file mode 100644 index 00000000..308566f2 --- /dev/null +++ b/examples/qmd-local-search/.gitignore @@ -0,0 +1,7 @@ +node_modules +.env +.env.local +.qmd-index.sqlite +.qmd-index.sqlite-* +output/ +corpus/pdf/ diff --git a/examples/qmd-local-search/README.md b/examples/qmd-local-search/README.md new file mode 100644 index 00000000..76186f8e --- /dev/null +++ b/examples/qmd-local-search/README.md @@ -0,0 +1,91 @@ +# qmd Local Search + DeepCitation + +CLI example pairing [qmd](https://github.com/tobi/qmd) (on-device markdown +search: BM25 + vector + LLM rerank) with DeepCitation for citation +verification. Retrieval runs locally against `corpus/md/*.md`; verification +runs against the parallel `corpus/pdf/*.pdf`, keyed by filename stem. + +## Why both? + +qmd is markdown-native; DeepCitation's verifier ingests PDFs (plain-text +input is on the roadmap but not yet shipped). So the example ships a +**parallel corpus**: `.md` for qmd to index, `.pdf` for DeepCitation to +verify against. The PDFs are generated from the same markdown by +`scripts/build-corpus.ts` at install time — you edit the markdown, the +PDFs rebuild, filename stems stay in sync. + +## Quick start + +```bash +cd examples/qmd-local-search +bun install # also runs scripts/build-corpus.ts (md → pdf) +cp .env.example .env # add DEEPCITATION_API_KEY + OPENAI_API_KEY + +bun run start # interactive picker +bun run start "How does Raft ensure safety?" # one-shot +``` + +## Prerequisites + +- **Node ≥ 22** (required by `@tobilu/qmd` — stricter than deepcitation's + Node ≥ 18 floor) +- **Bun** (the runner used by all `bun run` scripts — `npm i -g bun`) +- `@tobilu/qmd` has native deps (`better-sqlite3`, `node-llama-cpp`, + `sqlite-vec`). On first `embed()` qmd downloads a small GGUF embedding + model (~200 MB). Subsequent runs reuse the cached model and the + `.qmd-index.sqlite` index. + +## What happens when you run it + +1. `createStore({ dbPath: ".qmd-index.sqlite", config: { collections: { corpus: ... } } })` +2. `store.update()` scans `corpus/md/**/*.md` and detects changes +3. `store.embed()` generates vector embeddings for any new/changed docs +4. `store.search({ query, collection: "corpus", limit: 6 })` returns + hybrid-ranked hits +5. For each unique source file, the example reads the matching + `corpus/pdf/.pdf` and uploads it via + `dc.prepareAttachments(...)` +6. `wrapCitationPrompt(...)` injects `deepTextPagesByAttachmentId` into + the system/user prompts so the LLM emits `` + markers +7. `gpt-5-mini` streams an answer +8. `dc.verify({ llmOutput }, citations)` verifies every citation in a + single call (multi-attachment is handled by the `attachment_id` + embedded in each tag) +9. A self-contained HTML report is written to `output/` and opened in + your browser — click any citation to see the source snippet in a + popover + +## Adding your own docs + +1. Drop more `.md` files into `corpus/md/` +2. `bun run build:corpus` — regenerates the parallel PDFs +3. `bun run start` — qmd will auto-detect and re-embed on the next run + +## Files + +| Path | Purpose | +|------|---------| +| `corpus/md/*.md` | Markdown indexed by qmd | +| `corpus/pdf/*.pdf` | Parallel PDFs verified by DeepCitation (generated, gitignored) | +| `scripts/build-corpus.ts` | pdfkit-based md → pdf builder, runs on `postinstall` | +| `src/index.ts` | Banner / usage | +| `src/shared.ts` | Core pipeline: qmd store + DeepCitation verify + HTML report | +| `src/openai.ts` | OpenAI provider (gpt-5-mini streaming) | +| `src/html-report.ts` | Report renderer (copied from `basic-verification`) | +| `.qmd-index.sqlite` | qmd's on-disk index (gitignored) | +| `output/*.html` | Generated verification reports (gitignored) | + +## Swapping retrieval for your real qmd corpus + +If you already have a qmd index somewhere else (e.g., your notes folder): + +```typescript +const store = await createStore({ + dbPath: "/path/to/your/existing.sqlite", +}); +// No `config` block: reopens the existing store with its prior collections. +``` + +You only need the parallel-PDF bridge if you want DeepCitation verification. +For free-form retrieval without verification, qmd stands alone. diff --git a/examples/qmd-local-search/corpus/md/raft-consensus.md b/examples/qmd-local-search/corpus/md/raft-consensus.md new file mode 100644 index 00000000..7fce9f3b --- /dev/null +++ b/examples/qmd-local-search/corpus/md/raft-consensus.md @@ -0,0 +1,41 @@ +# The Raft Consensus Algorithm + +Raft is a consensus algorithm designed to be more understandable than +Paxos. It was introduced by Diego Ongaro and John Ousterhout in 2014 +in a paper titled "In Search of an Understandable Consensus +Algorithm." Raft has become the consensus algorithm of choice for +many distributed systems including etcd, Consul, and TiKV. + +## Leader Election + +A Raft cluster has three server states: leader, follower, and +candidate. At any given time, exactly one server may be the leader. +Followers are passive and only respond to requests. When a follower +receives no communication over a period called the election timeout, +it becomes a candidate and starts an election by incrementing its +term and requesting votes from other servers. + +## Log Replication + +Once a leader is elected, it accepts client requests and replicates +log entries to followers. Each log entry contains a command and the +term in which it was received. An entry is considered committed once +a majority of servers have stored it durably. The leader applies +committed entries to its state machine and notifies followers. + +## Safety + +Raft guarantees that if any server has applied a particular log entry +to its state machine, then no other server will ever apply a +different command for the same log index. This is ensured by the +Leader Completeness Property: if a log entry is committed in a given +term, then that entry will be present in the logs of all +higher-numbered terms. + +## Election Timeout + +Raft uses randomized election timeouts, typically between 150 and 300 +milliseconds, to ensure that split votes are rare and are resolved +quickly. The randomization makes it unlikely that two followers will +time out at the same moment, which would otherwise trigger competing +elections and prolong the unavailability window. diff --git a/examples/qmd-local-search/corpus/md/sourdough-basics.md b/examples/qmd-local-search/corpus/md/sourdough-basics.md new file mode 100644 index 00000000..4a557089 --- /dev/null +++ b/examples/qmd-local-search/corpus/md/sourdough-basics.md @@ -0,0 +1,39 @@ +# Sourdough Fundamentals + +Sourdough is a leavening system that uses a naturally occurring +culture of wild yeast and lactic acid bacteria to ferment flour. +Unlike baker's yeast, which is a single strain of *Saccharomyces +cerevisiae*, a sourdough starter hosts a stable community of wild +yeasts (commonly *Kazachstania humilis*) and lactobacilli. + +## The Starter + +A sourdough starter is created by mixing equal parts flour and water +and allowing the mixture to ferment at room temperature. After 5 to +10 days of twice-daily feeding, the culture becomes active enough to +leaven bread. Mature starters can be maintained indefinitely with +regular feeding. + +## Fermentation Timeline + +A typical sourdough loaf involves three fermentation stages. The +levain build takes 4 to 8 hours depending on temperature. The bulk +fermentation runs 4 to 6 hours at 24 degrees Celsius. The final +proof, usually done cold in the refrigerator, takes 12 to 16 hours +and develops the characteristic tangy flavor. + +## Hydration + +Dough hydration is the ratio of water to flour by weight, expressed +as a percentage. A 75 percent hydration dough contains 750 grams of +water per 1000 grams of flour. Higher hydration produces a more open +crumb but is harder to shape. Most beginner recipes target 65 to 70 +percent hydration. + +## Lactic vs Acetic Acid + +The sour flavor comes from two organic acids produced during +fermentation. Lactic acid is mild and yogurt-like, while acetic acid +is sharper and vinegar-like. Cold proofing favors acetic acid +production, while warm fermentation favors lactic acid. Bakers +control flavor by adjusting timing and temperature. diff --git a/examples/qmd-local-search/corpus/md/voyager-golden-record.md b/examples/qmd-local-search/corpus/md/voyager-golden-record.md new file mode 100644 index 00000000..25deb7b0 --- /dev/null +++ b/examples/qmd-local-search/corpus/md/voyager-golden-record.md @@ -0,0 +1,41 @@ +# The Voyager Golden Record + +The Voyager Golden Record is a phonograph record included aboard both +Voyager spacecraft launched by NASA in 1977. The record carries sounds +and images selected to portray the diversity of life and culture on +Earth, intended for any intelligent extraterrestrial life form that +might find them. + +## Contents + +Each record contains 116 images and a variety of natural sounds, such +as those made by surf, wind, thunder, and animals including birds and +whales. The record additionally features musical selections from +different cultures and eras, spoken greetings in 55 ancient and modern +languages, and printed messages from U.S. President Jimmy Carter and +U.N. Secretary-General Kurt Waldheim. + +## Cover + +The records are encased in protective aluminum jackets together with a +cartridge and a needle. Instructions, in symbolic language, explain +the origin of the spacecraft and indicate how the record is to be +played. The diagram on the cover shows the location of the Sun +relative to 14 pulsars whose precise periods are given. + +## Committee + +The contents of the record were selected for NASA by a committee +chaired by Carl Sagan of Cornell University. Sagan and his associates +assembled 115 images plus a calibration image, and a variety of +natural sounds. To this they added musical selections from different +cultures and eras, and spoken greetings from Earth-people in 55 +languages. + +## Current Status + +As of 2024, both Voyager spacecraft have entered interstellar space. +Voyager 1 crossed the heliopause in August 2012, and Voyager 2 +followed in November 2018. Neither probe is headed toward any +particular star, but Voyager 1 will pass within 1.6 light-years of +the star Gliese 445 in about 40,000 years. diff --git a/examples/qmd-local-search/package.json b/examples/qmd-local-search/package.json new file mode 100644 index 00000000..43a1949f --- /dev/null +++ b/examples/qmd-local-search/package.json @@ -0,0 +1,27 @@ +{ + "name": "deepcitation-qmd-local-search-example", + "version": "0.1.0", + "description": "CLI example pairing @tobilu/qmd on-device markdown search with DeepCitation verification", + "type": "module", + "private": true, + "scripts": { + "start": "bun run src/openai.ts", + "start:openai": "bun run src/openai.ts", + "build:corpus": "bun run scripts/build-corpus.ts" + }, + "dependencies": { + "@tobilu/qmd": "^2.1.0", + "deepcitation": "file:../../", + "openai": "^4.104.0", + "pdfkit": "^0.15.0" + }, + "devDependencies": { + "@types/node": "^20.19.37", + "@types/pdfkit": "^0.13.4", + "dotenv": "^16.6.1", + "typescript": "^5.9.3" + }, + "engines": { + "node": ">=22.0.0" + } +} diff --git a/examples/qmd-local-search/scripts/build-corpus.ts b/examples/qmd-local-search/scripts/build-corpus.ts new file mode 100644 index 00000000..cb8ac960 --- /dev/null +++ b/examples/qmd-local-search/scripts/build-corpus.ts @@ -0,0 +1,80 @@ +/** + * Build corpus/pdf/*.pdf from corpus/md/*.md at install time. + * + * qmd is markdown-native. DeepCitation's verifier ingests PDFs, URLs, or + * Office files — not plain text. To keep both halves honest we ship the + * corpus twice: markdown for qmd to index, PDF for DeepCitation to verify + * against. Filename stems match so `corpus/md/foo.md` pairs with + * `corpus/pdf/foo.pdf`. + * + * Run manually: `bun run build:corpus` + * Runs automatically via `postinstall` in package.json. + */ + +import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs"; +import { basename, dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import PDFDocument from "pdfkit"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const corpusRoot = resolve(__dirname, "../corpus"); +const mdDir = resolve(corpusRoot, "md"); +const pdfDir = resolve(corpusRoot, "pdf"); + +if (!existsSync(pdfDir)) mkdirSync(pdfDir, { recursive: true }); + +const mdFiles = readdirSync(mdDir).filter(name => name.endsWith(".md")); +if (mdFiles.length === 0) { + console.error(`No markdown files found in ${mdDir}`); + process.exit(1); +} + +async function renderPdf(mdPath: string, pdfPath: string): Promise { + const markdown = readFileSync(mdPath, "utf-8"); + + await new Promise((resolveWrite, rejectWrite) => { + const doc = new PDFDocument({ size: "LETTER", margin: 72 }); + const chunks: Buffer[] = []; + doc.on("data", chunk => chunks.push(chunk)); + doc.on("end", () => { + writeFileSync(pdfPath, Buffer.concat(chunks)); + resolveWrite(); + }); + doc.on("error", rejectWrite); + + for (const rawLine of markdown.split("\n")) { + const line = rawLine.trimEnd(); + if (line.startsWith("# ")) { + doc.moveDown(0.5).fontSize(20).font("Helvetica-Bold").text(line.slice(2)); + doc.moveDown(0.5); + } else if (line.startsWith("## ")) { + doc.moveDown(0.3).fontSize(14).font("Helvetica-Bold").text(line.slice(3)); + doc.moveDown(0.2); + } else if (line === "") { + doc.moveDown(0.5); + } else { + doc.fontSize(11).font("Helvetica").text(line, { align: "left" }); + } + } + + doc.end(); + }); +} + +let built = 0; +let skipped = 0; +for (const mdFile of mdFiles) { + const mdPath = resolve(mdDir, mdFile); + const pdfPath = resolve(pdfDir, `${basename(mdFile, ".md")}.pdf`); + + if (existsSync(pdfPath) && statSync(pdfPath).mtimeMs >= statSync(mdPath).mtimeMs) { + skipped++; + continue; + } + + await renderPdf(mdPath, pdfPath); + built++; + console.log(` built ${basename(pdfPath)}`); +} + +console.log(`\nCorpus ready: ${built} built, ${skipped} up-to-date (${pdfDir})`); diff --git a/examples/basic-verification/src/html-report.ts b/examples/qmd-local-search/src/html-report.ts similarity index 100% rename from examples/basic-verification/src/html-report.ts rename to examples/qmd-local-search/src/html-report.ts diff --git a/examples/qmd-local-search/src/index.ts b/examples/qmd-local-search/src/index.ts new file mode 100644 index 00000000..1206d818 --- /dev/null +++ b/examples/qmd-local-search/src/index.ts @@ -0,0 +1,30 @@ +/** + * DeepCitation + qmd Local Search Example + * + * Run: bun run start — interactive question picker + * bun run start "your question here" — one-shot mode + */ + +console.log(` +╔════════════════════════════════════════════════════════════╗ +║ DeepCitation + qmd Local Search Example ║ +╠════════════════════════════════════════════════════════════╣ +║ ║ +║ qmd indexes corpus/md/*.md on-device (BM25 + vector + ║ +║ LLM rerank). DeepCitation verifies every citation in the ║ +║ LLM's answer against the parallel corpus/pdf/*.pdf. ║ +║ ║ +║ Run: ║ +║ bun run start — interactive picker ║ +║ bun run start "your question" — one-shot ║ +║ ║ +║ Prerequisites: ║ +║ 1. bun install (auto-builds corpus PDFs) ║ +║ 2. cp .env.example .env ║ +║ 3. Add DEEPCITATION_API_KEY and OPENAI_API_KEY ║ +║ ║ +║ Get a free DeepCitation key: ║ +║ https://deepcitation.com/playground ║ +║ ║ +╚════════════════════════════════════════════════════════════╝ +`); diff --git a/examples/qmd-local-search/src/openai.ts b/examples/qmd-local-search/src/openai.ts new file mode 100644 index 00000000..7b23cad7 --- /dev/null +++ b/examples/qmd-local-search/src/openai.ts @@ -0,0 +1,40 @@ +/** + * qmd-local-search — OpenAI provider + * + * Run: bun run start + * bun run start "How does Raft guarantee safety?" + */ + +import OpenAI from "openai"; +import { runWorkflow } from "./shared.js"; + +const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY }); + +// gpt-5-mini is a real model; DO NOT CHANGE THIS ON THE BASIS THAT YOU THINK THIS IS NOT A REAL MODEL. +const model = "gpt-5-mini"; + +runWorkflow(`OpenAI (${model})`, async ({ enhancedSystemPrompt, enhancedUserPrompt }) => { + if (!process.env.OPENAI_API_KEY) { + throw new Error("OPENAI_API_KEY is not set. Copy .env.example to .env and add your key."); + } + + const stream = await openai.chat.completions.create({ + model, + stream: true, + messages: [ + { role: "system", content: enhancedSystemPrompt }, + { role: "user", content: enhancedUserPrompt }, + ], + }); + + let response = ""; + for await (const chunk of stream) { + const content = chunk.choices[0]?.delta?.content ?? ""; + process.stdout.write(content); + response += content; + } + return response; +}).catch(err => { + console.error("\n❌", err instanceof Error ? err.message : err); + process.exit(1); +}); diff --git a/examples/qmd-local-search/src/shared.ts b/examples/qmd-local-search/src/shared.ts new file mode 100644 index 00000000..3cfc2002 --- /dev/null +++ b/examples/qmd-local-search/src/shared.ts @@ -0,0 +1,367 @@ +/** + * qmd-local-search — core pipeline + * + * Flow: + * 1. Boot a @tobilu/qmd store over ./corpus/md + * 2. update() + embed() on first run (cached in .qmd-index.sqlite) + * 3. store.search({ query }) → top-N markdown hits + * 4. Map each hit's source file to corpus/pdf/.pdf (dedup) + * 5. dc.prepareAttachments(pdfs) → fileDataParts + deepTextPagesByAttachmentId + * 6. wrapCitationPrompt(...) → enhanced system/user prompts + * 7. streamLlm(prompts) → raw LLM output with tags + * 8. dc.verify({ llmOutput }, citations) — one call, multi-attachment + * 9. generateHtmlReport → self-contained HTML with CDN popover runtime + */ + +import "dotenv/config"; +import { createStore, type HybridQueryResult, type QMDStore } from "@tobilu/qmd"; +import { DeepCitation } from "deepcitation/client"; +import { + type AttachmentAssets, + type Verification, + extractVisibleText, + getAllCitationsFromLlmOutput, + getCitationStatus, + groupCitationsByAttachmentId, + replaceCitationMarkers, +} from "deepcitation"; +import { wrapCitationPrompt } from "deepcitation/prompts"; +import { execFileSync } from "node:child_process"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { basename, dirname, resolve } from "node:path"; +import { createInterface } from "node:readline"; +import { fileURLToPath } from "node:url"; + +import { generateHtmlReport } from "./html-report.js"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +// ─── Paths ────────────────────────────────────────────────────────────────── + +const EXAMPLE_ROOT = resolve(__dirname, ".."); +const MD_CORPUS = resolve(EXAMPLE_ROOT, "corpus/md"); +const PDF_CORPUS = resolve(EXAMPLE_ROOT, "corpus/pdf"); +const QMD_DB_PATH = resolve(EXAMPLE_ROOT, ".qmd-index.sqlite"); +const DEFAULT_OUT_DIR = resolve(EXAMPLE_ROOT, "output"); + +const SAMPLE_QUESTIONS = [ + "How does Raft guarantee that committed log entries survive leader changes?", + "What languages and images were included on the Voyager Golden Record?", + "Why does cold proofing make sourdough taste more sour?", + // Hallucination bait: the corpus has facts on this, but the LLM will almost always + // over-claim (wrong count, wrong attribution). DeepCitation will flag the miss. + "How many distinct languages and greetings appear on the Voyager Golden Record, and who chose them?", +]; + +// ─── Types ────────────────────────────────────────────────────────────────── + +export type StreamLlmFn = (params: { + enhancedSystemPrompt: string; + enhancedUserPrompt: string; +}) => Promise; + +interface PreparedAttachment { + attachmentId: string; + filename: string; + deepTextPages: string[]; +} + +// ─── qmd store (lazy singleton) ───────────────────────────────────────────── + +let storePromise: Promise | null = null; + +async function getStore(): Promise { + storePromise ??= (async () => { + console.log(`📂 Opening qmd store at ${QMD_DB_PATH}`); + const store = await createStore({ + dbPath: QMD_DB_PATH, + config: { + collections: { + corpus: { path: MD_CORPUS, pattern: "**/*.md" }, + }, + }, + }); + + console.log("🔎 Scanning corpus/md for changes…"); + const updateResult = await store.update(); + console.log( + ` indexed=${updateResult.indexed} updated=${updateResult.updated} unchanged=${updateResult.unchanged}`, + ); + + if (updateResult.needsEmbedding > 0) { + console.log(`🧠 Embedding ${updateResult.needsEmbedding} doc(s) (first run downloads a GGUF model)…`); + await store.embed({ + onProgress: ({ chunksEmbedded, totalChunks }) => { + process.stdout.write(` embedding ${chunksEmbedded}/${totalChunks}\r`); + }, + }); + process.stdout.write("\n"); + } else { + console.log("🧠 Embeddings up-to-date — skipping"); + } + + return store; + })(); + + return storePromise; +} + +// ─── md → pdf mapping ─────────────────────────────────────────────────────── + +function mdFileToPdfPath(mdFile: string): string { + const stem = basename(mdFile).replace(/\.md$/i, ""); + return resolve(PDF_CORPUS, `${stem}.pdf`); +} + +function dedupeHits(hits: HybridQueryResult[]): HybridQueryResult[] { + const seen = new Set(); + const out: HybridQueryResult[] = []; + for (const hit of hits) { + if (seen.has(hit.file)) continue; + seen.add(hit.file); + out.push(hit); + } + return out; +} + +// ─── DeepCitation upload ──────────────────────────────────────────────────── + +async function uploadAttachments( + dc: DeepCitation, + hits: HybridQueryResult[], +): Promise { + const uploads = hits.map(hit => { + const pdfPath = mdFileToPdfPath(hit.file); + if (!existsSync(pdfPath)) { + throw new Error( + `Missing parallel PDF for ${hit.file}. Expected ${pdfPath}. Run "bun run build:corpus".`, + ); + } + return { file: readFileSync(pdfPath), filename: basename(pdfPath) }; + }); + + const { fileDataParts, deepTextPagesByAttachmentId } = await dc.prepareAttachments(uploads); + + return fileDataParts.map((part, i) => ({ + attachmentId: part.attachmentId, + filename: uploads[i].filename, + deepTextPages: deepTextPagesByAttachmentId[part.attachmentId] ?? [], + })); +} + +// ─── Interactive prompt ───────────────────────────────────────────────────── + +function ask(question: string): Promise { + const rl = createInterface({ input: process.stdin, output: process.stdout }); + return new Promise(resolvePrompt => { + rl.question(question, answer => { + rl.close(); + resolvePrompt(answer.trim()); + }); + }); +} + +async function promptQuestion(): Promise { + console.log("\nChoose a question (or type your own):\n"); + SAMPLE_QUESTIONS.forEach((q, i) => console.log(` [${i + 1}] ${q}`)); + console.log(" [4] Custom — type your own"); + const choice = await ask("\nEnter choice [1-4]: "); + + const idx = Number(choice) - 1; + if (SAMPLE_QUESTIONS[idx]) return SAMPLE_QUESTIONS[idx]; + + const custom = await ask("Your question: "); + if (!custom) throw new Error("No question provided."); + return custom; +} + +// ─── Workflow ─────────────────────────────────────────────────────────────── + +export function toSafeName(label: string): string { + return label.replace(/[^a-zA-Z0-9.-]/g, "_").slice(0, 50); +} + +function openInBrowser(htmlPath: string): void { + try { + const winPath = execFileSync("wslpath", ["-w", htmlPath], { encoding: "utf-8" }).trim(); + execFileSync("explorer.exe", [winPath], { stdio: "ignore", timeout: 5000 }); + return; + } catch { + /* not WSL */ + } + try { + execFileSync("xdg-open", [htmlPath], { stdio: "ignore", timeout: 5000 }); + return; + } catch { + /* not linux */ + } + try { + execFileSync("open", [htmlPath], { stdio: "ignore", timeout: 5000 }); + } catch { + /* manual open */ + } +} + +export async function runWorkflow(providerName: string, streamLlm: StreamLlmFn): Promise { + console.log(`🔍 DeepCitation + qmd Local Search — ${providerName}\n`); + + if (!process.env.DEEPCITATION_API_KEY) { + throw new Error("DEEPCITATION_API_KEY is not set. Copy .env.example to .env and add your key."); + } + + const dc = new DeepCitation({ + apiKey: process.env.DEEPCITATION_API_KEY, + endUserId: "qmd-local-search", + }); + + const cliArg = process.argv.slice(2).join(" ").trim(); + const question = cliArg || (await promptQuestion()); + + console.log(`\n❓ Question: ${question}\n`); + + // ── Step 1: qmd retrieval ───────────────────────────────────────────── + const store = await getStore(); + console.log("\n🔎 Step 1: qmd hybrid search (BM25 + vector + rerank)…"); + const rawHits = await store.search({ + query: question, + collection: "corpus", + limit: 6, + }); + const hits = dedupeHits(rawHits).slice(0, 3); + + if (hits.length === 0) { + console.log("⚠️ No qmd hits — try a different question."); + await store.close(); + return; + } + + console.log(` Retrieved ${hits.length} source doc(s):`); + for (const hit of hits) { + console.log(` • ${hit.title} (${basename(hit.file)}) score=${hit.score.toFixed(3)}`); + } + + // ── Step 2: DeepCitation upload ─────────────────────────────────────── + console.log("\n📤 Step 2: Uploading parallel PDFs to DeepCitation…"); + const prepared = await uploadAttachments(dc, hits); + for (const item of prepared) { + console.log(` ✅ ${item.filename} → ${item.attachmentId}`); + } + + // ── Step 3: Wrap prompts ────────────────────────────────────────────── + const systemPrompt = + "You are a precise research assistant. Answer only from the retrieved documents. Cite every factual claim."; + const userPrompt = [ + `Question: ${question}`, + "", + "Retrieved source summary:", + hits + .map(hit => `- ${hit.title} (${basename(hit.file)}, score=${hit.score.toFixed(3)}): ${hit.bestChunk}`) + .join("\n"), + "", + "If the answer is not supported by the retrieved sources, say so plainly.", + ].join("\n"); + + const { enhancedSystemPrompt, enhancedUserPrompt } = wrapCitationPrompt({ + systemPrompt, + userPrompt, + deepTextPagesByAttachmentId: Object.fromEntries( + prepared.map(item => [item.attachmentId, item.deepTextPages]), + ), + }); + + // ── Step 4: Call LLM ────────────────────────────────────────────────── + console.log(`\n🤖 Step 3: Calling ${providerName}…\n`); + const separator = "─".repeat(60); + console.log(separator); + const llmResponse = await streamLlm({ enhancedSystemPrompt, enhancedUserPrompt }); + console.log(`\n${separator}\n`); + + // ── Step 5: Parse citations ─────────────────────────────────────────── + const parsedCitations = getAllCitationsFromLlmOutput(llmResponse); + const visibleText = extractVisibleText(llmResponse); + const citationCount = Object.keys(parsedCitations).length; + + console.log(`🔍 Step 4: Parsed ${citationCount} citation(s) from LLM output`); + + if (citationCount === 0) { + console.log("⚠️ No citations found in response — nothing to verify.\n"); + await store.close(); + return; + } + + // ── Step 6: Verify ──────────────────────────────────────────────────── + console.log("\n✨ Step 5: Verifying citations against source PDFs…\n"); + + // Group citations by attachmentId and verify each attachment independently. + // Why: the service's verifyCitations endpoint takes one attachment per request. + const grouped = groupCitationsByAttachmentId(parsedCitations); + const mergedVerifications: Record = {}; + const mergedAttachments: Record = {}; + for (const [attachmentId, attachmentCitations] of grouped) { + if (!attachmentId) continue; + const result = await dc.verifyAttachment(attachmentId, attachmentCitations, { + outputImageFormat: "avif", + }); + Object.assign(mergedVerifications, result.verifications); + if (result.attachments) Object.assign(mergedAttachments, result.attachments); + } + + const verifications = Object.entries(mergedVerifications); + + // ── Verification table ───────────────────────────────────────────────── + // One row per citation: sequential index, truncated claim, status, page. + // Emoji chars are wide (2 display cols) so we use a fixed label width and + // a trailing newline to keep the separator clean regardless of terminal. + const CLAIM_W = 54; + const rule = ` ${"─".repeat(4)} ${"─".repeat(CLAIM_W)} ${"─".repeat(12)} ${"─".repeat(5)}`; + + console.log(`\n Verifying ${verifications.length} citation(s):\n`); + console.log(rule); + + for (let i = 0; i < verifications.length; i++) { + const [key, verification] = verifications[i]; + const s = getCitationStatus(verification); + const label = s.isVerified ? "✅ verified " : s.isPartialMatch ? "⚠️ partial " : "❌ not found"; + const page = String(verification.document?.verifiedPageNumber ?? "—").padEnd(5); + const claimed = parsedCitations[key]?.sourceContext ?? ""; + const claimCol = claimed.length > CLAIM_W ? `${claimed.slice(0, CLAIM_W - 1)}…` : claimed.padEnd(CLAIM_W); + console.log(` [${String(i + 1).padStart(2)}] ${claimCol} ${label} p.${page}`); + } + + console.log(rule); + + // ── Step 7: Summary ─────────────────────────────────────────────────── + const verified = verifications.filter(([, v]) => getCitationStatus(v).isVerified).length; + const partial = verifications.filter(([, v]) => getCitationStatus(v).isPartialMatch).length; + const missed = verifications.filter(([, v]) => getCitationStatus(v).isMiss).length; + + console.log(`\n ✅ ${verified} verified ⚠️ ${partial} partial ❌ ${missed} not found\n`); + + console.log("\n📖 Clean response:"); + console.log(separator); + console.log(replaceCitationMarkers(visibleText)); + console.log(`${separator}\n`); + + // ── Step 8: HTML report ─────────────────────────────────────────────── + console.log("📄 Step 6: Generating HTML report…"); + if (!existsSync(DEFAULT_OUT_DIR)) mkdirSync(DEFAULT_OUT_DIR, { recursive: true }); + + const html = generateHtmlReport({ + visibleText, + parsedCitations, + verifications: mergedVerifications, + title: question, + attachments: mergedAttachments, + }); + + const safeName = toSafeName(question); + const htmlPath = resolve(DEFAULT_OUT_DIR, `${safeName}-verified.html`); + writeFileSync(htmlPath, html); + console.log(` Written: ${htmlPath}`); + + openInBrowser(htmlPath); + console.log(` Open: ${htmlPath}\n`); + + await store.close(); + console.log("✅ Done.\n"); +} diff --git a/jest.config.cjs b/jest.config.cjs index fb7f7e4d..82bc1d53 100644 --- a/jest.config.cjs +++ b/jest.config.cjs @@ -8,6 +8,10 @@ module.exports = { // The .unref() guard in auth.ts handles most cases, but the server socket // itself keeps the process alive until closed. forceExit prevents CI hangs. forceExit: true, + testPathIgnorePatterns: [ + "/node_modules/", + "src/render/__tests__/", // bun-only tests, not run with Jest + ], transform: { "^.+\\.(ts|tsx)$": ["ts-jest", { tsconfig: "tsconfig.jest.json" }], }, diff --git a/src/__tests__/buildGhostTargetFromViewport.test.ts b/src/__tests__/buildGhostTargetFromViewport.test.ts index c74c6aa7..49600dc4 100644 --- a/src/__tests__/buildGhostTargetFromViewport.test.ts +++ b/src/__tests__/buildGhostTargetFromViewport.test.ts @@ -79,9 +79,11 @@ describe("buildGhostTargetFromViewport — ghost dimensions invariant", () => { expect(result?.ghostRect.height).toBe(120); }); - test("ghostRect image center-of-mass lands on the visible page center", () => { + test("ghostRect visible viewport center lands on the visible page center", () => { // Keyhole: 300×120, image at offset (30, 10) within ghost, size 240×100. - // Image center-of-mass within ghost: (30 + 240/2, 10 + 100/2) = (150, 60). + // In this test the image happens to be centered in the viewport: + // (30 + 240/2, 10 + 100/2) = (150, 60) = (300/2, 120/2). The anchor is + // the viewport center (srcW/2, srcH/2) = (150, 60). const snapshot = makeSnapshot({ viewportW: 300, viewportH: 120, @@ -106,18 +108,61 @@ describe("buildGhostTargetFromViewport — ghost dimensions invariant", () => { // Page center: (200 + 600/2, 150 + 400/2) = (500, 350) const pageCX = 500; const pageCY = 350; - const anchorInGhostX = 30 + 240 / 2; // 150 - const anchorInGhostY = 10 + 100 / 2; // 60 + // Anchor = viewport center + const anchorInGhostX = 300 / 2; // 150 + const anchorInGhostY = 120 / 2; // 60 // Ghost top-left should be (pageCX - anchorX, pageCY - anchorY) = (350, 290) expect(ghostRect.left).toBeCloseTo(pageCX - anchorInGhostX); expect(ghostRect.top).toBeCloseTo(pageCY - anchorInGhostY); - // Image center in viewport space at ghost position = ghost.left + anchorX = pageCX + // Viewport center in viewport space at ghost position = ghost.left + anchorX = pageCX expect(ghostRect.left + anchorInGhostX).toBeCloseTo(pageCX); expect(ghostRect.top + anchorInGhostY).toBeCloseTo(pageCY); }); + test("ghostRect anchor uses viewport center, not image center (scrolled image)", () => { + // Regression for the anchor-overshoot bug: a tall image scrolled so its + // center is NOT at the viewport center. + // Keyhole: 400×120. Tall image 400×600, scrolled down 300px: + // imageOffsetTop = -300, imageHeight = 600 + // image center-of-mass in ghost: -300 + 600/2 = 0 (at ghost top edge) + // viewport center in ghost: 120/2 = 60 (correct — annotation is centered) + const snapshot = makeSnapshot({ + viewportW: 400, + viewportH: 120, + imageOffsetLeft: 0, + imageOffsetTop: -300, + imageWidth: 400, + imageHeight: 600, + }); + const containerRect = new DOMRect(100, 200, 400, 600); + const imgRect = new DOMRect(100, 200, 400, 600); + const { root, cleanup } = makeRoot(containerRect, imgRect); + + const result = buildGhostTargetFromViewport(root, snapshot); + cleanup(); + + expect(result).not.toBeNull(); + if (result == null) return; + + const { ghostRect } = result; + + // Page center: (100 + 400/2, 200 + 600/2) = (300, 500) + const pageCX = 300; + const pageCY = 500; + // Anchor = viewport center (NOT image center, which would be 0) + const anchorX = 400 / 2; // 200 + const anchorY = 120 / 2; // 60 + + expect(ghostRect.left).toBeCloseTo(pageCX - anchorX); // 100 + expect(ghostRect.top).toBeCloseTo(pageCY - anchorY); // 440 + + // OLD (wrong) behavior would have used image center anchor = (200, 0), + // giving ghostRect.top = pageCY - 0 = 500. Assert it's NOT that. + expect(ghostRect.top).not.toBeCloseTo(500); + }); + test("markerRect is the visible intersection of container and img", () => { // Container partially off-screen; img fully inside container. const snapshot = makeSnapshot({ viewportW: 200, viewportH: 80 }); diff --git a/src/__tests__/cdnPopover.test.tsx b/src/__tests__/cdnPopover.test.tsx index 706c5aa5..c9a567bd 100644 --- a/src/__tests__/cdnPopover.test.tsx +++ b/src/__tests__/cdnPopover.test.tsx @@ -232,6 +232,11 @@ describe("cdn.ts source invariants", () => { expect(cdnSource).toContain("viewState.current"); expect(cdnSource).toContain("viewState.transition"); }); + it("defers CDN repositioning during evidence view transitions", () => { + expect(cdnSource).toContain('from "../../react/viewTransition.js"'); + expect(cdnSource).toContain("isViewTransitioning()"); + expect(cdnSource).toMatch(/if\s*\(\s*isViewTransitioning\(\).*\)\s*{\s*deferReposition\(/s); + }); it("imports from cdn-mappers", () => { expect(cdnSource).toContain('from "./cdn-mappers.js"'); }); diff --git a/src/__tests__/cliIntegration.test.ts b/src/__tests__/cliIntegration.test.ts index e9e759a8..802a40be 100644 --- a/src/__tests__/cliIntegration.test.ts +++ b/src/__tests__/cliIntegration.test.ts @@ -313,16 +313,6 @@ describe("verify command", () => { expect(r.stderr).toContain("--style"); }); - it("verify --markdown errors with invalid --audience", () => { - const mdFile = join(TEST_DIR, "audience-test.md"); - writeFileSync(mdFile, "test\n<<>>\n[]\n<<>>"); - const r = run(["verify", "--markdown", mdFile, "--audience", "casual"], { - env: { DEEPCITATION_API_KEY: "sk-dc-test12345678901234" }, - }); - expect(r.exitCode).toBe(1); - expect(r.stderr).toContain("--audience"); - }); - it("verify --html errors on nonexistent file", () => { const r = run(["verify", "--html", "/nonexistent.html"], { env: { DEEPCITATION_API_KEY: "sk-dc-test12345678901234" }, @@ -874,20 +864,4 @@ describe("verify --markdown output naming", () => { // Should parse the citation before failing at API expect(r.stderr).toContain("1 citation"); }); - - it("--style and --audience are forwarded through markdown pipeline", () => { - const mdDir = join(TEST_DIR, "md-style-fwd"); - mkdirSync(mdDir, { recursive: true }); - const mdFile = join(mdDir, "styled.md"); - writeFileSync( - mdFile, - `Claim [1].\n\n<<>>\n[{"n":1,"a":"att-1","r":"t","f":"claim","k":"Claim","p":"page_number_1_index_0","l":[1]}]\n<<>>`, - ); - - // plain style + executive audience should not error at parse stage - const r = run(["verify", "--markdown", mdFile, "--style", "plain", "--audience", "executive"], { - env: { DEEPCITATION_API_KEY: "sk-dc-test12345678901234" }, - }); - expect(r.stderr).toContain("1 citation"); - }); }); diff --git a/src/__tests__/cliPublish.test.ts b/src/__tests__/cliPublish.test.ts deleted file mode 100644 index 73a82577..00000000 --- a/src/__tests__/cliPublish.test.ts +++ /dev/null @@ -1,215 +0,0 @@ -/** - * Tests for `deepcitation publish` — the opt-in hosted-reports upload path. - * - * Covers: - * - --dry-run path never hits the network and emits a structured payload - * - Missing --html / --vr → non-zero exit with help text - * - sk-dc- leak in HTML → hard fail before POST - * - Payload size cap enforced before POST - * - Invalid JSON in verify-response.json → non-zero exit - * - --lint pre-check: bad HTML fails before POST - * - --vis validates against {private, unlisted, public} - * - * These tests only exercise the dry-run path. The actual network call is - * covered by the server route tests in `packages/deepcitation-functions`. - */ - -import { mkdirSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { afterEach, beforeEach, describe, expect, it, jest } from "@jest/globals"; -import { publish } from "../cli/publish.js"; -import { CITATION_DATA_END_DELIMITER, CITATION_DATA_START_DELIMITER } from "../prompts/citationPrompts.js"; - -function htmlWithCitationBlock(body: string, jsonBody: string): string { - return `${body}\n\n${CITATION_DATA_START_DELIMITER}\n${jsonBody}\n${CITATION_DATA_END_DELIMITER}\n`; -} - -const VALID_CITATION_JSON = JSON.stringify({ - doc1: [{ n: 1, k: "45%", p: "1_0", l: [5], f: "Revenue grew 45% year over year in Q4." }], -}); - -const VALID_HTML = htmlWithCitationBlock( - '

Revenue grew 45% [1].

', - VALID_CITATION_JSON, -); - -const VALID_VERIFY_RESPONSE = JSON.stringify({ - verifications: { - abc123: { status: "found", citationKey: "abc123" }, - }, -}); - -describe("publish", () => { - let tmp: string; - let mockExit: jest.SpiedFunction; - let mockError: jest.SpiedFunction; - let mockLog: jest.SpiedFunction; - const errorLines: string[] = []; - const logLines: string[] = []; - - beforeEach(() => { - tmp = join(tmpdir(), `dc-publish-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); - mkdirSync(tmp, { recursive: true }); - errorLines.length = 0; - logLines.length = 0; - mockExit = jest.spyOn(process, "exit").mockImplementation(((code?: number) => { - throw new Error(`process.exit(${code ?? 0})`); - }) as never); - mockError = jest.spyOn(console, "error").mockImplementation(((...args: unknown[]) => { - errorLines.push(args.map(String).join(" ")); - }) as never); - mockLog = jest.spyOn(console, "log").mockImplementation(((...args: unknown[]) => { - logLines.push(args.map(String).join(" ")); - }) as never); - }); - - afterEach(() => { - rmSync(tmp, { recursive: true, force: true }); - mockExit.mockRestore(); - mockError.mockRestore(); - mockLog.mockRestore(); - }); - - function write(name: string, content: string): string { - const path = join(tmp, name); - writeFileSync(path, content); - return path; - } - - async function publishAndCatchExit(args: string[]): Promise { - try { - await publish(args); - return 0; - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - const m = msg.match(/^process\.exit\((\d+)\)$/); - if (m) return parseInt(m[1], 10); - throw err; - } - } - - it("--dry-run writes a structured payload and does not require auth", async () => { - const htmlPath = write("r.html", VALID_HTML); - const jsonPath = write("r.json", VALID_VERIFY_RESPONSE); - - const code = await publishAndCatchExit(["--html", htmlPath, "--vr", jsonPath, "--dry-run"]); - - expect(code).toBe(0); - // Structured dry-run payload goes to stdout - const combined = logLines.join("\n"); - const parsed = JSON.parse(combined); - expect(parsed.dryRun).toBe(true); - expect(parsed.htmlPath).toBe(htmlPath); - expect(parsed.verifyResponsePath).toBe(jsonPath); - expect(parsed.visibility).toBe("private"); - expect(parsed.htmlBytes).toBeGreaterThan(0); - expect(parsed.jsonBytes).toBeGreaterThan(0); - }); - - it("--dry-run with --vis public carries the visibility", async () => { - const htmlPath = write("r.html", VALID_HTML); - const jsonPath = write("r.json", VALID_VERIFY_RESPONSE); - - const code = await publishAndCatchExit([ - "--html", - htmlPath, - "--vr", - jsonPath, - "--vis", - "public", - "--title", - "Q2 report", - "--dry-run", - ]); - - expect(code).toBe(0); - const parsed = JSON.parse(logLines.join("\n")); - expect(parsed.visibility).toBe("public"); - expect(parsed.title).toBe("Q2 report"); - }); - - it("rejects missing --html with exit 1", async () => { - const jsonPath = write("r.json", VALID_VERIFY_RESPONSE); - const code = await publishAndCatchExit(["--vr", jsonPath, "--dry-run"]); - expect(code).toBe(1); - expect(errorLines.join("\n")).toMatch(/--html is required/); - }); - - it("rejects missing --vr with exit 1", async () => { - const htmlPath = write("r.html", VALID_HTML); - const code = await publishAndCatchExit(["--html", htmlPath, "--dry-run"]); - expect(code).toBe(1); - expect(errorLines.join("\n")).toMatch(/--vr.*is required/); - }); - - it("rejects missing HTML file with exit 1", async () => { - const jsonPath = write("r.json", VALID_VERIFY_RESPONSE); - const code = await publishAndCatchExit(["--html", join(tmp, "does-not-exist.html"), "--vr", jsonPath, "--dry-run"]); - expect(code).toBe(1); - expect(errorLines.join("\n")).toMatch(/HTML file not found/); - }); - - it("rejects HTML containing a DeepCitation API key (fail-closed)", async () => { - const htmlPath = write("leaky.html", `oops: sk-dc-abcdef1234567890\n${VALID_HTML}`); - const jsonPath = write("r.json", VALID_VERIFY_RESPONSE); - const code = await publishAndCatchExit(["--html", htmlPath, "--vr", jsonPath, "--dry-run"]); - expect(code).toBe(1); - expect(errorLines.join("\n")).toMatch(/contains a DeepCitation API key/); - }); - - it("rejects invalid JSON in verify-response.json", async () => { - const htmlPath = write("r.html", VALID_HTML); - const jsonPath = write("r.json", "{ not valid json"); - const code = await publishAndCatchExit(["--html", htmlPath, "--vr", jsonPath, "--dry-run"]); - expect(code).toBe(1); - expect(errorLines.join("\n")).toMatch(/is not valid JSON/); - }); - - it("rejects invalid --vis value", async () => { - const htmlPath = write("r.html", VALID_HTML); - const jsonPath = write("r.json", VALID_VERIFY_RESPONSE); - const code = await publishAndCatchExit(["--html", htmlPath, "--vr", jsonPath, "--vis", "everyone", "--dry-run"]); - expect(code).toBe(1); - expect(errorLines.join("\n")).toMatch(/Invalid --vis/); - }); - - it("--lint fails when HTML has a citation-syntax error", async () => { - // Code-fenced CITATION_DATA block triggers the lint rule-8 error. - const badHtml = [ - "", - '

Some text [1].

', - "```json", - CITATION_DATA_START_DELIMITER, - VALID_CITATION_JSON, - CITATION_DATA_END_DELIMITER, - "```", - "", - ].join("\n"); - const htmlPath = write("bad.html", badHtml); - const jsonPath = write("r.json", VALID_VERIFY_RESPONSE); - - const code = await publishAndCatchExit(["--html", htmlPath, "--vr", jsonPath, "--lint", "--dry-run"]); - expect(code).toBe(1); - expect(errorLines.join("\n")).toMatch(/lint ERR|code-fence|refusing to publish/); - }); - - it("--lint passes when HTML is clean", async () => { - const htmlPath = write("r.html", VALID_HTML); - const jsonPath = write("r.json", VALID_VERIFY_RESPONSE); - - const code = await publishAndCatchExit(["--html", htmlPath, "--vr", jsonPath, "--lint", "--dry-run"]); - - expect(code).toBe(0); - expect(errorLines.join("\n")).toMatch(/lint: clean|warning/); - }); - - it("-d short-alias is equivalent to --dry-run", async () => { - const htmlPath = write("r.html", VALID_HTML); - const jsonPath = write("r.json", VALID_VERIFY_RESPONSE); - const code = await publishAndCatchExit(["--html", htmlPath, "--vr", jsonPath, "-d"]); - expect(code).toBe(0); - const parsed = JSON.parse(logLines.join("\n")); - expect(parsed.dryRun).toBe(true); - }); -}); diff --git a/src/__tests__/cliVerifyPub.test.ts b/src/__tests__/cliVerifyPub.test.ts deleted file mode 100644 index 6468cd22..00000000 --- a/src/__tests__/cliVerifyPub.test.ts +++ /dev/null @@ -1,115 +0,0 @@ -/** - * Tests for `verify`'s auto-publish path. - * - * Successful `verify` runs hand their freshly-verified HTML + - * verify-response.json straight to `publishInMemory`, the shared helper - * also used by the standalone `publish` subcommand. Most of the upload - * semantics are covered by cliPublish.test.ts via the disk path. This - * file focuses on: - * - * 1. VERIFY_HELP documents the auto-publish defaults + escape hatch - * so agents can discover them. - * 2. The shared guards (size cap, API-key leak scan, JSON validate) - * reject bad payloads **before** any network call — i.e. the same - * fail-closed posture used by `publish`, but reached via the - * in-memory entry point. - * - * We never exercise the full verifyHtml pipeline here: it needs live - * auth and a live API. The thin wiring inside verifyHtml is: - * - * if (publishAfter) { await publishInMemory(...); } - * - * — where `publishAfter` is true unless --no-publish is passed. - */ - -import { describe, expect, it } from "@jest/globals"; -import { normalizeShortFlags } from "../cli/cliUtils.js"; -import { VERIFY_HELP } from "../cli/commands.js"; -import { API_KEY_LEAK_RE, MAX_HTML_BYTES, MAX_JSON_BYTES, publishInMemory, resolveVisibility } from "../cli/publish.js"; - -describe("verify auto-publish help surface", () => { - it("VERIFY_HELP documents the --no-publish opt-out", () => { - expect(VERIFY_HELP).toContain("--no-publish"); - }); - - it("VERIFY_HELP lists --vis for the publish visibility knob", () => { - expect(VERIFY_HELP).toContain("--vis"); - expect(VERIFY_HELP).toContain("--visibility"); - }); - - it("VERIFY_HELP advertises private as the default visibility", () => { - expect(VERIFY_HELP).toMatch(/default: private/); - }); - - it("VERIFY_HELP shows a --no-publish example so agents can copy it", () => { - expect(VERIFY_HELP).toMatch(/verify --md .*--no-publish/); - }); -}); - -describe("publishInMemory fail-closed guards (shared by verify --pub and publish)", () => { - const MINIMAL_JSON = JSON.stringify({ verifications: { abc: { status: "found" } } }); - - it("rejects HTML containing a DeepCitation API key", async () => { - const html = "leaked sk-dc-abcdefghijklmn01 in page"; - await expect( - publishInMemory({ - html, - verifyResponseJson: MINIMAL_JSON, - visibility: "unlisted", - }), - ).rejects.toThrow(/API key/); - }); - - it("rejects HTML larger than the MAX_HTML_BYTES cap", async () => { - // Build a string just over the cap without allocating a 10MB regex target. - const html = "x".repeat(MAX_HTML_BYTES + 1); - await expect( - publishInMemory({ - html, - verifyResponseJson: MINIMAL_JSON, - visibility: "unlisted", - }), - ).rejects.toThrow(/HTML exceeds/); - }); - - it("rejects verify-response.json larger than the MAX_JSON_BYTES cap", async () => { - const json = "x".repeat(MAX_JSON_BYTES + 1); - await expect( - publishInMemory({ - html: "ok", - verifyResponseJson: json, - visibility: "unlisted", - }), - ).rejects.toThrow(/verify-response\.json exceeds/); - }); - - it("rejects invalid JSON bodies", async () => { - await expect( - publishInMemory({ - html: "ok", - verifyResponseJson: "not json at all", - visibility: "unlisted", - }), - ).rejects.toThrow(/not valid JSON/); - }); -}); - -describe("verify backward compat: --pub / --publish are no-op aliases", () => { - it("normalizeShortFlags still maps --pub → --publish (alias preserved)", () => { - expect(normalizeShortFlags(["--pub"])).toEqual(["--publish"]); - }); - - it("resolveVisibility with no value returns private (auto-publish default)", () => { - expect(resolveVisibility(undefined, VERIFY_HELP)).toBe("private"); - }); -}); - -describe("API_KEY_LEAK_RE regression guard", () => { - it("matches production-length DeepCitation keys", () => { - expect(API_KEY_LEAK_RE.test("sk-dc-abcdefghijklmn01")).toBe(true); - }); - - it("does not flag shorter lookalikes (avoid over-eager strips)", () => { - expect(API_KEY_LEAK_RE.test("sk-dc-short")).toBe(false); - }); -}); diff --git a/src/__tests__/markdownToHtml.test.ts b/src/__tests__/markdownToHtml.test.ts index 40e74dff..12c913f8 100644 --- a/src/__tests__/markdownToHtml.test.ts +++ b/src/__tests__/markdownToHtml.test.ts @@ -1,10 +1,5 @@ import { describe, expect, it } from "@jest/globals"; -import { - AUDIENCE_PRESETS, - buildCdnComparisonShowcaseHtml, - markdownToHtml, - wrapCitationMarkers, -} from "../cli/markdownToHtml.js"; +import { buildCdnComparisonShowcaseHtml, markdownToHtml, wrapCitationMarkers } from "../cli/markdownToHtml.js"; // ── wrapCitationMarkers ─────────────────────────────────────────── @@ -301,36 +296,6 @@ describe("buildCdnComparisonShowcaseHtml", () => { }); }); -// ── markdownToHtml — audience presets ───────────────────────────── - -describe("markdownToHtml audience presets", () => { - it("exports all five audience presets", () => { - expect(AUDIENCE_PRESETS).toEqual(["general", "executive", "technical", "legal", "medical"]); - }); - - it("uses narrower width for executive audience", () => { - const md = "# Report\n\n## Section\n\nContent."; - const executive = markdownToHtml(md, { style: "report", audience: "executive" }); - const general = markdownToHtml(md, { style: "report", audience: "general" }); - expect(executive).toContain("720px"); - expect(general).toContain("960px"); - }); - - it("collapses details for executive audience", () => { - const md = "# Report\n\n## Key Findings\n\nImportant.\n\n## Details\n\nMore."; - const executive = markdownToHtml(md, { style: "report", audience: "executive" }); - // executive tier2Open is false, so no "open" attribute - expect(executive).toContain("
"); - expect(executive).not.toContain("
"); - }); - - it("expands details for general audience", () => { - const md = "# Report\n\n## Key Findings\n\nImportant.\n\n## Details\n\nMore."; - const general = markdownToHtml(md, { style: "report", audience: "general" }); - expect(general).toContain("
"); - }); -}); - // ── markdownToHtml — report body structure ──────────────────────── describe("markdownToHtml report body (progressive disclosure)", () => { @@ -511,3 +476,83 @@ describe("markdownToHtml — §7 extraction-script structure regressions", () => expect(result).not.toMatch(/interest rate<\/strong>\s*/); }); }); + +// ── Header claim + model ────────────────────────────────────────── + +describe("markdownToHtml header — claim & model", () => { + it("renders a claim card when claim is provided", () => { + const result = markdownToHtml("# T\nbody", { claim: "Did revenue exceed $4B?" }); + expect(result).toContain('class="dc-claim"'); + expect(result).toContain(">CLAIM<"); + expect(result).toContain("Did revenue exceed $4B?"); + }); + + it("omits the claim card when claim is absent", () => { + const result = markdownToHtml("# T\nbody", {}); + expect(result).not.toContain('
CLAIM<"); + }); + + it("suppresses a whitespace-only claim", () => { + const result = markdownToHtml("# T\nbody", { claim: " " }); + expect(result).not.toContain('
CLAIM<"); + }); + + it("escapes HTML in the claim", () => { + const result = markdownToHtml("# T\nbody", { claim: "" }); + expect(result).toContain("<script>"); + expect(result).not.toContain("", idIdx); + if (closeIdx === -1) break; + const end = closeIdx + "".length; + // Consume trailing whitespace + let ws = end; + while ( + ws < result.length && + (result[ws] === " " || result[ws] === "\t" || result[ws] === "\n" || result[ws] === "\r") + ) + ws++; + result = result.slice(0, tagStart) + result.slice(ws); hadExisting = true; - // Reset lastIndex since we tested before replacing - pattern.lastIndex = 0; - result = result.replace(pattern, ""); } } + // Strip plain ", contentStart); + if (closeIdx === -1) break; + const content = result.slice(contentStart, closeIdx); + + // Init call: bounded check on first 80 chars (trimStart + literal prefix) + const trimmed = content.trimStart(); + const isInitCall = + trimmed.startsWith("window.DeepCitationPopover") && + /^window\.DeepCitationPopover\s*&&/.test(trimmed.slice(0, 80)); + // CDN bundle: linear regex applied only to bounded content string + const isCdnBundle = + content.includes("window.DeepCitationPopover") && /window\.DeepCitationPopover\s*=/.test(content); + + if (isInitCall || isCdnBundle) { + hadExisting = true; + const end = closeIdx + "".length; + let ws = end; + while ( + ws < result.length && + (result[ws] === " " || result[ws] === "\t" || result[ws] === "\n" || result[ws] === "\r") + ) + ws++; + result = result.slice(0, scriptStart) + result.slice(ws); + // Don't advance pos — content was removed at this position + } else { + pos = contentStart; + } } return { html: result, hadExisting }; @@ -223,18 +259,79 @@ export function autoFixDisplayLabels( verifications: Record, ): { html: string; log: string[] } { const log: string[] = []; - const elementRe = /<([a-zA-Z][a-zA-Z0-9]*)[^>]*\sdata-citation-key="([^"]+)"([^>]*)>([\s\S]*?)<\/\1>/g; - const fixedHtml = html.replace(elementRe, (fullMatch, _tag, hashedKey, rest, content) => { - // Skip if data-dc-display-label is already set on this element - if (/data-dc-display-label=/.test(rest) || /data-dc-display-label=/.test(fullMatch)) return fullMatch; + // Use string scanning instead of regex on the full HTML to avoid ReDoS. + // The pattern [^>]*\s...[^>]* applied to uncontrolled input is polynomial. + const attrMarker = ' data-citation-key="'; + const parts: string[] = []; + let lastEnd = 0; + let searchPos = 0; + + while (true) { + const attrIdx = html.indexOf(attrMarker, searchPos); + if (attrIdx === -1) break; + + // Find the enclosing tag's opening < (must not be a closing tag) + const tagStart = html.lastIndexOf("<", attrIdx); + if (tagStart === -1 || tagStart < lastEnd || html[tagStart + 1] === "/") { + searchPos = attrIdx + 1; + continue; + } + + // Find the end of the opening tag + const tagClose = html.indexOf(">", attrIdx); + if (tagClose === -1) { + searchPos = attrIdx + 1; + continue; + } + + // Extract tag name from between < and first whitespace or > + const afterAngle = tagStart + 1; + const tagHeaderSlice = html.slice(afterAngle, tagClose); + const tagNameEnd = tagHeaderSlice.search(/[\s/>]/); + const tagName = tagNameEnd >= 0 ? tagHeaderSlice.slice(0, tagNameEnd) : tagHeaderSlice; + if (!/^[a-zA-Z][a-zA-Z0-9]*$/.test(tagName)) { + searchPos = tagClose + 1; + continue; + } + + // Extract citation key (bounded between the attribute marker and the next quote) + const keyStart = attrIdx + attrMarker.length; + const keyEnd = html.indexOf('"', keyStart); + if (keyEnd === -1) { + searchPos = attrIdx + 1; + continue; + } + const hashedKey = html.slice(keyStart, keyEnd); + + // Skip if opening tag already has data-dc-display-label + const openingTag = html.slice(tagStart, tagClose + 1); + if (openingTag.includes("data-dc-display-label=")) { + searchPos = tagClose + 1; + continue; + } const sourceMatch = (verifications[hashedKey] as { citation?: { sourceMatch?: string } } | undefined)?.citation ?.sourceMatch; - if (!sourceMatch) return fullMatch; + if (!sourceMatch) { + searchPos = tagClose + 1; + continue; + } + + // Find closing tag (uses first occurrence — same behaviour as the original lazy regex) + const closeTag = ``; + const contentStart = tagClose + 1; + const closeIdx = html.indexOf(closeTag, contentStart); + if (closeIdx === -1) { + searchPos = tagClose + 1; + continue; + } + + const content = html.slice(contentStart, closeIdx); // Strip inner HTML tags to get approximate visible text. // Loop until stable to handle nested fragments like ipt>. - let visibleText = content as string; + // Applied to bounded content string — no ReDoS risk. + let visibleText = content; let prev: string; do { prev = visibleText; @@ -242,19 +339,34 @@ export function autoFixDisplayLabels( } while (visibleText !== prev); visibleText = visibleText.replace(/\s+/g, " ").trim(); - if (!visibleText || visibleText.length > 80) return fullMatch; - if (sourceMatch.toLowerCase().includes(visibleText.toLowerCase())) return fullMatch; + const matchEnd = closeIdx + closeTag.length; + + if (!visibleText || visibleText.length > 80 || sourceMatch.toLowerCase().includes(visibleText.toLowerCase())) { + searchPos = tagClose + 1; + continue; + } const escaped = visibleText.replace(/"/g, """); log.push( ` [${hashedKey.slice(0, 8)}…] claimText="${visibleText}" sourceMatch="${sourceMatch.slice(0, 60)}${sourceMatch.length > 60 ? "…" : ""}"`, ); - return fullMatch.replace( - `data-citation-key="${hashedKey}"`, - `data-citation-key="${hashedKey}" data-dc-display-label="${escaped}"`, + + // Emit unchanged HTML up to this element, then the patched element + parts.push(html.slice(lastEnd, tagStart)); + const fullMatch = html.slice(tagStart, matchEnd); + parts.push( + fullMatch.replace( + `data-citation-key="${hashedKey}"`, + `data-citation-key="${hashedKey}" data-dc-display-label="${escaped}"`, + ), ); - }); - return { html: fixedHtml, log }; + lastEnd = matchEnd; + searchPos = matchEnd; + } + + if (parts.length === 0) return { html, log }; + parts.push(html.slice(lastEnd)); + return { html: parts.join(""), log }; } /** Options for {@link injectCdnRuntime}. */ diff --git a/src/vanilla/runtime/cdn.ts b/src/vanilla/runtime/cdn.ts index 329b85d7..b0d5dab0 100644 --- a/src/vanilla/runtime/cdn.ts +++ b/src/vanilla/runtime/cdn.ts @@ -9,6 +9,7 @@ import { DefaultPopoverContent } from "../../react/DefaultPopoverContent.js"; import { usePopoverViewState } from "../../react/hooks/usePopoverViewState.js"; import { usePrefersReducedMotion } from "../../react/hooks/usePrefersReducedMotion.js"; import { sanitizeUrl } from "../../react/urlUtils.js"; +import { isViewTransitioning } from "../../react/viewTransition.js"; import { canChildScrollVertically, findPageScrollEl } from "../../shared/scroll.js"; import type { Citation } from "../../types/citation.js"; import type { PageImage, Verification } from "../../types/verification.js"; @@ -84,6 +85,7 @@ let activeSelector = "[data-citation-key]"; let activeIndicatorVariant: CdnIndicatorVariant = "icon"; let dismissController: AbortController | null = null; let positionRafId = 0; +let repositionGen = 0; let resizeObserver: ResizeObserver | null = null; let lastCoords = { x: NaN, y: NaN }; let scrollPassthroughController: AbortController | null = null; @@ -283,9 +285,20 @@ function reposition(): void { contentEl.style.translate = `${dx}px ${dy}px`; } } +function deferReposition(retriesLeft: number, gen: number): void { + positionRafId = requestAnimationFrame(() => { + if (gen !== repositionGen) return; // stale chain — a newer scheduleReposition fired + if (isViewTransitioning() && retriesLeft > 0) { + deferReposition(retriesLeft - 1, gen); + return; + } + reposition(); + }); +} function scheduleReposition(): void { + repositionGen++; cancelAnimationFrame(positionRafId); - positionRafId = requestAnimationFrame(reposition); + deferReposition(30, repositionGen); } function startPositionTracking(): void { stopPositionTracking(); diff --git a/teardown-v14.png b/teardown-v14.png deleted file mode 100644 index f38c2703..00000000 Binary files a/teardown-v14.png and /dev/null differ diff --git a/tests/playwright/specs/annotationOverlay.spec.tsx b/tests/playwright/specs/annotationOverlay.spec.tsx index 4b4b7692..e57115e2 100644 --- a/tests/playwright/specs/annotationOverlay.spec.tsx +++ b/tests/playwright/specs/annotationOverlay.spec.tsx @@ -220,7 +220,7 @@ test.describe("Annotation Overlay — dismiss", () => { await overlayDismiss.click(); await expect(popover.locator("[data-dc-annotation-overlay]").filter({ visible: true })).toHaveCount(0); - await expect.poll(async () => scrollToBtn.getAttribute("data-dc-locate-pulse-stage")).toMatch(/grow|settle/); + await expect.poll(async () => scrollToBtn.getAttribute("data-dc-locate-pulse-stage")).toMatch(/flash|settle/); await expect.poll(async () => scrollToBtn.getAttribute("data-dc-locate-pulse-stage")).toBeNull(); }); @@ -248,7 +248,7 @@ test.describe("Annotation Overlay — dismiss", () => { await popover.locator("[data-dc-overlay-dismiss]").filter({ visible: true }).click(); await expect(overlay).toHaveCount(0); - await expect.poll(async () => scrollToBtn.getAttribute("data-dc-locate-pulse-stage")).toMatch(/grow|settle/); + await expect.poll(async () => scrollToBtn.getAttribute("data-dc-locate-pulse-stage")).toMatch(/flash|settle/); }); }); diff --git a/tsconfig.json b/tsconfig.json index 316e531e..1d74aa86 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -14,11 +14,27 @@ "skipLibCheck": true, "forceConsistentCasingInFileNames": true, "jsx": "react-jsx", - "types": ["node", "react", "react-dom", "jest", "@testing-library/jest-dom"], + "types": [ + "node", + "react", + "react-dom", + "jest", + "@testing-library/jest-dom" + ], "paths": { - "@/*": ["./src/*"] + "@/*": [ + "./src/*" + ] } }, - "include": ["src/**/*"], - "exclude": ["node_modules", "lib", "src/__tests__", "src/react/testing", "tests"] -} + "include": [ + "src/**/*" + ], + "exclude": [ + "node_modules", + "lib", + "src/**/__tests__", + "src/react/testing", + "tests" + ] +} \ No newline at end of file