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 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<KnowledgeState>
@ -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<HttpRequestMessage, HttpResponseMessage> respond)
=> new(
new HttpClient(new CaptureImageRequestHandler(respond)),

View File

@ -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);

View File

@ -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);