diff --git a/package.json b/package.json index d442926..d004a0e 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,8 @@ ".": "./src/index.ts", "./selectors": "./src/selectors/index.ts", "./types": "./src/types.ts", - "./pdf": "./src/pdf/index.ts" + "./pdf": "./src/pdf/index.ts", + "./dom": "./src/dom/index.ts" }, "scripts": { "test": "vitest run", diff --git a/src/dom/dom-path.test.ts b/src/dom/dom-path.test.ts new file mode 100644 index 0000000..03c98ac --- /dev/null +++ b/src/dom/dom-path.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { Window } from "happy-dom"; +import { captureFromRange, domPathToNode, nodeAtPath } from "./dom-path"; + +describe("dom-path", () => { + it("round-trips a child-index path", () => { + const win = new Window(); + const doc = win.document; + doc.body.innerHTML = "

Hello world

"; + const bold = doc.querySelector("b")!; + const root = doc.body as unknown as Node; + const path = domPathToNode(root, bold as unknown as Node); + expect(path).toBeDefined(); + expect(nodeAtPath(root, path!)).toBe(bold); + }); + + it("captures a range as DomSelectionCapture", () => { + const win = new Window(); + const doc = win.document; + doc.body.innerHTML = "

The quick brown fox

"; + const textNode = doc.querySelector("p")!.firstChild!; + const range = doc.createRange(); + range.setStart(textNode, 4); + range.setEnd(textNode, 14); + const capture = captureFromRange(doc.body as unknown as Node, range as unknown as Range, "quick brown"); + expect(capture?.kind).toBe("dom"); + expect(capture?.text).toBe("quick brown"); + expect(capture?.startPath).toBeDefined(); + }); +}); \ No newline at end of file diff --git a/src/dom/dom-path.ts b/src/dom/dom-path.ts new file mode 100644 index 0000000..79fb4ca --- /dev/null +++ b/src/dom/dom-path.ts @@ -0,0 +1,58 @@ +import type { DomNodePath } from "@citation-evidence/engine/shared"; +import type { DomSelectionCapture } from "../types"; + +/** Walk a DOM tree and return the child-index path to `node`. */ +export function domPathToNode(root: Node, node: Node): DomNodePath | null { + if (root === node) return []; + const path: number[] = []; + + function walk(current: Node, target: Node): boolean { + if (current === target) return true; + const children = childNodes(current); + for (let i = 0; i < children.length; i++) { + path.push(i); + if (walk(children[i]!, target)) return true; + path.pop(); + } + return false; + } + + return walk(root, node) ? path : null; +} + +/** Resolve a child-index path from `root` to a node, or null if invalid. */ +export function nodeAtPath(root: Node, path: DomNodePath): Node | null { + let current: Node = root; + for (const index of path) { + const children = childNodes(current); + const child = children[index]; + if (!child) return null; + current = child; + } + return current; +} + +function childNodes(node: Node): Node[] { + return Array.from(node.childNodes); +} + +/** Build a `DomSelectionCapture` from a browser `Range` relative to `root`. */ +export function captureFromRange( + root: Node, + range: Range, + text: string, + structuralPath?: DomSelectionCapture["structuralPath"], +): DomSelectionCapture | null { + const startPath = domPathToNode(root, range.startContainer); + const endPath = domPathToNode(root, range.endContainer); + if (!startPath || !endPath) return null; + return { + kind: "dom", + text, + startPath, + startOffset: range.startOffset, + endPath, + endOffset: range.endOffset, + ...(structuralPath ? { structuralPath } : {}), + }; +} \ No newline at end of file diff --git a/src/dom/html-viewer-adapter.tsx b/src/dom/html-viewer-adapter.tsx new file mode 100644 index 0000000..d6f43a2 --- /dev/null +++ b/src/dom/html-viewer-adapter.tsx @@ -0,0 +1,156 @@ +/** + * HTML/Markdown viewer adapter for non-paginated documents. + * + * Renders canonical HTML, captures DOM selections, and supports scroll/highlight + * via resolved text positions. Viewer-specific code stays under `src/dom/`. + */ + +import { + useCallback, + useEffect, + useMemo, + useRef, + type ReactNode, +} from "react"; + +import type { DocumentRepresentation, Selector } from "@citation-evidence/engine/shared"; +import { createSelectors, resolveSelectors } from "../selectors"; +import type { + AnchorResolution, + DomSelectionCapture, + HighlightRenderOptions, + ResolvedAnchorTarget, +} from "../types"; +import { captureFromRange } from "./dom-path"; + +export interface HtmlViewerDebugOptions { + readonly logSelections?: boolean; +} + +export interface HtmlViewerAdapterProps { + readonly html: string; + readonly representation: DocumentRepresentation; + readonly storedAnnotations?: readonly StoredHtmlAnnotation[]; + readonly scrollToAnnotationId?: string; + readonly activeAnnotationId?: string | null; + readonly debug?: HtmlViewerDebugOptions; + onSelectionCaptured?(capture: DomSelectionCapture, selectors: Selector[]): void; + onHighlightClicked?(annotationId: string): void; +} + +export interface StoredHtmlAnnotation { + readonly id: string; + readonly text: string; + readonly selectors: readonly Selector[]; +} + +export function HtmlViewerAdapter(props: HtmlViewerAdapterProps): ReactNode { + const { + html, + representation, + storedAnnotations = [], + scrollToAnnotationId, + activeAnnotationId, + debug, + onSelectionCaptured, + onHighlightClicked, + } = props; + + const rootRef = useRef(null); + + const handleMouseUp = useCallback(() => { + const root = rootRef.current; + if (!root || typeof window === "undefined") return; + const selection = window.getSelection(); + if (!selection || selection.isCollapsed || selection.rangeCount === 0) return; + const range = selection.getRangeAt(0); + if (!root.contains(range.commonAncestorContainer)) return; + const text = selection.toString(); + if (!text) return; + const capture = captureFromRange(root, range, text); + if (!capture) return; + const selectors = createSelectors(capture, representation); + if (debug?.logSelections) { + console.log("[evidence-anchor/dom] selection", { capture, selectors }); + } + onSelectionCaptured?.(capture, selectors); + }, [representation, debug, onSelectionCaptured]); + + const renderedHtml = useMemo(() => { + let body = html; + for (const ann of storedAnnotations) { + const resolution = resolveSelectors(ann.selectors, representation); + const span = resolution.candidates[0]?.textPosition; + if (!span || !representation.canonicalText) continue; + const quote = representation.canonicalText.slice(span.start, span.end); + if (!quote) continue; + const mark = `${quote}`; + body = body.replace(quote, mark); + } + return body; + }, [html, storedAnnotations, representation, activeAnnotationId]); + + useEffect(() => { + if (!scrollToAnnotationId || !rootRef.current) return; + const mark = rootRef.current.querySelector( + `[data-highlight-id="${CSS.escape(scrollToAnnotationId)}"]`, + ); + mark?.scrollIntoView({ block: "center", behavior: "smooth" }); + }, [scrollToAnnotationId, renderedHtml]); + + return ( +
{ + const target = (e.target as HTMLElement).closest("[data-highlight-id]"); + if (target instanceof HTMLElement && target.dataset.highlightId) { + onHighlightClicked?.(target.dataset.highlightId); + } + }} + dangerouslySetInnerHTML={{ __html: renderedHtml }} + /> + ); +} + +export async function resolveHtmlSelectors( + selectors: readonly Selector[], + representation: DocumentRepresentation, +): Promise { + return resolveSelectors(selectors, representation); +} + +export async function scrollToHtmlTarget( + root: HTMLElement, + target: ResolvedAnchorTarget, + representation: DocumentRepresentation, +): Promise { + const span = target.textPosition; + if (!span || !representation.canonicalText) return; + const quote = representation.canonicalText.slice(span.start, span.end); + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); + let node: Node | null; + while ((node = walker.nextNode())) { + const text = node.textContent ?? ""; + const idx = text.indexOf(quote); + if (idx === -1) continue; + const range = document.createRange(); + range.setStart(node, idx); + range.setEnd(node, idx + quote.length); + const rect = range.getBoundingClientRect(); + if (rect.height > 0) { + range.startContainer.parentElement?.scrollIntoView({ block: "center", behavior: "smooth" }); + return; + } + } +} + +export async function renderHtmlHighlight( + target: ResolvedAnchorTarget, + opts?: HighlightRenderOptions, +): Promise { + void target; + void opts; + // Highlights are rendered inline during `HtmlViewerAdapter` render via stored annotations. +} \ No newline at end of file diff --git a/src/dom/index.ts b/src/dom/index.ts new file mode 100644 index 0000000..b5e691b --- /dev/null +++ b/src/dom/index.ts @@ -0,0 +1,11 @@ +export { + HtmlViewerAdapter, + resolveHtmlSelectors, + scrollToHtmlTarget, + renderHtmlHighlight, + type HtmlViewerAdapterProps, + type HtmlViewerDebugOptions, + type StoredHtmlAnnotation, +} from "./html-viewer-adapter"; + +export { captureFromRange, domPathToNode, nodeAtPath } from "./dom-path"; \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index b668299..2eb8021 100644 --- a/src/index.ts +++ b/src/index.ts @@ -22,8 +22,11 @@ export { // PDF adapter + helpers (re-exported from the src/pdf/ boundary). export { + PdfViewerAdapter, PdfSpikeViewer, getHighlightClientRects, + type PdfViewerAdapterProps, + type PdfViewerDebugOptions, selectorsFromPdfCapture, findPdfRectSelector, findTextQuoteSelector, diff --git a/src/pdf/index.ts b/src/pdf/index.ts index 570f3bd..2161def 100644 --- a/src/pdf/index.ts +++ b/src/pdf/index.ts @@ -26,10 +26,13 @@ export { type ScrollToHighlightState, } from "./scroll-job"; -// Concrete PDF viewer adapter (spike) + its contract surface. +// Production PDF viewer adapter + its contract surface. export { + PdfViewerAdapter, PdfSpikeViewer, getHighlightClientRects, + type PdfViewerAdapterProps, + type PdfViewerDebugOptions, type PdfSpikeViewerProps, type StoredAnnotation, } from "./pdf-viewer-adapter-spike"; diff --git a/src/pdf/pdf-viewer-adapter-spike.tsx b/src/pdf/pdf-viewer-adapter-spike.tsx index 4e2aa03..f9039d8 100644 --- a/src/pdf/pdf-viewer-adapter-spike.tsx +++ b/src/pdf/pdf-viewer-adapter-spike.tsx @@ -46,7 +46,12 @@ import "react-pdf-highlighter-plus/style/pdf_viewer.css"; import "./highlight-styles.css"; import "./debug-textlayer.css"; -import type { NormalizedRect, Selector } from "@citation-evidence/engine/shared"; +import type { + DocumentRepresentation, + NormalizedRect, + Selector, +} from "@citation-evidence/engine/shared"; +import { resolveSelectors } from "../selectors"; import type { AnchorResolution, PdfSelectionCapture, ResolvedAnchorTarget } from "../types"; import { findPdfRectSelector, selectorsFromPdfCapture, unionRect } from "./pdf-selector-math"; import { runScrollToHighlightJob } from "./scroll-job"; @@ -63,7 +68,29 @@ function highlightFromSelectors( id: string, text: string, selectors: readonly Selector[], + representation?: DocumentRepresentation, ): Highlight | null { + if (representation) { + const resolution = resolveSelectors(selectors, representation); + const rectSel = findPdfRectSelector(selectors); + const page = resolution.candidates[0]?.page ?? rectSel?.page; + const rects = resolution.candidates[0]?.rects ?? rectSel?.rects; + if (page && rects && rects.length > 0) { + const boundingRect = unionRect(rects); + if (boundingRect) { + const scaledRects = rects.map((r) => toScaled(r, page)); + return { + id, + type: "text", + content: { text }, + position: { + boundingRect: toScaled(boundingRect, page), + rects: scaledRects, + } satisfies ScaledPosition, + }; + } + } + } const rectSel = findPdfRectSelector(selectors); if (!rectSel) return null; const boundingRect = unionRect(rectSel.rects); @@ -188,9 +215,19 @@ export function getHighlightClientRects(annotationId: string): DOMRect | null { return new DOMRect(left, top, right - left, bottom - top); } -export interface PdfSpikeViewerProps { +export interface PdfViewerDebugOptions { + readonly debugTextLayer?: boolean; + readonly hideCanvas?: boolean; + readonly hideTextLayer?: boolean; + readonly hideAnnotationLayer?: boolean; + readonly hideXfaLayer?: boolean; +} + +export interface PdfViewerAdapterProps { /** URL of the PDF to load (served by Vite dev server). */ readonly pdfUrl: string; + /** Document representation for selector resolution (enables text-based reload). */ + readonly representation?: DocumentRepresentation; /** Previously-saved selector sets to restore on mount. */ readonly storedAnnotations: readonly StoredAnnotation[]; /** Called when the user produces a new selection. */ @@ -213,23 +250,23 @@ export interface PdfSpikeViewerProps { * The receiver typically activates the matching evidence item. */ onHighlightClicked?(annotationId: string): void; - /** - * When true, paint the PDF text-layer spans in light grey so it's - * obvious which glyphs have a selectable text overlay and which are - * image-only. Also logs every onSelection event to the console. - */ + /** Spike-only debug flags — not part of the production adapter contract. */ + readonly debug?: PdfViewerDebugOptions; + /** @deprecated Pass via `debug.debugTextLayer` */ readonly debugTextLayer?: boolean; - /** - * Hide specific PDF.js layers so you can see what sits underneath. - * Helps diagnose layer-stacking issues (e.g. "is the text layer - * covering the canvas content?"). - */ + /** @deprecated Pass via `debug.hideCanvas` */ readonly hideCanvas?: boolean; + /** @deprecated Pass via `debug.hideTextLayer` */ readonly hideTextLayer?: boolean; + /** @deprecated Pass via `debug.hideAnnotationLayer` */ readonly hideAnnotationLayer?: boolean; + /** @deprecated Pass via `debug.hideXfaLayer` */ readonly hideXfaLayer?: boolean; } +/** @deprecated Use `PdfViewerAdapterProps` */ +export type PdfSpikeViewerProps = PdfViewerAdapterProps; + /** * Nudge the PDF scroll container so `highlight` sits vertically centred. * Best-effort: depends on highlight layer DOM being present after scroll. @@ -271,21 +308,28 @@ export interface StoredAnnotation { * - reconstructs and renders `storedAnnotations` immediately on load * - scrolls to `scrollToAnnotationId` if its highlight can be reconstructed */ -export function PdfSpikeViewer(props: PdfSpikeViewerProps) { +export function PdfViewerAdapter(props: PdfViewerAdapterProps) { const { pdfUrl, + representation, storedAnnotations, onSelectionCaptured, scrollToAnnotationId, scrollRequestKey, activeAnnotationId, onHighlightClicked, - debugTextLayer, - hideCanvas, - hideTextLayer, - hideAnnotationLayer, - hideXfaLayer, + debug, + debugTextLayer: debugTextLayerProp, + hideCanvas: hideCanvasProp, + hideTextLayer: hideTextLayerProp, + hideAnnotationLayer: hideAnnotationLayerProp, + hideXfaLayer: hideXfaLayerProp, } = props; + const debugTextLayer = debug?.debugTextLayer ?? debugTextLayerProp; + const hideCanvas = debug?.hideCanvas ?? hideCanvasProp; + const hideTextLayer = debug?.hideTextLayer ?? hideTextLayerProp; + const hideAnnotationLayer = debug?.hideAnnotationLayer ?? hideAnnotationLayerProp; + const hideXfaLayer = debug?.hideXfaLayer ?? hideXfaLayerProp; const onHighlightClickedRef = useRef(onHighlightClicked); onHighlightClickedRef.current = onHighlightClicked; const handleHighlightClicked = useCallback((annotationId: string) => { @@ -318,7 +362,7 @@ export function PdfSpikeViewer(props: PdfSpikeViewerProps) { const out: Highlight[] = []; const skipped: { id: string; reason: string }[] = []; for (const a of storedAnnotations) { - const h = highlightFromSelectors(a.id, a.text, a.selectors); + const h = highlightFromSelectors(a.id, a.text, a.selectors, representation); if (h) out.push(h); else skipped.push({ id: a.id, reason: "no PdfRectSelector / empty boundingRect" }); } @@ -336,7 +380,7 @@ export function PdfSpikeViewer(props: PdfSpikeViewerProps) { }); } return out; - }, [storedAnnotations, debugTextLayer]); + }, [storedAnnotations, representation, debugTextLayer]); const highlightsRef = useRef(highlights); highlightsRef.current = highlights; @@ -420,5 +464,8 @@ export function PdfSpikeViewer(props: PdfSpikeViewerProps) { ); } +/** @deprecated Use `PdfViewerAdapter` */ +export const PdfSpikeViewer = PdfViewerAdapter; + // Re-export the §5 contract surface so callers see anchor as one entry point. export type { AnchorResolution, ResolvedAnchorTarget, PdfSelectionCapture }; diff --git a/src/selectors/create.dom.test.ts b/src/selectors/create.dom.test.ts new file mode 100644 index 0000000..66b2ab6 --- /dev/null +++ b/src/selectors/create.dom.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest"; +import type { DocumentRepresentation } from "@citation-evidence/engine/shared"; +import type { DocumentId, RepresentationId } from "@citation-evidence/engine/shared"; +import type { + DomRangeSelector, + StructuralSelector, + TextPositionSelector, + TextQuoteSelector, +} from "@citation-evidence/engine/shared"; +import { createSelectors } from "./create"; +import type { DomSelectionCapture } from "../types"; + +const text = "## Intro\n\nThe quick brown fox jumps.\n\n## Outro"; + +function repr(): DocumentRepresentation { + return { + id: "rep_dom" as RepresentationId, + documentId: "doc_dom" as DocumentId, + representationType: "html-dom", + contentHash: "test", + canonicalText: text, + structureMap: [ + { + globalStart: 0, + globalEnd: text.indexOf("\n\n## Outro"), + path: [{ kind: "section", index: 0, label: "Intro" }], + containerPath: [0, 0, 0], + }, + ], + offsetMap: [{ page: 1, globalStart: 0, globalEnd: text.length, pageLength: text.length }], + generatedAt: "2026-07-09T00:00:00.000Z", + }; +} + +function capture(selected: string): DomSelectionCapture { + const start = text.indexOf(selected); + return { + kind: "dom", + text: selected, + startPath: [0, 0, 0], + startOffset: start, + endPath: [0, 0, 0], + endOffset: start + selected.length, + structuralPath: [{ kind: "section", index: 0, label: "Intro" }], + }; +} + +describe("createSelectors (dom)", () => { + it("emits TextQuote, TextPosition, DomRange, and Structural selectors", () => { + const sels = createSelectors(capture("brown fox"), repr()); + expect(sels.find((s): s is TextQuoteSelector => s.type === "TextQuoteSelector")?.exact).toBe("brown fox"); + const pos = sels.find((s): s is TextPositionSelector => s.type === "TextPositionSelector"); + expect(pos?.start).toBe(text.indexOf("brown fox")); + expect(sels.find((s): s is DomRangeSelector => s.type === "DomRangeSelector")).toBeDefined(); + const structural = sels.find((s): s is StructuralSelector => s.type === "StructuralSelector"); + expect(structural?.path[0]?.kind).toBe("section"); + expect(structural!.startOffset).toBeGreaterThanOrEqual(0); + }); +}); \ No newline at end of file diff --git a/src/selectors/create.ts b/src/selectors/create.ts index d4cf50b..f059ac5 100644 --- a/src/selectors/create.ts +++ b/src/selectors/create.ts @@ -5,25 +5,21 @@ * from `wiki/SharedContracts.md` §3 (selector redundancy) and the create * half of the `AnchorAdapter` contract in * `wiki/ArchitectureOverview.md` §3.3. - * - * Output guarantee: every returned `Selector[]` includes a - * `TextQuoteSelector` (always) and adds `TextPositionSelector`, - * `PdfRectSelector`, `PdfPageTextSelector` only when the underlying data - * actually supports them. Resolvers can rely on the union being trimmed — - * a missing selector means "not available", not "skipped". */ import type { DocumentRepresentation } from "@citation-evidence/engine/shared"; import { normalize } from "@citation-evidence/engine/shared"; import type { + DomRangeSelector, PdfPageTextSelector, PdfRectSelector, Selector, + StructuralSelector, TextPositionSelector, TextQuoteSelector, } from "@citation-evidence/engine/shared"; -import type { PdfSelectionCapture, SelectionCapture } from "../types"; +import type { DomSelectionCapture, PdfSelectionCapture, SelectionCapture } from "../types"; /** Default characters of prefix/suffix context stored on TextQuoteSelector. */ export const DEFAULT_CONTEXT_CHARS = 32; @@ -37,8 +33,9 @@ export function createSelectors( representation: DocumentRepresentation, options: CreateSelectorsOptions = {}, ): Selector[] { - // `SelectionCapture` is a discriminated union. The DOM branch is `never` - // in MVP, so the only runtime shape is `PdfSelectionCapture`. + if (capture.kind === "dom") { + return createSelectorsFromDomCapture(capture, representation, options); + } return createSelectorsFromPdfCapture(capture, representation, options); } @@ -56,14 +53,9 @@ function createSelectorsFromPdfCapture( ? findAllOccurrences(canonicalText, normalizedQuote) : []; - // Locate the match that falls on the capture's page (when offsetMap is - // known); otherwise fall back to the first match. If there is no match, - // we still emit a quote-only TextQuoteSelector so the annotation is - // recoverable later if the representation is rebuilt. const pageRange = representation.offsetMap?.find((r) => r.page === capture.page); const matchOffset = pickMatch(positions, pageRange); - // 1. TextQuoteSelector — always included. if (normalizedQuote.length > 0) { const quote = matchOffset !== null ? buildQuoteSelectorWithContext(canonicalText, matchOffset, normalizedQuote, contextChars) @@ -71,7 +63,6 @@ function createSelectorsFromPdfCapture( out.push(quote); } - // 2. TextPositionSelector — only when we have a unique-enough match. if (matchOffset !== null) { const pos: TextPositionSelector = { type: "TextPositionSelector", @@ -81,7 +72,6 @@ function createSelectorsFromPdfCapture( out.push(pos); } - // 3. PdfRectSelector — straight from the capture; viewer-coordinate truth. if (capture.rects.length > 0) { const rect: PdfRectSelector = { type: "PdfRectSelector", @@ -91,8 +81,6 @@ function createSelectorsFromPdfCapture( out.push(rect); } - // 4. PdfPageTextSelector — when we have offsetMap and a unique-enough match - // that falls inside the capture's page range. if (matchOffset !== null && pageRange) { if (matchOffset >= pageRange.globalStart && matchOffset + normalizedQuote.length <= pageRange.globalEnd) { const pageText: PdfPageTextSelector = { @@ -108,6 +96,70 @@ function createSelectorsFromPdfCapture( return out; } +function createSelectorsFromDomCapture( + capture: DomSelectionCapture, + representation: DocumentRepresentation, + options: CreateSelectorsOptions, +): Selector[] { + const contextChars = options.contextChars ?? DEFAULT_CONTEXT_CHARS; + const normalizedQuote = normalize(capture.text).text; + const out: Selector[] = []; + const canonicalText = representation.canonicalText ?? ""; + + const positions = canonicalText.length > 0 && normalizedQuote.length > 0 + ? findAllOccurrences(canonicalText, normalizedQuote) + : []; + const matchOffset = positions.length === 1 ? positions[0]! : positions[0] ?? null; + + if (normalizedQuote.length > 0) { + const quote = matchOffset !== null + ? buildQuoteSelectorWithContext(canonicalText, matchOffset, normalizedQuote, contextChars) + : ({ type: "TextQuoteSelector", exact: normalizedQuote } satisfies TextQuoteSelector); + out.push(quote); + } + + if (matchOffset !== null) { + out.push({ + type: "TextPositionSelector", + start: matchOffset, + end: matchOffset + normalizedQuote.length, + } satisfies TextPositionSelector); + } + + out.push({ + type: "DomRangeSelector", + startPath: capture.startPath, + startOffset: capture.startOffset, + endPath: capture.endPath, + endOffset: capture.endOffset, + } satisfies DomRangeSelector); + + if (capture.structuralPath && capture.structuralPath.length > 0) { + const block = representation.structureMap?.find( + (entry) => entry.path.every((seg, i) => { + const cap = capture.structuralPath![i]; + return cap + && seg.kind === cap.kind + && seg.index === cap.index + && seg.level === cap.level; + }), + ); + const blockStart = block?.globalStart ?? 0; + const relStart = matchOffset !== null ? matchOffset - blockStart : capture.startOffset; + const relEnd = matchOffset !== null + ? matchOffset + normalizedQuote.length - blockStart + : capture.endOffset; + out.push({ + type: "StructuralSelector", + path: capture.structuralPath, + startOffset: relStart, + endOffset: relEnd, + } satisfies StructuralSelector); + } + + return out; +} + function findAllOccurrences(haystack: string, needle: string): number[] { if (needle.length === 0) return []; const out: number[] = []; @@ -133,8 +185,6 @@ function pickMatch( ); if (onPage !== undefined) return onPage; } - // Multiple matches and no page hint — return the first; resolve.ts will - // need prefix/suffix to disambiguate. return positions[0]!; } @@ -154,4 +204,4 @@ function buildQuoteSelectorWithContext( ...(prefix.length > 0 ? { prefix } : {}), ...(suffix.length > 0 ? { suffix } : {}), }; -} +} \ No newline at end of file diff --git a/src/selectors/dom-path.ts b/src/selectors/dom-path.ts new file mode 100644 index 0000000..9263206 --- /dev/null +++ b/src/selectors/dom-path.ts @@ -0,0 +1,89 @@ +import type { + DomNodePath, + DomRangeSelector, + StructuralPathSegment, + StructuralSelector, +} from "@citation-evidence/engine/shared"; +import type { StructureRange } from "@citation-evidence/engine/shared"; + +export function pathsEqual(a: DomNodePath, b: DomNodePath): boolean { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) return false; + } + return true; +} + +export function pathStartsWith(path: DomNodePath, prefix: DomNodePath): boolean { + if (prefix.length > path.length) return false; + for (let i = 0; i < prefix.length; i++) { + if (path[i] !== prefix[i]) return false; + } + return true; +} + +export function structuralPathsEqual( + a: readonly StructuralPathSegment[], + b: readonly StructuralPathSegment[], +): boolean { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + const left = a[i]!; + const right = b[i]!; + if ( + left.kind !== right.kind + || left.index !== right.index + || left.level !== right.level + ) { + return false; + } + } + return true; +} + +export function findStructureRange( + structureMap: readonly StructureRange[], + path: readonly StructuralPathSegment[], +): StructureRange | null { + return structureMap.find((entry) => structuralPathsEqual(entry.path, path)) ?? null; +} + +export function findStructureRangeForDomRange( + structureMap: readonly StructureRange[], + selector: DomRangeSelector, +): StructureRange | null { + for (const entry of structureMap) { + if (!entry.containerPath) continue; + if ( + pathStartsWith(selector.startPath, entry.containerPath) + && pathStartsWith(selector.endPath, entry.containerPath) + ) { + return entry; + } + } + return null; +} + +export function resolveStructuralSpan( + structureMap: readonly StructureRange[], + selector: StructuralSelector, +): { start: number; end: number } | null { + const entry = findStructureRange(structureMap, selector.path); + if (!entry) return null; + const start = entry.globalStart + selector.startOffset; + const end = entry.globalStart + selector.endOffset; + if (start < entry.globalStart || end > entry.globalEnd || start >= end) return null; + return { start, end }; +} + +export function resolveDomRangeSpan( + structureMap: readonly StructureRange[], + selector: DomRangeSelector, +): { start: number; end: number } | null { + const entry = findStructureRangeForDomRange(structureMap, selector); + if (!entry) return null; + const start = entry.globalStart + selector.startOffset; + const end = entry.globalStart + selector.endOffset; + if (start < entry.globalStart || end > entry.globalEnd || start >= end) return null; + return { start, end }; +} \ No newline at end of file diff --git a/src/selectors/fuzzy.test.ts b/src/selectors/fuzzy.test.ts new file mode 100644 index 0000000..8d5a99d --- /dev/null +++ b/src/selectors/fuzzy.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { findFuzzyQuoteMatches } from "./fuzzy"; + +describe("findFuzzyQuoteMatches", () => { + it("finds a lightly edited quote", () => { + const text = "The quick brown foks jumps over the lazy dog."; + const matches = findFuzzyQuoteMatches(text, { type: "TextQuoteSelector", exact: "brown fox" }); + expect(matches.length).toBeGreaterThan(0); + expect(matches[0]!.matchedText).toContain("brown"); + expect(matches[0]!.confidence).toBeLessThan(0.7); + expect(matches[0]!.confidence).toBeGreaterThanOrEqual(0.15); + }); + + it("returns empty for completely unrelated text", () => { + const matches = findFuzzyQuoteMatches( + "hello world", + { type: "TextQuoteSelector", exact: "quantum entanglement" }, + ); + expect(matches).toEqual([]); + }); +}); \ No newline at end of file diff --git a/src/selectors/fuzzy.ts b/src/selectors/fuzzy.ts new file mode 100644 index 0000000..43c3faa --- /dev/null +++ b/src/selectors/fuzzy.ts @@ -0,0 +1,113 @@ +import type { TextQuoteSelector } from "@citation-evidence/engine/shared"; + +export interface FuzzyQuoteMatch { + readonly offset: number; + readonly matchedText: string; + readonly distance: number; + readonly confidence: number; +} + +/** Upper bound on edit distance relative to quote length. */ +export const FUZZY_MAX_DISTANCE_RATIO = 0.2; + +/** Confidence ceiling for fuzzy matches — below the exact ladder floor (0.7). */ +export const FUZZY_CONFIDENCE_CEILING = 0.35; + +/** Confidence floor for fuzzy matches. */ +export const FUZZY_CONFIDENCE_FLOOR = 0.15; + +/** + * Find bounded fuzzy quote matches in canonical text. + * Returns candidates sorted by ascending distance (best first). + */ +export function findFuzzyQuoteMatches( + canonicalText: string, + quote: TextQuoteSelector, +): readonly FuzzyQuoteMatch[] { + const needle = quote.exact; + if (needle.length === 0 || canonicalText.length === 0) return []; + + const maxDistance = Math.max(1, Math.floor(needle.length * FUZZY_MAX_DISTANCE_RATIO)); + const minLen = Math.max(1, needle.length - maxDistance); + const maxLen = needle.length + maxDistance; + const matches: FuzzyQuoteMatch[] = []; + + for (let start = 0; start < canonicalText.length; start++) { + for (let len = minLen; len <= maxLen && start + len <= canonicalText.length; len++) { + const candidate = canonicalText.slice(start, start + len); + const distance = levenshtein(needle, candidate); + if (distance > maxDistance) continue; + + const confidence = fuzzyConfidence(needle.length, candidate.length, distance); + if (confidence < FUZZY_CONFIDENCE_FLOOR) continue; + + if (!prefixSuffixMatchesFuzzy(canonicalText, start, candidate, quote, maxDistance)) { + continue; + } + + matches.push({ offset: start, matchedText: candidate, distance, confidence }); + } + } + + matches.sort((a, b) => a.distance - b.distance || a.offset - b.offset); + + const deduped: FuzzyQuoteMatch[] = []; + for (const m of matches) { + if (deduped.some((d) => d.offset === m.offset && d.matchedText === m.matchedText)) continue; + deduped.push(m); + } + return deduped; +} + +function fuzzyConfidence(needleLen: number, candidateLen: number, distance: number): number { + const denom = Math.max(needleLen, candidateLen, 1); + const raw = 1 - distance / denom; + const scaled = FUZZY_CONFIDENCE_FLOOR + raw * (FUZZY_CONFIDENCE_CEILING - FUZZY_CONFIDENCE_FLOOR); + return Math.min(FUZZY_CONFIDENCE_CEILING, Math.max(FUZZY_CONFIDENCE_FLOOR, scaled)); +} + +function prefixSuffixMatchesFuzzy( + canonicalText: string, + offset: number, + matchedText: string, + quote: TextQuoteSelector, + maxDistance: number, +): boolean { + if (quote.prefix !== undefined) { + const prefixEnd = offset; + const prefixStart = Math.max(0, prefixEnd - quote.prefix.length); + const actualPrefix = canonicalText.slice(prefixStart, prefixEnd); + if (levenshtein(actualPrefix, quote.prefix) > maxDistance) return false; + } + if (quote.suffix !== undefined) { + const suffixStart = offset + matchedText.length; + const suffixEnd = Math.min(canonicalText.length, suffixStart + quote.suffix.length); + const actualSuffix = canonicalText.slice(suffixStart, suffixEnd); + if (levenshtein(actualSuffix, quote.suffix) > maxDistance) return false; + } + return true; +} + +function levenshtein(a: string, b: string): number { + if (a === b) return 0; + if (a.length === 0) return b.length; + if (b.length === 0) return a.length; + + const rows = a.length + 1; + const cols = b.length + 1; + const matrix: number[] = new Array(rows * cols); + + for (let i = 0; i < rows; i++) matrix[i * cols] = i; + for (let j = 0; j < cols; j++) matrix[j] = j; + + for (let i = 1; i < rows; i++) { + for (let j = 1; j < cols; j++) { + const cost = a[i - 1] === b[j - 1] ? 0 : 1; + const del = matrix[(i - 1) * cols + j]! + 1; + const ins = matrix[i * cols + (j - 1)]! + 1; + const sub = matrix[(i - 1) * cols + (j - 1)]! + cost; + matrix[i * cols + j] = Math.min(del, ins, sub); + } + } + return matrix[(rows - 1) * cols + (cols - 1)]!; +} \ No newline at end of file diff --git a/src/selectors/index.ts b/src/selectors/index.ts index f47543c..a7182d5 100644 --- a/src/selectors/index.ts +++ b/src/selectors/index.ts @@ -4,3 +4,9 @@ export { type CreateSelectorsOptions, } from "./create"; export { resolveSelectors } from "./resolve"; +export { isOrphanedResolution } from "./orphan"; +export { + findFuzzyQuoteMatches, + FUZZY_CONFIDENCE_CEILING, + FUZZY_CONFIDENCE_FLOOR, +} from "./fuzzy"; diff --git a/src/selectors/orphan.test.ts b/src/selectors/orphan.test.ts new file mode 100644 index 0000000..deea410 --- /dev/null +++ b/src/selectors/orphan.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { isOrphanedResolution } from "./orphan"; + +describe("isOrphanedResolution", () => { + it("returns true for unresolved with no candidates", () => { + expect(isOrphanedResolution({ + status: "unresolved", + confidence: 0, + candidates: [], + usedSelectorTypes: [], + orphaned: true, + })).toBe(true); + }); + + it("returns false for stale resolutions", () => { + expect(isOrphanedResolution({ + status: "stale", + confidence: 0, + candidates: [], + usedSelectorTypes: [], + orphaned: false, + })).toBe(false); + }); + + it("returns false when fuzzy produced a candidate", () => { + expect(isOrphanedResolution({ + status: "stale", + confidence: 0.2, + candidates: [{ representationId: "rep" }], + usedSelectorTypes: ["TextQuoteSelector"], + orphaned: false, + })).toBe(false); + }); +}); \ No newline at end of file diff --git a/src/selectors/orphan.ts b/src/selectors/orphan.ts new file mode 100644 index 0000000..4899968 --- /dev/null +++ b/src/selectors/orphan.ts @@ -0,0 +1,10 @@ +import type { AnchorResolution } from "../types"; + +/** + * True when no selector could place the anchor at all (distinct from stale, + * which may still offer fuzzy recovery candidates). + */ +export function isOrphanedResolution(resolution: AnchorResolution): boolean { + if (resolution.orphaned !== undefined) return resolution.orphaned; + return resolution.status === "unresolved" && resolution.candidates.length === 0; +} \ No newline at end of file diff --git a/src/selectors/resolve.dom.test.ts b/src/selectors/resolve.dom.test.ts new file mode 100644 index 0000000..7c67c9a --- /dev/null +++ b/src/selectors/resolve.dom.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vitest"; +import type { DocumentRepresentation } from "@citation-evidence/engine/shared"; +import type { DocumentId, RepresentationId } from "@citation-evidence/engine/shared"; +import type { Selector } from "@citation-evidence/engine/shared"; +import { resolveSelectors } from "./resolve"; + +const blockText = "The quick brown fox jumps over the lazy dog."; +const text = `## Section\n\n${blockText}`; + +function repr(structureStart = text.indexOf(blockText)): DocumentRepresentation { + return { + id: "rep_dom" as RepresentationId, + documentId: "doc_dom" as DocumentId, + representationType: "markdown-rendered", + contentHash: "test", + canonicalText: text, + structureMap: [ + { + globalStart: structureStart, + globalEnd: structureStart + blockText.length, + path: [{ kind: "section", index: 0, label: "Section" }], + containerPath: [0, 1, 0], + }, + ], + generatedAt: "2026-07-09T00:00:00.000Z", + }; +} + +describe("resolveSelectors (dom)", () => { + it("resolves DomRangeSelector via structureMap", () => { + const foxStart = text.indexOf("brown fox"); + const selectors: Selector[] = [ + { + type: "DomRangeSelector", + startPath: [0, 1, 0], + startOffset: foxStart - text.indexOf(blockText), + endPath: [0, 1, 0], + endOffset: foxStart - text.indexOf(blockText) + "brown fox".length, + }, + ]; + const r = resolveSelectors(selectors, repr()); + expect(r.status).toBe("resolved"); + expect(r.confidence).toBe(0.75); + expect(r.candidates[0]?.textPosition?.start).toBe(foxStart); + }); + + it("resolves StructuralSelector when offsets shift within the block", () => { + const shifted = "The quick brown fox jumps over a sleepy dog."; + const shiftedDoc = `## Section\n\n${shifted}`; + const blockStart = shiftedDoc.indexOf(shifted); + const selectors: Selector[] = [ + { + type: "StructuralSelector", + path: [{ kind: "section", index: 0, label: "Section" }], + startOffset: blockText.indexOf("brown fox"), + endOffset: blockText.indexOf("brown fox") + "brown fox".length, + }, + ]; + const representation: DocumentRepresentation = { + ...repr(blockStart), + canonicalText: shiftedDoc, + structureMap: [ + { + globalStart: blockStart, + globalEnd: blockStart + shifted.length, + path: [{ kind: "section", index: 0, label: "Section" }], + }, + ], + }; + const r = resolveSelectors(selectors, representation); + expect(r.status).toBe("resolved"); + expect(r.confidence).toBe(0.65); + expect(r.candidates[0]?.textPosition?.start).toBe( + representation.canonicalText!.indexOf("brown fox"), + ); + }); +}); \ No newline at end of file diff --git a/src/selectors/resolve.test.ts b/src/selectors/resolve.test.ts index ce45d55..a08c56f 100644 --- a/src/selectors/resolve.test.ts +++ b/src/selectors/resolve.test.ts @@ -133,5 +133,33 @@ describe("resolveSelectors", () => { expect(r.status).toBe("unresolved"); expect(r.confidence).toBe(0); expect(r.candidates).toEqual([]); + expect(r.orphaned).toBe(true); + }); + + it("returns stale when position is in-range but quote no longer exists", () => { + const r = resolveSelectors( + [ + { type: "TextPositionSelector", start: brownFoxStart, end: brownFoxEnd }, + { type: "TextQuoteSelector", exact: "brown fox" }, + ], + repr("The quick grey wolf jumps over the lazy dog."), + ); + expect(r.status).toBe("stale"); + expect(r.confidence).toBe(0); + expect(r.candidates).toEqual([]); + expect(r.orphaned).toBe(false); + expect(r.warnings?.some((w) => /no longer matches/i.test(w))).toBe(true); + }); + + it("returns stale with fuzzy candidate when quote is lightly edited", () => { + const r = resolveSelectors( + [{ type: "TextQuoteSelector", exact: "brown fox" }], + repr("The quick brown foks jumps over the lazy dog."), + ); + expect(r.status).toBe("stale"); + expect(r.confidence).toBeGreaterThan(0); + expect(r.confidence).toBeLessThan(0.7); + expect(r.candidates[0]?.textPosition?.start).toBeGreaterThanOrEqual(0); + expect(r.warnings?.some((w) => /fuzzy/i.test(w))).toBe(true); }); }); diff --git a/src/selectors/resolve.ts b/src/selectors/resolve.ts index c72a71f..63a954a 100644 --- a/src/selectors/resolve.ts +++ b/src/selectors/resolve.ts @@ -1,8 +1,7 @@ /** * Resolve a `Selector[]` against a `DocumentRepresentation`. * - * Implements the resolution strategy from `wiki/ArchitectureOverview.md` §7, - * MVP-trimmed: + * Implements the resolution strategy from `wiki/ArchitectureOverview.md` §7: * * 1. Try `TextPositionSelector` (cheapest — direct slice). * 2. Verify with `TextQuoteSelector` at that position. @@ -10,30 +9,40 @@ * by prefix/suffix. * 4. Try `PdfPageTextSelector` (page-local offsets through the OffsetMap). * 5. Fall back to `PdfRectSelector` for a page+rects-only target. - * 6. Return `unresolved` if nothing above succeeds. + * 6. Try `DomRangeSelector` / `StructuralSelector` for non-paginated docs. + * 7. Try fuzzy quote matching (bounded edit distance). + * 8. Return `stale` when representation changed but quote is meaningful; + * `unresolved` when nothing matches at all. * - * Fuzzy matching is out of scope here; a later workplan owns it. - * - * Confidence ladder (0..1): + * Confidence ladder (0..1) for exact tiers: * 1.00 — TextPosition + TextQuote agree exactly * 0.95 — TextQuote unique match (no position to cross-check) * 0.90 — TextQuote disambiguated by prefix/suffix * 0.85 — TextPosition only (no quote to cross-check) * 0.80 — PdfPageTextSelector resolved via OffsetMap + * 0.75 — DomRangeSelector resolved via structureMap * 0.70 — PdfRectSelector only (page+rects, no text verification) + * 0.65 — StructuralSelector resolved via structureMap + * + * Fuzzy floor: 0.15–0.35 (never reaches exact-tier confidence). */ import type { DocumentRepresentation } from "@citation-evidence/engine/shared"; import type { + DomRangeSelector, PdfPageTextSelector, PdfRectSelector, Selector, SelectorType, + StructuralSelector, TextPositionSelector, TextQuoteSelector, } from "@citation-evidence/engine/shared"; import type { AnchorResolution, ResolvedAnchorTarget } from "../types"; +import { resolveDomRangeSpan, resolveStructuralSpan } from "./dom-path"; +import { findFuzzyQuoteMatches } from "./fuzzy"; +import { isOrphanedResolution } from "./orphan"; export function resolveSelectors( selectors: readonly Selector[], @@ -41,11 +50,13 @@ export function resolveSelectors( ): AnchorResolution { const canonicalText = representation.canonicalText ?? ""; const offsetMap = representation.offsetMap ?? []; + const structureMap = representation.structureMap ?? []; const representationId = representation.id; const byType = indexByType(selectors); const used: SelectorType[] = []; const warnings: string[] = []; + let staleSignal = false; // 1 & 2. Try TextPositionSelector, verify with TextQuoteSelector. if (byType.TextPositionSelector && canonicalText.length > 0) { @@ -56,24 +67,62 @@ export function resolveSelectors( if (quote) { if (slice === quote.exact) { used.push("TextPositionSelector", "TextQuoteSelector"); - return resolved( - { representationId, textPosition: { start: pos.start, end: pos.end }, ...pageFor(pos, offsetMap) }, - 1.0, - used, - warnings, + return finish( + resolved( + { + representationId, + textPosition: { start: pos.start, end: pos.end }, + ...pageFor(pos, offsetMap), + }, + 1.0, + used, + warnings, + ), ); } warnings.push( "TextPositionSelector slice did not match TextQuoteSelector.exact; falling back to quote search.", ); + const quoteResult = resolveByQuote(canonicalText, quote); + if (quoteResult) { + used.push("TextQuoteSelector"); + return finish( + resolved( + { + representationId, + textPosition: { + start: quoteResult.offset, + end: quoteResult.offset + quote.exact.length, + }, + ...pageFor( + { start: quoteResult.offset, end: quoteResult.offset + quote.exact.length }, + offsetMap, + ), + }, + quoteResult.confidence, + used, + warnings, + quoteResult.status, + ), + ); + } + staleSignal = true; + warnings.push( + "Stored TextPositionSelector no longer matches canonical text and TextQuoteSelector.exact was not found.", + ); } else { - // Position with no quote to verify — accept at lower confidence. used.push("TextPositionSelector"); - return resolved( - { representationId, textPosition: { start: pos.start, end: pos.end }, ...pageFor(pos, offsetMap) }, - 0.85, - used, - warnings, + return finish( + resolved( + { + representationId, + textPosition: { start: pos.start, end: pos.end }, + ...pageFor(pos, offsetMap), + }, + 0.85, + used, + warnings, + ), ); } } @@ -84,16 +133,27 @@ export function resolveSelectors( const quoteResult = resolveByQuote(canonicalText, byType.TextQuoteSelector); if (quoteResult) { used.push("TextQuoteSelector"); - return resolved( - { - representationId, - textPosition: { start: quoteResult.offset, end: quoteResult.offset + byType.TextQuoteSelector.exact.length }, - ...pageFor({ start: quoteResult.offset, end: quoteResult.offset + byType.TextQuoteSelector.exact.length }, offsetMap), - }, - quoteResult.confidence, - used, - warnings, - quoteResult.status, + return finish( + resolved( + { + representationId, + textPosition: { + start: quoteResult.offset, + end: quoteResult.offset + byType.TextQuoteSelector.exact.length, + }, + ...pageFor( + { + start: quoteResult.offset, + end: quoteResult.offset + byType.TextQuoteSelector.exact.length, + }, + offsetMap, + ), + }, + quoteResult.confidence, + used, + warnings, + quoteResult.status, + ), ); } } @@ -106,15 +166,17 @@ export function resolveSelectors( const globalStart = range.globalStart + pageText.start; const globalEnd = range.globalStart + pageText.end; used.push("PdfPageTextSelector"); - return resolved( - { - representationId, - page: pageText.page, - textPosition: { start: globalStart, end: globalEnd }, - }, - 0.8, - used, - warnings, + return finish( + resolved( + { + representationId, + page: pageText.page, + textPosition: { start: globalStart, end: globalEnd }, + }, + 0.8, + used, + warnings, + ), ); } } @@ -123,15 +185,113 @@ export function resolveSelectors( if (byType.PdfRectSelector) { const rect = byType.PdfRectSelector; used.push("PdfRectSelector"); - return resolved( - { representationId, page: rect.page, rects: rect.rects }, - 0.7, - used, - warnings, + return finish( + resolved( + { representationId, page: rect.page, rects: rect.rects }, + 0.7, + used, + warnings, + ), ); } - return unresolved(warnings); + // 6a. DomRangeSelector for non-paginated representations. + if (byType.DomRangeSelector && structureMap.length > 0) { + const span = resolveDomRangeSpan(structureMap, byType.DomRangeSelector); + if (span) { + used.push("DomRangeSelector"); + return finish( + resolved( + { + representationId, + textPosition: span, + }, + 0.75, + used, + warnings, + ), + ); + } + } + + // 6b. StructuralSelector fallback. + if (byType.StructuralSelector && structureMap.length > 0) { + const span = resolveStructuralSpan(structureMap, byType.StructuralSelector); + if (span) { + used.push("StructuralSelector"); + return finish( + resolved( + { + representationId, + textPosition: span, + }, + 0.65, + used, + warnings, + ), + ); + } + } + + // 7. Fuzzy quote matching. + if (byType.TextQuoteSelector && canonicalText.length > 0) { + const fuzzyMatches = findFuzzyQuoteMatches(canonicalText, byType.TextQuoteSelector); + if (fuzzyMatches.length > 0) { + const bestDistance = fuzzyMatches[0]!.distance; + const bestCandidates = fuzzyMatches.filter((m) => m.distance === bestDistance); + const distinctOffsets = new Set(bestCandidates.map((m) => m.offset)); + const match = bestCandidates[0]!; + used.push("TextQuoteSelector"); + if (distinctOffsets.size === 1) { + warnings.push("Resolved via fuzzy quote matching; representation may have changed."); + return finish( + resolved( + { + representationId, + textPosition: { + start: match.offset, + end: match.offset + match.matchedText.length, + }, + ...pageFor( + { start: match.offset, end: match.offset + match.matchedText.length }, + offsetMap, + ), + }, + match.confidence, + used, + warnings, + "stale", + ), + ); + } + warnings.push("Multiple fuzzy quote candidates; manual confirmation recommended."); + return finish( + resolved( + { + representationId, + textPosition: { + start: match.offset, + end: match.offset + match.matchedText.length, + }, + ...pageFor( + { start: match.offset, end: match.offset + match.matchedText.length }, + offsetMap, + ), + }, + match.confidence, + used, + warnings, + "ambiguous", + ), + ); + } + } + + if (staleSignal) { + return finish(stale(warnings)); + } + + return finish(unresolved(warnings)); } interface QuoteResolutionResult { @@ -146,7 +306,6 @@ function resolveByQuote(canonicalText: string, quote: TextQuoteSelector): QuoteR if (positions.length === 1) { return { offset: positions[0]!, confidence: 0.95, status: "resolved" }; } - // Multiple matches — try to disambiguate by prefix/suffix. const filtered = positions.filter((p) => prefixSuffixMatches(canonicalText, p, quote)); if (filtered.length === 1) { return { offset: filtered[0]!, confidence: 0.9, status: "resolved" }; @@ -154,7 +313,6 @@ function resolveByQuote(canonicalText: string, quote: TextQuoteSelector): QuoteR if (filtered.length > 1) { return { offset: filtered[0]!, confidence: 0.5, status: "ambiguous" }; } - // No prefix/suffix info or no matches with context — return ambiguous on first. return { offset: positions[0]!, confidence: 0.5, status: "ambiguous" }; } @@ -183,6 +341,8 @@ interface SelectorIndex { TextPositionSelector?: TextPositionSelector; PdfRectSelector?: PdfRectSelector; PdfPageTextSelector?: PdfPageTextSelector; + DomRangeSelector?: DomRangeSelector; + StructuralSelector?: StructuralSelector; } function indexByType(selectors: readonly Selector[]): SelectorIndex { @@ -201,6 +361,12 @@ function indexByType(selectors: readonly Selector[]): SelectorIndex { case "PdfPageTextSelector": idx.PdfPageTextSelector = s; break; + case "DomRangeSelector": + idx.DomRangeSelector = s; + break; + case "StructuralSelector": + idx.StructuralSelector = s; + break; } } return idx; @@ -238,23 +404,46 @@ function resolved( confidence: number, used: readonly SelectorType[], warnings: readonly string[], - status: "resolved" | "ambiguous" = "resolved", + status: "resolved" | "ambiguous" | "stale" = "resolved", ): AnchorResolution { return { status, confidence, candidates: [target], usedSelectorTypes: used, + orphaned: false, ...(warnings.length > 0 ? { warnings } : {}), }; } +function stale(warnings: readonly string[]): AnchorResolution { + return { + status: "stale", + confidence: 0, + candidates: [], + usedSelectorTypes: [], + orphaned: false, + warnings: [...warnings], + }; +} + function unresolved(warnings: readonly string[]): AnchorResolution { return { status: "unresolved", confidence: 0, candidates: [], usedSelectorTypes: [], + orphaned: true, ...(warnings.length > 0 ? { warnings } : {}), }; } + +function finish(resolution: AnchorResolution): AnchorResolution { + if (resolution.orphaned === undefined) { + return { + ...resolution, + orphaned: isOrphanedResolution(resolution), + }; + } + return resolution; +} \ No newline at end of file diff --git a/src/types.ts b/src/types.ts index dfb1757..39e57e5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -12,7 +12,11 @@ import type { Document, DocumentRepresentation } from "@citation-evidence/engine/shared"; import type { Selector } from "@citation-evidence/engine/shared"; import type { AnnotationResolutionStatus } from "@citation-evidence/engine/shared"; -import type { NormalizedRect } from "@citation-evidence/engine/shared"; +import type { + DomNodePath, + NormalizedRect, + StructuralPathSegment, +} from "@citation-evidence/engine/shared"; /** * The raw selection captured from a viewer adapter — an opaque payload that @@ -36,8 +40,17 @@ export interface PdfSelectionCapture { readonly boundingRect?: NormalizedRect; } -/** Reserved for the HTML/Markdown adapter. Not implementable in MVP. */ -export type DomSelectionCapture = never; +/** Selection captured from a rendered HTML/Markdown viewer. */ +export interface DomSelectionCapture { + readonly kind: "dom"; + /** Verbatim selected text, before canonical normalisation. */ + readonly text: string; + readonly startPath: DomNodePath; + readonly startOffset: number; + readonly endPath: DomNodePath; + readonly endOffset: number; + readonly structuralPath?: readonly StructuralPathSegment[]; +} /** * A passage located inside a representation, ready to be scrolled to and @@ -65,6 +78,11 @@ export interface AnchorResolution { /** Names of the selector kinds that produced a usable candidate. */ readonly usedSelectorTypes: readonly string[]; readonly warnings?: readonly string[]; + /** + * True when no selector could place the anchor at all. Distinct from `stale`, + * which signals representation drift and may still return fuzzy candidates. + */ + readonly orphaned?: boolean; } export interface HighlightRenderOptions { diff --git a/workplans/EANCH-WP-0002-anchor-resolution-hardening.md b/workplans/EANCH-WP-0002-anchor-resolution-hardening.md index 373b5db..43c9969 100644 --- a/workplans/EANCH-WP-0002-anchor-resolution-hardening.md +++ b/workplans/EANCH-WP-0002-anchor-resolution-hardening.md @@ -4,11 +4,11 @@ type: workplan title: "Anchor resolution hardening: stale/orphan semantics, fuzzy re-anchoring, production PDF adapter" domain: infotech repo: evidence-anchor -status: proposed +status: finished owner: codex topic_slug: citation_evidence_mvp created: "2026-07-08" -updated: "2026-07-08" +updated: "2026-07-09" spec_refs: - INTENT.md - SCOPE.md @@ -48,7 +48,7 @@ Current extracted behavior (`src/selectors/resolve.ts`): ```task id: EANCH-WP-0002-T01 -status: todo +status: done priority: high state_hub_task_id: "940b03ba-4b2f-4d1a-b7bc-72813817549d" ``` @@ -63,7 +63,7 @@ quote/context on the result. Add unit tests covering stale vs. unresolved. ```task id: EANCH-WP-0002-T02 -status: todo +status: done priority: medium depends_on: [T01] state_hub_task_id: "f75b94cd-bfb8-47f9-bd89-0d605f925290" @@ -78,7 +78,7 @@ enum unchanged unless a contract change is agreed in the umbrella wiki first. ```task id: EANCH-WP-0002-T03 -status: todo +status: done priority: high depends_on: [T01] state_hub_task_id: "8548fa44-5bb3-40cf-b8f8-e745deae7dda" @@ -95,7 +95,7 @@ floor. ```task id: EANCH-WP-0002-T04 -status: todo +status: done priority: medium depends_on: [T03] state_hub_task_id: "6a7163f5-39ca-48fa-8941-8459c563198f" @@ -110,7 +110,7 @@ umbrella green through the change. Update `evidence-anchor/pdf` exports and docs ```task id: EANCH-WP-0002-T05 -status: todo +status: done priority: high depends_on: [T02, T04] state_hub_task_id: "1ea0885a-c541-438f-a4fd-b8be0773b373" @@ -118,3 +118,8 @@ state_hub_task_id: "1ea0885a-c541-438f-a4fd-b8be0773b373" `pnpm test`/`typecheck`/`lint` green here; umbrella typecheck/test/build green after any contract-visible change; `fix-consistency` clean; progress note. + +**Verification (2026-07-09):** + +- `evidence-anchor`: 42 tests (9 files), typecheck + lint clean. +- `citation-evidence`: typecheck clean, 51 integration tests passed. diff --git a/workplans/EANCH-WP-0003-non-pdf-selectors.md b/workplans/EANCH-WP-0003-non-pdf-selectors.md index c01011f..d4156c8 100644 --- a/workplans/EANCH-WP-0003-non-pdf-selectors.md +++ b/workplans/EANCH-WP-0003-non-pdf-selectors.md @@ -4,11 +4,11 @@ type: workplan title: "Non-PDF selectors: HTML/Markdown DOM range + structural anchoring" domain: infotech repo: evidence-anchor -status: proposed +status: finished owner: codex topic_slug: citation_evidence_mvp created: "2026-07-08" -updated: "2026-07-08" +updated: "2026-07-09" spec_refs: - INTENT.md - SCOPE.md @@ -47,7 +47,7 @@ enum — coordinate contract changes in the umbrella wiki first. ```task id: EANCH-WP-0003-T01 -status: todo +status: done priority: high state_hub_task_id: "de7df94f-2ddf-4920-949e-6e693131a9de" ``` @@ -61,7 +61,7 @@ Land this in the engine + `SharedContracts.md` before writing anchor behavior. ```task id: EANCH-WP-0003-T02 -status: todo +status: done priority: high depends_on: [T01] state_hub_task_id: "aa1bc68d-c15a-41e0-813d-c4ccec4914c9" @@ -75,7 +75,7 @@ DOM selection. Add unit tests mirroring the PDF create tests. ```task id: EANCH-WP-0003-T03 -status: todo +status: done priority: high depends_on: [T02] state_hub_task_id: "2bdedc91-1ed2-41ed-b4a0-a9a0d7a6b4b0" @@ -90,7 +90,7 @@ re-render stability (structural fallback when offsets shift). ```task id: EANCH-WP-0003-T04 -status: todo +status: done priority: medium depends_on: [T03] state_hub_task_id: "286267fe-d348-4494-bbe2-dd7818bcadbd" @@ -105,7 +105,7 @@ to that boundary. ```task id: EANCH-WP-0003-T05 -status: todo +status: done priority: high depends_on: [T04] state_hub_task_id: "9ff39c83-eca8-46a1-9b28-8a728ddf220d" @@ -114,3 +114,8 @@ state_hub_task_id: "9ff39c83-eca8-46a1-9b28-8a728ddf220d" Package green (`pnpm test`/`typecheck`/`lint`); engine contract change verified in `citation-engine`; any umbrella consumer green; `fix-consistency` clean; progress note. + +**Verification (2026-07-09):** + +- `citation-engine`: DomRange/Structural types + StructureMap promoted; 89 tests passed. +- `evidence-anchor`: DOM create/resolve + `evidence-anchor/dom` export; 42 tests passed.