Correct relationship review chronology and evidence-based type suggestions

This commit is contained in:
Nick Beckley 2026-09-11 19:06:34 +00:00
parent 32e82459bf
commit 56fc5f5140
12 changed files with 339 additions and 42 deletions

View File

@ -79,6 +79,7 @@ var tests = new (string Name, Action Test)[]
("Asset structural phrases trim neighbouring nouns", AssetStructuralPhrasesTrimNeighbouringNouns), ("Asset structural phrases trim neighbouring nouns", AssetStructuralPhrasesTrimNeighbouringNouns),
("Asset significance is generic and not fixture-specific", AssetSignificanceIsGenericAndNotFixtureSpecific), ("Asset significance is generic and not fixture-specific", AssetSignificanceIsGenericAndNotFixtureSpecific),
("Story Intelligence asset review applies current identity rules", StoryIntelligenceAssetReviewAppliesCurrentIdentityRules), ("Story Intelligence asset review applies current identity rules", StoryIntelligenceAssetReviewAppliesCurrentIdentityRules),
("Relationship review requires pair evidence and distinguishes social types", RelationshipReviewUsesPairEvidence),
("Relationship signals map to broad lookup types", RelationshipSignalsMapToBroadTypes), ("Relationship signals map to broad lookup types", RelationshipSignalsMapToBroadTypes),
("Relationship signals infer friendship without romance", RelationshipSignalsInferFriendshipWithoutRomance), ("Relationship signals infer friendship without romance", RelationshipSignalsInferFriendshipWithoutRomance),
("Relationship events preserve chronological story order", RelationshipEventsPreserveChronologicalStoryOrder), ("Relationship events preserve chronological story order", RelationshipEventsPreserveChronologicalStoryOrder),
@ -3349,6 +3350,38 @@ static void StoryIntelligenceAssetReviewAppliesCurrentIdentityRules()
"Asset review should stay in-module while pending candidates remain."); "Asset review should stay in-module while pending candidates remain.");
} }
static void RelationshipReviewUsesPairEvidence()
{
var cases = new (string Signal, string Evidence, string? Context, string A, string B, string Expected)[]
{
("mistrust", "Maggie expects Mr Wilkinson to be far too strait laced and not enjoy her outfit.", "Colin and Maggie flirt while discussing examiners.", "Maggie", "Mr Wilkinson", "Other"),
("fear", "Leila worries that the official will be strict.", "Leila travels to her test.", "Leila", "Dr Rowan", "Other"),
("authority", "Dr Rowan is Leila's driving examiner.", null, "Leila", "Dr Rowan", "Educational"),
("rivalry", "Leila and Tomas compete against each other for the scholarship.", null, "Leila", "Tomas", "Rival"),
("conflict", "They disagree about the timetable.", null, "Leila", "Tomas", "Other"),
("affection", "Leila hugs Tomas and gives him a gift.", "Aisha and Omar are a romantic couple.", "Leila", "Tomas", "Other"),
("affection", "Leila kisses Tomas on the cheek in greeting.", null, "Leila", "Tomas", "Other"),
("affection", "Leila wants a romantic relationship with Tomas.", null, "Leila", "Tomas", "Romantic"),
("affection", "Tomas is Leila's boyfriend.", null, "Leila", "Tomas", "Romantic"),
("affection", "Leila tells Tomas about her boyfriend Omar.", null, "Leila", "Tomas", "Other"),
("affection", "Tomas is not Leila's boyfriend.", null, "Leila", "Tomas", "Other"),
("affection", "They are not a couple; the kiss was a greeting.", null, "Leila", "Tomas", "Other"),
("friendship", "Leila and Tomas are best friends.", null, "Leila", "Tomas", "Best Friend"),
("friendship", "Leila and Tomas are close friends.", null, "Leila", "Tomas", "Friend"),
("trust", "Leila and Tomas are acquaintances who have met before.", null, "Leila", "Tomas", "Acquaintance"),
("trust", "Sophie identified as Rebecca\u0019s sister; implied social tie by presence with her mates.", null, "Sophie", "Rebecca", "Sibling"),
("trust", "Aisha identified as Leila’s sister; they arrive together.", null, "Aisha", "Leila", "Sibling"),
("family", "Leila and Aisha are sisters.", null, "Leila", "Aisha", "Sibling"),
("family", "Tomas is Omar's older brother.", null, "Tomas", "Omar", "Sibling"),
("family", "Aisha is the sister of Leila.", null, "Leila", "Aisha", "Sibling"),
("family", "They are not siblings.", null, "Leila", "Tomas", "Other"),
("trust", "Leila speaks to Tomas about his sister.", null, "Leila", "Tomas", "Other")
};
foreach (var item in cases)
Assert(RelationshipTypeFromEvidence(item.Signal,item.Evidence,item.Context,item.A,item.B)==item.Expected,
$"Expected {item.Expected} for {item.Evidence}");
}
static void RelationshipSignalsMapToBroadTypes() static void RelationshipSignalsMapToBroadTypes()
{ {
Assert(RelationshipTypeFromSignal("Friend") == "Friend", "Friend should map to Friend."); Assert(RelationshipTypeFromSignal("Friend") == "Friend", "Friend should map to Friend.");
@ -3357,8 +3390,8 @@ static void RelationshipSignalsMapToBroadTypes()
Assert(RelationshipTypeFromSignal("boss", "employee at work") == "Professional", "Boss/employee should map to Professional."); 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("police", "suspect witness interview") == "Authority", "Police/suspect/witness should map to Authority.");
Assert(RelationshipTypeFromSignal("neighbour") == "Neighbour", "Neighbour should map to Neighbour."); Assert(RelationshipTypeFromSignal("neighbour") == "Neighbour", "Neighbour should map to Neighbour.");
Assert(RelationshipTypeFromSignal("adversarial", "mistrust between them") == "Rival", "Adversarial/mistrust should map to Rival."); Assert(RelationshipTypeFromSignal("adversarial", "mistrust between them") == "Other", "Mistrust alone must not establish rivalry.");
Assert(RelationshipTypeFromSignal("uncertain emotional connection") == "Unknown", "Uncertain signals should map to Unknown."); Assert(RelationshipTypeFromSignal("uncertain emotional connection") == "Other", "Uncertain signals should map to Other.");
} }
static void RelationshipSignalsInferFriendshipWithoutRomance() static void RelationshipSignalsInferFriendshipWithoutRomance()
@ -3387,7 +3420,7 @@ static void RelationshipProgressionKeepsChronologicalState()
Assert(RelationshipStateAtScene("Unknown", events, 1) == "Unknown", "Scene 1 must not inherit later engagement."); 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, 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, 4) == "Unknown", "Attraction alone must not establish a romantic relationship.");
Assert(RelationshipStateAtScene("Unknown", events, 5) == "Romantic", "A couple/romantic state should remain visible before proposal."); 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."); Assert(RelationshipStateAtScene("Unknown", events, 6) == "Engaged", "Engagement should appear only after the proposal/acceptance event.");
} }
@ -3413,19 +3446,19 @@ static void RelationshipEvidencePriorityPreservesExplicitSemantics()
{ {
Assert(RelationshipTypeFromEvidence("dependence", "My brother helped with the machine.", null, "Nora", "Elias") == "Sibling", "Explicit brother evidence should become Sibling."); Assert(RelationshipTypeFromEvidence("dependence", "My brother helped with the machine.", null, "Nora", "Elias") == "Sibling", "Explicit brother evidence should become Sibling.");
Assert(RelationshipTypeFromEvidence("affection", "One pile was just me and Mum, my entire childhood.", null, "Nora", "Nora's mother") == "Parent", "Explicit parent evidence should become Parent."); Assert(RelationshipTypeFromEvidence("affection", "One pile was just me and Mum, my entire childhood.", null, "Nora", "Nora's mother") == "Parent", "Explicit parent evidence should become Parent.");
Assert(RelationshipTypeFromEvidence("affection", "Iris is my best friend; it has been that way since school.", null, "Nora", "Iris") == "Friend", "Explicit best-friend evidence should become Friend."); Assert(RelationshipTypeFromEvidence("affection", "Iris is my best friend; it has been that way since school.", null, "Nora", "Iris") == "Best Friend", "Explicit best-friend evidence should become Best Friend.");
Assert(RelationshipTypeFromEvidence("dependence", "Nora goes next door to Mrs Alder and asks for help.", null, "Nora", "Mrs Alder") == "Neighbour", "Next-door context should become Neighbour."); Assert(RelationshipTypeFromEvidence("dependence", "Nora goes next door to Mrs Alder and asks for help.", null, "Nora", "Mrs Alder") == "Neighbour", "Next-door context should become Neighbour.");
Assert(RelationshipTypeFromEvidence("obligation", "They wait together.", "Nora meets coworkers Jae and Priya at the tram stop.", "Nora", "Jae") == "Professional", "Scene coworker context should become Professional."); Assert(RelationshipTypeFromEvidence("obligation", "They wait together.", "Nora meets coworkers Jae and Priya at the tram stop.", "Nora", "Jae") == "Professional", "Scene coworker context should become Professional.");
Assert(RelationshipTypeFromEvidence("trust", "Dr Vale reassures Mira during the assessment.", "Dr Vale is Mira's examiner.", "Mira", "Dr Vale") == "Educational", "Generic examiner/candidate context should become Educational."); Assert(RelationshipTypeFromEvidence("trust", "Dr Vale reassures Mira during the assessment.", "Dr Vale is Mira's examiner.", "Mira", "Dr Vale") == "Educational", "Generic examiner/candidate context should become Educational.");
Assert(RelationshipTypeFromEvidence("affection", "Jon looks Mira up and down and flirts.", null, "Jon", "Mira") != "Romantic", "One-sided attraction/flirtation should not establish Romantic."); Assert(RelationshipTypeFromEvidence("affection", "Jon looks Mira up and down and flirts.", null, "Jon", "Mira") != "Romantic", "One-sided attraction/flirtation should not establish Romantic.");
Assert(RelationshipTypeFromEvidence("conflict", "Two siblings argue and insult one another.", null, "Mira", "Talia") == "Sibling", "Sibling evidence should outrank conflict."); Assert(RelationshipTypeFromEvidence("conflict", "Two siblings argue and insult one another.", null, "Mira", "Talia") == "Sibling", "Sibling evidence should outrank conflict.");
Assert(RelationshipTypeFromEvidence("conflict", "Mira attacks Oren after he harms her friend.", null, "Mira", "Oren") == "Rival", "One-off adversarial interaction should stay hostile/adversarial, not friendly or romantic."); Assert(RelationshipTypeFromEvidence("conflict", "Mira attacks Oren after he harms her friend.", null, "Mira", "Oren") == "Enemy", "One-off adversarial interaction should stay hostile/adversarial, not friendly or romantic.");
Assert(RelationshipTypeFromEvidence("dependence", "Mary arrives to take them to the theatre and Nora gets into Mary's car.", null, "Nora", "Mary") == "Unknown", "One incidental lift should not become Ally."); Assert(RelationshipTypeFromEvidence("dependence", "Mary arrives to take them to the theatre and Nora gets into Mary's car.", null, "Nora", "Mary") == "Other", "One incidental lift should not become Ally.");
Assert(RelationshipTypeFromEvidence("secrecy", "Nora searches for her sister under another name.", null, "Nora", "Mira") == "Sibling", "Identity timing belongs to Knowledge; objective sibling evidence should remain Sibling."); Assert(RelationshipTypeFromEvidence("secrecy", "Nora searches for her sister under another name.", null, "Nora", "Mira") == "Sibling", "Identity timing belongs to Knowledge; objective sibling evidence should remain Sibling.");
Assert(RelationshipTypeFromEvidence("family", "Nora's adoptive mother speaks to Nora.", null, "Nora", "Nora's adoptive mother") == "Parent", "Adoptive family evidence should classify the direct relationship without transitive assumptions."); Assert(RelationshipTypeFromEvidence("family", "Nora's adoptive mother speaks to Nora.", null, "Nora", "Nora's adoptive mother") == "Parent", "Adoptive family evidence should classify the direct relationship without transitive assumptions.");
Assert(RelationshipTypeFromEvidence("admiration", "Nora regards Priya as like an older sister after the rescue.", null, "Nora", "Priya") != "Sibling", "Figurative sibling language should not create an objective Sibling relationship."); Assert(RelationshipTypeFromEvidence("admiration", "Nora regards Priya as like an older sister after the rescue.", null, "Nora", "Priya") != "Sibling", "Figurative sibling language should not create an objective Sibling relationship.");
Assert(RelationshipTypeFromEvidence("information", "Pat addresses Nora directly about looking for her sister.", null, "Pat", "Nora") != "Sibling", "Third-party kinship discussion should not make the speakers siblings."); Assert(RelationshipTypeFromEvidence("information", "Pat addresses Nora directly about looking for her sister.", null, "Pat", "Nora") != "Sibling", "Third-party kinship discussion should not make the speakers siblings.");
Assert(RelationshipTypeFromEvidence("conflict", "Oren kisses Mira before assaulting her.", null, "Oren", "Mira") == "Rival", "Assault evidence should outrank kiss wording and remain adversarial."); Assert(RelationshipTypeFromEvidence("conflict", "Oren kisses Mira before assaulting her.", null, "Oren", "Mira") == "Enemy", "Assault evidence should outrank kiss wording and remain adversarial.");
Assert(RelationshipTypeFromEvidence("family", "Mira's mum tells the group to look after Mira.", null, "Mira", "Mira's mum") == "Parent", "Owned mum/mother variants should classify as Parent."); Assert(RelationshipTypeFromEvidence("family", "Mira's mum tells the group to look after Mira.", null, "Mira", "Mira's mum") == "Parent", "Owned mum/mother variants should classify as Parent.");
Assert(RelationshipTypeFromEvidence("family", "Mira's mother tells the group to look after Mira.", null, "Mira", "Mira’s mum") == "Parent", "Curly apostrophe owned kinship names should classify as Parent."); Assert(RelationshipTypeFromEvidence("family", "Mira's mother tells the group to look after Mira.", null, "Mira", "Mira’s mum") == "Parent", "Curly apostrophe owned kinship names should classify as Parent.");
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.");
@ -3433,10 +3466,10 @@ static void RelationshipEvidencePriorityPreservesExplicitSemantics()
static void RelationshipEvidenceHandlesRetainedHostileAndIncidentalExamples() 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("conflict", "Maggie slaps Chrissy, calls her a bitch and threatens violence after discovering Chrissy's involvement.", null, "Maggie", "Chrissy") == "Enemy", "Slap/threat evidence from retained Scene JSON should classify as Enemy.");
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("mistrust", "Rebecca demands Chrissy never speak to her again and expresses disgust.", null, "Rebecca", "Chrissy") == "Estranged From", "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("dependence", "Maggie accepts Greg's offer of a lift and his help driving Rosie home.", null, "Maggie", "Greg") == "Other", "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("protection", "Greg offers to take Rosie to the hospital and pulls the car over when she is sick.", null, "Greg", "Rosie") == "Other", "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("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("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("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.");

View File

@ -0,0 +1,41 @@
-- Deliberately scrambled ranks, chapter 2 vs 10, and scene 2 vs 10; all data rolls back.
SET XACT_ABORT ON;
BEGIN TRY
BEGIN TRANSACTION;
INSERT dbo.Projects(ProjectName) VALUES (N'Relationship ordering fixture');
DECLARE @ProjectID int=SCOPE_IDENTITY();
INSERT dbo.Books(ProjectID,BookTitle,BookNumber,SortOrder) VALUES (@ProjectID,N'Ordering fixture',1,1);
DECLARE @BookID int=SCOPE_IDENTITY();
INSERT dbo.StoryIntelligenceReviewCandidateGenerations(ProjectID,BookID,Module,Status,CandidateCount,PendingCount)
VALUES (@ProjectID,@BookID,N'Relationships',N'Ready',4,3);
DECLARE @GenerationID int=SCOPE_IDENTITY();
INSERT dbo.StoryIntelligenceReviewCandidates(ReviewCandidateGenerationID,ProjectID,BookID,Module,StableKey,DisplayName,SortOrder,CandidatePayloadJson,DecisionStatus)
SELECT @GenerationID,@ProjectID,@BookID,N'Relationships',k,k,rank,payload,status
FROM (VALUES
(N'late',1,N'{"key":"late","firstChapterNumber":10,"firstSceneNumber":1,"firstSceneID":30}',N'Pending'),
(N'middle',2,N'{"key":"middle","firstChapterNumber":2,"firstSceneNumber":10,"firstSceneID":20}',N'Pending'),
(N'early',3,N'{"key":"early","firstChapterNumber":2,"firstSceneNumber":2,"firstSceneID":10}',N'Pending'),
(N'decided',4,N'{"key":"decided","relationshipType":"Friend"}',N'Reviewed')) data(k,rank,payload,status);
CREATE TABLE #Page (
ReviewCandidateID int,ReviewCandidateGenerationID int,ProjectID int,BookID int,Module nvarchar(40),StableKey nvarchar(450),
CandidateType nvarchar(80),DisplayName nvarchar(450),SortOrder int,Confidence decimal(9,4),ExistingCanonicalID int,
DecisionStatus nvarchar(30),DecisionAction nvarchar(40),DecisionTargetKey nvarchar(450),DecisionCanonicalID int,
CandidatePayloadJson nvarchar(max),CreatedUtc datetime2,ReviewedUtc datetime2,UpdatedUtc datetime2,
TotalCount int,PendingCount int,RowNumber bigint);
INSERT #Page EXEC dbo.StoryIntelligenceReviewCandidate_ListPage @BookID,N'Relationships',1,2;
IF NOT EXISTS(SELECT 1 FROM #Page WHERE RowNumber=1 AND StableKey=N'early') THROW 51001,'First page must begin at chapter 2 scene 2.',1;
IF NOT EXISTS(SELECT 1 FROM #Page WHERE RowNumber=2 AND StableKey=N'middle') THROW 51001,'Scene 10 must follow scene 2.',1;
TRUNCATE TABLE #Page;
INSERT #Page EXEC dbo.StoryIntelligenceReviewCandidate_ListPage @BookID,N'Relationships',2,2;
IF NOT EXISTS(SELECT 1 FROM #Page WHERE RowNumber=3 AND StableKey=N'late') THROW 51001,'Chapter 10 must follow chapter 2 across pages.',1;
EXEC dbo.StoryIntelligenceReviewCandidate_RefreshPendingRelationships @BookID,@GenerationID,
N'[{"key":"early","relationshipType":"Other","reviewRulesVersion":2,"firstChapterNumber":2,"firstSceneNumber":2},{"key":"decided","relationshipType":"Other","reviewRulesVersion":2}]';
IF NOT EXISTS(SELECT 1 FROM dbo.StoryIntelligenceReviewCandidates WHERE ReviewCandidateGenerationID=@GenerationID AND StableKey=N'early' AND CandidateType=N'Other') THROW 51001,'Pending suggestion did not refresh.',1;
IF NOT EXISTS(SELECT 1 FROM dbo.StoryIntelligenceReviewCandidates WHERE ReviewCandidateGenerationID=@GenerationID AND StableKey=N'decided' AND JSON_VALUE(CandidatePayloadJson,'$.relationshipType')=N'Friend') THROW 51001,'Author decision changed.',1;
ROLLBACK TRANSACTION;
SELECT N'PASS: numeric chapter/scene paging and preservation of author decisions; rolled back' AS Result;
END TRY
BEGIN CATCH
IF XACT_STATE()<>0 ROLLBACK TRANSACTION;
THROW;
END CATCH;

View File

@ -6,6 +6,7 @@ namespace PlotLine.Data;
public interface IStoryIntelligenceReviewCandidateRepository public interface IStoryIntelligenceReviewCandidateRepository
{ {
Task RefreshPendingRelationshipsAsync(int bookId, int generationId, string payloadJson);
Task<StoryIntelligenceReviewCandidateGeneration> BeginGenerationAsync(int projectId, int bookId, string module, string source); Task<StoryIntelligenceReviewCandidateGeneration> BeginGenerationAsync(int projectId, int bookId, string module, string source);
Task<StoryIntelligenceReviewCandidateGeneration?> GetCurrentGenerationAsync(int bookId, string module); Task<StoryIntelligenceReviewCandidateGeneration?> GetCurrentGenerationAsync(int bookId, string module);
Task<StoryIntelligenceReviewCandidateRow> SaveCandidateAsync(StoryIntelligenceReviewCandidateSaveRequest request); Task<StoryIntelligenceReviewCandidateRow> SaveCandidateAsync(StoryIntelligenceReviewCandidateSaveRequest request);
@ -21,6 +22,13 @@ public interface IStoryIntelligenceReviewCandidateRepository
public sealed class StoryIntelligenceReviewCandidateRepository(ISqlConnectionFactory connectionFactory) : IStoryIntelligenceReviewCandidateRepository public sealed class StoryIntelligenceReviewCandidateRepository(ISqlConnectionFactory connectionFactory) : IStoryIntelligenceReviewCandidateRepository
{ {
public async Task RefreshPendingRelationshipsAsync(int bookId, int generationId, string payloadJson)
{
using var connection = connectionFactory.CreateConnection();
await connection.ExecuteAsync("dbo.StoryIntelligenceReviewCandidate_RefreshPendingRelationships",
new { BookID = bookId, GenerationID = generationId, PayloadJson = payloadJson }, commandType: CommandType.StoredProcedure);
}
public async Task<StoryIntelligenceReviewCandidateGeneration> BeginGenerationAsync(int projectId, int bookId, string module, string source) public async Task<StoryIntelligenceReviewCandidateGeneration> BeginGenerationAsync(int projectId, int bookId, string module, string source)
{ {
using var connection = connectionFactory.CreateConnection(); using var connection = connectionFactory.CreateConnection();

View File

@ -268,6 +268,8 @@ Use neutral terms such as:
These are scene-local signals for later Relationship Intelligence. They are not final relationship records. These are scene-local signals for later Relationship Intelligence. They are not final relationship records.
Relationship evidence must describe the two named participants, not other people in the scene. Routine authority, an examiner's reputation, anxiety, disagreement, or distrust do not establish rivalry: use rivalry only for demonstrated competition between the pair. Ordinary affection, compliments, flirtation, attraction, gifts, hugs, or an ambiguous kiss do not establish a romantic relationship. Include romance only with evidence of an existing relationship or a participant wanting a romantic relationship with the other. Preserve explicit brother/sister/sibling statements (including older/younger and named possessives), while excluding figurative "like a sister" language and discussion of someone else's sibling. Preserve explicit "best friend" wording and distinguish simple acquaintance from friendship. Do not invent a relationship from mere co-presence.
For each relationship signal, include exactly these properties: `characterA`, `characterB`, `relationshipSignal`, `evidence`, `confidence`. For each relationship signal, include exactly these properties: `characterA`, `characterB`, `relationshipSignal`, `evidence`, `confidence`.
Do not use property names such as `characters`, `signal`, `type`, `description` or `notes` in relationship objects. Use one relationship object per supported signal between two named characters. If the same pair has multiple supported signals, each object must still use `characterA`, `characterB`, `relationshipSignal`, `evidence` and `confidence`. Do not use property names such as `characters`, `signal`, `type`, `description` or `notes` in relationship objects. Use one relationship object per supported signal between two named characters. If the same pair has multiple supported signals, each object must still use `characterA`, `characterB`, `relationshipSignal`, `evidence` and `confidence`.

View File

@ -320,6 +320,8 @@ Use neutral terms such as:
These are scene-local signals for later Relationship Intelligence. They are not final relationship records. These are scene-local signals for later Relationship Intelligence. They are not final relationship records.
Relationship evidence must describe the two named participants, not other people in the scene. Routine authority, an examiner's reputation, anxiety, disagreement, or distrust do not establish rivalry: use rivalry only for demonstrated competition between the pair. Ordinary affection, compliments, flirtation, attraction, gifts, hugs, or an ambiguous kiss do not establish a romantic relationship. Include romance only with evidence of an existing relationship or a participant wanting a romantic relationship with the other. Preserve explicit brother/sister/sibling statements (including older/younger and named possessives), while excluding figurative "like a sister" language and discussion of someone else's sibling. Preserve explicit "best friend" wording and distinguish simple acquaintance from friendship. Do not invent a relationship from mere co-presence.
For each relationship signal, include exactly these properties: `characterA`, `characterB`, `relationshipSignal`, `evidence`, `confidence`. For each relationship signal, include exactly these properties: `characterA`, `characterB`, `relationshipSignal`, `evidence`, `confidence`.
Do not use property names such as `characters`, `signal`, `type`, `description` or `notes` in relationship objects. Use one relationship object per supported signal between two named characters. If the same pair has multiple supported signals, each object must still use `characterA`, `characterB`, `relationshipSignal`, `evidence` and `confidence`. Do not use property names such as `characters`, `signal`, `type`, `description` or `notes` in relationship objects. Use one relationship object per supported signal between two named characters. If the same pair has multiple supported signals, each object must still use `characterA`, `characterB`, `relationshipSignal`, `evidence` and `confidence`.

View File

@ -58,6 +58,10 @@ public sealed class StoryIntelligenceRelationshipImportService(
Candidates = visibleCandidates.Select(candidate => new StoryIntelligenceRelationshipReviewCandidateViewModel Candidates = visibleCandidates.Select(candidate => new StoryIntelligenceRelationshipReviewCandidateViewModel
{ {
Key = candidate.Key, Key = candidate.Key,
ReviewRulesVersion = 2,
FirstChapterNumber = CandidateFirstStoryOrder(candidate).ChapterNumber,
FirstSceneNumber = CandidateFirstStoryOrder(candidate).SceneNumber,
FirstSceneID = CandidateFirstStoryOrder(candidate).SceneID,
CharacterAName = candidate.CharacterAName, CharacterAName = candidate.CharacterAName,
CharacterBName = candidate.CharacterBName, CharacterBName = candidate.CharacterBName,
RelationshipType = candidate.RelationshipType, RelationshipType = candidate.RelationshipType,
@ -354,7 +358,8 @@ public sealed class StoryIntelligenceRelationshipImportService(
observation.SubjectName, observation.SubjectName,
observation.ObjectName, observation.ObjectName,
FirstConfigured(observation.Predicate, observation.ObservationType), FirstConfigured(observation.Predicate, observation.ObservationType),
FirstConfigured(observation.Description, observation.Evidence), string.Join(" ", new[] { observation.Description, observation.Evidence }
.Where(text => !string.IsNullOrWhiteSpace(text)).Distinct(StringComparer.OrdinalIgnoreCase)),
observation.Confidence, observation.Confidence,
existingEvents); existingEvents);
} }
@ -411,10 +416,15 @@ public sealed class StoryIntelligenceRelationshipImportService(
} }
candidate.Confidence = Max(candidate.Confidence, confidence); candidate.Confidence = Max(candidate.Confidence, confidence);
candidate.FirstAppearanceNote ??= BuildSceneReference(importedScene); var incomingOrder = (importedScene.ChapterNumber, importedScene.SceneNumber, importedScene.SortOrder, importedScene.SceneID);
candidate.LastAppearanceNote = BuildSceneReference(importedScene); if (candidate.Appearances.Count == 0 || incomingOrder.CompareTo(CandidateFirstStoryOrder(candidate)) < 0)
candidate.ExampleScene ??= BuildFirstAppearance(importedScene, parsed); {
candidate.ExampleContext ??= Clean(evidence); candidate.FirstAppearanceNote = BuildSceneReference(importedScene);
candidate.ExampleScene = BuildFirstAppearance(importedScene, parsed);
candidate.ExampleContext = Clean(evidence);
}
if (candidate.Appearances.Count == 0 || incomingOrder.CompareTo(candidate.Appearances.Select(StoryOrder).Max()) > 0)
candidate.LastAppearanceNote = BuildSceneReference(importedScene);
candidate.Appearances.Add(new RelationshipAppearanceImport( candidate.Appearances.Add(new RelationshipAppearanceImport(
importedScene.SceneID, importedScene.SceneID,
importedScene.ChapterNumber, importedScene.ChapterNumber,
@ -529,7 +539,7 @@ public sealed class StoryIntelligenceRelationshipImportService(
return return
value.Contains("reconcil") ? "Reconciled" : value.Contains("reconcil") ? "Reconciled" :
value.Contains("engaged") || value.Contains("proposal") || value.Contains("proposes") ? "Engaged" : value.Contains("engaged") || value.Contains("proposal") || value.Contains("proposes") ? "Engaged" :
value.Contains("romantic") || value.Contains("attraction") || value.Contains("lover") || value.Contains("love") || value.Contains("couple") ? "Romantic" : HasRomanticRelationshipEvidence(signal) ? "Romantic" :
value.Contains("friend") || value.Contains("ally") || value.Contains("support") || value.Contains("trust") || value.Contains("hug") || value.Contains("gift") ? "Friendly" : 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("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("distrust") || value.Contains("suspect") || value.Contains("suspicious") ? "Distrustful" :
@ -549,13 +559,10 @@ public sealed class StoryIntelligenceRelationshipImportService(
string? characterBName) string? characterBName)
{ {
var relationshipText = NormaliseForMatching($"{evidence} {characterAName} {characterBName}"); var relationshipText = NormaliseForMatching($"{evidence} {characterAName} {characterBName}");
var explicitText = NormaliseForMatching($"{evidence} {sceneContext} {characterAName} {characterBName}"); // Scene-wide romance/conflict can belong to entirely different characters.
var allText = NormaliseForMatching($"{signal} {evidence} {sceneContext} {characterAName} {characterBName}"); var pairContext = ContextForPair(sceneContext, characterAName, characterBName);
if (ContainsAny(relationshipText, "best friend", "close friend", "friend since", "childhood friend")) var explicitText = NormaliseForMatching($"{evidence} {pairContext}");
{ var allText = NormaliseForMatching($"{signal} {evidence}");
return new("Friend", 500);
}
if (HasDirectSiblingEvidence(evidence, characterAName, characterBName)) if (HasDirectSiblingEvidence(evidence, characterAName, characterBName))
{ {
return new("Sibling", 490); return new("Sibling", 490);
@ -566,6 +573,12 @@ public sealed class StoryIntelligenceRelationshipImportService(
return new("Parent", 480); return new("Parent", 480);
} }
if (ContainsAny(relationshipText, "best friend", "best friends", "closest friend")
&& !ContainsAny(relationshipText, "not best friends", "not her best friend", "not his best friend"))
return new("Best Friend", 475);
if (ContainsAny(relationshipText, "close friend", "friend since", "childhood friend"))
return new("Friend", 465);
if (ContainsAny(explicitText, "neighbour", "neighbor", "next door")) if (ContainsAny(explicitText, "neighbour", "neighbor", "next door"))
{ {
return new("Neighbour", 470); return new("Neighbour", 470);
@ -576,7 +589,7 @@ public sealed class StoryIntelligenceRelationshipImportService(
return new("Professional", 460); return new("Professional", 460);
} }
if (ContainsAny(explicitText, "examiner", "candidate", "instructor", "learner", "teacher", "student", "mentor", "pupil", "tutor")) if (ContainsAny(explicitText, "examiner", "instructor", "learner", "teacher", "student", "mentor", "pupil", "tutor"))
{ {
return new("Educational", 455); return new("Educational", 455);
} }
@ -586,22 +599,21 @@ public sealed class StoryIntelligenceRelationshipImportService(
return new("Family", 420); return new("Family", 420);
} }
if (ContainsAny(allText, if (ContainsAny(relationshipText, "rivals", "rivalry", "compete against", "competing against", "competitors", "compete for", "competing for")
"rival", "enemy", "adversarial", "conflict", "mistrust", "distrust", "antagonist", "competitor", && !ContainsAny(relationshipText, "not rivals", "no rivalry"))
"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);
}
if (ContainsAny(allText, "romance", "romantic", "lover", "spouse", "husband", "wife", "girlfriend", "boyfriend", "love interest", "former partner", "partner like", "partner-like", "kiss", "proposal", "engaged", "couple")) if (ContainsAny(relationshipText, "enemy", "enemies", "attack", "attacks", "attacked", "assault", "assaults", "assaulted", "assaulting", "slap", "slaps",
{ "slapped", "threaten", "threatens", "threatened", "violence", "drugged", "groped", "hostile enemies"))
return new("Enemy", 365);
if (ContainsAny(relationshipText, "never speak", "estranged from", "cut off contact"))
return new("Estranged From", 355);
if (HasRomanticRelationshipEvidence(evidence))
return new("Romantic", 360); return new("Romantic", 360);
}
if (ContainsAny(allText, "teacher", "student", "instructor", "learner", "mentor", "pupil", "tutor", "driving instructor", "examiner", "candidate")) if (ContainsAny(allText, "teacher", "student", "instructor", "learner", "mentor", "pupil", "tutor", "driving instructor", "examiner"))
{ {
return new("Educational", 350); return new("Educational", 350);
} }
@ -646,7 +658,26 @@ public sealed class StoryIntelligenceRelationshipImportService(
return new("Ally", 250); return new("Ally", 250);
} }
return new("Unknown", 0); if (ContainsAny(relationshipText, "has met", "barely knows", "acquaintance", "acquaintances", "knows of", "aware of", "recognises", "recognizes", "met before", "know each other", "knows him", "knows her"))
return new("Acquaintance", 100);
return new("Other", 0);
}
private static string ContextForPair(string? context, string? characterA, string? characterB)
{
if (string.IsNullOrWhiteSpace(characterA) || string.IsNullOrWhiteSpace(characterB)) return string.Empty;
return string.Join(" ", Regex.Split(context ?? string.Empty, @"(?<=[.!?])\s+")
.Where(sentence => ContainsAny(NormaliseForMatching(sentence), characterA)
&& ContainsAny(NormaliseForMatching(sentence), characterB)));
}
private static bool HasRomanticRelationshipEvidence(string? evidence)
{
var value = NormaliseForMatching(evidence);
if (Regex.IsMatch(evidence ?? string.Empty, @"\b(?:about|discusses?|describes?)\b.{0,50}\b(?:boyfriend|girlfriend|lover|romantic relationship)\b", RegexOptions.IgnoreCase)) return false;
if (Regex.IsMatch(evidence ?? string.Empty, @"\b(?:not|no|never|isn't|aren't)\b.{0,25}\b(?:romantic|boyfriend|girlfriend|dating|in love|a couple)\b", RegexOptions.IgnoreCase)) return false;
if (ContainsAny(value, "not romantic", "no romantic", "not a couple", "not dating", "not in love", "unwanted", "forced kiss", "assault", "assaulting", "assaulted", "against her will", "against his will")) return false;
return ContainsAny(value, "romantic relationship", "romantically involved", "in a relationship", "dating each other", "they are dating", "they are a couple", "boyfriend", "girlfriend", "husband", "wife", "spouse", "lovers", "in love with", "has a crush on", "romantic feelings for", "wants to date", "wants a relationship with", "wants a romantic relationship", "hopes for a relationship", "asks her on a date", "asks him on a date", "asks them on a date", "romantic interest in", "romantic attraction to", "engaged to", "married to");
} }
private static bool HasMeaningfulRelationshipEvent(RelationshipAppearanceImport appearance) private static bool HasMeaningfulRelationshipEvent(RelationshipAppearanceImport appearance)
@ -660,19 +691,33 @@ public sealed class StoryIntelligenceRelationshipImportService(
var clean = Clean(evidence); var clean = Clean(evidence);
if (string.IsNullOrWhiteSpace(clean) if (string.IsNullOrWhiteSpace(clean)
|| FigurativeSibling.IsMatch(clean) || FigurativeSibling.IsMatch(clean)
|| ReportedThirdPartyKinship.IsMatch(clean)) || ReportedThirdPartyKinship.IsMatch(clean)
|| Regex.IsMatch(clean, @"\b(?:not|never)\s+(?:(?:his|her|my|their|a|the)\s+)?(?:sisters?|brothers?|siblings?)\b", RegexOptions.IgnoreCase))
{ {
return false; return false;
} }
var normalised = NormaliseForMatching(clean); var normalised = NormaliseForMatching(clean);
return ContainsAny(normalised, "my brother", "my sister", "her brother", "her sister", "his brother", "his sister", "their brother", "their sister") return HasNamedSiblingEvidence(normalised, characterAName, characterBName)
|| ContainsAny(normalised, "they are sisters", "they are brothers", "they are siblings", "we are sisters", "we re sisters", "we are brothers", "we re brothers", "between siblings", "the sisters", "the brothers", "sibling relationship", "my brother", "my sister", "her brother", "her sister", "his brother", "his sister", "their brother", "their sister", "my older sister", "my younger sister", "my older brother", "my younger brother", "her older sister", "her younger sister", "his older brother", "his younger brother")
|| ContainsAny(normalised, "two siblings", "both siblings", "the siblings") || ContainsAny(normalised, "two siblings", "both siblings", "the siblings")
|| HasOwnedKinshipBetweenParticipants("brother", characterAName, characterBName) || HasOwnedKinshipBetweenParticipants("brother", characterAName, characterBName)
|| HasOwnedKinshipBetweenParticipants("sister", characterAName, characterBName) || HasOwnedKinshipBetweenParticipants("sister", characterAName, characterBName)
|| HasOwnedKinshipBetweenParticipants("sibling", characterAName, characterBName); || HasOwnedKinshipBetweenParticipants("sibling", characterAName, characterBName);
} }
private static bool HasNamedSiblingEvidence(string value, string? a, string? b)
{
if (string.IsNullOrWhiteSpace(a) || string.IsNullOrWhiteSpace(b)) return false;
foreach (var (first, second) in new[] { (a, b), (b, a) })
{
if (ContainsAny(value, $"{first} and {second} are sisters", $"{first} and {second} are brothers", $"{first} and {second} are siblings")) return true;
foreach (var kin in new[] { "sister", "brother", "sibling", "older sister", "younger sister", "elder sister", "older brother", "younger brother", "elder brother" })
if (ContainsAny(value, $"{first} is {second}'s {kin}", $"{first} identified as {second}'s {kin}", $"{first} is identified as {second}'s {kin}", $"{first} described as {second}'s {kin}", $"{first} {second}'s {kin}", $"{first}'s {kin} {second}", $"{first} is the {kin} of {second}")) return true;
}
return false;
}
private static bool HasDirectParentEvidence(string? evidence, string? characterAName, string? characterBName) private static bool HasDirectParentEvidence(string? evidence, string? characterAName, string? characterBName)
{ {
var normalised = NormaliseForMatching(evidence); var normalised = NormaliseForMatching(evidence);
@ -1014,7 +1059,7 @@ public sealed class StoryIntelligenceRelationshipImportService(
{ {
"Sibling" or "Parent" or "Child" or "Adoptive Parent" or "Biological Parent" or "Spouse" => 5, "Sibling" or "Parent" or "Child" or "Adoptive Parent" or "Biological Parent" or "Spouse" => 5,
"Neighbour" or "Educational" or "Professional" or "Medical" or "Authority" => 4, "Neighbour" or "Educational" or "Professional" or "Medical" or "Authority" => 4,
"Friend" or "Romantic" or "Criminal" or "Rival" => 3, "Best Friend" or "Friend" or "Romantic" or "Criminal" or "Rival" or "Enemy" => 3,
"Family" or "Ally" => 2, "Family" or "Ally" => 2,
_ => 0 _ => 0
}; };

View File

@ -0,0 +1,71 @@
-- Additional author-facing types and chronological persisted relationship reviews.
SET XACT_ABORT ON;
GO
INSERT dbo.RelationshipTypes(TypeName,IsPermanentDefault,SortOrder,RelationshipCategoryID)
SELECT value.TypeName,0,value.SortOrder,category.RelationshipCategoryID
FROM (VALUES (N'Other',N'Other',900),(N'Acquaintance',N'Friendship',31),(N'Best Friend',N'Friendship',29)) value(TypeName,CategoryName,SortOrder)
JOIN dbo.RelationshipCategories category ON category.CategoryName=value.CategoryName
WHERE NOT EXISTS (SELECT 1 FROM dbo.RelationshipTypes existing WHERE existing.TypeName=value.TypeName);
GO
CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceReviewCandidate_RefreshPendingRelationships
@BookID int, @GenerationID int, @PayloadJson nvarchar(max)
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON;
-- Never replace author decisions or a generation that changed during regeneration.
UPDATE candidate SET CandidatePayloadJson=payload.value,
CandidateType=JSON_VALUE(payload.value,'$.relationshipType'),
SortOrder=CONVERT(int,payload.[key])+1,UpdatedUtc=SYSUTCDATETIME()
FROM dbo.StoryIntelligenceReviewCandidates candidate
JOIN dbo.StoryIntelligenceReviewCandidateGenerations generation
ON generation.ReviewCandidateGenerationID=candidate.ReviewCandidateGenerationID
JOIN OPENJSON(@PayloadJson) payload ON JSON_VALUE(payload.value,'$.key')=candidate.StableKey
WHERE generation.ReviewCandidateGenerationID=@GenerationID AND generation.BookID=@BookID
AND generation.Module=N'Relationships' AND generation.IsCurrent=1 AND generation.Status=N'Ready'
AND candidate.DecisionStatus=N'Pending';
END;
GO
CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceReviewCandidate_ListPage
@BookID int,
@Module nvarchar(40),
@PageNumber int = 1,
@PageSize int = 25
AS
BEGIN
SET NOCOUNT ON;
IF @PageNumber < 1 SET @PageNumber = 1;
IF @PageSize < 1 SET @PageSize = 25;
IF @PageSize > 100 SET @PageSize = 100;
DECLARE @GenerationID int;
SELECT TOP (1) @GenerationID = ReviewCandidateGenerationID
FROM dbo.StoryIntelligenceReviewCandidateGenerations
WHERE BookID = @BookID
AND Module = @Module
AND IsCurrent = 1
AND Status = N'Ready'
ORDER BY ReviewCandidateGenerationID DESC;
;WITH CurrentCandidates AS
(
SELECT candidate.*,
COUNT(*) OVER () AS TotalCount,
SUM(CASE WHEN candidate.DecisionStatus = N'Pending' THEN 1 ELSE 0 END) OVER () AS PendingCount,
ROW_NUMBER() OVER (ORDER BY
CASE WHEN @Module=N'Relationships' THEN TRY_CONVERT(decimal(18,4),JSON_VALUE(candidate.CandidatePayloadJson,'$.firstChapterNumber')) END,
CASE WHEN @Module=N'Relationships' THEN TRY_CONVERT(decimal(18,4),JSON_VALUE(candidate.CandidatePayloadJson,'$.firstSceneNumber')) END,
CASE WHEN @Module=N'Relationships' THEN TRY_CONVERT(int,JSON_VALUE(candidate.CandidatePayloadJson,'$.firstSceneID')) END,
candidate.SortOrder, candidate.ReviewCandidateID) AS RowNumber
FROM dbo.StoryIntelligenceReviewCandidates candidate
WHERE candidate.ReviewCandidateGenerationID = @GenerationID
AND candidate.DecisionStatus = N'Pending'
)
SELECT *
FROM CurrentCandidates
WHERE RowNumber BETWEEN ((@PageNumber - 1) * @PageSize) + 1 AND @PageNumber * @PageSize
ORDER BY RowNumber;
END;
GO

View File

@ -668,6 +668,10 @@ public sealed class StoryIntelligenceRelationshipReviewViewModel
public sealed class StoryIntelligenceRelationshipReviewCandidateViewModel public sealed class StoryIntelligenceRelationshipReviewCandidateViewModel
{ {
public int ReviewRulesVersion { get; init; }
public decimal? FirstChapterNumber { get; init; }
public decimal? FirstSceneNumber { get; init; }
public int? FirstSceneID { get; init; }
public string Key { get; init; } = string.Empty; public string Key { get; init; } = string.Empty;
public string CharacterAName { get; init; } = string.Empty; public string CharacterAName { get; init; } = string.Empty;
public string CharacterBName { get; init; } = string.Empty; public string CharacterBName { get; init; } = string.Empty;

View File

@ -11,7 +11,7 @@
<div class="onboarding-copy story-review-heading"> <div class="onboarding-copy story-review-heading">
<p class="eyebrow">Story Intelligence</p> <p class="eyebrow">Story Intelligence</p>
<h1 id="story-relationship-title">Review Relationships</h1> <h1 id="story-relationship-title">Review Relationships</h1>
<p>Review relationship suggestions by character pair and decide what should become canonical.</p> <p>Review relationships in chapter order, then scene order, by their first appearance. Decide which should become canonical.</p>
</div> </div>
@if (TempData["OnboardingStoryIntelligenceError"] is string error) @if (TempData["OnboardingStoryIntelligenceError"] is string error)
@ -58,9 +58,14 @@
@for (var i = 0; i < relationshipCandidates.Count; i++) @for (var i = 0; i < relationshipCandidates.Count; i++)
{ {
var candidate = relationshipCandidates[i]; var candidate = relationshipCandidates[i];
@if (candidate.FirstChapterNumber.HasValue && (i == 0 || candidate.FirstChapterNumber != relationshipCandidates[i - 1].FirstChapterNumber))
{
<h2 class="h5 mt-3">Chapter @candidate.FirstChapterNumber.Value.ToString("0.##")</h2>
}
<details class="story-character-card" open data-relationship-card> <details class="story-character-card" open data-relationship-card>
<summary class="story-review-card-header"> <summary class="story-review-card-header">
<span> <span>
<small>@Display(candidate.FirstAppearance)</small>
<strong>@candidate.CharacterAName / @candidate.CharacterBName</strong> <strong>@candidate.CharacterAName / @candidate.CharacterBName</strong>
<small>@candidate.RelationshipType - @candidate.AppearsInScenes.ToString("N0") scene@(candidate.AppearsInScenes == 1 ? string.Empty : "s")</small> <small>@candidate.RelationshipType - @candidate.AppearsInScenes.ToString("N0") scene@(candidate.AppearsInScenes == 1 ? string.Empty : "s")</small>
</span> </span>

View File

@ -0,0 +1,62 @@
using System.Text.Json;
using Dapper;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using PlotLine.Data;
using PlotLine.Services;
var key = "ConnectionStrings__DefaultConnection=";
var connectionString = File.ReadLines("/etc/plotdirector/plotdirector.env").First(x => x.StartsWith(key, StringComparison.Ordinal))[key.Length..];
var factory = new SqlConnectionFactory(new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?> { { "ConnectionStrings:DefaultConnection", connectionString } }).Build());
using var connection = factory.CreateConnection();
connection.Open();
if (connection.Database != "PlotDirector_Development") throw new Exception("Unexpected database");
var targets = (await connection.QueryAsync<(int GenerationID, int ProjectID, int BookID, int UserID)>("""
SELECT g.ReviewCandidateGenerationID GenerationID,g.ProjectID,g.BookID,
(SELECT TOP(1) UserID FROM dbo.ProjectUserAccess WHERE ProjectID=g.ProjectID AND IsActive=1 ORDER BY CASE WHEN AccessRole=N'Owner' THEN 0 ELSE 1 END,UserID) UserID
FROM dbo.StoryIntelligenceReviewCandidateGenerations g
WHERE g.IsCurrent=1 AND g.Status=N'Ready' AND g.Module=N'Relationships' AND EXISTS
(SELECT 1 FROM dbo.StoryIntelligenceReviewCandidates c WHERE c.ReviewCandidateGenerationID=g.ReviewCandidateGenerationID AND c.DecisionStatus=N'Pending')
""")).ToList();
var pipelines = new StoryIntelligencePipelineStateService(new StoryIntelligencePipelineRepository(factory), NullLogger<StoryIntelligencePipelineStateService>.Instance);
var builder = new StoryIntelligenceRelationshipImportService(new StoryIntelligenceResultRepository(factory), new CharacterRepository(factory), new SceneRepository(factory), pipelines, NullLogger<StoryIntelligenceRelationshipImportService>.Instance);
var repository = new StoryIntelligenceReviewCandidateRepository(factory);
foreach (var target in targets)
{
var runs = await pipelines.ListCommittedRunsByBookAsync(target.BookID, target.UserID);
Console.WriteLine($"Committed runs={runs.Count}; Active characters={(await new CharacterRepository(factory).ListCharactersAsync(target.ProjectID)).Count}");
var runRows = await new StoryIntelligenceResultRepository(factory).ListRunsByBookForUserAsync(target.BookID, target.UserID);
Console.WriteLine($"Analysis runs={runRows.Count}");
var batch = new OnboardingStoryIntelligenceBatch
{
ProjectID = target.ProjectID,
BookID = target.BookID,
UserID = target.UserID,
Items = runs.Count > 0
? runs.OrderByDescending(x => x.ChapterNumber).Select(x => new OnboardingStoryIntelligenceBatchItem { ChapterID = x.ChapterID, ChapterNumber = (int)x.ChapterNumber, RunID = x.StoryIntelligenceRunID }).ToList()
: runRows.Where(x => x.ChapterID.HasValue).OrderByDescending(x => x.ChapterNumber).Select(x => new OnboardingStoryIntelligenceBatchItem { ChapterID = x.ChapterID!.Value, ChapterNumber = (int)(x.ChapterNumber ?? 0), RunID = x.StoryIntelligenceRunID }).ToList()
};
// Deliberately reverse input chapters to verify first/last labels are independent of processing order.
var review = await builder.BuildReviewAsync(batch);
var sorted = review.Candidates.OrderBy(x => x.FirstChapterNumber).ThenBy(x => x.FirstSceneNumber).ThenBy(x => x.FirstSceneID).Select(x => x.Key);
if (!review.Candidates.Select(x => x.Key).SequenceEqual(sorted)) throw new Exception("Nonchronological candidates");
Console.WriteLine($"Book={target.BookID}; Candidates={review.Candidates.Count}; Types={string.Join(", ", review.Candidates.GroupBy(x => x.RelationshipType).Select(g => $"{g.Key}:{g.Count()}"))}");
var json = JsonSerializer.Serialize(review.Candidates, new JsonSerializerOptions(JsonSerializerDefaults.Web));
if (args.Contains("--apply"))
{
var previous = new List<PlotLine.Models.StoryIntelligenceReviewCandidateRow>();
for (var pageNumber = 1; ; pageNumber++)
{
var page = await repository.ListPageAsync(target.BookID, "Relationships", pageNumber, 100);
previous.AddRange(page);
if (page.Count < 100) break;
}
var proposedKeys = review.Candidates.Select(x => x.Key).ToHashSet(StringComparer.OrdinalIgnoreCase);
if (previous.Any(x => !proposedKeys.Contains(x.StableKey)))
throw new Exception("Some pending pairs could not be rebuilt. Investigate before refreshing this book.");
var backup = $"/var/tmp/plotdirector-relationship-review-{target.BookID}-{DateTime.UtcNow:yyyyMMddHHmmss}.json";
await File.WriteAllTextAsync(backup, JsonSerializer.Serialize(previous));
Console.WriteLine($"Pending snapshot: {backup}");
await repository.RefreshPendingRelationshipsAsync(target.BookID, target.GenerationID, json);
Console.WriteLine($"Refreshed pending suggestions for book {target.BookID}; author decisions preserved.");
}
}

View File

@ -0,0 +1,15 @@
# Refresh pending relationship reviews
After applying `PlotLine/Sql/196_RelationshipReviewChronologyAndTypes.sql`, rebuild existing pending suggestions using the current classifier and stored scene analysis. This does not call AI, regenerate other modules, or change accepted/rejected decisions. Review-page GETs continue to read persisted pages.
Run on the dev host with permission to read `/etc/plotdirector/plotdirector.env`:
```sh
dotnet build tools/RefreshRelationshipReviews/RefreshRelationshipReviews.csproj
sudo dotnet tools/RefreshRelationshipReviews/bin/Debug/net10.0/RefreshRelationshipReviews.dll
sudo dotnet tools/RefreshRelationshipReviews/bin/Debug/net10.0/RefreshRelationshipReviews.dll --apply
```
The first invocation previews counts. `--apply` saves a snapshot of pending rows in `/var/tmp` before updating them through a stored procedure. The tool refuses databases other than `PlotDirector_Development` and refuses to update a book if any pending pair cannot be rebuilt. The procedure only updates pending rows in the same current generation, protecting decisions made during the refresh. No analysis runs should be active during the refresh.
Chapter input is deliberately reversed to verify that first/last appearance metadata and output chronology do not depend on processing order. New review preparations already use the corrected rules; the tool is only needed for older saved suggestions.

View File

@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup><ProjectReference Include="../../PlotLine/PlotLine.csproj" /></ItemGroup>
</Project>