331 lines
16 KiB
C#
331 lines
16 KiB
C#
using System.Text.Json;
|
|
using PlotLine.Models;
|
|
using PlotLine.Services;
|
|
|
|
var tests = new (string Name, Action Test)[]
|
|
{
|
|
("Malformed confidence 0. is repaired to 0.0", RepairsTrailingDecimalConfidence),
|
|
("Markdown fences are stripped", StripsMarkdownFence),
|
|
("Unescaped backslash in evidence is repaired", RepairsUnescapedBackslash),
|
|
("Truncated JSON is rejected", RejectsTruncatedJson),
|
|
("Repaired JSON deserialises into SceneIntelligenceScene", RepairedJsonDeserialises),
|
|
("Chapter Structure confidence 0. repairs and deserialises", ChapterStructureConfidenceRepairs),
|
|
("Shared parser reports raw chapter output on unrecoverable JSON", SharedParserReportsRawOutput),
|
|
("Character filtering rejects generic groups", CharacterFilteringRejectsGenericGroups),
|
|
("Character filtering preserves titled names", CharacterFilteringPreservesTitledNames),
|
|
("Location filtering rejects merged phrases", LocationFilteringRejectsMergedPhrases),
|
|
("Location filtering preserves story locations", LocationFilteringPreservesStoryLocations),
|
|
("Location canonical keys merge trivial variants", LocationCanonicalKeysMergeTrivialVariants),
|
|
("Asset filtering rejects generic objects", AssetFilteringRejectsGenericObjects),
|
|
("Asset filtering preserves story assets", AssetFilteringPreservesStoryAssets),
|
|
("Asset canonical keys merge trivial variants", AssetCanonicalKeysMergeTrivialVariants),
|
|
("Relationship signals map to broad lookup types", RelationshipSignalsMapToBroadTypes),
|
|
("Knowledge signals map to existing knowledge states", KnowledgeSignalsMapToExistingStates),
|
|
("Knowledge duplicate statements share canonical keys", KnowledgeDuplicateStatementsShareCanonicalKeys)
|
|
};
|
|
|
|
foreach (var test in tests)
|
|
{
|
|
test.Test();
|
|
Console.WriteLine($"PASS {test.Name}");
|
|
}
|
|
|
|
static void RepairsTrailingDecimalConfidence()
|
|
{
|
|
var result = StoryIntelligenceJsonRepair.Repair("""{"observations":[{"confidence":0. }]}""");
|
|
Assert(result.Success, result.ErrorMessage ?? "Repair failed.");
|
|
Assert(result.RepairedJson.Contains("\"confidence\":0.0", StringComparison.Ordinal), result.RepairedJson);
|
|
}
|
|
|
|
static void StripsMarkdownFence()
|
|
{
|
|
var result = StoryIntelligenceJsonRepair.Repair("""
|
|
```json
|
|
{"schemaVersion":"1.0"}
|
|
```
|
|
""");
|
|
Assert(result.Success, result.ErrorMessage ?? "Repair failed.");
|
|
Assert(result.RepairedJson == """{"schemaVersion":"1.0"}""", result.RepairedJson);
|
|
}
|
|
|
|
static void RepairsUnescapedBackslash()
|
|
{
|
|
var result = StoryIntelligenceJsonRepair.Repair("""{"evidence":"Mara reads C:\qnotes"}""");
|
|
Assert(result.Success, result.ErrorMessage ?? "Repair failed.");
|
|
using var document = JsonDocument.Parse(result.RepairedJson);
|
|
var evidence = document.RootElement.GetProperty("evidence").GetString();
|
|
Assert(evidence == @"Mara reads C:\qnotes", evidence ?? "Evidence was null.");
|
|
}
|
|
|
|
static void RejectsTruncatedJson()
|
|
{
|
|
var result = StoryIntelligenceJsonRepair.Repair("""{"observations":[{"confidence":""");
|
|
Assert(!result.Success, "Truncated JSON should not be silently repaired.");
|
|
}
|
|
|
|
static void RepairedJsonDeserialises()
|
|
{
|
|
var result = StoryIntelligenceJsonRepair.Repair("""
|
|
{
|
|
"schemaVersion": "1.0",
|
|
"sceneReference": { "sceneId": null },
|
|
"summary": { "short": "A scene.", "detailed": "A scene.", "confidence": 0. },
|
|
"observations": []
|
|
}
|
|
""");
|
|
Assert(result.Success, result.ErrorMessage ?? "Repair failed.");
|
|
var parsed = JsonSerializer.Deserialize<SceneIntelligenceScene>(
|
|
result.RepairedJson,
|
|
new JsonSerializerOptions
|
|
{
|
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
|
PropertyNameCaseInsensitive = true
|
|
});
|
|
Assert(parsed is not null, "Scene JSON did not deserialise.");
|
|
}
|
|
|
|
static void ChapterStructureConfidenceRepairs()
|
|
{
|
|
var result = StoryIntelligenceAiJsonParser.Parse<ChapterStructureModel>(
|
|
"""
|
|
{
|
|
"schemaVersion": "1.0",
|
|
"chapterSummary": "A chapter.",
|
|
"sceneBoundaries": [
|
|
{
|
|
"sceneNumber": 1,
|
|
"startParagraph": 1,
|
|
"endParagraph": 2,
|
|
"confidence": 0. ,
|
|
"reason": "Opening scene."
|
|
}
|
|
]
|
|
}
|
|
""",
|
|
JsonOptions());
|
|
|
|
Assert(result.Success, result.ErrorMessage ?? "Chapter Structure parse failed.");
|
|
Assert(result.Parsed?.SceneBoundaries?[0].Confidence == 0.0m, "Confidence was not repaired to 0.0.");
|
|
Assert(result.Warnings.Any(warning => warning.Contains("$.sceneBoundaries[0].confidence", StringComparison.Ordinal)), "Repair warning did not include JSON path.");
|
|
}
|
|
|
|
static void SharedParserReportsRawOutput()
|
|
{
|
|
const string raw = """{"schemaVersion":"1.0","sceneBoundaries":[""";
|
|
var result = StoryIntelligenceAiJsonParser.Parse<ChapterStructureModel>(raw, JsonOptions());
|
|
Assert(!result.Success, "Unrecoverable JSON should fail.");
|
|
Assert(result.RawJson == raw, "Raw malformed output was not retained.");
|
|
}
|
|
|
|
static void CharacterFilteringRejectsGenericGroups()
|
|
{
|
|
Assert(IsGenericGroupReference("four examiners"), "Quantity + generic group should be rejected.");
|
|
Assert(IsGenericGroupReference("groups of men"), "Generic group phrase should be rejected.");
|
|
Assert(IsGenericGroupReference("neighbours"), "Generic plural group should be rejected.");
|
|
Assert(IsGenericGroupReference("passenger (blue Volkswagen Golf GTi)"), "Unnamed passenger descriptor should be rejected.");
|
|
Assert(IsGenericGroupReference("tubby lad"), "Unnamed descriptive person should be rejected.");
|
|
Assert(IsGenericGroupReference("lady examiner"), "Unnamed role descriptor should be rejected.");
|
|
Assert(IsGenericGroupReference("her mother"), "Determiner + family role should be rejected.");
|
|
Assert(IsGenericGroupReference("the child"), "Determiner + generic person should be rejected.");
|
|
}
|
|
|
|
static void CharacterFilteringPreservesTitledNames()
|
|
{
|
|
Assert(!IsGenericGroupReference("Reverend Potter"), "Reverend Potter should not be treated as generic.");
|
|
Assert(IsNamedOrTitledPerson("Reverend Potter"), "Reverend Potter should be recognised as a titled person.");
|
|
Assert(IsNamedOrTitledPerson("Detective Sergeant Summerhill"), "Detective Sergeant Summerhill should be recognised as a titled person.");
|
|
Assert(IsNamedOrTitledPerson("Mrs Patterson"), "Mrs Patterson should be recognised as a titled person.");
|
|
Assert(IsNamedOrTitledPerson("Aunt Elen"), "Aunt Elen should be recognised as a titled person.");
|
|
}
|
|
|
|
static bool IsGenericGroupReference(string name)
|
|
=> InvokePrivateCharacterFilter("IsGenericGroupReference", name);
|
|
|
|
static bool IsNamedOrTitledPerson(string name)
|
|
=> InvokePrivateCharacterFilter("IsNamedOrTitledPerson", name);
|
|
|
|
static bool InvokePrivateCharacterFilter(string methodName, string name)
|
|
{
|
|
var method = typeof(StoryIntelligenceCharacterImportService).GetMethod(
|
|
methodName,
|
|
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
|
|
Assert(method is not null, $"{methodName} was not found.");
|
|
return method!.Invoke(null, [name]) is true;
|
|
}
|
|
|
|
static void LocationFilteringRejectsMergedPhrases()
|
|
{
|
|
Assert(IsMergedLocationPhrase("shop and staff room"), "Merged shop/staff room phrase should be rejected.");
|
|
Assert(IsMergedLocationPhrase("house and garden"), "Merged house/garden phrase should be rejected.");
|
|
Assert(IsMergedLocationPhrase("road and bridge"), "Merged road/bridge phrase should be rejected.");
|
|
Assert(IsMergedLocationPhrase("kitchen and hallway"), "Merged room phrase should be rejected.");
|
|
Assert(IsMergedLocationPhrase("front and rear garden"), "Merged garden phrase should be rejected.");
|
|
Assert(!IsMergedLocationPhrase("Rose and Crown"), "Named pub should not be treated as a merged location.");
|
|
}
|
|
|
|
static void LocationFilteringPreservesStoryLocations()
|
|
{
|
|
Assert(IsStoryLocation("The Doweries"), "The Doweries should be preserved.");
|
|
Assert(IsStoryLocation("Ashdown Trust"), "Ashdown Trust should be preserved.");
|
|
Assert(IsStoryLocation("St Luke's Church"), "St Luke's Church should be preserved.");
|
|
Assert(IsStoryLocation("Mrs Patterson's House"), "Mrs Patterson's House should be preserved.");
|
|
Assert(IsStoryLocation("Bristol Road"), "Named roads should be preserved.");
|
|
Assert(!IsStoryLocation("Kitchen"), "Standalone generic rooms should not be story locations.");
|
|
}
|
|
|
|
static void LocationCanonicalKeysMergeTrivialVariants()
|
|
{
|
|
Assert(LocationCanonicalKey("Kitchen") == LocationCanonicalKey("The Kitchen"), "The Kitchen should fold into Kitchen.");
|
|
Assert(LocationCanonicalKey("Interview room") == LocationCanonicalKey("Interview Room"), "Case-only room variants should merge.");
|
|
Assert(LocationCanonicalKey("Ashdown Trust (reception)") == LocationCanonicalKey("Ashdown Trust"), "Parenthetical variants should merge.");
|
|
}
|
|
|
|
static bool IsMergedLocationPhrase(string name)
|
|
=> InvokePrivateLocationFilter("IsMergedLocationPhrase", name);
|
|
|
|
static bool IsStoryLocation(string name)
|
|
=> InvokePrivateLocationFilter("IsStoryLocation", name);
|
|
|
|
static string LocationCanonicalKey(string name)
|
|
{
|
|
var method = typeof(StoryIntelligenceLocationImportService).GetMethod(
|
|
"CanonicalLocationKey",
|
|
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
|
|
Assert(method is not null, "CanonicalLocationKey was not found.");
|
|
return (string)method!.Invoke(null, [name])!;
|
|
}
|
|
|
|
static bool InvokePrivateLocationFilter(string methodName, string name)
|
|
{
|
|
var method = typeof(StoryIntelligenceLocationImportService).GetMethod(
|
|
methodName,
|
|
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
|
|
Assert(method is not null, $"{methodName} was not found.");
|
|
return method!.Invoke(null, [name]) is true;
|
|
}
|
|
|
|
static void AssetFilteringRejectsGenericObjects()
|
|
{
|
|
Assert(!IsAssetNameCandidate("chair"), "Chair should not be treated as a story asset.");
|
|
Assert(!IsAssetNameCandidate("table"), "Table should not be treated as a story asset.");
|
|
Assert(!IsAssetNameCandidate("street"), "Street should not be treated as a story asset.");
|
|
Assert(!IsAssetNameCandidate("car park"), "Car park should not be treated as a story asset.");
|
|
}
|
|
|
|
static void AssetFilteringPreservesStoryAssets()
|
|
{
|
|
Assert(IsAssetNameCandidate("TR6"), "TR6 should be treated as a story asset.");
|
|
Assert(IsAssetNameCandidate("red notebook"), "Red notebook 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("driving licence"), "Driving licence should be treated as a story asset.");
|
|
}
|
|
|
|
static void AssetCanonicalKeysMergeTrivialVariants()
|
|
{
|
|
Assert(AssetCanonicalKey("red notebook") == AssetCanonicalKey("the notebook"), "Notebook variants should merge.");
|
|
Assert(AssetCanonicalKey("photo") == AssetCanonicalKey("photograph"), "Photo should fold into photograph.");
|
|
Assert(AssetCanonicalKey("driving license") == AssetCanonicalKey("driving licence"), "License/licence spelling should merge.");
|
|
}
|
|
|
|
static void RelationshipSignalsMapToBroadTypes()
|
|
{
|
|
Assert(RelationshipTypeFromSignal("Friend") == "Friend", "Friend should map to Friend.");
|
|
Assert(RelationshipTypeFromSignal("driving instructor", "learner makes progress") == "Educational", "Instructor/learner should map to Educational.");
|
|
Assert(RelationshipTypeFromSignal("aunt", "family visit") == "Family", "Aunt/family should map to Family.");
|
|
Assert(RelationshipTypeFromSignal("boss", "employee at work") == "Professional", "Boss/employee should map to Professional.");
|
|
Assert(RelationshipTypeFromSignal("police", "suspect witness interview") == "Authority", "Police/suspect/witness should map to Authority.");
|
|
Assert(RelationshipTypeFromSignal("neighbour") == "Neighbour", "Neighbour should map to Neighbour.");
|
|
Assert(RelationshipTypeFromSignal("adversarial", "mistrust between them") == "Rival", "Adversarial/mistrust should map to Rival.");
|
|
Assert(RelationshipTypeFromSignal("uncertain emotional connection") == "Unknown", "Uncertain signals should map to Unknown.");
|
|
}
|
|
|
|
static void KnowledgeSignalsMapToExistingStates()
|
|
{
|
|
var states = new List<KnowledgeState>
|
|
{
|
|
new() { KnowledgeStateID = 1, StateName = "Unaware", SortOrder = 10, IsActive = true },
|
|
new() { KnowledgeStateID = 2, StateName = "Suspects", SortOrder = 20, IsActive = true },
|
|
new() { KnowledgeStateID = 3, StateName = "Partially Knows", SortOrder = 30, IsActive = true },
|
|
new() { KnowledgeStateID = 4, StateName = "Knows", SortOrder = 40, IsActive = true },
|
|
new() { KnowledgeStateID = 5, StateName = "Misunderstands", SortOrder = 50, IsActive = true }
|
|
};
|
|
|
|
Assert(KnowledgeStateFromSignal("Discovers", "the letter exists", states) == "Knows", "Discovers should map to Knows.");
|
|
Assert(KnowledgeStateFromSignal("Suspects", "Maggie is lying", states) == "Suspects", "Suspects should map to Suspects.");
|
|
Assert(KnowledgeStateFromSignal("Believes", "Gareth is trustworthy", states) == "Partially Knows", "Believes should map to Partially Knows.");
|
|
Assert(KnowledgeStateFromSignal("Misunderstands", "Beth is dead", states) == "Misunderstands", "Misunderstands should map to Misunderstands.");
|
|
Assert(KnowledgeStateFromSignal("Forgets", "the hiding place", states) == "Unaware", "Forgets should map to Unaware.");
|
|
}
|
|
|
|
static void KnowledgeDuplicateStatementsShareCanonicalKeys()
|
|
{
|
|
var first = CanonicalKnowledgeKey("Simon is Beth's father.");
|
|
var second = CanonicalKnowledgeKey("Beth discovers Simon is her father.");
|
|
var third = CanonicalKnowledgeKey("Beth learns Simon is her biological father.");
|
|
Assert(first == second && second == third, $"Expected duplicate father facts to merge, got {first}, {second}, {third}.");
|
|
}
|
|
|
|
static bool IsAssetNameCandidate(string name)
|
|
=> InvokePrivateAssetFilter("IsAssetNameCandidate", name);
|
|
|
|
static string AssetCanonicalKey(string name)
|
|
{
|
|
var method = typeof(StoryIntelligenceAssetImportService).GetMethod(
|
|
"CanonicalAssetKey",
|
|
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
|
|
Assert(method is not null, "CanonicalAssetKey was not found.");
|
|
return (string)method!.Invoke(null, [name])!;
|
|
}
|
|
|
|
static bool InvokePrivateAssetFilter(string methodName, string name)
|
|
{
|
|
var method = typeof(StoryIntelligenceAssetImportService).GetMethod(
|
|
methodName,
|
|
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
|
|
Assert(method is not null, $"{methodName} was not found.");
|
|
return method!.Invoke(null, [name]) is true;
|
|
}
|
|
|
|
static string RelationshipTypeFromSignal(string signal, string? evidence = null)
|
|
{
|
|
var method = typeof(StoryIntelligenceRelationshipImportService).GetMethod(
|
|
"RelationshipTypeFromSignal",
|
|
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
|
|
Assert(method is not null, "RelationshipTypeFromSignal was not found.");
|
|
return (string)method!.Invoke(null, [signal, evidence])!;
|
|
}
|
|
|
|
static string KnowledgeStateFromSignal(string changeType, string statement, IReadOnlyList<KnowledgeState> states)
|
|
{
|
|
var method = typeof(StoryIntelligenceKnowledgeImportService).GetMethod(
|
|
"KnowledgeStateFromSignal",
|
|
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
|
|
Assert(method is not null, "KnowledgeStateFromSignal was not found.");
|
|
return (string)method!.Invoke(null, [changeType, statement, states])!;
|
|
}
|
|
|
|
static string CanonicalKnowledgeKey(string statement)
|
|
{
|
|
var method = typeof(StoryIntelligenceKnowledgeImportService).GetMethod(
|
|
"CanonicalKnowledgeKey",
|
|
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
|
|
Assert(method is not null, "CanonicalKnowledgeKey was not found.");
|
|
return (string)method!.Invoke(null, [statement])!;
|
|
}
|
|
|
|
static JsonSerializerOptions JsonOptions()
|
|
=> new()
|
|
{
|
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
|
PropertyNameCaseInsensitive = true
|
|
};
|
|
|
|
static void Assert(bool condition, string message)
|
|
{
|
|
if (!condition)
|
|
{
|
|
throw new InvalidOperationException(message);
|
|
}
|
|
}
|