Review Story Intelligence relationships as directional scene developments
This commit is contained in:
parent
56fc5f5140
commit
0b63bd751e
17
Docs/StoryIntelligence_SceneRelationshipReview.md
Normal file
17
Docs/StoryIntelligence_SceneRelationshipReview.md
Normal file
@ -0,0 +1,17 @@
|
||||
# Scene relationship review correction
|
||||
|
||||
The existing architecture already has the required canonical foundation:
|
||||
|
||||
- `CharacterRelationships` identifies participants, type, permanence, start/end scenes, initial state and reciprocity. Character A -> Character B is directional when reciprocity is false.
|
||||
- `RelationshipEvents` records scene, state, intensity, awareness and description. Scene Inspector and character history already consume these events; relationship summaries derive current state from history.
|
||||
- Stored scene results preserve relationship JSON. The persisted review candidate system supplies paging and independent author decisions.
|
||||
|
||||
The old Story Intelligence importer grouped every scene by an unordered character pair, promoted the strongest whole-book classification, forced reciprocity and awareness, converted confidence to intensity, and created filtered events only after the user accepted the pair. Review exposed neither event states nor direction. Additionally, substring matching treated distrust as friendly trust.
|
||||
|
||||
The corrected flow emits individual scene proposals with factual/state/development kind, scene state, direction, awareness, emotional intensity, development and evidence. Review permits editing these and reversing direction. Meaningful developments share an existing matching relationship track; the default changing-state track is Other, while factual ties use their factual type and permanence. Opposite directions and factual ties remain separate. Multiple developments within a scene retain separate identities. Repeated factual mentions yield only the earliest proposal.
|
||||
|
||||
Migration `197_SceneRelationshipDevelopments.sql` adds an optional provenance key to existing events and a transactional acceptance procedure. Retries cannot duplicate or overwrite an accepted event. New relationships have no fabricated initial emotional state. Accepting an earlier scene moves a newly established track's start backwards within the book. Existing relationships and events are preserved; no competing history tables are introduced. The existing event deletion/merge infrastructure remains applicable.
|
||||
|
||||
Pending legacy pair cards can be rebuilt with `tools/RefreshRelationshipReviews --apply`. The tool snapshots pending cards before replacement; the stored procedure preserves accepted/ignored decisions, including old pair decisions. Completed reviews remain completed. Old analysis lacks explicit direction/intensity; proposals are marked for direction review, unknown intensity stays empty, and no mutual feeling or awareness is assumed. This conversion uses stored evidence, not another AI pass. New analysis supplies the richer fields directly.
|
||||
|
||||
Validation covers scene review and acceptance with unrelated character fixtures, factual deduplication, independent directional identity, confidence/intensity separation, whole-page input validation, numeric chronology, SQL retry behaviour, distinct factual/directional tracks and preserved decisions. SQL regression fixtures roll back all data.
|
||||
@ -19,6 +19,7 @@ using PlotLine.ViewModels;
|
||||
|
||||
var tests = new (string Name, Action Test)[]
|
||||
{
|
||||
("Scene relationship proposals preserve direction and validate acceptance", RelationshipDevelopmentTests.SceneReviewAndAcceptance),
|
||||
("Location and asset merges require confirmation and distinct records", EntityMergeTests.ConfirmationAndDistinctRecordsAreRequired),
|
||||
("Location persisted analysis preserves contextual bedrooms and setting provenance", LocationSignificanceTests.PersistedAnalysisKeepsContextAndParentEvidence),
|
||||
("Location consequence outranks capped recurrence", LocationSignificanceTests.ConsequenceOutranksFrequency),
|
||||
@ -3409,7 +3410,7 @@ static void RelationshipEventsPreserveChronologicalStoryOrder()
|
||||
|
||||
static void RelationshipProgressionKeepsChronologicalState()
|
||||
{
|
||||
Assert(RelationshipPairKey(501, 702) == RelationshipPairKey(702, 501), "Relationship identity should be one canonical non-directional CharacterID pair.");
|
||||
Assert(RelationshipPairKey(501, 702) == RelationshipPairKey(702, 501), "Reciprocal pair helper should normalize participant order.");
|
||||
var events = new (int SceneID, decimal ChapterNumber, decimal SceneNumber, int SortOrder, string StateName)[]
|
||||
{
|
||||
(3, 1m, 3m, 30, RelationshipStateNameFromSignal("friendship is clearly established")),
|
||||
@ -3418,6 +3419,7 @@ static void RelationshipProgressionKeepsChronologicalState()
|
||||
(6, 1m, 6m, 60, RelationshipStateNameFromSignal("Rob proposes and Maggie accepts the proposal"))
|
||||
};
|
||||
|
||||
Assert(RelationshipStateNameFromSignal("mistrust") == "Distrustful" && RelationshipStateNameFromSignal("distrust") == "Distrustful", "Mistrust must not match trust and become friendly.");
|
||||
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, 4) == "Unknown", "Attraction alone must not establish a romantic relationship.");
|
||||
|
||||
93
PlotLine.Tests/RelationshipDevelopmentTests.cs
Normal file
93
PlotLine.Tests/RelationshipDevelopmentTests.cs
Normal file
@ -0,0 +1,93 @@
|
||||
using System.Reflection;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using PlotLine.Data;
|
||||
using PlotLine.Models;
|
||||
using PlotLine.Services;
|
||||
using PlotLine.ViewModels;
|
||||
|
||||
internal static class RelationshipDevelopmentTests
|
||||
{
|
||||
public static void SceneReviewAndAcceptance() => Run().GetAwaiter().GetResult();
|
||||
private static async Task Run()
|
||||
{
|
||||
foreach (var names in new[] { ("Rob", "Maggie"), ("Iris", "Jon") })
|
||||
{
|
||||
var accepted = new List<RelationshipDevelopmentAcceptance>();
|
||||
var lookups = new CharacterLookupData
|
||||
{
|
||||
RelationshipTypes = [new() { RelationshipTypeID=1,TypeName="Other" },new() { RelationshipTypeID=2,TypeName="Sibling" }],
|
||||
RelationshipStates = [new() { RelationshipStateID=1,StateName="Unknown" },new() { RelationshipStateID=2,StateName="Friendly" },new() { RelationshipStateID=3,StateName="Distrustful" },new() { RelationshipStateID=4,StateName="Romantic" }]
|
||||
};
|
||||
var characters = Stub<ICharacterRepository>((m,a) => m switch
|
||||
{
|
||||
"ListCharactersAsync" => Task.FromResult<IReadOnlyList<Character>>([new() { CharacterID=1, CharacterName=names.Item1 },new() { CharacterID=2,CharacterName=names.Item2 }]),
|
||||
"ListAliasesAsync" => Task.FromResult<IReadOnlyList<CharacterAlias>>([]),
|
||||
"ListRelationshipsByProjectAsync" => Task.FromResult<IReadOnlyList<CharacterRelationship>>([]),
|
||||
"ListRelationshipEventsBySceneAsync" => Task.FromResult<IReadOnlyList<RelationshipEvent>>([]),
|
||||
"GetLookupsAsync" => Task.FromResult(lookups),
|
||||
"AcceptRelationshipDevelopmentAsync" => Save((RelationshipDevelopmentAcceptance)a![0]!),
|
||||
_ => throw new Exception(m)
|
||||
});
|
||||
Task<int> Save(RelationshipDevelopmentAcceptance p) { accepted.Add(p); return Task.FromResult(77); }
|
||||
SceneIntelligenceRelationship Proposal(string kind,string state,bool mutual,string evidence,int? intensity=null) => new()
|
||||
{
|
||||
CharacterA=names.Item1,CharacterB=names.Item2,ProposalKind=kind,RelationshipState=state,
|
||||
IsReciprocal=mutual,IsKnownToOtherCharacter=false,Intensity=intensity,Confidence=.95m,
|
||||
Development=evidence,Evidence=evidence,RelationshipSignal="affection",FactualRelationshipType=kind=="Factual"?"Sibling":null
|
||||
};
|
||||
var results = new List<StoryIntelligenceSavedSceneResult>
|
||||
{
|
||||
Result(30,[Proposal("Development","Romantic",false,"A privately develops feelings for B",3),Proposal("Factual","Unknown",true,"They are siblings")]),
|
||||
Result(10,[Proposal("Development","Friendly",true,"They help each other escape",6),Proposal("Factual","Unknown",true,"They are siblings")]),
|
||||
Result(20,[Proposal("Development","Distrustful",false,"A discovers B has lied"),new() { CharacterA=names.Item1,CharacterB=names.Item2,RelationshipSignal="trust",Evidence="They merely appear together with no interaction" }])
|
||||
};
|
||||
var runs=Stub<IStoryIntelligenceResultRepository>((m,a)=>m switch
|
||||
{
|
||||
"GetRunAsync"=>Task.FromResult<StoryIntelligenceSavedRun?>(new() { Status=StoryIntelligenceRunStatuses.Completed }),
|
||||
"ListSceneResultsAsync"=>Task.FromResult<IReadOnlyList<StoryIntelligenceSavedSceneResult>>(results),
|
||||
_=>throw new Exception(m)
|
||||
});
|
||||
var scenes=Stub<ISceneRepository>((m,a)=> Task.FromResult<IReadOnlyList<Scene>>([
|
||||
new() { SceneID=30,SceneNumber=3,ChapterNumber=1 },new() { SceneID=10,SceneNumber=1,ChapterNumber=1 },new() { SceneID=20,SceneNumber=2,ChapterNumber=1 }]));
|
||||
var pipeline=Stub<IStoryIntelligencePipelineStateService>((m,a)=>Task.FromResult<StoryIntelligenceBookPipelineState?>(null));
|
||||
var service=new StoryIntelligenceRelationshipImportService(runs,characters,scenes,pipeline,NullLogger<StoryIntelligenceRelationshipImportService>.Instance);
|
||||
var batch=new OnboardingStoryIntelligenceBatch { ProjectID=1,BookID=2,UserID=3,Items=[new() { RunID=1,ChapterID=1 }] };
|
||||
var review=await service.BuildReviewAsync(batch);
|
||||
Check(review.Candidates.Count==4,"Meaningful proposals must stay separate by scene, with co-presence excluded.");
|
||||
Check(review.Candidates.Select(x=>x.FirstSceneID).SequenceEqual(new int?[]{10,10,20,30}),"Review must follow scene order.");
|
||||
Check(review.Candidates.Last().IsReciprocal==false && review.Candidates.Last().RelationshipState=="Romantic","One-sided feelings must not become mutual.");
|
||||
Check(review.Candidates.First(x=>x.ProposalKind=="Factual").RelationshipType=="Sibling","Factual ties must stay separate.");
|
||||
var form=new StoryIntelligenceRelationshipImportForm { Relationships=review.Candidates.Select(x=>new StoryIntelligenceRelationshipImportChoiceForm
|
||||
{ Key=x.Key,RelationshipTypeID=x.RelationshipTypeID,RelationshipStateID=x.RelationshipStateID,IsReciprocal=x.IsReciprocal,
|
||||
IsKnownToOtherCharacter=x.IsKnownToOtherCharacter,Intensity=x.Intensity,Development=x.Development }).ToList() };
|
||||
form.Relationships.Last().ReverseDirection=true;
|
||||
var result=await service.ImportAsync(batch,form,false);
|
||||
Check(result.Success && accepted.Count==4,"Acceptance must save all scene proposals.");
|
||||
Check(accepted.Last().CharacterAID==2 && accepted.Last().CharacterBID==1 && !accepted.Last().IsReciprocal,"Direction correction must reach persistence.");
|
||||
Check(accepted.Last().Intensity==3 && accepted.Any(x=>x.Intensity is null),"Confidence must never become intensity.");
|
||||
Check(accepted.Single(x=>x.IsPermanent).RelationshipTypeID==2,"Only factual ties should be permanent.");
|
||||
Check(form.Relationships.All(x=>x.AcceptedRelationshipID==77),"Review decisions must reference the canonical relationship ID, not the type ID.");
|
||||
accepted.Clear();
|
||||
form.Relationships.Last().RelationshipStateID=999;
|
||||
Check(!(await service.ImportAsync(batch,form,false)).Success && accepted.Count==0,"Invalid later rows must prevent all writes.");
|
||||
}
|
||||
Check(Key(10,1,2,false)!=Key(10,2,1,false),"Directional keys must distinguish reverse feelings.");
|
||||
Check(Key(10,1,2,true)==Key(10,2,1,true),"Mutual duplicate proposals must share identity.");
|
||||
Check(Key(10,1,2,false)!=Key(20,1,2,false),"Later scenes must retain independent identity.");
|
||||
}
|
||||
private static string Key(int scene,int a,int b,bool reciprocal) => (string)typeof(StoryIntelligenceRelationshipImportService).GetMethod("DevelopmentKey",BindingFlags.NonPublic|BindingFlags.Static)!.Invoke(null,[scene,a,b,reciprocal,"Development","trust","Evidence"])!;
|
||||
private static StoryIntelligenceSavedSceneResult Result(int scene,List<SceneIntelligenceRelationship> proposals) => new() { SceneID=scene,ParsedJson=JsonSerializer.Serialize(new SceneIntelligenceScene { SchemaVersion="1.0",Relationships=proposals }) };
|
||||
private static void Check(bool value,string message) { if(!value)throw new Exception(message); }
|
||||
private static T Stub<T>(Func<string,object?[]?,object?> handler) where T:class
|
||||
{
|
||||
var proxy=DispatchProxy.Create<T,RelationshipTestProxy>();
|
||||
((RelationshipTestProxy)(object)proxy).Handler=handler;
|
||||
return proxy;
|
||||
}
|
||||
}
|
||||
public class RelationshipTestProxy:DispatchProxy
|
||||
{
|
||||
public Func<string,object?[]?,object?> Handler { get; set; }=null!;
|
||||
protected override object? Invoke(MethodInfo? method,object?[]? args)=>Handler(method!.Name,args);
|
||||
}
|
||||
44
PlotLine.Tests/Sql/RelationshipDevelopmentAcceptance.sql
Normal file
44
PlotLine.Tests/Sql/RelationshipDevelopmentAcceptance.sql
Normal file
@ -0,0 +1,44 @@
|
||||
SET XACT_ABORT ON;
|
||||
BEGIN TRY
|
||||
BEGIN TRANSACTION;
|
||||
INSERT dbo.Projects(ProjectName) VALUES(N'Relationship development regression');
|
||||
DECLARE @Project int=SCOPE_IDENTITY();
|
||||
INSERT dbo.Books(ProjectID,BookTitle,BookNumber,SortOrder) VALUES(@Project,N'Regression',1,1);
|
||||
DECLARE @Book int=SCOPE_IDENTITY(),@Revision int=(SELECT TOP(1) RevisionStatusID FROM dbo.RevisionStatuses),
|
||||
@TimeMode int=(SELECT TOP(1) TimeModeID FROM dbo.TimeModes),@TimeConfidence int=(SELECT TOP(1) TimeConfidenceID FROM dbo.TimeConfidences);
|
||||
INSERT dbo.Chapters(BookID,ChapterNumber,ChapterTitle,SortOrder,RevisionStatusID) VALUES(@Book,1,N'Chapter',1,@Revision);
|
||||
DECLARE @Chapter int=SCOPE_IDENTITY();
|
||||
INSERT dbo.Scenes(ChapterID,SceneNumber,SceneTitle,SortOrder,TimeModeID,TimeConfidenceID,RevisionStatusID)
|
||||
VALUES(@Chapter,1,N'Early',1,@TimeMode,@TimeConfidence,@Revision);
|
||||
DECLARE @Early int=SCOPE_IDENTITY();
|
||||
INSERT dbo.Scenes(ChapterID,SceneNumber,SceneTitle,SortOrder,TimeModeID,TimeConfidenceID,RevisionStatusID)
|
||||
VALUES(@Chapter,2,N'Later',2,@TimeMode,@TimeConfidence,@Revision);
|
||||
DECLARE @Later int=SCOPE_IDENTITY();
|
||||
INSERT dbo.Characters(ProjectID,CharacterName) VALUES(@Project,N'Iris');
|
||||
DECLARE @A int=SCOPE_IDENTITY();
|
||||
INSERT dbo.Characters(ProjectID,CharacterName) VALUES(@Project,N'Jon');
|
||||
DECLARE @B int=SCOPE_IDENTITY();
|
||||
DECLARE @Other int=(SELECT RelationshipTypeID FROM dbo.RelationshipTypes WHERE TypeName=N'Other'),
|
||||
@Sibling int=(SELECT RelationshipTypeID FROM dbo.RelationshipTypes WHERE TypeName=N'Sibling'),
|
||||
@Friendly int=(SELECT RelationshipStateID FROM dbo.RelationshipStates WHERE StateName=N'Friendly'),
|
||||
@Distrust int=(SELECT RelationshipStateID FROM dbo.RelationshipStates WHERE StateName=N'Distrustful');
|
||||
EXEC dbo.StoryIntelligenceRelationship_AcceptDevelopment @Project,@Book,@Later,N'regression:later',@A,@B,@Other,@Distrust,0,0,0,NULL,N'Iris discovers the deception.';
|
||||
EXEC dbo.StoryIntelligenceRelationship_AcceptDevelopment @Project,@Book,@Early,N'regression:early',@A,@B,@Other,@Friendly,0,0,0,3,N'Iris confides in Jon.';
|
||||
-- Repeated acceptance must not duplicate or overwrite the original event.
|
||||
EXEC dbo.StoryIntelligenceRelationship_AcceptDevelopment @Project,@Book,@Early,N'regression:early',@A,@B,@Other,@Distrust,0,0,1,9,N'Changed retry';
|
||||
IF (SELECT COUNT(*) FROM dbo.CharacterRelationships WHERE ProjectID=@Project)<>1 THROW 51001,'Scene states were split into competing tracks.',1;
|
||||
IF (SELECT COUNT(*) FROM dbo.RelationshipEvents WHERE SceneID IN(@Early,@Later))<>2 THROW 51001,'Retry duplicated an event.',1;
|
||||
IF NOT EXISTS(SELECT 1 FROM dbo.CharacterRelationships WHERE ProjectID=@Project AND StartSceneID=@Early AND InitialRelationshipStateID IS NULL) THROW 51001,'Earlier acceptance must move start without projecting a later state backwards.',1;
|
||||
IF NOT EXISTS(SELECT 1 FROM dbo.RelationshipEvents WHERE SceneID=@Early AND Intensity=3 AND IsKnownToOtherCharacter=0 AND RelationshipStateID=@Friendly) THROW 51001,'Retry overwrote accepted evidence.',1;
|
||||
EXEC dbo.StoryIntelligenceRelationship_AcceptDevelopment @Project,@Book,@Early,N'regression:reverse',@B,@A,@Other,@Distrust,0,0,0,NULL,N'Jon distrusts Iris.';
|
||||
EXEC dbo.StoryIntelligenceRelationship_AcceptDevelopment @Project,@Book,@Early,N'regression:factual',@A,@B,@Sibling,@Friendly,1,1,1,NULL,N'They reveal they are siblings.';
|
||||
IF (SELECT COUNT(*) FROM dbo.CharacterRelationships WHERE ProjectID=@Project)<>3 THROW 51001,'Reverse and factual relationships must remain separate.',1;
|
||||
IF NOT EXISTS(SELECT 1 FROM dbo.RelationshipEvents e JOIN dbo.CharacterRelationships r ON r.CharacterRelationshipID=e.CharacterRelationshipID
|
||||
WHERE r.ProjectID=@Project AND r.CharacterAID=@B AND r.CharacterBID=@A AND r.IsReciprocal=0 AND e.RelationshipStateID=@Distrust) THROW 51001,'Reverse state lost.',1;
|
||||
ROLLBACK TRANSACTION;
|
||||
SELECT N'PASS: relationship acceptance, retry, direction, factual ties, chronology and null intensity; rolled back' Result;
|
||||
END TRY
|
||||
BEGIN CATCH
|
||||
IF XACT_STATE()<>0 ROLLBACK TRANSACTION;
|
||||
THROW;
|
||||
END CATCH;
|
||||
@ -615,6 +615,11 @@ public sealed class OnboardingController(
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> ImportStoryIntelligenceRelationships(StoryIntelligenceRelationshipImportForm form)
|
||||
{
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
TempData["OnboardingStoryIntelligenceError"] = "Check relationship values before saving. Intensity must be a whole number from 1 to 10, or blank.";
|
||||
return RedirectToAction(nameof(StoryIntelligenceRelationships), new { batchId = form.BatchID });
|
||||
}
|
||||
var (progress, result) = await storyIntelligence.ImportRelationshipsAsync(form.BatchID, form);
|
||||
if (progress is null)
|
||||
{
|
||||
|
||||
@ -250,6 +250,7 @@ public interface ICharacterRepository
|
||||
Task<IReadOnlyList<CharacterRelationship>> ListInitialRelationshipsByCharacterAsync(int characterId);
|
||||
Task<CharacterRelationship?> GetRelationshipAsync(int characterRelationshipId);
|
||||
Task<int> SaveRelationshipAsync(CharacterRelationship relationship);
|
||||
Task<int> AcceptRelationshipDevelopmentAsync(RelationshipDevelopmentAcceptance proposal);
|
||||
Task ArchiveRelationshipAsync(int characterRelationshipId);
|
||||
Task<IReadOnlyList<RelationshipEvent>> ListRelationshipEventsBySceneAsync(int sceneId);
|
||||
Task<IReadOnlyList<RelationshipEvent>> ListRelationshipEventsByCharacterAsync(int characterId);
|
||||
@ -4126,6 +4127,13 @@ public sealed class CharacterRepository(ISqlConnectionFactory connectionFactory)
|
||||
commandType: CommandType.StoredProcedure);
|
||||
}
|
||||
|
||||
public async Task<int> AcceptRelationshipDevelopmentAsync(RelationshipDevelopmentAcceptance proposal)
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
return await connection.QuerySingleAsync<int>("dbo.StoryIntelligenceRelationship_AcceptDevelopment", proposal,
|
||||
commandType: CommandType.StoredProcedure);
|
||||
}
|
||||
|
||||
public async Task<int> SaveRelationshipAsync(CharacterRelationship relationship)
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
|
||||
@ -556,6 +556,13 @@ The root object must not include scene-level thread candidates or continuity war
|
||||
{
|
||||
"characterA": "Mara",
|
||||
"characterB": "Elias",
|
||||
"proposalKind": "Development",
|
||||
"factualRelationshipType": null,
|
||||
"relationshipState": "Distrustful",
|
||||
"isReciprocal": false,
|
||||
"isKnownToOtherCharacter": true,
|
||||
"intensity": 5,
|
||||
"development": "Mara confronts Elias and starts doubting him.",
|
||||
"relationshipSignal": "mistrust",
|
||||
"evidence": "Mara questions Elias about what he is hiding.",
|
||||
"confidence": 0.78
|
||||
@ -563,7 +570,7 @@ The root object must not include scene-level thread candidates or continuity war
|
||||
]
|
||||
```
|
||||
|
||||
- Notes: These are evidence-backed scene-local signals, not final relationship records. Neutral signal terms include `trust`, `mistrust`, `affection`, `fear`, `authority`, `secrecy`, `conflict`, `dependence`, `protection`, `rivalry` and `obligation`.
|
||||
- Notes: These are scene proposals for author review. Proposal kind separates durable factual ties from newly revealed states and meaningful developments. A is the actor/feeling-holder and B the recipient; reciprocity and awareness require evidence. Intensity is emotional strength, never confidence. Omit routine repetition and co-presence. Legacy results may omit the new fields; review flags their missing direction. Neutral signal terms include `trust`, `mistrust`, `affection`, `fear`, `authority`, `secrecy`, `conflict`, `dependence`, `protection`, `rivalry` and `obligation`.
|
||||
|
||||
### `knowledgeChanges`
|
||||
|
||||
|
||||
@ -270,9 +270,13 @@ These are scene-local signals for later Relationship Intelligence. They are not
|
||||
|
||||
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 proposal include `characterA`, `characterB`, `proposalKind`, `factualRelationshipType`, `relationshipState`, `isReciprocal`, `isKnownToOtherCharacter`, `intensity`, `development`, `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`.
|
||||
Analyse only the current scene. Propose a meaningful interaction, newly revealed state, or change, never an event solely for co-presence or routine repetition. `proposalKind` is Factual for durable kinship facts, State for a meaningful newly revealed state, or Development for an interaction/change. Keep factual ties separate from emotional states: siblings can become distrustful. `factualRelationshipType` is null except for factual ties (e.g. Parent, Child, Sibling).
|
||||
|
||||
Character A is the person whose feeling/action is directed towards character B. `isReciprocal` is true only when evidence establishes the same state in both directions. A may feel attraction while B feels nothing; do not infer mutual romance. Use separate proposals when opposite directions differ. Parent means A is B's parent; Child means A is B's child. Sibling is reciprocal. `isKnownToOtherCharacter` is true only if B knows A's feeling/state.
|
||||
|
||||
`relationshipState` describes this scene's resulting state, using Unknown, Positive, Neutral, Negative, Tense, Friendly, Romantic, Sexual Tension, In Love, Distrustful, Hostile, Protective, Estranged, Broken, or Reconciled when supported. Attraction can be unilateral and need not mean an established couple. `intensity` is emotional strength 1–10, or null if unsupported; it is never confidence. `development` describes the meaningful interaction/change and direction; `evidence` cites scene text. Confidence measures certainty only. Do not project later developments into earlier scenes. Multiple meaningful developments in one scene may be separate proposals.
|
||||
|
||||
## Knowledge Change Rules
|
||||
|
||||
|
||||
@ -322,11 +322,13 @@ These are scene-local signals for later Relationship Intelligence. They are not
|
||||
|
||||
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 proposal include `characterA`, `characterB`, `proposalKind`, `factualRelationshipType`, `relationshipState`, `isReciprocal`, `isKnownToOtherCharacter`, `intensity`, `development`, `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`.
|
||||
Analyse only the current scene. Propose a meaningful interaction, newly revealed state, or change, never an event solely for co-presence or routine repetition. `proposalKind` is Factual for durable kinship facts, State for a meaningful newly revealed state, or Development for an interaction/change. Keep factual ties separate from emotional states: siblings can become distrustful. `factualRelationshipType` is null except for factual ties (e.g. Parent, Child, Sibling).
|
||||
|
||||
Preserve relationship progression evidence without flattening it. Repeated trust, hugs, gifts, shared social plans and familiar support can be friendship evidence. Romantic attraction, courtship, proposal or engagement needs explicit romantic evidence. Do not mark an early scene as a final state merely because adjacent or later context suggests a later progression.
|
||||
Character A is the person whose feeling/action is directed towards character B. `isReciprocal` is true only when evidence establishes the same state in both directions. A may feel attraction while B feels nothing; do not infer mutual romance. Use separate proposals when opposite directions differ. Parent means A is B's parent; Child means A is B's child. Sibling is reciprocal. `isKnownToOtherCharacter` is true only if B knows A's feeling/state.
|
||||
|
||||
`relationshipState` describes this scene's resulting state, using Unknown, Positive, Neutral, Negative, Tense, Friendly, Romantic, Sexual Tension, In Love, Distrustful, Hostile, Protective, Estranged, Broken, or Reconciled when supported. Attraction can be unilateral and need not mean an established couple. `intensity` is emotional strength 1–10, or null if unsupported; it is never confidence. `development` describes the meaningful interaction/change and direction; `evidence` cites scene text. Confidence measures certainty only. Do not project later developments into earlier scenes. Multiple meaningful developments in one scene may be separate proposals.
|
||||
|
||||
## Knowledge Change Rules
|
||||
|
||||
|
||||
@ -2665,3 +2665,20 @@ public sealed class LookupData
|
||||
public IReadOnlyList<ScenePurposeType> ScenePurposeTypes { get; init; } = [];
|
||||
public IReadOnlyList<ChapterPurposeType> ChapterPurposeTypes { get; init; } = [];
|
||||
}
|
||||
|
||||
public sealed class RelationshipDevelopmentAcceptance
|
||||
{
|
||||
public int ProjectID { get; set; }
|
||||
public int BookID { get; set; }
|
||||
public int SceneID { get; set; }
|
||||
public string SourceReviewKey { get; set; } = string.Empty;
|
||||
public int CharacterAID { get; set; }
|
||||
public int CharacterBID { get; set; }
|
||||
public int RelationshipTypeID { get; set; }
|
||||
public int RelationshipStateID { get; set; }
|
||||
public bool IsPermanent { get; set; }
|
||||
public bool IsReciprocal { get; set; }
|
||||
public bool IsKnownToOtherCharacter { get; set; }
|
||||
public int? Intensity { get; set; }
|
||||
public string Description { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
@ -165,6 +165,13 @@ public sealed class SceneIntelligenceRelationship
|
||||
{
|
||||
public string? CharacterA { get; init; }
|
||||
public string? CharacterB { get; init; }
|
||||
public string? ProposalKind { get; init; }
|
||||
public string? FactualRelationshipType { get; init; }
|
||||
public string? RelationshipState { get; init; }
|
||||
public bool? IsReciprocal { get; init; }
|
||||
public bool? IsKnownToOtherCharacter { get; init; }
|
||||
public int? Intensity { get; init; }
|
||||
public string? Development { get; init; }
|
||||
public string? RelationshipSignal { get; init; }
|
||||
public string? Evidence { get; init; }
|
||||
public decimal? Confidence { get; init; }
|
||||
|
||||
@ -55,10 +55,19 @@ public sealed class StoryIntelligenceRelationshipImportService(
|
||||
AlreadyLinkedCount = data.AlreadyLinkedCount,
|
||||
IsComplete = data.HasCommittedScenes && visibleCandidates.Count == 0,
|
||||
RelationshipTypeOptions = typeOptions,
|
||||
RelationshipStateOptions = lookupData.RelationshipStates.Select(x => new SelectListItem(x.StateName, x.RelationshipStateID.ToString())).ToList(),
|
||||
Candidates = visibleCandidates.Select(candidate => new StoryIntelligenceRelationshipReviewCandidateViewModel
|
||||
{
|
||||
Key = candidate.Key,
|
||||
ReviewRulesVersion = 2,
|
||||
ReviewRulesVersion = 3,
|
||||
ProposalKind = candidate.ProposalKind,
|
||||
RelationshipState = candidate.RelationshipState,
|
||||
RelationshipStateID = MatchRelationshipState(lookupData.RelationshipStates, candidate.RelationshipState),
|
||||
IsReciprocal = candidate.IsReciprocal,
|
||||
IsKnownToOtherCharacter = candidate.IsKnownToOtherCharacter,
|
||||
Intensity = candidate.Intensity,
|
||||
Development = candidate.Development,
|
||||
NeedsDirectionReview = candidate.NeedsDirectionReview,
|
||||
FirstChapterNumber = CandidateFirstStoryOrder(candidate).ChapterNumber,
|
||||
FirstSceneNumber = CandidateFirstStoryOrder(candidate).SceneNumber,
|
||||
FirstSceneID = CandidateFirstStoryOrder(candidate).SceneID,
|
||||
@ -86,190 +95,61 @@ public sealed class StoryIntelligenceRelationshipImportService(
|
||||
return new StoryIntelligenceImportCommitResult { Success = false, Message = "Complete Story Intelligence analysis before reviewing relationships." };
|
||||
}
|
||||
|
||||
var choices = form.Relationships
|
||||
.Where(choice => !string.IsNullOrWhiteSpace(choice.Key))
|
||||
.ToDictionary(choice => choice.Key, choice => choice, StringComparer.OrdinalIgnoreCase);
|
||||
SaveRelationshipSelections(batch, choices.Values);
|
||||
if (choices.Count == 0)
|
||||
{
|
||||
if (data.Candidates.Count == 0)
|
||||
{
|
||||
batch.RelationshipStageComplete = completeReview;
|
||||
batch.LastRelationshipImportResult = new StoryIntelligenceRelationshipImportBatchResult();
|
||||
return new StoryIntelligenceImportCommitResult
|
||||
{
|
||||
Success = true,
|
||||
Message = "Relationship review completed. No relationship decisions were waiting."
|
||||
};
|
||||
}
|
||||
var choices = form.Relationships;
|
||||
var candidates = data.Candidates.ToDictionary(x => x.Key, StringComparer.OrdinalIgnoreCase);
|
||||
var lookups = await characters.GetLookupsAsync(batch.ProjectID);
|
||||
// Validate the whole page before making any canonical writes.
|
||||
if (choices.Select(x => x.Key).Distinct(StringComparer.OrdinalIgnoreCase).Count() != choices.Count
|
||||
|| choices.Any(x => !candidates.ContainsKey(x.Key)
|
||||
|| x.Action is not (StoryIntelligenceRelationshipImportActions.CreateNew or StoryIntelligenceRelationshipImportActions.LinkExisting or StoryIntelligenceRelationshipImportActions.Ignore)
|
||||
|| (x.Action != StoryIntelligenceRelationshipImportActions.Ignore
|
||||
&& (!lookups.RelationshipTypes.Any(t => t.RelationshipTypeID == x.RelationshipTypeID)
|
||||
|| !lookups.RelationshipStates.Any(t => t.RelationshipStateID == x.RelationshipStateID)
|
||||
|| x.Intensity is < 1 or > 10
|
||||
|| string.IsNullOrWhiteSpace(x.Development)))))
|
||||
return new() { Success = false, Message = "The proposals changed or a value is invalid. Reload review and check the state, type and description before saving." };
|
||||
|
||||
return new StoryIntelligenceImportCommitResult { Success = false, Message = "Choose at least one relationship to create, link or ignore." };
|
||||
}
|
||||
|
||||
var lookupData = await characters.GetLookupsAsync(batch.ProjectID);
|
||||
var relationshipTypesById = lookupData.RelationshipTypes.ToDictionary(type => type.RelationshipTypeID);
|
||||
var created = 0;
|
||||
var linkedExisting = 0;
|
||||
var merged = 0;
|
||||
var accepted = 0;
|
||||
var ignored = 0;
|
||||
var eventsCreated = 0;
|
||||
var resolvedRelationships = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
||||
var candidatesByKey = data.Candidates.ToDictionary(candidate => candidate.Key, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var candidate in data.Candidates)
|
||||
foreach (var choice in choices)
|
||||
{
|
||||
if (!choices.TryGetValue(candidate.Key, out var choice))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (string.Equals(choice.Action, StoryIntelligenceRelationshipImportActions.Ignore, StringComparison.OrdinalIgnoreCase))
|
||||
choice.AcceptedRelationshipID = null;
|
||||
var candidate = candidates[choice.Key];
|
||||
if (choice.Action == StoryIntelligenceRelationshipImportActions.Ignore)
|
||||
{
|
||||
ignored++;
|
||||
AddDecision(batch, candidate.Key, StoryIntelligenceRelationshipImportActions.Ignore, null, RelationshipLabel(candidate), false);
|
||||
AddDecision(batch, candidate.Key, choice.Action, null, RelationshipLabel(candidate), false);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (string.Equals(choice.Action, StoryIntelligenceRelationshipImportActions.Alias, StringComparison.OrdinalIgnoreCase))
|
||||
var appearance = candidate.Appearances.Single();
|
||||
var id = await characters.AcceptRelationshipDevelopmentAsync(new RelationshipDevelopmentAcceptance
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var relationshipId = string.Equals(choice.Action, StoryIntelligenceRelationshipImportActions.LinkExisting, StringComparison.OrdinalIgnoreCase)
|
||||
? candidate.ExistingRelationshipID
|
||||
: null;
|
||||
|
||||
if (!relationshipId.HasValue)
|
||||
{
|
||||
if (!choice.RelationshipTypeID.HasValue || !relationshipTypesById.ContainsKey(choice.RelationshipTypeID.Value))
|
||||
{
|
||||
return new StoryIntelligenceImportCommitResult { Success = false, Message = "Choose a valid relationship type for each relationship you want to create." };
|
||||
}
|
||||
|
||||
relationshipId = await characters.SaveRelationshipAsync(new CharacterRelationship
|
||||
{
|
||||
ProjectID = batch.ProjectID,
|
||||
CharacterAID = candidate.CharacterAID,
|
||||
CharacterBID = candidate.CharacterBID,
|
||||
RelationshipTypeID = choice.RelationshipTypeID.Value,
|
||||
IsPermanent = false,
|
||||
StartSceneID = candidate.Appearances.OrderBy(StoryOrder).FirstOrDefault()?.SceneID,
|
||||
EndSceneID = null,
|
||||
IsKnownToReader = true,
|
||||
Notes = BuildRelationshipNotes(candidate),
|
||||
IsInitialRelationship = false,
|
||||
InitialBookID = batch.BookID,
|
||||
ReaderInitiallyKnows = true,
|
||||
InitialRelationshipStateID = MatchRelationshipState(lookupData.RelationshipStates, candidate.RelationshipType),
|
||||
InitialIntensity = ConfidenceIntensity(candidate.Confidence),
|
||||
IsReciprocal = true
|
||||
});
|
||||
created++;
|
||||
}
|
||||
else
|
||||
{
|
||||
linkedExisting++;
|
||||
}
|
||||
|
||||
resolvedRelationships[candidate.Key] = relationshipId.Value;
|
||||
AddDecision(batch, candidate.Key, choice.Action, relationshipId.Value, RelationshipLabel(candidate), true);
|
||||
ProjectID = batch.ProjectID, BookID = batch.BookID, SceneID = appearance.SceneID,
|
||||
SourceReviewKey = candidate.Key,
|
||||
CharacterAID = choice.ReverseDirection ? candidate.CharacterBID : candidate.CharacterAID,
|
||||
CharacterBID = choice.ReverseDirection ? candidate.CharacterAID : candidate.CharacterBID,
|
||||
RelationshipTypeID = choice.RelationshipTypeID!.Value,
|
||||
RelationshipStateID = choice.RelationshipStateID!.Value,
|
||||
IsPermanent = candidate.ProposalKind == "Factual",
|
||||
IsReciprocal = choice.IsReciprocal ?? candidate.IsReciprocal,
|
||||
IsKnownToOtherCharacter = choice.IsKnownToOtherCharacter ?? candidate.IsKnownToOtherCharacter,
|
||||
Intensity = choice.Intensity, Description = choice.Development!.Trim()
|
||||
});
|
||||
choice.AcceptedRelationshipID = id;
|
||||
accepted++;
|
||||
AddDecision(batch, candidate.Key, choice.Action, id, RelationshipLabel(candidate), true);
|
||||
}
|
||||
|
||||
foreach (var candidate in data.Candidates)
|
||||
{
|
||||
if (!choices.TryGetValue(candidate.Key, out var choice)
|
||||
|| !string.Equals(choice.Action, StoryIntelligenceRelationshipImportActions.Alias, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var targetKey = Clean(choice.AliasTargetKey);
|
||||
if (string.IsNullOrWhiteSpace(targetKey)
|
||||
|| string.Equals(targetKey, candidate.Key, StringComparison.OrdinalIgnoreCase)
|
||||
|| !choices.TryGetValue(targetKey, out var targetChoice)
|
||||
|| string.Equals(targetChoice.Action, StoryIntelligenceRelationshipImportActions.Ignore, StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(targetChoice.Action, StoryIntelligenceRelationshipImportActions.Alias, StringComparison.OrdinalIgnoreCase)
|
||||
|| !candidatesByKey.ContainsKey(targetKey)
|
||||
|| !resolvedRelationships.TryGetValue(targetKey, out var targetRelationshipId))
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Story Intelligence relationship merge candidate {CandidateKey} could not be imported because target {TargetKey} was not a resolved create/link target.",
|
||||
candidate.Key,
|
||||
targetKey);
|
||||
continue;
|
||||
}
|
||||
|
||||
resolvedRelationships[candidate.Key] = targetRelationshipId;
|
||||
merged++;
|
||||
AddDecision(batch, candidate.Key, StoryIntelligenceRelationshipImportActions.Alias, targetRelationshipId, RelationshipLabel(candidate), true);
|
||||
}
|
||||
|
||||
foreach (var candidate in data.Candidates)
|
||||
{
|
||||
if (!choices.TryGetValue(candidate.Key, out var choice)
|
||||
|| string.Equals(choice.Action, StoryIntelligenceRelationshipImportActions.Ignore, StringComparison.OrdinalIgnoreCase)
|
||||
|| !resolvedRelationships.TryGetValue(candidate.Key, out var relationshipId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var appearance in candidate.Appearances
|
||||
.Where(appearance => !appearance.AlreadyLinked)
|
||||
.GroupBy(appearance => appearance.SceneID)
|
||||
.Select(group => group.OrderByDescending(appearance => appearance.Confidence ?? 0m).First())
|
||||
.OrderBy(StoryOrder))
|
||||
{
|
||||
if (!HasMeaningfulRelationshipEvent(appearance))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
await characters.SaveRelationshipEventAsync(new RelationshipEvent
|
||||
{
|
||||
CharacterRelationshipID = relationshipId,
|
||||
SceneID = appearance.SceneID,
|
||||
RelationshipStateID = MatchRelationshipState(lookupData.RelationshipStates, appearance.Signal ?? candidate.RelationshipType),
|
||||
Intensity = ConfidenceIntensity(appearance.Confidence ?? candidate.Confidence),
|
||||
IsKnownToOtherCharacter = true,
|
||||
Description = BuildRelationshipEventDescription(candidate, appearance)
|
||||
});
|
||||
eventsCreated++;
|
||||
}
|
||||
}
|
||||
|
||||
batch.RelationshipStageComplete = completeReview;
|
||||
batch.LastRelationshipImportResult = new StoryIntelligenceRelationshipImportBatchResult
|
||||
{
|
||||
RelationshipsCreated = created,
|
||||
RelationshipsLinked = linkedExisting,
|
||||
RelationshipsMerged = merged,
|
||||
RelationshipsIgnored = ignored,
|
||||
RelationshipEventsCreated = eventsCreated
|
||||
};
|
||||
logger.LogInformation(
|
||||
"Imported Story Intelligence relationships for batch {BatchID}. Created={Created} LinkedExisting={LinkedExisting} Merged={Merged} Ignored={Ignored} Events={Events}",
|
||||
batch.BatchID,
|
||||
created,
|
||||
linkedExisting,
|
||||
merged,
|
||||
ignored,
|
||||
eventsCreated);
|
||||
|
||||
return new StoryIntelligenceImportCommitResult
|
||||
{
|
||||
Success = true,
|
||||
ScenesCreated = eventsCreated,
|
||||
Message = $"Relationships imported. {created:N0} created, {linkedExisting:N0} linked, {merged:N0} merged, {ignored:N0} ignored, {eventsCreated:N0} relationship event(s) added."
|
||||
};
|
||||
batch.RelationshipStageComplete = completeReview && choices.Count == data.Candidates.Count;
|
||||
batch.LastRelationshipImportResult = new() { RelationshipEventsCreated = accepted, RelationshipsIgnored = ignored };
|
||||
logger.LogInformation("Accepted {Accepted} relationship developments and ignored {Ignored} for book {BookID}", accepted, ignored, batch.BookID);
|
||||
return new() { Success = true, ScenesCreated = accepted, Message = $"Saved {accepted} scene relationship proposal(s); ignored {ignored}." };
|
||||
}
|
||||
|
||||
private async Task<RelationshipCandidateData> BuildCandidateDataAsync(OnboardingStoryIntelligenceBatch batch)
|
||||
{
|
||||
var characterIndex = await BuildCharacterIndexAsync(batch.ProjectID);
|
||||
var existingRelationships = await characters.ListRelationshipsByProjectAsync(batch.ProjectID);
|
||||
var existingByPair = existingRelationships
|
||||
.GroupBy(relationship => PairKey(relationship.CharacterAID, relationship.CharacterBID))
|
||||
.ToDictionary(group => group.Key, group => group.First(), StringComparer.OrdinalIgnoreCase);
|
||||
var existingByPair = existingRelationships;
|
||||
var groups = new Dictionary<string, RelationshipCandidate>(StringComparer.OrdinalIgnoreCase);
|
||||
var alreadyLinked = 0;
|
||||
var hasCommittedScenes = false;
|
||||
@ -319,7 +199,12 @@ public sealed class StoryIntelligenceRelationshipImportService(
|
||||
}
|
||||
}
|
||||
|
||||
// A repeated durable fact is not a new development on every mention.
|
||||
var candidates = groups.Values
|
||||
.GroupBy(candidate => candidate.ProposalKind == "Factual"
|
||||
? $"fact:{candidate.RelationshipType}:{(candidate.IsReciprocal ? PairKey(candidate.CharacterAID, candidate.CharacterBID) : $"{candidate.CharacterAID}>{candidate.CharacterBID}")}"
|
||||
: candidate.Key, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(group => group.OrderBy(CandidateFirstStoryOrder).First())
|
||||
.Where(candidate => candidate.Appearances.Any(appearance => !appearance.AlreadyLinked))
|
||||
.OrderBy(CandidateFirstStoryOrder)
|
||||
.ThenBy(candidate => candidate.CharacterAName)
|
||||
@ -332,19 +217,21 @@ public sealed class StoryIntelligenceRelationshipImportService(
|
||||
private static void AddSceneRelationships(
|
||||
Dictionary<string, RelationshipCandidate> groups,
|
||||
CharacterIndex characterIndex,
|
||||
IReadOnlyDictionary<string, CharacterRelationship> existingByPair,
|
||||
IReadOnlyList<CharacterRelationship> existingByPair,
|
||||
Scene importedScene,
|
||||
SceneIntelligenceScene parsed,
|
||||
IReadOnlyList<RelationshipEvent> existingEvents)
|
||||
{
|
||||
foreach (var relationship in parsed.Relationships ?? [])
|
||||
{
|
||||
AddRelationship(groups, characterIndex, existingByPair, importedScene, parsed, relationship.CharacterA, relationship.CharacterB, relationship.RelationshipSignal, relationship.Evidence, relationship.Confidence, existingEvents);
|
||||
AddRelationship(groups, characterIndex, existingByPair, importedScene, parsed, relationship.CharacterA, relationship.CharacterB, relationship.RelationshipSignal, relationship.Evidence, relationship.Confidence, existingEvents, relationship);
|
||||
}
|
||||
|
||||
foreach (var observation in parsed.Observations ?? [])
|
||||
{
|
||||
if (!IsRelationshipObservation(observation))
|
||||
if (!IsRelationshipObservation(observation)
|
||||
|| (parsed.Relationships ?? []).Any(r =>
|
||||
PairNamesMatch(r.CharacterA, r.CharacterB, observation.SubjectName, observation.ObjectName)))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@ -368,7 +255,7 @@ public sealed class StoryIntelligenceRelationshipImportService(
|
||||
private static void AddRelationship(
|
||||
Dictionary<string, RelationshipCandidate> groups,
|
||||
CharacterIndex characterIndex,
|
||||
IReadOnlyDictionary<string, CharacterRelationship> existingByPair,
|
||||
IReadOnlyList<CharacterRelationship> existingByPair,
|
||||
Scene importedScene,
|
||||
SceneIntelligenceScene parsed,
|
||||
string? characterAName,
|
||||
@ -376,7 +263,8 @@ public sealed class StoryIntelligenceRelationshipImportService(
|
||||
string? relationshipSignal,
|
||||
string? evidence,
|
||||
decimal? confidence,
|
||||
IReadOnlyList<RelationshipEvent> existingEvents)
|
||||
IReadOnlyList<RelationshipEvent> existingEvents,
|
||||
SceneIntelligenceRelationship? proposal = null)
|
||||
{
|
||||
var characterA = characterIndex.Find(characterAName, importedScene);
|
||||
var characterB = characterIndex.Find(characterBName, importedScene);
|
||||
@ -385,58 +273,52 @@ public sealed class StoryIntelligenceRelationshipImportService(
|
||||
return;
|
||||
}
|
||||
|
||||
var key = PairKey(characterA.CharacterID, characterB.CharacterID);
|
||||
var classification = RelationshipTypeFromEvidence(
|
||||
relationshipSignal,
|
||||
evidence,
|
||||
importedScene.StructuralSummary,
|
||||
characterA.CharacterName,
|
||||
characterB.CharacterName);
|
||||
if (!groups.TryGetValue(key, out var candidate))
|
||||
var classification = RelationshipTypeFromEvidence(relationshipSignal, evidence, null, characterA.CharacterName, characterB.CharacterName);
|
||||
var factual = proposal?.ProposalKind == "Factual"
|
||||
|| (proposal?.ProposalKind is null && classification.Type is "Sibling" or "Parent" or "Child" or "Adoptive Parent" or "Biological Parent");
|
||||
var kind = factual ? "Factual" : proposal?.ProposalKind == "State" ? "State" : "Development";
|
||||
var appearance = new RelationshipAppearanceImport(importedScene.SceneID, importedScene.ChapterNumber,
|
||||
importedScene.SceneNumber, importedScene.SortOrder, importedScene.SceneTitle, relationshipSignal, evidence, confidence, false);
|
||||
if (!factual && (string.IsNullOrWhiteSpace(evidence)
|
||||
|| (proposal?.ProposalKind is null && !HasMeaningfulRelationshipEvent(appearance)))) return;
|
||||
var reciprocal = proposal?.IsReciprocal ?? (factual && classification.Type == "Sibling");
|
||||
var type = factual ? FirstConfigured(proposal?.FactualRelationshipType, classification.Type)! : "Other";
|
||||
var state = FirstConfigured(proposal?.RelationshipState, RelationshipStateNameFromSignal(relationshipSignal))!;
|
||||
var key = DevelopmentKey(importedScene.SceneID, characterA.CharacterID, characterB.CharacterID, reciprocal, kind, $"{relationshipSignal}|{state}", evidence);
|
||||
if (groups.ContainsKey(key)) return;
|
||||
var existing = existingByPair.FirstOrDefault(r => r.IsReciprocal == reciprocal && r.IsPermanent == factual
|
||||
&& string.Equals(r.RelationshipTypeName, type, StringComparison.OrdinalIgnoreCase)
|
||||
&& ((r.CharacterAID == characterA.CharacterID && r.CharacterBID == characterB.CharacterID)
|
||||
|| (reciprocal && r.CharacterAID == characterB.CharacterID && r.CharacterBID == characterA.CharacterID)));
|
||||
var candidate = new RelationshipCandidate(key, characterA.CharacterID, characterA.CharacterName,
|
||||
characterB.CharacterID, characterB.CharacterName, type, existing?.CharacterRelationshipID,
|
||||
existing is null ? null : $"{existing.CharacterAName} / {existing.RelationshipTypeName} / {existing.CharacterBName}")
|
||||
{
|
||||
existingByPair.TryGetValue(key, out var existingMatch);
|
||||
candidate = new RelationshipCandidate(
|
||||
key,
|
||||
characterA.CharacterID,
|
||||
characterA.CharacterName,
|
||||
characterB.CharacterID,
|
||||
characterB.CharacterName,
|
||||
classification.Type,
|
||||
existingMatch?.CharacterRelationshipID,
|
||||
existingMatch is null ? null : $"{existingMatch.CharacterAName} / {existingMatch.RelationshipTypeName} / {existingMatch.CharacterBName}");
|
||||
candidate.RelationshipTypeClassification = classification;
|
||||
groups[key] = candidate;
|
||||
}
|
||||
|
||||
if (classification.CompareTo(candidate.RelationshipTypeClassification) > 0
|
||||
|| classification.CompareTo(candidate.RelationshipTypeClassification) == 0 && (confidence ?? 0m) >= (candidate.Confidence ?? 0m))
|
||||
{
|
||||
candidate.RelationshipType = classification.Type;
|
||||
candidate.RelationshipTypeClassification = classification;
|
||||
}
|
||||
|
||||
candidate.Confidence = Max(candidate.Confidence, confidence);
|
||||
var incomingOrder = (importedScene.ChapterNumber, importedScene.SceneNumber, importedScene.SortOrder, importedScene.SceneID);
|
||||
if (candidate.Appearances.Count == 0 || incomingOrder.CompareTo(CandidateFirstStoryOrder(candidate)) < 0)
|
||||
{
|
||||
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(
|
||||
importedScene.SceneID,
|
||||
importedScene.ChapterNumber,
|
||||
importedScene.SceneNumber,
|
||||
importedScene.SortOrder,
|
||||
importedScene.SceneTitle,
|
||||
relationshipSignal,
|
||||
evidence,
|
||||
confidence,
|
||||
candidate.ExistingRelationshipID.HasValue && existingEvents.Any(existing => existing.CharacterRelationshipID == candidate.ExistingRelationshipID.Value)));
|
||||
ProposalKind = kind, RelationshipState = state, IsReciprocal = reciprocal,
|
||||
IsKnownToOtherCharacter = proposal?.IsKnownToOtherCharacter ?? false,
|
||||
Intensity = proposal?.Intensity is >= 1 and <= 10 ? proposal.Intensity : null,
|
||||
Development = FirstConfigured(proposal?.Development, evidence, relationshipSignal),
|
||||
NeedsDirectionReview = proposal?.IsReciprocal is null,
|
||||
Confidence = confidence, FirstAppearanceNote = BuildSceneReference(importedScene),
|
||||
LastAppearanceNote = BuildSceneReference(importedScene), ExampleScene = BuildFirstAppearance(importedScene, parsed),
|
||||
ExampleContext = evidence
|
||||
};
|
||||
candidate.Appearances.Add(appearance);
|
||||
groups[key] = candidate;
|
||||
}
|
||||
|
||||
internal static string DevelopmentKey(int sceneId, int a, int b, bool reciprocal, string kind, string? signal, string? evidence)
|
||||
{
|
||||
var participants = reciprocal ? PairKey(a, b) : $"{a}>{b}";
|
||||
var hash = Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(
|
||||
System.Text.Encoding.UTF8.GetBytes($"{Normalise(signal)}|{Normalise(evidence)}")))[..24];
|
||||
return $"scene:{sceneId}:{participants}:{kind}:{hash}";
|
||||
}
|
||||
|
||||
private static bool PairNamesMatch(string? a, string? b, string? c, string? d)
|
||||
=> (Normalise(a) == Normalise(c) && Normalise(b) == Normalise(d))
|
||||
|| (Normalise(a) == Normalise(d) && Normalise(b) == Normalise(c));
|
||||
|
||||
private async Task<CharacterIndex> BuildCharacterIndexAsync(int projectId)
|
||||
{
|
||||
var index = new CharacterIndex();
|
||||
@ -527,7 +409,8 @@ public sealed class StoryIntelligenceRelationshipImportService(
|
||||
|
||||
private static int MatchRelationshipState(IReadOnlyList<RelationshipState> states, string? signal)
|
||||
{
|
||||
var stateName = RelationshipStateNameFromSignal(signal);
|
||||
var stateName = states.FirstOrDefault(x => string.Equals(x.StateName, signal, StringComparison.OrdinalIgnoreCase))?.StateName
|
||||
?? RelationshipStateNameFromSignal(signal);
|
||||
return states.FirstOrDefault(state => string.Equals(state.StateName, stateName, StringComparison.OrdinalIgnoreCase))?.RelationshipStateID
|
||||
?? states.FirstOrDefault(state => string.Equals(state.StateName, "Unknown", StringComparison.OrdinalIgnoreCase))?.RelationshipStateID
|
||||
?? states.First().RelationshipStateID;
|
||||
@ -540,6 +423,8 @@ public sealed class StoryIntelligenceRelationshipImportService(
|
||||
value.Contains("reconcil") ? "Reconciled" :
|
||||
value.Contains("engaged") || value.Contains("proposal") || value.Contains("proposes") ? "Engaged" :
|
||||
HasRomanticRelationshipEvidence(signal) ? "Romantic" :
|
||||
value.Contains("distrust") || value.Contains("mistrust") || value.Contains("suspect") ? "Distrustful" :
|
||||
value.Contains("protect") ? "Protective" :
|
||||
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("distrust") || value.Contains("suspect") || value.Contains("suspicious") ? "Distrustful" :
|
||||
@ -683,7 +568,8 @@ public sealed class StoryIntelligenceRelationshipImportService(
|
||||
private static bool HasMeaningfulRelationshipEvent(RelationshipAppearanceImport appearance)
|
||||
{
|
||||
var value = $"{appearance.Signal} {appearance.Evidence}";
|
||||
return RelationshipEventWords.Any(word => value.Contains(word, StringComparison.OrdinalIgnoreCase));
|
||||
if (Regex.IsMatch(value, @"\b(?:merely|simply|only) (?:appear|present|share|co.present)|\bno (?:meaningful )?(?:interaction|change)\b", RegexOptions.IgnoreCase)) return false;
|
||||
return RelationshipEventWords.Any(word => Regex.IsMatch(value, $@"\b{Regex.Escape(word)}\b", RegexOptions.IgnoreCase));
|
||||
}
|
||||
|
||||
private static bool HasDirectSiblingEvidence(string? evidence, string? characterAName, string? characterBName)
|
||||
@ -811,28 +697,6 @@ public sealed class StoryIntelligenceRelationshipImportService(
|
||||
.LastOrDefault().StateName ?? initialState;
|
||||
}
|
||||
|
||||
private static string BuildRelationshipNotes(RelationshipCandidate candidate)
|
||||
{
|
||||
var lines = new List<string> { "Imported from Story Intelligence relationship review." };
|
||||
if (!string.IsNullOrWhiteSpace(candidate.ExampleContext))
|
||||
{
|
||||
lines.Add(candidate.ExampleContext);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(candidate.FirstAppearanceNote))
|
||||
{
|
||||
lines.Add($"First detected: {candidate.FirstAppearanceNote}");
|
||||
}
|
||||
|
||||
return string.Join(Environment.NewLine, lines);
|
||||
}
|
||||
|
||||
private static string BuildRelationshipEventDescription(RelationshipCandidate candidate, RelationshipAppearanceImport appearance)
|
||||
=> FirstConfigured(
|
||||
Clean(appearance.Evidence),
|
||||
Clean(appearance.Signal),
|
||||
$"{candidate.CharacterAName} / {candidate.RelationshipType} / {candidate.CharacterBName}") ?? RelationshipLabel(candidate);
|
||||
|
||||
private static string RelationshipLabel(RelationshipCandidate candidate)
|
||||
=> $"{candidate.CharacterAName} / {candidate.RelationshipType} / {candidate.CharacterBName}";
|
||||
|
||||
@ -844,24 +708,6 @@ public sealed class StoryIntelligenceRelationshipImportService(
|
||||
.Select(type => new SelectListItem(type.TypeName, type.RelationshipTypeID.ToString()))
|
||||
.ToList();
|
||||
|
||||
private static void SaveRelationshipSelections(OnboardingStoryIntelligenceBatch batch, IEnumerable<StoryIntelligenceRelationshipImportChoiceForm> choices)
|
||||
{
|
||||
foreach (var choice in choices)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(choice.Key) || choice.RelationshipTypeID is not { } relationshipTypeId)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
batch.RelationshipSelections.RemoveAll(selection => string.Equals(selection.Key, choice.Key, StringComparison.OrdinalIgnoreCase));
|
||||
batch.RelationshipSelections.Add(new OnboardingStoryIntelligenceRelationshipSelection
|
||||
{
|
||||
Key = choice.Key,
|
||||
RelationshipTypeID = relationshipTypeId
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static string? BuildFirstAppearance(Scene scene, SceneIntelligenceScene parsed)
|
||||
{
|
||||
var summary = Clean(FirstConfigured(scene.StructuralSummary, scene.Summary, parsed.Summary?.Short));
|
||||
@ -889,12 +735,6 @@ public sealed class StoryIntelligenceRelationshipImportService(
|
||||
private static bool IsCharacterEntity(string? entityType)
|
||||
=> string.Equals(Clean(entityType), "Character", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static int? ConfidenceIntensity(decimal? confidence)
|
||||
=> confidence.HasValue ? Math.Clamp((int)Math.Round(confidence.Value * 10m, MidpointRounding.AwayFromZero), 1, 10) : null;
|
||||
|
||||
private static decimal? Max(decimal? current, decimal? next)
|
||||
=> current.HasValue && next.HasValue ? Math.Max(current.Value, next.Value) : current ?? next;
|
||||
|
||||
private static string ConfidenceBand(decimal? confidence)
|
||||
=> confidence switch
|
||||
{
|
||||
@ -940,11 +780,11 @@ public sealed class StoryIntelligenceRelationshipImportService(
|
||||
|
||||
private static readonly string[] RelationshipEventWords =
|
||||
[
|
||||
"begins", "begin", "starts", "first meeting", "meet", "met", "deteriorates", "deteriorate",
|
||||
"begins", "begin", "starts", "first meeting", "deteriorates", "deteriorate",
|
||||
"reconciles", "reconciliation", "reconciled", "argument", "argues", "separation", "separate",
|
||||
"estranged", "confront", "conflict", "tension", "hostile", "distrust", "supports", "protects",
|
||||
"becomes", "become", "establishes", "established", "explicit", "couple", "relationship",
|
||||
"attraction", "romantic", "proposes", "proposal", "engaged", "accepts"
|
||||
"becomes", "become", "establishes", "couple",
|
||||
"attraction", "romantic", "proposes", "proposal", "engaged", "accepts", "entrust", "betray", "confides", "asks", "helps"
|
||||
];
|
||||
|
||||
private sealed record RelationshipCandidateData(bool HasCommittedScenes, int AlreadyLinkedCount, IReadOnlyList<RelationshipCandidate> Candidates);
|
||||
@ -959,6 +799,13 @@ public sealed class StoryIntelligenceRelationshipImportService(
|
||||
int? existingRelationshipId,
|
||||
string? existingRelationshipLabel)
|
||||
{
|
||||
public string ProposalKind { get; init; } = "Development";
|
||||
public string RelationshipState { get; init; } = "Unknown";
|
||||
public bool IsReciprocal { get; init; }
|
||||
public bool IsKnownToOtherCharacter { get; init; }
|
||||
public bool NeedsDirectionReview { get; init; }
|
||||
public int? Intensity { get; init; }
|
||||
public string? Development { get; init; }
|
||||
public string Key { get; } = key;
|
||||
public int CharacterAID { get; } = characterAId;
|
||||
public string CharacterAName { get; } = characterAName;
|
||||
|
||||
@ -273,6 +273,7 @@ public sealed class StoryIntelligenceReviewCandidateService(
|
||||
PageSize = page.PageSize,
|
||||
TotalCandidateCount = page.TotalCount,
|
||||
PendingCandidateCount = page.PendingCount,
|
||||
RelationshipStateOptions = lookupData.RelationshipStates.Select(x => new SelectListItem(x.StateName, x.RelationshipStateID.ToString())).ToList(),
|
||||
RelationshipTypeOptions = lookupData.RelationshipTypes
|
||||
.OrderBy(type => type.SortOrder)
|
||||
.ThenBy(type => type.TypeName)
|
||||
@ -508,7 +509,7 @@ public sealed class StoryIntelligenceReviewCandidateService(
|
||||
StableKey = choice.Key,
|
||||
DecisionAction = choice.Action,
|
||||
DecisionTargetKey = choice.AliasTargetKey,
|
||||
DecisionCanonicalID = choice.RelationshipTypeID
|
||||
DecisionCanonicalID = choice.AcceptedRelationshipID
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -89,6 +89,13 @@ public static class StoryIntelligenceSceneStructuredOutputSchema
|
||||
var relationships = Arr(Obj(Props(
|
||||
("characterA", Str()),
|
||||
("characterB", Str()),
|
||||
("proposalKind", Enum("Factual", "State", "Development")),
|
||||
("factualRelationshipType", NullableString()),
|
||||
("relationshipState", NullableString()),
|
||||
("isReciprocal", Bool()),
|
||||
("isKnownToOtherCharacter", Bool()),
|
||||
("intensity", NullableInteger()),
|
||||
("development", Str()),
|
||||
("relationshipSignal", Enum("trust", "mistrust", "affection", "fear", "authority", "secrecy", "conflict", "dependence", "protection", "rivalry", "obligation")),
|
||||
("evidence", Str()),
|
||||
("confidence", Confidence()))));
|
||||
|
||||
@ -277,6 +277,19 @@ public sealed class StorySceneValidator : IStorySceneValidator
|
||||
AddWarning(result, $"{path}.relationshipSignal", "Relationship signal is outside the preferred V1 neutral terms.", "Use a supported neutral signal when one fits.");
|
||||
}
|
||||
|
||||
if (item.ProposalKind is not null)
|
||||
{
|
||||
if (item.ProposalKind is not ("Factual" or "State" or "Development"))
|
||||
AddError(result, $"{path}.proposalKind", "Unknown relationship proposal kind.", "Use Factual, State or Development.");
|
||||
RequiredString(item.Development, $"{path}.development", result);
|
||||
RequiredString(item.RelationshipState, $"{path}.relationshipState", result);
|
||||
if (item.ProposalKind == "Factual") RequiredString(item.FactualRelationshipType, $"{path}.factualRelationshipType", result);
|
||||
if (item.IsReciprocal is null || item.IsKnownToOtherCharacter is null)
|
||||
AddError(result, path, "Relationship direction and awareness are required.", "Supply isReciprocal and isKnownToOtherCharacter from scene evidence.");
|
||||
if (item.Intensity is < 1 or > 10)
|
||||
AddError(result, $"{path}.intensity", "Intensity must be between 1 and 10 or null.", "Use emotional strength, not confidence.");
|
||||
}
|
||||
|
||||
RequiredString(item.Evidence, $"{path}.evidence", result);
|
||||
Confidence(item.Confidence, $"{path}.confidence", result);
|
||||
var key = string.Join("|", new[] { item.CharacterA, item.CharacterB, item.RelationshipSignal }.Select(value => value?.Trim().ToLowerInvariant()));
|
||||
|
||||
92
PlotLine/Sql/197_SceneRelationshipDevelopments.sql
Normal file
92
PlotLine/Sql/197_SceneRelationshipDevelopments.sql
Normal file
@ -0,0 +1,92 @@
|
||||
-- Reuse canonical relationships and events. Existing author data is not rewritten.
|
||||
SET XACT_ABORT ON;
|
||||
GO
|
||||
IF COL_LENGTH('dbo.RelationshipEvents','SourceReviewKey') IS NULL
|
||||
ALTER TABLE dbo.RelationshipEvents ADD SourceReviewKey nvarchar(450) NULL;
|
||||
GO
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE object_id=OBJECT_ID('dbo.RelationshipEvents') AND name='UX_RelationshipEvents_ReviewSource')
|
||||
CREATE UNIQUE INDEX UX_RelationshipEvents_ReviewSource ON dbo.RelationshipEvents(SceneID,SourceReviewKey) WHERE SourceReviewKey IS NOT NULL;
|
||||
GO
|
||||
CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceRelationship_AcceptDevelopment
|
||||
@ProjectID int, @BookID int, @SceneID int, @SourceReviewKey nvarchar(450),
|
||||
@CharacterAID int, @CharacterBID int, @RelationshipTypeID int, @RelationshipStateID int,
|
||||
@IsPermanent bit, @IsReciprocal bit, @IsKnownToOtherCharacter bit,
|
||||
@Intensity int=NULL, @Description nvarchar(max)
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
SET XACT_ABORT ON;
|
||||
IF NOT EXISTS(SELECT 1 FROM dbo.Scenes s JOIN dbo.Chapters c ON c.ChapterID=s.ChapterID JOIN dbo.Books b ON b.BookID=c.BookID
|
||||
WHERE s.SceneID=@SceneID AND b.BookID=@BookID AND b.ProjectID=@ProjectID AND s.IsArchived=0 AND c.IsArchived=0 AND b.IsArchived=0)
|
||||
THROW 53100,'Invalid scene scope.',1;
|
||||
IF @CharacterAID=@CharacterBID OR (SELECT COUNT(*) FROM dbo.Characters WHERE CharacterID IN (@CharacterAID,@CharacterBID) AND ProjectID=@ProjectID AND IsArchived=0)<>2
|
||||
THROW 53101,'Invalid relationship participants.',1;
|
||||
IF @Intensity NOT BETWEEN 1 AND 10 OR NULLIF(LTRIM(RTRIM(@Description)),N'') IS NULL OR NULLIF(@SourceReviewKey,N'') IS NULL
|
||||
THROW 53102,'Invalid relationship development.',1;
|
||||
BEGIN TRANSACTION;
|
||||
DECLARE @LockResult int;
|
||||
EXEC @LockResult=sys.sp_getapplock @Resource=N'StoryIntelligenceRelationshipAcceptance',@LockMode=N'Exclusive',@LockOwner=N'Transaction',@LockTimeout=15000;
|
||||
IF @LockResult<0 THROW 53103,'Relationship acceptance is busy. Retry.',1;
|
||||
DECLARE @RelationshipID int;
|
||||
SELECT @RelationshipID=CharacterRelationshipID FROM dbo.RelationshipEvents WHERE SceneID=@SceneID AND SourceReviewKey=@SourceReviewKey;
|
||||
IF @RelationshipID IS NULL
|
||||
BEGIN
|
||||
-- Do not flatten factual ties into changing feelings, or reverse a directional track.
|
||||
SELECT TOP(1) @RelationshipID=CharacterRelationshipID FROM dbo.CharacterRelationships
|
||||
WHERE ProjectID=@ProjectID AND IsArchived=0 AND EndSceneID IS NULL
|
||||
AND RelationshipTypeID=@RelationshipTypeID AND IsPermanent=@IsPermanent AND IsReciprocal=@IsReciprocal
|
||||
AND ((CharacterAID=@CharacterAID AND CharacterBID=@CharacterBID)
|
||||
OR (@IsReciprocal=1 AND CharacterAID=@CharacterBID AND CharacterBID=@CharacterAID))
|
||||
ORDER BY CharacterRelationshipID;
|
||||
IF @RelationshipID IS NULL
|
||||
BEGIN
|
||||
INSERT dbo.CharacterRelationships(ProjectID,CharacterAID,CharacterBID,RelationshipTypeID,IsPermanent,StartSceneID,
|
||||
IsKnownToReader,Notes,IsInitialRelationship,InitialBookID,ReaderInitiallyKnows,IsReciprocal)
|
||||
VALUES(@ProjectID,@CharacterAID,@CharacterBID,@RelationshipTypeID,@IsPermanent,@SceneID,1,
|
||||
N'Imported from Story Intelligence scene relationship review.',0,@BookID,0,@IsReciprocal);
|
||||
SET @RelationshipID=CONVERT(int,SCOPE_IDENTITY());
|
||||
END;
|
||||
INSERT dbo.RelationshipEvents(CharacterRelationshipID,SceneID,RelationshipStateID,Intensity,IsKnownToOtherCharacter,Description,SourceReviewKey)
|
||||
VALUES(@RelationshipID,@SceneID,@RelationshipStateID,@Intensity,@IsKnownToOtherCharacter,@Description,@SourceReviewKey);
|
||||
-- A later-reviewed earlier scene must move the start of the track back in story order.
|
||||
UPDATE r SET StartSceneID=@SceneID FROM dbo.CharacterRelationships r
|
||||
JOIN dbo.Scenes oldScene ON oldScene.SceneID=r.StartSceneID JOIN dbo.Chapters oldChapter ON oldChapter.ChapterID=oldScene.ChapterID
|
||||
JOIN dbo.Scenes newScene ON newScene.SceneID=@SceneID JOIN dbo.Chapters newChapter ON newChapter.ChapterID=newScene.ChapterID
|
||||
WHERE r.CharacterRelationshipID=@RelationshipID AND r.IsInitialRelationship=0 AND oldChapter.BookID=@BookID
|
||||
AND (newChapter.ChapterNumber<oldChapter.ChapterNumber OR (newChapter.ChapterNumber=oldChapter.ChapterNumber AND
|
||||
(newScene.SceneNumber<oldScene.SceneNumber OR (newScene.SceneNumber=oldScene.SceneNumber AND newScene.SortOrder<oldScene.SortOrder))));
|
||||
END;
|
||||
COMMIT;
|
||||
SELECT @RelationshipID;
|
||||
END;
|
||||
GO
|
||||
-- Replace only pending legacy pair cards with scene proposals; preserve all author decisions.
|
||||
CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceReviewCandidate_RefreshPendingRelationships
|
||||
@BookID int,@GenerationID int,@PayloadJson nvarchar(max)
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
SET XACT_ABORT ON;
|
||||
BEGIN TRANSACTION;
|
||||
IF NOT EXISTS(SELECT 1 FROM dbo.StoryIntelligenceReviewCandidateGenerations WITH(UPDLOCK,HOLDLOCK)
|
||||
WHERE ReviewCandidateGenerationID=@GenerationID AND BookID=@BookID AND Module=N'Relationships' AND IsCurrent=1 AND Status=N'Ready')
|
||||
THROW 53104,'Review generation changed. Refresh aborted.',1;
|
||||
-- An accepted/ignored old pair remains an author decision, including its unseen scenes.
|
||||
SELECT p.value,p.[key] ordinal INTO #Incoming FROM OPENJSON(@PayloadJson) p
|
||||
WHERE NOT EXISTS(SELECT 1 FROM dbo.StoryIntelligenceReviewCandidates c WHERE c.ReviewCandidateGenerationID=@GenerationID
|
||||
AND c.DecisionStatus<>N'Pending' AND (c.StableKey=JSON_VALUE(p.value,'$.key') OR
|
||||
(ISNULL(TRY_CONVERT(int,JSON_VALUE(c.CandidatePayloadJson,'$.reviewRulesVersion')),0)<3
|
||||
AND ((JSON_VALUE(c.CandidatePayloadJson,'$.characterAName')=JSON_VALUE(p.value,'$.characterAName') AND JSON_VALUE(c.CandidatePayloadJson,'$.characterBName')=JSON_VALUE(p.value,'$.characterBName'))
|
||||
OR (JSON_VALUE(c.CandidatePayloadJson,'$.characterAName')=JSON_VALUE(p.value,'$.characterBName') AND JSON_VALUE(c.CandidatePayloadJson,'$.characterBName')=JSON_VALUE(p.value,'$.characterAName'))))));
|
||||
DELETE FROM dbo.StoryIntelligenceReviewCandidates WHERE ReviewCandidateGenerationID=@GenerationID AND DecisionStatus=N'Pending';
|
||||
INSERT dbo.StoryIntelligenceReviewCandidates(ReviewCandidateGenerationID,ProjectID,BookID,Module,StableKey,CandidateType,DisplayName,SortOrder,ExistingCanonicalID,CandidatePayloadJson)
|
||||
SELECT @GenerationID,g.ProjectID,@BookID,N'Relationships',JSON_VALUE(p.value,'$.key'),JSON_VALUE(p.value,'$.relationshipType'),
|
||||
CONCAT(JSON_VALUE(p.value,'$.characterAName'),N' / ',JSON_VALUE(p.value,'$.characterBName')),CONVERT(int,p.ordinal)+1,
|
||||
TRY_CONVERT(int,JSON_VALUE(p.value,'$.existingRelationshipID')),p.value
|
||||
FROM #Incoming p JOIN dbo.StoryIntelligenceReviewCandidateGenerations g ON g.ReviewCandidateGenerationID=@GenerationID;
|
||||
UPDATE g SET CandidateCount=(SELECT COUNT(*) FROM dbo.StoryIntelligenceReviewCandidates WHERE ReviewCandidateGenerationID=@GenerationID),
|
||||
PendingCount=(SELECT COUNT(*) FROM dbo.StoryIntelligenceReviewCandidates WHERE ReviewCandidateGenerationID=@GenerationID AND DecisionStatus=N'Pending'),UpdatedUtc=SYSUTCDATETIME()
|
||||
FROM dbo.StoryIntelligenceReviewCandidateGenerations g WHERE ReviewCandidateGenerationID=@GenerationID;
|
||||
COMMIT;
|
||||
END;
|
||||
GO
|
||||
@ -662,12 +662,21 @@ public sealed class StoryIntelligenceRelationshipReviewViewModel
|
||||
public int PendingCandidateCount { get; init; }
|
||||
public bool HasPreviousPage => PageNumber > 1;
|
||||
public bool HasNextPage => PageNumber * PageSize < PendingCandidateCount;
|
||||
public IReadOnlyList<SelectListItem> RelationshipStateOptions { get; init; } = [];
|
||||
public IReadOnlyList<SelectListItem> RelationshipTypeOptions { get; init; } = [];
|
||||
public IReadOnlyList<StoryIntelligenceRelationshipReviewCandidateViewModel> Candidates { get; init; } = [];
|
||||
}
|
||||
|
||||
public sealed class StoryIntelligenceRelationshipReviewCandidateViewModel
|
||||
{
|
||||
public string ProposalKind { get; init; } = "Development";
|
||||
public string RelationshipState { get; init; } = "Unknown";
|
||||
public int RelationshipStateID { get; init; }
|
||||
public bool IsReciprocal { get; init; }
|
||||
public bool IsKnownToOtherCharacter { get; init; }
|
||||
public int? Intensity { get; init; }
|
||||
public string? Development { get; init; }
|
||||
public bool NeedsDirectionReview { get; init; }
|
||||
public int ReviewRulesVersion { get; init; }
|
||||
public decimal? FirstChapterNumber { get; init; }
|
||||
public decimal? FirstSceneNumber { get; init; }
|
||||
@ -697,6 +706,14 @@ public sealed class StoryIntelligenceRelationshipImportForm
|
||||
|
||||
public sealed class StoryIntelligenceRelationshipImportChoiceForm
|
||||
{
|
||||
[Microsoft.AspNetCore.Mvc.ModelBinding.BindNever]
|
||||
public int? AcceptedRelationshipID { get; set; }
|
||||
public bool ReverseDirection { get; set; }
|
||||
public int? RelationshipStateID { get; set; }
|
||||
public bool? IsReciprocal { get; set; }
|
||||
public bool? IsKnownToOtherCharacter { get; set; }
|
||||
public int? Intensity { get; set; }
|
||||
public string? Development { get; set; }
|
||||
public string Key { get; set; } = string.Empty;
|
||||
public string Action { get; set; } = StoryIntelligenceRelationshipImportActions.CreateNew;
|
||||
public int? RelationshipTypeID { get; set; }
|
||||
|
||||
@ -11,7 +11,7 @@
|
||||
<div class="onboarding-copy story-review-heading">
|
||||
<p class="eyebrow">Story Intelligence</p>
|
||||
<h1 id="story-relationship-title">Review Relationships</h1>
|
||||
<p>Review relationships in chapter order, then scene order, by their first appearance. Decide which should become canonical.</p>
|
||||
<p>Review meaningful relationship facts, states and developments scene by scene. Direction runs from Character A to Character B unless marked reciprocal. Accepted proposals appear in the existing scene relationship timeline.</p>
|
||||
</div>
|
||||
|
||||
@if (TempData["OnboardingStoryIntelligenceError"] is string error)
|
||||
@ -45,8 +45,7 @@
|
||||
{
|
||||
<span class="story-review-page-status">Showing @relationshipCandidates.Count.ToString("N0") of @Model.RelationshipReview.PendingCandidateCount.ToString("N0") pending</span>
|
||||
}
|
||||
<button class="btn btn-outline-primary btn-sm" type="button" data-relationship-bulk="create-all">Create all</button>
|
||||
<button class="btn btn-outline-secondary btn-sm" type="button" data-relationship-bulk="link-existing">Link existing matches</button>
|
||||
<button class="btn btn-outline-primary btn-sm" type="button" data-relationship-bulk="create-all">Accept all on this page</button>
|
||||
<button class="btn btn-outline-secondary btn-sm" type="button" data-relationship-bulk="ignore-all">Ignore all</button>
|
||||
<button class="btn btn-outline-secondary btn-sm" type="button" data-relationship-bulk="expand">Expand all</button>
|
||||
<button class="btn btn-outline-secondary btn-sm" type="button" data-relationship-bulk="collapse">Collapse all</button>
|
||||
@ -67,7 +66,7 @@
|
||||
<span>
|
||||
<small>@Display(candidate.FirstAppearance)</small>
|
||||
<strong>@candidate.CharacterAName / @candidate.CharacterBName</strong>
|
||||
<small>@candidate.RelationshipType - @candidate.AppearsInScenes.ToString("N0") scene@(candidate.AppearsInScenes == 1 ? string.Empty : "s")</small>
|
||||
<small>@candidate.ProposalKind — @candidate.RelationshipState</small>
|
||||
</span>
|
||||
<em>@candidate.Confidence</em>
|
||||
</summary>
|
||||
@ -79,14 +78,14 @@
|
||||
<div><dt>Character A</dt><dd>@candidate.CharacterAName</dd></div>
|
||||
<div><dt>Character B</dt><dd>@candidate.CharacterBName</dd></div>
|
||||
<div><dt>First seen</dt><dd>@Display(candidate.FirstAppearance)</dd></div>
|
||||
<div><dt>Last seen</dt><dd>@Display(candidate.LastAppearance)</dd></div>
|
||||
<div><dt>Direction</dt><dd>@candidate.CharacterAName @(candidate.IsReciprocal ? "↔" : "→") @candidate.CharacterBName</dd></div>
|
||||
<div><dt>Existing match</dt><dd>@(candidate.ExistingRelationshipLabel ?? "None")</dd></div>
|
||||
</dl>
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(candidate.ExampleScene))
|
||||
{
|
||||
<div class="story-review-note">
|
||||
<strong>Example scene</strong>
|
||||
<strong>Scene</strong>
|
||||
<p>@candidate.ExampleScene</p>
|
||||
</div>
|
||||
}
|
||||
@ -99,62 +98,47 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (candidate.IsExistingMatch)
|
||||
@if (candidate.NeedsDirectionReview)
|
||||
{
|
||||
<div class="story-review-note">
|
||||
<strong>Possible existing relationship</strong>
|
||||
<p>@candidate.CharacterAName and @candidate.CharacterBName may already have a relationship in PlotDirector. Choose whether to link them or create a separate relationship.</p>
|
||||
</div>
|
||||
<div class="story-review-note">This stored analysis predates explicit direction. Check which character feels or acts towards the other and whether the state is mutual.</div>
|
||||
}
|
||||
|
||||
<fieldset class="story-character-actions">
|
||||
<legend>Decision</legend>
|
||||
<label>
|
||||
<input type="radio" name="Relationships[@i].Action" value="@StoryIntelligenceRelationshipImportActions.CreateNew" checked data-relationship-action />
|
||||
Create new relationship
|
||||
</label>
|
||||
<label>
|
||||
<input type="radio" name="Relationships[@i].Action" value="@StoryIntelligenceRelationshipImportActions.LinkExisting" data-relationship-action @(candidate.IsExistingMatch ? string.Empty : "disabled") />
|
||||
Link existing relationship
|
||||
</label>
|
||||
<label>
|
||||
<input type="radio" name="Relationships[@i].Action" value="@StoryIntelligenceRelationshipImportActions.Alias" data-relationship-action />
|
||||
Merge with another relationship
|
||||
</label>
|
||||
<label>
|
||||
<input type="radio" name="Relationships[@i].Action" value="@StoryIntelligenceRelationshipImportActions.Ignore" data-relationship-action />
|
||||
Ignore
|
||||
</label>
|
||||
<legend>Decision for this scene proposal</legend>
|
||||
<label><input type="radio" name="Relationships[@i].Action" value="@StoryIntelligenceRelationshipImportActions.CreateNew" checked data-relationship-action /> Accept development / fact</label>
|
||||
<label><input type="radio" name="Relationships[@i].Action" value="@StoryIntelligenceRelationshipImportActions.Ignore" data-relationship-action /> Ignore this proposal</label>
|
||||
</fieldset>
|
||||
|
||||
<div data-relationship-type-panel>
|
||||
<label class="form-label" for="relationship-type-@i">Relationship type</label>
|
||||
<label class="form-label" for="relationship-type-@i">@(candidate.ProposalKind == "Factual" ? "Factual relationship type" : "Relationship track (Other allows changing states)")</label>
|
||||
<select id="relationship-type-@i" class="form-select" name="Relationships[@i].RelationshipTypeID">
|
||||
@foreach (var option in Model.RelationshipReview.RelationshipTypeOptions)
|
||||
{
|
||||
<option value="@option.Value" selected="@(option.Value == candidate.RelationshipTypeID.ToString())">@option.Text</option>
|
||||
}
|
||||
</select>
|
||||
<div class="story-review-note mt-2">
|
||||
<strong>Story Intelligence observation</strong>
|
||||
<p>@candidate.RelationshipType</p>
|
||||
@if (!string.IsNullOrWhiteSpace(candidate.ExampleContext))
|
||||
<label class="form-label mt-2" for="relationship-state-@i">State in this scene</label>
|
||||
<select id="relationship-state-@i" class="form-select" name="Relationships[@i].RelationshipStateID">
|
||||
@foreach (var option in Model.RelationshipReview.RelationshipStateOptions)
|
||||
{
|
||||
<strong>Evidence</strong>
|
||||
<p>@candidate.ExampleContext</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div data-relationship-alias-panel hidden>
|
||||
<label class="form-label" for="relationship-alias-target-@i">Merge target</label>
|
||||
<select id="relationship-alias-target-@i" class="form-select" name="Relationships[@i].AliasTargetKey" data-relationship-alias-target>
|
||||
<option value="">Choose relationship...</option>
|
||||
@foreach (var target in relationshipCandidates.Where(target => !string.Equals(target.Key, candidate.Key, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
<option value="@target.Key">@target.CharacterAName / @target.RelationshipType / @target.CharacterBName@(target.IsExistingMatch ? $" -> {target.ExistingRelationshipLabel}" : string.Empty)</option>
|
||||
<option value="@option.Value" selected="@(option.Value == candidate.RelationshipStateID.ToString())">@option.Text</option>
|
||||
}
|
||||
</select>
|
||||
<label class="form-label mt-2" for="relationship-direction-@i">Direction</label>
|
||||
<select id="relationship-direction-@i" class="form-select" name="Relationships[@i].IsReciprocal">
|
||||
<option value="false" selected="@(!candidate.IsReciprocal)">@candidate.CharacterAName → @candidate.CharacterBName</option>
|
||||
<option value="true" selected="@candidate.IsReciprocal">Mutual / reciprocal</option>
|
||||
</select>
|
||||
<label class="mt-2"><input type="checkbox" name="Relationships[@i].ReverseDirection" value="true" /> Reverse A and B (also reverses who knows)</label>
|
||||
<label class="form-label mt-2" for="relationship-known-@i">Does @candidate.CharacterBName know this feeling / state?</label>
|
||||
<select id="relationship-known-@i" class="form-select" name="Relationships[@i].IsKnownToOtherCharacter">
|
||||
<option value="false" selected="@(!candidate.IsKnownToOtherCharacter)">No / not established</option>
|
||||
<option value="true" selected="@candidate.IsKnownToOtherCharacter">Yes</option>
|
||||
</select>
|
||||
<label class="form-label mt-2" for="relationship-intensity-@i">Emotional intensity (1–10; blank if unknown)</label>
|
||||
<input id="relationship-intensity-@i" class="form-control" type="number" min="1" max="10" name="Relationships[@i].Intensity" value="@candidate.Intensity" />
|
||||
<label class="form-label mt-2" for="relationship-development-@i">Development / factual discovery</label>
|
||||
<textarea id="relationship-development-@i" class="form-control" name="Relationships[@i].Development" rows="3">@candidate.Development</textarea>
|
||||
<p class="text-muted">Acceptance reuses a matching factual or directional relationship and adds this scene event. Existing history is preserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
@ -22,7 +22,8 @@ var builder = new StoryIntelligenceRelationshipImportService(new StoryIntelligen
|
||||
var repository = new StoryIntelligenceReviewCandidateRepository(factory);
|
||||
foreach (var target in targets)
|
||||
{
|
||||
var runs = await pipelines.ListCommittedRunsByBookAsync(target.BookID, target.UserID);
|
||||
var runs = (await pipelines.ListCommittedRunsByBookAsync(target.BookID, target.UserID))
|
||||
.GroupBy(x => x.ChapterID).Select(g => g.OrderByDescending(x => x.StoryIntelligenceRunID).First()).ToList();
|
||||
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}");
|
||||
@ -50,9 +51,6 @@ foreach (var target in targets)
|
||||
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}");
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user