feat(entity): Chunk #4 data layer + entity UI (characters & world-building)
Data layer: - Fuzzy scorer (packages/core/src/fuzzy.ts) with index recovery + unit tests - Backlink computation (packages/core/src/backlinks.ts) incl. entity-level backlinks - Consistency checker (packages/core/src/consistency.ts) incl. reciprocity/scope + unit tests - StorageAdapter contract extended with listEntities/searchEntities/saveEntity/deleteEntity; implemented in Local (Dexie) and Server adapters; contract tests cover entity CRUD + ?q= search - WorldItem.fields now supports list values (keyPeople, rivals); expanded RelationshipKind - @mention autocomplete extension + picker in the editor package Entity UI (apps/web/src/features/entities/): - Generic field renderer (EntityFields) driven by ENTITY_FIELDS/FieldDef - Tabbed directory with fuzzy search + entity cards (EntitiesView) - Create/edit/delete drawer (EntityEditor) reusing shared fields - Relationship editor + force-directed SVG relationship map - Entity engine stylesheet; wired into nav Store: project-store entity methods (load/create/update/delete) over the StorageAdapter contract. CI-green: typecheck, lint, 65 tests, and web build all pass.
This commit is contained in:
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* Consistency checks across entities and chapters. These catch the kinds of
|
||||
* contradictions that creep in as a manuscript grows: a character mentioned in
|
||||
* prose whose entity was deleted, or a relationship that only one side records.
|
||||
* See CHUNK #4 step 2.4.
|
||||
*/
|
||||
import type { Character, Entity, ProseMirrorDoc } from "./types.js";
|
||||
|
||||
export type RelationshipSymmetry = "symmetric" | "directed";
|
||||
|
||||
/**
|
||||
* Relationship kinds that should hold in both directions. If Ada lists "Bram:
|
||||
* sibling", then Bram should list "Ada: sibling" too.
|
||||
*/
|
||||
export const SYMMETRIC_RELATIONSHIPS = new Set(["sibling", "friend", "spouse", "partner", "family", "rival"]);
|
||||
|
||||
export interface MentionIssue {
|
||||
kind: "orphan-mention";
|
||||
chapterId: string;
|
||||
chapterTitle: string;
|
||||
entityId: string;
|
||||
/** The mention text, if any. */
|
||||
name?: string | undefined;
|
||||
}
|
||||
|
||||
export interface RelationshipIssue {
|
||||
kind: "relationship-asymmetry";
|
||||
fromEntity: string;
|
||||
fromName: string;
|
||||
toEntity: string;
|
||||
toName: string;
|
||||
relationship: string;
|
||||
}
|
||||
|
||||
export type ConsistencyIssue = MentionIssue | RelationshipIssue;
|
||||
|
||||
/**
|
||||
* Find mention nodes that reference an entity id that no longer exists (e.g.
|
||||
* the entity was deleted). Returns one issue per offending mention.
|
||||
*/
|
||||
export function findOrphanedMentions(
|
||||
chapters: Array<{ id: string; title: string; doc: ProseMirrorDoc }>,
|
||||
entityIds: Set<string>,
|
||||
): MentionIssue[] {
|
||||
const issues: MentionIssue[] = [];
|
||||
for (const ch of chapters) {
|
||||
const visit = (content: unknown[], _path: number[]): void => {
|
||||
for (const node of content as Record<string, unknown>[]) {
|
||||
if (!node || typeof node !== "object") continue;
|
||||
if (node.type === "CharacterMention") {
|
||||
const attrs = node.attrs as { entityId?: string; name?: string } | undefined;
|
||||
if (attrs?.entityId && !entityIds.has(attrs.entityId)) {
|
||||
issues.push({
|
||||
kind: "orphan-mention",
|
||||
chapterId: ch.id,
|
||||
chapterTitle: ch.title,
|
||||
entityId: attrs.entityId,
|
||||
name: attrs.name,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (Array.isArray(node.content)) visit(node.content as unknown[], _path);
|
||||
}
|
||||
};
|
||||
visit(ch.doc.content ?? [], []);
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the `relationships` array of a character into
|
||||
* `{ [targetId]: { kind, note } }`.
|
||||
*/
|
||||
export function parseRelationships(character: Character): Record<string, { kind: string; note: string }> {
|
||||
const out: Record<string, { kind: string; note: string }> = {};
|
||||
for (const r of character.relationships ?? []) {
|
||||
out[r.targetId] = { kind: r.kind, note: r.note };
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find relationships recorded by one character but not reciprocated by the
|
||||
* other, for symmetric relationship kinds. Returns one issue per asymmetric
|
||||
* pair (reported once, from the recording side).
|
||||
*/
|
||||
export function findRelationshipAsymmetry(entities: Entity[]): RelationshipIssue[] {
|
||||
const characters = entities.filter((e): e is Character => !("fields" in e) && !("body" in e));
|
||||
// index by id -> character (targetId is an entity id)
|
||||
const byId = new Map<string, Character>();
|
||||
for (const c of characters) byId.set(c.id, c);
|
||||
|
||||
const issues: RelationshipIssue[] = [];
|
||||
for (const c of characters) {
|
||||
const rels = parseRelationships(c);
|
||||
for (const [targetId, rel] of Object.entries(rels)) {
|
||||
const sym = rel.kind.toLowerCase();
|
||||
if (!SYMMETRIC_RELATIONSHIPS.has(sym)) continue;
|
||||
const other = byId.get(targetId);
|
||||
if (!other || other.id === c.id) continue;
|
||||
// Does the other side record this character back with a symmetric kind?
|
||||
const otherRel = parseRelationships(other);
|
||||
const back = otherRel[c.id];
|
||||
if (!back || !SYMMETRIC_RELATIONSHIPS.has(back.kind.toLowerCase())) {
|
||||
issues.push({
|
||||
kind: "relationship-asymmetry",
|
||||
fromEntity: c.id,
|
||||
fromName: c.name,
|
||||
toEntity: other.id,
|
||||
toName: other.name,
|
||||
relationship: rel.kind,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
/** Run every consistency check and return the combined issue list. */
|
||||
export function findInconsistencies(
|
||||
chapters: Array<{ id: string; title: string; doc: ProseMirrorDoc }>,
|
||||
entities: Entity[],
|
||||
): ConsistencyIssue[] {
|
||||
const entityIds = new Set(entities.map((e) => e.id));
|
||||
const issues: ConsistencyIssue[] = [
|
||||
...findOrphanedMentions(chapters, entityIds),
|
||||
...findRelationshipAsymmetry(entities),
|
||||
];
|
||||
return issues;
|
||||
}
|
||||
Reference in New Issue
Block a user