Improve story intelligence entity quality

This commit is contained in:
Nick Beckley 2026-08-28 13:58:29 +00:00
parent 108df3db3e
commit 6aea9736d4
9 changed files with 525 additions and 75 deletions

View File

@ -35,12 +35,19 @@ var tests = new (string Name, Action Test)[]
("Location filtering rejects merged phrases", LocationFilteringRejectsMergedPhrases), ("Location filtering rejects merged phrases", LocationFilteringRejectsMergedPhrases),
("Location filtering preserves story locations", LocationFilteringPreservesStoryLocations), ("Location filtering preserves story locations", LocationFilteringPreservesStoryLocations),
("Location canonical keys merge trivial variants", LocationCanonicalKeysMergeTrivialVariants), ("Location canonical keys merge trivial variants", LocationCanonicalKeysMergeTrivialVariants),
("Location contextual names use POV and structural setting", LocationContextualNamesUsePovAndStructuralSetting),
("Location canonical keys merge punctuation and safe plurals", LocationCanonicalKeysMergePunctuationAndSafePlurals),
("Asset filtering rejects generic objects", AssetFilteringRejectsGenericObjects), ("Asset filtering rejects generic objects", AssetFilteringRejectsGenericObjects),
("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 contextual names resolve POV ownership", AssetContextualNamesResolvePovOwnership),
("Relationship signals map to broad lookup types", RelationshipSignalsMapToBroadTypes), ("Relationship signals map to broad lookup types", RelationshipSignalsMapToBroadTypes),
("Relationship signals infer friendship without romance", RelationshipSignalsInferFriendshipWithoutRomance),
("Relationship events preserve chronological story order", RelationshipEventsPreserveChronologicalStoryOrder),
("Knowledge signals map to existing knowledge states", KnowledgeSignalsMapToExistingStates), ("Knowledge signals map to existing knowledge states", KnowledgeSignalsMapToExistingStates),
("Knowledge duplicate statements share canonical keys", KnowledgeDuplicateStatementsShareCanonicalKeys), ("Knowledge duplicate statements share canonical keys", KnowledgeDuplicateStatementsShareCanonicalKeys),
("Knowledge suspicion and certainty remain distinct", KnowledgeSuspicionAndCertaintyRemainDistinct),
("Illustration prompt builder separates spec and prompt", IllustrationPromptBuilderSeparatesSpecAndPrompt), ("Illustration prompt builder separates spec and prompt", IllustrationPromptBuilderSeparatesSpecAndPrompt),
("Illustration prompt builder uses realistic image direction", IllustrationPromptBuilderUsesRealisticImageDirection), ("Illustration prompt builder uses realistic image direction", IllustrationPromptBuilderUsesRealisticImageDirection),
("Illustration specification validation catches missing typed fields", IllustrationSpecificationValidationCatchesMissingTypedFields), ("Illustration specification validation catches missing typed fields", IllustrationSpecificationValidationCatchesMissingTypedFields),
@ -156,6 +163,8 @@ var tests = new (string Name, Action Test)[]
("Story Intelligence import enables project metric defaults before save", StoryIntelligenceImportEnablesProjectMetricDefaultsBeforeSave), ("Story Intelligence import enables project metric defaults before save", StoryIntelligenceImportEnablesProjectMetricDefaultsBeforeSave),
("Project metrics add defaults backfills scene values", ProjectMetricsAddDefaultsBackfillsSceneValues), ("Project metrics add defaults backfills scene values", ProjectMetricsAddDefaultsBackfillsSceneValues),
("Story Intelligence scene prompt uses configured metrics", StoryIntelligenceScenePromptUsesConfiguredMetrics), ("Story Intelligence scene prompt uses configured metrics", StoryIntelligenceScenePromptUsesConfiguredMetrics),
("Story Intelligence scene prompt uses scene characters terminology", StoryIntelligenceScenePromptUsesSceneCharactersTerminology),
("Story Intelligence downstream imports support canonical scene results", StoryIntelligenceDownstreamImportsSupportCanonicalSceneResults),
("Story Intelligence runner supplies metrics and Plot Lines", StoryIntelligenceRunnerSuppliesMetricsAndPlotLines), ("Story Intelligence runner supplies metrics and Plot Lines", StoryIntelligenceRunnerSuppliesMetricsAndPlotLines),
("Story Intelligence metric persistence uses configured keys", StoryIntelligenceMetricPersistenceUsesConfiguredKeys), ("Story Intelligence metric persistence uses configured keys", StoryIntelligenceMetricPersistenceUsesConfiguredKeys),
("Story Intelligence narrative candidates require review", StoryIntelligenceNarrativeCandidatesRequireReview), ("Story Intelligence narrative candidates require review", StoryIntelligenceNarrativeCandidatesRequireReview),
@ -1228,12 +1237,39 @@ static void LocationCanonicalKeysMergeTrivialVariants()
Assert(LocationCanonicalKey("Ashdown Trust (reception)") == LocationCanonicalKey("Ashdown Trust"), "Parenthetical variants should merge."); Assert(LocationCanonicalKey("Ashdown Trust (reception)") == LocationCanonicalKey("Ashdown Trust"), "Parenthetical variants should merge.");
} }
static void LocationContextualNamesUsePovAndStructuralSetting()
{
var bethScene = new Scene { StructuralSetting = "Beth's bedroom", POVCharacterName = "Beth" };
var maggieScene = new Scene { StructuralSetting = "Maggie's bedroom", POVCharacterName = "Maggie" };
Assert(ResolveContextualLocationName("my bedroom", "bedroom", bethScene) == "Beth's bedroom", "Beth POV plus Beth structural setting should resolve my bedroom to Beth's bedroom.");
Assert(ResolveContextualLocationName("bedroom", "bedroom", maggieScene) == "Maggie's bedroom", "Maggie structural setting should resolve generic bedroom to Maggie's bedroom.");
Assert(LocationCanonicalKey("Beth's bedroom") != LocationCanonicalKey("Maggie's bedroom"), "Owned bedrooms for different POV characters must remain distinct.");
Assert(ResolveContextualLocationName("Narrator's home", null, bethScene) == "Beth's home", "Narrator-owned locations should resolve through canonical POV.");
}
static void LocationCanonicalKeysMergePunctuationAndSafePlurals()
{
Assert(LocationCanonicalKey("Mrs Patterson's house") == LocationCanonicalKey("Mrs. Patterson's house"), "Title punctuation variants should merge.");
Assert(LocationCanonicalKey("garage") == LocationCanonicalKey("garages"), "Safe singular/plural location variants should merge.");
Assert(!IsStoryLocation("road"), "Incidental generic road should not be a strong story location.");
}
static bool IsMergedLocationPhrase(string name) static bool IsMergedLocationPhrase(string name)
=> InvokePrivateLocationFilter("IsMergedLocationPhrase", name); => InvokePrivateLocationFilter("IsMergedLocationPhrase", name);
static bool IsStoryLocation(string name) static bool IsStoryLocation(string name)
=> InvokePrivateLocationFilter("IsStoryLocation", name); => InvokePrivateLocationFilter("IsStoryLocation", name);
static string ResolveContextualLocationName(string name, string? genericRoomType, Scene scene)
{
var method = typeof(StoryIntelligenceLocationImportService).GetMethod(
"ResolveContextualLocationName",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
Assert(method is not null, "ResolveContextualLocationName was not found.");
return (string)method!.Invoke(null, [name, genericRoomType, scene])!;
}
static string LocationCanonicalKey(string name) static string LocationCanonicalKey(string name)
{ {
var method = typeof(StoryIntelligenceLocationImportService).GetMethod( var method = typeof(StoryIntelligenceLocationImportService).GetMethod(
@ -1267,6 +1303,7 @@ static void AssetFilteringPreservesStoryAssets()
Assert(IsAssetNameCandidate("passport"), "Passport should be treated as a story asset."); Assert(IsAssetNameCandidate("passport"), "Passport should be treated as a story asset.");
Assert(IsAssetNameCandidate("car keys"), "Car keys should be treated as a story asset."); Assert(IsAssetNameCandidate("car keys"), "Car keys should be treated as a story asset.");
Assert(IsAssetNameCandidate("driving licence"), "Driving licence should be treated as a story asset."); Assert(IsAssetNameCandidate("driving licence"), "Driving licence should be treated as a story asset.");
Assert(IsAssetNameCandidate("Memory Tin"), "Structurally significant Memory Tin-style objects should be treated as story assets.");
} }
static void AssetCanonicalKeysMergeTrivialVariants() static void AssetCanonicalKeysMergeTrivialVariants()
@ -1276,6 +1313,19 @@ static void AssetCanonicalKeysMergeTrivialVariants()
Assert(AssetCanonicalKey("driving license") == AssetCanonicalKey("driving licence"), "License/licence spelling should merge."); Assert(AssetCanonicalKey("driving license") == AssetCanonicalKey("driving licence"), "License/licence spelling should merge.");
} }
static void AssetCanonicalKeysMergeContextualVehicleVariants()
{
Assert(AssetCanonicalKey("Triumph TR6") == AssetCanonicalKey("TR6"), "Triumph TR6 and TR6 should merge when the model identifies the specific vehicle.");
}
static void AssetContextualNamesResolvePovOwnership()
{
var scene = new Scene { POVCharacterName = "Beth" };
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(IsAssetNameCandidate("Beth's Memory Tin"), "Owned Memory Tin-style significant objects should be retained.");
}
static void RelationshipSignalsMapToBroadTypes() static void RelationshipSignalsMapToBroadTypes()
{ {
Assert(RelationshipTypeFromSignal("Friend") == "Friend", "Friend should map to Friend."); Assert(RelationshipTypeFromSignal("Friend") == "Friend", "Friend should map to Friend.");
@ -1288,6 +1338,19 @@ static void RelationshipSignalsMapToBroadTypes()
Assert(RelationshipTypeFromSignal("uncertain emotional connection") == "Unknown", "Uncertain signals should map to Unknown."); Assert(RelationshipTypeFromSignal("uncertain emotional connection") == "Unknown", "Uncertain signals should map to Unknown.");
} }
static void RelationshipSignalsInferFriendshipWithoutRomance()
{
Assert(RelationshipTypeFromSignal("trust", "Maggie and Rosie hug, exchange presents and socialise together.") == "Friend", "Repeated trust, hugs, gifts and social outings should infer Friend.");
Assert(RelationshipTypeFromSignal("trust", "Maggie and Rosie hug, exchange presents and socialise together.") != "Romantic", "Friendship evidence must not overclaim Romance.");
}
static void RelationshipEventsPreserveChronologicalStoryOrder()
{
var first = (SceneID: 1, ChapterNumber: 2m, SceneNumber: 1m, SortOrder: 10);
var second = (SceneID: 2, ChapterNumber: 1m, SceneNumber: 8m, SortOrder: 80);
Assert(StoryOrderKey(second).CompareTo(StoryOrderKey(first)) < 0, "Chapter order should outrank repeated scene numbers.");
}
static void KnowledgeSignalsMapToExistingStates() static void KnowledgeSignalsMapToExistingStates()
{ {
var states = new List<KnowledgeState> var states = new List<KnowledgeState>
@ -1314,6 +1377,13 @@ static void KnowledgeDuplicateStatementsShareCanonicalKeys()
Assert(first == second && second == third, $"Expected duplicate father facts to merge, got {first}, {second}, {third}."); Assert(first == second && second == third, $"Expected duplicate father facts to merge, got {first}, {second}, {third}.");
} }
static void KnowledgeSuspicionAndCertaintyRemainDistinct()
{
var suspects = KnowledgeKey(617, "Simon is involved.", "Suspects");
var knows = KnowledgeKey(617, "Simon is involved.", "Knows");
Assert(suspects != knows, "Suspicion and certainty must remain distinct knowledge states.");
}
static bool IsAssetNameCandidate(string name) static bool IsAssetNameCandidate(string name)
=> InvokePrivateAssetFilter("IsAssetNameCandidate", name); => InvokePrivateAssetFilter("IsAssetNameCandidate", name);
@ -1344,6 +1414,50 @@ static string RelationshipTypeFromSignal(string signal, string? evidence = null)
return (string)method!.Invoke(null, [signal, evidence])!; return (string)method!.Invoke(null, [signal, evidence])!;
} }
static string ResolveContextualAssetName(string name, string? ownerOrHolder, Scene scene)
{
var method = typeof(StoryIntelligenceAssetImportService).GetMethod(
"ResolveContextualAssetName",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
Assert(method is not null, "ResolveContextualAssetName was not found.");
return (string)method!.Invoke(null, [name, ownerOrHolder, scene])!;
}
static IComparable StoryOrderKey((int SceneID, decimal ChapterNumber, decimal SceneNumber, int SortOrder) shim)
{
var nested = typeof(StoryIntelligenceRelationshipImportService).GetNestedType(
"RelationshipAppearanceImport",
System.Reflection.BindingFlags.NonPublic);
Assert(nested is not null, "RelationshipAppearanceImport was not found.");
var nestedType = nested!;
var instance = Activator.CreateInstance(nestedType, [
shim.SceneID,
shim.ChapterNumber,
shim.SceneNumber,
shim.SortOrder,
"Scene",
"trust",
"Evidence",
0.8m,
false
]);
var method = typeof(StoryIntelligenceRelationshipImportService).GetMethod(
"StoryOrder",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
Assert(method is not null, "StoryOrder was not found.");
return (IComparable)method!.Invoke(null, [instance])!;
}
static string KnowledgeKey(int characterId, string statement, string knowledgeStateName)
{
var method = typeof(StoryIntelligenceKnowledgeImportService).GetMethod(
"KnowledgeKey",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
Assert(method is not null, "KnowledgeKey was not found.");
return (string)method!.Invoke(null, [characterId, statement, knowledgeStateName])!;
}
static string KnowledgeStateFromSignal(string changeType, string statement, IReadOnlyList<KnowledgeState> states) static string KnowledgeStateFromSignal(string changeType, string statement, IReadOnlyList<KnowledgeState> states)
{ {
var method = typeof(StoryIntelligenceKnowledgeImportService).GetMethod( var method = typeof(StoryIntelligenceKnowledgeImportService).GetMethod(
@ -3268,7 +3382,7 @@ static void StoryIntelligenceNewFormatContextUsesCanonicalCharacters()
Assert(service.Contains("Source = \"CanonicalCoreImport\"", StringComparison.Ordinal), "New-format known-character context should be marked as canonical Core Import context."); Assert(service.Contains("Source = \"CanonicalCoreImport\"", StringComparison.Ordinal), "New-format known-character context should be marked as canonical Core Import context.");
Assert(service.Contains("return BuildKnownCharacterContextJson(preview, review);", StringComparison.Ordinal), "Legacy scan-derived known-character context should remain fallback-only."); Assert(service.Contains("return BuildKnownCharacterContextJson(preview, review);", StringComparison.Ordinal), "Legacy scan-derived known-character context should remain fallback-only.");
Assert(model.Contains("public IReadOnlyList<int> SceneIDs", StringComparison.Ordinal), "Known-character context should carry mapped canonical scene IDs."); Assert(model.Contains("public IReadOnlyList<int> SceneIDs", StringComparison.Ordinal), "Known-character context should carry mapped canonical scene IDs.");
Assert(prompt.Contains("do not create a character solely because it appears in `knownCharacters`", StringComparison.Ordinal), "Scene prompt should continue preventing known-character-only creation."); Assert(prompt.Contains("do not create a character solely because it appears in `sceneCharacters`", StringComparison.Ordinal), "Scene prompt should continue preventing context-only character creation.");
} }
static void StoryIntelligenceCharacterDefaultsLinkProposedExistingMatches() static void StoryIntelligenceCharacterDefaultsLinkProposedExistingMatches()
@ -3677,6 +3791,36 @@ static void StoryIntelligenceScenePromptUsesConfiguredMetrics()
Assert(validator.Contains("configured metric scale", StringComparison.Ordinal), "Validator error guidance should refer to configured metric scales."); Assert(validator.Contains("configured metric scale", StringComparison.Ordinal), "Validator error guidance should refer to configured metric scales.");
} }
static void StoryIntelligenceScenePromptUsesSceneCharactersTerminology()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var prompt = File.ReadAllText(Path.Combine(root, "Docs/AI/Scene-Prompt-V2.md"));
Assert(prompt.Contains("`sceneCharacters`", StringComparison.Ordinal), "Scene prompt should document the canonical sceneCharacters contract.");
Assert(!prompt.Contains("`knownCharacters`", StringComparison.Ordinal), "Scene prompt should not describe a separate knownCharacters contract.");
Assert(!prompt.Contains("\"subjectName\": \"Narrator\"", StringComparison.Ordinal), "Scene prompt examples should not preserve Narrator as the subject when POV is canonical.");
}
static void StoryIntelligenceDownstreamImportsSupportCanonicalSceneResults()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
foreach (var file in new[]
{
"StoryIntelligenceCharacterImportService.cs",
"StoryIntelligenceLocationImportService.cs",
"StoryIntelligenceAssetImportService.cs",
"StoryIntelligenceRelationshipImportService.cs",
"StoryIntelligenceKnowledgeImportService.cs",
"StoryIntelligenceNarrativeReviewService.cs"
})
{
var source = File.ReadAllText(Path.Combine(root, "Services", file));
Assert(source.Contains("sceneResults.Any(result => result.SceneID.HasValue)", StringComparison.Ordinal), $"{file} should detect canonical scene-result mode.");
Assert(source.Contains("? chapterScenes", StringComparison.Ordinal), $"{file} should resolve canonical SceneID results against saved chapter scenes.");
Assert(source.Contains(": chapterScenes.Where(scene => scene.ImportRunID == item.RunID).ToList()", StringComparison.Ordinal), $"{file} should retain the legacy import-run fallback.");
}
}
static void StoryIntelligenceRunnerSuppliesMetricsAndPlotLines() static void StoryIntelligenceRunnerSuppliesMetricsAndPlotLines()
{ {
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");

View File

@ -89,11 +89,11 @@ When `currentScene.structuralSetting` or `currentScene.primaryLocationName` is s
Do not regenerate scene boundaries, scene titles, scene numbers, chapter numbers, structural summaries, canonical POV or primary setting. These belong to Core Import and the saved PlotDirector scene model. Do not regenerate scene boundaries, scene titles, scene numbers, chapter numbers, structural summaries, canonical POV or primary setting. These belong to Core Import and the saved PlotDirector scene model.
When `knownCharacters` is supplied in the scene context, use it only as identity context for names already detected before analysis: When `sceneCharacters` is supplied in the scene context, use it only as identity context for characters already mapped to this canonical scene before analysis:
- match scene character names against `canonicalName` and `aliases`; - match scene character names against `canonicalName` and `aliases`;
- when the scene uses a supplied alias for the same identity, use the supplied `canonicalName` and include the scene-used name in `aliases`; - when the scene uses a supplied alias for the same identity, use the supplied `canonicalName` and include the scene-used name in `aliases`;
- do not create a character solely because it appears in `knownCharacters`; - do not create a character solely because it appears in `sceneCharacters`;
- do not override explicit scene evidence with scan context. - do not override explicit scene evidence with scan context.
If something is not stated or strongly implied: If something is not stated or strongly implied:
@ -194,7 +194,7 @@ Report characters who are:
For each character: For each character:
- use the manuscript's name or label; - use the manuscript's name or label;
- when supplied `knownCharacters` identify that name as an alias, use the supplied canonical name and place the alias used in the scene in `aliases`; - when supplied `sceneCharacters` identify that name as an alias, use the supplied canonical name and place the alias used in the scene in `aliases`;
- include exactly these properties: `name`, `roleInScene`, `mentionedOnly`, `aliases`, `actions`, `confidence`, `notes`; - include exactly these properties: `name`, `roleInScene`, `mentionedOnly`, `aliases`, `actions`, `confidence`, `notes`;
- set `mentionedOnly` to `false` when active in the scene; - set `mentionedOnly` to `false` when active in the scene;
- set `mentionedOnly` to `true` when absent and only referred to; - set `mentionedOnly` to `true` when absent and only referred to;
@ -258,6 +258,8 @@ For each location, include exactly these properties: `name`, `locationType`, `ge
Use the `name` property for both named locations and generic labels. For an unnamed generic place, use the generic label as `name`, such as `"stair"` or `"doorway"`, and also populate `genericRoomType` when supported. Use `presentInScene: true` when the action occurs there. Use `mentionedOnly: true` only when the location is referred to but not used as the scene's action space. Use `notes: null` when no note is needed. Use the `name` property for both named locations and generic labels. For an unnamed generic place, use the generic label as `name`, such as `"stair"` or `"doorway"`, and also populate `genericRoomType` when supported. Use `presentInScene: true` when the action occurs there. Use `mentionedOnly: true` only when the location is referred to but not used as the scene's action space. Use `notes: null` when no note is needed.
Treat `sceneContext.currentScene.structuralSetting` as the primary established location clue when it is present. If the scene text says `my bedroom`, `the kitchen`, `upstairs`, `the house` or similar and the structural setting already identifies the containing place, report the contextual place rather than a separate bare generic location. Use canonical POV context to resolve first-person ownership such as `my bedroom`; do not output `Narrator's home` when a POV character is supplied.
Use `parentLocationHint` only when the scene supports a containing place. Do not invent location hierarchy. Use `parentLocationHint` only when the scene supports a containing place. Do not invent location hierarchy.
Prefer the active scene location over nearby route or address wording. If the action happens in a house, shop, office, garage, waiting room, test centre, car park, vehicle interior or other functional place that is described by reference to a road, report the functional place as the present location and put the road/address wording in `parentLocationHint` or `notes` only when the scene supports it. Report a road, lane or street as the present location only when the action space is actually the road, lane or street. Prefer the active scene location over nearby route or address wording. If the action happens in a house, shop, office, garage, waiting room, test centre, car park, vehicle interior or other functional place that is described by reference to a road, report the functional place as the present location and put the road/address wording in `parentLocationHint` or `notes` only when the scene supports it. Report a road, lane or street as the present location only when the action space is actually the road, lane or street.
@ -291,6 +293,7 @@ Report an object as an asset when it is:
- sought, lost, hidden, stolen or exchanged; - sought, lost, hidden, stolen or exchanged;
- linked to a story question; - linked to a story question;
- important to a character's factual action or knowledge; - important to a character's factual action or knowledge;
- named in `currentScene.structuralSummary` or the scene title as part of the scene's meaningful action;
- likely to recur as a story object. - likely to recur as a story object.
Do not report ordinary scenery or props unless the scene gives them story importance. Do not report ordinary scenery or props unless the scene gives them story importance.
@ -299,6 +302,8 @@ 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.
## Relationship Signal Rules ## Relationship Signal Rules
Report relationship signals only when the scene supports them. Report relationship signals only when the scene supports them.
@ -323,6 +328,8 @@ For each relationship signal, include exactly these properties: `characterA`, `c
Do not use property names such as `characters`, `signal`, `type`, `description` or `notes` in relationship objects. Use one relationship object per supported signal between two named characters. If the same pair has multiple supported signals, each object must still use `characterA`, `characterB`, `relationshipSignal`, `evidence` and `confidence`. Do not use property names such as `characters`, `signal`, `type`, `description` or `notes` in relationship objects. Use one relationship object per supported signal between two named characters. If the same pair has multiple supported signals, each object must still use `characterA`, `characterB`, `relationshipSignal`, `evidence` and `confidence`.
Preserve relationship progression evidence without flattening it. Repeated trust, hugs, gifts, shared social plans and familiar support can be friendship evidence. Romantic attraction, courtship, proposal or engagement needs explicit romantic evidence. Do not mark an early scene as a final state merely because adjacent or later context suggests a later progression.
## Knowledge Change Rules ## Knowledge Change Rules
Report knowledge changes only when the scene clearly shows that a character gains, confirms, suspects or misunderstands something during the scene. Report knowledge changes only when the scene clearly shows that a character gains, confirms, suspects or misunderstands something during the scene.
@ -355,7 +362,9 @@ Rules:
- Include exactly these properties in every knowledge change: `recipientCharacter`, `knowledgeItem`, `changeType`, `sourceCharacter`, `sourceType`, `evidence`, `confidence`. - Include exactly these properties in every knowledge change: `recipientCharacter`, `knowledgeItem`, `changeType`, `sourceCharacter`, `sourceType`, `evidence`, `confidence`.
- Do not use `knowledgeChanges` for information the reader learns but no character learns. - Do not use `knowledgeChanges` for information the reader learns but no character learns.
- Do not repeat an existing fact in every scene; report a knowledge change when a character acquires, confirms, newly suspects or misunderstands it in this scene.
- Do not assume a character did not know something before unless the scene supports that. - Do not assume a character did not know something before unless the scene supports that.
- Keep subjective knowledge separate from objective identity. A canonical CharacterID may identify a person while a character in the story still only suspects or later discovers that identity.
- Use `sourceCharacter: null` when the source is not a character. - Use `sourceCharacter: null` when the source is not a character.
- Use `sourceType: null` when the source type is unsupported. - Use `sourceType: null` when the source type is unsupported.
- Keep `evidence` concise and scene-backed. - Keep `evidence` concise and scene-backed.
@ -536,11 +545,11 @@ Correct observation:
{ {
"observationType": "CharacterKnowledge", "observationType": "CharacterKnowledge",
"subjectEntityType": "Character", "subjectEntityType": "Character",
"subjectName": "Narrator", "subjectName": "Beth",
"objectEntityType": "Character", "objectEntityType": "Character",
"objectName": "Maggie", "objectName": "Maggie",
"predicate": "noticesAbsenceOf", "predicate": "noticesAbsenceOf",
"description": "The narrator feels Maggie's absence in the car.", "description": "Beth feels Maggie's absence in the car.",
"evidence": "The car felt too quiet without Maggie.", "evidence": "The car felt too quiet without Maggie.",
"confidence": 0.9 "confidence": 0.9
} }

View File

@ -37,7 +37,8 @@ public sealed class StoryIntelligenceAssetImportService(
"tr6", "passport", "photograph", "photo", "key", "keys", "car keys", "letter", "envelope", "note", "tr6", "passport", "photograph", "photo", "key", "keys", "car keys", "letter", "envelope", "note",
"notebook", "red notebook", "old notebook", "knife", "gun", "suitcase", "rucksack", "wedding ring", "notebook", "red notebook", "old 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");
private static readonly HashSet<string> StorySignals = CreateSet( private static readonly HashSet<string> StorySignals = CreateSet(
"lost", "found", "hidden", "stolen", "missing", "taken", "given", "carried", "used", "opened", "lost", "found", "hidden", "stolen", "missing", "taken", "given", "carried", "used", "opened",
@ -317,15 +318,16 @@ public sealed class StoryIntelligenceAssetImportService(
} }
hasCommittedScenes = true; hasCommittedScenes = true;
var importedScenes = (await scenes.ListByChapterAsync(item.ChapterID)) var sceneResults = await runs.ListSceneResultsAsync(item.RunID);
.Where(scene => scene.ImportRunID == item.RunID) var chapterScenes = (await scenes.ListByChapterAsync(item.ChapterID)).ToList();
.ToList(); var importedScenes = sceneResults.Any(result => result.SceneID.HasValue)
? chapterScenes
: chapterScenes.Where(scene => scene.ImportRunID == item.RunID).ToList();
var importedById = importedScenes.ToDictionary(scene => scene.SceneID, scene => scene); var importedById = importedScenes.ToDictionary(scene => scene.SceneID, scene => scene);
var importedByRange = importedScenes var importedByRange = importedScenes
.Where(scene => scene.SourceStartParagraph.HasValue && scene.SourceEndParagraph.HasValue) .Where(scene => scene.SourceStartParagraph.HasValue && scene.SourceEndParagraph.HasValue)
.ToDictionary(scene => $"{scene.SourceStartParagraph}-{scene.SourceEndParagraph}", scene => scene, StringComparer.OrdinalIgnoreCase); .ToDictionary(scene => $"{scene.SourceStartParagraph}-{scene.SourceEndParagraph}", scene => scene, StringComparer.OrdinalIgnoreCase);
var importedByNumber = importedScenes.ToDictionary(scene => Convert.ToInt32(scene.SceneNumber), scene => scene); var importedByNumber = importedScenes.ToDictionary(scene => Convert.ToInt32(scene.SceneNumber), scene => scene);
var sceneResults = await runs.ListSceneResultsAsync(item.RunID);
foreach (var sceneResult in sceneResults) foreach (var sceneResult in sceneResults)
{ {
@ -364,6 +366,8 @@ public sealed class StoryIntelligenceAssetImportService(
SceneIntelligenceScene parsed, SceneIntelligenceScene parsed,
IReadOnlyList<AssetEvent> existingEvents) IReadOnlyList<AssetEvent> existingEvents)
{ {
AddStructuralSummaryAssets(groups, existingIndex, importedScene, parsed, existingEvents);
foreach (var asset in parsed.Assets ?? []) foreach (var asset in parsed.Assets ?? [])
{ {
AddAsset(groups, existingIndex, importedScene, parsed, asset, existingEvents); AddAsset(groups, existingIndex, importedScene, parsed, asset, existingEvents);
@ -406,8 +410,8 @@ public sealed class StoryIntelligenceAssetImportService(
SceneIntelligenceAsset asset, SceneIntelligenceAsset asset,
IReadOnlyList<AssetEvent> existingEvents) IReadOnlyList<AssetEvent> existingEvents)
{ {
var cleanName = CleanAssetName(asset.Name); var cleanName = ResolveContextualAssetName(asset.Name, asset.OwnerOrHolder, importedScene);
if (!IsAssetNameCandidate(cleanName)) if (!IsAssetNameCandidate(cleanName) && !CanAttachToExistingContextualAsset(groups, cleanName))
{ {
return; return;
} }
@ -429,7 +433,7 @@ public sealed class StoryIntelligenceAssetImportService(
candidate.Confidence = Max(candidate.Confidence, asset.Confidence); candidate.Confidence = Max(candidate.Confidence, asset.Confidence);
candidate.Description ??= Clean(asset.Notes); candidate.Description ??= Clean(asset.Notes);
candidate.OwnerOrHolder ??= Clean(asset.OwnerOrHolder); candidate.OwnerOrHolder ??= ResolveOwnerName(asset.OwnerOrHolder, importedScene);
candidate.FirstAppearanceNote ??= BuildFirstAppearance(importedScene, parsed, cleanName); candidate.FirstAppearanceNote ??= BuildFirstAppearance(importedScene, parsed, cleanName);
candidate.Appearances.Add(new AssetAppearanceImport( candidate.Appearances.Add(new AssetAppearanceImport(
importedScene.SceneID, importedScene.SceneID,
@ -442,6 +446,61 @@ public sealed class StoryIntelligenceAssetImportService(
candidate.ExistingAssetID.HasValue && existingEvents.Any(existing => existing.StoryAssetID == candidate.ExistingAssetID.Value))); candidate.ExistingAssetID.HasValue && existingEvents.Any(existing => existing.StoryAssetID == candidate.ExistingAssetID.Value)));
} }
private static void AddStructuralSummaryAssets(
Dictionary<string, AssetCandidate> groups,
AssetIndex existingIndex,
Scene importedScene,
SceneIntelligenceScene parsed,
IReadOnlyList<AssetEvent> existingEvents)
{
var source = FirstConfigured(importedScene.StructuralSummary, importedScene.SceneTitle);
if (string.IsNullOrWhiteSpace(source))
{
return;
}
foreach (Match match in StructuralPossessiveAssetPattern.Matches(source))
{
var owner = match.Groups["owner"].Value;
var objectName = match.Groups["asset"].Value;
if (string.IsNullOrWhiteSpace(owner) || string.IsNullOrWhiteSpace(objectName))
{
continue;
}
var name = $"{owner}'s {objectName}";
AddAsset(groups, existingIndex, importedScene, parsed, new SceneIntelligenceAsset
{
Name = name,
OwnerOrHolder = owner,
Status = "structuralSummarySignificance",
MentionedOnly = true,
Confidence = 0.86m,
Notes = $"Structural summary identifies {name}."
}, existingEvents);
}
foreach (Match match in StructuralSignificantAssetPattern.Matches(source))
{
var objectName = match.Groups["asset"].Value;
if (string.IsNullOrWhiteSpace(objectName) || !IsOwnedAssetName(objectName))
{
continue;
}
var owner = Clean(importedScene.POVCharacterName ?? importedScene.POVSuggestedName);
AddAsset(groups, existingIndex, importedScene, parsed, new SceneIntelligenceAsset
{
Name = objectName,
OwnerOrHolder = owner,
Status = "structuralSummarySignificance",
MentionedOnly = true,
Confidence = 0.82m,
Notes = $"Structural summary identifies {objectName} as significant."
}, existingEvents);
}
}
private async Task<AssetIndex> BuildAssetIndexAsync(int projectId) private async Task<AssetIndex> BuildAssetIndexAsync(int projectId)
{ {
var index = new AssetIndex(); var index = new AssetIndex();
@ -599,9 +658,14 @@ 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 sceneCount >= 2 && ContainsStoryAssetSignal(signalText); return ContainsStructuralSignificance(candidate)
|| sceneCount >= 2 && ContainsStoryAssetSignal(signalText);
} }
private static bool ContainsStructuralSignificance(AssetCandidate candidate)
=> candidate.Appearances.Any(appearance => string.Equals(appearance.EventType, "Mentioned", StringComparison.OrdinalIgnoreCase)
&& ContainsStoryAssetSignal($"{candidate.DisplayName} {candidate.Description} {appearance.Notes} structural summary"));
private static bool IsAssetNameCandidate(string? name) private static bool IsAssetNameCandidate(string? name)
{ {
var clean = CleanAssetName(name); var clean = CleanAssetName(name);
@ -626,6 +690,9 @@ public sealed class StoryIntelligenceAssetImportService(
return AcceptedAssetNames.Contains(normalised) return AcceptedAssetNames.Contains(normalised)
|| AcceptedAssetNames.Contains(withoutArticle) || AcceptedAssetNames.Contains(withoutArticle)
|| normalised.Contains("tr6", StringComparison.OrdinalIgnoreCase) || normalised.Contains("tr6", StringComparison.OrdinalIgnoreCase)
|| normalised.Equals("memory tin", StringComparison.OrdinalIgnoreCase)
|| normalised.EndsWith(" memory tin", 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);
} }
@ -687,9 +754,80 @@ public sealed class StoryIntelligenceAssetImportService(
} }
var canonical = CanonicalAssetKey(name); var canonical = CanonicalAssetKey(name);
return groups.ContainsKey(canonical) ? canonical : canonical; if (groups.ContainsKey(canonical))
{
return canonical;
} }
var contextual = FindContextualAssetKey(groups.Keys, canonical);
return contextual ?? canonical;
}
private static string? FindContextualAssetKey(IEnumerable<string> existingKeys, string candidateKey)
{
if (candidateKey.Contains(' '))
{
return null;
}
var matches = existingKeys
.Where(key => key.EndsWith($" {candidateKey}", StringComparison.OrdinalIgnoreCase))
.ToList();
return matches.Count == 1 ? matches[0] : null;
}
private static bool CanAttachToExistingContextualAsset(IReadOnlyDictionary<string, AssetCandidate> groups, string name)
=> FindContextualAssetKey(groups.Keys, CanonicalAssetKey(name)) is not null;
private static string ResolveContextualAssetName(string? name, string? ownerOrHolder, Scene scene)
{
var cleanName = CleanAssetName(name);
if (string.IsNullOrWhiteSpace(cleanName))
{
return cleanName;
}
var owner = ResolveOwnerName(ownerOrHolder, scene);
if (cleanName.StartsWith("my ", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(scene.POVCharacterName))
{
return $"{scene.POVCharacterName}'s {cleanName[3..].Trim()}";
}
if (!string.IsNullOrWhiteSpace(owner)
&& !cleanName.Contains("'s", StringComparison.OrdinalIgnoreCase)
&& !cleanName.Contains("’s", StringComparison.OrdinalIgnoreCase)
&& IsOwnedAssetName(cleanName))
{
return $"{owner}'s {cleanName}";
}
return cleanName;
}
private static string ResolveOwnerName(string? ownerOrHolder, Scene scene)
{
var owner = Clean(ownerOrHolder);
if (string.IsNullOrWhiteSpace(owner))
{
return owner;
}
if (string.Equals(owner, "Narrator", StringComparison.OrdinalIgnoreCase)
|| string.Equals(owner, "me", StringComparison.OrdinalIgnoreCase)
|| string.Equals(owner, "my", StringComparison.OrdinalIgnoreCase))
{
return Clean(scene.POVCharacterName ?? scene.POVSuggestedName);
}
return owner;
}
private static bool IsOwnedAssetName(string name)
=> ContainsStoryAssetSignal(name)
|| IsAcceptedStoryAssetName(name)
|| name.Contains("tin", StringComparison.OrdinalIgnoreCase)
|| 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)
{ {
var summary = Clean(FirstConfigured(scene.StructuralSummary, scene.Summary, parsed.Summary?.Short)); var summary = Clean(FirstConfigured(scene.StructuralSummary, scene.Summary, parsed.Summary?.Short));
@ -742,6 +880,11 @@ 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))
{
return "tr6";
}
var words = clean.Split(' ', StringSplitOptions.RemoveEmptyEntries).ToList(); var words = clean.Split(' ', StringSplitOptions.RemoveEmptyEntries).ToList();
while (words.Count > 1 && AdjectivesToFold.Contains(words[0])) while (words.Count > 1 && AdjectivesToFold.Contains(words[0]))
{ {
@ -767,7 +910,7 @@ public sealed class StoryIntelligenceAssetImportService(
} }
private static string CleanAssetName(string? value) private static string CleanAssetName(string? value)
=> Clean(value).Trim(' ', '.', ',', ';', ':', '!', '?', '"', '\''); => StoryIntelligenceEntityTextNormaliser.CleanName(value);
private static string RemoveLeadingArticle(string value) private static string RemoveLeadingArticle(string value)
=> value.StartsWith("the ", StringComparison.OrdinalIgnoreCase) => value.StartsWith("the ", StringComparison.OrdinalIgnoreCase)
@ -779,14 +922,22 @@ public sealed class StoryIntelligenceAssetImportService(
: value; : value;
private static string Normalise(string? value) private static string Normalise(string? value)
=> Clean(value).ToLowerInvariant(); => StoryIntelligenceEntityTextNormaliser.SafeKey(value);
private static string Clean(string? value) private static string Clean(string? value)
=> string.Join(' ', (value ?? string.Empty).Trim().Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)); => StoryIntelligenceEntityTextNormaliser.Clean(value);
private static HashSet<string> CreateSet(params string[] values) private static HashSet<string> CreateSet(params string[] values)
=> values.Select(Normalise).ToHashSet(StringComparer.OrdinalIgnoreCase); => values.Select(Normalise).ToHashSet(StringComparer.OrdinalIgnoreCase);
private static readonly Regex StructuralPossessiveAssetPattern = new(
@"\b(?<owner>[A-Z][\p{L}'-]{1,40})'s\s+(?<asset>[A-Z][\p{L}\p{N}'-]*(?:\s+[A-Z][\p{L}\p{N}'-]*){0,3})\b",
RegexOptions.Compiled);
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",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
private sealed class AssetIndex private sealed class AssetIndex
{ {
private readonly Dictionary<string, AssetMatch> byName = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary<string, AssetMatch> byName = new(StringComparer.OrdinalIgnoreCase);

View File

@ -423,15 +423,16 @@ public sealed class StoryIntelligenceCharacterImportService(
} }
hasCommittedScenes = true; hasCommittedScenes = true;
var importedScenes = (await scenes.ListByChapterAsync(item.ChapterID)) var sceneResults = await runs.ListSceneResultsAsync(item.RunID);
.Where(scene => scene.ImportRunID == item.RunID) var chapterScenes = (await scenes.ListByChapterAsync(item.ChapterID)).ToList();
.ToList(); var importedScenes = sceneResults.Any(result => result.SceneID.HasValue)
? chapterScenes
: chapterScenes.Where(scene => scene.ImportRunID == item.RunID).ToList();
var importedById = importedScenes.ToDictionary(scene => scene.SceneID, scene => scene); var importedById = importedScenes.ToDictionary(scene => scene.SceneID, scene => scene);
var importedByRange = importedScenes var importedByRange = importedScenes
.Where(scene => scene.SourceStartParagraph.HasValue && scene.SourceEndParagraph.HasValue) .Where(scene => scene.SourceStartParagraph.HasValue && scene.SourceEndParagraph.HasValue)
.ToDictionary(scene => $"{scene.SourceStartParagraph}-{scene.SourceEndParagraph}", scene => scene, StringComparer.OrdinalIgnoreCase); .ToDictionary(scene => $"{scene.SourceStartParagraph}-{scene.SourceEndParagraph}", scene => scene, StringComparer.OrdinalIgnoreCase);
var importedByNumber = importedScenes.ToDictionary(scene => Convert.ToInt32(scene.SceneNumber), scene => scene); var importedByNumber = importedScenes.ToDictionary(scene => Convert.ToInt32(scene.SceneNumber), scene => scene);
var sceneResults = await runs.ListSceneResultsAsync(item.RunID);
foreach (var sceneResult in sceneResults) foreach (var sceneResult in sceneResults)
{ {

View File

@ -0,0 +1,65 @@
using System.Text.RegularExpressions;
namespace PlotLine.Services;
public static class StoryIntelligenceEntityTextNormaliser
{
private static readonly Regex Whitespace = new(@"\s+", RegexOptions.Compiled);
private static readonly Regex TitleFullStops = new(@"\b(Mr|Mrs|Ms|Miss|Dr|Prof|Rev|Revd)\.", RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly Regex NonKeyCharacters = new(@"[^\p{L}\p{N}']+", RegexOptions.Compiled);
public static string Clean(string? value)
=> Whitespace.Replace((value ?? string.Empty).Replace('’', '\''), " ").Trim();
public static string CleanName(string? value)
=> Clean(value).Trim(' ', '.', ',', ';', ':', '!', '?', '"', '\'');
public static string StripLeadingArticle(string value)
{
var clean = Clean(value);
return clean.StartsWith("the ", StringComparison.OrdinalIgnoreCase)
? clean[4..]
: clean.StartsWith("a ", StringComparison.OrdinalIgnoreCase)
? clean[2..]
: clean.StartsWith("an ", StringComparison.OrdinalIgnoreCase)
? clean[3..]
: clean;
}
public static string SafeKey(string? value)
{
var clean = TitleFullStops.Replace(Clean(value), "$1");
clean = NonKeyCharacters.Replace(clean, " ");
clean = Whitespace.Replace(clean, " ").Trim().ToLowerInvariant();
return clean;
}
public static string SingulariseSafeFinalWord(string value, IReadOnlySet<string> singularisableWords)
{
var words = SafeKey(value).Split(' ', StringSplitOptions.RemoveEmptyEntries).ToList();
if (words.Count == 0)
{
return string.Empty;
}
var last = words[^1];
if (last.EndsWith("ies", StringComparison.OrdinalIgnoreCase))
{
var singular = $"{last[..^3]}y";
if (singularisableWords.Contains(singular))
{
words[^1] = singular;
}
}
else if (last.EndsWith('s') && last.Length > 3)
{
var singular = last[..^1];
if (singularisableWords.Contains(singular))
{
words[^1] = singular;
}
}
return string.Join(' ', words);
}
}

View File

@ -234,15 +234,16 @@ public sealed class StoryIntelligenceKnowledgeImportService(
} }
hasCommittedScenes = true; hasCommittedScenes = true;
var importedScenes = (await scenes.ListByChapterAsync(item.ChapterID)) var sceneResults = await runs.ListSceneResultsAsync(item.RunID);
.Where(scene => scene.ImportRunID == item.RunID) var chapterScenes = (await scenes.ListByChapterAsync(item.ChapterID)).ToList();
.ToList(); var importedScenes = sceneResults.Any(result => result.SceneID.HasValue)
? chapterScenes
: chapterScenes.Where(scene => scene.ImportRunID == item.RunID).ToList();
var importedById = importedScenes.ToDictionary(scene => scene.SceneID, scene => scene); var importedById = importedScenes.ToDictionary(scene => scene.SceneID, scene => scene);
var importedByRange = importedScenes var importedByRange = importedScenes
.Where(scene => scene.SourceStartParagraph.HasValue && scene.SourceEndParagraph.HasValue) .Where(scene => scene.SourceStartParagraph.HasValue && scene.SourceEndParagraph.HasValue)
.ToDictionary(scene => $"{scene.SourceStartParagraph}-{scene.SourceEndParagraph}", scene => scene, StringComparer.OrdinalIgnoreCase); .ToDictionary(scene => $"{scene.SourceStartParagraph}-{scene.SourceEndParagraph}", scene => scene, StringComparer.OrdinalIgnoreCase);
var importedByNumber = importedScenes.ToDictionary(scene => Convert.ToInt32(scene.SceneNumber), scene => scene); var importedByNumber = importedScenes.ToDictionary(scene => Convert.ToInt32(scene.SceneNumber), scene => scene);
var sceneResults = await runs.ListSceneResultsAsync(item.RunID);
foreach (var sceneResult in sceneResults) foreach (var sceneResult in sceneResults)
{ {
@ -331,12 +332,12 @@ public sealed class StoryIntelligenceKnowledgeImportService(
return; return;
} }
var key = KnowledgeKey(character.CharacterID, statement); var stateName = KnowledgeStateFromSignal(changeType, statement, knowledgeStates);
var key = KnowledgeKey(character.CharacterID, statement, stateName);
if (!groups.TryGetValue(key, out var candidate)) if (!groups.TryGetValue(key, out var candidate))
{ {
var existingKey = ExistingKnowledgeKey(character.CharacterID, statement); var existingKey = ExistingKnowledgeKey(character.CharacterID, statement, stateName);
existingKnowledge.TryGetValue(existingKey, out var existingMatch); existingKnowledge.TryGetValue(existingKey, out var existingMatch);
var stateName = KnowledgeStateFromSignal(changeType, statement, knowledgeStates);
var assetMatch = assetIndex.Find(statement); var assetMatch = assetIndex.Find(statement);
candidate = new KnowledgeCandidate( candidate = new KnowledgeCandidate(
key, key,
@ -375,7 +376,7 @@ public sealed class StoryIntelligenceKnowledgeImportService(
{ {
foreach (var item in await characters.ListKnowledgeByCharacterAsync(character.CharacterID)) foreach (var item in await characters.ListKnowledgeByCharacterAsync(character.CharacterID))
{ {
var key = ExistingKnowledgeKey(item.CharacterID, KnowledgeTitle(item)); var key = ExistingKnowledgeKey(item.CharacterID, KnowledgeTitle(item), item.KnowledgeStateName);
if (!rows.ContainsKey(key)) if (!rows.ContainsKey(key))
{ {
rows[key] = item; rows[key] = item;
@ -583,11 +584,37 @@ public sealed class StoryIntelligenceKnowledgeImportService(
return firstLine ?? cleaned; return firstLine ?? cleaned;
} }
private static string KnowledgeKey(int characterId, string statement) private static string KnowledgeKey(int characterId, string statement, string? knowledgeStateName)
=> $"{characterId}:{CanonicalKnowledgeKey(statement)}"; => $"{characterId}:{CanonicalKnowledgeStateKey(knowledgeStateName)}:{CanonicalKnowledgeKey(statement)}";
private static string ExistingKnowledgeKey(int characterId, string statement) private static string ExistingKnowledgeKey(int characterId, string statement, string? knowledgeStateName)
=> KnowledgeKey(characterId, statement); => KnowledgeKey(characterId, statement, knowledgeStateName);
private static string CanonicalKnowledgeStateKey(string? value)
{
var normalised = Normalise(value);
if (normalised.Contains("suspect"))
{
return "suspects";
}
if (normalised.Contains("misunderstand"))
{
return "misunderstands";
}
if (normalised.Contains("unaware"))
{
return "unaware";
}
if (normalised.Contains("partial") || normalised.Contains("believe"))
{
return "partial";
}
return "knows";
}
private static string CanonicalKnowledgeKey(string? value) private static string CanonicalKnowledgeKey(string? value)
{ {

View File

@ -43,7 +43,10 @@ public sealed class StoryIntelligenceLocationImportService(
private static readonly HashSet<string> RepeatableGenericSettings = CreateSet( private static readonly HashSet<string> RepeatableGenericSettings = CreateSet(
"kitchen", "bathroom", "hallway", "corridor", "lobby", "reception", "office", "bedroom", "garage", "kitchen", "bathroom", "hallway", "corridor", "lobby", "reception", "office", "bedroom", "garage",
"seating area", "waiting room", "interview room", "staff room"); "seating area", "waiting room", "interview room", "staff room", "car park", "parking lot");
private static readonly HashSet<string> SingularisableLocationWords = CreateSet(
"garage", "road", "street", "room", "house", "flat", "bedroom", "kitchen", "garden", "office");
private static readonly HashSet<string> StoryLocationSignals = CreateSet( private static readonly HashSet<string> StoryLocationSignals = CreateSet(
"trust", "church", "school", "university", "police", "headquarters", "hq", "house", "flat", "pub", "trust", "church", "school", "university", "police", "headquarters", "hq", "house", "flat", "pub",
@ -273,15 +276,16 @@ public sealed class StoryIntelligenceLocationImportService(
} }
hasCommittedScenes = true; hasCommittedScenes = true;
var importedScenes = (await scenes.ListByChapterAsync(item.ChapterID)) var sceneResults = await runs.ListSceneResultsAsync(item.RunID);
.Where(scene => scene.ImportRunID == item.RunID) var chapterScenes = (await scenes.ListByChapterAsync(item.ChapterID)).ToList();
.ToList(); var importedScenes = sceneResults.Any(result => result.SceneID.HasValue)
? chapterScenes
: chapterScenes.Where(scene => scene.ImportRunID == item.RunID).ToList();
var importedById = importedScenes.ToDictionary(scene => scene.SceneID, scene => scene); var importedById = importedScenes.ToDictionary(scene => scene.SceneID, scene => scene);
var importedByRange = importedScenes var importedByRange = importedScenes
.Where(scene => scene.SourceStartParagraph.HasValue && scene.SourceEndParagraph.HasValue) .Where(scene => scene.SourceStartParagraph.HasValue && scene.SourceEndParagraph.HasValue)
.ToDictionary(scene => $"{scene.SourceStartParagraph}-{scene.SourceEndParagraph}", scene => scene, StringComparer.OrdinalIgnoreCase); .ToDictionary(scene => $"{scene.SourceStartParagraph}-{scene.SourceEndParagraph}", scene => scene, StringComparer.OrdinalIgnoreCase);
var importedByNumber = importedScenes.ToDictionary(scene => Convert.ToInt32(scene.SceneNumber), scene => scene); var importedByNumber = importedScenes.ToDictionary(scene => Convert.ToInt32(scene.SceneNumber), scene => scene);
var sceneResults = await runs.ListSceneResultsAsync(item.RunID);
foreach (var sceneResult in sceneResults) foreach (var sceneResult in sceneResults)
{ {
@ -323,25 +327,31 @@ public sealed class StoryIntelligenceLocationImportService(
Scene importedScene, Scene importedScene,
SceneIntelligenceScene parsed) SceneIntelligenceScene parsed)
{ {
var structuralSetting = Clean(importedScene.StructuralSetting);
if (!string.IsNullOrWhiteSpace(structuralSetting))
{
AddLocation(groups, existingIndex, importedScene, parsed, structuralSetting, "named", null, null, true, false, true, 0.95m, "Structural setting from Core Import.");
}
var setting = parsed.Setting; var setting = parsed.Setting;
var settingName = FirstConfigured(setting?.LocationName, setting?.GenericRoomType); var settingName = ResolveContextualLocationName(FirstConfigured(setting?.LocationName, setting?.GenericRoomType), setting?.GenericRoomType, importedScene);
AddLocation(groups, existingIndex, importedScene, parsed, settingName, setting?.LocationType, setting?.GenericRoomType, setting?.ParentLocationHint, true, false, true, setting?.Confidence, "Scene setting"); AddLocation(groups, existingIndex, importedScene, parsed, settingName, setting?.LocationType, setting?.GenericRoomType, setting?.ParentLocationHint, true, false, true, setting?.Confidence, "Scene setting");
foreach (var location in parsed.Locations ?? []) foreach (var location in parsed.Locations ?? [])
{ {
AddLocation(groups, existingIndex, importedScene, parsed, location.Name, location.LocationType, location.GenericRoomType, location.ParentLocationHint, location.PresentInScene == true, location.MentionedOnly == true, false, location.Confidence, location.Notes); AddLocation(groups, existingIndex, importedScene, parsed, ResolveContextualLocationName(location.Name, location.GenericRoomType, importedScene), location.LocationType, location.GenericRoomType, location.ParentLocationHint, location.PresentInScene == true, location.MentionedOnly == true, false, location.Confidence, location.Notes);
} }
foreach (var observation in parsed.Observations ?? []) foreach (var observation in parsed.Observations ?? [])
{ {
if (IsLocationEntity(observation.SubjectEntityType)) if (IsLocationEntity(observation.SubjectEntityType))
{ {
AddLocation(groups, existingIndex, importedScene, parsed, observation.SubjectName, null, null, null, IsPresentPredicate(observation.Predicate), !IsPresentPredicate(observation.Predicate), false, observation.Confidence, observation.Description); AddLocation(groups, existingIndex, importedScene, parsed, ResolveContextualLocationName(observation.SubjectName, null, importedScene), null, null, null, IsPresentPredicate(observation.Predicate), !IsPresentPredicate(observation.Predicate), false, observation.Confidence, observation.Description);
} }
if (IsLocationEntity(observation.ObjectEntityType)) if (IsLocationEntity(observation.ObjectEntityType))
{ {
AddLocation(groups, existingIndex, importedScene, parsed, observation.ObjectName, null, null, null, IsPresentPredicate(observation.Predicate), !IsPresentPredicate(observation.Predicate), false, observation.Confidence, observation.Description); AddLocation(groups, existingIndex, importedScene, parsed, ResolveContextualLocationName(observation.ObjectName, null, importedScene), null, null, null, IsPresentPredicate(observation.Predicate), !IsPresentPredicate(observation.Predicate), false, observation.Confidence, observation.Description);
} }
} }
} }
@ -761,34 +771,63 @@ public sealed class StoryIntelligenceLocationImportService(
private static string CanonicalLocationKey(string name) private static string CanonicalLocationKey(string name)
{ {
var clean = StripParenthetical(CleanLocationName(name)); var clean = StoryIntelligenceEntityTextNormaliser.StripLeadingArticle(StripParenthetical(CleanLocationName(name)));
if (clean.StartsWith("the ", StringComparison.OrdinalIgnoreCase)) return StoryIntelligenceEntityTextNormaliser.SingulariseSafeFinalWord(clean, SingularisableLocationWords);
{
clean = clean[4..];
} }
return Normalise(clean); private static string ResolveContextualLocationName(string? name, string? genericRoomType, Scene scene)
{
var clean = CleanLocationName(name);
if (string.IsNullOrWhiteSpace(clean))
{
return clean;
}
var structuralSetting = CleanLocationName(scene.StructuralSetting);
var leaf = CleanLocationName(FirstConfigured(genericRoomType, clean));
if (!string.IsNullOrWhiteSpace(structuralSetting) && StructuralSettingCoversLocation(structuralSetting, leaf))
{
return structuralSetting;
}
var povName = Clean(scene.POVCharacterName ?? scene.POVSuggestedName);
if (!string.IsNullOrWhiteSpace(povName))
{
clean = Regex.Replace(clean, @"\bNarrator's\b", $"{povName}'s", RegexOptions.IgnoreCase);
clean = Regex.Replace(clean, @"\bNarrator’s\b", $"{povName}'s", RegexOptions.IgnoreCase);
if (clean.StartsWith("my ", StringComparison.OrdinalIgnoreCase))
{
return $"{povName}'s {clean[3..].Trim()}";
}
}
return clean;
}
private static bool StructuralSettingCoversLocation(string structuralSetting, string locationName)
{
var settingKey = CanonicalLocationKey(structuralSetting);
var locationKey = CanonicalLocationKey(locationName);
if (string.IsNullOrWhiteSpace(settingKey) || string.IsNullOrWhiteSpace(locationKey))
{
return false;
}
return settingKey.Equals(locationKey, StringComparison.OrdinalIgnoreCase)
|| settingKey.EndsWith($" {locationKey}", StringComparison.OrdinalIgnoreCase);
} }
private static string StripParenthetical(string? value) private static string StripParenthetical(string? value)
=> Regex.Replace(Clean(value), @"\s*\([^)]*\)\s*$", string.Empty); => Regex.Replace(Clean(value), @"\s*\([^)]*\)\s*$", string.Empty);
private static string CleanLocationName(string? value) private static string CleanLocationName(string? value)
{ => StoryIntelligenceEntityTextNormaliser.CleanName(value);
var clean = Clean(value).Trim(' ', '.', ',', ';', ':', '!', '?', '"', '\'');
if (clean.EndsWith("'s", StringComparison.OrdinalIgnoreCase) || clean.EndsWith("’s", StringComparison.OrdinalIgnoreCase))
{
return clean;
}
return clean;
}
private static string Normalise(string? value) private static string Normalise(string? value)
=> Clean(value).ToLowerInvariant(); => StoryIntelligenceEntityTextNormaliser.SafeKey(value);
private static string Clean(string? value) private static string Clean(string? value)
=> string.Join(' ', (value ?? string.Empty).Trim().Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)); => StoryIntelligenceEntityTextNormaliser.Clean(value);
private static HashSet<string> CreateSet(params string[] values) private static HashSet<string> CreateSet(params string[] values)
=> values.Select(Normalise).ToHashSet(StringComparer.OrdinalIgnoreCase); => values.Select(Normalise).ToHashSet(StringComparer.OrdinalIgnoreCase);

View File

@ -203,15 +203,16 @@ public sealed class StoryIntelligenceNarrativeReviewService(
continue; continue;
} }
var importedScenes = (await scenes.ListByChapterAsync(item.ChapterID)) var sceneResults = await runs.ListSceneResultsAsync(item.RunID);
.Where(scene => scene.ImportRunID == item.RunID) var chapterScenes = (await scenes.ListByChapterAsync(item.ChapterID)).ToList();
.ToList(); var importedScenes = sceneResults.Any(result => result.SceneID.HasValue)
? chapterScenes
: chapterScenes.Where(scene => scene.ImportRunID == item.RunID).ToList();
var importedById = importedScenes.ToDictionary(scene => scene.SceneID, scene => scene); var importedById = importedScenes.ToDictionary(scene => scene.SceneID, scene => scene);
var importedByRange = importedScenes var importedByRange = importedScenes
.Where(scene => scene.SourceStartParagraph.HasValue && scene.SourceEndParagraph.HasValue) .Where(scene => scene.SourceStartParagraph.HasValue && scene.SourceEndParagraph.HasValue)
.ToDictionary(scene => $"{scene.SourceStartParagraph}-{scene.SourceEndParagraph}", scene => scene, StringComparer.OrdinalIgnoreCase); .ToDictionary(scene => $"{scene.SourceStartParagraph}-{scene.SourceEndParagraph}", scene => scene, StringComparer.OrdinalIgnoreCase);
var importedByNumber = importedScenes.ToDictionary(scene => Convert.ToInt32(scene.SceneNumber), scene => scene); var importedByNumber = importedScenes.ToDictionary(scene => Convert.ToInt32(scene.SceneNumber), scene => scene);
var sceneResults = await runs.ListSceneResultsAsync(item.RunID);
foreach (var sceneResult in sceneResults) foreach (var sceneResult in sceneResults)
{ {

View File

@ -136,7 +136,7 @@ public sealed class StoryIntelligenceRelationshipImportService(
CharacterBID = candidate.CharacterBID, CharacterBID = candidate.CharacterBID,
RelationshipTypeID = choice.RelationshipTypeID.Value, RelationshipTypeID = choice.RelationshipTypeID.Value,
IsPermanent = false, IsPermanent = false,
StartSceneID = candidate.Appearances.OrderBy(appearance => appearance.SceneNumber).FirstOrDefault()?.SceneID, StartSceneID = candidate.Appearances.OrderBy(StoryOrder).FirstOrDefault()?.SceneID,
EndSceneID = null, EndSceneID = null,
IsKnownToReader = true, IsKnownToReader = true,
Notes = BuildRelationshipNotes(candidate), Notes = BuildRelationshipNotes(candidate),
@ -199,7 +199,8 @@ public sealed class StoryIntelligenceRelationshipImportService(
foreach (var appearance in candidate.Appearances foreach (var appearance in candidate.Appearances
.Where(appearance => !appearance.AlreadyLinked) .Where(appearance => !appearance.AlreadyLinked)
.GroupBy(appearance => appearance.SceneID) .GroupBy(appearance => appearance.SceneID)
.Select(group => group.OrderByDescending(appearance => appearance.Confidence ?? 0m).First())) .Select(group => group.OrderByDescending(appearance => appearance.Confidence ?? 0m).First())
.OrderBy(StoryOrder))
{ {
if (!HasMeaningfulRelationshipEvent(appearance)) if (!HasMeaningfulRelationshipEvent(appearance))
{ {
@ -267,15 +268,16 @@ public sealed class StoryIntelligenceRelationshipImportService(
} }
hasCommittedScenes = true; hasCommittedScenes = true;
var importedScenes = (await scenes.ListByChapterAsync(item.ChapterID)) var sceneResults = await runs.ListSceneResultsAsync(item.RunID);
.Where(scene => scene.ImportRunID == item.RunID) var chapterScenes = (await scenes.ListByChapterAsync(item.ChapterID)).ToList();
.ToList(); var importedScenes = sceneResults.Any(result => result.SceneID.HasValue)
? chapterScenes
: chapterScenes.Where(scene => scene.ImportRunID == item.RunID).ToList();
var importedById = importedScenes.ToDictionary(scene => scene.SceneID, scene => scene); var importedById = importedScenes.ToDictionary(scene => scene.SceneID, scene => scene);
var importedByRange = importedScenes var importedByRange = importedScenes
.Where(scene => scene.SourceStartParagraph.HasValue && scene.SourceEndParagraph.HasValue) .Where(scene => scene.SourceStartParagraph.HasValue && scene.SourceEndParagraph.HasValue)
.ToDictionary(scene => $"{scene.SourceStartParagraph}-{scene.SourceEndParagraph}", scene => scene, StringComparer.OrdinalIgnoreCase); .ToDictionary(scene => $"{scene.SourceStartParagraph}-{scene.SourceEndParagraph}", scene => scene, StringComparer.OrdinalIgnoreCase);
var importedByNumber = importedScenes.ToDictionary(scene => Convert.ToInt32(scene.SceneNumber), scene => scene); var importedByNumber = importedScenes.ToDictionary(scene => Convert.ToInt32(scene.SceneNumber), scene => scene);
var sceneResults = await runs.ListSceneResultsAsync(item.RunID);
foreach (var sceneResult in sceneResults) foreach (var sceneResult in sceneResults)
{ {
@ -390,7 +392,9 @@ public sealed class StoryIntelligenceRelationshipImportService(
candidate.ExampleContext ??= Clean(evidence); candidate.ExampleContext ??= Clean(evidence);
candidate.Appearances.Add(new RelationshipAppearanceImport( candidate.Appearances.Add(new RelationshipAppearanceImport(
importedScene.SceneID, importedScene.SceneID,
importedScene.ChapterNumber,
importedScene.SceneNumber, importedScene.SceneNumber,
importedScene.SortOrder,
importedScene.SceneTitle, importedScene.SceneTitle,
relationshipSignal, relationshipSignal,
evidence, evidence,
@ -491,8 +495,9 @@ public sealed class StoryIntelligenceRelationshipImportService(
var value = Normalise(signal); var value = Normalise(signal);
var stateName = var stateName =
value.Contains("reconcil") ? "Reconciled" : value.Contains("reconcil") ? "Reconciled" :
value.Contains("romantic") || value.Contains("lover") || value.Contains("love") ? "Romantic" : value.Contains("engaged") || value.Contains("proposal") || value.Contains("proposes") ? "Engaged" :
value.Contains("friend") || value.Contains("ally") || value.Contains("support") ? "Friendly" : value.Contains("romantic") || value.Contains("attraction") || value.Contains("lover") || value.Contains("love") ? "Romantic" :
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" :
@ -511,7 +516,7 @@ public sealed class StoryIntelligenceRelationshipImportService(
return "Family"; return "Family";
} }
if (ContainsAny(value, "romance", "romantic", "lover", "spouse", "husband", "wife", "girlfriend", "boyfriend", "love interest", "former partner", "partner like", "partner-like")) if (ContainsAny(value, "romance", "romantic", "lover", "spouse", "husband", "wife", "girlfriend", "boyfriend", "love interest", "former partner", "partner like", "partner-like", "attraction", "flirt", "kiss", "proposal", "engaged"))
{ {
return "Romantic"; return "Romantic";
} }
@ -546,7 +551,7 @@ public sealed class StoryIntelligenceRelationshipImportService(
return "Professional"; return "Professional";
} }
if (ContainsAny(value, "friend", "friendship", "best friend", "close friend", "childhood friend", "trust between friends", "affection between friends")) if (ContainsAny(value, "friend", "friendship", "best friend", "close friend", "childhood friend", "trust between friends", "affection between friends", "trust", "familiarity", "comfortable together", "socialise", "socialize", "spend time together", "spends time together", "hug", "hugs", "gift", "gifts", "present", "presents"))
{ {
return "Friend"; return "Friend";
} }
@ -570,6 +575,9 @@ public sealed class StoryIntelligenceRelationshipImportService(
return RelationshipEventWords.Any(word => value.Contains(word, StringComparison.OrdinalIgnoreCase)); return RelationshipEventWords.Any(word => value.Contains(word, StringComparison.OrdinalIgnoreCase));
} }
private static (decimal ChapterNumber, decimal SceneNumber, int SortOrder, int SceneID) StoryOrder(RelationshipAppearanceImport appearance)
=> (appearance.ChapterNumber, appearance.SceneNumber, appearance.SortOrder, appearance.SceneID);
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." };
@ -687,14 +695,17 @@ public sealed class StoryIntelligenceRelationshipImportService(
private static readonly string[] RelationshipSignalWords = private static readonly string[] RelationshipSignalWords =
[ [
"friend", "family", "romantic", "lover", "spouse", "parent", "child", "sibling", "enemy", "friend", "family", "romantic", "lover", "spouse", "parent", "child", "sibling", "enemy",
"rival", "ally", "argument", "conflict", "reconciled", "separated", "deteriorates", "meeting" "rival", "ally", "argument", "conflict", "reconciled", "separated", "deteriorates", "meeting",
"trust", "hug", "gift", "present", "proposal", "engaged", "attraction"
]; ];
private static readonly string[] RelationshipEventWords = private static readonly string[] RelationshipEventWords =
[ [
"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",
"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);
@ -727,7 +738,9 @@ public sealed class StoryIntelligenceRelationshipImportService(
private sealed record RelationshipAppearanceImport( private sealed record RelationshipAppearanceImport(
int SceneID, int SceneID,
decimal ChapterNumber,
decimal SceneNumber, decimal SceneNumber,
int SortOrder,
string SceneTitle, string SceneTitle,
string? Signal, string? Signal,
string? Evidence, string? Evidence,