generated from coulomb/repo-seed
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.
130 lines
4.4 KiB
TypeScript
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;
|
|
},
|
|
};
|
|
}
|