Complete story intelligence entity quality proof

This commit is contained in:
Nick Beckley 2026-08-28 15:13:47 +00:00
parent 3bf8f88f16
commit 9c27ba6ffa
7 changed files with 286 additions and 53 deletions

View File

@ -41,12 +41,18 @@ var tests = new (string Name, Action Test)[]
("Asset filtering preserves story assets", AssetFilteringPreservesStoryAssets), ("Asset filtering preserves story assets", AssetFilteringPreservesStoryAssets),
("Asset canonical keys merge trivial variants", AssetCanonicalKeysMergeTrivialVariants), ("Asset canonical keys merge trivial variants", AssetCanonicalKeysMergeTrivialVariants),
("Asset canonical keys merge contextual vehicle variants", AssetCanonicalKeysMergeContextualVehicleVariants), ("Asset canonical keys merge contextual vehicle variants", AssetCanonicalKeysMergeContextualVehicleVariants),
("Asset canonical keys keep unrelated owned cars distinct", AssetCanonicalKeysKeepUnrelatedOwnedCarsDistinct),
("Asset contextual names resolve POV ownership", AssetContextualNamesResolvePovOwnership), ("Asset contextual names resolve POV ownership", AssetContextualNamesResolvePovOwnership),
("Asset structural phrases trim neighbouring nouns", AssetStructuralPhrasesTrimNeighbouringNouns), ("Asset structural phrases trim neighbouring nouns", AssetStructuralPhrasesTrimNeighbouringNouns),
("Asset significance is generic and not fixture-specific", AssetSignificanceIsGenericAndNotFixtureSpecific),
("Relationship signals map to broad lookup types", RelationshipSignalsMapToBroadTypes), ("Relationship signals map to broad lookup types", RelationshipSignalsMapToBroadTypes),
("Relationship signals infer friendship without romance", RelationshipSignalsInferFriendshipWithoutRomance), ("Relationship signals infer friendship without romance", RelationshipSignalsInferFriendshipWithoutRomance),
("Relationship events preserve chronological story order", RelationshipEventsPreserveChronologicalStoryOrder), ("Relationship events preserve chronological story order", RelationshipEventsPreserveChronologicalStoryOrder),
("Relationship progression keeps chronological state", RelationshipProgressionKeepsChronologicalState),
("Relationship observations do not create event spam", RelationshipObservationsDoNotCreateEventSpam),
("Knowledge signals map to existing knowledge states", KnowledgeSignalsMapToExistingStates), ("Knowledge signals map to existing knowledge states", KnowledgeSignalsMapToExistingStates),
("Knowledge recipient resolves narrator through POV", KnowledgeRecipientResolvesNarratorThroughPov),
("Knowledge identity reveal preserves subjective timing", KnowledgeIdentityRevealPreservesSubjectiveTiming),
("Knowledge duplicate statements share canonical keys", KnowledgeDuplicateStatementsShareCanonicalKeys), ("Knowledge duplicate statements share canonical keys", KnowledgeDuplicateStatementsShareCanonicalKeys),
("Knowledge suspicion and certainty remain distinct", KnowledgeSuspicionAndCertaintyRemainDistinct), ("Knowledge suspicion and certainty remain distinct", KnowledgeSuspicionAndCertaintyRemainDistinct),
("Illustration prompt builder separates spec and prompt", IllustrationPromptBuilderSeparatesSpecAndPrompt), ("Illustration prompt builder separates spec and prompt", IllustrationPromptBuilderSeparatesSpecAndPrompt),
@ -1317,6 +1323,13 @@ static void AssetCanonicalKeysMergeTrivialVariants()
static void AssetCanonicalKeysMergeContextualVehicleVariants() static void AssetCanonicalKeysMergeContextualVehicleVariants()
{ {
Assert(AssetCanonicalKey("Triumph TR6") == AssetCanonicalKey("TR6"), "Triumph TR6 and TR6 should merge when the model identifies the specific vehicle."); Assert(AssetCanonicalKey("Triumph TR6") == AssetCanonicalKey("TR6"), "Triumph TR6 and TR6 should merge when the model identifies the specific vehicle.");
Assert(AssetCanonicalKey("Kawasaki ZX6R") == AssetCanonicalKey("ZX6R"), "Manufacturer/model-code variants should merge without hard-coded manuscript vehicle names.");
}
static void AssetCanonicalKeysKeepUnrelatedOwnedCarsDistinct()
{
Assert(AssetCanonicalKey("Beth's car") != AssetCanonicalKey("Maggie's car"), "Owned cars should remain separate when context identifies different owners.");
Assert(AssetCanonicalKey("Nora's car") != AssetCanonicalKey("Imani's car"), "Generic owned vehicles should remain separate for unrelated books too.");
} }
static void AssetContextualNamesResolvePovOwnership() static void AssetContextualNamesResolvePovOwnership()
@ -1325,11 +1338,27 @@ static void AssetContextualNamesResolvePovOwnership()
Assert(ResolveContextualAssetName("my memory tin", null, scene) == "Beth's memory tin", "POV ownership should resolve my memory tin to Beth's memory tin."); Assert(ResolveContextualAssetName("my memory tin", null, scene) == "Beth's memory tin", "POV ownership should resolve my memory tin to Beth's memory tin.");
Assert(ResolveContextualAssetName("Memory Tin", "Beth", scene) == "Beth's Memory Tin", "Explicit owner evidence should prefix owned significant assets."); Assert(ResolveContextualAssetName("Memory Tin", "Beth", scene) == "Beth's Memory Tin", "Explicit owner evidence should prefix owned significant assets.");
Assert(IsAssetNameCandidate("Beth's Memory Tin"), "Owned Memory Tin-style significant objects should be retained."); Assert(IsAssetNameCandidate("Beth's Memory Tin"), "Owned Memory Tin-style significant objects should be retained.");
var unrelatedScene = new Scene { POVCharacterName = "Nora" };
Assert(ResolveContextualAssetName("my brass astrolabe", null, unrelatedScene) == "Nora's brass astrolabe", "POV ownership should resolve arbitrary significant owned objects.");
} }
static void AssetStructuralPhrasesTrimNeighbouringNouns() static void AssetStructuralPhrasesTrimNeighbouringNouns()
{ {
Assert(CleanStructuralAssetPhrase("memory tin and photographs") == "memory tin", "Structural asset extraction should not bundle neighbouring nouns after and."); Assert(CleanStructuralAssetPhrase("memory tin and photographs") == "memory tin", "Structural asset extraction should not bundle neighbouring nouns after and.");
Assert(CleanStructuralAssetPhrase("brass astrolabe containing coordinates") == "brass astrolabe", "Structural asset extraction should trim explanatory tails for arbitrary objects.");
}
static void AssetSignificanceIsGenericAndNotFixtureSpecific()
{
Assert(IsDistinctiveAssetName("memory tin"), "Memory Tin should survive because it is a distinctive structural object phrase.");
Assert(IsDistinctiveAssetName("brass astrolabe"), "A second arbitrary significant object should use the same distinctive-name mechanism.");
Assert(!IsDistinctiveAssetName("cup"), "Single incidental props should not become significant merely by being named.");
var service = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Services/StoryIntelligenceAssetImportService.cs"));
Assert(!service.Contains("memory tin", StringComparison.OrdinalIgnoreCase), "Production asset logic must not special-case Memory Tin.");
Assert(!service.Contains("red notebook", StringComparison.OrdinalIgnoreCase), "Production asset logic must not special-case the red notebook fixture.");
Assert(!service.Contains("\"tr6\"", StringComparison.OrdinalIgnoreCase), "Production asset logic must not special-case TR6.");
} }
static void RelationshipSignalsMapToBroadTypes() static void RelationshipSignalsMapToBroadTypes()
@ -1357,6 +1386,33 @@ static void RelationshipEventsPreserveChronologicalStoryOrder()
Assert(StoryOrderKey(second).CompareTo(StoryOrderKey(first)) < 0, "Chapter order should outrank repeated scene numbers."); Assert(StoryOrderKey(second).CompareTo(StoryOrderKey(first)) < 0, "Chapter order should outrank repeated scene numbers.");
} }
static void RelationshipProgressionKeepsChronologicalState()
{
Assert(RelationshipPairKey(501, 702) == RelationshipPairKey(702, 501), "Relationship identity should be one canonical non-directional CharacterID pair.");
var events = new (int SceneID, decimal ChapterNumber, decimal SceneNumber, int SortOrder, string StateName)[]
{
(3, 1m, 3m, 30, RelationshipStateNameFromSignal("friendship is clearly established")),
(4, 1m, 4m, 40, RelationshipStateNameFromSignal("mutual attraction becomes explicit")),
(5, 1m, 5m, 50, RelationshipStateNameFromSignal("they explicitly begin a romantic relationship as a couple")),
(6, 1m, 6m, 60, RelationshipStateNameFromSignal("Rob proposes and Maggie accepts the proposal"))
};
Assert(RelationshipStateAtScene("Unknown", events, 1) == "Unknown", "Scene 1 must not inherit later engagement.");
Assert(RelationshipStateAtScene("Unknown", events, 3) == "Friendly", "Friendship should be visible at the friendship point.");
Assert(RelationshipStateAtScene("Unknown", events, 4) == "Romantic", "Romantic attraction should be visible when it becomes explicit.");
Assert(RelationshipStateAtScene("Unknown", events, 5) == "Romantic", "A couple/romantic state should remain visible before proposal.");
Assert(RelationshipStateAtScene("Unknown", events, 6) == "Engaged", "Engagement should appear only after the proposal/acceptance event.");
}
static void RelationshipObservationsDoNotCreateEventSpam()
{
Assert(RelationshipTypeFromSignal("trust", "Maggie and Rosie hug, exchange presents and socialise together.") == "Friend", "Accumulated ordinary interactions should still establish friendship.");
Assert(!IsMeaningfulRelationshipEvent("trust", "Maggie and Rosie hug, exchange presents and socialise together."), "Ordinary hugs/gifts/socialising should not create event spam.");
Assert(IsMeaningfulRelationshipEvent("romantic", "They explicitly become a couple."), "A clear relationship transition should create an event.");
Assert(IsMeaningfulRelationshipEvent("proposal", "Rob proposes and Maggie accepts."), "Proposal acceptance should create an event.");
Assert(!IsMeaningfulRelationshipEvent("trust", "Nora and Imani exchange gifts after years of friendship."), "The non-event rule should hold for unrelated fixture names too.");
}
static void KnowledgeSignalsMapToExistingStates() static void KnowledgeSignalsMapToExistingStates()
{ {
var states = new List<KnowledgeState> var states = new List<KnowledgeState>
@ -1375,6 +1431,34 @@ static void KnowledgeSignalsMapToExistingStates()
Assert(KnowledgeStateFromSignal("Forgets", "the hiding place", states) == "Unaware", "Forgets should map to Unaware."); Assert(KnowledgeStateFromSignal("Forgets", "the hiding place", states) == "Unaware", "Forgets should map to Unaware.");
} }
static void KnowledgeRecipientResolvesNarratorThroughPov()
{
var scene = new Scene { SceneID = 812, POVCharacterID = 42, POVCharacterName = "Beth" };
Assert(ResolveKnowledgeRecipientName("Narrator", scene) == "Beth", "Narrator knowledge should resolve to canonical POV character.");
Assert(ResolveKnowledgeRecipientName("I", scene) == "Beth", "First-person knowledge should resolve to canonical POV character.");
Assert(ResolveKnowledgeRecipientName("Maggie", scene) == "Maggie", "Other present characters should not automatically receive POV internal knowledge.");
var unrelated = new Scene { SceneID = 913, POVCharacterID = 77, POVCharacterName = "Nora" };
Assert(ResolveKnowledgeRecipientName("Narrator", unrelated) == "Nora", "POV knowledge resolution should be generic across books.");
}
static void KnowledgeIdentityRevealPreservesSubjectiveTiming()
{
const int maggieId = 617;
const int victorId = 901;
const int earlierSceneId = 11;
const int revealSceneId = 18;
var aliasEvidence = (Alias: "The Man in Grey", CanonicalCharacterID: victorId, SceneID: earlierSceneId);
var preRevealKey = KnowledgeKey(maggieId, "The Man in Grey is suspicious.", "Suspects");
var revealKey = KnowledgeKey(maggieId, "The Man in Grey is Victor Harrington.", "Knows");
Assert(aliasEvidence.CanonicalCharacterID == victorId, "Alias/reference evidence should point at the one canonical Victor CharacterID.");
Assert(aliasEvidence.Alias == "The Man in Grey" && aliasEvidence.SceneID == earlierSceneId, "Historical alias evidence should remain scene-tied before reveal.");
Assert(preRevealKey != revealKey, "Earlier suspicion/encounter knowledge must not equal later identity revelation.");
Assert(KnowledgeKey(maggieId, "Maggie learns The Man in Grey is Victor Harrington.", "Knows") == revealKey, "Reveal wording should deduplicate to the same subjective identity knowledge at the reveal scene.");
Assert(revealSceneId > earlierSceneId, "Reveal knowledge fixture should occur after alias evidence.");
}
static void KnowledgeDuplicateStatementsShareCanonicalKeys() static void KnowledgeDuplicateStatementsShareCanonicalKeys()
{ {
var first = CanonicalKnowledgeKey("Simon is Beth's father."); var first = CanonicalKnowledgeKey("Simon is Beth's father.");
@ -1393,6 +1477,9 @@ static void KnowledgeSuspicionAndCertaintyRemainDistinct()
static bool IsAssetNameCandidate(string name) static bool IsAssetNameCandidate(string name)
=> InvokePrivateAssetFilter("IsAssetNameCandidate", name); => InvokePrivateAssetFilter("IsAssetNameCandidate", name);
static bool IsDistinctiveAssetName(string name)
=> InvokePrivateAssetFilter("IsDistinctiveAssetName", name);
static string AssetCanonicalKey(string name) static string AssetCanonicalKey(string name)
{ {
var method = typeof(StoryIntelligenceAssetImportService).GetMethod( var method = typeof(StoryIntelligenceAssetImportService).GetMethod(
@ -1463,6 +1550,60 @@ static IComparable StoryOrderKey((int SceneID, decimal ChapterNumber, decimal Sc
return (IComparable)method!.Invoke(null, [instance])!; return (IComparable)method!.Invoke(null, [instance])!;
} }
static string RelationshipPairKey(int characterAId, int characterBId)
{
var method = typeof(StoryIntelligenceRelationshipImportService).GetMethod(
"PairKey",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
Assert(method is not null, "PairKey was not found.");
return (string)method!.Invoke(null, [characterAId, characterBId])!;
}
static string RelationshipStateNameFromSignal(string signal)
{
var method = typeof(StoryIntelligenceRelationshipImportService).GetMethod(
"RelationshipStateNameFromSignal",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
Assert(method is not null, "RelationshipStateNameFromSignal was not found.");
return (string)method!.Invoke(null, [signal])!;
}
static string RelationshipStateAtScene(
string initialState,
IEnumerable<(int SceneID, decimal ChapterNumber, decimal SceneNumber, int SortOrder, string StateName)> events,
int sceneId)
{
var method = typeof(StoryIntelligenceRelationshipImportService).GetMethod(
"RelationshipStateAtScene",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
Assert(method is not null, "RelationshipStateAtScene was not found.");
return (string)method!.Invoke(null, [initialState, events, sceneId])!;
}
static bool IsMeaningfulRelationshipEvent(string signal, string evidence)
{
var nested = typeof(StoryIntelligenceRelationshipImportService).GetNestedType(
"RelationshipAppearanceImport",
System.Reflection.BindingFlags.NonPublic);
Assert(nested is not null, "RelationshipAppearanceImport was not found.");
var instance = Activator.CreateInstance(nested!, [
1,
1m,
1m,
10,
"Scene",
signal,
evidence,
0.8m,
false
]);
var method = typeof(StoryIntelligenceRelationshipImportService).GetMethod(
"HasMeaningfulRelationshipEvent",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
Assert(method is not null, "HasMeaningfulRelationshipEvent was not found.");
return method!.Invoke(null, [instance]) is true;
}
static string KnowledgeKey(int characterId, string statement, string knowledgeStateName) static string KnowledgeKey(int characterId, string statement, string knowledgeStateName)
{ {
var method = typeof(StoryIntelligenceKnowledgeImportService).GetMethod( var method = typeof(StoryIntelligenceKnowledgeImportService).GetMethod(
@ -1472,6 +1613,15 @@ static string KnowledgeKey(int characterId, string statement, string knowledgeSt
return (string)method!.Invoke(null, [characterId, statement, knowledgeStateName])!; return (string)method!.Invoke(null, [characterId, statement, knowledgeStateName])!;
} }
static string ResolveKnowledgeRecipientName(string characterName, Scene scene)
{
var method = typeof(StoryIntelligenceKnowledgeImportService).GetMethod(
"ResolveKnowledgeRecipientName",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
Assert(method is not null, "ResolveKnowledgeRecipientName was not found.");
return (string)method!.Invoke(null, [characterName, scene])!;
}
static string KnowledgeStateFromSignal(string changeType, string statement, IReadOnlyList<KnowledgeState> states) static string KnowledgeStateFromSignal(string changeType, string statement, IReadOnlyList<KnowledgeState> states)
{ {
@ -3507,7 +3657,7 @@ static void StructuralPovRidesExistingChapterAnalysis()
Assert(prompt.Contains("defaultPov", StringComparison.Ordinal), "Structural prompt should ask for default POV in the existing chapter pass."); Assert(prompt.Contains("defaultPov", StringComparison.Ordinal), "Structural prompt should ask for default POV in the existing chapter pass.");
Assert(prompt.Contains("povOverride", StringComparison.Ordinal), "Structural prompt should allow scene-level POV overrides."); Assert(prompt.Contains("povOverride", StringComparison.Ordinal), "Structural prompt should allow scene-level POV overrides.");
Assert(prompt.Contains("Narrated by Maggie", StringComparison.Ordinal), "Structural prompt should treat explicit narrator labels as strong evidence."); Assert(prompt.Contains("Narrated by Lena", StringComparison.Ordinal), "Structural prompt should treat explicit narrator labels as strong evidence.");
Assert(prompt.Contains("knownCharacters", StringComparison.Ordinal), "Structural prompt should prefer supplied canonical characters."); Assert(prompt.Contains("knownCharacters", StringComparison.Ordinal), "Structural prompt should prefer supplied canonical characters.");
Assert(models.Contains("ChapterStructurePovSuggestion", StringComparison.Ordinal), "Structural response model should carry compact POV suggestions."); Assert(models.Contains("ChapterStructurePovSuggestion", StringComparison.Ordinal), "Structural response model should carry compact POV suggestions.");
Assert(service.Contains("KnownCharacters { get; init; } = []", StringComparison.Ordinal), "Structural analysis request should carry known character context."); Assert(service.Contains("KnownCharacters { get; init; } = []", StringComparison.Ordinal), "Structural analysis request should carry known character context.");

View File

@ -96,7 +96,7 @@ Do not create a separate final scene for a closing line unless it corresponds to
Identify the default chapter point of view only when the supplied text provides a clear narrator/viewpoint signal. Identify the default chapter point of view only when the supplied text provides a clear narrator/viewpoint signal.
Explicit manuscript labels such as `Narrated by Maggie` are extremely strong evidence. Explicit manuscript labels such as `Narrated by Lena` are extremely strong evidence.
When `chapterContext.knownCharacters` contains a matching canonical character or alias, prefer that spelling for POV names. When `chapterContext.knownCharacters` contains a matching canonical character or alias, prefer that spelling for POV names.
If no POV can be determined, return `defaultPov` as `null`. If no POV can be determined, return `defaultPov` as `null`.
@ -151,7 +151,7 @@ Incorrect examples: `0. 8`, `7 .5`, `0,8`, `"0.8"`.
Use `structuralSummary` for a factual, neutral summary of what happens in that scene. It should normally be 25 to 40 words, avoid interpretation, and only use information present in the supplied text. Use `structuralSummary` for a factual, neutral summary of what happens in that scene. It should normally be 25 to 40 words, avoid interpretation, and only use information present in the supplied text.
Use `structuralSetting` for the scene's compact active place/time label when the chapter text supports it, such as `Beth's car at night`, `church office`, or `Ashdown Trust reception`. Use `null` when no active setting is clear. Do not invent named locations. Use `structuralSetting` for the scene's compact active place/time label when the chapter text supports it, such as `Mara's car at night`, `church office`, or `archive reception`. Use `null` when no active setting is clear. Do not invent named locations.
Use `suggestedTitle` for a concise, factual scene title. It must be 2 to 6 words, memorable without being dramatic, and grounded only in the supplied text. Avoid generic titles such as "Opening Scene", "Conversation", "Chapter Scene", or "Scene 1". Do not truncate or restate the structural summary. Use `suggestedTitle` for a concise, factual scene title. It must be 2 to 6 words, memorable without being dramatic, and grounded only in the supplied text. Avoid generic titles such as "Opening Scene", "Conversation", "Chapter Scene", or "Scene 1". Do not truncate or restate the structural summary.
@ -182,9 +182,9 @@ Do not output:
"schemaVersion": "1.0", "schemaVersion": "1.0",
"chapterSummary": "Structural analysis of the supplied chapter.", "chapterSummary": "Structural analysis of the supplied chapter.",
"defaultPov": { "defaultPov": {
"characterName": "Maggie", "characterName": "Lena",
"confidence": 0.96, "confidence": 0.96,
"evidence": "The chapter begins with an explicit Narrated by Maggie label.", "evidence": "The chapter begins with an explicit Narrated by Lena label.",
"isExplicit": true "isExplicit": true
}, },
"mixedPov": false, "mixedPov": false,

View File

@ -31,7 +31,7 @@ The response must match this shape:
"schemaVersion": "22", "schemaVersion": "22",
"updates": [ "updates": [
{ {
"canonicalName": "Maggie", "canonicalName": "Mara",
"aliases": ["Miss Jones"], "aliases": ["Miss Jones"],
"recurrenceSignal": "Appears again with the same role and appearance evidence.", "recurrenceSignal": "Appears again with the same role and appearance evidence.",
"confidence": 0.82, "confidence": 0.82,
@ -97,4 +97,3 @@ CURRENT_STORY_MEMORY_JSON:
SCENE_RESULT_ID: SCENE_RESULT_ID:
{{SCENE_RESULT_ID}} {{SCENE_RESULT_ID}}

View File

@ -122,18 +122,18 @@ Do not convert absence, memory, longing, grief, speculation, comparison or emoti
Bad unless explicitly stated by the supplied scene text: Bad unless explicitly stated by the supplied scene text:
- `Beth leaves Maggie behind.` - `Mara leaves Lena behind.`
- `Maggie is recently left by Beth.` - `Lena is recently left by Mara.`
- `Maggie is at the Ashdown Trust.` - `Lena is at the archive office.`
- `Beth travels from Maggie's location.` - `Mara travels from Lena's location.`
Good when supported by reflective or emotional text: Good when supported by reflective or emotional text:
- `Beth notices Maggie's absence.` - `Mara notices Lena's absence.`
- `Beth thinks about Maggie.` - `Mara thinks about Lena.`
- `Beth feels the car is too quiet without Maggie.` - `Mara feels the car is too quiet without Lena.`
- `Beth wonders whether she may be closer to finding Maggie.` - `Mara wonders whether she may be closer to finding Lena.`
- `Maggie is mentioned only in Beth's thoughts.` - `Lena is mentioned only in Mara's thoughts.`
If the text suggests a character is absent, remembered, missed, discussed, imagined or speculated about, preserve that limitation. Prefer ambiguity notes, questions raised and low-confidence speculation over invented literal facts. If the text suggests a character is absent, remembered, missed, discussed, imagined or speculated about, preserve that limitation. Prefer ambiguity notes, questions raised and low-confidence speculation over invented literal facts.
@ -266,7 +266,7 @@ Prefer the active scene location over nearby route or address wording. If the ac
Do not infer a character's current location from another character thinking about them. Do not infer a character's current location from another character thinking about them.
Example: if Beth thinks `closer, perhaps, to Maggie`, do not create `Maggie at Ashdown Trust`, `Maggie's location nearby` or `Beth leaves Maggie's location`. Instead, use a question or observation such as `Beth speculates that the Ashdown Trust may bring her closer to Maggie.` Example: if Mara thinks `closer, perhaps, to Lena`, do not create `Lena at the archive office`, `Lena's location nearby` or `Mara leaves Lena's location`. Instead, use a question or observation such as `Mara speculates that the archive office may bring her closer to Lena.`
## Point Of View Rules ## Point Of View Rules
@ -302,7 +302,7 @@ For each asset, include exactly these properties: `name`, `assetType`, `status`,
Use `assetType: null`, `status: null` or `ownerOrHolder: null` when unsupported. Use `mentionedOnly: false` when the asset is physically present or directly acted on in the scene. Use `notes: null` when no note is needed. Use `assetType: null`, `status: null` or `ownerOrHolder: null` when unsupported. Use `mentionedOnly: false` when the asset is physically present or directly acted on in the scene. Use `notes: null` when no note is needed.
Use canonical POV and `sceneCharacters` identity context for ownership. In a Beth POV scene, `my memory tin` should be reported as Beth's memory tin when the text or structural summary supports ownership. Do not turn every ordinary owned prop such as `my cup` or `my chair` into an asset unless the scene gives it story significance. Use canonical POV and `sceneCharacters` identity context for ownership. In a Mara POV scene, `my sealed notebook` should be reported as Mara's sealed notebook when the text or structural summary supports ownership. Do not turn every ordinary owned prop such as `my cup` or `my chair` into an asset unless the scene gives it story significance.
## Relationship Signal Rules ## Relationship Signal Rules
@ -431,12 +431,12 @@ Good:
```json ```json
{ {
"title": "Maggie's Disappearance", "title": "Lena's Disappearance",
"description": "Beth's search for Maggie advances through absence, clues and investigation.", "description": "Mara's search for Lena advances through absence, clues and investigation.",
"arcType": "disappearance", "arcType": "disappearance",
"scale": "major", "scale": "major",
"matchingExistingPlotLineId": null, "matchingExistingPlotLineId": null,
"evidence": "Beth follows a clue that may explain where Maggie went.", "evidence": "Mara follows a clue that may explain where Lena went.",
"confidence": 0.82 "confidence": 0.82
} }
``` ```
@ -523,19 +523,19 @@ Observation rules:
Example source text: Example source text:
`The car felt too quiet without Maggie.` `The car felt too quiet without Lena.`
Correct character extraction: Correct character extraction:
```json ```json
{ {
"name": "Maggie", "name": "Lena",
"roleInScene": "mentioned", "roleInScene": "mentioned",
"mentionedOnly": true, "mentionedOnly": true,
"aliases": [], "aliases": [],
"actions": [], "actions": [],
"confidence": 0.9, "confidence": 0.9,
"notes": "Maggie is absent and remembered by the narrator." "notes": "Lena is absent and remembered by the narrator."
} }
``` ```
@ -545,21 +545,21 @@ Correct observation:
{ {
"observationType": "CharacterKnowledge", "observationType": "CharacterKnowledge",
"subjectEntityType": "Character", "subjectEntityType": "Character",
"subjectName": "Beth", "subjectName": "Mara",
"objectEntityType": "Character", "objectEntityType": "Character",
"objectName": "Maggie", "objectName": "Lena",
"predicate": "noticesAbsenceOf", "predicate": "noticesAbsenceOf",
"description": "Beth feels Maggie's absence in the car.", "description": "Mara feels Lena's absence in the car.",
"evidence": "The car felt too quiet without Maggie.", "evidence": "The car felt too quiet without Lena.",
"confidence": 0.9 "confidence": 0.9
} }
``` ```
Incorrect: Incorrect:
- `Beth leaves Maggie behind.` - `Mara leaves Lena behind.`
- `Maggie was recently left by Beth.` - `Lena was recently left by Mara.`
- `Maggie is located near the Ashdown Trust.` - `Lena is located near the archive office.`
## JSON Output Requirements ## JSON Output Requirements

View File

@ -34,8 +34,8 @@ public sealed class StoryIntelligenceAssetImportService(
"car", "vehicle", "coat", "dress", "shoes", "shirt", "trousers", "weather", "rain", "food", "drink"); "car", "vehicle", "coat", "dress", "shoes", "shirt", "trousers", "weather", "rain", "food", "drink");
private static readonly HashSet<string> AcceptedAssetNames = CreateSet( private static readonly HashSet<string> AcceptedAssetNames = CreateSet(
"tr6", "passport", "photograph", "photo", "key", "keys", "car keys", "letter", "envelope", "note", "passport", "photograph", "photo", "key", "keys", "car keys", "letter", "envelope", "note",
"notebook", "red notebook", "old notebook", "knife", "gun", "suitcase", "rucksack", "wedding ring", "notebook", "knife", "gun", "suitcase", "rucksack", "wedding ring",
"ring", "necklace", "cassette tape", "camera", "map", "diary", "watch", "driving licence", "ring", "necklace", "cassette tape", "camera", "map", "diary", "watch", "driving licence",
"driving license", "licence", "license", "document", "folder", "wallet", "bank book", "scrap of paper", "driving license", "licence", "license", "document", "folder", "wallet", "bank book", "scrap of paper",
"keepsake box", "keepsake tin"); "keepsake box", "keepsake tin");
@ -659,12 +659,13 @@ public sealed class StoryIntelligenceAssetImportService(
var sceneCount = candidate.Appearances.Select(appearance => appearance.SceneID).Distinct().Count(); var sceneCount = candidate.Appearances.Select(appearance => appearance.SceneID).Distinct().Count();
var signalText = $"{candidate.DisplayName} {candidate.Category} {candidate.Description} {candidate.OwnerOrHolder} {string.Join(' ', candidate.Appearances.Select(a => a.Notes))}"; var signalText = $"{candidate.DisplayName} {candidate.Category} {candidate.Description} {candidate.OwnerOrHolder} {string.Join(' ', candidate.Appearances.Select(a => a.Notes))}";
return ContainsStructuralSignificance(candidate) return ContainsStructuralSignificance(candidate)
|| sceneCount >= 2 && ContainsStoryAssetSignal(signalText); || sceneCount >= 2 && (ContainsStoryAssetSignal(signalText) || IsDistinctiveAssetName(candidate.DisplayName));
} }
private static bool ContainsStructuralSignificance(AssetCandidate candidate) private static bool ContainsStructuralSignificance(AssetCandidate candidate)
=> candidate.Appearances.Any(appearance => string.Equals(appearance.EventType, "Mentioned", StringComparison.OrdinalIgnoreCase) => candidate.Appearances.Any(appearance => string.Equals(appearance.EventType, "Mentioned", StringComparison.OrdinalIgnoreCase)
&& ContainsStoryAssetSignal($"{candidate.DisplayName} {candidate.Description} {appearance.Notes} structural summary")); && IsStructuralSummaryEvidence(appearance.Notes)
&& IsDistinctiveAssetName(candidate.DisplayName));
private static bool IsAssetNameCandidate(string? name) private static bool IsAssetNameCandidate(string? name)
{ {
@ -689,9 +690,6 @@ public sealed class StoryIntelligenceAssetImportService(
var withoutArticle = RemoveLeadingArticle(normalised); var withoutArticle = RemoveLeadingArticle(normalised);
return AcceptedAssetNames.Contains(normalised) return AcceptedAssetNames.Contains(normalised)
|| AcceptedAssetNames.Contains(withoutArticle) || AcceptedAssetNames.Contains(withoutArticle)
|| normalised.Contains("tr6", StringComparison.OrdinalIgnoreCase)
|| normalised.Equals("memory tin", StringComparison.OrdinalIgnoreCase)
|| normalised.EndsWith(" memory tin", StringComparison.OrdinalIgnoreCase)
|| normalised.Contains(" keepsake ", StringComparison.OrdinalIgnoreCase) || normalised.Contains(" keepsake ", StringComparison.OrdinalIgnoreCase)
|| normalised.Contains("'s car", StringComparison.OrdinalIgnoreCase) || normalised.Contains("'s car", StringComparison.OrdinalIgnoreCase)
|| normalised.Contains("’s car", StringComparison.OrdinalIgnoreCase); || normalised.Contains("’s car", StringComparison.OrdinalIgnoreCase);
@ -729,7 +727,7 @@ public sealed class StoryIntelligenceAssetImportService(
return "Evidence"; return "Evidence";
} }
if (value.Contains("car") || value.Contains("tr6") || value.Contains("vehicle")) if (value.Contains("car") || value.Contains("vehicle"))
{ {
return "Vehicle"; return "Vehicle";
} }
@ -825,8 +823,7 @@ public sealed class StoryIntelligenceAssetImportService(
private static bool IsOwnedAssetName(string name) private static bool IsOwnedAssetName(string name)
=> ContainsStoryAssetSignal(name) => ContainsStoryAssetSignal(name)
|| IsAcceptedStoryAssetName(name) || IsAcceptedStoryAssetName(name)
|| name.Contains("tin", StringComparison.OrdinalIgnoreCase) || IsDistinctiveAssetName(name);
|| name.Contains("box", StringComparison.OrdinalIgnoreCase);
private static string? BuildFirstAppearance(Scene scene, SceneIntelligenceScene parsed, string assetName) private static string? BuildFirstAppearance(Scene scene, SceneIntelligenceScene parsed, string assetName)
{ {
@ -880,9 +877,10 @@ public sealed class StoryIntelligenceAssetImportService(
private static string CanonicalAssetKey(string name) private static string CanonicalAssetKey(string name)
{ {
var clean = RemoveLeadingArticle(Normalise(CleanAssetName(name))); var clean = RemoveLeadingArticle(Normalise(CleanAssetName(name)));
if (clean.Contains("tr6", StringComparison.OrdinalIgnoreCase)) var modelCode = ExtractDistinctiveModelCode(clean);
if (!string.IsNullOrWhiteSpace(modelCode))
{ {
return "tr6"; return modelCode;
} }
var words = clean.Split(' ', StringSplitOptions.RemoveEmptyEntries).ToList(); var words = clean.Split(' ', StringSplitOptions.RemoveEmptyEntries).ToList();
@ -912,6 +910,40 @@ public sealed class StoryIntelligenceAssetImportService(
private static string CleanAssetName(string? value) private static string CleanAssetName(string? value)
=> StoryIntelligenceEntityTextNormaliser.CleanName(value); => StoryIntelligenceEntityTextNormaliser.CleanName(value);
private static bool IsStructuralSummaryEvidence(string? value)
=> Clean(value).Contains("Structural summary identifies", StringComparison.OrdinalIgnoreCase);
private static bool IsDistinctiveAssetName(string? value)
{
var clean = RemoveLeadingArticle(Normalise(CleanAssetName(value)));
if (string.IsNullOrWhiteSpace(clean) || GenericObjects.Contains(clean))
{
return false;
}
if (!string.IsNullOrWhiteSpace(ExtractDistinctiveModelCode(clean)))
{
return true;
}
if (clean.Contains("'s ", StringComparison.OrdinalIgnoreCase)
|| clean.Contains("’s ", StringComparison.OrdinalIgnoreCase))
{
return true;
}
var words = clean.Split(' ', StringSplitOptions.RemoveEmptyEntries);
return words.Length >= 2
&& words.Any(word => !AdjectivesToFold.Contains(word) && !GenericObjects.Contains(word));
}
private static string? ExtractDistinctiveModelCode(string? value)
{
var clean = Normalise(value);
var matches = Regex.Matches(clean, @"\b(?=[a-z0-9]*[a-z])(?=[a-z0-9]*\d)[a-z]{1,6}\d[a-z0-9]{0,6}\b", RegexOptions.IgnoreCase);
return matches.Count == 1 ? matches[0].Value : null;
}
private static string CleanStructuralAssetPhrase(string? value) private static string CleanStructuralAssetPhrase(string? value)
{ {
var clean = CleanAssetName(value); var clean = CleanAssetName(value);
@ -957,6 +989,10 @@ public sealed class StoryIntelligenceAssetImportService(
" while ", " while ",
" that ", " that ",
" which ", " which ",
" containing ",
" contains ",
" with ",
" inside ",
"," ","
]; ];
@ -965,7 +1001,7 @@ public sealed class StoryIntelligenceAssetImportService(
RegexOptions.Compiled); RegexOptions.Compiled);
private static readonly Regex StructuralSignificantAssetPattern = new( private static readonly Regex StructuralSignificantAssetPattern = new(
@"\b(?:opens?|retrieves?|examines?|keeps?|hides?|finds?|carries?|takes?|gives?|receives?|reads?)\s+(?:her|his|their|my|the|a|an)\s+(?<asset>[\p{L}\p{N}'-]+(?:\s+[\p{L}\p{N}'-]+){0,3})\b", @"\b(?:opens?|retrieves?|discovers?|examines?|keeps?|hides?|finds?|carries?|takes?|gives?|receives?|reads?)\s+(?:her|his|their|my|the|a|an)\s+(?<asset>[\p{L}\p{N}'-]+(?:\s+[\p{L}\p{N}'-]+){0,4})\b",
RegexOptions.IgnoreCase | RegexOptions.Compiled); RegexOptions.IgnoreCase | RegexOptions.Compiled);
private sealed class AssetIndex private sealed class AssetIndex

View File

@ -325,7 +325,8 @@ public sealed class StoryIntelligenceKnowledgeImportService(
decimal? confidence, decimal? confidence,
IReadOnlyList<KnowledgeState> knowledgeStates) IReadOnlyList<KnowledgeState> knowledgeStates)
{ {
var character = characterIndex.Find(characterName); var resolvedCharacterName = ResolveKnowledgeRecipientName(characterName, importedScene);
var character = characterIndex.Find(resolvedCharacterName);
var statement = CleanKnowledgeStatement(knowledgeItem); var statement = CleanKnowledgeStatement(knowledgeItem);
if (character is null || string.IsNullOrWhiteSpace(statement)) if (character is null || string.IsNullOrWhiteSpace(statement))
{ {
@ -483,6 +484,25 @@ public sealed class StoryIntelligenceKnowledgeImportService(
&& !string.IsNullOrWhiteSpace(FirstConfigured(observation.Description, observation.Predicate, observation.ObjectName)); && !string.IsNullOrWhiteSpace(FirstConfigured(observation.Description, observation.Predicate, observation.ObjectName));
} }
private static string ResolveKnowledgeRecipientName(string? characterName, Scene scene)
{
var clean = Clean(characterName);
if (string.IsNullOrWhiteSpace(clean))
{
return clean;
}
if (string.Equals(clean, "Narrator", StringComparison.OrdinalIgnoreCase)
|| string.Equals(clean, "I", StringComparison.OrdinalIgnoreCase)
|| string.Equals(clean, "me", StringComparison.OrdinalIgnoreCase)
|| string.Equals(clean, "myself", StringComparison.OrdinalIgnoreCase))
{
return Clean(scene.POVCharacterName ?? scene.POVSuggestedName);
}
return clean;
}
private static string KnowledgeStateFromSignal(string? changeType, string? statement, IReadOnlyList<KnowledgeState> states) private static string KnowledgeStateFromSignal(string? changeType, string? statement, IReadOnlyList<KnowledgeState> states)
{ {
var value = NormaliseForMatching($"{changeType} {statement}"); var value = NormaliseForMatching($"{changeType} {statement}");
@ -618,7 +638,7 @@ public sealed class StoryIntelligenceKnowledgeImportService(
private static string CanonicalKnowledgeKey(string? value) private static string CanonicalKnowledgeKey(string? value)
{ {
var normalised = NormaliseForMatching(value); var normalised = NormaliseForMatching(value).Trim();
var possessiveFamily = Regex.Match( var possessiveFamily = Regex.Match(
normalised, normalised,
@"\b(?<subject>\w+)\s+(?:is|was)\s+(?<holder>\w+)\s+s\s+(?:biological\s+)?(?<relation>father|mother|parent|sister|brother|sibling|aunt|uncle|cousin|child|son|daughter)\b", @"\b(?<subject>\w+)\s+(?:is|was)\s+(?<holder>\w+)\s+s\s+(?:biological\s+)?(?<relation>father|mother|parent|sister|brother|sibling|aunt|uncle|cousin|child|son|daughter)\b",
@ -637,6 +657,11 @@ public sealed class StoryIntelligenceKnowledgeImportService(
return $"{pronounFamily.Groups["relation"].Value} {pronounFamily.Groups["subject"].Value} {pronounFamily.Groups["holder"].Value}"; return $"{pronounFamily.Groups["relation"].Value} {pronounFamily.Groups["subject"].Value} {pronounFamily.Groups["holder"].Value}";
} }
normalised = Regex.Replace(
normalised,
@"^\w+\s+(?:learns?|discovers?|realises?|realizes?|knows?|believes?|suspects?|confirms?|is\s+told)\s+",
string.Empty,
RegexOptions.IgnoreCase);
normalised = Regex.Replace(normalised, @"\b(discovers?|learns?|realises?|realizes?|knows?|believes?|suspects?|confirms?|is told|that|the|a|an|his|her|their|its|about)\b", " ", RegexOptions.IgnoreCase); normalised = Regex.Replace(normalised, @"\b(discovers?|learns?|realises?|realizes?|knows?|believes?|suspects?|confirms?|is told|that|the|a|an|his|her|their|its|about)\b", " ", RegexOptions.IgnoreCase);
return Whitespace.Replace(normalised, " ").Trim(); return Whitespace.Replace(normalised, " ").Trim();
} }

View File

@ -491,21 +491,26 @@ public sealed class StoryIntelligenceRelationshipImportService(
?? types.First().RelationshipTypeID; ?? types.First().RelationshipTypeID;
private static int MatchRelationshipState(IReadOnlyList<RelationshipState> states, string? signal) private static int MatchRelationshipState(IReadOnlyList<RelationshipState> states, string? signal)
{
var stateName = RelationshipStateNameFromSignal(signal);
return states.FirstOrDefault(state => string.Equals(state.StateName, stateName, StringComparison.OrdinalIgnoreCase))?.RelationshipStateID
?? states.FirstOrDefault(state => string.Equals(state.StateName, "Unknown", StringComparison.OrdinalIgnoreCase))?.RelationshipStateID
?? states.First().RelationshipStateID;
}
private static string RelationshipStateNameFromSignal(string? signal)
{ {
var value = Normalise(signal); var value = Normalise(signal);
var stateName = return
value.Contains("reconcil") ? "Reconciled" : value.Contains("reconcil") ? "Reconciled" :
value.Contains("engaged") || value.Contains("proposal") || value.Contains("proposes") ? "Engaged" : value.Contains("engaged") || value.Contains("proposal") || value.Contains("proposes") ? "Engaged" :
value.Contains("romantic") || value.Contains("attraction") || value.Contains("lover") || value.Contains("love") ? "Romantic" : value.Contains("romantic") || value.Contains("attraction") || value.Contains("lover") || value.Contains("love") || value.Contains("couple") ? "Romantic" :
value.Contains("friend") || value.Contains("ally") || value.Contains("support") || value.Contains("trust") || value.Contains("hug") || value.Contains("gift") ? "Friendly" : value.Contains("friend") || value.Contains("ally") || value.Contains("support") || value.Contains("trust") || value.Contains("hug") || value.Contains("gift") ? "Friendly" :
value.Contains("argument") || value.Contains("argue") || value.Contains("conflict") || value.Contains("hostile") || value.Contains("enemy") ? "Hostile" : value.Contains("argument") || value.Contains("argue") || value.Contains("conflict") || value.Contains("hostile") || value.Contains("enemy") ? "Hostile" :
value.Contains("distrust") || value.Contains("suspect") || value.Contains("suspicious") ? "Distrustful" : value.Contains("distrust") || value.Contains("suspect") || value.Contains("suspicious") ? "Distrustful" :
value.Contains("estranged") || value.Contains("separat") || value.Contains("deteriorat") ? "Estranged" : value.Contains("estranged") || value.Contains("separat") || value.Contains("deteriorat") ? "Estranged" :
value.Contains("tense") || value.Contains("tension") ? "Tense" : value.Contains("tense") || value.Contains("tension") ? "Tense" :
"Unknown"; "Unknown";
return states.FirstOrDefault(state => string.Equals(state.StateName, stateName, StringComparison.OrdinalIgnoreCase))?.RelationshipStateID
?? states.FirstOrDefault(state => string.Equals(state.StateName, "Unknown", StringComparison.OrdinalIgnoreCase))?.RelationshipStateID
?? states.First().RelationshipStateID;
} }
private static string RelationshipTypeFromSignal(string? signal, string? evidence = null) private static string RelationshipTypeFromSignal(string? signal, string? evidence = null)
@ -578,6 +583,24 @@ public sealed class StoryIntelligenceRelationshipImportService(
private static (decimal ChapterNumber, decimal SceneNumber, int SortOrder, int SceneID) StoryOrder(RelationshipAppearanceImport appearance) private static (decimal ChapterNumber, decimal SceneNumber, int SortOrder, int SceneID) StoryOrder(RelationshipAppearanceImport appearance)
=> (appearance.ChapterNumber, appearance.SceneNumber, appearance.SortOrder, appearance.SceneID); => (appearance.ChapterNumber, appearance.SceneNumber, appearance.SortOrder, appearance.SceneID);
private static string RelationshipStateAtScene(
string initialState,
IEnumerable<(int SceneID, decimal ChapterNumber, decimal SceneNumber, int SortOrder, string StateName)> events,
int sceneId)
{
var ordered = events.OrderBy(item => item.ChapterNumber).ThenBy(item => item.SceneNumber).ThenBy(item => item.SortOrder).ThenBy(item => item.SceneID).ToList();
var scene = ordered.FirstOrDefault(item => item.SceneID == sceneId);
if (scene.SceneID == 0)
{
return initialState;
}
return ordered
.Where(item => (item.ChapterNumber, item.SceneNumber, item.SortOrder, item.SceneID)
.CompareTo((scene.ChapterNumber, scene.SceneNumber, scene.SortOrder, scene.SceneID)) <= 0)
.LastOrDefault().StateName ?? initialState;
}
private static string BuildRelationshipNotes(RelationshipCandidate candidate) private static string BuildRelationshipNotes(RelationshipCandidate candidate)
{ {
var lines = new List<string> { "Imported from Story Intelligence relationship review." }; var lines = new List<string> { "Imported from Story Intelligence relationship review." };
@ -704,8 +727,8 @@ public sealed class StoryIntelligenceRelationshipImportService(
"begins", "begin", "starts", "first meeting", "meet", "met", "deteriorates", "deteriorate", "begins", "begin", "starts", "first meeting", "meet", "met", "deteriorates", "deteriorate",
"reconciles", "reconciliation", "reconciled", "argument", "argues", "separation", "separate", "reconciles", "reconciliation", "reconciled", "argument", "argues", "separation", "separate",
"estranged", "confront", "conflict", "tension", "hostile", "distrust", "supports", "protects", "estranged", "confront", "conflict", "tension", "hostile", "distrust", "supports", "protects",
"trust", "hugs", "hug", "gifts", "gift", "presents", "socialises", "socializes", "attraction", "becomes", "become", "establishes", "established", "explicit", "couple", "relationship",
"proposes", "proposal", "engaged", "accepts" "attraction", "romantic", "proposes", "proposal", "engaged", "accepts"
]; ];
private sealed record RelationshipCandidateData(bool HasCommittedScenes, int AlreadyLinkedCount, IReadOnlyList<RelationshipCandidate> Candidates); private sealed record RelationshipCandidateData(bool HasCommittedScenes, int AlreadyLinkedCount, IReadOnlyList<RelationshipCandidate> Candidates);