Implement ESRC-WP-0002/0003/0004: HTML/MD ingest, metadata, recovery
Some checks failed
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Failing after 15m18s

Add ingestHtml and ingestMarkdown with ADR-0003 pageless offset semantics,
PDF intrinsic metadata extraction with caller-wins merge (WP-0003), and
citation recovery primitives including re-ingest reconcile, local quote
search, and pluggable discovery hooks (ADR-0004). Mark all three workplans
finished with contract tests (80 passing).
This commit is contained in:
tegwick 2026-07-09 01:48:28 +02:00
parent df6acad828
commit 97437dfa18
28 changed files with 1295 additions and 69 deletions

58
src/html/extract.ts Normal file
View file

@ -0,0 +1,58 @@
/**
* HTML text extraction canonical text + gap-free offset map.
*
* Implements ADR-0003 for the `html-dom` representation. Active content is
* stripped before text extraction; canonical text is produced via `normalize()`.
*/
import { normalize, type OffsetMap } from "@citation-evidence/engine/shared";
import { buildWholeTextOffsetMap } from "../shared/offset-map";
const ACTIVE_CONTENT =
/<(script|style|iframe|object|embed|noscript)\b[^>]*>[\s\S]*?<\/\1>/gi;
const BLOCK_BREAK = /<\/(p|div|h[1-6]|li|tr|section|article|blockquote|pre)>/gi;
const BR_TAG = /<br\s*\/?>/gi;
const TAG = /<[^>]+>/g;
export interface HtmlExtractionResult {
readonly canonicalText: string;
readonly sanitizedHtml: string;
readonly offsetMap: OffsetMap;
}
export function extractHtml(source: string): HtmlExtractionResult {
const sanitizedHtml = sanitizeHtml(source);
const rawText = htmlToPlainText(sanitizedHtml);
const canonicalText = normalize(rawText).text;
return {
canonicalText,
sanitizedHtml,
offsetMap: buildWholeTextOffsetMap(canonicalText),
};
}
export function sanitizeHtml(source: string): string {
return source.replace(ACTIVE_CONTENT, "");
}
function htmlToPlainText(html: string): string {
let text = html.replace(BR_TAG, "\n").replace(BLOCK_BREAK, "\n\n");
text = text.replace(TAG, "");
return decodeHtmlEntities(text);
}
function decodeHtmlEntities(text: string): string {
return text
.replace(/&nbsp;/gi, " ")
.replace(/&amp;/gi, "&")
.replace(/&lt;/gi, "<")
.replace(/&gt;/gi, ">")
.replace(/&quot;/gi, '"')
.replace(/&#39;/g, "'")
.replace(/&#x([0-9a-f]+);/gi, (_, hex: string) =>
String.fromCodePoint(Number.parseInt(hex, 16)),
)
.replace(/&#(\d+);/g, (_, dec: string) =>
String.fromCodePoint(Number.parseInt(dec, 10)),
);
}

54
src/html/ingest.test.ts Normal file
View file

@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest";
import { fingerprintBytes } from "../pdf/fingerprint";
import { HTML_FIXTURE } from "../../tests/fixtures-text";
import { ingestHtml } from "./ingest";
import { extractHtml, sanitizeHtml } from "./extract";
describe("extractHtml", () => {
it("strips active content before text extraction", () => {
const { sanitizedHtml } = extractHtml(HTML_FIXTURE);
expect(sanitizeHtml(HTML_FIXTURE)).not.toMatch(/<script/i);
expect(sanitizedHtml).not.toMatch(/<script/i);
});
it("produces normalized canonical text with the known-good quote", () => {
const { canonicalText } = extractHtml(HTML_FIXTURE);
expect(canonicalText).toContain("Known good quote for HTML ingest testing.");
expect(canonicalText).toContain("Hello World");
});
});
describe("ingestHtml", () => {
const bytes = new TextEncoder().encode(HTML_FIXTURE);
it("produces a Document with text/html media type and SHA-256 fingerprint", async () => {
const { document } = await ingestHtml(bytes, { filename: "sample.html" });
expect(document.mediaType).toBe("text/html");
expect(document.fingerprint).toMatch(/^[0-9a-f]{64}$/);
expect(document.title).toBe("sample.html");
expect(document.fingerprint).toBe(await fingerprintBytes(bytes));
});
it("produces an html-dom representation with a gap-free offset map", async () => {
const { representation } = await ingestHtml(HTML_FIXTURE);
const text = representation.canonicalText ?? "";
const offsets = representation.offsetMap ?? [];
expect(representation.representationType).toBe("html-dom");
expect(representation.pageMap).toBeUndefined();
expect(offsets).toHaveLength(1);
expect(offsets[0]!.page).toBe(1);
expect(offsets[0]!.globalStart).toBe(0);
expect(offsets[0]!.globalEnd).toBe(text.length);
expect(text).toContain("Known good quote for HTML ingest testing.");
});
it("accepts string input and propagates uri/metadata", async () => {
const { document } = await ingestHtml(HTML_FIXTURE, {
uri: "file:///sample.html",
metadata: { source: "test" },
});
expect(document.uri).toBe("file:///sample.html");
expect(document.metadata).toEqual({ source: "test" });
});
});

81
src/html/ingest.ts Normal file
View file

@ -0,0 +1,81 @@
/**
* HTML ingest pipeline `{ document, representation }`.
*
* Implements ADR-0003 for the `html-dom` representation type.
*/
import {
newId,
type Document,
type DocumentRepresentation,
} from "@citation-evidence/engine/shared";
import { fingerprintBytes } from "../pdf/fingerprint";
import { toBytes, type ByteInput } from "../shared/to-bytes";
import { extractHtml } from "./extract";
const HTML_MEDIA_TYPE = "text/html";
export interface IngestHtmlOptions {
readonly filename?: string;
readonly title?: string;
readonly uri?: string;
readonly metadata?: Readonly<Record<string, unknown>>;
}
export interface IngestHtmlResult {
readonly document: Document;
readonly representation: DocumentRepresentation;
}
export type IngestHtmlInput = ByteInput | string;
export async function ingestHtml(
input: IngestHtmlInput,
options: IngestHtmlOptions = {},
): Promise<IngestHtmlResult> {
const { bytes, source } = await resolveInput(input);
const [fingerprint, extraction] = await Promise.all([
fingerprintBytes(bytes),
Promise.resolve(extractHtml(source)),
]);
const now = new Date().toISOString();
const documentId = newId("document");
const representationId = newId("representation");
const title = options.title ?? options.filename;
const document: Document = {
id: documentId,
mediaType: HTML_MEDIA_TYPE,
fingerprint,
createdAt: now,
updatedAt: now,
...(title !== undefined ? { title } : {}),
...(options.uri !== undefined ? { uri: options.uri } : {}),
...(options.metadata !== undefined ? { metadata: options.metadata } : {}),
};
const representation: DocumentRepresentation = {
id: representationId,
documentId,
representationType: "html-dom",
contentHash: fingerprint,
canonicalText: extraction.canonicalText,
offsetMap: extraction.offsetMap,
generatedAt: now,
};
return { document, representation };
}
async function resolveInput(
input: IngestHtmlInput,
): Promise<{ bytes: Uint8Array; source: string }> {
if (typeof input === "string") {
const bytes = new TextEncoder().encode(input);
return { bytes, source: input };
}
const bytes = await toBytes(input);
const source = new TextDecoder("utf-8", { fatal: false }).decode(bytes);
return { bytes, source };
}

View file

@ -1,7 +1,7 @@
/**
* `evidence-source` headless document ingest core (PDF slice).
* `evidence-source` headless document ingest core.
*
* This entry point exposes only runtime-agnostic ingest: pass PDF bytes,
* This entry point exposes only runtime-agnostic ingest: pass document bytes,
* receive a `{ document, representation }` pair built entirely from
* engine-owned contracts (`@citation-evidence/engine/shared`). No browser,
* viewer, or persistence concerns leak through here.
@ -17,4 +17,50 @@ export {
type IngestPdfResult,
} from "./pdf/ingest";
export { extractPdf, type PdfExtractionResult } from "./pdf/extract";
export {
extractPdfMetadata,
mergeDocumentFields,
type MergeDocumentFieldOptions,
type MergedDocumentFields,
type PdfIntrinsicMetadata,
} from "./pdf/metadata";
export { fingerprintBytes } from "./pdf/fingerprint";
export {
ingestHtml,
type IngestHtmlInput,
type IngestHtmlOptions,
type IngestHtmlResult,
} from "./html/ingest";
export { extractHtml, sanitizeHtml, type HtmlExtractionResult } from "./html/extract";
export {
ingestMarkdown,
type IngestMarkdownInput,
type IngestMarkdownOptions,
type IngestMarkdownResult,
} from "./markdown/ingest";
export { extractMarkdown, type MarkdownExtractionResult } from "./markdown/extract";
export {
applyLocalQuoteSearch,
createRecoveryAttempt,
createSourceDiscoveryRegistry,
reconcileFingerprint,
reIngestAndCompare,
recoveryStateAfterLocalSearch,
searchCanonicalQuote,
withRecoveryState,
type CitationClue,
type CitationRecoveryState,
type DiscoveryCandidate,
type IngestFn,
type IngestOutcome,
type QuoteMatch,
type ReIngestInput,
type ReIngestResult,
type ReconcileResult,
type RecoveryAttempt,
type SourceDiscoveryHook,
type SourceDiscoveryRegistry,
} from "./recovery";

53
src/markdown/extract.ts Normal file
View file

@ -0,0 +1,53 @@
/**
* Markdown text extraction canonical text + gap-free offset map.
*
* Implements ADR-0003 for the `markdown-rendered` representation. Markup is
* stripped to plain text before `normalize()` is applied.
*/
import { normalize, type OffsetMap } from "@citation-evidence/engine/shared";
import { buildWholeTextOffsetMap } from "../shared/offset-map";
const FENCED_CODE = /```[\s\S]*?```/g;
const INLINE_CODE = /`([^`]+)`/g;
const IMAGE = /!\[([^\]]*)\]\([^)]+\)/g;
const LINK = /\[([^\]]+)\]\([^)]+\)/g;
const HEADING = /^#{1,6}\s+/gm;
const BLOCKQUOTE = /^>\s?/gm;
const LIST_MARKER = /^[\s]*[-*+]\s+/gm;
const ORDERED_LIST = /^[\s]*\d+\.\s+/gm;
const EMPHASIS = /(\*\*|__)(.*?)\1/g;
const ITALIC = /(\*|_)([^*_]+)\1/g;
const STRIKETHROUGH = /~~(.*?)~~/g;
const HORIZONTAL_RULE = /^[-*_]{3,}\s*$/gm;
export interface MarkdownExtractionResult {
readonly canonicalText: string;
readonly offsetMap: OffsetMap;
}
export function extractMarkdown(source: string): MarkdownExtractionResult {
const rawText = markdownToPlainText(source);
const canonicalText = normalize(rawText).text;
return {
canonicalText,
offsetMap: buildWholeTextOffsetMap(canonicalText),
};
}
function markdownToPlainText(source: string): string {
let text = source;
text = text.replace(FENCED_CODE, "\n\n");
text = text.replace(INLINE_CODE, "$1");
text = text.replace(IMAGE, "$1");
text = text.replace(LINK, "$1");
text = text.replace(HEADING, "");
text = text.replace(BLOCKQUOTE, "");
text = text.replace(LIST_MARKER, "");
text = text.replace(ORDERED_LIST, "");
text = text.replace(EMPHASIS, "$2");
text = text.replace(ITALIC, "$2");
text = text.replace(STRIKETHROUGH, "$1");
text = text.replace(HORIZONTAL_RULE, "\n\n");
return text;
}

View file

@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";
import { fingerprintBytes } from "../pdf/fingerprint";
import { MARKDOWN_FIXTURE } from "../../tests/fixtures-text";
import { ingestMarkdown } from "./ingest";
import { extractMarkdown } from "./extract";
describe("extractMarkdown", () => {
it("strips markup and preserves the known-good quote", () => {
const { canonicalText } = extractMarkdown(MARKDOWN_FIXTURE);
expect(canonicalText).toContain("Known good quote for Markdown ingest testing.");
expect(canonicalText).toContain("bold");
expect(canonicalText).toContain("italic");
expect(canonicalText).not.toContain("**");
});
});
describe("ingestMarkdown", () => {
const bytes = new TextEncoder().encode(MARKDOWN_FIXTURE);
it("produces a Document with text/markdown media type and SHA-256 fingerprint", async () => {
const { document } = await ingestMarkdown(bytes, { filename: "sample.md" });
expect(document.mediaType).toBe("text/markdown");
expect(document.fingerprint).toMatch(/^[0-9a-f]{64}$/);
expect(document.title).toBe("sample.md");
expect(document.fingerprint).toBe(await fingerprintBytes(bytes));
});
it("produces a markdown-rendered representation with a gap-free offset map", async () => {
const { representation } = await ingestMarkdown(MARKDOWN_FIXTURE);
const text = representation.canonicalText ?? "";
const offsets = representation.offsetMap ?? [];
expect(representation.representationType).toBe("markdown-rendered");
expect(representation.pageMap).toBeUndefined();
expect(offsets).toHaveLength(1);
expect(offsets[0]!.page).toBe(1);
expect(offsets[0]!.globalStart).toBe(0);
expect(offsets[0]!.globalEnd).toBe(text.length);
expect(text).toContain("Known good quote for Markdown ingest testing.");
});
it("uses explicit title over filename", async () => {
const { document } = await ingestMarkdown(bytes, {
filename: "sample.md",
title: "Custom Title",
});
expect(document.title).toBe("Custom Title");
});
});

81
src/markdown/ingest.ts Normal file
View file

@ -0,0 +1,81 @@
/**
* Markdown ingest pipeline `{ document, representation }`.
*
* Implements ADR-0003 for the `markdown-rendered` representation type.
*/
import {
newId,
type Document,
type DocumentRepresentation,
} from "@citation-evidence/engine/shared";
import { fingerprintBytes } from "../pdf/fingerprint";
import { toBytes, type ByteInput } from "../shared/to-bytes";
import { extractMarkdown } from "./extract";
const MARKDOWN_MEDIA_TYPE = "text/markdown";
export interface IngestMarkdownOptions {
readonly filename?: string;
readonly title?: string;
readonly uri?: string;
readonly metadata?: Readonly<Record<string, unknown>>;
}
export interface IngestMarkdownResult {
readonly document: Document;
readonly representation: DocumentRepresentation;
}
export type IngestMarkdownInput = ByteInput | string;
export async function ingestMarkdown(
input: IngestMarkdownInput,
options: IngestMarkdownOptions = {},
): Promise<IngestMarkdownResult> {
const { bytes, source } = await resolveInput(input);
const [fingerprint, extraction] = await Promise.all([
fingerprintBytes(bytes),
Promise.resolve(extractMarkdown(source)),
]);
const now = new Date().toISOString();
const documentId = newId("document");
const representationId = newId("representation");
const title = options.title ?? options.filename;
const document: Document = {
id: documentId,
mediaType: MARKDOWN_MEDIA_TYPE,
fingerprint,
createdAt: now,
updatedAt: now,
...(title !== undefined ? { title } : {}),
...(options.uri !== undefined ? { uri: options.uri } : {}),
...(options.metadata !== undefined ? { metadata: options.metadata } : {}),
};
const representation: DocumentRepresentation = {
id: representationId,
documentId,
representationType: "markdown-rendered",
contentHash: fingerprint,
canonicalText: extraction.canonicalText,
offsetMap: extraction.offsetMap,
generatedAt: now,
};
return { document, representation };
}
async function resolveInput(
input: IngestMarkdownInput,
): Promise<{ bytes: Uint8Array; source: string }> {
if (typeof input === "string") {
const bytes = new TextEncoder().encode(input);
return { bytes, source: input };
}
const bytes = await toBytes(input);
const source = new TextDecoder("utf-8", { fatal: false }).decode(bytes);
return { bytes, source };
}

View file

@ -18,6 +18,8 @@ import {
} from "@citation-evidence/engine/shared";
import { extractPdf } from "./extract";
import { fingerprintBytes } from "./fingerprint";
import { extractPdfMetadata, mergeDocumentFields } from "./metadata";
import { toBytes, type ByteInput } from "../shared/to-bytes";
const PDF_MEDIA_TYPE = "application/pdf";
@ -37,22 +39,23 @@ export interface IngestPdfResult {
readonly representation: DocumentRepresentation;
}
export type IngestPdfInput = Uint8Array | ArrayBuffer | Blob;
export type IngestPdfInput = ByteInput;
export async function ingestPdf(
input: IngestPdfInput,
options: IngestPdfOptions = {},
): Promise<IngestPdfResult> {
const bytes = await toBytes(input);
const [fingerprint, extraction] = await Promise.all([
const [fingerprint, extraction, intrinsicMetadata] = await Promise.all([
fingerprintBytes(bytes),
extractPdf(bytes),
extractPdfMetadata(bytes),
]);
const now = new Date().toISOString();
const documentId = newId("document");
const representationId = newId("representation");
const title = options.title ?? options.filename;
const merged = mergeDocumentFields(intrinsicMetadata, options);
const document: Document = {
id: documentId,
@ -60,9 +63,7 @@ export async function ingestPdf(
fingerprint,
createdAt: now,
updatedAt: now,
...(title !== undefined ? { title } : {}),
...(options.uri !== undefined ? { uri: options.uri } : {}),
...(options.metadata !== undefined ? { metadata: options.metadata } : {}),
...merged,
};
const representation: DocumentRepresentation = {
@ -78,11 +79,3 @@ export async function ingestPdf(
return { document, representation };
}
async function toBytes(input: IngestPdfInput): Promise<Uint8Array> {
if (input instanceof Uint8Array) return input;
if (input instanceof ArrayBuffer) return new Uint8Array(input);
// Blob (covers `File` in browsers — File extends Blob).
const buf = await input.arrayBuffer();
return new Uint8Array(buf);
}

77
src/pdf/metadata.test.ts Normal file
View file

@ -0,0 +1,77 @@
import { describe, expect, it } from "vitest";
import {
extractPdfMetadata,
mergeDocumentFields,
type PdfIntrinsicMetadata,
} from "./metadata";
import {
fixtureBytes,
fixtureCorpusAvailable,
loadFixtures,
} from "../../tests/fixtures";
describe("mergeDocumentFields", () => {
const intrinsic: PdfIntrinsicMetadata = {
title: "Intrinsic Title",
author: "Intrinsic Author",
subject: "Intrinsic Subject",
creationDate: "2024-01-01T00:00:00.000Z",
xmp: { "dc:title": "XMP Title" },
};
it("prefers caller title over intrinsic title and filename", () => {
const merged = mergeDocumentFields(intrinsic, {
filename: "file.pdf",
title: "Caller Title",
});
expect(merged.title).toBe("Caller Title");
});
it("falls back to filename when caller title is absent", () => {
const merged = mergeDocumentFields(intrinsic, { filename: "file.pdf" });
expect(merged.title).toBe("file.pdf");
});
it("falls back to intrinsic title when caller title and filename are absent", () => {
const merged = mergeDocumentFields(intrinsic);
expect(merged.title).toBe("Intrinsic Title");
});
it("merges intrinsic metadata under caller metadata with caller winning", () => {
const merged = mergeDocumentFields(intrinsic, {
metadata: { author: "Caller Author", source: "test" },
});
expect(merged.metadata).toEqual({
author: "Caller Author",
subject: "Intrinsic Subject",
creationDate: "2024-01-01T00:00:00.000Z",
xmp: { "dc:title": "XMP Title" },
source: "test",
});
});
it("omits metadata when neither intrinsic nor caller metadata is present", () => {
const merged = mergeDocumentFields({});
expect(merged.metadata).toBeUndefined();
});
});
const hasCorpus = fixtureCorpusAvailable();
const FIXTURES = hasCorpus ? loadFixtures() : [];
describe.skipIf(!hasCorpus)("extractPdfMetadata — fixture corpus", { timeout: 30_000 }, () => {
for (const fixture of FIXTURES) {
it(`${fixture.id} extracts without throwing and caller overrides intrinsic title`, async () => {
const bytes = fixtureBytes(fixture.filename);
const intrinsic = await extractPdfMetadata(bytes);
expect(intrinsic).toBeTypeOf("object");
const merged = mergeDocumentFields(intrinsic, {
filename: fixture.filename,
title: "Override Title",
});
expect(merged.title).toBe("Override Title");
});
}
});

185
src/pdf/metadata.ts Normal file
View file

@ -0,0 +1,185 @@
/**
* PDF intrinsic metadata extraction and merge precedence.
*
* Implements ESRC-WP-0003: extract info-dictionary and XMP fields via PDF.js,
* then merge into the Document record with caller-supplied values winning.
*/
import { getDocument } from "pdfjs-dist";
import type { Metadata } from "pdfjs-dist";
export interface PdfIntrinsicMetadata {
readonly title?: string;
readonly author?: string;
readonly subject?: string;
readonly keywords?: string;
readonly creator?: string;
readonly producer?: string;
readonly creationDate?: string;
readonly modificationDate?: string;
readonly xmp?: Readonly<Record<string, string>>;
}
export interface MergedDocumentFields {
readonly title?: string;
readonly uri?: string;
readonly metadata?: Readonly<Record<string, unknown>>;
}
export interface MergeDocumentFieldOptions {
readonly filename?: string;
readonly title?: string;
readonly uri?: string;
readonly metadata?: Readonly<Record<string, unknown>>;
}
export async function extractPdfMetadata(
bytes: Uint8Array,
): Promise<PdfIntrinsicMetadata> {
const data = new Uint8Array(bytes);
const loadingTask = getDocument({ data });
const doc = await loadingTask.promise;
try {
const { info, metadata } = await doc.getMetadata();
return normalizePdfMetadata(info as Record<string, unknown>, metadata);
} finally {
await doc.destroy();
}
}
export function mergeDocumentFields(
intrinsic: PdfIntrinsicMetadata,
options: MergeDocumentFieldOptions = {},
): MergedDocumentFields {
const intrinsicRecord = intrinsicMetadataRecord(intrinsic);
const mergedMetadata =
Object.keys(intrinsicRecord).length > 0 || options.metadata !== undefined
? { ...intrinsicRecord, ...options.metadata }
: undefined;
const title =
options.title ??
options.filename ??
intrinsic.title;
return {
...(title !== undefined ? { title } : {}),
...(options.uri !== undefined ? { uri: options.uri } : {}),
...(mergedMetadata !== undefined ? { metadata: mergedMetadata } : {}),
};
}
function normalizePdfMetadata(
info: Record<string, unknown>,
metadata: Metadata | null,
): PdfIntrinsicMetadata {
const result: {
title?: string;
author?: string;
subject?: string;
keywords?: string;
creator?: string;
producer?: string;
creationDate?: string;
modificationDate?: string;
xmp?: Record<string, string>;
} = {};
const title = stringField(info, "Title");
if (title !== undefined) result.title = title;
const author = stringField(info, "Author");
if (author !== undefined) result.author = author;
const subject = stringField(info, "Subject");
if (subject !== undefined) result.subject = subject;
const keywords = keywordsField(info.Keywords);
if (keywords !== undefined) result.keywords = keywords;
const creator = stringField(info, "Creator");
if (creator !== undefined) result.creator = creator;
const producer = stringField(info, "Producer");
if (producer !== undefined) result.producer = producer;
const creationDate = pdfDateField(info.CreationDate);
if (creationDate !== undefined) result.creationDate = creationDate;
const modificationDate = pdfDateField(info.ModDate);
if (modificationDate !== undefined) result.modificationDate = modificationDate;
const xmp = xmpFields(metadata);
if (xmp !== undefined) result.xmp = xmp;
return result;
}
function intrinsicMetadataRecord(
intrinsic: PdfIntrinsicMetadata,
): Record<string, unknown> {
const record: Record<string, unknown> = {};
if (intrinsic.author !== undefined) record.author = intrinsic.author;
if (intrinsic.subject !== undefined) record.subject = intrinsic.subject;
if (intrinsic.keywords !== undefined) record.keywords = intrinsic.keywords;
if (intrinsic.creator !== undefined) record.creator = intrinsic.creator;
if (intrinsic.producer !== undefined) record.producer = intrinsic.producer;
if (intrinsic.creationDate !== undefined) {
record.creationDate = intrinsic.creationDate;
}
if (intrinsic.modificationDate !== undefined) {
record.modificationDate = intrinsic.modificationDate;
}
if (intrinsic.xmp !== undefined) record.xmp = intrinsic.xmp;
return record;
}
function stringField(
info: Record<string, unknown>,
key: string,
): string | undefined {
const value = info[key];
if (typeof value !== "string") return undefined;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
function keywordsField(value: unknown): string | undefined {
if (typeof value === "string") {
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
if (Array.isArray(value)) {
const parts = value
.filter((entry): entry is string => typeof entry === "string")
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
return parts.length > 0 ? parts.join(", ") : undefined;
}
return undefined;
}
function pdfDateField(value: unknown): string | undefined {
if (typeof value !== "string") return undefined;
const trimmed = value.trim();
if (trimmed.length === 0) return undefined;
const match = /^D:(\d{4})(\d{2})(\d{2})(\d{2})?(\d{2})?(\d{2})?/.exec(trimmed);
if (!match) return trimmed;
const [, year, month, day, hour = "00", minute = "00", second = "00"] = match;
const iso = `${year}-${month}-${day}T${hour}:${minute}:${second}Z`;
const parsed = new Date(iso);
return Number.isNaN(parsed.getTime()) ? trimmed : parsed.toISOString();
}
function xmpFields(metadata: Metadata | null): Record<string, string> | undefined {
if (metadata === null) return undefined;
const all = metadata.getAll() as Record<string, unknown>;
const xmp: Record<string, string> = {};
for (const [key, value] of Object.entries(all)) {
if (typeof value === "string" && value.trim().length > 0) {
xmp[key] = value.trim();
}
}
return Object.keys(xmp).length > 0 ? xmp : undefined;
}

39
src/recovery/attempt.ts Normal file
View file

@ -0,0 +1,39 @@
import { newId } from "@citation-evidence/engine/shared";
import type { CitationClue, CitationRecoveryState } from "./types";
export interface RecoveryAttempt {
readonly id: string;
readonly state: CitationRecoveryState;
readonly clue: CitationClue;
readonly createdAt: string;
readonly updatedAt: string;
readonly documentId?: string;
readonly notes: readonly string[];
}
export function createRecoveryAttempt(clue: CitationClue): RecoveryAttempt {
const now = new Date().toISOString();
return {
id: newId("citation-recovery"),
state: "created",
clue,
createdAt: now,
updatedAt: now,
notes: [],
};
}
export function withRecoveryState(
attempt: RecoveryAttempt,
state: CitationRecoveryState,
note?: string,
): RecoveryAttempt {
const now = new Date().toISOString();
return {
...attempt,
state,
updatedAt: now,
notes: note === undefined ? attempt.notes : [...attempt.notes, note],
};
}

37
src/recovery/discovery.ts Normal file
View file

@ -0,0 +1,37 @@
import type { CitationClue } from "./types";
export interface DiscoveryCandidate {
readonly uri: string;
readonly title?: string;
readonly confidence: number;
readonly source: "local" | "external";
readonly metadata?: Readonly<Record<string, unknown>>;
}
/** Pluggable discovery provider — implementations may perform network I/O. */
export interface SourceDiscoveryHook {
readonly name: string;
discover(clue: CitationClue): Promise<readonly DiscoveryCandidate[]>;
}
export interface SourceDiscoveryRegistry {
register(hook: SourceDiscoveryHook): void;
discover(clue: CitationClue): Promise<readonly DiscoveryCandidate[]>;
}
export function createSourceDiscoveryRegistry(): SourceDiscoveryRegistry {
const hooks: SourceDiscoveryHook[] = [];
return {
register(hook: SourceDiscoveryHook) {
hooks.push(hook);
},
async discover(clue: CitationClue): Promise<readonly DiscoveryCandidate[]> {
const results = await Promise.all(hooks.map((hook) => hook.discover(clue)));
return results
.flat()
.sort((a, b) => b.confidence - a.confidence);
},
};
}

26
src/recovery/index.ts Normal file
View file

@ -0,0 +1,26 @@
export type { CitationClue, CitationRecoveryState } from "./types";
export {
createRecoveryAttempt,
withRecoveryState,
type RecoveryAttempt,
} from "./attempt";
export { reconcileFingerprint, type ReconcileResult } from "./reconcile";
export {
reIngestAndCompare,
type IngestFn,
type IngestOutcome,
type ReIngestInput,
type ReIngestResult,
} from "./re-ingest";
export {
applyLocalQuoteSearch,
recoveryStateAfterLocalSearch,
searchCanonicalQuote,
type QuoteMatch,
} from "./local-search";
export {
createSourceDiscoveryRegistry,
type DiscoveryCandidate,
type SourceDiscoveryHook,
type SourceDiscoveryRegistry,
} from "./discovery";

View file

@ -0,0 +1,65 @@
import { normalize, type DocumentRepresentation } from "@citation-evidence/engine/shared";
import type { CitationRecoveryState } from "./types";
import { withRecoveryState, type RecoveryAttempt } from "./attempt";
export interface QuoteMatch {
readonly start: number;
readonly end: number;
readonly matchedText: string;
}
export function searchCanonicalQuote(
representation: Pick<DocumentRepresentation, "canonicalText">,
quote: string,
): readonly QuoteMatch[] {
const canonicalText = representation.canonicalText ?? "";
const normalizedQuote = normalize(quote).text;
if (normalizedQuote.length === 0 || canonicalText.length === 0) return [];
const normalizedCanonical = normalize(canonicalText).text;
const matches: QuoteMatch[] = [];
let from = 0;
while (from <= normalizedCanonical.length) {
const index = normalizedCanonical.indexOf(normalizedQuote, from);
if (index === -1) break;
matches.push({
start: index,
end: index + normalizedQuote.length,
matchedText: normalizedCanonical.slice(index, index + normalizedQuote.length),
});
from = index + 1;
}
return matches;
}
export function applyLocalQuoteSearch(
attempt: RecoveryAttempt,
representation: Pick<DocumentRepresentation, "canonicalText">,
): RecoveryAttempt {
const quote = attempt.clue.quote;
if (quote === undefined || quote.trim().length === 0) {
return withRecoveryState(attempt, "quote-not-found", "No quote in clue");
}
const matches = searchCanonicalQuote(representation, quote);
if (matches.length === 1) {
return withRecoveryState(attempt, "quote-found", "Exact quote match");
}
if (matches.length > 1) {
return withRecoveryState(
attempt,
"candidate-passages-found",
`${matches.length} quote matches`,
);
}
return withRecoveryState(attempt, "quote-not-found", "Quote not found locally");
}
export function recoveryStateAfterLocalSearch(
matchCount: number,
): CitationRecoveryState {
if (matchCount === 1) return "quote-found";
if (matchCount > 1) return "candidate-passages-found";
return "quote-not-found";
}

33
src/recovery/re-ingest.ts Normal file
View file

@ -0,0 +1,33 @@
import type { Document, DocumentRepresentation } from "@citation-evidence/engine/shared";
import { reconcileFingerprint, type ReconcileResult } from "./reconcile";
export interface IngestOutcome {
readonly document: Document;
readonly representation: DocumentRepresentation;
}
export type IngestFn<TOptions = void> = (
bytes: Uint8Array,
options?: TOptions,
) => Promise<IngestOutcome>;
export interface ReIngestInput<TOptions = void> {
readonly previous: Document;
readonly bytes: Uint8Array;
readonly options?: TOptions;
}
export interface ReIngestResult {
readonly outcome: IngestOutcome;
readonly reconcile: ReconcileResult;
}
export async function reIngestAndCompare<TOptions>(
input: ReIngestInput<TOptions>,
ingest: IngestFn<TOptions>,
): Promise<ReIngestResult> {
const outcome = await ingest(input.bytes, input.options);
const reconcile = reconcileFingerprint(input.previous, outcome.document);
return { outcome, reconcile };
}

27
src/recovery/reconcile.ts Normal file
View file

@ -0,0 +1,27 @@
import type { Document } from "@citation-evidence/engine/shared";
export interface ReconcileResult {
readonly unchanged: boolean;
readonly previousFingerprint?: string;
readonly currentFingerprint: string;
readonly requiresReanchor: boolean;
}
export function reconcileFingerprint(
previous: Pick<Document, "fingerprint">,
current: Pick<Document, "fingerprint">,
): ReconcileResult {
const previousFingerprint = previous.fingerprint;
const currentFingerprint = current.fingerprint ?? "";
const unchanged =
previousFingerprint !== undefined &&
currentFingerprint.length > 0 &&
previousFingerprint === currentFingerprint;
return {
unchanged,
previousFingerprint,
currentFingerprint,
requiresReanchor: !unchanged,
};
}

View file

@ -0,0 +1,97 @@
import { describe, expect, it, vi } from "vitest";
import { HTML_FIXTURE } from "../../tests/fixtures-text";
import { ingestHtml } from "../html/ingest";
import {
applyLocalQuoteSearch,
createRecoveryAttempt,
createSourceDiscoveryRegistry,
reconcileFingerprint,
reIngestAndCompare,
recoveryStateAfterLocalSearch,
searchCanonicalQuote,
} from "./index";
describe("reconcileFingerprint", () => {
it("flags re-anchor when fingerprints differ", () => {
const result = reconcileFingerprint(
{ fingerprint: "aaa" },
{ fingerprint: "bbb" },
);
expect(result.unchanged).toBe(false);
expect(result.requiresReanchor).toBe(true);
});
it("reports unchanged when fingerprints match", () => {
const fp = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
const result = reconcileFingerprint({ fingerprint: fp }, { fingerprint: fp });
expect(result.unchanged).toBe(true);
expect(result.requiresReanchor).toBe(false);
});
});
describe("searchCanonicalQuote", () => {
it("finds normalized quote matches in canonical text", async () => {
const { representation } = await ingestHtml(HTML_FIXTURE);
const matches = searchCanonicalQuote(
representation,
"Known good quote for HTML ingest testing.",
);
expect(matches).toHaveLength(1);
expect(matches[0]!.matchedText).toContain("Known good quote");
});
it("maps match counts to recovery states", () => {
expect(recoveryStateAfterLocalSearch(1)).toBe("quote-found");
expect(recoveryStateAfterLocalSearch(2)).toBe("candidate-passages-found");
expect(recoveryStateAfterLocalSearch(0)).toBe("quote-not-found");
});
});
describe("applyLocalQuoteSearch", () => {
it("updates attempt state from local quote search", async () => {
const { representation } = await ingestHtml(HTML_FIXTURE);
const attempt = createRecoveryAttempt({
quote: "Known good quote for HTML ingest testing.",
});
const updated = applyLocalQuoteSearch(attempt, representation);
expect(updated.state).toBe("quote-found");
});
});
describe("reIngestAndCompare", () => {
it("detects unchanged document on identical bytes", async () => {
const bytes = new TextEncoder().encode(HTML_FIXTURE);
const first = await ingestHtml(bytes);
const { reconcile } = await reIngestAndCompare(
{ previous: first.document, bytes },
ingestHtml,
);
expect(reconcile.unchanged).toBe(true);
expect(reconcile.requiresReanchor).toBe(false);
});
});
describe("createSourceDiscoveryRegistry", () => {
it("aggregates provider results sorted by confidence", async () => {
const registry = createSourceDiscoveryRegistry();
registry.register({
name: "low",
discover: vi.fn(async () => [
{ uri: "file:///a", confidence: 0.2, source: "local" },
]),
});
registry.register({
name: "high",
discover: vi.fn(async () => [
{ uri: "https://example.com/doc", confidence: 0.9, source: "external" },
]),
});
const candidates = await registry.discover({ title: "Example" });
expect(candidates).toHaveLength(2);
expect(candidates[0]!.confidence).toBe(0.9);
expect(candidates[1]!.confidence).toBe(0.2);
});
});

31
src/recovery/types.ts Normal file
View file

@ -0,0 +1,31 @@
/**
* Citation recovery vocabulary aligned with citation-engine SharedContracts §2.6.
*
* The canonical `CitationRecoveryAttempt` type will live in citation-engine;
* this repo uses the same state strings for local attempt records.
*/
export type CitationRecoveryState =
| "created"
| "source-found-fulltext"
| "source-found-preview-only"
| "source-found-metadata-only"
| "source-not-found"
| "quote-found"
| "quote-not-found"
| "candidate-passages-found"
| "manual-confirmation-needed"
| "confirmed"
| "annotation-created"
| "failed";
/** Clue supplied when starting or continuing a recovery attempt. */
export interface CitationClue {
readonly quote?: string;
readonly prefix?: string;
readonly suffix?: string;
readonly title?: string;
readonly uri?: string;
readonly doi?: string;
readonly metadata?: Readonly<Record<string, unknown>>;
}

19
src/shared/offset-map.ts Normal file
View file

@ -0,0 +1,19 @@
import type { OffsetMap } from "@citation-evidence/engine/shared";
/**
* Gap-free offset map for pageless representations (HTML, Markdown).
*
* Uses a single synthetic page (page 1) covering `[0, canonicalText.length)`.
* `pageMap` stays unset there are no physical pages.
*/
export function buildWholeTextOffsetMap(canonicalText: string): OffsetMap {
const length = canonicalText.length;
return [
{
page: 1,
globalStart: 0,
globalEnd: length,
pageLength: length,
},
];
}

9
src/shared/to-bytes.ts Normal file
View file

@ -0,0 +1,9 @@
/** Runtime-agnostic byte input accepted by ingest pipelines. */
export type ByteInput = Uint8Array | ArrayBuffer | Blob;
export async function toBytes(input: ByteInput): Promise<Uint8Array> {
if (input instanceof Uint8Array) return input;
if (input instanceof ArrayBuffer) return new Uint8Array(input);
const buf = await input.arrayBuffer();
return new Uint8Array(buf);
}