Phase 20AU – Knowledge Intelligence Review and Import

This commit is contained in:
Nick Beckley 2026-07-10 21:30:53 +00:00
parent 3d54362c3b
commit faf0e43412
16 changed files with 1377 additions and 16 deletions

View File

@ -19,7 +19,9 @@ var tests = new (string Name, Action Test)[]
("Asset filtering rejects generic objects", AssetFilteringRejectsGenericObjects),
("Asset filtering preserves story assets", AssetFilteringPreservesStoryAssets),
("Asset canonical keys merge trivial variants", AssetCanonicalKeysMergeTrivialVariants),
("Relationship signals map to broad lookup types", RelationshipSignalsMapToBroadTypes)
("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)
@ -238,6 +240,32 @@ static void RelationshipSignalsMapToBroadTypes()
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);
@ -268,6 +296,24 @@ static string RelationshipTypeFromSignal(string signal, string? evidence = null)
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()
{

View File

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

View File

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

View File

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

View File

@ -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,
@ -852,14 +907,18 @@ public sealed class OnboardingStoryIntelligenceBatch
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
@ -951,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";
@ -958,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";
}

View File

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

View File

@ -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,

View File

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

View File

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

View File

@ -147,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);
@ -165,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
@ -183,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
@ -379,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();
@ -450,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;

View File

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

View File

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

View File

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

View File

@ -12,7 +12,7 @@
<span aria-hidden="true">&check;</span>
<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>

View File

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

View File

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