Complete story intelligence entity quality proof
This commit is contained in:
parent
3bf8f88f16
commit
9c27ba6ffa
@ -41,12 +41,18 @@ var tests = new (string Name, Action Test)[]
|
||||
("Asset filtering preserves story assets", AssetFilteringPreservesStoryAssets),
|
||||
("Asset canonical keys merge trivial variants", AssetCanonicalKeysMergeTrivialVariants),
|
||||
("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 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 infer friendship without romance", RelationshipSignalsInferFriendshipWithoutRomance),
|
||||
("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 recipient resolves narrator through POV", KnowledgeRecipientResolvesNarratorThroughPov),
|
||||
("Knowledge identity reveal preserves subjective timing", KnowledgeIdentityRevealPreservesSubjectiveTiming),
|
||||
("Knowledge duplicate statements share canonical keys", KnowledgeDuplicateStatementsShareCanonicalKeys),
|
||||
("Knowledge suspicion and certainty remain distinct", KnowledgeSuspicionAndCertaintyRemainDistinct),
|
||||
("Illustration prompt builder separates spec and prompt", IllustrationPromptBuilderSeparatesSpecAndPrompt),
|
||||
@ -1317,6 +1323,13 @@ static void AssetCanonicalKeysMergeTrivialVariants()
|
||||
static void AssetCanonicalKeysMergeContextualVehicleVariants()
|
||||
{
|
||||
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()
|
||||
@ -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("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.");
|
||||
|
||||
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()
|
||||
{
|
||||
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()
|
||||
@ -1357,6 +1386,33 @@ static void RelationshipEventsPreserveChronologicalStoryOrder()
|
||||
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()
|
||||
{
|
||||
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.");
|
||||
}
|
||||
|
||||
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()
|
||||
{
|
||||
var first = CanonicalKnowledgeKey("Simon is Beth's father.");
|
||||
@ -1393,6 +1477,9 @@ static void KnowledgeSuspicionAndCertaintyRemainDistinct()
|
||||
static bool IsAssetNameCandidate(string name)
|
||||
=> InvokePrivateAssetFilter("IsAssetNameCandidate", name);
|
||||
|
||||
static bool IsDistinctiveAssetName(string name)
|
||||
=> InvokePrivateAssetFilter("IsDistinctiveAssetName", name);
|
||||
|
||||
static string AssetCanonicalKey(string name)
|
||||
{
|
||||
var method = typeof(StoryIntelligenceAssetImportService).GetMethod(
|
||||
@ -1463,6 +1550,60 @@ static IComparable StoryOrderKey((int SceneID, decimal ChapterNumber, decimal Sc
|
||||
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)
|
||||
{
|
||||
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])!;
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
@ -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("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(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.");
|
||||
|
||||
@ -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.
|
||||
|
||||
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.
|
||||
|
||||
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 `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.
|
||||
|
||||
@ -182,9 +182,9 @@ Do not output:
|
||||
"schemaVersion": "1.0",
|
||||
"chapterSummary": "Structural analysis of the supplied chapter.",
|
||||
"defaultPov": {
|
||||
"characterName": "Maggie",
|
||||
"characterName": "Lena",
|
||||
"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
|
||||
},
|
||||
"mixedPov": false,
|
||||
|
||||
@ -31,7 +31,7 @@ The response must match this shape:
|
||||
"schemaVersion": "22",
|
||||
"updates": [
|
||||
{
|
||||
"canonicalName": "Maggie",
|
||||
"canonicalName": "Mara",
|
||||
"aliases": ["Miss Jones"],
|
||||
"recurrenceSignal": "Appears again with the same role and appearance evidence.",
|
||||
"confidence": 0.82,
|
||||
@ -97,4 +97,3 @@ CURRENT_STORY_MEMORY_JSON:
|
||||
|
||||
SCENE_RESULT_ID:
|
||||
{{SCENE_RESULT_ID}}
|
||||
|
||||
|
||||
@ -122,18 +122,18 @@ Do not convert absence, memory, longing, grief, speculation, comparison or emoti
|
||||
|
||||
Bad unless explicitly stated by the supplied scene text:
|
||||
|
||||
- `Beth leaves Maggie behind.`
|
||||
- `Maggie is recently left by Beth.`
|
||||
- `Maggie is at the Ashdown Trust.`
|
||||
- `Beth travels from Maggie's location.`
|
||||
- `Mara leaves Lena behind.`
|
||||
- `Lena is recently left by Mara.`
|
||||
- `Lena is at the archive office.`
|
||||
- `Mara travels from Lena's location.`
|
||||
|
||||
Good when supported by reflective or emotional text:
|
||||
|
||||
- `Beth notices Maggie's absence.`
|
||||
- `Beth thinks about Maggie.`
|
||||
- `Beth feels the car is too quiet without Maggie.`
|
||||
- `Beth wonders whether she may be closer to finding Maggie.`
|
||||
- `Maggie is mentioned only in Beth's thoughts.`
|
||||
- `Mara notices Lena's absence.`
|
||||
- `Mara thinks about Lena.`
|
||||
- `Mara feels the car is too quiet without Lena.`
|
||||
- `Mara wonders whether she may be closer to finding Lena.`
|
||||
- `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.
|
||||
|
||||
@ -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.
|
||||
|
||||
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
|
||||
|
||||
@ -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 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
|
||||
|
||||
@ -431,12 +431,12 @@ Good:
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "Maggie's Disappearance",
|
||||
"description": "Beth's search for Maggie advances through absence, clues and investigation.",
|
||||
"title": "Lena's Disappearance",
|
||||
"description": "Mara's search for Lena advances through absence, clues and investigation.",
|
||||
"arcType": "disappearance",
|
||||
"scale": "major",
|
||||
"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
|
||||
}
|
||||
```
|
||||
@ -523,19 +523,19 @@ Observation rules:
|
||||
|
||||
Example source text:
|
||||
|
||||
`The car felt too quiet without Maggie.`
|
||||
`The car felt too quiet without Lena.`
|
||||
|
||||
Correct character extraction:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Maggie",
|
||||
"name": "Lena",
|
||||
"roleInScene": "mentioned",
|
||||
"mentionedOnly": true,
|
||||
"aliases": [],
|
||||
"actions": [],
|
||||
"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",
|
||||
"subjectEntityType": "Character",
|
||||
"subjectName": "Beth",
|
||||
"subjectName": "Mara",
|
||||
"objectEntityType": "Character",
|
||||
"objectName": "Maggie",
|
||||
"objectName": "Lena",
|
||||
"predicate": "noticesAbsenceOf",
|
||||
"description": "Beth feels Maggie's absence in the car.",
|
||||
"evidence": "The car felt too quiet without Maggie.",
|
||||
"description": "Mara feels Lena's absence in the car.",
|
||||
"evidence": "The car felt too quiet without Lena.",
|
||||
"confidence": 0.9
|
||||
}
|
||||
```
|
||||
|
||||
Incorrect:
|
||||
|
||||
- `Beth leaves Maggie behind.`
|
||||
- `Maggie was recently left by Beth.`
|
||||
- `Maggie is located near the Ashdown Trust.`
|
||||
- `Mara leaves Lena behind.`
|
||||
- `Lena was recently left by Mara.`
|
||||
- `Lena is located near the archive office.`
|
||||
|
||||
## JSON Output Requirements
|
||||
|
||||
|
||||
@ -34,8 +34,8 @@ public sealed class StoryIntelligenceAssetImportService(
|
||||
"car", "vehicle", "coat", "dress", "shoes", "shirt", "trousers", "weather", "rain", "food", "drink");
|
||||
|
||||
private static readonly HashSet<string> AcceptedAssetNames = CreateSet(
|
||||
"tr6", "passport", "photograph", "photo", "key", "keys", "car keys", "letter", "envelope", "note",
|
||||
"notebook", "red notebook", "old notebook", "knife", "gun", "suitcase", "rucksack", "wedding ring",
|
||||
"passport", "photograph", "photo", "key", "keys", "car keys", "letter", "envelope", "note",
|
||||
"notebook", "knife", "gun", "suitcase", "rucksack", "wedding ring",
|
||||
"ring", "necklace", "cassette tape", "camera", "map", "diary", "watch", "driving licence",
|
||||
"driving license", "licence", "license", "document", "folder", "wallet", "bank book", "scrap of paper",
|
||||
"keepsake box", "keepsake tin");
|
||||
@ -659,12 +659,13 @@ public sealed class StoryIntelligenceAssetImportService(
|
||||
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))}";
|
||||
return ContainsStructuralSignificance(candidate)
|
||||
|| sceneCount >= 2 && ContainsStoryAssetSignal(signalText);
|
||||
|| sceneCount >= 2 && (ContainsStoryAssetSignal(signalText) || IsDistinctiveAssetName(candidate.DisplayName));
|
||||
}
|
||||
|
||||
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"));
|
||||
&& IsStructuralSummaryEvidence(appearance.Notes)
|
||||
&& IsDistinctiveAssetName(candidate.DisplayName));
|
||||
|
||||
private static bool IsAssetNameCandidate(string? name)
|
||||
{
|
||||
@ -689,9 +690,6 @@ public sealed class StoryIntelligenceAssetImportService(
|
||||
var withoutArticle = RemoveLeadingArticle(normalised);
|
||||
return AcceptedAssetNames.Contains(normalised)
|
||||
|| 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("'s car", StringComparison.OrdinalIgnoreCase)
|
||||
|| normalised.Contains("’s car", StringComparison.OrdinalIgnoreCase);
|
||||
@ -729,7 +727,7 @@ public sealed class StoryIntelligenceAssetImportService(
|
||||
return "Evidence";
|
||||
}
|
||||
|
||||
if (value.Contains("car") || value.Contains("tr6") || value.Contains("vehicle"))
|
||||
if (value.Contains("car") || value.Contains("vehicle"))
|
||||
{
|
||||
return "Vehicle";
|
||||
}
|
||||
@ -825,8 +823,7 @@ public sealed class StoryIntelligenceAssetImportService(
|
||||
private static bool IsOwnedAssetName(string name)
|
||||
=> ContainsStoryAssetSignal(name)
|
||||
|| IsAcceptedStoryAssetName(name)
|
||||
|| name.Contains("tin", StringComparison.OrdinalIgnoreCase)
|
||||
|| name.Contains("box", StringComparison.OrdinalIgnoreCase);
|
||||
|| IsDistinctiveAssetName(name);
|
||||
|
||||
private static string? BuildFirstAppearance(Scene scene, SceneIntelligenceScene parsed, string assetName)
|
||||
{
|
||||
@ -880,9 +877,10 @@ public sealed class StoryIntelligenceAssetImportService(
|
||||
private static string CanonicalAssetKey(string 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();
|
||||
@ -912,6 +910,40 @@ public sealed class StoryIntelligenceAssetImportService(
|
||||
private static string CleanAssetName(string? 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)
|
||||
{
|
||||
var clean = CleanAssetName(value);
|
||||
@ -957,6 +989,10 @@ public sealed class StoryIntelligenceAssetImportService(
|
||||
" while ",
|
||||
" that ",
|
||||
" which ",
|
||||
" containing ",
|
||||
" contains ",
|
||||
" with ",
|
||||
" inside ",
|
||||
","
|
||||
];
|
||||
|
||||
@ -965,7 +1001,7 @@ public sealed class StoryIntelligenceAssetImportService(
|
||||
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",
|
||||
@"\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);
|
||||
|
||||
private sealed class AssetIndex
|
||||
|
||||
@ -325,7 +325,8 @@ public sealed class StoryIntelligenceKnowledgeImportService(
|
||||
decimal? confidence,
|
||||
IReadOnlyList<KnowledgeState> knowledgeStates)
|
||||
{
|
||||
var character = characterIndex.Find(characterName);
|
||||
var resolvedCharacterName = ResolveKnowledgeRecipientName(characterName, importedScene);
|
||||
var character = characterIndex.Find(resolvedCharacterName);
|
||||
var statement = CleanKnowledgeStatement(knowledgeItem);
|
||||
if (character is null || string.IsNullOrWhiteSpace(statement))
|
||||
{
|
||||
@ -483,6 +484,25 @@ public sealed class StoryIntelligenceKnowledgeImportService(
|
||||
&& !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)
|
||||
{
|
||||
var value = NormaliseForMatching($"{changeType} {statement}");
|
||||
@ -618,7 +638,7 @@ public sealed class StoryIntelligenceKnowledgeImportService(
|
||||
|
||||
private static string CanonicalKnowledgeKey(string? value)
|
||||
{
|
||||
var normalised = NormaliseForMatching(value);
|
||||
var normalised = NormaliseForMatching(value).Trim();
|
||||
var possessiveFamily = Regex.Match(
|
||||
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",
|
||||
@ -637,6 +657,11 @@ public sealed class StoryIntelligenceKnowledgeImportService(
|
||||
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);
|
||||
return Whitespace.Replace(normalised, " ").Trim();
|
||||
}
|
||||
|
||||
@ -491,21 +491,26 @@ public sealed class StoryIntelligenceRelationshipImportService(
|
||||
?? types.First().RelationshipTypeID;
|
||||
|
||||
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 stateName =
|
||||
return
|
||||
value.Contains("reconcil") ? "Reconciled" :
|
||||
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("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("estranged") || value.Contains("separat") || value.Contains("deteriorat") ? "Estranged" :
|
||||
value.Contains("tense") || value.Contains("tension") ? "Tense" :
|
||||
"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)
|
||||
@ -578,6 +583,24 @@ public sealed class StoryIntelligenceRelationshipImportService(
|
||||
private static (decimal ChapterNumber, decimal SceneNumber, int SortOrder, int SceneID) StoryOrder(RelationshipAppearanceImport appearance)
|
||||
=> (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)
|
||||
{
|
||||
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",
|
||||
"reconciles", "reconciliation", "reconciled", "argument", "argues", "separation", "separate",
|
||||
"estranged", "confront", "conflict", "tension", "hostile", "distrust", "supports", "protects",
|
||||
"trust", "hugs", "hug", "gifts", "gift", "presents", "socialises", "socializes", "attraction",
|
||||
"proposes", "proposal", "engaged", "accepts"
|
||||
"becomes", "become", "establishes", "established", "explicit", "couple", "relationship",
|
||||
"attraction", "romantic", "proposes", "proposal", "engaged", "accepts"
|
||||
];
|
||||
|
||||
private sealed record RelationshipCandidateData(bool HasCommittedScenes, int AlreadyLinkedCount, IReadOnlyList<RelationshipCandidate> Candidates);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user