This repository has been archived on 2026-07-08. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
citation-engine/src/engine/services/annotations.ts
tegwick 78085e1eb3 Extract engine from citation-evidence umbrella (CENG-WP-0001)
Bootstrap @citation-evidence/engine as a standalone TypeScript package
with shared types and engine services copied from the umbrella MVP.
All 89 tests pass with lint and typecheck clean.
2026-06-22 18:02:05 +02:00

130 lines
4.4 KiB
TypeScript

/**
* Annotation service — creates technical marks on document ranges and
* emits `AnnotationCreated`. Resolution-status updates emit
* `AnnotationResolved` / `AnnotationResolutionFailed`.
*
* Annotation creation is the engine's response to a user action in the
* viewer (T07). The viewer adapter has already turned the selection into
* `Selector[]`; this service stamps an ID, normalize-version, timestamps,
* persists, and broadcasts.
*/
import type {
Annotation,
AnnotationResolutionStatus,
} from "@shared/annotation";
import type { DocumentId, RepresentationId, AnnotationId } from "@shared/ids";
import type { Selector } from "@shared/selector";
import { newId } from "@shared/ids";
import { NORMALIZE_VERSION } from "@shared/text/normalize";
import type { EventBus } from "../events";
import type { AnnotationRepository } from "../repos";
export interface CreateAnnotationInput {
readonly documentId: DocumentId;
readonly representationId?: RepresentationId;
readonly selectors: readonly Selector[];
readonly quote?: string;
readonly note?: string;
readonly createdBy?: string;
}
export interface AnnotationService {
create(input: CreateAnnotationInput): Annotation;
get(id: AnnotationId): Annotation | null;
listByDocument(documentId: DocumentId): readonly Annotation[];
setResolutionStatus(
id: AnnotationId,
status: AnnotationResolutionStatus,
opts: { readonly confidence: number; readonly reason?: string },
): Annotation;
/**
* Edit the human-facing `quote` text on an annotation without touching
* the underlying selectors. Selectors stay the source of truth for
* locating the passage; the quote is the user's editable display copy.
*/
updateQuote(id: AnnotationId, quote: string): Annotation;
}
export function createAnnotationService(
annotations: AnnotationRepository,
bus: EventBus,
now: () => string = () => new Date().toISOString(),
): AnnotationService {
return {
create(input) {
const ts = now();
const annotation: Annotation = {
id: newId("annotation"),
documentId: input.documentId,
...(input.representationId !== undefined ? { representationId: input.representationId } : {}),
selectors: input.selectors,
...(input.quote !== undefined ? { quote: input.quote } : {}),
...(input.note !== undefined ? { note: input.note } : {}),
normalizeVersion: NORMALIZE_VERSION,
...(input.createdBy !== undefined ? { createdBy: input.createdBy } : {}),
createdAt: ts,
updatedAt: ts,
};
const stored = annotations.create(annotation);
bus.emit({ type: "AnnotationCreated", annotationId: stored.id, annotation: stored });
return stored;
},
get(id) {
return annotations.get(id);
},
listByDocument(documentId) {
return annotations.listByDocument(documentId);
},
setResolutionStatus(id, status, opts) {
const existing = annotations.get(id);
if (!existing) {
throw new Error(`AnnotationService.setResolutionStatus: unknown id ${id}`);
}
const updated: Annotation = {
...existing,
resolutionStatus: status,
updatedAt: now(),
};
const stored = annotations.update(updated);
if (status === "unresolved" || status === "stale") {
bus.emit({
type: "AnnotationResolutionFailed",
annotationId: stored.id,
reason: opts.reason ?? status,
});
} else {
bus.emit({
type: "AnnotationResolved",
annotationId: stored.id,
status,
confidence: opts.confidence,
});
}
return stored;
},
updateQuote(id, quote) {
const existing = annotations.get(id);
if (!existing) {
throw new Error(`AnnotationService.updateQuote: unknown id ${id}`);
}
const trimmed = quote.length === 0 ? undefined : quote;
const updated: Annotation = {
...existing,
// exactOptionalPropertyTypes: drop `quote` when empty rather
// than setting it to undefined.
...(trimmed !== undefined ? { quote: trimmed } : {}),
updatedAt: now(),
};
if (trimmed === undefined && "quote" in updated) {
// Remove the field outright when clearing.
delete (updated as { quote?: string }).quote;
}
const stored = annotations.update(updated);
bus.emit({ type: "AnnotationUpdated", annotationId: stored.id, annotation: stored });
return stored;
},
};
}