Compare commits

...

2 Commits

21 changed files with 1635 additions and 53 deletions

View File

@ -18,7 +18,10 @@ var tests = new (string Name, Action Test)[]
("Location canonical keys merge trivial variants", LocationCanonicalKeysMergeTrivialVariants), ("Location canonical keys merge trivial variants", LocationCanonicalKeysMergeTrivialVariants),
("Asset filtering rejects generic objects", AssetFilteringRejectsGenericObjects), ("Asset filtering rejects generic objects", AssetFilteringRejectsGenericObjects),
("Asset filtering preserves story assets", AssetFilteringPreservesStoryAssets), ("Asset filtering preserves story assets", AssetFilteringPreservesStoryAssets),
("Asset canonical keys merge trivial variants", AssetCanonicalKeysMergeTrivialVariants) ("Asset canonical keys merge trivial variants", AssetCanonicalKeysMergeTrivialVariants),
("Relationship signals map to broad lookup types", RelationshipSignalsMapToBroadTypes),
("Knowledge signals map to existing knowledge states", KnowledgeSignalsMapToExistingStates),
("Knowledge duplicate statements share canonical keys", KnowledgeDuplicateStatementsShareCanonicalKeys)
}; };
foreach (var test in tests) foreach (var test in tests)
@ -225,6 +228,44 @@ static void AssetCanonicalKeysMergeTrivialVariants()
Assert(AssetCanonicalKey("driving license") == AssetCanonicalKey("driving licence"), "License/licence spelling should merge."); Assert(AssetCanonicalKey("driving license") == AssetCanonicalKey("driving licence"), "License/licence spelling should merge.");
} }
static void RelationshipSignalsMapToBroadTypes()
{
Assert(RelationshipTypeFromSignal("Friend") == "Friend", "Friend should map to Friend.");
Assert(RelationshipTypeFromSignal("driving instructor", "learner makes progress") == "Educational", "Instructor/learner should map to Educational.");
Assert(RelationshipTypeFromSignal("aunt", "family visit") == "Family", "Aunt/family should map to Family.");
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("neighbour") == "Neighbour", "Neighbour should map to Neighbour.");
Assert(RelationshipTypeFromSignal("adversarial", "mistrust between them") == "Rival", "Adversarial/mistrust should map to Rival.");
Assert(RelationshipTypeFromSignal("uncertain emotional connection") == "Unknown", "Uncertain signals should map to Unknown.");
}
static void KnowledgeSignalsMapToExistingStates()
{
var states = new List<KnowledgeState>
{
new() { KnowledgeStateID = 1, StateName = "Unaware", SortOrder = 10, IsActive = true },
new() { KnowledgeStateID = 2, StateName = "Suspects", SortOrder = 20, IsActive = true },
new() { KnowledgeStateID = 3, StateName = "Partially Knows", SortOrder = 30, IsActive = true },
new() { KnowledgeStateID = 4, StateName = "Knows", SortOrder = 40, IsActive = true },
new() { KnowledgeStateID = 5, StateName = "Misunderstands", SortOrder = 50, IsActive = true }
};
Assert(KnowledgeStateFromSignal("Discovers", "the letter exists", states) == "Knows", "Discovers should map to Knows.");
Assert(KnowledgeStateFromSignal("Suspects", "Maggie is lying", states) == "Suspects", "Suspects should map to Suspects.");
Assert(KnowledgeStateFromSignal("Believes", "Gareth is trustworthy", states) == "Partially Knows", "Believes should map to Partially Knows.");
Assert(KnowledgeStateFromSignal("Misunderstands", "Beth is dead", states) == "Misunderstands", "Misunderstands should map to Misunderstands.");
Assert(KnowledgeStateFromSignal("Forgets", "the hiding place", states) == "Unaware", "Forgets should map to Unaware.");
}
static void KnowledgeDuplicateStatementsShareCanonicalKeys()
{
var first = CanonicalKnowledgeKey("Simon is Beth's father.");
var second = CanonicalKnowledgeKey("Beth discovers Simon is her father.");
var third = CanonicalKnowledgeKey("Beth learns Simon is her biological father.");
Assert(first == second && second == third, $"Expected duplicate father facts to merge, got {first}, {second}, {third}.");
}
static bool IsAssetNameCandidate(string name) static bool IsAssetNameCandidate(string name)
=> InvokePrivateAssetFilter("IsAssetNameCandidate", name); => InvokePrivateAssetFilter("IsAssetNameCandidate", name);
@ -246,6 +287,33 @@ static bool InvokePrivateAssetFilter(string methodName, string name)
return method!.Invoke(null, [name]) is true; return method!.Invoke(null, [name]) is true;
} }
static string RelationshipTypeFromSignal(string signal, string? evidence = null)
{
var method = typeof(StoryIntelligenceRelationshipImportService).GetMethod(
"RelationshipTypeFromSignal",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
Assert(method is not null, "RelationshipTypeFromSignal was not found.");
return (string)method!.Invoke(null, [signal, evidence])!;
}
static string KnowledgeStateFromSignal(string changeType, string statement, IReadOnlyList<KnowledgeState> states)
{
var method = typeof(StoryIntelligenceKnowledgeImportService).GetMethod(
"KnowledgeStateFromSignal",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
Assert(method is not null, "KnowledgeStateFromSignal was not found.");
return (string)method!.Invoke(null, [changeType, statement, states])!;
}
static string CanonicalKnowledgeKey(string statement)
{
var method = typeof(StoryIntelligenceKnowledgeImportService).GetMethod(
"CanonicalKnowledgeKey",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
Assert(method is not null, "CanonicalKnowledgeKey was not found.");
return (string)method!.Invoke(null, [statement])!;
}
static JsonSerializerOptions JsonOptions() static JsonSerializerOptions JsonOptions()
=> new() => new()
{ {

View File

@ -75,6 +75,7 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
return target.Route switch return target.Route switch
{ {
StoryIntelligenceResumeRoutes.Complete => RedirectToAction(nameof(StoryIntelligenceComplete), new { batchId = target.BatchID }), StoryIntelligenceResumeRoutes.Complete => RedirectToAction(nameof(StoryIntelligenceComplete), new { batchId = target.BatchID }),
StoryIntelligenceResumeRoutes.Knowledge => RedirectToAction(nameof(StoryIntelligenceKnowledge), new { batchId = target.BatchID }),
StoryIntelligenceResumeRoutes.Relationships => RedirectToAction(nameof(StoryIntelligenceRelationships), new { batchId = target.BatchID }), StoryIntelligenceResumeRoutes.Relationships => RedirectToAction(nameof(StoryIntelligenceRelationships), new { batchId = target.BatchID }),
StoryIntelligenceResumeRoutes.Assets => RedirectToAction(nameof(StoryIntelligenceAssets), new { batchId = target.BatchID }), StoryIntelligenceResumeRoutes.Assets => RedirectToAction(nameof(StoryIntelligenceAssets), new { batchId = target.BatchID }),
StoryIntelligenceResumeRoutes.Locations => RedirectToAction(nameof(StoryIntelligenceLocations), new { batchId = target.BatchID }), StoryIntelligenceResumeRoutes.Locations => RedirectToAction(nameof(StoryIntelligenceLocations), new { batchId = target.BatchID }),
@ -388,6 +389,68 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
return model is null ? NotFound() : View(model); return model is null ? NotFound() : View(model);
} }
[HttpGet("story-intelligence/knowledge")]
public async Task<IActionResult> StoryIntelligenceKnowledge(Guid batchId)
{
var model = await storyIntelligence.GetProgressAsync(batchId);
if (model is null)
{
return NotFound();
}
if (model.HasActiveRuns)
{
return RedirectToAction(nameof(StoryIntelligenceProgress), new { batchId });
}
if (!model.AllCommitted)
{
return RedirectToAction(nameof(StoryIntelligenceReview), new { batchId });
}
if (!model.PipelineDashboard.CharacterStageComplete)
{
return RedirectToAction(nameof(StoryIntelligenceCharacters), new { batchId });
}
if (!model.PipelineDashboard.LocationStageComplete)
{
return RedirectToAction(nameof(StoryIntelligenceLocations), new { batchId });
}
if (!model.PipelineDashboard.AssetStageComplete)
{
return RedirectToAction(nameof(StoryIntelligenceAssets), new { batchId });
}
return !model.PipelineDashboard.RelationshipStageComplete
? RedirectToAction(nameof(StoryIntelligenceRelationships), new { batchId })
: View(model);
}
[HttpPost("story-intelligence/knowledge")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> ImportStoryIntelligenceKnowledge(StoryIntelligenceKnowledgeImportForm form)
{
var (progress, result) = await storyIntelligence.ImportKnowledgeAsync(form.BatchID, form);
if (progress is null)
{
return NotFound();
}
TempData[result.Success ? "OnboardingStoryIntelligenceMessage" : "OnboardingStoryIntelligenceError"] = result.Message;
return result.Success
? RedirectToAction(nameof(StoryIntelligenceKnowledgeComplete), new { batchId = form.BatchID })
: RedirectToAction(nameof(StoryIntelligenceKnowledge), new { batchId = form.BatchID });
}
[HttpGet("story-intelligence/knowledge/complete")]
public async Task<IActionResult> StoryIntelligenceKnowledgeComplete(Guid batchId)
{
var model = await storyIntelligence.GetKnowledgeImportResultAsync(batchId);
return model is null ? NotFound() : View(model);
}
[HttpGet("story-intelligence/complete")] [HttpGet("story-intelligence/complete")]
public async Task<IActionResult> StoryIntelligenceComplete(Guid batchId) public async Task<IActionResult> StoryIntelligenceComplete(Guid batchId)
{ {
@ -417,6 +480,11 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
return RedirectToAction(nameof(StoryIntelligenceRelationships), new { batchId }); return RedirectToAction(nameof(StoryIntelligenceRelationships), new { batchId });
} }
if (!progress.PipelineDashboard.KnowledgeStageComplete)
{
return RedirectToAction(nameof(StoryIntelligenceKnowledge), new { batchId });
}
var model = await storyIntelligence.GetCompletionAsync(batchId); var model = await storyIntelligence.GetCompletionAsync(batchId);
return model is null ? NotFound() : View(model); return model is null ? NotFound() : View(model);
} }

View File

@ -34,6 +34,8 @@ public static class StoryIntelligencePipelineStages
public const string AssetImport = "AssetImport"; public const string AssetImport = "AssetImport";
public const string RelationshipReview = "RelationshipReview"; public const string RelationshipReview = "RelationshipReview";
public const string RelationshipImport = "RelationshipImport"; public const string RelationshipImport = "RelationshipImport";
public const string KnowledgeReview = "KnowledgeReview";
public const string KnowledgeImport = "KnowledgeImport";
public const string Complete = "Complete"; public const string Complete = "Complete";
} }
@ -64,7 +66,7 @@ public sealed class StoryIntelligenceBookPipelineState
public bool IsComplete public bool IsComplete
=> string.Equals(Status, StoryIntelligencePipelineStatuses.Complete, StringComparison.OrdinalIgnoreCase) => string.Equals(Status, StoryIntelligencePipelineStatuses.Complete, StringComparison.OrdinalIgnoreCase)
&& string.Equals(LastCompletedStage, StoryIntelligencePipelineStages.RelationshipImport, StringComparison.OrdinalIgnoreCase); && string.Equals(LastCompletedStage, StoryIntelligencePipelineStages.KnowledgeImport, StringComparison.OrdinalIgnoreCase);
} }
public sealed class StoryIntelligenceBookPipelineSaveRequest public sealed class StoryIntelligenceBookPipelineSaveRequest

View File

@ -17,6 +17,10 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<Content Include="Docs\AI\**\*">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
</Content>
<Content Include="Help\**\*"> <Content Include="Help\**\*">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory> <CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>

View File

@ -211,6 +211,7 @@ public class Program
builder.Services.AddScoped<IStoryIntelligenceLocationImportService, StoryIntelligenceLocationImportService>(); builder.Services.AddScoped<IStoryIntelligenceLocationImportService, StoryIntelligenceLocationImportService>();
builder.Services.AddScoped<IStoryIntelligenceAssetImportService, StoryIntelligenceAssetImportService>(); builder.Services.AddScoped<IStoryIntelligenceAssetImportService, StoryIntelligenceAssetImportService>();
builder.Services.AddScoped<IStoryIntelligenceRelationshipImportService, StoryIntelligenceRelationshipImportService>(); builder.Services.AddScoped<IStoryIntelligenceRelationshipImportService, StoryIntelligenceRelationshipImportService>();
builder.Services.AddScoped<IStoryIntelligenceKnowledgeImportService, StoryIntelligenceKnowledgeImportService>();
builder.Services.AddScoped<IStoryIntelligencePipelineStateService, StoryIntelligencePipelineStateService>(); builder.Services.AddScoped<IStoryIntelligencePipelineStateService, StoryIntelligencePipelineStateService>();
builder.Services.AddScoped<IStoryIntelligenceProgressNotifier, StoryIntelligenceProgressNotifier>(); builder.Services.AddScoped<IStoryIntelligenceProgressNotifier, StoryIntelligenceProgressNotifier>();
builder.Services.AddScoped<ISceneImportResolver, SceneImportResolver>(); builder.Services.AddScoped<ISceneImportResolver, SceneImportResolver>();

View File

@ -22,6 +22,8 @@ public interface IOnboardingStoryIntelligenceService
Task<StoryIntelligenceAssetImportResultViewModel?> GetAssetImportResultAsync(Guid batchId); Task<StoryIntelligenceAssetImportResultViewModel?> GetAssetImportResultAsync(Guid batchId);
Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> ImportRelationshipsAsync(Guid batchId, StoryIntelligenceRelationshipImportForm form); Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> ImportRelationshipsAsync(Guid batchId, StoryIntelligenceRelationshipImportForm form);
Task<StoryIntelligenceRelationshipImportResultViewModel?> GetRelationshipImportResultAsync(Guid batchId); Task<StoryIntelligenceRelationshipImportResultViewModel?> GetRelationshipImportResultAsync(Guid batchId);
Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> ImportKnowledgeAsync(Guid batchId, StoryIntelligenceKnowledgeImportForm form);
Task<StoryIntelligenceKnowledgeImportResultViewModel?> GetKnowledgeImportResultAsync(Guid batchId);
Task<StoryIntelligenceCompletionViewModel?> GetCompletionAsync(Guid batchId); Task<StoryIntelligenceCompletionViewModel?> GetCompletionAsync(Guid batchId);
Task<OnboardingStoryIntelligenceResumeTarget?> ResumeBookAsync(int bookId); Task<OnboardingStoryIntelligenceResumeTarget?> ResumeBookAsync(int bookId);
} }
@ -36,6 +38,7 @@ public sealed class OnboardingStoryIntelligenceService(
IStoryIntelligenceLocationImportService locationImport, IStoryIntelligenceLocationImportService locationImport,
IStoryIntelligenceAssetImportService assetImport, IStoryIntelligenceAssetImportService assetImport,
IStoryIntelligenceRelationshipImportService relationshipImport, IStoryIntelligenceRelationshipImportService relationshipImport,
IStoryIntelligenceKnowledgeImportService knowledgeImport,
IStoryIntelligencePipelineStateService pipelineState, IStoryIntelligencePipelineStateService pipelineState,
IStoryIntelligenceClient client, IStoryIntelligenceClient client,
IOnboardingStoryIntelligenceBatchStore batchStore, IOnboardingStoryIntelligenceBatchStore batchStore,
@ -271,6 +274,7 @@ public sealed class OnboardingStoryIntelligenceService(
var locationReview = await locationImport.BuildReviewAsync(batch); var locationReview = await locationImport.BuildReviewAsync(batch);
var assetReview = await assetImport.BuildReviewAsync(batch); var assetReview = await assetImport.BuildReviewAsync(batch);
var relationshipReview = await relationshipImport.BuildReviewAsync(batch); var relationshipReview = await relationshipImport.BuildReviewAsync(batch);
var knowledgeReview = await knowledgeImport.BuildReviewAsync(batch);
return new StoryIntelligenceProgressViewModel return new StoryIntelligenceProgressViewModel
{ {
BatchID = batch.BatchID, BatchID = batch.BatchID,
@ -284,6 +288,7 @@ public sealed class OnboardingStoryIntelligenceService(
LocationReview = locationReview, LocationReview = locationReview,
AssetReview = assetReview, AssetReview = assetReview,
RelationshipReview = relationshipReview, RelationshipReview = relationshipReview,
KnowledgeReview = knowledgeReview,
PipelineDashboard = new StoryIntelligencePipelineDashboardViewModel PipelineDashboard = new StoryIntelligencePipelineDashboardViewModel
{ {
ChaptersAnalysed = chapters.Count(chapter => chapter.IsRunComplete), ChaptersAnalysed = chapters.Count(chapter => chapter.IsRunComplete),
@ -299,7 +304,10 @@ public sealed class OnboardingStoryIntelligenceService(
AssetStageComplete = batch.AssetStageComplete || assetReview.IsComplete, AssetStageComplete = batch.AssetStageComplete || assetReview.IsComplete,
RelationshipsIdentified = relationshipReview.Candidates.Count + batch.RelationshipDecisions.Count, RelationshipsIdentified = relationshipReview.Candidates.Count + batch.RelationshipDecisions.Count,
RelationshipsCreatedOrLinked = batch.RelationshipDecisions.Count(decision => decision.CreatedOrLinked), RelationshipsCreatedOrLinked = batch.RelationshipDecisions.Count(decision => decision.CreatedOrLinked),
RelationshipStageComplete = batch.RelationshipStageComplete || relationshipReview.IsComplete RelationshipStageComplete = batch.RelationshipStageComplete || relationshipReview.IsComplete,
KnowledgeIdentified = knowledgeReview.Candidates.Count + batch.KnowledgeDecisions.Count,
KnowledgeCreatedOrLinked = batch.KnowledgeDecisions.Count(decision => decision.CreatedOrLinked),
KnowledgeStageComplete = batch.KnowledgeStageComplete || knowledgeReview.IsComplete
} }
}; };
} }
@ -501,6 +509,38 @@ public sealed class OnboardingStoryIntelligenceService(
}; };
} }
public async Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> ImportKnowledgeAsync(Guid batchId, StoryIntelligenceKnowledgeImportForm form)
{
var batch = await batchStore.GetAsync(RequireUserId(), batchId);
if (batch is null)
{
return (null, new StoryIntelligenceImportCommitResult { Success = false, Message = "This Story Intelligence batch could not be found." });
}
var result = await knowledgeImport.ImportAsync(batch, form);
return (await GetProgressAsync(batchId), result);
}
public async Task<StoryIntelligenceKnowledgeImportResultViewModel?> GetKnowledgeImportResultAsync(Guid batchId)
{
var batch = await batchStore.GetAsync(RequireUserId(), batchId);
if (batch is null)
{
return null;
}
return new StoryIntelligenceKnowledgeImportResultViewModel
{
BatchID = batch.BatchID,
ProjectID = batch.ProjectID,
BookID = batch.BookID,
KnowledgeCreated = batch.LastKnowledgeImportResult?.KnowledgeCreated ?? 0,
KnowledgeLinked = batch.LastKnowledgeImportResult?.KnowledgeLinked ?? 0,
KnowledgeMerged = batch.LastKnowledgeImportResult?.KnowledgeMerged ?? 0,
KnowledgeIgnored = batch.LastKnowledgeImportResult?.KnowledgeIgnored ?? 0
};
}
public async Task<StoryIntelligenceCompletionViewModel?> GetCompletionAsync(Guid batchId) public async Task<StoryIntelligenceCompletionViewModel?> GetCompletionAsync(Guid batchId)
{ {
var progress = await GetProgressAsync(batchId); var progress = await GetProgressAsync(batchId);
@ -575,28 +615,41 @@ public sealed class OnboardingStoryIntelligenceService(
or StoryIntelligencePipelineStages.AssetImport or StoryIntelligencePipelineStages.AssetImport
or StoryIntelligencePipelineStages.RelationshipReview or StoryIntelligencePipelineStages.RelationshipReview
or StoryIntelligencePipelineStages.RelationshipImport or StoryIntelligencePipelineStages.RelationshipImport
or StoryIntelligencePipelineStages.KnowledgeReview
or StoryIntelligencePipelineStages.KnowledgeImport
or StoryIntelligencePipelineStages.Complete; or StoryIntelligencePipelineStages.Complete;
var locationStageComplete = HasCompletedStage(state, StoryIntelligencePipelineStages.LocationImport) var locationStageComplete = HasCompletedStage(state, StoryIntelligencePipelineStages.LocationImport)
|| state.CurrentStage is StoryIntelligencePipelineStages.AssetReview || state.CurrentStage is StoryIntelligencePipelineStages.AssetReview
or StoryIntelligencePipelineStages.AssetImport or StoryIntelligencePipelineStages.AssetImport
or StoryIntelligencePipelineStages.RelationshipReview or StoryIntelligencePipelineStages.RelationshipReview
or StoryIntelligencePipelineStages.RelationshipImport or StoryIntelligencePipelineStages.RelationshipImport
or StoryIntelligencePipelineStages.KnowledgeReview
or StoryIntelligencePipelineStages.KnowledgeImport
or StoryIntelligencePipelineStages.Complete; or StoryIntelligencePipelineStages.Complete;
var assetStageComplete = HasCompletedStage(state, StoryIntelligencePipelineStages.AssetImport) var assetStageComplete = HasCompletedStage(state, StoryIntelligencePipelineStages.AssetImport)
|| state.CurrentStage is StoryIntelligencePipelineStages.RelationshipReview || state.CurrentStage is StoryIntelligencePipelineStages.RelationshipReview
or StoryIntelligencePipelineStages.RelationshipImport or StoryIntelligencePipelineStages.RelationshipImport
or StoryIntelligencePipelineStages.KnowledgeReview
or StoryIntelligencePipelineStages.KnowledgeImport
or StoryIntelligencePipelineStages.Complete; or StoryIntelligencePipelineStages.Complete;
var relationshipStageComplete = HasCompletedStage(state, StoryIntelligencePipelineStages.RelationshipImport); var relationshipStageComplete = HasCompletedStage(state, StoryIntelligencePipelineStages.RelationshipImport)
|| state.CurrentStage is StoryIntelligencePipelineStages.KnowledgeReview
or StoryIntelligencePipelineStages.KnowledgeImport
or StoryIntelligencePipelineStages.Complete;
var knowledgeStageComplete = HasCompletedStage(state, StoryIntelligencePipelineStages.KnowledgeImport);
batch.CharacterStageComplete = characterStageComplete; batch.CharacterStageComplete = characterStageComplete;
batch.LocationStageComplete = locationStageComplete; batch.LocationStageComplete = locationStageComplete;
batch.AssetStageComplete = assetStageComplete; batch.AssetStageComplete = assetStageComplete;
batch.RelationshipStageComplete = relationshipStageComplete; batch.RelationshipStageComplete = relationshipStageComplete;
batch.KnowledgeStageComplete = knowledgeStageComplete;
await batchStore.SaveAsync(batch); await batchStore.SaveAsync(batch);
var route = relationshipStageComplete var route = knowledgeStageComplete
? StoryIntelligenceResumeRoutes.Complete ? StoryIntelligenceResumeRoutes.Complete
: relationshipStageComplete
? StoryIntelligenceResumeRoutes.Knowledge
: assetStageComplete : assetStageComplete
? StoryIntelligenceResumeRoutes.Relationships ? StoryIntelligenceResumeRoutes.Relationships
: locationStageComplete : locationStageComplete
@ -607,6 +660,8 @@ public sealed class OnboardingStoryIntelligenceService(
{ {
StoryIntelligencePipelineStages.RelationshipReview => StoryIntelligenceResumeRoutes.Relationships, StoryIntelligencePipelineStages.RelationshipReview => StoryIntelligenceResumeRoutes.Relationships,
StoryIntelligencePipelineStages.RelationshipImport => StoryIntelligenceResumeRoutes.Relationships, StoryIntelligencePipelineStages.RelationshipImport => StoryIntelligenceResumeRoutes.Relationships,
StoryIntelligencePipelineStages.KnowledgeReview => StoryIntelligenceResumeRoutes.Knowledge,
StoryIntelligencePipelineStages.KnowledgeImport => StoryIntelligenceResumeRoutes.Knowledge,
StoryIntelligencePipelineStages.CharacterReview => StoryIntelligenceResumeRoutes.Characters, StoryIntelligencePipelineStages.CharacterReview => StoryIntelligenceResumeRoutes.Characters,
StoryIntelligencePipelineStages.CharacterImport => StoryIntelligenceResumeRoutes.Characters, StoryIntelligencePipelineStages.CharacterImport => StoryIntelligenceResumeRoutes.Characters,
StoryIntelligencePipelineStages.SceneReview => StoryIntelligenceResumeRoutes.SceneReview, StoryIntelligencePipelineStages.SceneReview => StoryIntelligenceResumeRoutes.SceneReview,
@ -850,15 +905,20 @@ public sealed class OnboardingStoryIntelligenceBatch
public List<OnboardingStoryIntelligenceCharacterDecision> CharacterDecisions { get; } = []; public List<OnboardingStoryIntelligenceCharacterDecision> CharacterDecisions { get; } = [];
public List<OnboardingStoryIntelligenceLocationDecision> LocationDecisions { get; } = []; public List<OnboardingStoryIntelligenceLocationDecision> LocationDecisions { get; } = [];
public List<OnboardingStoryIntelligenceAssetDecision> AssetDecisions { get; } = []; public List<OnboardingStoryIntelligenceAssetDecision> AssetDecisions { get; } = [];
public List<OnboardingStoryIntelligenceRelationshipSelection> RelationshipSelections { get; } = [];
public List<OnboardingStoryIntelligenceRelationshipDecision> RelationshipDecisions { get; } = []; public List<OnboardingStoryIntelligenceRelationshipDecision> RelationshipDecisions { get; } = [];
public List<OnboardingStoryIntelligenceKnowledgeSelection> KnowledgeSelections { get; } = [];
public List<OnboardingStoryIntelligenceKnowledgeDecision> KnowledgeDecisions { get; } = [];
public StoryIntelligenceCharacterImportBatchResult? LastCharacterImportResult { get; set; } public StoryIntelligenceCharacterImportBatchResult? LastCharacterImportResult { get; set; }
public StoryIntelligenceLocationImportBatchResult? LastLocationImportResult { get; set; } public StoryIntelligenceLocationImportBatchResult? LastLocationImportResult { get; set; }
public StoryIntelligenceAssetImportBatchResult? LastAssetImportResult { get; set; } public StoryIntelligenceAssetImportBatchResult? LastAssetImportResult { get; set; }
public StoryIntelligenceRelationshipImportBatchResult? LastRelationshipImportResult { get; set; } public StoryIntelligenceRelationshipImportBatchResult? LastRelationshipImportResult { get; set; }
public StoryIntelligenceKnowledgeImportBatchResult? LastKnowledgeImportResult { get; set; }
public bool CharacterStageComplete { get; set; } public bool CharacterStageComplete { get; set; }
public bool LocationStageComplete { get; set; } public bool LocationStageComplete { get; set; }
public bool AssetStageComplete { get; set; } public bool AssetStageComplete { get; set; }
public bool RelationshipStageComplete { get; set; } public bool RelationshipStageComplete { get; set; }
public bool KnowledgeStageComplete { get; set; }
} }
public sealed class OnboardingStoryIntelligenceBatchItem public sealed class OnboardingStoryIntelligenceBatchItem
@ -906,6 +966,12 @@ public sealed class OnboardingStoryIntelligenceRelationshipDecision
public bool CreatedOrLinked { get; init; } public bool CreatedOrLinked { get; init; }
} }
public sealed class OnboardingStoryIntelligenceRelationshipSelection
{
public string Key { get; init; } = string.Empty;
public int RelationshipTypeID { get; init; }
}
public sealed class StoryIntelligenceCharacterImportBatchResult public sealed class StoryIntelligenceCharacterImportBatchResult
{ {
public int CharactersCreated { get; init; } public int CharactersCreated { get; init; }
@ -944,6 +1010,29 @@ public sealed class StoryIntelligenceRelationshipImportBatchResult
public int RelationshipEventsCreated { get; init; } public int RelationshipEventsCreated { get; init; }
} }
public sealed class OnboardingStoryIntelligenceKnowledgeDecision
{
public string Key { get; init; } = string.Empty;
public string Action { get; init; } = string.Empty;
public int? CharacterKnowledgeID { get; init; }
public string? KnowledgeLabel { get; init; }
public bool CreatedOrLinked { get; init; }
}
public sealed class OnboardingStoryIntelligenceKnowledgeSelection
{
public string Key { get; init; } = string.Empty;
public int KnowledgeStateID { get; init; }
}
public sealed class StoryIntelligenceKnowledgeImportBatchResult
{
public int KnowledgeCreated { get; init; }
public int KnowledgeLinked { get; init; }
public int KnowledgeMerged { get; init; }
public int KnowledgeIgnored { get; init; }
}
public static class StoryIntelligenceResumeRoutes public static class StoryIntelligenceResumeRoutes
{ {
public const string SceneReview = "SceneReview"; public const string SceneReview = "SceneReview";
@ -951,6 +1040,7 @@ public static class StoryIntelligenceResumeRoutes
public const string Locations = "Locations"; public const string Locations = "Locations";
public const string Assets = "Assets"; public const string Assets = "Assets";
public const string Relationships = "Relationships"; public const string Relationships = "Relationships";
public const string Knowledge = "Knowledge";
public const string Complete = "Complete"; public const string Complete = "Complete";
} }

View File

@ -0,0 +1,730 @@
using System.Text.Json;
using System.Text.RegularExpressions;
using Microsoft.AspNetCore.Mvc.Rendering;
using PlotLine.Data;
using PlotLine.Models;
using PlotLine.ViewModels;
namespace PlotLine.Services;
public interface IStoryIntelligenceKnowledgeImportService
{
Task<StoryIntelligenceKnowledgeReviewViewModel> BuildReviewAsync(OnboardingStoryIntelligenceBatch batch);
Task<StoryIntelligenceImportCommitResult> ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceKnowledgeImportForm form);
}
public sealed class StoryIntelligenceKnowledgeImportService(
IStoryIntelligenceResultRepository runs,
ICharacterRepository characters,
ISceneRepository scenes,
IAssetRepository assets,
IStoryIntelligencePipelineStateService pipelineState,
ILogger<StoryIntelligenceKnowledgeImportService> logger) : IStoryIntelligenceKnowledgeImportService
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true
};
public async Task<StoryIntelligenceKnowledgeReviewViewModel> BuildReviewAsync(OnboardingStoryIntelligenceBatch batch)
{
var lookupData = await characters.GetLookupsAsync(batch.ProjectID);
var data = await BuildCandidateDataAsync(batch, lookupData.KnowledgeStates);
var decidedKeys = batch.KnowledgeDecisions.Select(decision => decision.Key).ToHashSet(StringComparer.OrdinalIgnoreCase);
var selectedStateIds = batch.KnowledgeSelections.ToDictionary(selection => selection.Key, selection => selection.KnowledgeStateID, StringComparer.OrdinalIgnoreCase);
var visibleCandidates = data.Candidates.Where(candidate => !decidedKeys.Contains(candidate.Key)).ToList();
return new StoryIntelligenceKnowledgeReviewViewModel
{
HasCommittedScenes = data.HasCommittedScenes,
CanImport = visibleCandidates.Count > 0,
AlreadyLinkedCount = data.AlreadyLinkedCount,
IsComplete = data.HasCommittedScenes && visibleCandidates.Count == 0,
KnowledgeStateOptions = lookupData.KnowledgeStates
.OrderBy(state => state.SortOrder)
.ThenBy(state => state.StateName)
.Select(state => new SelectListItem(state.StateName, state.KnowledgeStateID.ToString()))
.ToList(),
Candidates = visibleCandidates.Select(candidate => new StoryIntelligenceKnowledgeReviewCandidateViewModel
{
Key = candidate.Key,
CharacterName = candidate.CharacterName,
KnowledgeStatement = candidate.KnowledgeStatement,
KnowledgeStateID = ResolveKnowledgeStateID(lookupData.KnowledgeStates, selectedStateIds.GetValueOrDefault(candidate.Key), candidate.KnowledgeStateName),
KnowledgeStateName = candidate.KnowledgeStateName,
AppearsInScenes = candidate.Appearances.Select(appearance => appearance.SceneID).Distinct().Count(),
Confidence = ConfidenceBand(candidate.Confidence),
FirstScene = candidate.FirstScene,
ExampleScene = candidate.ExampleScene,
Evidence = candidate.Evidence,
ExistingKnowledgeID = candidate.ExistingKnowledgeID,
ExistingKnowledgeLabel = candidate.ExistingKnowledgeLabel
}).ToList()
};
}
public async Task<StoryIntelligenceImportCommitResult> ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceKnowledgeImportForm form)
{
var lookupData = await characters.GetLookupsAsync(batch.ProjectID);
var data = await BuildCandidateDataAsync(batch, lookupData.KnowledgeStates);
if (!data.HasCommittedScenes)
{
return new StoryIntelligenceImportCommitResult { Success = false, Message = "Create scenes before importing knowledge." };
}
var choices = form.Knowledge
.Where(choice => !string.IsNullOrWhiteSpace(choice.Key))
.ToDictionary(choice => choice.Key, choice => choice, StringComparer.OrdinalIgnoreCase);
SaveKnowledgeSelections(batch, choices.Values);
if (choices.Count == 0)
{
if (data.Candidates.Count == 0)
{
batch.KnowledgeStageComplete = true;
batch.LastKnowledgeImportResult = new StoryIntelligenceKnowledgeImportBatchResult();
await pipelineState.RecordKnowledgeImportAsync(batch.ProjectID, batch.BookID);
return new StoryIntelligenceImportCommitResult
{
Success = true,
Message = "Knowledge review completed. No knowledge decisions were waiting."
};
}
return new StoryIntelligenceImportCommitResult { Success = false, Message = "Choose at least one knowledge item to create, link, merge or ignore." };
}
var stateIds = lookupData.KnowledgeStates.Select(state => state.KnowledgeStateID).ToHashSet();
var created = 0;
var linkedExisting = 0;
var merged = 0;
var ignored = 0;
var resolvedKnowledge = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
var candidatesByKey = data.Candidates.ToDictionary(candidate => candidate.Key, StringComparer.OrdinalIgnoreCase);
foreach (var candidate in data.Candidates)
{
if (!choices.TryGetValue(candidate.Key, out var choice))
{
continue;
}
if (string.Equals(choice.Action, StoryIntelligenceKnowledgeImportActions.Ignore, StringComparison.OrdinalIgnoreCase))
{
ignored++;
AddDecision(batch, candidate.Key, StoryIntelligenceKnowledgeImportActions.Ignore, null, KnowledgeLabel(candidate), false);
continue;
}
if (string.Equals(choice.Action, StoryIntelligenceKnowledgeImportActions.Alias, StringComparison.OrdinalIgnoreCase))
{
continue;
}
var knowledgeId = string.Equals(choice.Action, StoryIntelligenceKnowledgeImportActions.LinkExisting, StringComparison.OrdinalIgnoreCase)
? candidate.ExistingKnowledgeID
: null;
if (!knowledgeId.HasValue)
{
if (!choice.KnowledgeStateID.HasValue || !stateIds.Contains(choice.KnowledgeStateID.Value))
{
return new StoryIntelligenceImportCommitResult { Success = false, Message = "Choose a valid knowledge state for each knowledge item you want to create." };
}
var firstAppearance = candidate.Appearances.OrderBy(appearance => appearance.SceneNumber).First();
knowledgeId = await characters.SaveKnowledgeAsync(new CharacterKnowledgeItem
{
CharacterID = candidate.CharacterID,
StoryAssetID = candidate.StoryAssetID,
PlotThreadID = null,
SceneID = firstAppearance.SceneID,
KnowledgeStateID = choice.KnowledgeStateID.Value,
SourceEventID = null,
Description = BuildKnowledgeDescription(candidate, firstAppearance)
});
created++;
}
else
{
linkedExisting++;
}
resolvedKnowledge[candidate.Key] = knowledgeId.Value;
AddDecision(batch, candidate.Key, choice.Action, knowledgeId.Value, KnowledgeLabel(candidate), true);
}
foreach (var candidate in data.Candidates)
{
if (!choices.TryGetValue(candidate.Key, out var choice)
|| !string.Equals(choice.Action, StoryIntelligenceKnowledgeImportActions.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, StoryIntelligenceKnowledgeImportActions.Ignore, StringComparison.OrdinalIgnoreCase)
|| string.Equals(targetChoice.Action, StoryIntelligenceKnowledgeImportActions.Alias, StringComparison.OrdinalIgnoreCase)
|| !candidatesByKey.ContainsKey(targetKey)
|| !resolvedKnowledge.TryGetValue(targetKey, out var targetKnowledgeId))
{
logger.LogWarning(
"Story Intelligence knowledge merge candidate {CandidateKey} could not be imported because target {TargetKey} was not a resolved create/link target.",
candidate.Key,
targetKey);
continue;
}
resolvedKnowledge[candidate.Key] = targetKnowledgeId;
merged++;
AddDecision(batch, candidate.Key, StoryIntelligenceKnowledgeImportActions.Alias, targetKnowledgeId, KnowledgeLabel(candidate), true);
}
batch.KnowledgeStageComplete = true;
batch.LastKnowledgeImportResult = new StoryIntelligenceKnowledgeImportBatchResult
{
KnowledgeCreated = created,
KnowledgeLinked = linkedExisting,
KnowledgeMerged = merged,
KnowledgeIgnored = ignored
};
await pipelineState.RecordKnowledgeImportAsync(batch.ProjectID, batch.BookID);
logger.LogInformation(
"Imported Story Intelligence knowledge for batch {BatchID}. Created={Created} LinkedExisting={LinkedExisting} Merged={Merged} Ignored={Ignored}",
batch.BatchID,
created,
linkedExisting,
merged,
ignored);
return new StoryIntelligenceImportCommitResult
{
Success = true,
Message = $"Knowledge imported. {created:N0} created, {linkedExisting:N0} linked, {merged:N0} merged, {ignored:N0} ignored."
};
}
private async Task<KnowledgeCandidateData> BuildCandidateDataAsync(OnboardingStoryIntelligenceBatch batch, IReadOnlyList<KnowledgeState> knowledgeStates)
{
var characterIndex = await BuildCharacterIndexAsync(batch.ProjectID);
var assetIndex = await BuildAssetIndexAsync(batch.ProjectID);
var existingKnowledge = await ListExistingKnowledgeAsync(batch.ProjectID);
var groups = new Dictionary<string, KnowledgeCandidate>(StringComparer.OrdinalIgnoreCase);
var alreadyLinked = 0;
var hasCommittedScenes = false;
foreach (var item in batch.Items)
{
var commit = await runs.GetImportCommitAsync(item.RunID);
if (commit is null || !string.Equals(commit.Status, StoryIntelligenceImportCommitStatuses.Completed, StringComparison.OrdinalIgnoreCase))
{
continue;
}
hasCommittedScenes = true;
var importedScenes = (await scenes.ListByChapterAsync(item.ChapterID))
.Where(scene => scene.ImportRunID == item.RunID)
.ToList();
var importedByRange = importedScenes
.Where(scene => scene.SourceStartParagraph.HasValue && scene.SourceEndParagraph.HasValue)
.ToDictionary(scene => $"{scene.SourceStartParagraph}-{scene.SourceEndParagraph}", scene => scene, StringComparer.OrdinalIgnoreCase);
var importedByNumber = importedScenes.ToDictionary(scene => Convert.ToInt32(scene.SceneNumber), scene => scene);
var sceneResults = await runs.ListSceneResultsAsync(item.RunID);
foreach (var sceneResult in sceneResults)
{
var parsed = TryReadScene(sceneResult);
if (parsed is null)
{
continue;
}
var importedScene = ResolveImportedScene(sceneResult, importedByRange, importedByNumber);
if (importedScene is null)
{
continue;
}
var existingSceneKnowledge = await characters.ListKnowledgeBySceneAsync(importedScene.SceneID);
alreadyLinked += existingSceneKnowledge.Count;
AddSceneKnowledge(groups, characterIndex, assetIndex, existingKnowledge, importedScene, parsed, knowledgeStates);
}
}
var candidates = groups.Values
.OrderByDescending(candidate => candidate.Appearances.Select(appearance => appearance.SceneID).Distinct().Count())
.ThenBy(candidate => candidate.CharacterName)
.ThenBy(candidate => candidate.KnowledgeStatement)
.ToList();
return new KnowledgeCandidateData(hasCommittedScenes, alreadyLinked, candidates);
}
private static void AddSceneKnowledge(
Dictionary<string, KnowledgeCandidate> groups,
CharacterIndex characterIndex,
AssetIndex assetIndex,
IReadOnlyDictionary<string, CharacterKnowledgeItem> existingKnowledge,
Scene importedScene,
SceneIntelligenceScene parsed,
IReadOnlyList<KnowledgeState> knowledgeStates)
{
foreach (var change in parsed.KnowledgeChanges ?? [])
{
AddKnowledge(groups, characterIndex, assetIndex, existingKnowledge, importedScene, parsed, change.RecipientCharacter, change.KnowledgeItem, change.ChangeType, change.Evidence, change.Confidence, knowledgeStates);
}
foreach (var observation in parsed.Observations ?? [])
{
if (!IsKnowledgeObservation(observation))
{
continue;
}
AddKnowledge(
groups,
characterIndex,
assetIndex,
existingKnowledge,
importedScene,
parsed,
observation.SubjectName,
FirstConfigured(observation.Description, JoinConfigured(observation.Predicate, observation.ObjectName)),
FirstConfigured(observation.Predicate, observation.ObservationType),
observation.Evidence,
observation.Confidence,
knowledgeStates);
}
}
private static void AddKnowledge(
Dictionary<string, KnowledgeCandidate> groups,
CharacterIndex characterIndex,
AssetIndex assetIndex,
IReadOnlyDictionary<string, CharacterKnowledgeItem> existingKnowledge,
Scene importedScene,
SceneIntelligenceScene parsed,
string? characterName,
string? knowledgeItem,
string? changeType,
string? evidence,
decimal? confidence,
IReadOnlyList<KnowledgeState> knowledgeStates)
{
var character = characterIndex.Find(characterName);
var statement = CleanKnowledgeStatement(knowledgeItem);
if (character is null || string.IsNullOrWhiteSpace(statement))
{
return;
}
var key = KnowledgeKey(character.CharacterID, statement);
if (!groups.TryGetValue(key, out var candidate))
{
var existingKey = ExistingKnowledgeKey(character.CharacterID, statement);
existingKnowledge.TryGetValue(existingKey, out var existingMatch);
var stateName = KnowledgeStateFromSignal(changeType, statement, knowledgeStates);
var assetMatch = assetIndex.Find(statement);
candidate = new KnowledgeCandidate(
key,
character.CharacterID,
character.CharacterName,
statement,
stateName,
assetMatch?.StoryAssetID,
existingMatch?.CharacterKnowledgeID,
existingMatch is null ? null : ExistingKnowledgeLabel(existingMatch));
groups[key] = candidate;
}
if ((confidence ?? 0m) >= (candidate.Confidence ?? 0m))
{
candidate.KnowledgeStateName = KnowledgeStateFromSignal(changeType, statement, knowledgeStates);
}
candidate.Confidence = Max(candidate.Confidence, confidence);
candidate.FirstScene ??= BuildSceneReference(importedScene);
candidate.ExampleScene ??= BuildFirstAppearance(importedScene, parsed);
candidate.Evidence ??= Clean(evidence);
candidate.Appearances.Add(new KnowledgeAppearanceImport(
importedScene.SceneID,
importedScene.SceneNumber,
importedScene.SceneTitle,
changeType,
evidence,
confidence));
}
private async Task<IReadOnlyDictionary<string, CharacterKnowledgeItem>> ListExistingKnowledgeAsync(int projectId)
{
var rows = new Dictionary<string, CharacterKnowledgeItem>(StringComparer.OrdinalIgnoreCase);
foreach (var character in await characters.ListCharactersAsync(projectId))
{
foreach (var item in await characters.ListKnowledgeByCharacterAsync(character.CharacterID))
{
var key = ExistingKnowledgeKey(item.CharacterID, KnowledgeTitle(item));
if (!rows.ContainsKey(key))
{
rows[key] = item;
}
}
}
return rows;
}
private async Task<CharacterIndex> BuildCharacterIndexAsync(int projectId)
{
var index = new CharacterIndex();
foreach (var character in await characters.ListCharactersAsync(projectId))
{
index.Add(character.CharacterName, character.CharacterID, character.CharacterName);
index.Add(character.ShortName, character.CharacterID, character.CharacterName);
foreach (var alias in await characters.ListAliasesAsync(character.CharacterID))
{
index.Add(alias.Alias, character.CharacterID, character.CharacterName);
}
}
return index;
}
private async Task<AssetIndex> BuildAssetIndexAsync(int projectId)
{
var index = new AssetIndex();
foreach (var asset in await assets.ListAssetsAsync(projectId))
{
index.Add(asset.AssetName, asset.StoryAssetID, asset.AssetName);
foreach (var alias in await assets.ListAliasesAsync(asset.StoryAssetID))
{
index.Add(alias.Alias, asset.StoryAssetID, asset.AssetName);
}
}
return index;
}
private static Scene? ResolveImportedScene(
StoryIntelligenceSavedSceneResult sceneResult,
IReadOnlyDictionary<string, Scene> byRange,
IReadOnlyDictionary<int, Scene> byNumber)
{
if (sceneResult.StartParagraph.HasValue
&& sceneResult.EndParagraph.HasValue
&& byRange.TryGetValue($"{sceneResult.StartParagraph}-{sceneResult.EndParagraph}", out var rangeMatch))
{
return rangeMatch;
}
return byNumber.TryGetValue(sceneResult.TemporarySceneNumber, out var numberMatch) ? numberMatch : null;
}
private static SceneIntelligenceScene? TryReadScene(StoryIntelligenceSavedSceneResult result)
{
if (string.IsNullOrWhiteSpace(result.ParsedJson))
{
return null;
}
try
{
var direct = JsonSerializer.Deserialize<SceneIntelligenceScene>(result.ParsedJson, JsonOptions);
if (!string.IsNullOrWhiteSpace(direct?.SchemaVersion))
{
return direct;
}
using var document = JsonDocument.Parse(result.ParsedJson);
if (document.RootElement.TryGetProperty("parsedScene", out var parsedScene))
{
return parsedScene.Deserialize<SceneIntelligenceScene>(JsonOptions);
}
}
catch (JsonException)
{
}
return null;
}
private static bool IsKnowledgeObservation(SceneIntelligenceObservation observation)
{
var type = Clean(observation.ObservationType);
if (!type.Contains("knowledge", StringComparison.OrdinalIgnoreCase)
&& !type.Contains("discover", StringComparison.OrdinalIgnoreCase)
&& !type.Contains("belief", StringComparison.OrdinalIgnoreCase)
&& !type.Contains("suspect", StringComparison.OrdinalIgnoreCase))
{
return false;
}
return IsCharacterEntity(observation.SubjectEntityType)
&& !string.IsNullOrWhiteSpace(observation.SubjectName)
&& !string.IsNullOrWhiteSpace(FirstConfigured(observation.Description, observation.Predicate, observation.ObjectName));
}
private static string KnowledgeStateFromSignal(string? changeType, string? statement, IReadOnlyList<KnowledgeState> states)
{
var value = NormaliseForMatching($"{changeType} {statement}");
var preferred =
ContainsAny(value, "unaware", "does not know", "doesn't know", "not know", "forgets", "forgot", "stops believing") ? "Unaware" :
ContainsAny(value, "suspects", "suspect", "suspicion") ? "Suspects" :
ContainsAny(value, "misunderstands", "misunderstand", "mistaken", "false belief", "wrongly believes") ? "Misunderstands" :
ContainsAny(value, "believes", "belief", "partially", "may know") ? "Partially Knows" :
ContainsAny(value, "learns", "discovers", "confirms", "realises", "realizes", "is told", "knows", "reveals", "revealed") ? "Knows" :
"Knows";
return states.FirstOrDefault(state => string.Equals(state.StateName, preferred, StringComparison.OrdinalIgnoreCase))?.StateName
?? states.FirstOrDefault(state => string.Equals(state.StateName, "Knows", StringComparison.OrdinalIgnoreCase))?.StateName
?? states.FirstOrDefault()?.StateName
?? "Knows";
}
private static int ResolveKnowledgeStateID(IReadOnlyList<KnowledgeState> states, int? selectedKnowledgeStateId, string? suggestedState)
=> selectedKnowledgeStateId.HasValue && states.Any(state => state.KnowledgeStateID == selectedKnowledgeStateId.Value)
? selectedKnowledgeStateId.Value
: states.FirstOrDefault(state => string.Equals(state.StateName, suggestedState, StringComparison.OrdinalIgnoreCase))?.KnowledgeStateID
?? states.FirstOrDefault(state => string.Equals(state.StateName, "Knows", StringComparison.OrdinalIgnoreCase))?.KnowledgeStateID
?? states.First().KnowledgeStateID;
private static void SaveKnowledgeSelections(OnboardingStoryIntelligenceBatch batch, IEnumerable<StoryIntelligenceKnowledgeImportChoiceForm> choices)
{
foreach (var choice in choices)
{
if (string.IsNullOrWhiteSpace(choice.Key) || choice.KnowledgeStateID is not { } knowledgeStateId)
{
continue;
}
batch.KnowledgeSelections.RemoveAll(selection => string.Equals(selection.Key, choice.Key, StringComparison.OrdinalIgnoreCase));
batch.KnowledgeSelections.Add(new OnboardingStoryIntelligenceKnowledgeSelection
{
Key = choice.Key,
KnowledgeStateID = knowledgeStateId
});
}
}
private static void AddDecision(OnboardingStoryIntelligenceBatch batch, string key, string action, int? knowledgeId, string? label, bool createdOrLinked)
{
batch.KnowledgeDecisions.RemoveAll(decision => string.Equals(decision.Key, key, StringComparison.OrdinalIgnoreCase));
batch.KnowledgeDecisions.Add(new OnboardingStoryIntelligenceKnowledgeDecision
{
Key = key,
Action = action,
CharacterKnowledgeID = knowledgeId,
KnowledgeLabel = label,
CreatedOrLinked = createdOrLinked
});
}
private static string BuildKnowledgeDescription(KnowledgeCandidate candidate, KnowledgeAppearanceImport appearance)
{
var lines = new List<string> { candidate.KnowledgeStatement };
if (!string.IsNullOrWhiteSpace(appearance.ChangeType))
{
lines.Add($"Change: {Clean(appearance.ChangeType)}.");
}
if (!string.IsNullOrWhiteSpace(appearance.Evidence))
{
lines.Add($"Evidence: {Clean(appearance.Evidence)}");
}
lines.Add("Imported from Story Intelligence knowledge review.");
return string.Join(Environment.NewLine, lines);
}
private static string KnowledgeLabel(KnowledgeCandidate candidate)
=> $"{candidate.CharacterName}: {candidate.KnowledgeStatement}";
private static string ExistingKnowledgeLabel(CharacterKnowledgeItem item)
=> $"{item.CharacterName}: {item.KnowledgeStateName} - {KnowledgeTitle(item)}";
private static string KnowledgeTitle(CharacterKnowledgeItem item)
=> CleanKnowledgeStatement(item.Description);
private static string CleanKnowledgeStatement(string? value)
{
var cleaned = Clean(value);
if (string.IsNullOrWhiteSpace(cleaned))
{
return string.Empty;
}
foreach (var prefix in new[] { "Knowledge:", "Evidence:", "Change:" })
{
if (cleaned.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
{
cleaned = cleaned[prefix.Length..].Trim();
}
}
var firstLine = cleaned.Split('\n', '\r').Select(line => line.Trim()).FirstOrDefault(line => !string.IsNullOrWhiteSpace(line));
return firstLine ?? cleaned;
}
private static string KnowledgeKey(int characterId, string statement)
=> $"{characterId}:{CanonicalKnowledgeKey(statement)}";
private static string ExistingKnowledgeKey(int characterId, string statement)
=> KnowledgeKey(characterId, statement);
private static string CanonicalKnowledgeKey(string? value)
{
var normalised = NormaliseForMatching(value);
var possessiveFamily = Regex.Match(
normalised,
@"\b(?<subject>\w+)\s+(?:is|was)\s+(?<holder>\w+)\s+s\s+(?:biological\s+)?(?<relation>father|mother|parent|sister|brother|sibling|aunt|uncle|cousin|child|son|daughter)\b",
RegexOptions.IgnoreCase);
if (possessiveFamily.Success)
{
return $"{possessiveFamily.Groups["relation"].Value} {possessiveFamily.Groups["subject"].Value} {possessiveFamily.Groups["holder"].Value}";
}
var pronounFamily = Regex.Match(
normalised,
@"\b(?<holder>\w+)\s+.*?\b(?<subject>\w+)\s+(?:is|was)\s+(?:her|his|their)\s+(?:biological\s+)?(?<relation>father|mother|parent|sister|brother|sibling|aunt|uncle|cousin|child|son|daughter)\b",
RegexOptions.IgnoreCase);
if (pronounFamily.Success)
{
return $"{pronounFamily.Groups["relation"].Value} {pronounFamily.Groups["subject"].Value} {pronounFamily.Groups["holder"].Value}";
}
normalised = Regex.Replace(normalised, @"\b(discovers?|learns?|realises?|realizes?|knows?|believes?|suspects?|confirms?|is told|that|the|a|an|his|her|their|its|about)\b", " ", RegexOptions.IgnoreCase);
return Whitespace.Replace(normalised, " ").Trim();
}
private static string? BuildFirstAppearance(Scene scene, SceneIntelligenceScene parsed)
{
var summary = Clean(parsed.Summary?.Short);
return string.IsNullOrWhiteSpace(summary)
? BuildSceneReference(scene)
: $"{BuildSceneReference(scene)}: {summary}";
}
private static string BuildSceneReference(Scene scene)
=> $"Scene {scene.SceneNumber:g}";
private static bool IsCharacterEntity(string? entityType)
=> string.Equals(Clean(entityType), "Character", StringComparison.OrdinalIgnoreCase);
private static string ConfidenceBand(decimal? confidence)
=> confidence switch
{
>= 0.75m => "High",
>= 0.45m => "Medium",
null => "Unknown",
_ => "Low"
};
private static decimal? Max(decimal? current, decimal? next)
=> current.HasValue && next.HasValue ? Math.Max(current.Value, next.Value) : current ?? next;
private static string? JoinConfigured(params string?[] values)
{
var configured = values.Select(Clean).Where(value => !string.IsNullOrWhiteSpace(value)).ToList();
return configured.Count == 0 ? null : string.Join(" ", configured);
}
private static string? FirstConfigured(params string?[] values)
=> values.Select(Clean).FirstOrDefault(value => !string.IsNullOrWhiteSpace(value));
private static string Normalise(string? value)
=> Clean(value).ToLowerInvariant();
private static string NormaliseForMatching(string? value)
=> $" {NonWord.Replace(Normalise(value), " ")} ";
private static bool ContainsAny(string value, params string[] needles)
=> needles.Any(needle => value.Contains(NormaliseForMatching(needle), StringComparison.Ordinal));
private static string Clean(string? value)
=> string.IsNullOrWhiteSpace(value) ? string.Empty : Whitespace.Replace(value, " ").Trim();
private static readonly Regex Whitespace = new(@"\s+", RegexOptions.Compiled);
private static readonly Regex NonWord = new(@"[^\p{L}\p{N}]+", RegexOptions.Compiled);
private sealed record KnowledgeCandidateData(bool HasCommittedScenes, int AlreadyLinkedCount, IReadOnlyList<KnowledgeCandidate> Candidates);
private sealed class KnowledgeCandidate(
string key,
int characterId,
string characterName,
string knowledgeStatement,
string knowledgeStateName,
int? storyAssetId,
int? existingKnowledgeId,
string? existingKnowledgeLabel)
{
public string Key { get; } = key;
public int CharacterID { get; } = characterId;
public string CharacterName { get; } = characterName;
public string KnowledgeStatement { get; } = knowledgeStatement;
public string KnowledgeStateName { get; set; } = knowledgeStateName;
public int? StoryAssetID { get; } = storyAssetId;
public int? ExistingKnowledgeID { get; } = existingKnowledgeId;
public string? ExistingKnowledgeLabel { get; } = existingKnowledgeLabel;
public decimal? Confidence { get; set; }
public string? FirstScene { get; set; }
public string? ExampleScene { get; set; }
public string? Evidence { get; set; }
public List<KnowledgeAppearanceImport> Appearances { get; } = [];
}
private sealed record KnowledgeAppearanceImport(
int SceneID,
decimal SceneNumber,
string SceneTitle,
string? ChangeType,
string? Evidence,
decimal? Confidence);
private sealed class CharacterIndex
{
private readonly Dictionary<string, CharacterMatch> byName = new(StringComparer.OrdinalIgnoreCase);
public void Add(string? name, int characterId, string characterName)
{
var key = Clean(name);
if (string.IsNullOrWhiteSpace(key) || byName.ContainsKey(key))
{
return;
}
byName[key] = new CharacterMatch(characterId, characterName);
}
public CharacterMatch? Find(string? name)
=> byName.TryGetValue(Clean(name), out var match) ? match : null;
}
private sealed class AssetIndex
{
private readonly Dictionary<string, AssetMatch> byName = new(StringComparer.OrdinalIgnoreCase);
public void Add(string? name, int storyAssetId, string assetName)
{
var key = Clean(name);
if (string.IsNullOrWhiteSpace(key) || byName.ContainsKey(key))
{
return;
}
byName[key] = new AssetMatch(storyAssetId, assetName);
}
public AssetMatch? Find(string? statement)
{
var value = NormaliseForMatching(statement);
return byName.Values.FirstOrDefault(asset => value.Contains(NormaliseForMatching(asset.AssetName), StringComparison.Ordinal));
}
}
private sealed record CharacterMatch(int CharacterID, string CharacterName);
private sealed record AssetMatch(int StoryAssetID, string AssetName);
}

View File

@ -60,6 +60,7 @@ public sealed class StoryPromptRepository(
ILogger<StoryPromptRepository> logger) : IStoryPromptRepository ILogger<StoryPromptRepository> logger) : IStoryPromptRepository
{ {
private const string PromptFolder = "Docs/AI"; private const string PromptFolder = "Docs/AI";
private const string RepositoryPromptFolder = "PlotLine/Docs/AI";
public async Task<string> LoadPromptAsync(string fileName, CancellationToken cancellationToken) public async Task<string> LoadPromptAsync(string fileName, CancellationToken cancellationToken)
{ {
@ -77,10 +78,10 @@ public sealed class StoryPromptRepository(
return cachedPrompt; return cachedPrompt;
} }
var promptPath = Path.Combine(environment.ContentRootPath, PromptFolder, fileName); var promptPath = ResolvePromptPath(fileName);
if (!File.Exists(promptPath)) if (promptPath is null)
{ {
throw new FileNotFoundException($"Story Intelligence prompt file '{fileName}' was not found in /Docs/AI/.", promptPath); throw new FileNotFoundException($"Story Intelligence prompt file '{fileName}' was not found in /{PromptFolder}/ or /{RepositoryPromptFolder}/.", Path.Combine(environment.ContentRootPath, PromptFolder, fileName));
} }
var prompt = await File.ReadAllTextAsync(promptPath, cancellationToken); var prompt = await File.ReadAllTextAsync(promptPath, cancellationToken);
@ -93,6 +94,23 @@ public sealed class StoryPromptRepository(
logger.LogInformation("Loaded Story Intelligence prompt {PromptFile} from {PromptPath}.", fileName, promptPath); logger.LogInformation("Loaded Story Intelligence prompt {PromptFile} from {PromptPath}.", fileName, promptPath);
return prompt; return prompt;
} }
private string? ResolvePromptPath(string fileName)
{
var promptPath = Path.Combine(environment.ContentRootPath, PromptFolder, fileName);
if (File.Exists(promptPath))
{
return promptPath;
}
var repositoryPromptPath = Path.Combine(environment.ContentRootPath, RepositoryPromptFolder, fileName);
if (File.Exists(repositoryPromptPath))
{
return repositoryPromptPath;
}
return null;
}
} }
public sealed class StoryPromptBuilder : IStoryPromptBuilder public sealed class StoryPromptBuilder : IStoryPromptBuilder

View File

@ -13,6 +13,7 @@ public interface IStoryIntelligencePipelineStateService
Task RecordLocationImportAsync(int projectId, int bookId); Task RecordLocationImportAsync(int projectId, int bookId);
Task RecordAssetImportAsync(int projectId, int bookId); Task RecordAssetImportAsync(int projectId, int bookId);
Task RecordRelationshipImportAsync(int projectId, int bookId); Task RecordRelationshipImportAsync(int projectId, int bookId);
Task RecordKnowledgeImportAsync(int projectId, int bookId);
Task<IReadOnlyList<StoryIntelligenceCommittedRunSummary>> ListCommittedRunsByBookAsync(int bookId, int userId); Task<IReadOnlyList<StoryIntelligenceCommittedRunSummary>> ListCommittedRunsByBookAsync(int bookId, int userId);
} }
@ -128,8 +129,23 @@ public sealed class StoryIntelligencePipelineStateService(
{ {
ProjectID = projectId, ProjectID = projectId,
BookID = bookId, BookID = bookId,
CurrentStage = StoryIntelligencePipelineStages.Complete, CurrentStage = StoryIntelligencePipelineStages.KnowledgeReview,
LastCompletedStage = StoryIntelligencePipelineStages.RelationshipImport, LastCompletedStage = StoryIntelligencePipelineStages.RelationshipImport,
CurrentReviewStage = StoryIntelligencePipelineStages.KnowledgeReview,
Status = StoryIntelligencePipelineStatuses.NeedsReview,
CompletedUtc = null,
LastRunID = null
});
}
public async Task RecordKnowledgeImportAsync(int projectId, int bookId)
{
await pipelines.UpsertAsync(new StoryIntelligenceBookPipelineSaveRequest
{
ProjectID = projectId,
BookID = bookId,
CurrentStage = StoryIntelligencePipelineStages.Complete,
LastCompletedStage = StoryIntelligencePipelineStages.KnowledgeImport,
CurrentReviewStage = null, CurrentReviewStage = null,
Status = StoryIntelligencePipelineStatuses.Complete, Status = StoryIntelligencePipelineStatuses.Complete,
CompletedUtc = DateTime.UtcNow, CompletedUtc = DateTime.UtcNow,

View File

@ -1,5 +1,6 @@
using System.Text.Json; using System.Text.Json;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using Microsoft.AspNetCore.Mvc.Rendering;
using PlotLine.Data; using PlotLine.Data;
using PlotLine.Models; using PlotLine.Models;
using PlotLine.ViewModels; using PlotLine.ViewModels;
@ -28,6 +29,9 @@ public sealed class StoryIntelligenceRelationshipImportService(
public async Task<StoryIntelligenceRelationshipReviewViewModel> BuildReviewAsync(OnboardingStoryIntelligenceBatch batch) public async Task<StoryIntelligenceRelationshipReviewViewModel> BuildReviewAsync(OnboardingStoryIntelligenceBatch batch)
{ {
var data = await BuildCandidateDataAsync(batch); var data = await BuildCandidateDataAsync(batch);
var lookupData = await characters.GetLookupsAsync(batch.ProjectID);
var typeOptions = ToRelationshipTypeOptions(lookupData.RelationshipTypes);
var selectedTypeIds = batch.RelationshipSelections.ToDictionary(selection => selection.Key, selection => selection.RelationshipTypeID, StringComparer.OrdinalIgnoreCase);
var decidedKeys = batch.RelationshipDecisions.Select(decision => decision.Key).ToHashSet(StringComparer.OrdinalIgnoreCase); var decidedKeys = batch.RelationshipDecisions.Select(decision => decision.Key).ToHashSet(StringComparer.OrdinalIgnoreCase);
var visibleCandidates = data.Candidates.Where(candidate => !decidedKeys.Contains(candidate.Key)).ToList(); var visibleCandidates = data.Candidates.Where(candidate => !decidedKeys.Contains(candidate.Key)).ToList();
return new StoryIntelligenceRelationshipReviewViewModel return new StoryIntelligenceRelationshipReviewViewModel
@ -36,12 +40,14 @@ public sealed class StoryIntelligenceRelationshipImportService(
CanImport = visibleCandidates.Count > 0, CanImport = visibleCandidates.Count > 0,
AlreadyLinkedCount = data.AlreadyLinkedCount, AlreadyLinkedCount = data.AlreadyLinkedCount,
IsComplete = data.HasCommittedScenes && visibleCandidates.Count == 0, IsComplete = data.HasCommittedScenes && visibleCandidates.Count == 0,
RelationshipTypeOptions = typeOptions,
Candidates = visibleCandidates.Select(candidate => new StoryIntelligenceRelationshipReviewCandidateViewModel Candidates = visibleCandidates.Select(candidate => new StoryIntelligenceRelationshipReviewCandidateViewModel
{ {
Key = candidate.Key, Key = candidate.Key,
CharacterAName = candidate.CharacterAName, CharacterAName = candidate.CharacterAName,
CharacterBName = candidate.CharacterBName, CharacterBName = candidate.CharacterBName,
RelationshipType = candidate.RelationshipType, RelationshipType = candidate.RelationshipType,
RelationshipTypeID = ResolveRelationshipTypeID(lookupData.RelationshipTypes, selectedTypeIds.GetValueOrDefault(candidate.Key), candidate.RelationshipType),
AppearsInScenes = candidate.Appearances.Select(appearance => appearance.SceneID).Distinct().Count(), AppearsInScenes = candidate.Appearances.Select(appearance => appearance.SceneID).Distinct().Count(),
Confidence = ConfidenceBand(candidate.Confidence), Confidence = ConfidenceBand(candidate.Confidence),
FirstAppearance = candidate.FirstAppearanceNote, FirstAppearance = candidate.FirstAppearanceNote,
@ -65,6 +71,7 @@ public sealed class StoryIntelligenceRelationshipImportService(
var choices = form.Relationships var choices = form.Relationships
.Where(choice => !string.IsNullOrWhiteSpace(choice.Key)) .Where(choice => !string.IsNullOrWhiteSpace(choice.Key))
.ToDictionary(choice => choice.Key, choice => choice, StringComparer.OrdinalIgnoreCase); .ToDictionary(choice => choice.Key, choice => choice, StringComparer.OrdinalIgnoreCase);
SaveRelationshipSelections(batch, choices.Values);
if (choices.Count == 0) if (choices.Count == 0)
{ {
if (data.Candidates.Count == 0) if (data.Candidates.Count == 0)
@ -83,6 +90,7 @@ public sealed class StoryIntelligenceRelationshipImportService(
} }
var lookupData = await characters.GetLookupsAsync(batch.ProjectID); var lookupData = await characters.GetLookupsAsync(batch.ProjectID);
var relationshipTypesById = lookupData.RelationshipTypes.ToDictionary(type => type.RelationshipTypeID);
var created = 0; var created = 0;
var linkedExisting = 0; var linkedExisting = 0;
var merged = 0; var merged = 0;
@ -116,12 +124,17 @@ public sealed class StoryIntelligenceRelationshipImportService(
if (!relationshipId.HasValue) 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 relationshipId = await characters.SaveRelationshipAsync(new CharacterRelationship
{ {
ProjectID = batch.ProjectID, ProjectID = batch.ProjectID,
CharacterAID = candidate.CharacterAID, CharacterAID = candidate.CharacterAID,
CharacterBID = candidate.CharacterBID, CharacterBID = candidate.CharacterBID,
RelationshipTypeID = MatchRelationshipType(lookupData.RelationshipTypes, choice.RelationshipType ?? candidate.RelationshipType), RelationshipTypeID = choice.RelationshipTypeID.Value,
IsPermanent = false, IsPermanent = false,
StartSceneID = candidate.Appearances.OrderBy(appearance => appearance.SceneNumber).FirstOrDefault()?.SceneID, StartSceneID = candidate.Appearances.OrderBy(appearance => appearance.SceneNumber).FirstOrDefault()?.SceneID,
EndSceneID = null, EndSceneID = null,
@ -358,7 +371,7 @@ public sealed class StoryIntelligenceRelationshipImportService(
characterA.CharacterName, characterA.CharacterName,
characterB.CharacterID, characterB.CharacterID,
characterB.CharacterName, characterB.CharacterName,
RelationshipTypeFromSignal(relationshipSignal), RelationshipTypeFromSignal(relationshipSignal, evidence),
existingMatch?.CharacterRelationshipID, existingMatch?.CharacterRelationshipID,
existingMatch is null ? null : $"{existingMatch.CharacterAName} / {existingMatch.RelationshipTypeName} / {existingMatch.CharacterBName}"); existingMatch is null ? null : $"{existingMatch.CharacterAName} / {existingMatch.RelationshipTypeName} / {existingMatch.CharacterBName}");
groups[key] = candidate; groups[key] = candidate;
@ -366,7 +379,7 @@ public sealed class StoryIntelligenceRelationshipImportService(
if ((confidence ?? 0m) >= (candidate.Confidence ?? 0m)) if ((confidence ?? 0m) >= (candidate.Confidence ?? 0m))
{ {
candidate.RelationshipType = RelationshipTypeFromSignal(relationshipSignal); candidate.RelationshipType = RelationshipTypeFromSignal(relationshipSignal, evidence);
} }
candidate.Confidence = Max(candidate.Confidence, confidence); candidate.Confidence = Max(candidate.Confidence, confidence);
@ -459,15 +472,12 @@ public sealed class StoryIntelligenceRelationshipImportService(
&& !string.IsNullOrWhiteSpace(observation.ObjectName); && !string.IsNullOrWhiteSpace(observation.ObjectName);
} }
private static int MatchRelationshipType(IReadOnlyList<RelationshipType> types, string? signal) private static int ResolveRelationshipTypeID(IReadOnlyList<RelationshipType> types, int? selectedRelationshipTypeId, string? suggestedType)
{ => selectedRelationshipTypeId.HasValue && types.Any(type => type.RelationshipTypeID == selectedRelationshipTypeId.Value)
var normalised = Normalise(signal); ? selectedRelationshipTypeId.Value
var mapped = RelationshipTypeFromSignal(signal); : types.FirstOrDefault(type => string.Equals(type.TypeName, suggestedType, StringComparison.OrdinalIgnoreCase))?.RelationshipTypeID
return types.FirstOrDefault(type => string.Equals(type.TypeName, mapped, StringComparison.OrdinalIgnoreCase))?.RelationshipTypeID ?? types.FirstOrDefault(type => string.Equals(type.TypeName, "Unknown", StringComparison.OrdinalIgnoreCase))?.RelationshipTypeID
?? types.FirstOrDefault(type => normalised.Contains(Normalise(type.TypeName), StringComparison.OrdinalIgnoreCase))?.RelationshipTypeID ?? types.First().RelationshipTypeID;
?? types.FirstOrDefault(type => string.Equals(type.TypeName, "Unknown", StringComparison.OrdinalIgnoreCase))?.RelationshipTypeID
?? types.First().RelationshipTypeID;
}
private static int MatchRelationshipState(IReadOnlyList<RelationshipState> states, string? signal) private static int MatchRelationshipState(IReadOnlyList<RelationshipState> states, string? signal)
{ {
@ -486,27 +496,64 @@ public sealed class StoryIntelligenceRelationshipImportService(
?? states.First().RelationshipStateID; ?? states.First().RelationshipStateID;
} }
private static string RelationshipTypeFromSignal(string? signal) private static string RelationshipTypeFromSignal(string? signal, string? evidence = null)
{ {
var value = Normalise(signal); var value = NormaliseForMatching($"{signal} {evidence}");
if (value.Contains("mother") || value.Contains("father") || value.Contains("parent")) return "Parent"; if (ContainsAny(value, "mother", "father", "sister", "brother", "parent", "daughter", "son", " child ", "aunt", "uncle", "cousin", "family", "sibling", "parent and child"))
if (value.Contains("daughter") || value.Contains("son") || value.Contains("child")) return "Child"; {
if (value.Contains("sister") || value.Contains("brother") || value.Contains("sibling")) return "Sibling"; return "Family";
if (value.Contains("spouse") || value.Contains("husband") || value.Contains("wife") || value.Contains("married")) return "Spouse"; }
if (value.Contains("romantic") || value.Contains("lover") || value.Contains("love")) return "Lover";
if (value.Contains("friend")) return "Friend"; if (ContainsAny(value, "romance", "romantic", "lover", "spouse", "husband", "wife", "girlfriend", "boyfriend", "love interest", "former partner", "partner like", "partner-like"))
if (value.Contains("enemy") || value.Contains("adversarial") || value.Contains("hostile")) return "Enemy"; {
if (value.Contains("rival")) return "Rival"; return "Romantic";
if (value.Contains("suspicious") || value.Contains("suspect")) return "Suspicious Of"; }
if (value.Contains("neighbour") || value.Contains("neighbor")) return "Neighbour";
if (value.Contains("teacher")) return "Teacher"; if (ContainsAny(value, "teacher", "student", "instructor", "learner", "mentor", "pupil", "tutor", "driving instructor"))
if (value.Contains("student")) return "Student"; {
if (value.Contains("employer") || value.Contains("boss")) return "Employer"; return "Educational";
if (value.Contains("employee")) return "Employee"; }
if (value.Contains("colleague") || value.Contains("professional") || value.Contains("police") || value.Contains("detective") || value.Contains("sergeant")) return "Colleague";
if (value.Contains("ally") || value.Contains("support")) return "Ally"; if (ContainsAny(value, "police", "detective", "suspect", "witness", "authority", "guard", "prisoner", "investigator", "person of interest"))
if (value.Contains("guardian") || value.Contains("protector")) return "Protector"; {
if (value.Contains("estranged") || value.Contains("separat")) return "Estranged From"; return "Authority";
}
if (ContainsAny(value, "criminal", "accomplice", "offender", "victim", "gang", "conspirator"))
{
return "Criminal";
}
if (ContainsAny(value, "doctor", "patient", "nurse", "therapist", "medical"))
{
return "Medical";
}
if (ContainsAny(value, "neighbour", "neighbor"))
{
return "Neighbour";
}
if (ContainsAny(value, "colleague", "coworker", "co worker", "boss", "employee", "manager", "client", "professional", "work relationship"))
{
return "Professional";
}
if (ContainsAny(value, "friend", "friendship", "best friend", "close friend", "childhood friend", "trust between friends", "affection between friends"))
{
return "Friend";
}
if (ContainsAny(value, "rival", "enemy", "adversarial", "conflict", "mistrust", "antagonist", "competitor"))
{
return "Rival";
}
if (ContainsAny(value, "ally", "protector", "supporter", "confidant", "dependence", "cooperation"))
{
return "Ally";
}
return "Unknown"; return "Unknown";
} }
@ -541,6 +588,32 @@ public sealed class StoryIntelligenceRelationshipImportService(
private static string RelationshipLabel(RelationshipCandidate candidate) private static string RelationshipLabel(RelationshipCandidate candidate)
=> $"{candidate.CharacterAName} / {candidate.RelationshipType} / {candidate.CharacterBName}"; => $"{candidate.CharacterAName} / {candidate.RelationshipType} / {candidate.CharacterBName}";
private static IReadOnlyList<SelectListItem> ToRelationshipTypeOptions(IEnumerable<RelationshipType> relationshipTypes)
=> relationshipTypes
.OrderBy(type => type.CategorySortOrder)
.ThenBy(type => type.SortOrder)
.ThenBy(type => type.TypeName)
.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) private static string? BuildFirstAppearance(Scene scene, SceneIntelligenceScene parsed)
{ {
var summary = Clean(parsed.Summary?.Short); var summary = Clean(parsed.Summary?.Short);
@ -592,10 +665,17 @@ public sealed class StoryIntelligenceRelationshipImportService(
private static string Normalise(string? value) private static string Normalise(string? value)
=> Clean(value).ToLowerInvariant(); => Clean(value).ToLowerInvariant();
private static string NormaliseForMatching(string? value)
=> $" {NonWord.Replace(Normalise(value), " ")} ";
private static bool ContainsAny(string value, params string[] needles)
=> needles.Any(needle => value.Contains(NormaliseForMatching(needle), StringComparison.Ordinal));
private static string Clean(string? value) private static string Clean(string? value)
=> string.IsNullOrWhiteSpace(value) ? string.Empty : Whitespace.Replace(value, " ").Trim(); => string.IsNullOrWhiteSpace(value) ? string.Empty : Whitespace.Replace(value, " ").Trim();
private static readonly Regex Whitespace = new(@"\s+", RegexOptions.Compiled); private static readonly Regex Whitespace = new(@"\s+", RegexOptions.Compiled);
private static readonly Regex NonWord = new(@"[^\p{L}\p{N}]+", RegexOptions.Compiled);
private static readonly string[] RelationshipSignalWords = private static readonly string[] RelationshipSignalWords =
[ [

View File

@ -269,11 +269,13 @@ public sealed class StoryIntelligenceService(
return state.CurrentReviewStage switch return state.CurrentReviewStage switch
{ {
StoryIntelligencePipelineStages.KnowledgeReview => "Waiting for Knowledge Review",
StoryIntelligencePipelineStages.RelationshipReview => "Waiting for Relationship Review", StoryIntelligencePipelineStages.RelationshipReview => "Waiting for Relationship Review",
StoryIntelligencePipelineStages.AssetReview => "Waiting for Asset Review", StoryIntelligencePipelineStages.AssetReview => "Waiting for Asset Review",
StoryIntelligencePipelineStages.LocationReview => "Waiting for Location Review", StoryIntelligencePipelineStages.LocationReview => "Waiting for Location Review",
StoryIntelligencePipelineStages.CharacterReview => "Waiting for Character Review", StoryIntelligencePipelineStages.CharacterReview => "Waiting for Character Review",
StoryIntelligencePipelineStages.SceneReview => "Needs Scene Review", StoryIntelligencePipelineStages.SceneReview => "Needs Scene Review",
_ when StoryIntelligenceReadyForKnowledge(state) => "Waiting for Knowledge Review",
_ when StoryIntelligenceReadyForRelationships(state) => "Waiting for Relationship Review", _ when StoryIntelligenceReadyForRelationships(state) => "Waiting for Relationship Review",
_ when StoryIntelligenceReadyForAssets(state) => "Waiting for Asset Review", _ when StoryIntelligenceReadyForAssets(state) => "Waiting for Asset Review",
_ when StoryIntelligenceReadyForLocations(state) => "Waiting for Location Review", _ when StoryIntelligenceReadyForLocations(state) => "Waiting for Location Review",
@ -288,33 +290,42 @@ public sealed class StoryIntelligenceService(
? "All currently available Story Intelligence stages are complete." ? "All currently available Story Intelligence stages are complete."
: state.CurrentReviewStage switch : state.CurrentReviewStage switch
{ {
StoryIntelligencePipelineStages.KnowledgeReview => "Review detected knowledge changes and decide what to create or link.",
StoryIntelligencePipelineStages.RelationshipReview => "Review detected relationships and decide what to create or link.", StoryIntelligencePipelineStages.RelationshipReview => "Review detected relationships and decide what to create or link.",
StoryIntelligencePipelineStages.AssetReview => "Review detected assets and decide what to create or link.", StoryIntelligencePipelineStages.AssetReview => "Review detected assets and decide what to create or link.",
StoryIntelligencePipelineStages.LocationReview => "Review detected locations and decide what to create or link.", StoryIntelligencePipelineStages.LocationReview => "Review detected locations and decide what to create or link.",
StoryIntelligencePipelineStages.CharacterReview => "Review detected characters and decide what to create or link.", StoryIntelligencePipelineStages.CharacterReview => "Review detected characters and decide what to create or link.",
StoryIntelligencePipelineStages.SceneReview => "Review prepared scenes before creating them.", StoryIntelligencePipelineStages.SceneReview => "Review prepared scenes before creating them.",
_ when StoryIntelligenceReadyForKnowledge(state) => "Review detected knowledge changes and decide what to create or link.",
_ when StoryIntelligenceReadyForRelationships(state) => "Review detected relationships and decide what to create or link.", _ when StoryIntelligenceReadyForRelationships(state) => "Review detected relationships and decide what to create or link.",
_ when StoryIntelligenceReadyForAssets(state) => "Review detected assets and decide what to create or link.", _ when StoryIntelligenceReadyForAssets(state) => "Review detected assets and decide what to create or link.",
_ when StoryIntelligenceReadyForLocations(state) => "Review detected locations and decide what to create or link.", _ when StoryIntelligenceReadyForLocations(state) => "Review detected locations and decide what to create or link.",
_ => "Continue from the next Story Intelligence stage." _ => "Continue from the next Story Intelligence stage."
}; };
private static bool StoryIntelligenceReadyForKnowledge(StoryIntelligenceBookPipelineState state)
=> string.Equals(state.LastCompletedStage, StoryIntelligencePipelineStages.RelationshipImport, StringComparison.OrdinalIgnoreCase)
|| state.CurrentStage is StoryIntelligencePipelineStages.KnowledgeReview or StoryIntelligencePipelineStages.KnowledgeImport;
private static bool StoryIntelligenceReadyForRelationships(StoryIntelligenceBookPipelineState state) private static bool StoryIntelligenceReadyForRelationships(StoryIntelligenceBookPipelineState state)
=> string.Equals(state.LastCompletedStage, StoryIntelligencePipelineStages.AssetImport, StringComparison.OrdinalIgnoreCase) => string.Equals(state.LastCompletedStage, StoryIntelligencePipelineStages.AssetImport, StringComparison.OrdinalIgnoreCase)
|| state.CurrentStage is StoryIntelligencePipelineStages.RelationshipReview or StoryIntelligencePipelineStages.RelationshipImport; || state.CurrentStage is StoryIntelligencePipelineStages.RelationshipReview or StoryIntelligencePipelineStages.RelationshipImport
or StoryIntelligencePipelineStages.KnowledgeReview or StoryIntelligencePipelineStages.KnowledgeImport;
private static bool StoryIntelligenceReadyForAssets(StoryIntelligenceBookPipelineState state) private static bool StoryIntelligenceReadyForAssets(StoryIntelligenceBookPipelineState state)
=> string.Equals(state.LastCompletedStage, StoryIntelligencePipelineStages.LocationImport, StringComparison.OrdinalIgnoreCase) => string.Equals(state.LastCompletedStage, StoryIntelligencePipelineStages.LocationImport, StringComparison.OrdinalIgnoreCase)
|| state.CurrentStage is StoryIntelligencePipelineStages.AssetReview or StoryIntelligencePipelineStages.AssetImport || state.CurrentStage is StoryIntelligencePipelineStages.AssetReview or StoryIntelligencePipelineStages.AssetImport
or StoryIntelligencePipelineStages.RelationshipReview or StoryIntelligencePipelineStages.RelationshipImport or StoryIntelligencePipelineStages.RelationshipReview or StoryIntelligencePipelineStages.RelationshipImport
or StoryIntelligencePipelineStages.KnowledgeReview or StoryIntelligencePipelineStages.KnowledgeImport
|| (string.Equals(state.CurrentStage, StoryIntelligencePipelineStages.Complete, StringComparison.OrdinalIgnoreCase) || (string.Equals(state.CurrentStage, StoryIntelligencePipelineStages.Complete, StringComparison.OrdinalIgnoreCase)
&& !string.Equals(state.LastCompletedStage, StoryIntelligencePipelineStages.RelationshipImport, StringComparison.OrdinalIgnoreCase)); && !string.Equals(state.LastCompletedStage, StoryIntelligencePipelineStages.KnowledgeImport, StringComparison.OrdinalIgnoreCase));
private static bool StoryIntelligenceReadyForLocations(StoryIntelligenceBookPipelineState state) private static bool StoryIntelligenceReadyForLocations(StoryIntelligenceBookPipelineState state)
=> string.Equals(state.LastCompletedStage, StoryIntelligencePipelineStages.CharacterImport, StringComparison.OrdinalIgnoreCase) => string.Equals(state.LastCompletedStage, StoryIntelligencePipelineStages.CharacterImport, StringComparison.OrdinalIgnoreCase)
|| state.CurrentStage is StoryIntelligencePipelineStages.LocationReview or StoryIntelligencePipelineStages.LocationImport || state.CurrentStage is StoryIntelligencePipelineStages.LocationReview or StoryIntelligencePipelineStages.LocationImport
or StoryIntelligencePipelineStages.AssetReview or StoryIntelligencePipelineStages.AssetImport or StoryIntelligencePipelineStages.AssetReview or StoryIntelligencePipelineStages.AssetImport
or StoryIntelligencePipelineStages.RelationshipReview or StoryIntelligencePipelineStages.RelationshipImport; or StoryIntelligencePipelineStages.RelationshipReview or StoryIntelligencePipelineStages.RelationshipImport
or StoryIntelligencePipelineStages.KnowledgeReview or StoryIntelligencePipelineStages.KnowledgeImport;
private static string FormatElapsed(TimeSpan elapsed) private static string FormatElapsed(TimeSpan elapsed)
=> elapsed.TotalMinutes < 1 => elapsed.TotalMinutes < 1

View File

@ -0,0 +1,73 @@
SET ANSI_NULLS ON;
GO
SET QUOTED_IDENTIFIER ON;
GO
DECLARE @OtherCategoryID int = (
SELECT TOP (1) RelationshipCategoryID
FROM dbo.RelationshipCategories
WHERE CategoryName = N'Other'
ORDER BY RelationshipCategoryID
);
DECLARE @FamilyCategoryID int = (
SELECT TOP (1) RelationshipCategoryID
FROM dbo.RelationshipCategories
WHERE CategoryName = N'Family'
ORDER BY RelationshipCategoryID
);
DECLARE @FriendshipCategoryID int = (
SELECT TOP (1) RelationshipCategoryID
FROM dbo.RelationshipCategories
WHERE CategoryName = N'Friendship'
ORDER BY RelationshipCategoryID
);
DECLARE @RomanticCategoryID int = (
SELECT TOP (1) RelationshipCategoryID
FROM dbo.RelationshipCategories
WHERE CategoryName = N'Romantic / Sexual'
ORDER BY RelationshipCategoryID
);
DECLARE @HostileCategoryID int = (
SELECT TOP (1) RelationshipCategoryID
FROM dbo.RelationshipCategories
WHERE CategoryName = N'Hostile / Conflict'
ORDER BY RelationshipCategoryID
);
DECLARE @ProfessionalCategoryID int = (
SELECT TOP (1) RelationshipCategoryID
FROM dbo.RelationshipCategories
WHERE CategoryName = N'Professional / Social'
ORDER BY RelationshipCategoryID
);
MERGE dbo.RelationshipTypes AS target
USING (VALUES
(N'Unknown', 0, 1, @OtherCategoryID),
(N'Family', 1, 2, @FamilyCategoryID),
(N'Friend', 0, 3, @FriendshipCategoryID),
(N'Romantic', 0, 4, @RomanticCategoryID),
(N'Professional', 0, 5, @ProfessionalCategoryID),
(N'Educational', 0, 6, @ProfessionalCategoryID),
(N'Authority', 0, 7, @ProfessionalCategoryID),
(N'Criminal', 0, 8, @HostileCategoryID),
(N'Medical', 0, 9, @ProfessionalCategoryID),
(N'Neighbour', 0, 10, @ProfessionalCategoryID),
(N'Ally', 0, 11, @FriendshipCategoryID),
(N'Rival', 0, 12, @HostileCategoryID)
) AS source (TypeName, IsPermanentDefault, SortOrder, RelationshipCategoryID)
ON target.TypeName = source.TypeName
WHEN MATCHED THEN
UPDATE SET
target.IsPermanentDefault = source.IsPermanentDefault,
target.SortOrder = source.SortOrder,
target.IsActive = 1,
target.RelationshipCategoryID = COALESCE(source.RelationshipCategoryID, target.RelationshipCategoryID)
WHEN NOT MATCHED THEN
INSERT (TypeName, IsPermanentDefault, SortOrder, IsActive, RelationshipCategoryID)
VALUES (source.TypeName, source.IsPermanentDefault, source.SortOrder, 1, source.RelationshipCategoryID);
GO

View File

@ -0,0 +1,53 @@
SET ANSI_NULLS ON;
GO
SET QUOTED_IDENTIFIER ON;
GO
IF OBJECT_ID(N'dbo.StoryIntelligenceBookPipelines', N'U') IS NOT NULL
BEGIN
IF EXISTS
(
SELECT 1
FROM sys.check_constraints
WHERE name = N'CK_StoryIntelligenceBookPipelines_CurrentStage'
AND parent_object_id = OBJECT_ID(N'dbo.StoryIntelligenceBookPipelines')
)
BEGIN
ALTER TABLE dbo.StoryIntelligenceBookPipelines
DROP CONSTRAINT CK_StoryIntelligenceBookPipelines_CurrentStage;
END;
ALTER TABLE dbo.StoryIntelligenceBookPipelines
ADD CONSTRAINT CK_StoryIntelligenceBookPipelines_CurrentStage
CHECK (CurrentStage IN
(
N'Chapters',
N'SceneReview',
N'SceneImport',
N'CharacterReview',
N'CharacterImport',
N'LocationReview',
N'LocationImport',
N'AssetReview',
N'AssetImport',
N'RelationshipReview',
N'RelationshipImport',
N'KnowledgeReview',
N'KnowledgeImport',
N'Complete'
));
END;
GO
IF OBJECT_ID(N'dbo.StoryIntelligenceBookPipelines', N'U') IS NOT NULL
BEGIN
UPDATE dbo.StoryIntelligenceBookPipelines
SET CurrentStage = N'KnowledgeReview',
CurrentReviewStage = N'KnowledgeReview',
Status = N'NeedsReview',
CompletedUtc = NULL,
UpdatedUtc = SYSUTCDATETIME()
WHERE CurrentStage = N'Complete'
AND LastCompletedStage = N'RelationshipImport';
END;
GO

View File

@ -1,4 +1,5 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.Mvc.Rendering;
using PlotLine.Models; using PlotLine.Models;
namespace PlotLine.ViewModels; namespace PlotLine.ViewModels;
@ -146,6 +147,7 @@ public sealed class StoryIntelligenceProgressViewModel
public StoryIntelligenceLocationReviewViewModel LocationReview { get; init; } = new(); public StoryIntelligenceLocationReviewViewModel LocationReview { get; init; } = new();
public StoryIntelligenceAssetReviewViewModel AssetReview { get; init; } = new(); public StoryIntelligenceAssetReviewViewModel AssetReview { get; init; } = new();
public StoryIntelligenceRelationshipReviewViewModel RelationshipReview { get; init; } = new(); public StoryIntelligenceRelationshipReviewViewModel RelationshipReview { get; init; } = new();
public StoryIntelligenceKnowledgeReviewViewModel KnowledgeReview { get; init; } = new();
public StoryIntelligencePipelineDashboardViewModel PipelineDashboard { get; init; } = new(); public StoryIntelligencePipelineDashboardViewModel PipelineDashboard { get; init; } = new();
public int ChapterCount => Chapters.Count; public int ChapterCount => Chapters.Count;
public int CompletedChapterCount => Chapters.Count(chapter => chapter.IsRunComplete); public int CompletedChapterCount => Chapters.Count(chapter => chapter.IsRunComplete);
@ -164,6 +166,7 @@ public sealed class StoryIntelligenceProgressViewModel
public bool HasLocationsToReview => LocationReview.Candidates.Count > 0; public bool HasLocationsToReview => LocationReview.Candidates.Count > 0;
public bool HasAssetsToReview => AssetReview.Candidates.Count > 0; public bool HasAssetsToReview => AssetReview.Candidates.Count > 0;
public bool HasRelationshipsToReview => RelationshipReview.Candidates.Count > 0; public bool HasRelationshipsToReview => RelationshipReview.Candidates.Count > 0;
public bool HasKnowledgeToReview => KnowledgeReview.Candidates.Count > 0;
} }
public sealed class StoryIntelligencePipelineDashboardViewModel public sealed class StoryIntelligencePipelineDashboardViewModel
@ -182,6 +185,9 @@ public sealed class StoryIntelligencePipelineDashboardViewModel
public int RelationshipsIdentified { get; init; } public int RelationshipsIdentified { get; init; }
public int RelationshipsCreatedOrLinked { get; init; } public int RelationshipsCreatedOrLinked { get; init; }
public bool RelationshipStageComplete { get; init; } public bool RelationshipStageComplete { get; init; }
public int KnowledgeIdentified { get; init; }
public int KnowledgeCreatedOrLinked { get; init; }
public bool KnowledgeStageComplete { get; init; }
} }
public sealed class StoryIntelligenceCharacterReviewViewModel public sealed class StoryIntelligenceCharacterReviewViewModel
@ -333,6 +339,7 @@ public sealed class StoryIntelligenceRelationshipReviewViewModel
public bool HasCommittedScenes { get; init; } public bool HasCommittedScenes { get; init; }
public int AlreadyLinkedCount { get; init; } public int AlreadyLinkedCount { get; init; }
public bool IsComplete { get; init; } public bool IsComplete { get; init; }
public IReadOnlyList<SelectListItem> RelationshipTypeOptions { get; init; } = [];
public IReadOnlyList<StoryIntelligenceRelationshipReviewCandidateViewModel> Candidates { get; init; } = []; public IReadOnlyList<StoryIntelligenceRelationshipReviewCandidateViewModel> Candidates { get; init; } = [];
} }
@ -342,6 +349,7 @@ public sealed class StoryIntelligenceRelationshipReviewCandidateViewModel
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;
public string RelationshipType { get; init; } = "Unknown"; public string RelationshipType { get; init; } = "Unknown";
public int RelationshipTypeID { get; init; }
public int AppearsInScenes { get; init; } public int AppearsInScenes { get; init; }
public string Confidence { get; init; } = "Unknown"; public string Confidence { get; init; } = "Unknown";
public string? FirstAppearance { get; init; } public string? FirstAppearance { get; init; }
@ -364,7 +372,7 @@ public sealed class StoryIntelligenceRelationshipImportChoiceForm
{ {
public string Key { get; set; } = string.Empty; public string Key { get; set; } = string.Empty;
public string Action { get; set; } = StoryIntelligenceRelationshipImportActions.CreateNew; public string Action { get; set; } = StoryIntelligenceRelationshipImportActions.CreateNew;
public string? RelationshipType { get; set; } public int? RelationshipTypeID { get; set; }
public string? AliasTargetKey { get; set; } public string? AliasTargetKey { get; set; }
} }
@ -376,6 +384,55 @@ public static class StoryIntelligenceRelationshipImportActions
public const string Alias = "Alias"; public const string Alias = "Alias";
} }
public sealed class StoryIntelligenceKnowledgeReviewViewModel
{
public bool CanImport { get; init; }
public bool HasCommittedScenes { get; init; }
public int AlreadyLinkedCount { get; init; }
public bool IsComplete { get; init; }
public IReadOnlyList<SelectListItem> KnowledgeStateOptions { get; init; } = [];
public IReadOnlyList<StoryIntelligenceKnowledgeReviewCandidateViewModel> Candidates { get; init; } = [];
}
public sealed class StoryIntelligenceKnowledgeReviewCandidateViewModel
{
public string Key { get; init; } = string.Empty;
public string CharacterName { get; init; } = string.Empty;
public string KnowledgeStatement { get; init; } = string.Empty;
public int KnowledgeStateID { get; init; }
public string KnowledgeStateName { get; init; } = "Knows";
public int AppearsInScenes { get; init; }
public string Confidence { get; init; } = "Unknown";
public string? FirstScene { get; init; }
public string? ExampleScene { get; init; }
public string? Evidence { get; init; }
public int? ExistingKnowledgeID { get; init; }
public string? ExistingKnowledgeLabel { get; init; }
public bool IsExistingMatch => ExistingKnowledgeID.HasValue;
}
public sealed class StoryIntelligenceKnowledgeImportForm
{
public Guid BatchID { get; set; }
public List<StoryIntelligenceKnowledgeImportChoiceForm> Knowledge { get; set; } = [];
}
public sealed class StoryIntelligenceKnowledgeImportChoiceForm
{
public string Key { get; set; } = string.Empty;
public string Action { get; set; } = StoryIntelligenceKnowledgeImportActions.CreateNew;
public int? KnowledgeStateID { get; set; }
public string? AliasTargetKey { get; set; }
}
public static class StoryIntelligenceKnowledgeImportActions
{
public const string CreateNew = "CreateNew";
public const string LinkExisting = "LinkExisting";
public const string Ignore = "Ignore";
public const string Alias = "Alias";
}
public sealed class StoryIntelligenceCompletionViewModel public sealed class StoryIntelligenceCompletionViewModel
{ {
public StoryIntelligenceJobProgress Job { get; init; } = new(); public StoryIntelligenceJobProgress Job { get; init; } = new();
@ -447,6 +504,17 @@ public sealed class StoryIntelligenceRelationshipImportResultViewModel
public int RelationshipEventsCreated { get; init; } public int RelationshipEventsCreated { get; init; }
} }
public sealed class StoryIntelligenceKnowledgeImportResultViewModel
{
public Guid BatchID { get; init; }
public int ProjectID { get; init; }
public int BookID { get; init; }
public int KnowledgeCreated { get; init; }
public int KnowledgeLinked { get; init; }
public int KnowledgeMerged { get; init; }
public int KnowledgeIgnored { get; init; }
}
public sealed class StoryIntelligenceOnboardingChapterViewModel public sealed class StoryIntelligenceOnboardingChapterViewModel
{ {
public string TemporaryChapterKey { get; init; } = string.Empty; public string TemporaryChapterKey { get; init; } = string.Empty;

View File

@ -25,7 +25,7 @@
<li><span aria-hidden="true">&check;</span><strong>Locations</strong></li> <li><span aria-hidden="true">&check;</span><strong>Locations</strong></li>
<li><span aria-hidden="true">&check;</span><strong>Assets</strong></li> <li><span aria-hidden="true">&check;</span><strong>Assets</strong></li>
<li><span aria-hidden="true">&check;</span><strong>Relationships</strong></li> <li><span aria-hidden="true">&check;</span><strong>Relationships</strong></li>
<li class="is-upcoming"><span aria-hidden="true">○</span><strong>Knowledge</strong><em>Coming Soon</em></li> <li><span aria-hidden="true">&check;</span><strong>Knowledge</strong></li>
<li><span aria-hidden="true">&check;</span><strong>Preparing continuity...</strong></li> <li><span aria-hidden="true">&check;</span><strong>Preparing continuity...</strong></li>
<li><span aria-hidden="true">&check;</span><strong>Generating story indexes...</strong></li> <li><span aria-hidden="true">&check;</span><strong>Generating story indexes...</strong></li>
<li><span aria-hidden="true">&check;</span><strong>Finalising project...</strong></li> <li><span aria-hidden="true">&check;</span><strong>Finalising project...</strong></li>

View File

@ -0,0 +1,236 @@
@model StoryIntelligenceProgressViewModel
@{
ViewData["Title"] = "Review knowledge";
}
<section class="onboarding-shell" aria-labelledby="story-knowledge-title">
<div class="onboarding-panel onboarding-review-panel story-review-page">
<partial name="_StoryIntelligencePipelineHeader" model="@(new StoryIntelligencePipelineHeaderViewModel { CurrentStage = "Review Knowledge" })" />
<div class="onboarding-copy story-review-heading">
<p class="eyebrow">Phase 3</p>
<p class="story-review-heading__phase">Review Story Intelligence</p>
<p class="story-review-heading__stage">Stage 6 of 6</p>
<h1 id="story-knowledge-title">Review Knowledge</h1>
<p>Approve the facts, discoveries, beliefs and suspicions PlotDirector should add to the existing Knowledge Explorer.</p>
</div>
@if (TempData["OnboardingStoryIntelligenceError"] is string error)
{
<div class="alert alert-danger">@error</div>
}
@if (TempData["OnboardingStoryIntelligenceMessage"] is string message)
{
<div class="alert alert-success">@message</div>
}
<partial name="_StoryIntelligencePipelineSummary" model="Model.PipelineDashboard" />
<section class="story-review-chapter-list" aria-label="Knowledge review">
@if (Model.KnowledgeReview.Candidates.Count == 0)
{
<div class="story-review-note">
<strong>No knowledge decisions are waiting.</strong>
<p class="mb-0">Knowledge is already up to date, or there were no knowledge changes ready to import from the stored scene analysis.</p>
</div>
<div class="story-future-stage-grid">
@FutureStage("Review Continuity")
@FutureStage("Timeline Events")
</div>
<div class="onboarding-actions">
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceRelationships" asp-route-batchId="@Model.BatchID">Back to relationships</a>
<form asp-action="ImportStoryIntelligenceKnowledge" method="post">
<input type="hidden" name="BatchID" value="@Model.BatchID" />
<button class="btn btn-primary" type="submit">Continue</button>
</form>
</div>
}
else
{
<div class="story-review-card-actions">
<button class="btn btn-outline-primary btn-sm" type="button" data-knowledge-bulk="create-all">Create all</button>
<button class="btn btn-outline-secondary btn-sm" type="button" data-knowledge-bulk="link-existing">Link existing matches</button>
<button class="btn btn-outline-secondary btn-sm" type="button" data-knowledge-bulk="ignore-all">Ignore all</button>
<button class="btn btn-outline-secondary btn-sm" type="button" data-knowledge-bulk="expand">Expand all</button>
<button class="btn btn-outline-secondary btn-sm" type="button" data-knowledge-bulk="collapse">Collapse all</button>
</div>
<form asp-action="ImportStoryIntelligenceKnowledge" method="post" data-knowledge-review-form>
<input type="hidden" name="BatchID" value="@Model.BatchID" />
<div class="story-character-card-grid">
@for (var i = 0; i < Model.KnowledgeReview.Candidates.Count; i++)
{
var candidate = Model.KnowledgeReview.Candidates[i];
<details class="story-character-card" open data-knowledge-card>
<summary>
<span>
<strong>@candidate.CharacterName</strong>
<small>@candidate.KnowledgeStateName - @candidate.KnowledgeStatement</small>
</span>
<em>@candidate.Confidence</em>
</summary>
<input type="hidden" name="Knowledge[@i].Key" value="@candidate.Key" />
<div class="story-character-card__body" data-knowledge-key="@candidate.Key">
<dl>
<div><dt>Character</dt><dd>@candidate.CharacterName</dd></div>
<div><dt>Knowledge statement</dt><dd>@candidate.KnowledgeStatement</dd></div>
<div><dt>First scene</dt><dd>@Display(candidate.FirstScene)</dd></div>
<div><dt>Existing match</dt><dd>@(candidate.ExistingKnowledgeLabel ?? "None")</dd></div>
</dl>
@if (!string.IsNullOrWhiteSpace(candidate.ExampleScene))
{
<div class="story-review-note">
<strong>Example scene</strong>
<p>@candidate.ExampleScene</p>
</div>
}
@if (!string.IsNullOrWhiteSpace(candidate.Evidence))
{
<div class="story-review-note">
<strong>Evidence</strong>
<p>@candidate.Evidence</p>
</div>
}
@if (candidate.IsExistingMatch)
{
<div class="story-review-note">
<strong>Possible existing knowledge</strong>
<p>This fact may already exist in PlotDirector. Choose whether to link it or create a separate knowledge record.</p>
</div>
}
<fieldset class="story-character-actions">
<legend>Decision</legend>
<label>
<input type="radio" name="Knowledge[@i].Action" value="@StoryIntelligenceKnowledgeImportActions.CreateNew" checked data-knowledge-action />
Create new knowledge
</label>
<label>
<input type="radio" name="Knowledge[@i].Action" value="@StoryIntelligenceKnowledgeImportActions.LinkExisting" data-knowledge-action @(candidate.IsExistingMatch ? string.Empty : "disabled") />
Link existing knowledge
</label>
<label>
<input type="radio" name="Knowledge[@i].Action" value="@StoryIntelligenceKnowledgeImportActions.Alias" data-knowledge-action />
Merge with another knowledge item
</label>
<label>
<input type="radio" name="Knowledge[@i].Action" value="@StoryIntelligenceKnowledgeImportActions.Ignore" data-knowledge-action />
Ignore
</label>
</fieldset>
<div data-knowledge-state-panel>
<label class="form-label" for="knowledge-state-@i">Knowledge state</label>
<select id="knowledge-state-@i" class="form-select" name="Knowledge[@i].KnowledgeStateID">
@foreach (var option in Model.KnowledgeReview.KnowledgeStateOptions)
{
<option value="@option.Value" selected="@(option.Value == candidate.KnowledgeStateID.ToString())">@option.Text</option>
}
</select>
</div>
<div data-knowledge-alias-panel hidden>
<label class="form-label" for="knowledge-alias-target-@i">Merge target</label>
<select id="knowledge-alias-target-@i" class="form-select" name="Knowledge[@i].AliasTargetKey" data-knowledge-alias-target>
<option value="">Choose knowledge item...</option>
@foreach (var target in Model.KnowledgeReview.Candidates.Where(target => !string.Equals(target.Key, candidate.Key, StringComparison.OrdinalIgnoreCase)))
{
<option value="@target.Key">@target.CharacterName - @target.KnowledgeStatement@(target.IsExistingMatch ? $" -> {target.ExistingKnowledgeLabel}" : string.Empty)</option>
}
</select>
</div>
</div>
</details>
}
</div>
<div class="onboarding-actions">
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceRelationships" asp-route-batchId="@Model.BatchID">Back to relationships</a>
<button class="btn btn-primary" type="submit">Create knowledge</button>
</div>
</form>
}
</section>
</div>
</section>
@section Scripts {
<script>
(() => {
const form = document.querySelector("[data-knowledge-review-form]");
if (!form) return;
const cards = () => Array.from(form.querySelectorAll("[data-knowledge-card]"));
const setAction = (card, action) => {
const input = card.querySelector(`[data-knowledge-action][value="${action}"]`);
if (input) {
input.checked = true;
updateCard(card);
}
};
const actionFor = (card) => card.querySelector("[data-knowledge-action]:checked")?.value || "@StoryIntelligenceKnowledgeImportActions.CreateNew";
const isCanonicalTarget = (card) => {
const action = actionFor(card);
return action !== "@StoryIntelligenceKnowledgeImportActions.Ignore" && action !== "@StoryIntelligenceKnowledgeImportActions.Alias";
};
const updateAliasTargets = () => {
const allCards = cards();
for (const card of allCards) {
const body = card.querySelector("[data-knowledge-key]");
const ownKey = body?.getAttribute("data-knowledge-key") || "";
const select = card.querySelector("[data-knowledge-alias-target]");
if (!select) continue;
const previous = select.value;
for (const option of Array.from(select.options)) {
if (!option.value) continue;
const targetCard = allCards.find(item => item.querySelector("[data-knowledge-key]")?.getAttribute("data-knowledge-key") === option.value);
option.disabled = option.value === ownKey || !targetCard || !isCanonicalTarget(targetCard);
}
if (select.selectedOptions[0]?.disabled) {
select.value = "";
} else {
select.value = previous;
}
}
};
const updateCard = (card) => {
const action = actionFor(card);
const statePanel = card.querySelector("[data-knowledge-state-panel]");
const aliasPanel = card.querySelector("[data-knowledge-alias-panel]");
if (statePanel) statePanel.hidden = action === "@StoryIntelligenceKnowledgeImportActions.Ignore" || action === "@StoryIntelligenceKnowledgeImportActions.Alias";
if (aliasPanel) aliasPanel.hidden = action !== "@StoryIntelligenceKnowledgeImportActions.Alias";
updateAliasTargets();
};
document.querySelectorAll("[data-knowledge-bulk]").forEach(button => {
button.addEventListener("click", () => {
const action = button.getAttribute("data-knowledge-bulk");
if (action === "create-all") cards().forEach(card => setAction(card, "@StoryIntelligenceKnowledgeImportActions.CreateNew"));
if (action === "link-existing") cards().forEach(card => {
if (card.querySelector(`[data-knowledge-action][value="@StoryIntelligenceKnowledgeImportActions.LinkExisting"]:not(:disabled)`)) {
setAction(card, "@StoryIntelligenceKnowledgeImportActions.LinkExisting");
}
});
if (action === "ignore-all") cards().forEach(card => setAction(card, "@StoryIntelligenceKnowledgeImportActions.Ignore"));
if (action === "expand") cards().forEach(card => card.open = true);
if (action === "collapse") cards().forEach(card => card.open = false);
});
});
form.querySelectorAll("[data-knowledge-action]").forEach(input => {
input.addEventListener("change", () => updateCard(input.closest("[data-knowledge-card]")));
});
cards().forEach(updateCard);
})();
</script>
}
@functions {
private static string Display(string? value) => string.IsNullOrWhiteSpace(value) ? "Not detected" : value;
private static Microsoft.AspNetCore.Html.IHtmlContent FutureStage(string label)
=> new Microsoft.AspNetCore.Html.HtmlString($"<article class=\"story-future-stage\"><strong>{System.Net.WebUtility.HtmlEncode(label)}</strong><span>Coming Soon</span></article>");
}

View File

@ -0,0 +1,47 @@
@model StoryIntelligenceKnowledgeImportResultViewModel
@{
ViewData["Title"] = "Knowledge created";
}
<section class="onboarding-shell" aria-labelledby="story-knowledge-complete-title">
<div class="onboarding-panel onboarding-review-panel">
<partial name="_StoryIntelligencePipelineHeader" model="@(new StoryIntelligencePipelineHeaderViewModel { CurrentStage = "Complete" })" />
<div class="onboarding-copy">
<p class="eyebrow">Build Story Database</p>
<div class="onboarding-success-heading">
<span aria-hidden="true">&check;</span>
<h1 id="story-knowledge-complete-title">Knowledge created</h1>
</div>
<p>PlotDirector updated the existing Knowledge Explorer from the approved Story Intelligence decisions.</p>
</div>
<div class="onboarding-scan-counts onboarding-review-counts">
<div>
<span>Knowledge created</span>
<strong>@Model.KnowledgeCreated.ToString("N0")</strong>
</div>
<div>
<span>Knowledge linked</span>
<strong>@Model.KnowledgeLinked.ToString("N0")</strong>
</div>
<div>
<span>Knowledge merged</span>
<strong>@Model.KnowledgeMerged.ToString("N0")</strong>
</div>
<div>
<span>Knowledge ignored</span>
<strong>@Model.KnowledgeIgnored.ToString("N0")</strong>
</div>
</div>
<div class="story-future-stage-grid">
<article class="story-future-stage"><strong>Review Continuity</strong><span>Coming Soon</span></article>
<article class="story-future-stage"><strong>Timeline Events</strong><span>Coming Soon</span></article>
</div>
<div class="onboarding-actions">
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceKnowledge" asp-route-batchId="@Model.BatchID">Back to knowledge</a>
<a class="btn btn-primary" asp-action="StoryIntelligenceComplete" asp-route-batchId="@Model.BatchID">Continue</a>
</div>
</div>
</section>

View File

@ -12,7 +12,7 @@
<span aria-hidden="true">&check;</span> <span aria-hidden="true">&check;</span>
<h1 id="story-relationship-complete-title">Relationships created</h1> <h1 id="story-relationship-complete-title">Relationships created</h1>
</div> </div>
<p>PlotDirector updated your story database from the approved relationship decisions. The implemented Story Intelligence import stages are now complete.</p> <p>PlotDirector updated your story database from the approved relationship decisions. Next, review the knowledge changes detected in the imported scenes.</p>
</div> </div>
<div class="onboarding-scan-counts onboarding-review-counts"> <div class="onboarding-scan-counts onboarding-review-counts">
@ -39,14 +39,13 @@
</div> </div>
<div class="story-future-stage-grid"> <div class="story-future-stage-grid">
<article class="story-future-stage"><strong>Review Knowledge</strong><span>Coming Soon</span></article>
<article class="story-future-stage"><strong>Review Continuity</strong><span>Coming Soon</span></article> <article class="story-future-stage"><strong>Review Continuity</strong><span>Coming Soon</span></article>
<article class="story-future-stage"><strong>Timeline Events</strong><span>Coming Soon</span></article> <article class="story-future-stage"><strong>Timeline Events</strong><span>Coming Soon</span></article>
</div> </div>
<div class="onboarding-actions"> <div class="onboarding-actions">
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceRelationships" asp-route-batchId="@Model.BatchID">Back to relationships</a> <a class="btn btn-outline-secondary" asp-action="StoryIntelligenceRelationships" asp-route-batchId="@Model.BatchID">Back to relationships</a>
<a class="btn btn-primary" asp-action="StoryIntelligenceComplete" asp-route-batchId="@Model.BatchID">Continue</a> <a class="btn btn-primary" asp-action="StoryIntelligenceKnowledge" asp-route-batchId="@Model.BatchID">Review knowledge</a>
</div> </div>
</div> </div>
</section> </section>

View File

@ -127,7 +127,21 @@
<div data-relationship-type-panel> <div data-relationship-type-panel>
<label class="form-label" for="relationship-type-@i">Relationship type</label> <label class="form-label" for="relationship-type-@i">Relationship type</label>
<input id="relationship-type-@i" class="form-control" name="Relationships[@i].RelationshipType" value="@candidate.RelationshipType" /> <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))
{
<strong>Evidence</strong>
<p>@candidate.ExampleContext</p>
}
</div>
</div> </div>
<div data-relationship-alias-panel hidden> <div data-relationship-alias-panel hidden>

View File

@ -7,7 +7,7 @@
new PipelineStage("Review Locations", "Locations", true), new PipelineStage("Review Locations", "Locations", true),
new PipelineStage("Review Assets", "Assets", true), new PipelineStage("Review Assets", "Assets", true),
new PipelineStage("Review Relationships", "Relationships", true), new PipelineStage("Review Relationships", "Relationships", true),
new PipelineStage("Review Knowledge", "Knowledge", false) new PipelineStage("Review Knowledge", "Knowledge", true)
}; };
var implementedReviewStages = reviewStages.Where(stage => stage.IsImplemented).ToList(); var implementedReviewStages = reviewStages.Where(stage => stage.IsImplemented).ToList();
var currentReviewIndex = implementedReviewStages.FindIndex(stage => string.Equals(stage.StageName, Model.CurrentStage, StringComparison.OrdinalIgnoreCase)); var currentReviewIndex = implementedReviewStages.FindIndex(stage => string.Equals(stage.StageName, Model.CurrentStage, StringComparison.OrdinalIgnoreCase));

View File

@ -42,7 +42,11 @@
<strong>@(Model.RelationshipStageComplete ? $"Done: {Model.RelationshipsCreatedOrLinked:N0}" : "In review")</strong> <strong>@(Model.RelationshipStageComplete ? $"Done: {Model.RelationshipsCreatedOrLinked:N0}" : "In review")</strong>
</div> </div>
<div> <div>
<span>Knowledge</span> <span>Knowledge identified</span>
<strong>Coming next</strong> <strong>@Model.KnowledgeIdentified.ToString("N0")</strong>
</div>
<div>
<span>Knowledge created / linked</span>
<strong>@(Model.KnowledgeStageComplete ? $"Done: {Model.KnowledgeCreatedOrLinked:N0}" : "In review")</strong>
</div> </div>
</section> </section>