@@ -972,9 +1055,24 @@ export default function ChatClient() {
Source Details
+
+ Retrieved passages, evidence-chain paths, and citation verification details.
+
- {selectedResponse && }
+ {selectedResponse && (
+ {
+ const citationDocId = citation.doc_id || selectedResponse.doc_id || selectedDoc;
+ if (!citationDocId || citationDocId === "__all__") {
+ toast.error("Citation does not include a resolvable document ID.");
+ return;
+ }
+ handleCitationClick(citation, citationDocId);
+ }}
+ />
+ )}
diff --git a/frontend/src/components/pdf-viewer/geometry.test.ts b/frontend/src/components/pdf-viewer/geometry.test.ts
new file mode 100644
index 0000000..ad8e69c
--- /dev/null
+++ b/frontend/src/components/pdf-viewer/geometry.test.ts
@@ -0,0 +1,28 @@
+import { describe, expect, it } from "vitest";
+
+import { normalizedRectToViewport } from "@/components/pdf-viewer/geometry";
+
+describe("normalized evidence geometry", () => {
+ it("maps normalized top-left coordinates directly to the rendered viewport", () => {
+ const result = normalizedRectToViewport(
+ { x0: 0.1, y0: 0.25, x1: 0.6, y1: 0.3 },
+ 1000,
+ 800,
+ );
+ expect(result.x).toBeCloseTo(100);
+ expect(result.y).toBeCloseTo(200);
+ expect(result.width).toBeCloseTo(500);
+ expect(result.height).toBeCloseTo(40);
+ });
+
+ it("scales without drifting when the PDF is zoomed", () => {
+ const rect = { x0: 0.125, y0: 0.2, x1: 0.5, y1: 0.24 };
+ const at100 = normalizedRectToViewport(rect, 612, 792);
+ const at200 = normalizedRectToViewport(rect, 1224, 1584);
+
+ expect(at200.x).toBeCloseTo(at100.x * 2);
+ expect(at200.y).toBeCloseTo(at100.y * 2);
+ expect(at200.width).toBeCloseTo(at100.width * 2);
+ expect(at200.height).toBeCloseTo(at100.height * 2);
+ });
+});
diff --git a/frontend/src/components/pdf-viewer/geometry.ts b/frontend/src/components/pdf-viewer/geometry.ts
new file mode 100644
index 0000000..eab3507
--- /dev/null
+++ b/frontend/src/components/pdf-viewer/geometry.ts
@@ -0,0 +1,21 @@
+import type { NormalizedRect } from "@/lib/api";
+
+export interface ViewportRect {
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+}
+
+export function normalizedRectToViewport(
+ rect: NormalizedRect,
+ viewportWidth: number,
+ viewportHeight: number,
+): ViewportRect {
+ return {
+ x: rect.x0 * viewportWidth,
+ y: rect.y0 * viewportHeight,
+ width: (rect.x1 - rect.x0) * viewportWidth,
+ height: (rect.y1 - rect.y0) * viewportHeight,
+ };
+}
diff --git a/frontend/src/components/pdf-viewer/index.tsx b/frontend/src/components/pdf-viewer/index.tsx
index cdd7125..20c78ab 100644
--- a/frontend/src/components/pdf-viewer/index.tsx
+++ b/frontend/src/components/pdf-viewer/index.tsx
@@ -7,20 +7,21 @@
* Uses pdfjs-dist for PDF rendering with custom highlight overlays.
*
* Highlight rendering:
- * 1. Verified bbox overlays
- * 2. Verified exact quote overlays
+ * 1. Server-verified normalized source rectangles
+ * 2. Legacy locators, when explicitly identified as non-verified
*/
import React, { useEffect, useRef, useState, useCallback } from "react";
import * as pdfjsLib from "pdfjs-dist";
import { Button } from "@/components/ui/button";
-import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
+import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
+import { normalizedRectToViewport } from "@/components/pdf-viewer/geometry";
import {
ChevronLeft,
ChevronRight,
@@ -64,6 +65,21 @@ export interface CitationHighlight {
type: "text_offsets";
start: number;
end: number;
+ }
+ | {
+ type: "rects";
+ coordinate_system: "normalized_top_left";
+ rects: Array<{
+ x0: number;
+ y0: number;
+ x1: number;
+ y1: number;
+ }>;
+ page_size?: {
+ width: number;
+ height: number;
+ };
+ page_rotation?: 0 | 90 | 180 | 270;
};
quote_text: string;
confidence?: number;
@@ -191,6 +207,15 @@ export function PDFViewer({
[],
);
+ const normalizedToViewportRect = useCallback(
+ (
+ viewport: pdfjsLib.PageViewport,
+ rect: { x0: number; y0: number; x1: number; y1: number },
+ ): TextHighlightRect =>
+ normalizedRectToViewport(rect, viewport.width, viewport.height),
+ [],
+ );
+
const findExactQuoteHighlights = useCallback(async (
page: pdfjsLib.PDFPageProxy,
viewport: pdfjsLib.PageViewport,
@@ -350,7 +375,11 @@ export function PDFViewer({
const nextBboxHighlights: TextHighlightRect[] = [];
for (const item of highlightsForCurrentPage) {
- if (item.locator.type === "bbox") {
+ if (item.locator.type === "rects") {
+ nextBboxHighlights.push(
+ ...item.locator.rects.map((rect) => normalizedToViewportRect(viewport, rect)),
+ );
+ } else if (item.locator.type === "bbox") {
nextBboxHighlights.push(toViewportRect(viewport, item.locator.bbox));
} else if (item.quote_text) {
const exactRects = await findExactQuoteHighlights(page, viewport, item.quote_text);
@@ -374,7 +403,15 @@ export function PDFViewer({
};
renderPage();
- }, [pdf, currentPage, scale, highlightsForCurrentPage, findExactQuoteHighlights, toViewportRect]);
+ }, [
+ pdf,
+ currentPage,
+ scale,
+ highlightsForCurrentPage,
+ findExactQuoteHighlights,
+ normalizedToViewportRect,
+ toViewportRect,
+ ]);
// Navigation handlers
const goToFirstPage = () => { setCurrentPage(1); setPageInput("1"); };
@@ -408,6 +445,7 @@ export function PDFViewer({
const resetZoom = () => setScale(1.0);
const hasHighlights = highlights.length > 0;
+ const hasDirectRectLocator = highlights.some((item) => item.locator.type === "rects");
const hasBboxLocator = highlights.some((item) => item.locator.type === "bbox");
const visibleFailureMessage = !hasHighlights
? (evidenceFailureMessage || null)
@@ -462,11 +500,20 @@ export function PDFViewer({
const getHighlightBadge = () => {
if (!hasHighlights) return null;
+ if (hasDirectRectLocator) {
+ return (
+
+
+ Verified Evidence
+
+ );
+ }
+
if (hasBboxLocator) {
return (
- Verified (BBox)
+ Approximate Location
);
}
@@ -475,7 +522,7 @@ export function PDFViewer({
return (
- Verified (Text)
+ Canonical Text Match
);
}
@@ -502,6 +549,9 @@ export function PDFViewer({
>
{/* Header */}
+
+ Original PDF source with independently verified evidence highlights.
+
diff --git a/frontend/src/components/source-viewer/citation-routing.test.ts b/frontend/src/components/source-viewer/citation-routing.test.ts
new file mode 100644
index 0000000..3eb1c7d
--- /dev/null
+++ b/frontend/src/components/source-viewer/citation-routing.test.ts
@@ -0,0 +1,47 @@
+import { describe, expect, it } from "vitest";
+
+import type { Citation } from "@/lib/api";
+import {
+ findCitationForInlineRef,
+ INLINE_CITATION_PATTERN,
+} from "@/components/source-viewer/citation-routing";
+
+const citations: Citation[] = [
+ {
+ citation_id: "C1",
+ node_id: "node-a",
+ doc_id: "doc-1",
+ page_no: 14,
+ label: "Table 2",
+ },
+ {
+ citation_id: "C2",
+ node_id: "node-b",
+ doc_id: "doc-1",
+ page_no: 14,
+ },
+];
+
+describe("stable citation routing", () => {
+ it("routes a stable citation ID to exactly one evidence record", () => {
+ expect(findCitationForInlineRef(citations, "C2")?.node_id).toBe("node-b");
+ });
+
+ it("supports exact legacy labels without suffix guessing", () => {
+ expect(findCitationForInlineRef(citations, "Table 2")?.node_id).toBe("node-a");
+ expect(findCitationForInlineRef(citations, "2")).toBeUndefined();
+ });
+
+ it("never substitutes the first citation on a matching page", () => {
+ expect(findCitationForInlineRef(citations, "page:14")).toBeUndefined();
+ expect(findCitationForInlineRef(citations, "seed:14")).toBeUndefined();
+ });
+
+ it("recognizes V2 and legacy inline tokens", () => {
+ const answer = "Claim [C1]. Legacy [page: 14].";
+ expect([...answer.matchAll(INLINE_CITATION_PATTERN)].map((m) => m[1])).toEqual([
+ "C1",
+ "page: 14",
+ ]);
+ });
+});
diff --git a/frontend/src/components/source-viewer/citation-routing.ts b/frontend/src/components/source-viewer/citation-routing.ts
new file mode 100644
index 0000000..beecf68
--- /dev/null
+++ b/frontend/src/components/source-viewer/citation-routing.ts
@@ -0,0 +1,28 @@
+import type { Citation } from "@/lib/api";
+
+export const INLINE_CITATION_PATTERN = /\[(C\d+|(?:seed|adjacent|page):\s*\d+)\]/gi;
+
+export function normalizeInlineRef(value: string): string {
+ return value.toLowerCase().replace(/[\[\]\s]+/g, "");
+}
+
+export function findCitationForInlineRef(
+ citations: Citation[] | undefined,
+ rawRef: string,
+): Citation | undefined {
+ if (!citations?.length) return undefined;
+
+ const normalizedRef = normalizeInlineRef(rawRef);
+ const byStableId = citations.find(
+ (citation) => citation.citation_id?.toLowerCase() === normalizedRef,
+ );
+ if (byStableId) return byStableId;
+
+ // Legacy labels are accepted only on an exact normalized match. Page-number
+ // guessing is intentionally forbidden because multiple nodes may share a page.
+ return citations.find(
+ (citation) =>
+ Boolean(citation.label)
+ && normalizeInlineRef(citation.label as string) === normalizedRef,
+ );
+}
diff --git a/frontend/src/components/source-viewer/evidence.test.ts b/frontend/src/components/source-viewer/evidence.test.ts
index 5f1eac0..698b8fe 100644
--- a/frontend/src/components/source-viewer/evidence.test.ts
+++ b/frontend/src/components/source-viewer/evidence.test.ts
@@ -45,12 +45,14 @@ describe("source-viewer evidence mapping", () => {
citation.evidence_verification = [
{
status: "FOUND",
+ grade: "verified",
matched_locator: { type: "text_offsets", start: 120, end: 158 },
confidence: 1,
reason: "exact_text_offsets_match",
},
{
status: "FOUND",
+ grade: "verified",
matched_locator: {
type: "bbox",
bbox: { x0: 12, y0: 44, x1: 180, y1: 78 },
@@ -94,6 +96,30 @@ describe("source-viewer evidence mapping", () => {
expect(getEvidenceFailureMessage(citation)).toBe("Evidence not found on cited page");
});
+ it("fails closed when a legacy verification has no explicit verified grade", () => {
+ const citation = makeCitation();
+ citation.evidence_spans = [
+ {
+ doc_id: "doc-1",
+ page_index: 14,
+ page_index_base: 1,
+ quote_text: "Legacy quote",
+ locator: { type: "text_offsets", start: 10, end: 22 },
+ confidence: 1,
+ },
+ ];
+ citation.evidence_verification = [
+ {
+ status: "FOUND",
+ matched_locator: { type: "text_offsets", start: 10, end: 22 },
+ confidence: 1,
+ reason: "legacy_response_without_grade",
+ },
+ ];
+
+ expect(getVerifiedEvidenceHighlights(citation)).toEqual([]);
+ });
+
it("builds deterministic merged canonical highlight ranges from verified offsets", () => {
const citation = makeCitation();
citation.evidence_spans = [
@@ -125,18 +151,21 @@ describe("source-viewer evidence mapping", () => {
citation.evidence_verification = [
{
status: "FOUND",
+ grade: "verified",
matched_locator: { type: "text_offsets", start: 5, end: 12 },
confidence: 1,
reason: "exact_text_offsets_match",
},
{
status: "FOUND",
+ grade: "verified",
matched_locator: { type: "text_offsets", start: 12, end: 16 },
confidence: 1,
reason: "exact_text_offsets_match",
},
{
status: "FOUND",
+ grade: "verified",
matched_locator: { type: "text_offsets", start: 40, end: 45 },
confidence: 1,
reason: "exact_text_offsets_match",
@@ -149,4 +178,57 @@ describe("source-viewer evidence mapping", () => {
{ start: 40, end: 45 },
]);
});
+
+ it("uses only V2 verified rectangle records for original-PDF highlighting", () => {
+ const citation = makeCitation();
+ citation.evidence_records = [
+ {
+ schema_version: "2.0",
+ citation_id: "C1",
+ doc_id: "doc-1",
+ document_version: 1,
+ node_id: "node-1",
+ page: 14,
+ exact_quote: "Exact supporting words.",
+ source_hash: "sha256:test",
+ status: "verified",
+ verification_reason: "exact_unique_quote_with_source_rectangles",
+ locator: {
+ type: "rects",
+ coordinate_system: "normalized_top_left",
+ rects: [{ x0: 0.1, y0: 0.2, x1: 0.5, y1: 0.23 }],
+ page_rotation: 0,
+ },
+ confidence: 1,
+ },
+ ];
+
+ const highlights = getVerifiedEvidenceHighlights(citation);
+ expect(highlights).toHaveLength(1);
+ expect(highlights[0].locator.type).toBe("rects");
+ expect(getEvidenceFailureMessage(citation)).toBeNull();
+ });
+
+ it("does not present approximate V2 evidence as verified", () => {
+ const citation = makeCitation();
+ citation.evidence_records = [
+ {
+ schema_version: "2.0",
+ citation_id: "C1",
+ doc_id: "doc-1",
+ document_version: 1,
+ node_id: "node-1",
+ page: 14,
+ exact_quote: "Legacy reconstructed text.",
+ source_hash: "sha256:test",
+ status: "approximate",
+ verification_reason: "exact_quote_match_on_page",
+ locator: { type: "text_offsets", start: 10, end: 36 },
+ confidence: 1,
+ },
+ ];
+
+ expect(getVerifiedEvidenceHighlights(citation)).toEqual([]);
+ expect(getEvidenceFailureMessage(citation)).toContain("approximate");
+ });
});
diff --git a/frontend/src/components/source-viewer/evidence.ts b/frontend/src/components/source-viewer/evidence.ts
index 81d59e3..8232055 100644
--- a/frontend/src/components/source-viewer/evidence.ts
+++ b/frontend/src/components/source-viewer/evidence.ts
@@ -28,6 +28,22 @@ function isValidBbox(locator: EvidenceLocator): locator is Extract
+ record.status === "verified"
+ && record.locator?.type === "rects"
+ && record.locator.rects.length > 0,
+ )
+ .map((record) => ({
+ pageNo: record.page,
+ locator: record.locator!,
+ quoteText: record.exact_quote.trim(),
+ confidence: record.confidence,
+ }));
+ }
+
if (!citation?.evidence_spans?.length) {
return [];
}
@@ -37,7 +53,12 @@ export function getVerifiedEvidenceHighlights(citation?: Citation): VerifiedEvid
for (let i = 0; i < citation.evidence_spans.length; i += 1) {
const span = citation.evidence_spans[i];
const verification = verifications[i];
- if (!verification || verification.status !== "FOUND" || !verification.matched_locator) {
+ if (
+ !verification
+ || verification.status !== "FOUND"
+ || verification.grade !== "verified"
+ || !verification.matched_locator
+ ) {
continue;
}
const locator = verification.matched_locator;
@@ -71,17 +92,22 @@ export function buildCanonicalHighlightRanges(
if (!citation || canonicalTextLength <= 0) {
return [];
}
- const highlights = getVerifiedEvidenceHighlights(citation);
- const ranges = highlights
+ const recordLocators = citation.evidence_records
+ ?.filter((record) => record.status !== "unavailable" && record.locator?.type === "text_offsets")
+ .map((record) => record.locator as Extract);
+ const legacyLocators = getVerifiedEvidenceHighlights(citation)
.filter(
(
h,
): h is VerifiedEvidenceHighlight & { locator: Extract } =>
h.locator.type === "text_offsets",
)
- .map((h) => {
- const start = Math.max(0, Math.min(h.locator.start, canonicalTextLength));
- const end = Math.max(start, Math.min(h.locator.end, canonicalTextLength));
+ .map((h) => h.locator);
+ const locators = recordLocators?.length ? recordLocators : legacyLocators;
+ const ranges = locators
+ .map((locator) => {
+ const start = Math.max(0, Math.min(locator.start, canonicalTextLength));
+ const end = Math.max(start, Math.min(locator.end, canonicalTextLength));
return { start, end };
})
.filter((r) => r.end > r.start)
@@ -111,5 +137,8 @@ export function getEvidenceFailureMessage(citation?: Citation): string | null {
if (highlights.length > 0) {
return null;
}
+ if (citation.evidence_records?.some((record) => record.status === "approximate")) {
+ return "Exact source coordinates unavailable; this citation is approximate";
+ }
return "Evidence not found on cited page";
}
diff --git a/frontend/src/components/source-viewer/html-viewer.tsx b/frontend/src/components/source-viewer/html-viewer.tsx
index 2d32341..ea28efb 100644
--- a/frontend/src/components/source-viewer/html-viewer.tsx
+++ b/frontend/src/components/source-viewer/html-viewer.tsx
@@ -5,7 +5,7 @@ import { AlertCircle, CheckCircle2, FileText } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { ScrollArea } from "@/components/ui/scroll-area";
-import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
+import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { SourceMapResponse, Citation } from "@/lib/api";
import { buildCanonicalHighlightRanges, getEvidenceFailureMessage } from "@/components/source-viewer/evidence";
@@ -102,7 +102,7 @@ export function HTMLSourceViewer({
return <>{fragments}>;
}, [sourceMap?.canonical_text, citation]);
- const resolveStatus = citation?.resolve_status || "unresolved";
+ const resolveStatus = citation?.evidence_status || citation?.resolve_status || "unavailable";
const evidenceFailureMessage = getEvidenceFailureMessage(citation);
const showEvidenceNotFound = Boolean(evidenceFailureMessage);
@@ -114,16 +114,19 @@ export function HTMLSourceViewer({
{title || "Document Source"}
+
+ Canonical document source with citation evidence and verification status.
+
- {resolveStatus === "exact" ? (
+ {resolveStatus === "verified" ? (
- Verified
+ Verified Evidence
- ) : resolveStatus === "fuzzy" ? (
-
Fuzzy Match
+ ) : resolveStatus === "approximate" || resolveStatus === "exact" || resolveStatus === "fuzzy" ? (
+
Approximate Source
) : (
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts
index cc4154e..94aaa9a 100644
--- a/frontend/src/lib/api.ts
+++ b/frontend/src/lib/api.ts
@@ -18,6 +18,7 @@ export type JobStatus =
| "skipped_alias";
export interface Citation {
+ citation_id?: string;
page_no: number;
node_id: string;
doc_id: string;
@@ -41,6 +42,18 @@ export interface Citation {
normalization?: string;
evidence_spans?: EvidenceSpan[];
evidence_verification?: EvidenceVerification[];
+ evidence_status?: EvidenceStatus;
+ verification_status?: EvidenceStatus;
+ evidence_records?: EvidenceRecord[];
+}
+
+export type EvidenceStatus = "verified" | "approximate" | "unavailable";
+
+export interface NormalizedRect {
+ x0: number;
+ y0: number;
+ x1: number;
+ y1: number;
}
export type EvidenceLocator =
@@ -53,6 +66,13 @@ export type EvidenceLocator =
type: "bbox";
bbox: { x0: number; y0: number; x1: number; y1: number };
page_size?: { width: number; height: number };
+ }
+ | {
+ type: "rects";
+ coordinate_system: "normalized_top_left";
+ rects: NormalizedRect[];
+ page_size?: { width: number; height: number };
+ page_rotation?: 0 | 90 | 180 | 270;
};
export interface EvidenceSpan {
@@ -67,6 +87,7 @@ export interface EvidenceSpan {
export interface EvidenceVerification {
status: "FOUND" | "NOT_FOUND";
+ grade?: "verified" | "approximate" | "unavailable";
matched_locator?: EvidenceLocator | null;
confidence: number;
reason: string;
@@ -74,6 +95,22 @@ export interface EvidenceVerification {
page_index?: number;
}
+export interface EvidenceRecord {
+ schema_version: "2.0";
+ citation_id: string;
+ claim_id?: string | null;
+ doc_id: string;
+ document_version: number;
+ node_id: string;
+ page: number;
+ exact_quote: string;
+ source_hash: string;
+ status: EvidenceStatus;
+ verification_reason: string;
+ locator?: EvidenceLocator | null;
+ confidence: number;
+}
+
export interface SelectorBundle {
schema_version: string;
node_id: string;
@@ -126,6 +163,12 @@ export interface SourceManifestResponse {
selector_coverage: {
nodes_with_selectors: number;
};
+ evidence_v2_coverage?: {
+ eligible_text_nodes: number;
+ nodes_with_source_spans: number;
+ nodes_with_exact_source_spans: number;
+ };
+ evidence_v2_reingest_recommended?: boolean;
backfill_needed: boolean;
}
@@ -170,6 +213,48 @@ export interface AskResponse {
clarify_options?: string[] | null;
propagation_safety_mode: boolean;
propagation_safety_audit?: Record | null;
+ evidence_chain?: {
+ enabled: boolean;
+ mode: "off" | "auto" | "on";
+ applied: boolean;
+ time_ms: number;
+ audit?: {
+ scoring_version: string;
+ route: {
+ applied: boolean;
+ mode: "off" | "auto" | "on";
+ score: number;
+ reasons: string[];
+ };
+ applied: boolean;
+ fallback_used: boolean;
+ fallback_reason?: string | null;
+ candidate_count: number;
+ edge_count: number;
+ iterations: number;
+ converged: boolean;
+ selected_nodes: Array<{
+ node_id: string;
+ final_score: number;
+ propagation_score: number;
+ query_relevance: number;
+ seed_relevance: number;
+ is_seed: boolean;
+ }>;
+ paths: Array<{
+ path_id: string;
+ node_ids: string[];
+ relevance_score: number;
+ edges: Array<{
+ from_node_id: string;
+ to_node_id: string;
+ edge_type: string;
+ weight: number;
+ }>;
+ }>;
+ ordered_node_ids: string[];
+ } | null;
+ } | null;
original_question?: string | null;
llm_rewrite?: {
used_llm?: boolean;
@@ -469,6 +554,7 @@ export async function askQuestion(params: {
top_k?: number;
chat_history?: Array<{ role: "user" | "assistant"; content: string }>;
mode?: "standard" | "propagation_safety";
+ evidence_chain_mode?: "off" | "auto" | "on";
}): Promise {
const response = await fetchWithTimeout(`${API_BASE_URL}/v1/qa/ask`, {
method: "POST",