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 ListRunsAsync(); Task GetRunDetailAsync(int runId); } public sealed class StoryIntelligenceResultPersistenceService( IStoryIntelligenceResultRepository repository, 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 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 status = !string.IsNullOrWhiteSpace(result.ErrorMessage) || result.PipelineStopped ? StoryIntelligenceRunStatuses.Failed : errorCount > 0 || warningCount > 0 || result.SceneBlocks.Any(block => !block.Success && block.SplitValid) ? StoryIntelligenceRunStatuses.CompletedWithIssues : StoryIntelligenceRunStatuses.Completed; return new StoryIntelligenceRunSaveRequest { UserID = userId, ProjectID = form.ProjectID, BookID = form.BookID, Status = status, SourceType = "AdminDryRun", PromptVersion = $"Chapter={result.ChapterPromptVersion}; Scene={result.ScenePromptVersion}", Model = result.Model, StartedUtc = startedUtc, CompletedUtc = completedUtc, TotalInputTokens = result.TotalInputTokens, TotalOutputTokens = result.TotalOutputTokens, TotalTokens = SumTokens(result.TotalInputTokens, result.TotalOutputTokens), TotalDurationMs = result.TotalDurationMs, ErrorMessage = FirstError(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 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; }