From d2449a4683663b9bb8d3cff38ffa22451f7fdc7d Mon Sep 17 00:00:00 2001 From: Nick Beckley Date: Sat, 29 Aug 2026 20:41:38 +0000 Subject: [PATCH] Fix story intelligence review regressions --- PlotLine.Tests/Program.cs | 73 +++++++++++++++++++ .../OnboardingStoryIntelligenceService.cs | 49 +++---------- ...ryIntelligenceRelationshipImportService.cs | 33 ++++++++- 3 files changed, 113 insertions(+), 42 deletions(-) diff --git a/PlotLine.Tests/Program.cs b/PlotLine.Tests/Program.cs index e346db7..ec13f22 100644 --- a/PlotLine.Tests/Program.cs +++ b/PlotLine.Tests/Program.cs @@ -56,6 +56,7 @@ var tests = new (string Name, Action Test)[] ("Relationship observations do not create event spam", RelationshipObservationsDoNotCreateEventSpam), ("Relationship review orders by first story appearance", RelationshipReviewOrdersByFirstStoryAppearance), ("Relationship evidence priority preserves explicit semantics", RelationshipEvidencePriorityPreservesExplicitSemantics), + ("Relationship evidence handles retained hostile and incidental examples", RelationshipEvidenceHandlesRetainedHostileAndIncidentalExamples), ("Knowledge signals map to existing knowledge states", KnowledgeSignalsMapToExistingStates), ("Knowledge recipient resolves narrator through POV", KnowledgeRecipientResolvesNarratorThroughPov), ("Knowledge identity reveal preserves subjective timing", KnowledgeIdentityRevealPreservesSubjectiveTiming), @@ -114,6 +115,7 @@ var tests = new (string Name, Action Test)[] ("Story Intelligence downstream imports prefer canonical SceneIDs", StoryIntelligenceDownstreamImportsPreferCanonicalSceneIds), ("Story Intelligence scene prompt no longer asks for duplicate factual summary", StoryIntelligenceScenePromptNoLongerRequestsDuplicateSummary), ("Story Intelligence review uses canonical structural summaries and counts", StoryIntelligenceReviewUsesCanonicalStructuralSummariesAndCounts), + ("Story Intelligence review centre completion is category scoped", StoryIntelligenceReviewCentreCompletionIsCategoryScoped), ("Word sync requires save acknowledgement", WordSyncRequiresSaveAcknowledgement), ("Word sync retry resumes existing structure", WordSyncRetryResumesExistingStructure), ("Story Intelligence enriches existing structural scenes", StoryIntelligenceEnrichesExistingStructuralScenes), @@ -2264,6 +2266,18 @@ static void RelationshipEvidencePriorityPreservesExplicitSemantics() Assert(RelationshipTypeFromEvidence("affection", "Nora likes Alison and sees her as like an older sister.", "Alison is a nurse who recently moved back to town.", "Nora", "Alison") != "Medical", "Occupation-only context should not create a Medical relationship."); } +static void RelationshipEvidenceHandlesRetainedHostileAndIncidentalExamples() +{ + Assert(RelationshipTypeFromEvidence("conflict", "Maggie slaps Chrissy, calls her a bitch and threatens violence after discovering Chrissy's involvement.", null, "Maggie", "Chrissy") == "Rival", "Slap/threat evidence from retained Scene JSON should classify as Rival."); + Assert(RelationshipTypeFromEvidence("mistrust", "Rebecca demands Chrissy never speak to her again and expresses disgust.", null, "Rebecca", "Chrissy") == "Rival", "Never-speak/disgust evidence should remain adversarial."); + Assert(RelationshipTypeFromEvidence("dependence", "Maggie accepts Greg's offer of a lift and his help driving Rosie home.", null, "Maggie", "Greg") == "Unknown", "One practical lift/help should not become Authority, Professional or Ally."); + Assert(RelationshipTypeFromEvidence("protection", "Greg offers to take Rosie to the hospital and pulls the car over when she is sick.", null, "Greg", "Rosie") == "Unknown", "An incidental hospital offer should not create a professional or medical relationship."); + Assert(RelationshipTypeFromEvidence("authority", "Mary is late and is Rosie's mum expected to arrive.", null, "Maggie", "Mary") != "Authority", "A broad authority signal without an authority role should not create Authority."); + Assert(RelationshipTypeFromEvidence("professional", "Greg offers to take Rosie to the hospital and pulls the car over when she is sick.", null, "Greg", "Rosie") != "Professional", "A broad professional signal without work-role evidence should not create Professional."); + Assert(RelationshipTypeFromEvidence("protection", "Rosie's mum Mary knocked at the door and drives them in her old Ford Cortina.", null, "Mary", "Rosie") == "Parent", "Named parent evidence should classify Mary/Rosie as Parent."); + Assert(RelationshipTypeFromEvidence("authority", "The social worker says Beth's life is another box on a checklist.", null, "social worker", "Beth") == "Authority", "Explicit social-worker role evidence should still classify Authority."); +} + static void KnowledgeSignalsMapToExistingStates() { var states = new List @@ -3308,6 +3322,37 @@ static void StoryIntelligenceReviewUsesCanonicalStructuralSummariesAndCounts() Assert(repository.Contains("SceneID = item.SceneID ??", StringComparison.Ordinal), "Commit should target canonical SceneID when one exists."); } +static void StoryIntelligenceReviewCentreCompletionIsCategoryScoped() +{ + var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); + var onboardingStory = File.ReadAllText(Path.Combine(root, "Services/OnboardingStoryIntelligenceService.cs")); + var controller = File.ReadAllText(Path.Combine(root, "Controllers/OnboardingController.cs")); + var reviewView = File.ReadAllText(Path.Combine(root, "Views/Onboarding/StoryIntelligenceReviewCentre.cshtml")); + + Assert(onboardingStory.Contains("LocationStageComplete = locationReview.IsComplete", StringComparison.Ordinal), "Location tile completion should come from the Location review service."); + Assert(onboardingStory.Contains("AssetStageComplete = assetReview.IsComplete", StringComparison.Ordinal), "Asset tile completion should come from the Asset review service."); + Assert(onboardingStory.Contains("RelationshipStageComplete = relationshipReview.IsComplete", StringComparison.Ordinal), "Relationship tile completion should come from the Relationship review service."); + Assert(onboardingStory.Contains("KnowledgeStageComplete = knowledgeReview.IsComplete", StringComparison.Ordinal), "Knowledge tile completion should come from the Knowledge review service."); + Assert(onboardingStory.Contains("NarrativeStageComplete = narrative.IsComplete", StringComparison.Ordinal), "Narrative tile completion should come from the Narrative review service."); + Assert(onboardingStory.Contains("batch.LocationStageComplete = false;", StringComparison.Ordinal), "Resume should not hydrate Location completion from the single pipeline stage marker."); + Assert(onboardingStory.Contains("batch.AssetStageComplete = false;", StringComparison.Ordinal), "Resume should not hydrate Asset completion from the single pipeline stage marker."); + Assert(onboardingStory.Contains("batch.RelationshipStageComplete = false;", StringComparison.Ordinal), "Resume should not hydrate Relationship completion from the single pipeline stage marker."); + Assert(onboardingStory.Contains("batch.KnowledgeStageComplete = false;", StringComparison.Ordinal), "Resume should not hydrate Knowledge completion from the single pipeline stage marker."); + Assert(onboardingStory.Contains("batch.NarrativeStageComplete = false;", StringComparison.Ordinal), "Resume should not hydrate Narrative completion from the single pipeline stage marker."); + Assert(reviewView.Contains("Model.PipelineDashboard.RelationshipStageComplete", StringComparison.Ordinal), "Review Centre should render the scoped dashboard completion flags."); + + var locationGet = ExtractMethod(controller, "StoryIntelligenceLocations"); + var assetGet = ExtractMethod(controller, "StoryIntelligenceAssets"); + var relationshipGet = ExtractMethod(controller, "StoryIntelligenceRelationships"); + var knowledgeGet = ExtractMethod(controller, "StoryIntelligenceKnowledge"); + var narrativeGet = ExtractMethod(controller, "StoryIntelligenceNarrative"); + Assert(!locationGet.Contains("RecordLocationImportAsync", StringComparison.Ordinal), "Opening Location review should not mark Location complete."); + Assert(!assetGet.Contains("RecordAssetImportAsync", StringComparison.Ordinal), "Opening Asset review should not mark Asset complete."); + Assert(!relationshipGet.Contains("RecordRelationshipImportAsync", StringComparison.Ordinal), "Opening Relationship review should not mark Relationship complete."); + Assert(!knowledgeGet.Contains("RecordKnowledgeImportAsync", StringComparison.Ordinal), "Opening Knowledge review should not mark Knowledge complete."); + Assert(!narrativeGet.Contains("RecordNarrativeImportAsync", StringComparison.Ordinal), "Opening Narrative review should not mark Narrative complete."); +} + static void WordSyncRequiresSaveAcknowledgement() { var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); @@ -5864,6 +5909,34 @@ static void Assert(bool condition, string message) } } +static string ExtractMethod(string source, string methodName) +{ + var signature = $" {methodName}("; + var signatureIndex = source.IndexOf(signature, StringComparison.Ordinal); + Assert(signatureIndex >= 0, $"{methodName} was not found."); + var bodyStart = source.IndexOf('{', signatureIndex); + Assert(bodyStart >= 0, $"{methodName} body was not found."); + + var depth = 0; + for (var index = bodyStart; index < source.Length; index++) + { + if (source[index] == '{') + { + depth++; + } + else if (source[index] == '}') + { + depth--; + if (depth == 0) + { + return source.Substring(signatureIndex, index - signatureIndex + 1); + } + } + } + + throw new InvalidOperationException($"{methodName} body was not closed."); +} + static StoryIntelligenceClient BuildStoryIntelligenceClient(Func respond) => new( new HttpClient(new CaptureImageRequestHandler(respond)), diff --git a/PlotLine/Services/OnboardingStoryIntelligenceService.cs b/PlotLine/Services/OnboardingStoryIntelligenceService.cs index e99388c..be8e4f6 100644 --- a/PlotLine/Services/OnboardingStoryIntelligenceService.cs +++ b/PlotLine/Services/OnboardingStoryIntelligenceService.cs @@ -691,24 +691,24 @@ public sealed class OnboardingStoryIntelligenceService( SceneAnalysisComplete = pipeline is not null && SceneAnalysisComplete(pipeline), CharactersIdentified = characterReview.Candidates.Count + batch.CharacterDecisions.Count, CharactersCreatedOrLinked = batch.CharacterDecisions.Count(decision => decision.CreatedOrLinked), - CharacterStageComplete = batch.CharacterStageComplete || characterReview.IsComplete, + CharacterStageComplete = characterReview.IsComplete, LocationsIdentified = locationReview.Candidates.Count + batch.LocationDecisions.Count, LocationsCreatedOrLinked = batch.LocationDecisions.Count(decision => decision.CreatedOrLinked), - LocationStageComplete = batch.LocationStageComplete || locationReview.IsComplete, + LocationStageComplete = locationReview.IsComplete, AssetsIdentified = assetReview.Candidates.Count + batch.AssetDecisions.Count, AssetsCreatedOrLinked = batch.AssetDecisions.Count(decision => decision.CreatedOrLinked), - AssetStageComplete = batch.AssetStageComplete || assetReview.IsComplete, + AssetStageComplete = assetReview.IsComplete, RelationshipsIdentified = relationshipReview.Candidates.Count + batch.RelationshipDecisions.Count, RelationshipsCreatedOrLinked = batch.RelationshipDecisions.Count(decision => decision.CreatedOrLinked), - RelationshipStageComplete = batch.RelationshipStageComplete || relationshipReview.IsComplete, + RelationshipStageComplete = relationshipReview.IsComplete, KnowledgeIdentified = knowledgeReview.Candidates.Count + batch.KnowledgeDecisions.Count, KnowledgeCreatedOrLinked = batch.KnowledgeDecisions.Count(decision => decision.CreatedOrLinked), - KnowledgeStageComplete = batch.KnowledgeStageComplete || knowledgeReview.IsComplete, + KnowledgeStageComplete = knowledgeReview.IsComplete, NarrativeCandidatesIdentified = narrative.Candidates.Count + (batch.LastNarrativeImportResult?.TotalReviewed ?? 0), NarrativeCandidatesApplied = (batch.LastNarrativeImportResult?.PlotLinesCreated ?? 0) + (batch.LastNarrativeImportResult?.PlotLinesMerged ?? 0) + (batch.LastNarrativeImportResult?.ThreadsCreated ?? 0), - NarrativeStageComplete = batch.NarrativeStageComplete || narrative.IsComplete + NarrativeStageComplete = narrative.IsComplete } }; } @@ -1057,39 +1057,12 @@ public sealed class OnboardingStoryIntelligenceService( or StoryIntelligencePipelineStages.NarrativeReview or StoryIntelligencePipelineStages.NarrativeImport or StoryIntelligencePipelineStages.Complete; - var locationStageComplete = HasCompletedStage(state, StoryIntelligencePipelineStages.LocationImport) - || state.CurrentStage is StoryIntelligencePipelineStages.AssetReview - or StoryIntelligencePipelineStages.AssetImport - or StoryIntelligencePipelineStages.RelationshipReview - or StoryIntelligencePipelineStages.RelationshipImport - or StoryIntelligencePipelineStages.KnowledgeReview - or StoryIntelligencePipelineStages.KnowledgeImport - or StoryIntelligencePipelineStages.NarrativeReview - or StoryIntelligencePipelineStages.NarrativeImport - or StoryIntelligencePipelineStages.Complete; - var assetStageComplete = HasCompletedStage(state, StoryIntelligencePipelineStages.AssetImport) - || state.CurrentStage is StoryIntelligencePipelineStages.RelationshipReview - or StoryIntelligencePipelineStages.RelationshipImport - or StoryIntelligencePipelineStages.KnowledgeReview - or StoryIntelligencePipelineStages.KnowledgeImport - or StoryIntelligencePipelineStages.NarrativeReview - or StoryIntelligencePipelineStages.NarrativeImport - or StoryIntelligencePipelineStages.Complete; - var relationshipStageComplete = HasCompletedStage(state, StoryIntelligencePipelineStages.RelationshipImport) - || state.CurrentStage is StoryIntelligencePipelineStages.KnowledgeReview - or StoryIntelligencePipelineStages.KnowledgeImport - or StoryIntelligencePipelineStages.NarrativeReview - or StoryIntelligencePipelineStages.NarrativeImport - or StoryIntelligencePipelineStages.Complete; - var knowledgeStageComplete = HasCompletedStage(state, StoryIntelligencePipelineStages.KnowledgeImport); - var narrativeStageComplete = HasCompletedStage(state, StoryIntelligencePipelineStages.NarrativeImport); - batch.CharacterStageComplete = characterStageComplete; - batch.LocationStageComplete = locationStageComplete; - batch.AssetStageComplete = assetStageComplete; - batch.RelationshipStageComplete = relationshipStageComplete; - batch.KnowledgeStageComplete = knowledgeStageComplete; - batch.NarrativeStageComplete = narrativeStageComplete; + batch.LocationStageComplete = false; + batch.AssetStageComplete = false; + batch.RelationshipStageComplete = false; + batch.KnowledgeStageComplete = false; + batch.NarrativeStageComplete = false; await batchStore.SaveAsync(batch); diff --git a/PlotLine/Services/StoryIntelligenceRelationshipImportService.cs b/PlotLine/Services/StoryIntelligenceRelationshipImportService.cs index 6c1a262..4976236 100644 --- a/PlotLine/Services/StoryIntelligenceRelationshipImportService.cs +++ b/PlotLine/Services/StoryIntelligenceRelationshipImportService.cs @@ -575,7 +575,12 @@ public sealed class StoryIntelligenceRelationshipImportService( return new("Family", 420); } - if (ContainsAny(allText, "rival", "enemy", "adversarial", "conflict", "mistrust", "antagonist", "competitor", "confront", "attack", "attacks", "assault", "assaults", "insult", "anger", "hostile")) + if (ContainsAny(allText, + "rival", "enemy", "adversarial", "conflict", "mistrust", "distrust", "antagonist", "competitor", + "confront", "attack", "attacks", "attacked", "assault", "assaults", "assaulted", "slap", "slaps", + "slapped", "threat", "threaten", "threatens", "threatened", "violence", "never speak", "disgust", + "betray", "betrays", "betrayed", "spiked", "drugged", "grope", "gropes", "groped", "force", + "forces", "forced", "insult", "insults", "anger", "hostile")) { return new("Rival", 370); } @@ -590,7 +595,7 @@ public sealed class StoryIntelligenceRelationshipImportService( return new("Educational", 350); } - if (ContainsAny(allText, "police", "detective", "suspect", "witness", "authority", "guard", "prisoner", "investigator", "person of interest")) + if (ContainsAny(explicitText, "police", "detective", "suspect", "witness", "guard", "prisoner", "investigator", "person of interest", "social worker", "social services")) { return new("Authority", 340); } @@ -610,7 +615,7 @@ public sealed class StoryIntelligenceRelationshipImportService( return new("Neighbour", 310); } - if (ContainsAny(allText, "colleague", "coworker", "co worker", "boss", "employee", "manager", "client", "professional", "work relationship")) + if (ContainsAny(explicitText, "colleague", "coworker", "co worker", "co-worker", "boss", "employee", "manager", "client", "work relationship")) { return new("Professional", 300); } @@ -625,7 +630,7 @@ public sealed class StoryIntelligenceRelationshipImportService( return new("Friend", 270); } - if (ContainsAny(allText, "ally", "protector", "supporter", "confidant", "protects", "supports")) + if (ContainsAny(explicitText, "ally", "protector", "supporter", "confidant", "protects", "supports")) { return new("Ally", 250); } @@ -661,6 +666,8 @@ public sealed class StoryIntelligenceRelationshipImportService( { var normalised = NormaliseForMatching(evidence); return ContainsAny(normalised, "my mother", "my mum", "my mom", "my father", "my dad", "me and mum", "me and mother", "me and dad", "me and father") + || HasNamedParentEvidence(normalised, characterAName, characterBName) + || HasNamedParentEvidence(normalised, characterBName, characterAName) || HasOwnedKinshipBetweenParticipants("mother", characterAName, characterBName) || HasOwnedKinshipBetweenParticipants("father", characterAName, characterBName) || HasOwnedKinshipBetweenParticipants("mum", characterAName, characterBName) @@ -668,6 +675,24 @@ public sealed class StoryIntelligenceRelationshipImportService( || HasOwnedKinshipBetweenParticipants("dad", characterAName, characterBName); } + private static bool HasNamedParentEvidence(string normalisedEvidence, string? possibleParentName, string? possibleChildName) + { + var parent = Clean(possibleParentName); + var child = Clean(possibleChildName); + if (string.IsNullOrWhiteSpace(parent) || string.IsNullOrWhiteSpace(child)) + { + return false; + } + + return ContainsAny( + normalisedEvidence, + $"{child}'s mother {parent}", + $"{child}'s mum {parent}", + $"{child}'s mom {parent}", + $"{child}'s father {parent}", + $"{child}'s dad {parent}"); + } + private static bool HasOwnedKinshipBetweenParticipants(string kinship, string? characterAName, string? characterBName) => IsOwnedKinshipFor(characterAName, characterBName, kinship) || IsOwnedKinshipFor(characterBName, characterAName, kinship);