diff --git a/PlotLine/Controllers/AdminController.cs b/PlotLine/Controllers/AdminController.cs index bee584e..f81de0d 100644 --- a/PlotLine/Controllers/AdminController.cs +++ b/PlotLine/Controllers/AdminController.cs @@ -18,7 +18,8 @@ public sealed class AdminController( IChapterStoryIntelligenceDryRunService chapterStoryIntelligenceDryRun, IStoryIntelligenceResultPersistenceService storyIntelligenceResults, IStoryIntelligenceExistingChapterQueueService existingChapterQueue, - IStoryIntelligenceFullChapterTestService fullChapterTest) : Controller + IStoryIntelligenceFullChapterTestService fullChapterTest, + IStoryIntelligenceImportCommitService storyIntelligenceImportCommit) : Controller { [HttpGet("")] [HttpGet("index")] @@ -200,6 +201,35 @@ public sealed class AdminController( return RedirectToAction(nameof(StoryIntelligenceRunDetails), new { id }); } + [HttpGet("story-intelligence-runs/{id:int}/commit")] + public async Task ConfirmStoryIntelligenceImportCommit(int id) + { + var model = await storyIntelligenceImportCommit.BuildConfirmationAsync(id); + return model is null ? NotFound() : View(model); + } + + [HttpPost("story-intelligence-runs/{id:int}/commit")] + [ValidateAntiForgeryToken] + public async Task CommitStoryIntelligenceImport(int id) + { + if (currentUser.UserId is not int userId) + { + return Forbid(); + } + + try + { + var result = await storyIntelligenceImportCommit.CommitAsync(id, userId); + TempData[result.Success ? "AdminMessage" : "AdminError"] = result.Message; + return RedirectToAction(result.Success ? nameof(StoryIntelligenceRunDetails) : nameof(ConfirmStoryIntelligenceImportCommit), new { id }); + } + catch (InvalidOperationException ex) + { + TempData["AdminError"] = ex.Message; + return RedirectToAction(nameof(ConfirmStoryIntelligenceImportCommit), new { id }); + } + } + [HttpGet("feature-requests")] public async Task FeatureRequests(string? status, string? appArea, string? importance, string? search, string? sort) { diff --git a/PlotLine/Data/StoryIntelligenceResultRepository.cs b/PlotLine/Data/StoryIntelligenceResultRepository.cs index c96a1a6..3224fa3 100644 --- a/PlotLine/Data/StoryIntelligenceResultRepository.cs +++ b/PlotLine/Data/StoryIntelligenceResultRepository.cs @@ -33,6 +33,8 @@ public interface IStoryIntelligenceResultRepository Task GetRunAsync(int runId); Task GetChapterResultAsync(int runId); Task> ListSceneResultsAsync(int runId); + Task GetImportCommitAsync(int runId); + Task CommitImportAsync(StoryIntelligenceImportCommitRequest request); } public sealed class StoryIntelligenceResultRepository(ISqlConnectionFactory connectionFactory) : IStoryIntelligenceResultRepository @@ -374,4 +376,197 @@ public sealed class StoryIntelligenceResultRepository(ISqlConnectionFactory conn commandType: CommandType.StoredProcedure); return rows.ToList(); } + + public async Task GetImportCommitAsync(int runId) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleOrDefaultAsync( + "dbo.StoryIntelligenceImportCommit_GetByRun", + new { StoryIntelligenceRunID = runId }, + commandType: CommandType.StoredProcedure); + } + + public async Task CommitImportAsync(StoryIntelligenceImportCommitRequest request) + { + using var connection = connectionFactory.CreateConnection(); + connection.Open(); + using var transaction = connection.BeginTransaction(); + + var scenesCreated = 0; + var metricsCreated = 0; + try + { + var alreadyCommitted = await connection.QuerySingleAsync( + "dbo.StoryIntelligenceImportCommit_HasCompleted", + new { request.StoryIntelligenceRunID }, + transaction, + commandType: CommandType.StoredProcedure); + if (alreadyCommitted) + { + transaction.Rollback(); + return new StoryIntelligenceImportCommitResult + { + Success = false, + Message = "This Story Intelligence run has already been committed." + }; + } + + var existingSceneCount = await connection.QuerySingleAsync( + "dbo.StoryIntelligenceImportCommit_CountChapterScenes", + new { request.ChapterID }, + transaction, + commandType: CommandType.StoredProcedure); + if (existingSceneCount > 0) + { + transaction.Rollback(); + return new StoryIntelligenceImportCommitResult + { + Success = false, + Message = "This chapter already has scenes. Remove them first or wait for a future merge mode." + }; + } + + foreach (var item in request.Scenes.OrderBy(scene => scene.TemporarySceneNumber)) + { + var sceneId = await connection.QuerySingleAsync( + "dbo.Scene_Save", + new + { + SceneID = (int?)null, + ChapterID = request.ChapterID, + SceneNumber = Convert.ToDecimal(item.TemporarySceneNumber), + SceneTitle = item.SceneTitle, + Summary = item.Summary, + TimeModeID = request.TimeModeID, + StartDateTime = (DateTime?)null, + EndDateTime = (DateTime?)null, + DurationAmount = (decimal?)null, + DurationUnitID = (int?)null, + RelativeTimeText = item.ParsedScene.Setting?.DateOrTimeReference ?? item.ParsedScene.Setting?.TimeOfDay, + TimeConfidenceID = request.TimeConfidenceID, + ScenePurposeNotes = item.PurposeNotes, + SceneOutcomeNotes = item.OutcomeNotes, + RevisionStatusID = request.RevisionStatusID, + PrimaryLocationID = (int?)null, + FloorPlanID = (int?)null, + InitialFloorPlanFloorID = (int?)null + }, + transaction, + commandType: CommandType.StoredProcedure); + + await connection.ExecuteAsync( + "dbo.StoryIntelligenceSceneSource_Save", + new + { + SceneID = sceneId, + ImportSource = "Story Intelligence", + ImportRunID = request.StoryIntelligenceRunID, + SourceStartParagraph = item.StartParagraph, + SourceEndParagraph = item.EndParagraph + }, + transaction, + commandType: CommandType.StoredProcedure); + + if (item.PovCharacterID.HasValue) + { + await connection.ExecuteAsync( + "dbo.Scene_UpdatePovCharacter", + new { SceneID = sceneId, POVCharacterID = item.PovCharacterID }, + transaction, + commandType: CommandType.StoredProcedure); + } + + if (item.PurposeTypeIDs.Count > 0) + { + await connection.ExecuteAsync( + "dbo.Scene_SavePurposes", + new { SceneID = sceneId, PurposeIds = string.Join(',', item.PurposeTypeIDs.Distinct()) }, + transaction, + commandType: CommandType.StoredProcedure); + } + + foreach (var metric in item.Metrics) + { + await connection.ExecuteAsync( + "dbo.SceneMetric_SaveValue", + new { SceneID = sceneId, metric.MetricTypeID, metric.Value, metric.Notes }, + transaction, + commandType: CommandType.StoredProcedure); + metricsCreated++; + } + + if (request.TimelineNoteTypeID.HasValue && !string.IsNullOrWhiteSpace(item.ImportNoteText)) + { + await connection.ExecuteAsync( + "dbo.SceneNote_Save", + new + { + SceneNoteID = (int?)null, + SceneID = sceneId, + SceneNoteTypeID = request.TimelineNoteTypeID.Value, + NoteTitle = "Story Intelligence import notes", + NoteText = item.ImportNoteText, + SortOrder = 10, + IsPinned = false, + IsResolved = false + }, + transaction, + commandType: CommandType.StoredProcedure); + } + + scenesCreated++; + } + + var commitId = await connection.QuerySingleAsync( + "dbo.StoryIntelligenceImportCommit_RecordCompleted", + new + { + request.StoryIntelligenceRunID, + request.ProjectID, + request.BookID, + request.ChapterID, + CommittedByUserID = request.UserID, + ScenesCreated = scenesCreated, + MetricsCreated = metricsCreated, + Notes = string.Join(Environment.NewLine, request.Warnings) + }, + transaction, + commandType: CommandType.StoredProcedure); + + transaction.Commit(); + return new StoryIntelligenceImportCommitResult + { + Success = true, + CommitID = commitId, + ScenesCreated = scenesCreated, + MetricsCreated = metricsCreated, + Message = $"Created {scenesCreated:N0} scene(s)." + }; + } + catch (Exception ex) + { + transaction.Rollback(); + await RecordFailedCommitAsync(request, scenesCreated, metricsCreated, ex); + throw; + } + } + + private async Task RecordFailedCommitAsync(StoryIntelligenceImportCommitRequest request, int scenesCreated, int metricsCreated, Exception ex) + { + using var connection = connectionFactory.CreateConnection(); + await connection.ExecuteAsync( + "dbo.StoryIntelligenceImportCommit_RecordFailed", + new + { + request.StoryIntelligenceRunID, + request.ProjectID, + request.BookID, + request.ChapterID, + CommittedByUserID = request.UserID, + ScenesCreated = scenesCreated, + MetricsCreated = metricsCreated, + Notes = ex.Message + }, + commandType: CommandType.StoredProcedure); + } } diff --git a/PlotLine/Models/CoreModels.cs b/PlotLine/Models/CoreModels.cs index bf22e85..cf64513 100644 --- a/PlotLine/Models/CoreModels.cs +++ b/PlotLine/Models/CoreModels.cs @@ -405,6 +405,10 @@ public sealed class Scene public int? ManuscriptDocumentID { get; set; } public string? ExternalReference { get; set; } public DateTime? LinkedUtc { get; set; } + public string? ImportSource { get; set; } + public int? ImportRunID { get; set; } + public int? SourceStartParagraph { get; set; } + public int? SourceEndParagraph { get; set; } public List SelectedPurposeTypeIds { get; set; } = []; public List PurposeLabels { get; set; } = []; public List Metrics { get; set; } = []; diff --git a/PlotLine/Models/StoryIntelligencePersistenceModels.cs b/PlotLine/Models/StoryIntelligencePersistenceModels.cs index a8a0858..708d2f9 100644 --- a/PlotLine/Models/StoryIntelligencePersistenceModels.cs +++ b/PlotLine/Models/StoryIntelligencePersistenceModels.cs @@ -356,3 +356,68 @@ public sealed class StoryIntelligenceSavedSceneResult public long? DurationMs { get; init; } public DateTime CreatedUtc { get; init; } } + +public static class StoryIntelligenceImportCommitStatuses +{ + public const string Pending = "Pending"; + public const string Completed = "Completed"; + public const string Failed = "Failed"; +} + +public sealed class StoryIntelligenceImportCommit +{ + public int StoryIntelligenceImportCommitID { get; init; } + public int StoryIntelligenceRunID { get; init; } + public int? ProjectID { get; init; } + public int? BookID { get; init; } + public int? ChapterID { get; init; } + public DateTime CommittedAt { get; init; } + public int CommittedByUserID { get; init; } + public int ScenesCreated { get; init; } + public int MetricsCreated { get; init; } + public string Status { get; init; } = string.Empty; + public string? Notes { get; init; } + public DateTime CreatedUtc { get; init; } +} + +public sealed class StoryIntelligenceSceneImportItem +{ + public int SceneResultID { get; init; } + public int TemporarySceneNumber { get; init; } + public int? StartParagraph { get; init; } + public int? EndParagraph { get; init; } + public string SourceLabel { get; init; } = string.Empty; + public SceneIntelligenceScene ParsedScene { get; init; } = new(); + public int? PovCharacterID { get; init; } + public IReadOnlyList PurposeTypeIDs { get; init; } = []; + public IReadOnlyList Metrics { get; init; } = []; + public string SceneTitle { get; init; } = string.Empty; + public string? Summary { get; init; } + public string? PurposeNotes { get; init; } + public string? OutcomeNotes { get; init; } + public string? ImportNoteText { get; init; } +} + +public sealed class StoryIntelligenceImportCommitRequest +{ + public int StoryIntelligenceRunID { get; init; } + public int UserID { get; init; } + public int ProjectID { get; init; } + public int BookID { get; init; } + public int ChapterID { get; init; } + public int TimeModeID { get; init; } + public int TimeConfidenceID { get; init; } + public int RevisionStatusID { get; init; } + public int? TimelineNoteTypeID { get; init; } + public IReadOnlyList Scenes { get; init; } = []; + public IReadOnlyList Warnings { get; init; } = []; +} + +public sealed class StoryIntelligenceImportCommitResult +{ + public bool Success { get; init; } + public int? CommitID { get; init; } + public int ScenesCreated { get; init; } + public int MetricsCreated { get; init; } + public string Message { get; init; } = string.Empty; +} diff --git a/PlotLine/Program.cs b/PlotLine/Program.cs index 531a132..4b632da 100644 --- a/PlotLine/Program.cs +++ b/PlotLine/Program.cs @@ -203,6 +203,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/StoryIntelligenceImportCommitService.cs b/PlotLine/Services/StoryIntelligenceImportCommitService.cs new file mode 100644 index 0000000..ff80067 --- /dev/null +++ b/PlotLine/Services/StoryIntelligenceImportCommitService.cs @@ -0,0 +1,446 @@ +using System.Text.Json; +using PlotLine.Data; +using PlotLine.Models; +using PlotLine.ViewModels; + +namespace PlotLine.Services; + +public interface IStoryIntelligenceImportCommitService +{ + Task BuildConfirmationAsync(int runId); + Task CommitAsync(int runId, int userId); +} + +public sealed class StoryIntelligenceImportCommitService( + IStoryIntelligenceResultRepository storyRuns, + IProjectRepository projects, + IBookRepository books, + IChapterRepository chapters, + ISceneRepository scenes, + ILookupRepository lookups, + ISceneMetricTypeRepository metricTypes, + IWriterWorkspaceRepository writerWorkspace, + ICharacterRepository characters, + ILogger logger) : IStoryIntelligenceImportCommitService +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true + }; + + private static readonly IReadOnlyDictionary MetricAliases = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["action"] = ["Action"], + ["emotion"] = ["Emotion", "Emotional Weight", "Emotional Resonance"], + ["tension"] = ["Tension"], + ["conflict"] = ["Conflict", "Relationship Conflict"], + ["mystery"] = ["Mystery"], + ["romance"] = ["Romance", "Emotional Intimacy"], + ["humour"] = ["Humour", "Humor", "Comedy", "Comic Relief"], + ["revelation"] = ["Revelation", "Reveal"], + ["pacingIntensity"] = ["PacingIntensity", "Pacing Intensity", "Overall Intensity"] + }; + + public async Task BuildConfirmationAsync(int runId) + { + var prepared = await PrepareAsync(runId); + if (prepared is null) + { + return null; + } + + return prepared.Confirmation; + } + + public async Task CommitAsync(int runId, int userId) + { + var prepared = await PrepareAsync(runId); + if (prepared is null) + { + return new StoryIntelligenceImportCommitResult { Success = false, Message = "This Story Intelligence run could not be found." }; + } + + if (!prepared.Confirmation.CanCommit) + { + return new StoryIntelligenceImportCommitResult + { + Success = false, + Message = prepared.Confirmation.HasCompletedCommit + ? "This Story Intelligence run has already been committed." + : string.Join(" ", prepared.Confirmation.Blockers) + }; + } + + var result = await storyRuns.CommitImportAsync(new StoryIntelligenceImportCommitRequest + { + StoryIntelligenceRunID = prepared.Run.StoryIntelligenceRunID, + UserID = userId, + ProjectID = prepared.Run.ProjectID!.Value, + BookID = prepared.Run.BookID!.Value, + ChapterID = prepared.Run.ChapterID!.Value, + TimeModeID = prepared.TimeModeID, + TimeConfidenceID = prepared.TimeConfidenceID, + RevisionStatusID = prepared.RevisionStatusID, + TimelineNoteTypeID = prepared.TimelineNoteTypeID, + Scenes = prepared.Scenes, + Warnings = prepared.Confirmation.Warnings + }); + + logger.LogInformation( + "Committed Story Intelligence run {StoryIntelligenceRunID}. CommitID={CommitID} ScenesCreated={ScenesCreated} MetricsCreated={MetricsCreated}", + runId, + result.CommitID, + result.ScenesCreated, + result.MetricsCreated); + + return result; + } + + private async Task PrepareAsync(int runId) + { + var run = await storyRuns.GetRunAsync(runId); + if (run is null) + { + return null; + } + + var existingCommit = await storyRuns.GetImportCommitAsync(runId); + var blockers = new List(); + var warnings = new List(); + + if (run.Status is not (StoryIntelligenceRunStatuses.Completed or StoryIntelligenceRunStatuses.CompletedWithWarnings)) + { + blockers.Add("Only completed Story Intelligence runs can be committed."); + } + + if (run.ProjectID is null || run.BookID is null || run.ChapterID is null) + { + blockers.Add("The run must have a project, book and chapter before it can create scenes."); + } + + var sceneResults = await storyRuns.ListSceneResultsAsync(runId); + if (sceneResults.Count == 0) + { + blockers.Add("No saved scene results are available to import."); + } + + var existingScenes = run.ChapterID.HasValue + ? await scenes.ListByChapterAsync(run.ChapterID.Value) + : []; + if (existingScenes.Count > 0) + { + blockers.Add("This chapter already has scenes. Existing scenes must be removed or a future merge mode implemented before importing."); + } + + if (existingCommit is not null && string.Equals(existingCommit.Status, StoryIntelligenceImportCommitStatuses.Completed, StringComparison.OrdinalIgnoreCase)) + { + blockers.Add("This Story Intelligence run has already been committed."); + } + + var lookupData = await lookups.GetAllAsync(); + var activeMetricTypes = run.ProjectID.HasValue + ? (await metricTypes.ListForManagementAsync(run.ProjectID.Value)).Where(metric => metric.IsActive).ToList() + : []; + var noteTypes = await writerWorkspace.ListNoteTypesAsync(); + var characterMap = run.ProjectID.HasValue + ? await BuildCharacterMapAsync(run.ProjectID.Value) + : new Dictionary(StringComparer.OrdinalIgnoreCase); + + var timeModeId = FindId(lookupData.TimeModes, item => item.TimeModeName, item => item.TimeModeID, "Relative", "Unknown"); + var timeConfidenceId = FindId(lookupData.TimeConfidences, item => item.TimeConfidenceName, item => item.TimeConfidenceID, "Implied", "Unknown"); + var revisionStatusId = FindId(lookupData.RevisionStatuses, item => item.StatusName, item => item.RevisionStatusID, "Planned"); + var timelineNoteTypeId = noteTypes.FirstOrDefault(type => string.Equals(type.TypeName, "Timeline Note", StringComparison.OrdinalIgnoreCase))?.SceneNoteTypeID; + if (timelineNoteTypeId is null) + { + warnings.Add("Timeline clues will not be imported because the Timeline Note type is not available."); + } + + var purposeMap = lookupData.ScenePurposeTypes.ToDictionary(type => Clean(type.PurposeName), type => type.ScenePurposeTypeID, StringComparer.OrdinalIgnoreCase); + var metricMap = BuildMetricMap(activeMetricTypes); + var importScenes = new List(); + foreach (var sceneResult in sceneResults + .Where(scene => scene.ValidationErrorsCount == 0) + .OrderBy(scene => scene.TemporarySceneNumber)) + { + var parsed = TryReadScene(sceneResult); + if (parsed is null) + { + warnings.Add($"Suggested scene {sceneResult.TemporarySceneNumber:N0} was skipped because its parsed JSON could not be read."); + continue; + } + + importScenes.Add(BuildImportScene(sceneResult, parsed, characterMap, purposeMap, metricMap)); + } + + if (importScenes.Count == 0) + { + blockers.Add("No valid scene intelligence results are available to import."); + } + + var projectName = run.ProjectID.HasValue ? (await projects.GetAsync(run.ProjectID.Value))?.ProjectName ?? $"Project {run.ProjectID.Value:N0}" : "None"; + var bookTitle = run.BookID.HasValue ? (await books.GetAsync(run.BookID.Value))?.BookDisplayTitle ?? $"Book {run.BookID.Value:N0}" : "None"; + var chapter = run.ChapterID.HasValue ? await chapters.GetAsync(run.ChapterID.Value) : null; + var chapterLabel = chapter is null + ? "None" + : string.IsNullOrWhiteSpace(chapter.ChapterTitle) + ? $"Chapter {chapter.ChapterNumber:0.##}" + : $"Chapter {chapter.ChapterNumber:0.##}: {chapter.ChapterTitle}"; + + return new PreparedImport( + run, + new StoryIntelligenceImportConfirmationViewModel + { + Run = run, + ExistingCommit = existingCommit, + ProjectName = projectName, + BookTitle = bookTitle, + ChapterLabel = chapterLabel, + ExistingSceneCount = existingScenes.Count, + ScenesToCreate = importScenes.Count, + MetricsToImport = importScenes.Sum(scene => scene.Metrics.Count), + MetricNames = importScenes.SelectMany(scene => scene.Metrics.Select(metric => metric.MetricName)).Distinct(StringComparer.OrdinalIgnoreCase).OrderBy(name => name).ToList(), + Warnings = warnings, + Blockers = blockers + }, + importScenes, + timeModeId, + timeConfidenceId, + revisionStatusId, + timelineNoteTypeId); + } + + private static StoryIntelligenceSceneImportItem BuildImportScene( + StoryIntelligenceSavedSceneResult sceneResult, + SceneIntelligenceScene parsed, + IReadOnlyDictionary characterMap, + IReadOnlyDictionary purposeMap, + IReadOnlyDictionary metricMap) + { + var povName = Clean(parsed.PointOfView?.CharacterName); + var povCharacterId = !string.IsNullOrWhiteSpace(povName) + && !string.Equals(povName, "Narrator", StringComparison.OrdinalIgnoreCase) + && characterMap.TryGetValue(povName, out var characterId) + ? characterId + : (int?)null; + var summary = Clean(parsed.Summary?.Short); + var sceneTitle = GenerateTitle(sceneResult.TemporarySceneNumber, summary); + + return new StoryIntelligenceSceneImportItem + { + SceneResultID = sceneResult.SceneResultID, + TemporarySceneNumber = sceneResult.TemporarySceneNumber, + StartParagraph = sceneResult.StartParagraph, + EndParagraph = sceneResult.EndParagraph, + SourceLabel = sceneResult.SourceLabel, + ParsedScene = parsed, + PovCharacterID = povCharacterId, + PurposeTypeIDs = MatchPurposes(parsed.ScenePurpose?.ObservedFunction, purposeMap), + Metrics = BuildMetrics(parsed, metricMap), + SceneTitle = sceneTitle, + Summary = string.IsNullOrWhiteSpace(summary) ? null : summary, + PurposeNotes = BuildPurposeNotes(parsed, sceneResult, povName, povCharacterId), + OutcomeNotes = string.IsNullOrWhiteSpace(parsed.Summary?.Detailed) ? null : $"Detailed summary: {parsed.Summary.Detailed}", + ImportNoteText = BuildImportNote(parsed, povName, povCharacterId) + }; + } + + private async Task> BuildCharacterMapAsync(int projectId) + { + var map = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var character in await characters.ListCharactersAsync(projectId)) + { + AddCharacterMap(map, character.CharacterName, character.CharacterID); + AddCharacterMap(map, character.ShortName, character.CharacterID); + foreach (var alias in await characters.ListAliasesAsync(character.CharacterID)) + { + AddCharacterMap(map, alias.Alias, character.CharacterID); + } + } + + return map; + } + + private static void AddCharacterMap(Dictionary map, string? name, int characterId) + { + var clean = Clean(name); + if (!string.IsNullOrWhiteSpace(clean)) + { + map.TryAdd(clean, characterId); + } + } + + private static Dictionary BuildMetricMap(IReadOnlyList metricTypes) + { + var byName = metricTypes.ToDictionary(metric => Clean(metric.MetricName), metric => metric, StringComparer.OrdinalIgnoreCase); + var map = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var (schemaName, aliases) in MetricAliases) + { + var metric = aliases.Select(alias => byName.TryGetValue(Clean(alias), out var match) ? match : null).FirstOrDefault(match => match is not null); + if (metric is not null) + { + map[schemaName] = metric; + } + } + + return map; + } + + private static IReadOnlyList BuildMetrics(SceneIntelligenceScene parsed, IReadOnlyDictionary metricMap) + { + if (parsed.Metrics is null || parsed.Metrics.Count == 0) + { + return []; + } + + var values = new List(); + foreach (var (name, metric) in parsed.Metrics) + { + if (!metric.Score.HasValue || !metricMap.TryGetValue(name, out var metricType)) + { + continue; + } + + values.Add(new SceneMetricValue + { + MetricTypeID = metricType.MetricTypeID, + MetricName = metricType.MetricName, + Value = Math.Clamp(metric.Score.Value, metricType.MinValue, metricType.MaxValue), + Notes = metric.Confidence.HasValue ? $"Story Intelligence confidence {metric.Confidence.Value:0.##}." : "Imported from Story Intelligence." + }); + } + + return values; + } + + private static IReadOnlyList MatchPurposes(string? observedFunction, IReadOnlyDictionary purposeMap) + { + var value = Clean(observedFunction); + if (string.IsNullOrWhiteSpace(value)) + { + return []; + } + + var matches = purposeMap + .Where(pair => value.Contains(pair.Key, StringComparison.OrdinalIgnoreCase) || pair.Key.Contains(value, StringComparison.OrdinalIgnoreCase)) + .Select(pair => pair.Value) + .Distinct() + .ToList(); + return matches; + } + + private static string? BuildPurposeNotes(SceneIntelligenceScene parsed, StoryIntelligenceSavedSceneResult result, string povName, int? povCharacterId) + { + var lines = new List + { + $"Story Intelligence import source: run {result.StoryIntelligenceRunID:N0}, paragraphs {DisplayRange(result.StartParagraph, result.EndParagraph)}." + }; + + if (!string.IsNullOrWhiteSpace(parsed.ScenePurpose?.ObservedFunction)) + { + lines.Add($"Observed function: {parsed.ScenePurpose.ObservedFunction}"); + } + + if (parsed.Setting is not null) + { + var setting = string.Join("; ", new[] + { + Label("Location", parsed.Setting.LocationName), + Label("Location type", parsed.Setting.LocationType), + Label("Room", parsed.Setting.GenericRoomType), + Label("Parent hint", parsed.Setting.ParentLocationHint), + Label("Time", parsed.Setting.DateOrTimeReference ?? parsed.Setting.TimeOfDay) + }.Where(value => !string.IsNullOrWhiteSpace(value))); + if (!string.IsNullOrWhiteSpace(setting)) + { + lines.Add($"Setting notes: {setting}"); + } + } + + if (!string.IsNullOrWhiteSpace(povName) && !povCharacterId.HasValue) + { + lines.Add($"POV not linked: {povName}"); + } + + return string.Join(Environment.NewLine, lines); + } + + private static string? BuildImportNote(SceneIntelligenceScene parsed, string povName, int? povCharacterId) + { + var lines = new List(); + if (parsed.TimelineClues?.Count > 0) + { + lines.Add("Timeline clues:"); + lines.AddRange(parsed.TimelineClues.Select(clue => $"- {Clean(clue.AbsoluteDate ?? clue.RelativeOrder ?? clue.Clue)}")); + } + + if (!string.IsNullOrWhiteSpace(povName) && !povCharacterId.HasValue) + { + lines.Add($"POV left unlinked because '{povName}' did not resolve to an existing character."); + } + + return lines.Count == 0 ? null : string.Join(Environment.NewLine, lines); + } + + 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; + } + } + catch (JsonException) + { + } + + return null; + } + + private static int FindId(IReadOnlyList items, Func name, Func id, params string[] preferredNames) + { + foreach (var preferred in preferredNames) + { + var match = items.FirstOrDefault(item => string.Equals(name(item), preferred, StringComparison.OrdinalIgnoreCase)); + if (match is not null) + { + return id(match); + } + } + + return items.Count > 0 ? id(items[0]) : throw new InvalidOperationException("Required lookup data is missing."); + } + + private static string GenerateTitle(int sceneNumber, string summary) + => string.IsNullOrWhiteSpace(summary) + ? $"Scene {sceneNumber:N0}" + : $"Scene {sceneNumber:N0} - {TrimTo(summary, 60)}"; + + private static string TrimTo(string value, int maxLength) + => value.Length <= maxLength ? value : value[..maxLength].TrimEnd() + "..."; + + private static string DisplayRange(int? start, int? end) + => start.HasValue && end.HasValue ? $"{start.Value:N0}-{end.Value:N0}" : "unknown"; + + private static string? Label(string label, string? value) + => string.IsNullOrWhiteSpace(value) ? null : $"{label}: {value}"; + + private static string Clean(string? value) + => string.Join(' ', (value ?? string.Empty).Trim().Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)); + + private sealed record PreparedImport( + StoryIntelligenceSavedRun Run, + StoryIntelligenceImportConfirmationViewModel Confirmation, + IReadOnlyList Scenes, + int TimeModeID, + int TimeConfidenceID, + int RevisionStatusID, + int? TimelineNoteTypeID); +} diff --git a/PlotLine/Services/StoryIntelligenceResultPersistenceService.cs b/PlotLine/Services/StoryIntelligenceResultPersistenceService.cs index 3890d7c..5b999f9 100644 --- a/PlotLine/Services/StoryIntelligenceResultPersistenceService.cs +++ b/PlotLine/Services/StoryIntelligenceResultPersistenceService.cs @@ -111,7 +111,8 @@ public sealed class StoryIntelligenceResultPersistenceService( Run = run, ChapterResult = await repository.GetChapterResultAsync(runId), SceneResults = sceneResults, - ImportPreview = await importResolver.ResolveAsync(run, sceneResults) + ImportPreview = await importResolver.ResolveAsync(run, sceneResults), + ImportCommit = await repository.GetImportCommitAsync(runId) }; } diff --git a/PlotLine/Sql/121_Phase20V_StoryIntelligenceImportCommit.sql b/PlotLine/Sql/121_Phase20V_StoryIntelligenceImportCommit.sql new file mode 100644 index 0000000..efd5ca9 --- /dev/null +++ b/PlotLine/Sql/121_Phase20V_StoryIntelligenceImportCommit.sql @@ -0,0 +1,165 @@ +SET ANSI_NULLS ON; +GO +SET QUOTED_IDENTIFIER ON; +GO + +IF OBJECT_ID(N'dbo.StoryIntelligenceImportCommits', N'U') IS NULL +BEGIN + CREATE TABLE dbo.StoryIntelligenceImportCommits + ( + StoryIntelligenceImportCommitID int IDENTITY(1,1) NOT NULL CONSTRAINT PK_StoryIntelligenceImportCommits PRIMARY KEY, + StoryIntelligenceRunID int NOT NULL, + ProjectID int NULL, + BookID int NULL, + ChapterID int NULL, + CommittedAt datetime2 NOT NULL CONSTRAINT DF_StoryIntelligenceImportCommits_CommittedAt DEFAULT SYSUTCDATETIME(), + CommittedByUserID int NOT NULL, + ScenesCreated int NOT NULL CONSTRAINT DF_StoryIntelligenceImportCommits_ScenesCreated DEFAULT 0, + MetricsCreated int NOT NULL CONSTRAINT DF_StoryIntelligenceImportCommits_MetricsCreated DEFAULT 0, + Status nvarchar(40) NOT NULL, + Notes nvarchar(max) NULL, + CreatedUtc datetime2 NOT NULL CONSTRAINT DF_StoryIntelligenceImportCommits_CreatedUtc DEFAULT SYSUTCDATETIME(), + CONSTRAINT FK_StoryIntelligenceImportCommits_Runs FOREIGN KEY (StoryIntelligenceRunID) REFERENCES dbo.StoryIntelligenceRuns(StoryIntelligenceRunID), + CONSTRAINT FK_StoryIntelligenceImportCommits_Projects FOREIGN KEY (ProjectID) REFERENCES dbo.Projects(ProjectID), + CONSTRAINT FK_StoryIntelligenceImportCommits_Books FOREIGN KEY (BookID) REFERENCES dbo.Books(BookID), + CONSTRAINT FK_StoryIntelligenceImportCommits_Chapters FOREIGN KEY (ChapterID) REFERENCES dbo.Chapters(ChapterID), + CONSTRAINT FK_StoryIntelligenceImportCommits_AppUser FOREIGN KEY (CommittedByUserID) REFERENCES dbo.AppUser(UserID) + ); +END; +GO + +IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'UX_StoryIntelligenceImportCommits_CompletedRun' AND object_id = OBJECT_ID(N'dbo.StoryIntelligenceImportCommits')) + CREATE UNIQUE INDEX UX_StoryIntelligenceImportCommits_CompletedRun ON dbo.StoryIntelligenceImportCommits(StoryIntelligenceRunID) WHERE Status = N'Completed'; +GO + +IF COL_LENGTH(N'dbo.Scenes', N'ImportSource') IS NULL + ALTER TABLE dbo.Scenes ADD ImportSource nvarchar(80) NULL; +GO +IF COL_LENGTH(N'dbo.Scenes', N'ImportRunID') IS NULL + ALTER TABLE dbo.Scenes ADD ImportRunID int NULL; +GO +IF COL_LENGTH(N'dbo.Scenes', N'SourceStartParagraph') IS NULL + ALTER TABLE dbo.Scenes ADD SourceStartParagraph int NULL; +GO +IF COL_LENGTH(N'dbo.Scenes', N'SourceEndParagraph') IS NULL + ALTER TABLE dbo.Scenes ADD SourceEndParagraph int NULL; +GO + +IF NOT EXISTS (SELECT 1 FROM sys.foreign_keys WHERE name = N'FK_Scenes_StoryIntelligenceRuns_ImportRunID') + ALTER TABLE dbo.Scenes ADD CONSTRAINT FK_Scenes_StoryIntelligenceRuns_ImportRunID FOREIGN KEY (ImportRunID) REFERENCES dbo.StoryIntelligenceRuns(StoryIntelligenceRunID); +GO + +CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceImportCommit_GetByRun + @StoryIntelligenceRunID int +AS +BEGIN + SET NOCOUNT ON; + + SELECT TOP (1) StoryIntelligenceImportCommitID, StoryIntelligenceRunID, ProjectID, BookID, ChapterID, + CommittedAt, CommittedByUserID, ScenesCreated, MetricsCreated, Status, Notes, CreatedUtc + FROM dbo.StoryIntelligenceImportCommits + WHERE StoryIntelligenceRunID = @StoryIntelligenceRunID + ORDER BY CASE WHEN Status = N'Completed' THEN 0 ELSE 1 END, CreatedUtc DESC; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceImportCommit_HasCompleted + @StoryIntelligenceRunID int +AS +BEGIN + SET NOCOUNT ON; + SELECT CAST(CASE WHEN EXISTS + ( + SELECT 1 + FROM dbo.StoryIntelligenceImportCommits + WHERE StoryIntelligenceRunID = @StoryIntelligenceRunID + AND Status = N'Completed' + ) THEN 1 ELSE 0 END AS bit); +END; +GO + +CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceImportCommit_CountChapterScenes + @ChapterID int +AS +BEGIN + SET NOCOUNT ON; + SELECT COUNT(1) + FROM dbo.Scenes + WHERE ChapterID = @ChapterID + AND IsArchived = 0; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceSceneSource_Save + @SceneID int, + @ImportSource nvarchar(80), + @ImportRunID int, + @SourceStartParagraph int = NULL, + @SourceEndParagraph int = NULL +AS +BEGIN + SET NOCOUNT ON; + + UPDATE dbo.Scenes + SET ImportSource = @ImportSource, + ImportRunID = @ImportRunID, + SourceStartParagraph = @SourceStartParagraph, + SourceEndParagraph = @SourceEndParagraph, + UpdatedDate = SYSUTCDATETIME() + WHERE SceneID = @SceneID; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceImportCommit_RecordCompleted + @StoryIntelligenceRunID int, + @ProjectID int = NULL, + @BookID int = NULL, + @ChapterID int = NULL, + @CommittedByUserID int, + @ScenesCreated int, + @MetricsCreated int, + @Notes nvarchar(max) = NULL +AS +BEGIN + SET NOCOUNT ON; + + INSERT dbo.StoryIntelligenceImportCommits + ( + StoryIntelligenceRunID, ProjectID, BookID, ChapterID, CommittedAt, CommittedByUserID, + ScenesCreated, MetricsCreated, Status, Notes + ) + VALUES + ( + @StoryIntelligenceRunID, @ProjectID, @BookID, @ChapterID, SYSUTCDATETIME(), @CommittedByUserID, + @ScenesCreated, @MetricsCreated, N'Completed', @Notes + ); + + SELECT CAST(SCOPE_IDENTITY() AS int); +END; +GO + +CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceImportCommit_RecordFailed + @StoryIntelligenceRunID int, + @ProjectID int = NULL, + @BookID int = NULL, + @ChapterID int = NULL, + @CommittedByUserID int, + @ScenesCreated int, + @MetricsCreated int, + @Notes nvarchar(max) = NULL +AS +BEGIN + SET NOCOUNT ON; + + INSERT dbo.StoryIntelligenceImportCommits + ( + StoryIntelligenceRunID, ProjectID, BookID, ChapterID, CommittedAt, CommittedByUserID, + ScenesCreated, MetricsCreated, Status, Notes + ) + VALUES + ( + @StoryIntelligenceRunID, @ProjectID, @BookID, @ChapterID, SYSUTCDATETIME(), @CommittedByUserID, + @ScenesCreated, @MetricsCreated, N'Failed', @Notes + ); +END; +GO diff --git a/PlotLine/ViewModels/FeatureRequestViewModels.cs b/PlotLine/ViewModels/FeatureRequestViewModels.cs index c4c618a..352c610 100644 --- a/PlotLine/ViewModels/FeatureRequestViewModels.cs +++ b/PlotLine/ViewModels/FeatureRequestViewModels.cs @@ -293,6 +293,25 @@ public sealed class StoryIntelligenceSavedRunDetailViewModel public StoryIntelligenceSavedChapterResult? ChapterResult { get; init; } public IReadOnlyList SceneResults { get; init; } = []; public StoryIntelligenceImportPreview? ImportPreview { get; init; } + public StoryIntelligenceImportCommit? ImportCommit { get; init; } +} + +public sealed class StoryIntelligenceImportConfirmationViewModel +{ + public StoryIntelligenceSavedRun Run { get; init; } = new(); + public StoryIntelligenceImportCommit? ExistingCommit { get; init; } + public string ProjectName { get; init; } = string.Empty; + public string BookTitle { get; init; } = string.Empty; + public string ChapterLabel { get; init; } = string.Empty; + public int ExistingSceneCount { get; init; } + public int ScenesToCreate { get; init; } + public int MetricsToImport { get; init; } + public IReadOnlyList MetricNames { get; init; } = []; + public IReadOnlyList Warnings { get; init; } = []; + public IReadOnlyList Blockers { get; init; } = []; + public bool HasCompletedCommit => ExistingCommit is not null + && string.Equals(ExistingCommit.Status, StoryIntelligenceImportCommitStatuses.Completed, StringComparison.OrdinalIgnoreCase); + public bool CanCommit => Blockers.Count == 0 && !HasCompletedCommit; } public sealed class AdminFeatureRequestFilter diff --git a/PlotLine/Views/Admin/ConfirmStoryIntelligenceImportCommit.cshtml b/PlotLine/Views/Admin/ConfirmStoryIntelligenceImportCommit.cshtml new file mode 100644 index 0000000..092087e --- /dev/null +++ b/PlotLine/Views/Admin/ConfirmStoryIntelligenceImportCommit.cshtml @@ -0,0 +1,106 @@ +@model StoryIntelligenceImportConfirmationViewModel +@{ + ViewData["Title"] = "Commit Story Intelligence Import"; +} + +
+
+

Admin utility

+

Commit Story Intelligence Import

+

Create PlotDirector scenes from a completed Story Intelligence run.

+
+
+ + + +@if (TempData["AdminError"] is string error) +{ +
@error
+} + +
+

Confirmation

+
+ This phase imports scenes and scene intelligence only. Characters, locations, assets, relationships and knowledge remain preview-only. +
+ + @if (Model.ExistingCommit is not null && string.Equals(Model.ExistingCommit.Status, StoryIntelligenceImportCommitStatuses.Completed, StringComparison.OrdinalIgnoreCase)) + { +
+ This run has already been committed. It created @Model.ExistingCommit.ScenesCreated.ToString("N0") scene@(Model.ExistingCommit.ScenesCreated == 1 ? string.Empty : "s") + on @Model.ExistingCommit.CommittedAt.ToString("yyyy-MM-dd HH:mm:ss") UTC. +
+ } + + @if (Model.Blockers.Count > 0) + { +
+ Import is blocked. +
    + @foreach (var blocker in Model.Blockers) + { +
  • @blocker
  • + } +
+
+ } + +
+
Project
+
@Model.ProjectName
+
Book
+
@Model.BookTitle
+
Chapter
+
@Model.ChapterLabel
+
Scenes to create
+
@Model.ScenesToCreate.ToString("N0")
+
Existing scene count for this chapter
+
@Model.ExistingSceneCount.ToString("N0")
+
Metric values to import
+
@Model.MetricsToImport.ToString("N0")
+
Metrics
+
@(Model.MetricNames.Count == 0 ? "None" : string.Join(", ", Model.MetricNames))
+
+ + @if (Model.ExistingSceneCount > 0) + { +
+ This chapter already has scenes. The current import mode will not merge, replace, or overwrite existing scenes. +
+ } + + @if (Model.Warnings.Count > 0) + { +

Warnings

+
    + @foreach (var warning in Model.Warnings) + { +
  • @warning
  • + } +
+ } + +

Not Imported Yet

+
    +
  • Characters
  • +
  • Locations
  • +
  • Assets
  • +
  • Relationships
  • +
  • Knowledge changes
  • +
  • Real timeline events
  • +
+ +
+ + Back to run +
+
diff --git a/PlotLine/Views/Admin/StoryIntelligenceRunDetails.cshtml b/PlotLine/Views/Admin/StoryIntelligenceRunDetails.cshtml index 7dfb702..1eb86a3 100644 --- a/PlotLine/Views/Admin/StoryIntelligenceRunDetails.cshtml +++ b/PlotLine/Views/Admin/StoryIntelligenceRunDetails.cshtml @@ -32,6 +32,10 @@ {
@message
} +@if (TempData["AdminError"] is string error) +{ +
@error
+} @if (run is null) { @@ -158,6 +162,21 @@ else Run same source again with different models } + +
+ @if (Model.ImportCommit is not null && string.Equals(Model.ImportCommit.Status, StoryIntelligenceImportCommitStatuses.Completed, StringComparison.OrdinalIgnoreCase)) + { +
+ This run was committed on @Model.ImportCommit.CommittedAt.ToString("yyyy-MM-dd HH:mm:ss") UTC. + Created @Model.ImportCommit.ScenesCreated.ToString("N0") scene@(Model.ImportCommit.ScenesCreated == 1 ? string.Empty : "s") + and @Model.ImportCommit.MetricsCreated.ToString("N0") metric value@(Model.ImportCommit.MetricsCreated == 1 ? string.Empty : "s"). +
+ } + else if (CanCommitImport(run, Model.SceneResults)) + { + Commit Import + } +
@@ -555,6 +574,13 @@ else or StoryIntelligenceRunStatuses.Failed or StoryIntelligenceRunStatuses.Cancelled); + private static bool CanCommitImport(StoryIntelligenceSavedRun run, IReadOnlyList scenes) + => run.Status is StoryIntelligenceRunStatuses.Completed or StoryIntelligenceRunStatuses.CompletedWithWarnings + && run.ProjectID.HasValue + && run.BookID.HasValue + && run.ChapterID.HasValue + && scenes.Count > 0; + private static string SceneFailureStage(StoryIntelligenceSavedSceneResult scene) => SceneErrorMessage(scene).Contains("boundary", StringComparison.OrdinalIgnoreCase) ? StoryIntelligenceFailureStages.SceneSplit