Fix story intelligence review regressions

This commit is contained in:
Nick Beckley 2026-08-29 20:41:38 +00:00
parent 99d818ed9f
commit d2449a4683
3 changed files with 113 additions and 42 deletions

View File

@ -56,6 +56,7 @@ var tests = new (string Name, Action Test)[]
("Relationship observations do not create event spam", RelationshipObservationsDoNotCreateEventSpam), ("Relationship observations do not create event spam", RelationshipObservationsDoNotCreateEventSpam),
("Relationship review orders by first story appearance", RelationshipReviewOrdersByFirstStoryAppearance), ("Relationship review orders by first story appearance", RelationshipReviewOrdersByFirstStoryAppearance),
("Relationship evidence priority preserves explicit semantics", RelationshipEvidencePriorityPreservesExplicitSemantics), ("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 signals map to existing knowledge states", KnowledgeSignalsMapToExistingStates),
("Knowledge recipient resolves narrator through POV", KnowledgeRecipientResolvesNarratorThroughPov), ("Knowledge recipient resolves narrator through POV", KnowledgeRecipientResolvesNarratorThroughPov),
("Knowledge identity reveal preserves subjective timing", KnowledgeIdentityRevealPreservesSubjectiveTiming), ("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 downstream imports prefer canonical SceneIDs", StoryIntelligenceDownstreamImportsPreferCanonicalSceneIds),
("Story Intelligence scene prompt no longer asks for duplicate factual summary", StoryIntelligenceScenePromptNoLongerRequestsDuplicateSummary), ("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 uses canonical structural summaries and counts", StoryIntelligenceReviewUsesCanonicalStructuralSummariesAndCounts),
("Story Intelligence review centre completion is category scoped", StoryIntelligenceReviewCentreCompletionIsCategoryScoped),
("Word sync requires save acknowledgement", WordSyncRequiresSaveAcknowledgement), ("Word sync requires save acknowledgement", WordSyncRequiresSaveAcknowledgement),
("Word sync retry resumes existing structure", WordSyncRetryResumesExistingStructure), ("Word sync retry resumes existing structure", WordSyncRetryResumesExistingStructure),
("Story Intelligence enriches existing structural scenes", StoryIntelligenceEnrichesExistingStructuralScenes), ("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."); 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() static void KnowledgeSignalsMapToExistingStates()
{ {
var states = new List<KnowledgeState> var states = new List<KnowledgeState>
@ -3308,6 +3322,37 @@ static void StoryIntelligenceReviewUsesCanonicalStructuralSummariesAndCounts()
Assert(repository.Contains("SceneID = item.SceneID ??", StringComparison.Ordinal), "Commit should target canonical SceneID when one exists."); 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() static void WordSyncRequiresSaveAcknowledgement()
{ {
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); 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<HttpRequestMessage, HttpResponseMessage> respond) static StoryIntelligenceClient BuildStoryIntelligenceClient(Func<HttpRequestMessage, HttpResponseMessage> respond)
=> new( => new(
new HttpClient(new CaptureImageRequestHandler(respond)), new HttpClient(new CaptureImageRequestHandler(respond)),

View File

@ -691,24 +691,24 @@ public sealed class OnboardingStoryIntelligenceService(
SceneAnalysisComplete = pipeline is not null && SceneAnalysisComplete(pipeline), SceneAnalysisComplete = pipeline is not null && SceneAnalysisComplete(pipeline),
CharactersIdentified = characterReview.Candidates.Count + batch.CharacterDecisions.Count, CharactersIdentified = characterReview.Candidates.Count + batch.CharacterDecisions.Count,
CharactersCreatedOrLinked = batch.CharacterDecisions.Count(decision => decision.CreatedOrLinked), CharactersCreatedOrLinked = batch.CharacterDecisions.Count(decision => decision.CreatedOrLinked),
CharacterStageComplete = batch.CharacterStageComplete || characterReview.IsComplete, CharacterStageComplete = characterReview.IsComplete,
LocationsIdentified = locationReview.Candidates.Count + batch.LocationDecisions.Count, LocationsIdentified = locationReview.Candidates.Count + batch.LocationDecisions.Count,
LocationsCreatedOrLinked = batch.LocationDecisions.Count(decision => decision.CreatedOrLinked), LocationsCreatedOrLinked = batch.LocationDecisions.Count(decision => decision.CreatedOrLinked),
LocationStageComplete = batch.LocationStageComplete || locationReview.IsComplete, LocationStageComplete = locationReview.IsComplete,
AssetsIdentified = assetReview.Candidates.Count + batch.AssetDecisions.Count, AssetsIdentified = assetReview.Candidates.Count + batch.AssetDecisions.Count,
AssetsCreatedOrLinked = batch.AssetDecisions.Count(decision => decision.CreatedOrLinked), AssetsCreatedOrLinked = batch.AssetDecisions.Count(decision => decision.CreatedOrLinked),
AssetStageComplete = batch.AssetStageComplete || assetReview.IsComplete, AssetStageComplete = assetReview.IsComplete,
RelationshipsIdentified = relationshipReview.Candidates.Count + batch.RelationshipDecisions.Count, RelationshipsIdentified = relationshipReview.Candidates.Count + batch.RelationshipDecisions.Count,
RelationshipsCreatedOrLinked = batch.RelationshipDecisions.Count(decision => decision.CreatedOrLinked), RelationshipsCreatedOrLinked = batch.RelationshipDecisions.Count(decision => decision.CreatedOrLinked),
RelationshipStageComplete = batch.RelationshipStageComplete || relationshipReview.IsComplete, RelationshipStageComplete = relationshipReview.IsComplete,
KnowledgeIdentified = knowledgeReview.Candidates.Count + batch.KnowledgeDecisions.Count, KnowledgeIdentified = knowledgeReview.Candidates.Count + batch.KnowledgeDecisions.Count,
KnowledgeCreatedOrLinked = batch.KnowledgeDecisions.Count(decision => decision.CreatedOrLinked), KnowledgeCreatedOrLinked = batch.KnowledgeDecisions.Count(decision => decision.CreatedOrLinked),
KnowledgeStageComplete = batch.KnowledgeStageComplete || knowledgeReview.IsComplete, KnowledgeStageComplete = knowledgeReview.IsComplete,
NarrativeCandidatesIdentified = narrative.Candidates.Count + (batch.LastNarrativeImportResult?.TotalReviewed ?? 0), NarrativeCandidatesIdentified = narrative.Candidates.Count + (batch.LastNarrativeImportResult?.TotalReviewed ?? 0),
NarrativeCandidatesApplied = (batch.LastNarrativeImportResult?.PlotLinesCreated ?? 0) NarrativeCandidatesApplied = (batch.LastNarrativeImportResult?.PlotLinesCreated ?? 0)
+ (batch.LastNarrativeImportResult?.PlotLinesMerged ?? 0) + (batch.LastNarrativeImportResult?.PlotLinesMerged ?? 0)
+ (batch.LastNarrativeImportResult?.ThreadsCreated ?? 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.NarrativeReview
or StoryIntelligencePipelineStages.NarrativeImport or StoryIntelligencePipelineStages.NarrativeImport
or StoryIntelligencePipelineStages.Complete; 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.CharacterStageComplete = characterStageComplete;
batch.LocationStageComplete = locationStageComplete; batch.LocationStageComplete = false;
batch.AssetStageComplete = assetStageComplete; batch.AssetStageComplete = false;
batch.RelationshipStageComplete = relationshipStageComplete; batch.RelationshipStageComplete = false;
batch.KnowledgeStageComplete = knowledgeStageComplete; batch.KnowledgeStageComplete = false;
batch.NarrativeStageComplete = narrativeStageComplete; batch.NarrativeStageComplete = false;
await batchStore.SaveAsync(batch); await batchStore.SaveAsync(batch);

View File

@ -575,7 +575,12 @@ public sealed class StoryIntelligenceRelationshipImportService(
return new("Family", 420); 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); return new("Rival", 370);
} }
@ -590,7 +595,7 @@ public sealed class StoryIntelligenceRelationshipImportService(
return new("Educational", 350); 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); return new("Authority", 340);
} }
@ -610,7 +615,7 @@ public sealed class StoryIntelligenceRelationshipImportService(
return new("Neighbour", 310); 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); return new("Professional", 300);
} }
@ -625,7 +630,7 @@ public sealed class StoryIntelligenceRelationshipImportService(
return new("Friend", 270); 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); return new("Ally", 250);
} }
@ -661,6 +666,8 @@ public sealed class StoryIntelligenceRelationshipImportService(
{ {
var normalised = NormaliseForMatching(evidence); 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") 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("mother", characterAName, characterBName)
|| HasOwnedKinshipBetweenParticipants("father", characterAName, characterBName) || HasOwnedKinshipBetweenParticipants("father", characterAName, characterBName)
|| HasOwnedKinshipBetweenParticipants("mum", characterAName, characterBName) || HasOwnedKinshipBetweenParticipants("mum", characterAName, characterBName)
@ -668,6 +675,24 @@ public sealed class StoryIntelligenceRelationshipImportService(
|| HasOwnedKinshipBetweenParticipants("dad", characterAName, characterBName); || 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) private static bool HasOwnedKinshipBetweenParticipants(string kinship, string? characterAName, string? characterBName)
=> IsOwnedKinshipFor(characterAName, characterBName, kinship) => IsOwnedKinshipFor(characterAName, characterBName, kinship)
|| IsOwnedKinshipFor(characterBName, characterAName, kinship); || IsOwnedKinshipFor(characterBName, characterAName, kinship);