Implement EANCH-WP-0002 and EANCH-WP-0003 anchor hardening and DOM selectors
WP-0002: stale vs unresolved resolution, orphaned flag/helper, bounded fuzzy quote recovery, and PdfViewerAdapter promotion with resolveSelectors integration. WP-0003: DomSelectionCapture, DOM create/resolve ladder, HtmlViewerAdapter under evidence-anchor/dom, and finished workplans. Verification: 42 anchor tests, citation-evidence typecheck + 51 tests green.
This commit is contained in:
parent
e4582b52fa
commit
a1af0c6a45
22 changed files with 1118 additions and 105 deletions
|
|
@ -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",
|
||||
|
|
|
|||
30
src/dom/dom-path.test.ts
Normal file
30
src/dom/dom-path.test.ts
Normal file
|
|
@ -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 = "<article><p>Hello <b>world</b></p></article>";
|
||||
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 = "<p>The quick brown fox</p>";
|
||||
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();
|
||||
});
|
||||
});
|
||||
58
src/dom/dom-path.ts
Normal file
58
src/dom/dom-path.ts
Normal file
|
|
@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
156
src/dom/html-viewer-adapter.tsx
Normal file
156
src/dom/html-viewer-adapter.tsx
Normal file
|
|
@ -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<HTMLDivElement | null>(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 = `<mark data-highlight-id="${ann.id}" data-ce-active="${activeAnnotationId === ann.id ? "true" : "false"}">${quote}</mark>`;
|
||||
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 (
|
||||
<div
|
||||
ref={rootRef}
|
||||
className="ea-html-viewer"
|
||||
onMouseUp={handleMouseUp}
|
||||
onClick={(e) => {
|
||||
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<AnchorResolution> {
|
||||
return resolveSelectors(selectors, representation);
|
||||
}
|
||||
|
||||
export async function scrollToHtmlTarget(
|
||||
root: HTMLElement,
|
||||
target: ResolvedAnchorTarget,
|
||||
representation: DocumentRepresentation,
|
||||
): Promise<void> {
|
||||
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> {
|
||||
void target;
|
||||
void opts;
|
||||
// Highlights are rendered inline during `HtmlViewerAdapter` render via stored annotations.
|
||||
}
|
||||
11
src/dom/index.ts
Normal file
11
src/dom/index.ts
Normal file
|
|
@ -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";
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
59
src/selectors/create.dom.test.ts
Normal file
59
src/selectors/create.dom.test.ts
Normal file
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
89
src/selectors/dom-path.ts
Normal file
89
src/selectors/dom-path.ts
Normal file
|
|
@ -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 };
|
||||
}
|
||||
21
src/selectors/fuzzy.test.ts
Normal file
21
src/selectors/fuzzy.test.ts
Normal file
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
113
src/selectors/fuzzy.ts
Normal file
113
src/selectors/fuzzy.ts
Normal file
|
|
@ -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)]!;
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
34
src/selectors/orphan.test.ts
Normal file
34
src/selectors/orphan.test.ts
Normal file
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
10
src/selectors/orphan.ts
Normal file
10
src/selectors/orphan.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
77
src/selectors/resolve.dom.test.ts
Normal file
77
src/selectors/resolve.dom.test.ts
Normal file
|
|
@ -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"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
24
src/types.ts
24
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 {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue