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, 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 readiness = run.ChapterID.HasValue ? await storyRuns.GetChapterImportReadinessAsync(run.ChapterID.Value) : null; if (readiness is not null && !readiness.CanCommit) { blockers.Add(readiness.BlockReason ?? "This chapter already contains scenes."); } 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($"Scene {sceneResult.TemporarySceneNumber:N0} could not be prepared automatically and needs review."); continue; } importScenes.Add(BuildImportScene(sceneResult, parsed, characterMap, purposeMap, metricMap)); } if (importScenes.Count == 0) { blockers.Add("No scenes are ready to create for this chapter."); } 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 = readiness?.ActiveSceneCount ?? 0, CommitMode = readiness?.CommitMode ?? string.Empty, 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, Scenes = importScenes }, 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); }