301e842c5c
- Consistency tab in EntitiesView surfacing findInconsistencies() results - ContextRail + MentionPopover wired into WritingView for entity context/hover - CommandPalette indexes entities and chapters for cross-entity search - link field type for entity references in character fields - findDeadCharacterMentions consistency check + tests - Expand seed with a location, note, and a natural Elias Vell mention so the dead-character-after-death case is actually exercised - Author docs/DATA_MODEL.md documenting core schemas, types, storage contract - Rename CharacterMention id attr -> entityId to match core backlinks/consistency
167 lines
5.3 KiB
TypeScript
167 lines
5.3 KiB
TypeScript
/**
|
|
* 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";
|
|
import { collectMentionsInDoc } from "./backlinks.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 interface DeadCharacterIssue {
|
|
kind: "dead-character-mentioned";
|
|
chapterId: string;
|
|
chapterTitle: string;
|
|
entityId: string;
|
|
/** The mention text, if any. */
|
|
name?: string | undefined;
|
|
}
|
|
|
|
export type ConsistencyIssue = MentionIssue | RelationshipIssue | DeadCharacterIssue;
|
|
|
|
/**
|
|
* 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 mentioned = collectMentionsInDoc(ch.doc);
|
|
for (const entityId of mentioned) {
|
|
if (!entityIds.has(entityId)) {
|
|
issues.push({
|
|
kind: "orphan-mention",
|
|
chapterId: ch.id,
|
|
chapterTitle: ch.title,
|
|
entityId,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
return issues;
|
|
}
|
|
|
|
/**
|
|
* Find characters who are marked `dead` but are still mentioned (by entity id)
|
|
* in a chapter body. A dead character should not appear alive in the story.
|
|
*
|
|
* Bare-name mentions (no `entityId`) are ignored — a reference to a name that
|
|
* is not yet an entity is an orphan-mention concern, not a dead-character one.
|
|
*/
|
|
export function findDeadCharacterMentions(
|
|
chapters: Array<{ id: string; title: string; doc: ProseMirrorDoc }>,
|
|
entities: Entity[],
|
|
): DeadCharacterIssue[] {
|
|
const dead = new Set(
|
|
entities
|
|
.filter((e): e is Character => !("fields" in e) && !("body" in e) && e.status === "dead")
|
|
.map((c) => c.id),
|
|
);
|
|
const issues: DeadCharacterIssue[] = [];
|
|
for (const chapter of chapters) {
|
|
const mentioned = collectMentionsInDoc(chapter.doc);
|
|
for (const entityId of mentioned) {
|
|
if (dead.has(entityId)) {
|
|
issues.push({
|
|
kind: "dead-character-mentioned",
|
|
chapterId: chapter.id,
|
|
chapterTitle: chapter.title,
|
|
entityId,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
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),
|
|
...findDeadCharacterMentions(chapters, entities),
|
|
];
|
|
return issues;
|
|
}
|