using System.Text.Json; using PlotLine.Data; using PlotLine.Models; using PlotLine.ViewModels; namespace PlotLine.Services; public interface IStoryIntelligenceResultPersistenceService { Task SaveDisplayedRunAsync(int userId, string payload); Task QueueAdminTextRunAsync(int userId, ChapterStoryIntelligenceDryRunForm form); Task CancelRunAsync(int runId); Task ListRunsAsync(); Task GetRunDetailAsync(int runId); } public sealed class StoryIntelligenceResultPersistenceService( IStoryIntelligenceResultRepository repository, IStoryIntelligenceClient client, ILogger logger) : IStoryIntelligenceResultPersistenceService { private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, PropertyNameCaseInsensitive = true, WriteIndented = true }; public async Task SaveDisplayedRunAsync(int userId, string payload) { if (string.IsNullOrWhiteSpace(payload)) { throw new InvalidOperationException("No dry-run result was submitted for saving."); } var dryRun = JsonSerializer.Deserialize(payload, JsonOptions) ?? throw new InvalidOperationException("The submitted dry-run result could not be read."); if (dryRun.Result is null) { throw new InvalidOperationException("The submitted dry-run result is empty."); } var request = BuildSaveRequest(userId, dryRun); var runId = await repository.SaveAsync(request); logger.LogInformation( "Saved Story Intelligence admin dry-run. StoryIntelligenceRunID={StoryIntelligenceRunID} UserID={UserID} Status={Status} ChapterPromptVersion={ChapterPromptVersion} ScenePromptVersion={ScenePromptVersion} Model={Model} SceneCount={SceneCount} ValidationErrors={ValidationErrors} ValidationWarnings={ValidationWarnings} TotalDurationMs={TotalDurationMs} TotalTokens={TotalTokens}", runId, userId, request.Status, dryRun.Result.ChapterPromptVersion, dryRun.Result.ScenePromptVersion, dryRun.Result.Model, request.SceneResults.Count, CountErrors(dryRun.Result), CountWarnings(dryRun.Result), request.TotalDurationMs, request.TotalTokens); return runId; } public async Task QueueAdminTextRunAsync(int userId, ChapterStoryIntelligenceDryRunForm form) { if (string.IsNullOrWhiteSpace(form.ChapterText)) { throw new InvalidOperationException("Enter chapter text before queueing a persisted Story Intelligence run."); } var promptVersionsSummary = "Chapter=Chapter-Structure-Prompt-V1; Scene=Scene-Prompt-V1"; var clientStatus = client.GetConfigurationStatus(); return await repository.QueueAdminTextAsync(new StoryIntelligenceRunQueueRequest { UserID = userId, ProjectID = form.ProjectID, BookID = form.BookID, SourceType = "AdminText", SourceLabel = CleanSourceLabel(form.SourceLabel), ChapterText = form.ChapterText, SourceWordCount = CountWords(form.ChapterText), SourceCharacterCount = form.ChapterText.Length, SourceChapterCount = 1, Model = clientStatus.Model, PromptVersionsSummary = promptVersionsSummary }); } public Task CancelRunAsync(int runId) => repository.RequestCancelAsync(runId); public async Task ListRunsAsync() => new() { Runs = await repository.ListRunsAsync() }; public async Task GetRunDetailAsync(int runId) { var run = await repository.GetRunAsync(runId); if (run is null) { return null; } return new StoryIntelligenceSavedRunDetailViewModel { Run = run, ChapterResult = await repository.GetChapterResultAsync(runId), SceneResults = await repository.ListSceneResultsAsync(runId) }; } private static StoryIntelligenceRunSaveRequest BuildSaveRequest(int userId, ChapterStoryIntelligenceDryRunViewModel dryRun) { var form = dryRun.Form; var result = dryRun.Result!; var completedUtc = DateTime.UtcNow; var startedUtc = result.TotalDurationMs.HasValue ? completedUtc.AddMilliseconds(-result.TotalDurationMs.Value) : completedUtc; var errorCount = CountErrors(result); var warningCount = CountWarnings(result); var sceneFailed = result.SceneBlocks.Any(block => !block.Success && block.SplitValid); var status = !string.IsNullOrWhiteSpace(result.ErrorMessage) || result.PipelineStopped || errorCount > 0 || sceneFailed ? StoryIntelligenceRunStatuses.Failed : warningCount > 0 ? StoryIntelligenceRunStatuses.CompletedWithWarnings : StoryIntelligenceRunStatuses.Completed; var promptVersionsSummary = $"Chapter={result.ChapterPromptVersion}; Scene={result.ScenePromptVersion}"; return new StoryIntelligenceRunSaveRequest { UserID = userId, ProjectID = form.ProjectID, BookID = form.BookID, Status = status, SourceType = "AdminDryRun", SourceWordCount = CountWords(form.ChapterText), SourceCharacterCount = string.IsNullOrEmpty(form.ChapterText) ? null : form.ChapterText.Length, SourceChapterCount = 1, PromptVersion = promptVersionsSummary, PromptVersionsSummary = promptVersionsSummary, Model = result.Model, StartedUtc = startedUtc, CompletedUtc = completedUtc, FailureStage = DetermineFailureStage(result), TotalInputTokens = result.TotalInputTokens, TotalOutputTokens = result.TotalOutputTokens, TotalTokens = SumTokens(result.TotalInputTokens, result.TotalOutputTokens), TotalDurationMs = result.TotalDurationMs, ErrorMessage = FirstError(result), ErrorDetail = BuildErrorDetail(result), ChapterResult = BuildChapterResult(form, result), SceneResults = result.SceneBlocks.Select(block => BuildSceneResult(form, result, block)).ToList() }; } private static StoryIntelligenceChapterResultSaveRequest BuildChapterResult( ChapterStoryIntelligenceDryRunForm form, ChapterStoryIntelligenceDryRunResultViewModel result) => new() { ProjectID = form.ProjectID, BookID = form.BookID, ChapterID = form.ChapterID, ChapterNumber = form.ChapterNumber, SourceLabel = CleanSourceLabel(form.SourceLabel), PromptVersion = result.ChapterPromptVersion, Model = result.Model, RawResponseJson = result.ChapterRawResponseText, OutputTextJson = result.ChapterJsonText, ParsedJson = SerializeParsed(result.ParsedChapter), ValidationErrorsCount = result.ChapterValidation?.Errors.Count ?? 0, ValidationWarningsCount = result.ChapterValidation?.Warnings.Count ?? 0, InputTokens = result.ChapterInputTokens, OutputTokens = result.ChapterOutputTokens, TotalTokens = SumTokens(result.ChapterInputTokens, result.ChapterOutputTokens), DurationMs = result.ChapterDurationMs }; private static StoryIntelligenceSceneResultSaveRequest BuildSceneResult( ChapterStoryIntelligenceDryRunForm form, ChapterStoryIntelligenceDryRunResultViewModel result, ChapterStorySceneBlockViewModel block) => new() { ProjectID = form.ProjectID, BookID = form.BookID, ChapterID = form.ChapterID, SceneID = null, TemporarySceneNumber = block.TemporarySceneNumber, StartParagraph = block.StartParagraph > 0 ? block.StartParagraph : null, EndParagraph = block.EndParagraph > 0 ? block.EndParagraph : null, SourceLabel = $"{CleanSourceLabel(form.SourceLabel)}, suggested scene {block.TemporarySceneNumber}", PromptVersion = result.ScenePromptVersion, Model = result.Model, RawResponseJson = block.RawResponseText, OutputTextJson = block.SceneJsonText, ParsedJson = SerializeParsed(block.ParsedScene), ValidationErrorsCount = block.Validation?.Errors.Count ?? 0, ValidationWarningsCount = block.Validation?.Warnings.Count ?? 0, InputTokens = block.InputTokens, OutputTokens = block.OutputTokens, TotalTokens = SumTokens(block.InputTokens, block.OutputTokens), DurationMs = block.DurationMs }; private static string SerializeParsed(T? value) => value is null ? string.Empty : JsonSerializer.Serialize(value, JsonOptions); private static string CleanSourceLabel(string? value) => string.IsNullOrWhiteSpace(value) ? "Admin pipeline test chapter" : value.Trim(); private static int? SumTokens(int? inputTokens, int? outputTokens) => inputTokens.HasValue || outputTokens.HasValue ? (inputTokens ?? 0) + (outputTokens ?? 0) : null; private static int? CountWords(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries).Length; private static int CountErrors(ChapterStoryIntelligenceDryRunResultViewModel result) => (result.ChapterValidation?.Errors.Count ?? 0) + result.SceneBlocks.Sum(block => block.Validation?.Errors.Count ?? 0); private static int CountWarnings(ChapterStoryIntelligenceDryRunResultViewModel result) => (result.ChapterValidation?.Warnings.Count ?? 0) + result.SceneBlocks.Sum(block => block.Validation?.Warnings.Count ?? 0); private static string? FirstError(ChapterStoryIntelligenceDryRunResultViewModel result) => !string.IsNullOrWhiteSpace(result.ErrorMessage) ? result.ErrorMessage : result.SceneBlocks.FirstOrDefault(block => !string.IsNullOrWhiteSpace(block.ErrorMessage))?.ErrorMessage; private static string? DetermineFailureStage(ChapterStoryIntelligenceDryRunResultViewModel result) { if (string.IsNullOrWhiteSpace(result.ErrorMessage) && !result.PipelineStopped && result.ChapterValidation?.Errors.Count is not > 0 && result.SceneBlocks.All(block => block.Success || !block.SplitValid) && result.SceneBlocks.All(block => block.Validation?.Errors.Count is not > 0)) { return null; } if (result.ChapterValidation?.Errors.Count is > 0 || result.StopReason.Contains("validation", StringComparison.OrdinalIgnoreCase)) { return StoryIntelligenceFailureStages.Validation; } if (result.SceneBlocks.Any(block => !block.SplitValid)) { return StoryIntelligenceFailureStages.SceneSplit; } if (result.SceneBlocks.Any(block => !block.Success || !string.IsNullOrWhiteSpace(block.ErrorMessage))) { return StoryIntelligenceFailureStages.SceneIntelligence; } return StoryIntelligenceFailureStages.ChapterStructure; } private static string? BuildErrorDetail(ChapterStoryIntelligenceDryRunResultViewModel result) { var details = new List(); if (!string.IsNullOrWhiteSpace(result.StopReason)) { details.Add(result.StopReason); } if (!string.IsNullOrWhiteSpace(result.ErrorMessage)) { details.Add(result.ErrorMessage); } details.AddRange(result.ChapterValidation?.Errors.Select(issue => $"{issue.Path}: {issue.Message}") ?? []); details.AddRange(result.SceneBlocks .Where(block => !string.IsNullOrWhiteSpace(block.ErrorMessage)) .Select(block => $"Scene {block.TemporarySceneNumber}: {block.ErrorMessage}")); details.AddRange(result.SceneBlocks .Where(block => block.Validation is not null) .SelectMany(block => block.Validation!.Errors.Select(issue => $"Scene {block.TemporarySceneNumber} {issue.Path}: {issue.Message}"))); return details.Count == 0 ? null : string.Join(Environment.NewLine, details); } }