Compare commits
2 Commits
e8b42664f1
...
faf0e43412
| Author | SHA1 | Date | |
|---|---|---|---|
| faf0e43412 | |||
| 3d54362c3b |
@ -18,7 +18,10 @@ var tests = new (string Name, Action Test)[]
|
||||
("Location canonical keys merge trivial variants", LocationCanonicalKeysMergeTrivialVariants),
|
||||
("Asset filtering rejects generic objects", AssetFilteringRejectsGenericObjects),
|
||||
("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)
|
||||
@ -225,6 +228,44 @@ static void AssetCanonicalKeysMergeTrivialVariants()
|
||||
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)
|
||||
=> InvokePrivateAssetFilter("IsAssetNameCandidate", name);
|
||||
|
||||
@ -246,6 +287,33 @@ static bool InvokePrivateAssetFilter(string methodName, string name)
|
||||
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()
|
||||
=> new()
|
||||
{
|
||||
|
||||
@ -75,6 +75,7 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
|
||||
return target.Route switch
|
||||
{
|
||||
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.Assets => RedirectToAction(nameof(StoryIntelligenceAssets), 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);
|
||||
}
|
||||
|
||||
[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")]
|
||||
public async Task<IActionResult> StoryIntelligenceComplete(Guid batchId)
|
||||
{
|
||||
@ -417,6 +480,11 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
|
||||
return RedirectToAction(nameof(StoryIntelligenceRelationships), new { batchId });
|
||||
}
|
||||
|
||||
if (!progress.PipelineDashboard.KnowledgeStageComplete)
|
||||
{
|
||||
return RedirectToAction(nameof(StoryIntelligenceKnowledge), new { batchId });
|
||||
}
|
||||
|
||||
var model = await storyIntelligence.GetCompletionAsync(batchId);
|
||||
return model is null ? NotFound() : View(model);
|
||||
}
|
||||
|
||||
@ -34,6 +34,8 @@ public static class StoryIntelligencePipelineStages
|
||||
public const string AssetImport = "AssetImport";
|
||||
public const string RelationshipReview = "RelationshipReview";
|
||||
public const string RelationshipImport = "RelationshipImport";
|
||||
public const string KnowledgeReview = "KnowledgeReview";
|
||||
public const string KnowledgeImport = "KnowledgeImport";
|
||||
public const string Complete = "Complete";
|
||||
}
|
||||
|
||||
@ -64,7 +66,7 @@ public sealed class StoryIntelligenceBookPipelineState
|
||||
|
||||
public bool IsComplete
|
||||
=> 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
|
||||
|
||||
@ -17,6 +17,10 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="Docs\AI\**\*">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
|
||||
</Content>
|
||||
<Content Include="Help\**\*">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
|
||||
|
||||
@ -211,6 +211,7 @@ public class Program
|
||||
builder.Services.AddScoped<IStoryIntelligenceLocationImportService, StoryIntelligenceLocationImportService>();
|
||||
builder.Services.AddScoped<IStoryIntelligenceAssetImportService, StoryIntelligenceAssetImportService>();
|
||||
builder.Services.AddScoped<IStoryIntelligenceRelationshipImportService, StoryIntelligenceRelationshipImportService>();
|
||||
builder.Services.AddScoped<IStoryIntelligenceKnowledgeImportService, StoryIntelligenceKnowledgeImportService>();
|
||||
builder.Services.AddScoped<IStoryIntelligencePipelineStateService, StoryIntelligencePipelineStateService>();
|
||||
builder.Services.AddScoped<IStoryIntelligenceProgressNotifier, StoryIntelligenceProgressNotifier>();
|
||||
builder.Services.AddScoped<ISceneImportResolver, SceneImportResolver>();
|
||||
|
||||
@ -22,6 +22,8 @@ public interface IOnboardingStoryIntelligenceService
|
||||
Task<StoryIntelligenceAssetImportResultViewModel?> GetAssetImportResultAsync(Guid batchId);
|
||||
Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> ImportRelationshipsAsync(Guid batchId, StoryIntelligenceRelationshipImportForm form);
|
||||
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<OnboardingStoryIntelligenceResumeTarget?> ResumeBookAsync(int bookId);
|
||||
}
|
||||
@ -36,6 +38,7 @@ public sealed class OnboardingStoryIntelligenceService(
|
||||
IStoryIntelligenceLocationImportService locationImport,
|
||||
IStoryIntelligenceAssetImportService assetImport,
|
||||
IStoryIntelligenceRelationshipImportService relationshipImport,
|
||||
IStoryIntelligenceKnowledgeImportService knowledgeImport,
|
||||
IStoryIntelligencePipelineStateService pipelineState,
|
||||
IStoryIntelligenceClient client,
|
||||
IOnboardingStoryIntelligenceBatchStore batchStore,
|
||||
@ -271,6 +274,7 @@ public sealed class OnboardingStoryIntelligenceService(
|
||||
var locationReview = await locationImport.BuildReviewAsync(batch);
|
||||
var assetReview = await assetImport.BuildReviewAsync(batch);
|
||||
var relationshipReview = await relationshipImport.BuildReviewAsync(batch);
|
||||
var knowledgeReview = await knowledgeImport.BuildReviewAsync(batch);
|
||||
return new StoryIntelligenceProgressViewModel
|
||||
{
|
||||
BatchID = batch.BatchID,
|
||||
@ -284,6 +288,7 @@ public sealed class OnboardingStoryIntelligenceService(
|
||||
LocationReview = locationReview,
|
||||
AssetReview = assetReview,
|
||||
RelationshipReview = relationshipReview,
|
||||
KnowledgeReview = knowledgeReview,
|
||||
PipelineDashboard = new StoryIntelligencePipelineDashboardViewModel
|
||||
{
|
||||
ChaptersAnalysed = chapters.Count(chapter => chapter.IsRunComplete),
|
||||
@ -299,7 +304,10 @@ public sealed class OnboardingStoryIntelligenceService(
|
||||
AssetStageComplete = batch.AssetStageComplete || assetReview.IsComplete,
|
||||
RelationshipsIdentified = relationshipReview.Candidates.Count + batch.RelationshipDecisions.Count,
|
||||
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)
|
||||
{
|
||||
var progress = await GetProgressAsync(batchId);
|
||||
@ -575,28 +615,41 @@ public sealed class OnboardingStoryIntelligenceService(
|
||||
or StoryIntelligencePipelineStages.AssetImport
|
||||
or StoryIntelligencePipelineStages.RelationshipReview
|
||||
or StoryIntelligencePipelineStages.RelationshipImport
|
||||
or StoryIntelligencePipelineStages.KnowledgeReview
|
||||
or StoryIntelligencePipelineStages.KnowledgeImport
|
||||
or StoryIntelligencePipelineStages.Complete;
|
||||
var locationStageComplete = HasCompletedStage(state, StoryIntelligencePipelineStages.LocationImport)
|
||||
|| state.CurrentStage is StoryIntelligencePipelineStages.AssetReview
|
||||
or StoryIntelligencePipelineStages.AssetImport
|
||||
or StoryIntelligencePipelineStages.RelationshipReview
|
||||
or StoryIntelligencePipelineStages.RelationshipImport
|
||||
or StoryIntelligencePipelineStages.KnowledgeReview
|
||||
or StoryIntelligencePipelineStages.KnowledgeImport
|
||||
or StoryIntelligencePipelineStages.Complete;
|
||||
var assetStageComplete = HasCompletedStage(state, StoryIntelligencePipelineStages.AssetImport)
|
||||
|| state.CurrentStage is StoryIntelligencePipelineStages.RelationshipReview
|
||||
or StoryIntelligencePipelineStages.RelationshipImport
|
||||
or StoryIntelligencePipelineStages.KnowledgeReview
|
||||
or StoryIntelligencePipelineStages.KnowledgeImport
|
||||
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.LocationStageComplete = locationStageComplete;
|
||||
batch.AssetStageComplete = assetStageComplete;
|
||||
batch.RelationshipStageComplete = relationshipStageComplete;
|
||||
batch.KnowledgeStageComplete = knowledgeStageComplete;
|
||||
|
||||
await batchStore.SaveAsync(batch);
|
||||
|
||||
var route = relationshipStageComplete
|
||||
var route = knowledgeStageComplete
|
||||
? StoryIntelligenceResumeRoutes.Complete
|
||||
: relationshipStageComplete
|
||||
? StoryIntelligenceResumeRoutes.Knowledge
|
||||
: assetStageComplete
|
||||
? StoryIntelligenceResumeRoutes.Relationships
|
||||
: locationStageComplete
|
||||
@ -607,6 +660,8 @@ public sealed class OnboardingStoryIntelligenceService(
|
||||
{
|
||||
StoryIntelligencePipelineStages.RelationshipReview => StoryIntelligenceResumeRoutes.Relationships,
|
||||
StoryIntelligencePipelineStages.RelationshipImport => StoryIntelligenceResumeRoutes.Relationships,
|
||||
StoryIntelligencePipelineStages.KnowledgeReview => StoryIntelligenceResumeRoutes.Knowledge,
|
||||
StoryIntelligencePipelineStages.KnowledgeImport => StoryIntelligenceResumeRoutes.Knowledge,
|
||||
StoryIntelligencePipelineStages.CharacterReview => StoryIntelligenceResumeRoutes.Characters,
|
||||
StoryIntelligencePipelineStages.CharacterImport => StoryIntelligenceResumeRoutes.Characters,
|
||||
StoryIntelligencePipelineStages.SceneReview => StoryIntelligenceResumeRoutes.SceneReview,
|
||||
@ -850,15 +905,20 @@ public sealed class OnboardingStoryIntelligenceBatch
|
||||
public List<OnboardingStoryIntelligenceCharacterDecision> CharacterDecisions { get; } = [];
|
||||
public List<OnboardingStoryIntelligenceLocationDecision> LocationDecisions { get; } = [];
|
||||
public List<OnboardingStoryIntelligenceAssetDecision> AssetDecisions { get; } = [];
|
||||
public List<OnboardingStoryIntelligenceRelationshipSelection> RelationshipSelections { get; } = [];
|
||||
public List<OnboardingStoryIntelligenceRelationshipDecision> RelationshipDecisions { get; } = [];
|
||||
public List<OnboardingStoryIntelligenceKnowledgeSelection> KnowledgeSelections { get; } = [];
|
||||
public List<OnboardingStoryIntelligenceKnowledgeDecision> KnowledgeDecisions { get; } = [];
|
||||
public StoryIntelligenceCharacterImportBatchResult? LastCharacterImportResult { get; set; }
|
||||
public StoryIntelligenceLocationImportBatchResult? LastLocationImportResult { get; set; }
|
||||
public StoryIntelligenceAssetImportBatchResult? LastAssetImportResult { get; set; }
|
||||
public StoryIntelligenceRelationshipImportBatchResult? LastRelationshipImportResult { get; set; }
|
||||
public StoryIntelligenceKnowledgeImportBatchResult? LastKnowledgeImportResult { get; set; }
|
||||
public bool CharacterStageComplete { get; set; }
|
||||
public bool LocationStageComplete { get; set; }
|
||||
public bool AssetStageComplete { get; set; }
|
||||
public bool RelationshipStageComplete { get; set; }
|
||||
public bool KnowledgeStageComplete { get; set; }
|
||||
}
|
||||
|
||||
public sealed class OnboardingStoryIntelligenceBatchItem
|
||||
@ -906,6 +966,12 @@ public sealed class OnboardingStoryIntelligenceRelationshipDecision
|
||||
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 int CharactersCreated { get; init; }
|
||||
@ -944,6 +1010,29 @@ public sealed class StoryIntelligenceRelationshipImportBatchResult
|
||||
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 const string SceneReview = "SceneReview";
|
||||
@ -951,6 +1040,7 @@ public static class StoryIntelligenceResumeRoutes
|
||||
public const string Locations = "Locations";
|
||||
public const string Assets = "Assets";
|
||||
public const string Relationships = "Relationships";
|
||||
public const string Knowledge = "Knowledge";
|
||||
public const string Complete = "Complete";
|
||||
}
|
||||
|
||||
|
||||
730
PlotLine/Services/StoryIntelligenceKnowledgeImportService.cs
Normal file
730
PlotLine/Services/StoryIntelligenceKnowledgeImportService.cs
Normal 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);
|
||||
}
|
||||
@ -60,6 +60,7 @@ public sealed class StoryPromptRepository(
|
||||
ILogger<StoryPromptRepository> logger) : IStoryPromptRepository
|
||||
{
|
||||
private const string PromptFolder = "Docs/AI";
|
||||
private const string RepositoryPromptFolder = "PlotLine/Docs/AI";
|
||||
|
||||
public async Task<string> LoadPromptAsync(string fileName, CancellationToken cancellationToken)
|
||||
{
|
||||
@ -77,10 +78,10 @@ public sealed class StoryPromptRepository(
|
||||
return cachedPrompt;
|
||||
}
|
||||
|
||||
var promptPath = Path.Combine(environment.ContentRootPath, PromptFolder, fileName);
|
||||
if (!File.Exists(promptPath))
|
||||
var promptPath = ResolvePromptPath(fileName);
|
||||
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);
|
||||
@ -93,6 +94,23 @@ public sealed class StoryPromptRepository(
|
||||
logger.LogInformation("Loaded Story Intelligence prompt {PromptFile} from {PromptPath}.", fileName, promptPath);
|
||||
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
|
||||
|
||||
@ -13,6 +13,7 @@ public interface IStoryIntelligencePipelineStateService
|
||||
Task RecordLocationImportAsync(int projectId, int bookId);
|
||||
Task RecordAssetImportAsync(int projectId, int bookId);
|
||||
Task RecordRelationshipImportAsync(int projectId, int bookId);
|
||||
Task RecordKnowledgeImportAsync(int projectId, int bookId);
|
||||
Task<IReadOnlyList<StoryIntelligenceCommittedRunSummary>> ListCommittedRunsByBookAsync(int bookId, int userId);
|
||||
}
|
||||
|
||||
@ -128,8 +129,23 @@ public sealed class StoryIntelligencePipelineStateService(
|
||||
{
|
||||
ProjectID = projectId,
|
||||
BookID = bookId,
|
||||
CurrentStage = StoryIntelligencePipelineStages.Complete,
|
||||
CurrentStage = StoryIntelligencePipelineStages.KnowledgeReview,
|
||||
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,
|
||||
Status = StoryIntelligencePipelineStatuses.Complete,
|
||||
CompletedUtc = DateTime.UtcNow,
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.AspNetCore.Mvc.Rendering;
|
||||
using PlotLine.Data;
|
||||
using PlotLine.Models;
|
||||
using PlotLine.ViewModels;
|
||||
@ -28,6 +29,9 @@ public sealed class StoryIntelligenceRelationshipImportService(
|
||||
public async Task<StoryIntelligenceRelationshipReviewViewModel> BuildReviewAsync(OnboardingStoryIntelligenceBatch 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 visibleCandidates = data.Candidates.Where(candidate => !decidedKeys.Contains(candidate.Key)).ToList();
|
||||
return new StoryIntelligenceRelationshipReviewViewModel
|
||||
@ -36,12 +40,14 @@ public sealed class StoryIntelligenceRelationshipImportService(
|
||||
CanImport = visibleCandidates.Count > 0,
|
||||
AlreadyLinkedCount = data.AlreadyLinkedCount,
|
||||
IsComplete = data.HasCommittedScenes && visibleCandidates.Count == 0,
|
||||
RelationshipTypeOptions = typeOptions,
|
||||
Candidates = visibleCandidates.Select(candidate => new StoryIntelligenceRelationshipReviewCandidateViewModel
|
||||
{
|
||||
Key = candidate.Key,
|
||||
CharacterAName = candidate.CharacterAName,
|
||||
CharacterBName = candidate.CharacterBName,
|
||||
RelationshipType = candidate.RelationshipType,
|
||||
RelationshipTypeID = ResolveRelationshipTypeID(lookupData.RelationshipTypes, selectedTypeIds.GetValueOrDefault(candidate.Key), candidate.RelationshipType),
|
||||
AppearsInScenes = candidate.Appearances.Select(appearance => appearance.SceneID).Distinct().Count(),
|
||||
Confidence = ConfidenceBand(candidate.Confidence),
|
||||
FirstAppearance = candidate.FirstAppearanceNote,
|
||||
@ -65,6 +71,7 @@ public sealed class StoryIntelligenceRelationshipImportService(
|
||||
var choices = form.Relationships
|
||||
.Where(choice => !string.IsNullOrWhiteSpace(choice.Key))
|
||||
.ToDictionary(choice => choice.Key, choice => choice, StringComparer.OrdinalIgnoreCase);
|
||||
SaveRelationshipSelections(batch, choices.Values);
|
||||
if (choices.Count == 0)
|
||||
{
|
||||
if (data.Candidates.Count == 0)
|
||||
@ -83,6 +90,7 @@ public sealed class StoryIntelligenceRelationshipImportService(
|
||||
}
|
||||
|
||||
var lookupData = await characters.GetLookupsAsync(batch.ProjectID);
|
||||
var relationshipTypesById = lookupData.RelationshipTypes.ToDictionary(type => type.RelationshipTypeID);
|
||||
var created = 0;
|
||||
var linkedExisting = 0;
|
||||
var merged = 0;
|
||||
@ -116,12 +124,17 @@ public sealed class StoryIntelligenceRelationshipImportService(
|
||||
|
||||
if (!relationshipId.HasValue)
|
||||
{
|
||||
if (!choice.RelationshipTypeID.HasValue || !relationshipTypesById.ContainsKey(choice.RelationshipTypeID.Value))
|
||||
{
|
||||
return new StoryIntelligenceImportCommitResult { Success = false, Message = "Choose a valid relationship type for each relationship you want to create." };
|
||||
}
|
||||
|
||||
relationshipId = await characters.SaveRelationshipAsync(new CharacterRelationship
|
||||
{
|
||||
ProjectID = batch.ProjectID,
|
||||
CharacterAID = candidate.CharacterAID,
|
||||
CharacterBID = candidate.CharacterBID,
|
||||
RelationshipTypeID = MatchRelationshipType(lookupData.RelationshipTypes, choice.RelationshipType ?? candidate.RelationshipType),
|
||||
RelationshipTypeID = choice.RelationshipTypeID.Value,
|
||||
IsPermanent = false,
|
||||
StartSceneID = candidate.Appearances.OrderBy(appearance => appearance.SceneNumber).FirstOrDefault()?.SceneID,
|
||||
EndSceneID = null,
|
||||
@ -358,7 +371,7 @@ public sealed class StoryIntelligenceRelationshipImportService(
|
||||
characterA.CharacterName,
|
||||
characterB.CharacterID,
|
||||
characterB.CharacterName,
|
||||
RelationshipTypeFromSignal(relationshipSignal),
|
||||
RelationshipTypeFromSignal(relationshipSignal, evidence),
|
||||
existingMatch?.CharacterRelationshipID,
|
||||
existingMatch is null ? null : $"{existingMatch.CharacterAName} / {existingMatch.RelationshipTypeName} / {existingMatch.CharacterBName}");
|
||||
groups[key] = candidate;
|
||||
@ -366,7 +379,7 @@ public sealed class StoryIntelligenceRelationshipImportService(
|
||||
|
||||
if ((confidence ?? 0m) >= (candidate.Confidence ?? 0m))
|
||||
{
|
||||
candidate.RelationshipType = RelationshipTypeFromSignal(relationshipSignal);
|
||||
candidate.RelationshipType = RelationshipTypeFromSignal(relationshipSignal, evidence);
|
||||
}
|
||||
|
||||
candidate.Confidence = Max(candidate.Confidence, confidence);
|
||||
@ -459,15 +472,12 @@ public sealed class StoryIntelligenceRelationshipImportService(
|
||||
&& !string.IsNullOrWhiteSpace(observation.ObjectName);
|
||||
}
|
||||
|
||||
private static int MatchRelationshipType(IReadOnlyList<RelationshipType> types, string? signal)
|
||||
{
|
||||
var normalised = Normalise(signal);
|
||||
var mapped = RelationshipTypeFromSignal(signal);
|
||||
return types.FirstOrDefault(type => string.Equals(type.TypeName, mapped, StringComparison.OrdinalIgnoreCase))?.RelationshipTypeID
|
||||
?? types.FirstOrDefault(type => normalised.Contains(Normalise(type.TypeName), StringComparison.OrdinalIgnoreCase))?.RelationshipTypeID
|
||||
?? types.FirstOrDefault(type => string.Equals(type.TypeName, "Unknown", StringComparison.OrdinalIgnoreCase))?.RelationshipTypeID
|
||||
?? types.First().RelationshipTypeID;
|
||||
}
|
||||
private static int ResolveRelationshipTypeID(IReadOnlyList<RelationshipType> types, int? selectedRelationshipTypeId, string? suggestedType)
|
||||
=> selectedRelationshipTypeId.HasValue && types.Any(type => type.RelationshipTypeID == selectedRelationshipTypeId.Value)
|
||||
? selectedRelationshipTypeId.Value
|
||||
: types.FirstOrDefault(type => string.Equals(type.TypeName, suggestedType, StringComparison.OrdinalIgnoreCase))?.RelationshipTypeID
|
||||
?? types.FirstOrDefault(type => string.Equals(type.TypeName, "Unknown", StringComparison.OrdinalIgnoreCase))?.RelationshipTypeID
|
||||
?? types.First().RelationshipTypeID;
|
||||
|
||||
private static int MatchRelationshipState(IReadOnlyList<RelationshipState> states, string? signal)
|
||||
{
|
||||
@ -486,27 +496,64 @@ public sealed class StoryIntelligenceRelationshipImportService(
|
||||
?? states.First().RelationshipStateID;
|
||||
}
|
||||
|
||||
private static string RelationshipTypeFromSignal(string? signal)
|
||||
private static string RelationshipTypeFromSignal(string? signal, string? evidence = null)
|
||||
{
|
||||
var value = Normalise(signal);
|
||||
if (value.Contains("mother") || value.Contains("father") || value.Contains("parent")) return "Parent";
|
||||
if (value.Contains("daughter") || value.Contains("son") || value.Contains("child")) return "Child";
|
||||
if (value.Contains("sister") || value.Contains("brother") || value.Contains("sibling")) return "Sibling";
|
||||
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 (value.Contains("enemy") || value.Contains("adversarial") || value.Contains("hostile")) return "Enemy";
|
||||
if (value.Contains("rival")) return "Rival";
|
||||
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 (value.Contains("student")) return "Student";
|
||||
if (value.Contains("employer") || value.Contains("boss")) return "Employer";
|
||||
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 (value.Contains("guardian") || value.Contains("protector")) return "Protector";
|
||||
if (value.Contains("estranged") || value.Contains("separat")) return "Estranged From";
|
||||
var value = NormaliseForMatching($"{signal} {evidence}");
|
||||
if (ContainsAny(value, "mother", "father", "sister", "brother", "parent", "daughter", "son", " child ", "aunt", "uncle", "cousin", "family", "sibling", "parent and child"))
|
||||
{
|
||||
return "Family";
|
||||
}
|
||||
|
||||
if (ContainsAny(value, "romance", "romantic", "lover", "spouse", "husband", "wife", "girlfriend", "boyfriend", "love interest", "former partner", "partner like", "partner-like"))
|
||||
{
|
||||
return "Romantic";
|
||||
}
|
||||
|
||||
if (ContainsAny(value, "teacher", "student", "instructor", "learner", "mentor", "pupil", "tutor", "driving instructor"))
|
||||
{
|
||||
return "Educational";
|
||||
}
|
||||
|
||||
if (ContainsAny(value, "police", "detective", "suspect", "witness", "authority", "guard", "prisoner", "investigator", "person of interest"))
|
||||
{
|
||||
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";
|
||||
}
|
||||
|
||||
@ -541,6 +588,32 @@ public sealed class StoryIntelligenceRelationshipImportService(
|
||||
private static string RelationshipLabel(RelationshipCandidate candidate)
|
||||
=> $"{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)
|
||||
{
|
||||
var summary = Clean(parsed.Summary?.Short);
|
||||
@ -592,10 +665,17 @@ public sealed class StoryIntelligenceRelationshipImportService(
|
||||
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 static readonly string[] RelationshipSignalWords =
|
||||
[
|
||||
|
||||
@ -269,11 +269,13 @@ public sealed class StoryIntelligenceService(
|
||||
|
||||
return state.CurrentReviewStage switch
|
||||
{
|
||||
StoryIntelligencePipelineStages.KnowledgeReview => "Waiting for Knowledge Review",
|
||||
StoryIntelligencePipelineStages.RelationshipReview => "Waiting for Relationship Review",
|
||||
StoryIntelligencePipelineStages.AssetReview => "Waiting for Asset Review",
|
||||
StoryIntelligencePipelineStages.LocationReview => "Waiting for Location Review",
|
||||
StoryIntelligencePipelineStages.CharacterReview => "Waiting for Character Review",
|
||||
StoryIntelligencePipelineStages.SceneReview => "Needs Scene Review",
|
||||
_ when StoryIntelligenceReadyForKnowledge(state) => "Waiting for Knowledge Review",
|
||||
_ when StoryIntelligenceReadyForRelationships(state) => "Waiting for Relationship Review",
|
||||
_ when StoryIntelligenceReadyForAssets(state) => "Waiting for Asset Review",
|
||||
_ when StoryIntelligenceReadyForLocations(state) => "Waiting for Location Review",
|
||||
@ -288,33 +290,42 @@ public sealed class StoryIntelligenceService(
|
||||
? "All currently available Story Intelligence stages are complete."
|
||||
: 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.AssetReview => "Review detected assets 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.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 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.",
|
||||
_ => "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)
|
||||
=> 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)
|
||||
=> string.Equals(state.LastCompletedStage, StoryIntelligencePipelineStages.LocationImport, StringComparison.OrdinalIgnoreCase)
|
||||
|| state.CurrentStage is StoryIntelligencePipelineStages.AssetReview or StoryIntelligencePipelineStages.AssetImport
|
||||
or StoryIntelligencePipelineStages.RelationshipReview or StoryIntelligencePipelineStages.RelationshipImport
|
||||
or StoryIntelligencePipelineStages.KnowledgeReview or StoryIntelligencePipelineStages.KnowledgeImport
|
||||
|| (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)
|
||||
=> string.Equals(state.LastCompletedStage, StoryIntelligencePipelineStages.CharacterImport, StringComparison.OrdinalIgnoreCase)
|
||||
|| state.CurrentStage is StoryIntelligencePipelineStages.LocationReview or StoryIntelligencePipelineStages.LocationImport
|
||||
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)
|
||||
=> elapsed.TotalMinutes < 1
|
||||
|
||||
73
PlotLine/Sql/132_Phase20AT_RelationshipTypeTaxonomy.sql
Normal file
73
PlotLine/Sql/132_Phase20AT_RelationshipTypeTaxonomy.sql
Normal 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
|
||||
53
PlotLine/Sql/133_Phase20AU_KnowledgeReviewPipelineStages.sql
Normal file
53
PlotLine/Sql/133_Phase20AU_KnowledgeReviewPipelineStages.sql
Normal 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
|
||||
@ -1,4 +1,5 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Microsoft.AspNetCore.Mvc.Rendering;
|
||||
using PlotLine.Models;
|
||||
|
||||
namespace PlotLine.ViewModels;
|
||||
@ -146,6 +147,7 @@ public sealed class StoryIntelligenceProgressViewModel
|
||||
public StoryIntelligenceLocationReviewViewModel LocationReview { get; init; } = new();
|
||||
public StoryIntelligenceAssetReviewViewModel AssetReview { get; init; } = new();
|
||||
public StoryIntelligenceRelationshipReviewViewModel RelationshipReview { get; init; } = new();
|
||||
public StoryIntelligenceKnowledgeReviewViewModel KnowledgeReview { get; init; } = new();
|
||||
public StoryIntelligencePipelineDashboardViewModel PipelineDashboard { get; init; } = new();
|
||||
public int ChapterCount => Chapters.Count;
|
||||
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 HasAssetsToReview => AssetReview.Candidates.Count > 0;
|
||||
public bool HasRelationshipsToReview => RelationshipReview.Candidates.Count > 0;
|
||||
public bool HasKnowledgeToReview => KnowledgeReview.Candidates.Count > 0;
|
||||
}
|
||||
|
||||
public sealed class StoryIntelligencePipelineDashboardViewModel
|
||||
@ -182,6 +185,9 @@ public sealed class StoryIntelligencePipelineDashboardViewModel
|
||||
public int RelationshipsIdentified { get; init; }
|
||||
public int RelationshipsCreatedOrLinked { 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
|
||||
@ -333,6 +339,7 @@ public sealed class StoryIntelligenceRelationshipReviewViewModel
|
||||
public bool HasCommittedScenes { get; init; }
|
||||
public int AlreadyLinkedCount { get; init; }
|
||||
public bool IsComplete { get; init; }
|
||||
public IReadOnlyList<SelectListItem> RelationshipTypeOptions { 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 CharacterBName { get; init; } = string.Empty;
|
||||
public string RelationshipType { get; init; } = "Unknown";
|
||||
public int RelationshipTypeID { get; init; }
|
||||
public int AppearsInScenes { get; init; }
|
||||
public string Confidence { get; init; } = "Unknown";
|
||||
public string? FirstAppearance { get; init; }
|
||||
@ -364,7 +372,7 @@ public sealed class StoryIntelligenceRelationshipImportChoiceForm
|
||||
{
|
||||
public string Key { get; set; } = string.Empty;
|
||||
public string Action { get; set; } = StoryIntelligenceRelationshipImportActions.CreateNew;
|
||||
public string? RelationshipType { get; set; }
|
||||
public int? RelationshipTypeID { get; set; }
|
||||
public string? AliasTargetKey { get; set; }
|
||||
}
|
||||
|
||||
@ -376,6 +384,55 @@ public static class StoryIntelligenceRelationshipImportActions
|
||||
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 StoryIntelligenceJobProgress Job { get; init; } = new();
|
||||
@ -447,6 +504,17 @@ public sealed class StoryIntelligenceRelationshipImportResultViewModel
|
||||
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 string TemporaryChapterKey { get; init; } = string.Empty;
|
||||
|
||||
@ -25,7 +25,7 @@
|
||||
<li><span aria-hidden="true">✓</span><strong>Locations</strong></li>
|
||||
<li><span aria-hidden="true">✓</span><strong>Assets</strong></li>
|
||||
<li><span aria-hidden="true">✓</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">✓</span><strong>Knowledge</strong></li>
|
||||
<li><span aria-hidden="true">✓</span><strong>Preparing continuity...</strong></li>
|
||||
<li><span aria-hidden="true">✓</span><strong>Generating story indexes...</strong></li>
|
||||
<li><span aria-hidden="true">✓</span><strong>Finalising project...</strong></li>
|
||||
|
||||
236
PlotLine/Views/Onboarding/StoryIntelligenceKnowledge.cshtml
Normal file
236
PlotLine/Views/Onboarding/StoryIntelligenceKnowledge.cshtml
Normal 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>");
|
||||
}
|
||||
@ -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">✓</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>
|
||||
@ -12,7 +12,7 @@
|
||||
<span aria-hidden="true">✓</span>
|
||||
<h1 id="story-relationship-complete-title">Relationships created</h1>
|
||||
</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 class="onboarding-scan-counts onboarding-review-counts">
|
||||
@ -39,14 +39,13 @@
|
||||
</div>
|
||||
|
||||
<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>Timeline Events</strong><span>Coming Soon</span></article>
|
||||
</div>
|
||||
|
||||
<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-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>
|
||||
</section>
|
||||
|
||||
@ -127,7 +127,21 @@
|
||||
|
||||
<div data-relationship-type-panel>
|
||||
<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 data-relationship-alias-panel hidden>
|
||||
|
||||
@ -7,7 +7,7 @@
|
||||
new PipelineStage("Review Locations", "Locations", true),
|
||||
new PipelineStage("Review Assets", "Assets", 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 currentReviewIndex = implementedReviewStages.FindIndex(stage => string.Equals(stage.StageName, Model.CurrentStage, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
@ -42,7 +42,11 @@
|
||||
<strong>@(Model.RelationshipStageComplete ? $"Done: {Model.RelationshipsCreatedOrLinked:N0}" : "In review")</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Knowledge</span>
|
||||
<strong>Coming next</strong>
|
||||
<span>Knowledge identified</span>
|
||||
<strong>@Model.KnowledgeIdentified.ToString("N0")</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Knowledge created / linked</span>
|
||||
<strong>@(Model.KnowledgeStageComplete ? $"Done: {Model.KnowledgeCreatedOrLinked:N0}" : "In review")</strong>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user