diff --git a/PlotLine/Controllers/OnboardingController.cs b/PlotLine/Controllers/OnboardingController.cs index f6a9578..4468266 100644 --- a/PlotLine/Controllers/OnboardingController.cs +++ b/PlotLine/Controllers/OnboardingController.cs @@ -164,6 +164,20 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard : RedirectToAction(nameof(StoryIntelligenceReview), new { batchId }); } + [HttpPost("story-intelligence/characters")] + [ValidateAntiForgeryToken] + public async Task ImportStoryIntelligenceCharacters(StoryIntelligenceCharacterImportForm form) + { + var (progress, result) = await storyIntelligence.ImportCharactersAsync(form.BatchID, form); + if (progress is null) + { + return NotFound(); + } + + TempData[result.Success ? "OnboardingStoryIntelligenceMessage" : "OnboardingStoryIntelligenceError"] = result.Message; + return RedirectToAction(nameof(StoryIntelligenceReview), new { batchId = form.BatchID }); + } + [HttpGet("story-intelligence/complete")] public async Task StoryIntelligenceComplete(Guid batchId) { diff --git a/PlotLine/Data/Repositories.cs b/PlotLine/Data/Repositories.cs index ac6834e..9cb5c50 100644 --- a/PlotLine/Data/Repositories.cs +++ b/PlotLine/Data/Repositories.cs @@ -2397,7 +2397,7 @@ public sealed class ChapterRepository(ISqlConnectionFactory connectionFactory) : using var connection = connectionFactory.CreateConnection(); return await connection.QuerySingleAsync( "dbo.Chapter_Save", - new { chapter.ChapterID, chapter.BookID, chapter.ChapterNumber, chapter.ChapterTitle, chapter.Summary, chapter.RevisionStatusID }, + new { chapter.ChapterID, chapter.BookID, chapter.ChapterNumber, chapter.ChapterTitle, chapter.Summary, chapter.ChapterPurposeID, chapter.RevisionStatusID }, commandType: CommandType.StoredProcedure); } @@ -2657,7 +2657,8 @@ public sealed class LookupRepository(ISqlConnectionFactory connectionFactory) : TimeModes = (await result.ReadAsync()).ToList(), DurationUnits = (await result.ReadAsync()).ToList(), TimeConfidences = (await result.ReadAsync()).ToList(), - ScenePurposeTypes = (await result.ReadAsync()).ToList() + ScenePurposeTypes = (await result.ReadAsync()).ToList(), + ChapterPurposeTypes = (await result.ReadAsync()).ToList() }; } } diff --git a/PlotLine/Data/StoryIntelligenceResultRepository.cs b/PlotLine/Data/StoryIntelligenceResultRepository.cs index e2ee7e0..c8c1673 100644 --- a/PlotLine/Data/StoryIntelligenceResultRepository.cs +++ b/PlotLine/Data/StoryIntelligenceResultRepository.cs @@ -437,6 +437,20 @@ public sealed class StoryIntelligenceResultRepository(ISqlConnectionFactory conn }; } + if (!string.IsNullOrWhiteSpace(request.ChapterSummary) || request.ChapterPurposeID.HasValue) + { + await connection.ExecuteAsync( + "dbo.StoryIntelligenceImportCommit_UpdateChapterPlanning", + new + { + request.ChapterID, + Summary = request.ChapterSummary, + request.ChapterPurposeID + }, + transaction, + commandType: CommandType.StoredProcedure); + } + var orderedScenes = request.Scenes.OrderBy(scene => scene.TemporarySceneNumber).ToList(); for (var index = 0; index < orderedScenes.Count; index++) { diff --git a/PlotLine/Docs/StoryIntelligence_Phase20AH_ChapterAndCharacterImport.md b/PlotLine/Docs/StoryIntelligence_Phase20AH_ChapterAndCharacterImport.md new file mode 100644 index 0000000..4ce073d --- /dev/null +++ b/PlotLine/Docs/StoryIntelligence_Phase20AH_ChapterAndCharacterImport.md @@ -0,0 +1,67 @@ +# Phase 20AH - Chapter Intelligence and Character Import + +Phase 20AH completes the first chapter-level import path and begins author-reviewed character import from existing stored Story Intelligence JSON. + +## Chapter Intelligence Import + +When a Story Intelligence scene commit runs, PlotDirector now also updates the target chapter inside the same import transaction. + +Imported chapter fields: + +- Chapter summary: populated from the Chapter Structure `chapterSummary` response. +- Chapter purpose: mapped to an existing Chapter Purpose lookup value where the stored Chapter Intelligence response supports a clear match. + +The import does not concatenate scene summaries to create a chapter summary. + +Chapter Purpose is a chapter-level lookup, separate from Scene Purpose. The default lookup values are: + +- Opening +- Character Development +- Relationship Development +- World Building +- Investigation +- Revelation +- Escalation +- Turning Point +- Climax +- Resolution +- Transition + +Chapter Purpose is editable on the standard chapter edit screen and visible on the chapter detail page. + +## Character Import + +After scenes have been created, the onboarding review page can show a character review section. + +The review is built from stored Scene Intelligence results only. It does not call OpenAI again. + +For each candidate, PlotDirector shows: + +- Character name +- Number of scenes where the character appears or is mentioned +- Confidence band +- Possible aliases +- First appearance example +- Existing matched character where available + +The author can approve, ignore, or rename new characters before import. + +When approved: + +- Existing characters are linked where names or aliases match. +- New characters are created only for approved candidates. +- Aliases from Scene Intelligence are saved where they do not duplicate the primary name or an existing alias. +- Scene People panels are populated for characters physically present in imported scenes. +- Mentioned-only characters can be created if approved, but they are not added as physically present scene appearances. +- Previously unresolved scene POV links are retried after character creation. + +The import does not infer gender or create placeholder characters. + +## Still Future Phases + +- Locations +- Assets +- Relationships +- Knowledge changes +- Continuity warnings +- Real timeline events diff --git a/PlotLine/Models/CoreModels.cs b/PlotLine/Models/CoreModels.cs index cf64513..0fcdcf6 100644 --- a/PlotLine/Models/CoreModels.cs +++ b/PlotLine/Models/CoreModels.cs @@ -345,6 +345,8 @@ public sealed class Chapter public string ChapterTitle { get; set; } = string.Empty; public int? POVCharacterID { get; set; } public string? Summary { get; set; } + public int? ChapterPurposeID { get; set; } + public string? ChapterPurposeName { get; set; } public int SortOrder { get; set; } public int RevisionStatusID { get; set; } public string RevisionStatusName { get; set; } = string.Empty; @@ -439,6 +441,14 @@ public sealed class RevisionStatus public int SortOrder { get; set; } } +public sealed class ChapterPurposeType +{ + public int ChapterPurposeID { get; set; } + public string PurposeName { get; set; } = string.Empty; + public int SortOrder { get; set; } + public bool IsArchived { get; set; } +} + public sealed class TimeMode { public int TimeModeID { get; set; } @@ -2555,4 +2565,5 @@ public sealed class LookupData public IReadOnlyList DurationUnits { get; init; } = []; public IReadOnlyList TimeConfidences { get; init; } = []; public IReadOnlyList ScenePurposeTypes { get; init; } = []; + public IReadOnlyList ChapterPurposeTypes { get; init; } = []; } diff --git a/PlotLine/Models/StoryIntelligencePersistenceModels.cs b/PlotLine/Models/StoryIntelligencePersistenceModels.cs index a6190e1..a263512 100644 --- a/PlotLine/Models/StoryIntelligencePersistenceModels.cs +++ b/PlotLine/Models/StoryIntelligencePersistenceModels.cs @@ -409,6 +409,8 @@ public sealed class StoryIntelligenceImportCommitRequest public int TimeConfidenceID { get; init; } public int RevisionStatusID { get; init; } public int? TimelineNoteTypeID { get; init; } + public string? ChapterSummary { get; init; } + public int? ChapterPurposeID { get; init; } public IReadOnlyList Scenes { get; init; } = []; public IReadOnlyList Warnings { get; init; } = []; } diff --git a/PlotLine/Program.cs b/PlotLine/Program.cs index a4c6cab..88a3016 100644 --- a/PlotLine/Program.cs +++ b/PlotLine/Program.cs @@ -206,6 +206,7 @@ public class Program builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/PlotLine/Services/CoreServices.cs b/PlotLine/Services/CoreServices.cs index b974c3d..6ab5268 100644 --- a/PlotLine/Services/CoreServices.cs +++ b/PlotLine/Services/CoreServices.cs @@ -1377,7 +1377,8 @@ public sealed class ChapterService( Project = project, ChapterNumber = existing.Count + 1, RevisionStatusID = DefaultRevisionStatusId(lookupData), - RevisionStatuses = ToSelectList(lookupData.RevisionStatuses, x => x.RevisionStatusID, x => x.StatusName) + RevisionStatuses = ToSelectList(lookupData.RevisionStatuses, x => x.RevisionStatusID, x => x.StatusName), + ChapterPurposes = ToOptionalSelectList(lookupData.ChapterPurposeTypes, x => x.ChapterPurposeID, x => x.PurposeName) }; } @@ -1404,11 +1405,13 @@ public sealed class ChapterService( ChapterNumber = chapter.ChapterNumber, ChapterTitle = chapter.ChapterTitle, Summary = chapter.Summary, + ChapterPurposeID = chapter.ChapterPurposeID, RevisionStatusID = chapter.RevisionStatusID, IsLinkedToManuscript = chapter.IsLinkedToManuscript, Book = book, Project = project, - RevisionStatuses = ToSelectList(lookupData.RevisionStatuses, x => x.RevisionStatusID, x => x.StatusName) + RevisionStatuses = ToSelectList(lookupData.RevisionStatuses, x => x.RevisionStatusID, x => x.StatusName), + ChapterPurposes = ToOptionalSelectList(lookupData.ChapterPurposeTypes, x => x.ChapterPurposeID, x => x.PurposeName) }; } @@ -1427,6 +1430,7 @@ public sealed class ChapterService( ChapterNumber = model.ChapterNumber, ChapterTitle = model.ChapterTitle, Summary = model.Summary, + ChapterPurposeID = model.ChapterPurposeID, RevisionStatusID = model.RevisionStatusID }); var book = await books.GetAsync(model.BookID); @@ -1454,6 +1458,9 @@ public sealed class ChapterService( internal static IReadOnlyList ToSelectList(IEnumerable rows, Func value, Func text) => rows.Select(x => new SelectListItem(text(x), value(x).ToString())).ToList(); + + internal static IReadOnlyList ToOptionalSelectList(IEnumerable rows, Func value, Func text) + => rows.Select(x => new SelectListItem(text(x), value(x).ToString())).Prepend(new SelectListItem("None", string.Empty)).ToList(); } public sealed class SceneService( diff --git a/PlotLine/Services/OnboardingStoryIntelligenceService.cs b/PlotLine/Services/OnboardingStoryIntelligenceService.cs index de86cd6..f63a998 100644 --- a/PlotLine/Services/OnboardingStoryIntelligenceService.cs +++ b/PlotLine/Services/OnboardingStoryIntelligenceService.cs @@ -14,6 +14,7 @@ public interface IOnboardingStoryIntelligenceService Task CancelAsync(Guid batchId); Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> CommitAsync(Guid batchId, int runId); Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> CommitAllAsync(Guid batchId); + Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> ImportCharactersAsync(Guid batchId, StoryIntelligenceCharacterImportForm form); Task GetCompletionAsync(Guid batchId); } @@ -23,6 +24,7 @@ public sealed class OnboardingStoryIntelligenceService( IManuscriptScanPreviewStore scanStore, IStoryIntelligenceResultRepository runs, IStoryIntelligenceImportCommitService commits, + IStoryIntelligenceCharacterImportService characterImport, IStoryIntelligenceClient client, IOnboardingStoryIntelligenceBatchStore batchStore, IStoryIntelligenceProgressNotifier notifier, @@ -261,7 +263,8 @@ public sealed class OnboardingStoryIntelligenceService( BookID = batch.BookID, ProjectName = batch.ProjectName, BookTitle = batch.BookTitle, - Chapters = chapters + Chapters = chapters, + CharacterReview = await characterImport.BuildReviewAsync(batch) }; } @@ -328,6 +331,18 @@ public sealed class OnboardingStoryIntelligenceService( }); } + public async Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> ImportCharactersAsync(Guid batchId, StoryIntelligenceCharacterImportForm 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 characterImport.ImportAsync(batch, form); + return (await GetProgressAsync(batchId), result); + } + public async Task GetCompletionAsync(Guid batchId) { var progress = await GetProgressAsync(batchId); diff --git a/PlotLine/Services/StoryIntelligenceCharacterImportService.cs b/PlotLine/Services/StoryIntelligenceCharacterImportService.cs new file mode 100644 index 0000000..1d6e547 --- /dev/null +++ b/PlotLine/Services/StoryIntelligenceCharacterImportService.cs @@ -0,0 +1,472 @@ +using System.Text.Json; +using PlotLine.Data; +using PlotLine.Models; +using PlotLine.ViewModels; + +namespace PlotLine.Services; + +public interface IStoryIntelligenceCharacterImportService +{ + Task BuildReviewAsync(OnboardingStoryIntelligenceBatch batch); + Task ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceCharacterImportForm form); +} + +public sealed class StoryIntelligenceCharacterImportService( + IStoryIntelligenceResultRepository runs, + ICharacterRepository characters, + ISceneRepository scenes, + ILogger logger) : IStoryIntelligenceCharacterImportService +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true + }; + + private static readonly HashSet IgnoredNames = CreateSet("narrator", "unknown"); + + private static readonly HashSet GenericPeople = CreateSet( + "man", "woman", "boy", "girl", "child", "children", "person", "people", "doctor", "nurse", + "police officer", "driver", "receptionist", "attendant", "guard", "security guard", "waiter", "waitress", + "clerk", "shopkeeper", "cashier", "crowd", "family", "group", "small group"); + + public async Task BuildReviewAsync(OnboardingStoryIntelligenceBatch batch) + { + var data = await BuildCandidateDataAsync(batch); + return new StoryIntelligenceCharacterReviewViewModel + { + HasCommittedScenes = data.HasCommittedScenes, + CanImport = data.Candidates.Count > 0, + AlreadyLinkedCount = data.AlreadyLinkedCount, + Candidates = data.Candidates.Select(candidate => new StoryIntelligenceCharacterReviewCandidateViewModel + { + Key = candidate.Key, + CharacterName = candidate.DisplayName, + ImportName = candidate.DisplayName, + AppearsInScenes = candidate.PresentAppearances.Concat(candidate.MentionedOnlyAppearances).Select(appearance => appearance.SceneID).Distinct().Count(), + Confidence = ConfidenceBand(candidate.Confidence), + PossibleAliases = candidate.Aliases.OrderBy(value => value).ToList(), + ExampleFirstAppearance = candidate.FirstAppearanceNote, + ExistingCharacterID = candidate.ExistingCharacterID, + ExistingCharacterName = candidate.ExistingCharacterName + }).ToList() + }; + } + + public async Task ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceCharacterImportForm form) + { + var data = await BuildCandidateDataAsync(batch); + if (!data.HasCommittedScenes) + { + return new StoryIntelligenceImportCommitResult { Success = false, Message = "Create scenes before importing characters." }; + } + + var choices = form.Characters + .Where(choice => choice.Include && !string.IsNullOrWhiteSpace(choice.Key)) + .ToDictionary(choice => choice.Key, choice => choice, StringComparer.OrdinalIgnoreCase); + if (choices.Count == 0) + { + return new StoryIntelligenceImportCommitResult { Success = false, Message = "Choose at least one character to create or link." }; + } + + var existingIndex = await BuildCharacterIndexAsync(batch.ProjectID); + var lookupData = await characters.GetLookupsAsync(batch.ProjectID); + var roleTypes = lookupData.RoleTypes; + var presenceTypes = lookupData.PresenceTypes; + var created = 0; + var linkedAppearances = 0; + var povLinks = 0; + var resolvedCharacters = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var candidate in data.Candidates) + { + if (!choices.TryGetValue(candidate.Key, out var choice)) + { + continue; + } + + var requestedName = Clean(choice.ImportName); + var importName = string.IsNullOrWhiteSpace(requestedName) ? candidate.DisplayName : requestedName; + var characterId = candidate.ExistingCharacterID + ?? existingIndex.Find(importName)?.CharacterID + ?? existingIndex.Find(candidate.DisplayName)?.CharacterID; + + if (!characterId.HasValue) + { + characterId = await characters.SaveCharacterAsync(new Character + { + ProjectID = batch.ProjectID, + CharacterName = importName, + ShortName = FirstName(importName), + DefaultDescription = candidate.Description + }); + created++; + } + + AddResolvedName(resolvedCharacters, candidate.DisplayName, characterId.Value); + AddResolvedName(resolvedCharacters, importName, characterId.Value); + foreach (var alias in candidate.Aliases) + { + AddResolvedName(resolvedCharacters, alias, characterId.Value); + await TryAddAliasAsync(characterId.Value, alias, importName); + } + + foreach (var appearance in candidate.PresentAppearances) + { + await characters.SaveSceneCharacterAsync(new SceneCharacter + { + SceneID = appearance.SceneID, + CharacterID = characterId.Value, + RoleInSceneTypeID = MatchRole(roleTypes, appearance.RoleInScene), + PresenceTypeID = MatchPresence(presenceTypes, appearance.MentionedOnly), + AppearanceNotes = appearance.Notes + }); + linkedAppearances++; + } + } + + foreach (var scene in data.SceneAnalyses) + { + var povName = Clean(scene.Parsed.PointOfView?.CharacterName); + if (string.IsNullOrWhiteSpace(povName) || !resolvedCharacters.TryGetValue(povName, out var characterId)) + { + continue; + } + + await scenes.UpdatePovCharacterAsync(scene.SceneID, characterId); + povLinks++; + } + + logger.LogInformation( + "Imported Story Intelligence characters for batch {BatchID}. Created={Created} LinkedAppearances={LinkedAppearances} PovLinks={PovLinks}", + batch.BatchID, + created, + linkedAppearances, + povLinks); + + return new StoryIntelligenceImportCommitResult + { + Success = true, + ScenesCreated = linkedAppearances, + Message = $"Characters imported. {created:N0} character(s) created, {linkedAppearances:N0} scene appearance(s) linked, {povLinks:N0} POV link(s) updated." + }; + } + + private async Task BuildCandidateDataAsync(OnboardingStoryIntelligenceBatch batch) + { + var existingIndex = await BuildCharacterIndexAsync(batch.ProjectID); + var groups = new Dictionary(StringComparer.OrdinalIgnoreCase); + var sceneAnalyses = new List(); + 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; + } + + sceneAnalyses.Add(new ImportedSceneAnalysis(importedScene.SceneID, parsed)); + var existingAppearances = await characters.ListSceneCharactersAsync(importedScene.SceneID); + alreadyLinked += existingAppearances.Count; + + foreach (var character in parsed.Characters ?? []) + { + AddMention(groups, existingIndex, importedScene, parsed, character, existingAppearances); + } + } + } + + var candidates = groups.Values + .Where(candidate => candidate.PresentAppearances.Count > 0 || candidate.MentionedOnlyAppearances.Count > 0) + .Where(candidate => candidate.PresentAppearances.Any(appearance => !appearance.AlreadyLinked)) + .OrderByDescending(candidate => candidate.PresentAppearances.Select(appearance => appearance.SceneID).Distinct().Count()) + .ThenBy(candidate => candidate.DisplayName) + .ToList(); + + return new CharacterCandidateData(hasCommittedScenes, alreadyLinked, candidates, sceneAnalyses); + } + + private static void AddMention( + Dictionary groups, + CharacterIndex existingIndex, + Scene importedScene, + SceneIntelligenceScene parsed, + SceneIntelligenceCharacter character, + IReadOnlyList existingAppearances) + { + var name = Clean(character.Name); + if (string.IsNullOrWhiteSpace(name) || IgnoredNames.Contains(name) || IsGenericPerson(name)) + { + return; + } + + var key = Normalise(name); + if (!groups.TryGetValue(key, out var candidate)) + { + var match = existingIndex.Find(name); + candidate = new CharacterCandidate(key, name, match?.CharacterID, match?.CharacterName); + groups[key] = candidate; + } + + foreach (var alias in character.Aliases ?? []) + { + var cleanAlias = Clean(alias); + if (!string.IsNullOrWhiteSpace(cleanAlias) && !string.Equals(cleanAlias, candidate.DisplayName, StringComparison.OrdinalIgnoreCase)) + { + candidate.Aliases.Add(cleanAlias); + } + } + + var notes = Clean(character.Notes); + if (string.IsNullOrWhiteSpace(notes) && character.Actions?.Count > 0) + { + notes = Clean(string.Join("; ", character.Actions)); + } + + var appearance = new CharacterAppearanceImport( + importedScene.SceneID, + importedScene.SceneNumber, + importedScene.SceneTitle, + character.RoleInScene, + character.MentionedOnly == true, + notes, + character.Confidence, + candidate.ExistingCharacterID.HasValue && existingAppearances.Any(appearance => appearance.CharacterID == candidate.ExistingCharacterID.Value)); + if (character.MentionedOnly == true) + { + candidate.MentionedOnlyAppearances.Add(appearance); + } + else + { + candidate.PresentAppearances.Add(appearance); + } + + candidate.Confidence = Max(candidate.Confidence, character.Confidence); + candidate.Description ??= notes; + candidate.FirstAppearanceNote ??= BuildFirstAppearance(importedScene, parsed, character); + } + + private async Task 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 TryAddAliasAsync(int characterId, string alias, string primaryName) + { + var cleanAlias = Clean(alias); + if (string.IsNullOrWhiteSpace(cleanAlias) || string.Equals(cleanAlias, primaryName, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + var existingAliases = await characters.ListAliasesAsync(characterId); + if (existingAliases.Any(item => string.Equals(Clean(item.Alias), cleanAlias, StringComparison.OrdinalIgnoreCase))) + { + return; + } + + await characters.AddAliasAsync(characterId, cleanAlias, null); + } + + private static Scene? ResolveImportedScene( + StoryIntelligenceSavedSceneResult sceneResult, + IReadOnlyDictionary byRange, + IReadOnlyDictionary 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(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(JsonOptions); + } + } + catch (JsonException) + { + } + + return null; + } + + private static int? MatchRole(IReadOnlyList roleTypes, string? role) + { + var value = Clean(role); + if (string.IsNullOrWhiteSpace(value)) + { + return null; + } + + return roleTypes.FirstOrDefault(type => + value.Contains(type.TypeName, StringComparison.OrdinalIgnoreCase) + || type.TypeName.Contains(value, StringComparison.OrdinalIgnoreCase))?.CharacterRoleInSceneTypeID; + } + + private static int? MatchPresence(IReadOnlyList presenceTypes, bool mentionedOnly) + { + var preferred = mentionedOnly ? "Mentioned" : "Present"; + return presenceTypes.FirstOrDefault(type => type.TypeName.Contains(preferred, StringComparison.OrdinalIgnoreCase))?.PresenceTypeID; + } + + private static void AddResolvedName(Dictionary map, string name, int characterId) + { + var clean = Clean(name); + if (!string.IsNullOrWhiteSpace(clean)) + { + map[clean] = characterId; + } + } + + private static string? BuildFirstAppearance(Scene scene, SceneIntelligenceScene parsed, SceneIntelligenceCharacter character) + { + var note = Clean(character.Notes); + if (!string.IsNullOrWhiteSpace(note)) + { + return $"Scene {scene.SceneNumber:g}: {note}"; + } + + var summary = Clean(parsed.Summary?.Short); + return string.IsNullOrWhiteSpace(summary) ? $"Scene {scene.SceneNumber:g}: {scene.SceneTitle}" : $"Scene {scene.SceneNumber:g}: {summary}"; + } + + private static decimal? Max(decimal? current, decimal? next) + => current.HasValue && next.HasValue ? Math.Max(current.Value, next.Value) : current ?? next; + + private static string ConfidenceBand(decimal? confidence) + => confidence switch + { + >= 0.75m => "High", + >= 0.45m => "Medium", + null => "Unknown", + _ => "Low" + }; + + private static string? FirstName(string name) + { + var parts = Clean(name).Split(' ', StringSplitOptions.RemoveEmptyEntries); + return parts.Length > 1 ? parts[0] : null; + } + + private static bool IsGenericPerson(string name) + => GenericPeople.Contains(name) || GenericPeople.Contains(Normalise(name)); + + private static string Normalise(string? value) + => Clean(value).ToLowerInvariant(); + + private static string Clean(string? value) + => string.Join(' ', (value ?? string.Empty).Trim().Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)); + + private static HashSet CreateSet(params string[] values) + => values.Select(Normalise).ToHashSet(StringComparer.OrdinalIgnoreCase); + + private sealed class CharacterIndex + { + private readonly Dictionary byName = new(StringComparer.OrdinalIgnoreCase); + + public void Add(string? name, int characterId, string characterName) + { + var key = Normalise(name); + if (!string.IsNullOrWhiteSpace(key)) + { + byName.TryAdd(key, new CharacterMatch(characterId, characterName)); + } + } + + public CharacterMatch? Find(string name) + => byName.TryGetValue(Normalise(name), out var match) ? match : null; + } + + private sealed record CharacterMatch(int CharacterID, string CharacterName); + + private sealed class CharacterCandidate(string key, string displayName, int? existingCharacterId, string? existingCharacterName) + { + public string Key { get; } = key; + public string DisplayName { get; } = displayName; + public int? ExistingCharacterID { get; } = existingCharacterId; + public string? ExistingCharacterName { get; } = existingCharacterName; + public HashSet Aliases { get; } = new(StringComparer.OrdinalIgnoreCase); + public List PresentAppearances { get; } = []; + public List MentionedOnlyAppearances { get; } = []; + public decimal? Confidence { get; set; } + public string? Description { get; set; } + public string? FirstAppearanceNote { get; set; } + } + + private sealed record CharacterAppearanceImport( + int SceneID, + decimal SceneNumber, + string SceneTitle, + string? RoleInScene, + bool MentionedOnly, + string? Notes, + decimal? Confidence, + bool AlreadyLinked); + + private sealed record ImportedSceneAnalysis(int SceneID, SceneIntelligenceScene Parsed); + + private sealed record CharacterCandidateData( + bool HasCommittedScenes, + int AlreadyLinkedCount, + IReadOnlyList Candidates, + IReadOnlyList SceneAnalyses); +} diff --git a/PlotLine/Services/StoryIntelligenceImportCommitService.cs b/PlotLine/Services/StoryIntelligenceImportCommitService.cs index afc1e3f..fe9b3c7 100644 --- a/PlotLine/Services/StoryIntelligenceImportCommitService.cs +++ b/PlotLine/Services/StoryIntelligenceImportCommitService.cs @@ -84,6 +84,8 @@ public sealed class StoryIntelligenceImportCommitService( TimeConfidenceID = prepared.TimeConfidenceID, RevisionStatusID = prepared.RevisionStatusID, TimelineNoteTypeID = prepared.TimelineNoteTypeID, + ChapterSummary = prepared.ChapterSummary, + ChapterPurposeID = prepared.ChapterPurposeID, Scenes = prepared.Scenes, Warnings = prepared.Confirmation.Warnings }); @@ -158,7 +160,12 @@ public sealed class StoryIntelligenceImportCommitService( } var purposeMap = lookupData.ScenePurposeTypes.ToDictionary(type => Clean(type.PurposeName), type => type.ScenePurposeTypeID, StringComparer.OrdinalIgnoreCase); + var chapterPurposeMap = lookupData.ChapterPurposeTypes.ToDictionary(type => Clean(type.PurposeName), type => type.ChapterPurposeID, StringComparer.OrdinalIgnoreCase); var metricMap = BuildMetricMap(activeMetricTypes); + var chapterResult = await storyRuns.GetChapterResultAsync(runId); + var parsedChapter = TryReadChapter(chapterResult); + var chapterSummary = Clean(parsedChapter?.ChapterSummary); + var chapterPurposeId = MatchChapterPurpose(parsedChapter, chapterPurposeMap); var importScenes = new List(); foreach (var sceneResult in sceneResults.OrderBy(scene => scene.TemporarySceneNumber)) { @@ -216,7 +223,9 @@ public sealed class StoryIntelligenceImportCommitService( timeModeId, timeConfidenceId, revisionStatusId, - timelineNoteTypeId); + timelineNoteTypeId, + string.IsNullOrWhiteSpace(chapterSummary) ? null : chapterSummary, + chapterPurposeId); } private static StoryIntelligenceSceneImportItem BuildImportScene( @@ -671,6 +680,84 @@ public sealed class StoryIntelligenceImportCommitService( return null; } + private static ChapterStructureModel? TryReadChapter(StoryIntelligenceSavedChapterResult? result) + { + if (string.IsNullOrWhiteSpace(result?.ParsedJson)) + { + return null; + } + + try + { + return JsonSerializer.Deserialize(result.ParsedJson, JsonOptions); + } + catch (JsonException) + { + return null; + } + } + + private static int? MatchChapterPurpose(ChapterStructureModel? chapter, IReadOnlyDictionary purposeMap) + { + if (chapter is null || purposeMap.Count == 0) + { + return null; + } + + var texts = new List(); + if (!string.IsNullOrWhiteSpace(chapter.ChapterSummary)) + { + texts.Add(chapter.ChapterSummary); + } + + if (chapter.ExtensionData is not null) + { + foreach (var name in new[] { "chapterPurpose", "primaryPurpose", "purpose" }) + { + if (chapter.ExtensionData.TryGetValue(name, out var element) && element.ValueKind == JsonValueKind.String) + { + texts.Add(element.GetString() ?? string.Empty); + } + } + } + + texts.AddRange((chapter.SceneBoundaries ?? []).Select(boundary => boundary.Reason ?? string.Empty)); + var combined = Clean(string.Join(" ", texts)); + if (string.IsNullOrWhiteSpace(combined)) + { + return null; + } + + var direct = purposeMap.FirstOrDefault(pair => combined.Contains(pair.Key, StringComparison.OrdinalIgnoreCase)); + if (direct.Value > 0) + { + return direct.Value; + } + + var lowered = combined.ToLowerInvariant(); + foreach (var (key, value) in purposeMap) + { + var purpose = key.ToLowerInvariant(); + if ((purpose.Contains("investigation") && ContainsAny(lowered, "investigat", "question", "search", "clue")) + || (purpose.Contains("revelation") && ContainsAny(lowered, "reveal", "discover", "learns", "truth")) + || (purpose.Contains("relationship") && ContainsAny(lowered, "relationship", "bond", "trust", "conflict between")) + || (purpose.Contains("character") && ContainsAny(lowered, "character", "grief", "fear", "choice", "realises", "realizes")) + || (purpose.Contains("escalation") && ContainsAny(lowered, "escalat", "danger", "worsens", "pressure")) + || (purpose.Contains("turning") && ContainsAny(lowered, "turning point", "changes direction", "decision")) + || (purpose.Contains("transition") && ContainsAny(lowered, "transition", "moves from", "sets up")) + || (purpose.Contains("resolution") && ContainsAny(lowered, "resolve", "settles", "concludes")) + || (purpose.Contains("world") && ContainsAny(lowered, "world", "setting", "institution", "rules"))) + { + return value; + } + } + + return null; + } + + private static bool ContainsAny(string value, params string[] needles) + => needles.Any(needle => value.Contains(needle, StringComparison.OrdinalIgnoreCase)); + private static string? ExtractSummaryFromJsonText(string? value) { if (string.IsNullOrWhiteSpace(value)) @@ -781,5 +868,7 @@ public sealed class StoryIntelligenceImportCommitService( int TimeModeID, int TimeConfidenceID, int RevisionStatusID, - int? TimelineNoteTypeID); + int? TimelineNoteTypeID, + string? ChapterSummary, + int? ChapterPurposeID); } diff --git a/PlotLine/Sql/126_Phase20AH_ChapterPurposeAndStoryIntelligenceCharacters.sql b/PlotLine/Sql/126_Phase20AH_ChapterPurposeAndStoryIntelligenceCharacters.sql new file mode 100644 index 0000000..554b12f --- /dev/null +++ b/PlotLine/Sql/126_Phase20AH_ChapterPurposeAndStoryIntelligenceCharacters.sql @@ -0,0 +1,140 @@ +SET ANSI_NULLS ON; +GO +SET QUOTED_IDENTIFIER ON; +GO + +IF OBJECT_ID(N'dbo.ChapterPurposeTypes', N'U') IS NULL +BEGIN + CREATE TABLE dbo.ChapterPurposeTypes + ( + ChapterPurposeID int IDENTITY(1,1) NOT NULL CONSTRAINT PK_ChapterPurposeTypes PRIMARY KEY, + PurposeName nvarchar(100) NOT NULL, + SortOrder int NOT NULL CONSTRAINT DF_ChapterPurposeTypes_SortOrder DEFAULT 0, + IsArchived bit NOT NULL CONSTRAINT DF_ChapterPurposeTypes_IsArchived DEFAULT 0, + CONSTRAINT UQ_ChapterPurposeTypes_PurposeName UNIQUE (PurposeName) + ); +END; +GO + +MERGE dbo.ChapterPurposeTypes AS target +USING (VALUES + (N'Opening', 10), + (N'Character Development', 20), + (N'Relationship Development', 30), + (N'World Building', 40), + (N'Investigation', 50), + (N'Revelation', 60), + (N'Escalation', 70), + (N'Turning Point', 80), + (N'Climax', 90), + (N'Resolution', 100), + (N'Transition', 110) +) AS source (PurposeName, SortOrder) +ON target.PurposeName = source.PurposeName +WHEN MATCHED THEN UPDATE SET SortOrder = source.SortOrder, IsArchived = 0 +WHEN NOT MATCHED THEN INSERT (PurposeName, SortOrder, IsArchived) VALUES (source.PurposeName, source.SortOrder, 0); +GO + +IF COL_LENGTH(N'dbo.Chapters', N'ChapterPurposeID') IS NULL + ALTER TABLE dbo.Chapters ADD ChapterPurposeID int NULL; +GO + +IF NOT EXISTS (SELECT 1 FROM sys.foreign_keys WHERE name = N'FK_Chapters_ChapterPurposeTypes') + ALTER TABLE dbo.Chapters ADD CONSTRAINT FK_Chapters_ChapterPurposeTypes FOREIGN KEY (ChapterPurposeID) REFERENCES dbo.ChapterPurposeTypes(ChapterPurposeID); +GO + +CREATE OR ALTER PROCEDURE dbo.Lookup_All +AS +BEGIN + SET NOCOUNT ON; + SELECT RevisionStatusID, StatusName, SortOrder FROM dbo.RevisionStatuses ORDER BY SortOrder; + SELECT TimeModeID, TimeModeName, SortOrder FROM dbo.TimeModes ORDER BY SortOrder; + SELECT DurationUnitID, DurationUnitName, SortOrder FROM dbo.DurationUnits ORDER BY SortOrder; + SELECT TimeConfidenceID, TimeConfidenceName, SortOrder FROM dbo.TimeConfidences ORDER BY SortOrder; + SELECT ScenePurposeTypeID, PurposeName, SortOrder FROM dbo.ScenePurposeTypes WHERE IsArchived = 0 ORDER BY SortOrder; + SELECT ChapterPurposeID, PurposeName, SortOrder, IsArchived FROM dbo.ChapterPurposeTypes WHERE IsArchived = 0 ORDER BY SortOrder; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.Chapter_ListByBook + @BookID int +AS +BEGIN + SET NOCOUNT ON; + SELECT c.ChapterID, c.BookID, c.ChapterNumber, c.ChapterTitle, c.POVCharacterID, c.Summary, + c.ChapterPurposeID, cpt.PurposeName AS ChapterPurposeName, + c.SortOrder, c.RevisionStatusID, rs.StatusName AS RevisionStatusName, + c.CreatedDate, c.UpdatedDate, c.IsArchived, c.IsLinkedToManuscript, + c.ManuscriptDocumentID, c.ExternalReference, c.LinkedUtc + FROM dbo.Chapters c + INNER JOIN dbo.RevisionStatuses rs ON rs.RevisionStatusID = c.RevisionStatusID + LEFT JOIN dbo.ChapterPurposeTypes cpt ON cpt.ChapterPurposeID = c.ChapterPurposeID + WHERE c.BookID = @BookID AND c.IsArchived = 0 + ORDER BY c.SortOrder, c.ChapterNumber; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.Chapter_Get + @ChapterID int +AS +BEGIN + SET NOCOUNT ON; + SELECT c.ChapterID, c.BookID, c.ChapterNumber, c.ChapterTitle, c.POVCharacterID, c.Summary, + c.ChapterPurposeID, cpt.PurposeName AS ChapterPurposeName, + c.SortOrder, c.RevisionStatusID, rs.StatusName AS RevisionStatusName, + c.CreatedDate, c.UpdatedDate, c.IsArchived, c.IsLinkedToManuscript, + c.ManuscriptDocumentID, c.ExternalReference, c.LinkedUtc + FROM dbo.Chapters c + INNER JOIN dbo.RevisionStatuses rs ON rs.RevisionStatusID = c.RevisionStatusID + LEFT JOIN dbo.ChapterPurposeTypes cpt ON cpt.ChapterPurposeID = c.ChapterPurposeID + WHERE c.ChapterID = @ChapterID AND c.IsArchived = 0; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.Chapter_Save + @ChapterID int = NULL, + @BookID int, + @ChapterNumber decimal(10,2), + @ChapterTitle nvarchar(200), + @Summary nvarchar(max) = NULL, + @ChapterPurposeID int = NULL, + @RevisionStatusID int +AS +BEGIN + SET NOCOUNT ON; + IF @ChapterID IS NULL OR @ChapterID = 0 + BEGIN + DECLARE @NextChapterOrder int = ISNULL((SELECT MAX(SortOrder) FROM dbo.Chapters WHERE BookID = @BookID), 0) + 10; + INSERT dbo.Chapters (BookID, ChapterNumber, ChapterTitle, Summary, ChapterPurposeID, SortOrder, RevisionStatusID) + VALUES (@BookID, @ChapterNumber, @ChapterTitle, @Summary, @ChapterPurposeID, @NextChapterOrder, @RevisionStatusID); + SELECT CAST(SCOPE_IDENTITY() AS int) AS ChapterID; + RETURN; + END; + + UPDATE dbo.Chapters + SET ChapterNumber = @ChapterNumber, + ChapterTitle = @ChapterTitle, + Summary = @Summary, + ChapterPurposeID = @ChapterPurposeID, + RevisionStatusID = @RevisionStatusID, + UpdatedDate = SYSUTCDATETIME() + WHERE ChapterID = @ChapterID; + SELECT @ChapterID AS ChapterID; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceImportCommit_UpdateChapterPlanning + @ChapterID int, + @Summary nvarchar(max) = NULL, + @ChapterPurposeID int = NULL +AS +BEGIN + SET NOCOUNT ON; + + UPDATE dbo.Chapters + SET Summary = COALESCE(NULLIF(LTRIM(RTRIM(@Summary)), N''), Summary), + ChapterPurposeID = COALESCE(@ChapterPurposeID, ChapterPurposeID), + UpdatedDate = SYSUTCDATETIME() + WHERE ChapterID = @ChapterID; +END; +GO diff --git a/PlotLine/ViewModels/CoreViewModels.cs b/PlotLine/ViewModels/CoreViewModels.cs index 7c84c29..191ccf2 100644 --- a/PlotLine/ViewModels/CoreViewModels.cs +++ b/PlotLine/ViewModels/CoreViewModels.cs @@ -260,6 +260,9 @@ public sealed class ChapterEditViewModel public string? Summary { get; set; } + [Display(Name = "Chapter purpose")] + public int? ChapterPurposeID { get; set; } + [Display(Name = "Revision status")] public int RevisionStatusID { get; set; } @@ -268,6 +271,7 @@ public sealed class ChapterEditViewModel public Book? Book { get; set; } public Project? Project { get; set; } public IReadOnlyList RevisionStatuses { get; set; } = []; + public IReadOnlyList ChapterPurposes { get; set; } = []; } public sealed class ChapterDetailViewModel diff --git a/PlotLine/ViewModels/OnboardingViewModels.cs b/PlotLine/ViewModels/OnboardingViewModels.cs index 63ed4d4..0f8725c 100644 --- a/PlotLine/ViewModels/OnboardingViewModels.cs +++ b/PlotLine/ViewModels/OnboardingViewModels.cs @@ -137,6 +137,7 @@ public sealed class StoryIntelligenceProgressViewModel public string? ProjectName { get; init; } public string? BookTitle { get; init; } public IReadOnlyList Chapters { get; init; } = []; + public StoryIntelligenceCharacterReviewViewModel CharacterReview { get; init; } = new(); public int ChapterCount => Chapters.Count; public int CompletedChapterCount => Chapters.Count(chapter => chapter.IsRunComplete); public int CommittedChapterCount => Chapters.Count(chapter => chapter.HasCompletedCommit); @@ -150,6 +151,43 @@ public sealed class StoryIntelligenceProgressViewModel public bool HasActiveRuns => Chapters.Any(chapter => chapter.IsActive); public bool AllCommitted => Chapters.Count > 0 && Chapters.All(chapter => chapter.HasCompletedCommit); public bool HasReadyScenesToCreate => Chapters.Any(chapter => chapter.CanCommit); + public bool HasCharactersToReview => CharacterReview.Candidates.Count > 0; +} + +public sealed class StoryIntelligenceCharacterReviewViewModel +{ + public bool CanImport { get; init; } + public bool HasCommittedScenes { get; init; } + public int AlreadyLinkedCount { get; init; } + public IReadOnlyList Candidates { get; init; } = []; +} + +public sealed class StoryIntelligenceCharacterReviewCandidateViewModel +{ + public string Key { get; init; } = string.Empty; + public string CharacterName { get; init; } = string.Empty; + public string ImportName { get; init; } = string.Empty; + public int AppearsInScenes { get; init; } + public string Confidence { get; init; } = "Unknown"; + public IReadOnlyList PossibleAliases { get; init; } = []; + public string? ExampleFirstAppearance { get; init; } + public int? ExistingCharacterID { get; init; } + public string? ExistingCharacterName { get; init; } + public bool IsExistingMatch => ExistingCharacterID.HasValue; + public string ActionLabel => IsExistingMatch ? "Link existing" : "Create"; +} + +public sealed class StoryIntelligenceCharacterImportForm +{ + public Guid BatchID { get; set; } + public List Characters { get; set; } = []; +} + +public sealed class StoryIntelligenceCharacterImportChoiceForm +{ + public string Key { get; set; } = string.Empty; + public bool Include { get; set; } + public string? ImportName { get; set; } } public sealed class StoryIntelligenceCompletionViewModel diff --git a/PlotLine/Views/Books/Details.cshtml b/PlotLine/Views/Books/Details.cshtml index 8799fc8..239c20b 100644 --- a/PlotLine/Views/Books/Details.cshtml +++ b/PlotLine/Views/Books/Details.cshtml @@ -147,7 +147,13 @@ @chapter.ChapterNumber @chapter.ChapterTitle - @chapter.RevisionStatusName + + @chapter.RevisionStatusName + @if (!string.IsNullOrWhiteSpace(chapter.ChapterPurposeName)) + { + @chapter.ChapterPurposeName + } + @(chapter.IsLinkedToManuscript ? "🔗 Linked" : "📝 Planned") @chapter.Summary diff --git a/PlotLine/Views/Chapters/Details.cshtml b/PlotLine/Views/Chapters/Details.cshtml index 2170650..d05b197 100644 --- a/PlotLine/Views/Chapters/Details.cshtml +++ b/PlotLine/Views/Chapters/Details.cshtml @@ -26,6 +26,10 @@

Chapter @Model.Chapter.ChapterNumber: @Model.Chapter.ChapterTitle

@(Model.Chapter.IsLinkedToManuscript ? "🔗 Linked" : "📝 Planned") + @if (!string.IsNullOrWhiteSpace(Model.Chapter.ChapterPurposeName)) + { + @Model.Chapter.ChapterPurposeName + } @if (!string.IsNullOrWhiteSpace(Model.Chapter.Summary)) {

@Model.Chapter.Summary

diff --git a/PlotLine/Views/Chapters/Edit.cshtml b/PlotLine/Views/Chapters/Edit.cshtml index 8c9b5af..cae2079 100644 --- a/PlotLine/Views/Chapters/Edit.cshtml +++ b/PlotLine/Views/Chapters/Edit.cshtml @@ -20,7 +20,7 @@ -
+
@@ -29,6 +29,10 @@
+
+ + +
diff --git a/PlotLine/Views/Onboarding/StoryIntelligenceReview.cshtml b/PlotLine/Views/Onboarding/StoryIntelligenceReview.cshtml index ccbc257..e60fddc 100644 --- a/PlotLine/Views/Onboarding/StoryIntelligenceReview.cshtml +++ b/PlotLine/Views/Onboarding/StoryIntelligenceReview.cshtml @@ -17,7 +17,7 @@

Step 9 of 9

Review Story Intelligence Results

-

PlotDirector has finished reading your manuscript. Nothing has been added to your project yet. Please review the detected scenes before creating them.

+

PlotDirector has finished reading your manuscript. Review the detected scenes first, then approve any characters you want to add or link.

@if (TempData["OnboardingStoryIntelligenceError"] is string error) @@ -30,7 +30,7 @@ }
- Only scenes will be created. Characters, locations, assets, relationships and knowledge will be imported during later Story Intelligence phases. + Scene creation runs first. Character import is reviewed separately and only creates or links approved characters. Locations, assets, relationships and knowledge remain for later Story Intelligence phases.
@@ -178,6 +178,56 @@ Scene creation uses PlotDirector import safeguards. Duplicate imports are blocked, and failures roll back safely.
+ @if (Model.CharacterReview.HasCommittedScenes) + { +
+

Review Characters

+ @if (Model.CharacterReview.Candidates.Count == 0) + { +

No new character suggestions are waiting. Existing scene character links are already up to date where PlotDirector could match them.

+ } + else + { +

Approve the characters PlotDirector should create or link from the imported scenes. You can rename new characters before import.

+
+ +
+ @for (var i = 0; i < Model.CharacterReview.Candidates.Count; i++) + { + var candidate = Model.CharacterReview.Candidates[i]; +
+ +
+ + @candidate.ActionLabel +
+
+
Appears in
@candidate.AppearsInScenes.ToString("N0") scene@(candidate.AppearsInScenes == 1 ? string.Empty : "s")
+
Confidence
@candidate.Confidence
+
Match
@(candidate.ExistingCharacterName ?? "New character")
+
+ @if (candidate.PossibleAliases.Count > 0) + { +

Aliases: @string.Join(", ", candidate.PossibleAliases)

+ } + @if (!string.IsNullOrWhiteSpace(candidate.ExampleFirstAppearance)) + { +

@candidate.ExampleFirstAppearance

+ } + + +
+ } +
+ +
+ } +
+ } +
@if (Model.BatchID.HasValue) {