using System.Diagnostics; using System.Text.Json; using Microsoft.Extensions.Options; using PlotLine.Data; using PlotLine.Models; namespace PlotLine.Services; public interface IPersistedStoryIntelligenceRunner { Task ProcessNextAsync(CancellationToken cancellationToken); } public sealed class PersistedStoryIntelligenceRunner( IStoryIntelligenceResultRepository repository, IStoryPromptRepository prompts, IStoryPromptVersionService versions, IStoryPromptBuilder scenePromptBuilder, IStoryIntelligenceClient client, IChapterStructureValidator chapterValidator, IStorySceneValidator sceneValidator, IOptions pricingOptions, ILogger logger) : IPersistedStoryIntelligenceRunner { private const string ChapterPromptFile = "Chapter-Structure-Prompt-V1.md"; private const string ScenePromptFile = "Scene-Prompt-V1.md"; private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, PropertyNameCaseInsensitive = true, WriteIndented = true }; private readonly StoryIntelligencePricingOptions pricing = pricingOptions.Value; public async Task ProcessNextAsync(CancellationToken cancellationToken) { var run = await repository.ClaimNextPendingAsync(); if (run is null) { return; } await ProcessAsync(run, cancellationToken); } private async Task ProcessAsync(StoryIntelligenceQueuedRun run, CancellationToken cancellationToken) { var stopwatch = Stopwatch.StartNew(); var totals = new TokenTotals(); var chapterPromptVersion = versions.GetPromptVersion(ChapterPromptFile); var scenePromptVersion = versions.GetPromptVersion(ScenePromptFile); int? chapterResultId = null; var failedScenes = 0; var completedScenes = 0; var hasWarnings = false; try { if (string.IsNullOrWhiteSpace(run.SourceText)) { throw new StoryIntelligenceRunFailureException( StoryIntelligenceFailureStages.DocumentRead, "Story Intelligence run has no source text."); } await ThrowIfCancellationRequestedAsync(run.StoryIntelligenceRunID, stopwatch.ElapsedMilliseconds, cancellationToken); var paragraphs = SplitParagraphs(run.SourceText); if (paragraphs.Count == 0) { throw new StoryIntelligenceRunFailureException( StoryIntelligenceFailureStages.DocumentRead, "Story Intelligence run source text does not contain readable paragraphs."); } var chapterContextJson = BuildChapterContextJson(run); var chapterTemplate = await prompts.LoadPromptAsync(ChapterPromptFile, cancellationToken); var chapterPrompt = BuildChapterPrompt(chapterTemplate, chapterContextJson, run.SourceText); await repository.UpdateProgressAsync( run.StoryIntelligenceRunID, currentStage: StoryIntelligenceFailureStages.ChapterStructure, currentMessage: "Running Chapter Structure analysis.", totalDurationMs: stopwatch.ElapsedMilliseconds); var chapterClientResult = await client.ExecutePromptAsync(chapterPrompt, chapterPromptVersion, cancellationToken); totals.Add(chapterClientResult); var chapterJson = ExtractOutputText(chapterClientResult.RawResponseText); var parsedChapter = JsonSerializer.Deserialize(chapterJson, JsonOptions); var chapterValidation = chapterValidator.Validate(parsedChapter, paragraphs.Count); hasWarnings = chapterValidation.Warnings.Count > 0; chapterResultId = await repository.SaveChapterResultAsync( run.StoryIntelligenceRunID, new StoryIntelligenceChapterResultSaveRequest { ProjectID = run.ProjectID, BookID = run.BookID, ChapterID = run.ChapterID, ChapterNumber = ChapterNumber(run), SourceLabel = SourceLabel(run), PromptVersion = chapterPromptVersion, Model = chapterClientResult.Model, RawResponseJson = chapterClientResult.RawResponseText, OutputTextJson = chapterJson, ParsedJson = SerializeParsed(parsedChapter), ValidationErrorsCount = chapterValidation.Errors.Count, ValidationWarningsCount = chapterValidation.Warnings.Count, InputTokens = chapterClientResult.InputTokens, OutputTokens = chapterClientResult.OutputTokens, TotalTokens = SumTokens(chapterClientResult.InputTokens, chapterClientResult.OutputTokens), DurationMs = Convert.ToInt64(chapterClientResult.Duration.TotalMilliseconds) }); if (!chapterValidation.IsValid || parsedChapter?.SceneBoundaries is null) { throw new StoryIntelligenceRunFailureException( StoryIntelligenceFailureStages.ChapterStructure, "Chapter Structure validation failed.", BuildValidationDetail(chapterValidation)); } var sceneBlocks = parsedChapter.SceneBoundaries .Select(boundary => BuildSceneBlock(run, paragraphs, boundary)) .ToList(); failedScenes += sceneBlocks.Count(block => !block.SplitValid); await repository.UpdateProgressAsync( run.StoryIntelligenceRunID, currentStage: StoryIntelligenceFailureStages.SceneSplit, currentMessage: $"Detected {sceneBlocks.Count:N0} suggested scene(s).", totalDetectedScenes: sceneBlocks.Count, failedScenes: failedScenes, totalInputTokens: totals.InputTokens, totalOutputTokens: totals.OutputTokens, totalTokens: totals.TotalTokens, totalDurationMs: stopwatch.ElapsedMilliseconds, estimatedCostUSD: EstimateUsd(totals), estimatedCostGBP: EstimateGbp(totals)); var sceneTemplate = await prompts.LoadPromptAsync(ScenePromptFile, cancellationToken); foreach (var block in sceneBlocks) { await ThrowIfCancellationRequestedAsync(run.StoryIntelligenceRunID, stopwatch.ElapsedMilliseconds, cancellationToken); if (!block.SplitValid) { await PersistFailedSceneAsync(run, chapterResultId, block, scenePromptVersion, "Scene boundary range is invalid for the submitted chapter paragraphs."); continue; } await repository.UpdateProgressAsync( run.StoryIntelligenceRunID, currentStage: StoryIntelligenceFailureStages.SceneIntelligence, currentMessage: $"Running Scene Intelligence for suggested scene {block.TemporarySceneNumber:N0}.", completedScenes: completedScenes, failedScenes: failedScenes, totalDurationMs: stopwatch.ElapsedMilliseconds); try { var completedScenePrompt = scenePromptBuilder.BuildPrompt(sceneTemplate, block.SceneContextJson, block.SceneText); var sceneClientResult = await client.ExecutePromptAsync(completedScenePrompt, scenePromptVersion, cancellationToken); totals.Add(sceneClientResult); var sceneJson = ExtractOutputText(sceneClientResult.RawResponseText); var parsedScene = JsonSerializer.Deserialize(sceneJson, JsonOptions); var validation = sceneValidator.Validate(parsedScene); hasWarnings = hasWarnings || validation.Warnings.Count > 0; await repository.SaveSceneResultAsync( run.StoryIntelligenceRunID, chapterResultId, new StoryIntelligenceSceneResultSaveRequest { ProjectID = run.ProjectID, BookID = run.BookID, ChapterID = run.ChapterID, SceneID = null, TemporarySceneNumber = block.TemporarySceneNumber, StartParagraph = block.StartParagraph, EndParagraph = block.EndParagraph, SourceLabel = block.SourceLabel, PromptVersion = scenePromptVersion, Model = sceneClientResult.Model, RawResponseJson = sceneClientResult.RawResponseText, OutputTextJson = sceneJson, ParsedJson = SerializeParsed(parsedScene), ValidationErrorsCount = validation.Errors.Count, ValidationWarningsCount = validation.Warnings.Count, InputTokens = sceneClientResult.InputTokens, OutputTokens = sceneClientResult.OutputTokens, TotalTokens = SumTokens(sceneClientResult.InputTokens, sceneClientResult.OutputTokens), DurationMs = Convert.ToInt64(sceneClientResult.Duration.TotalMilliseconds) }); completedScenes++; if (!validation.IsValid || validation.Warnings.Count > 0) { failedScenes += validation.Errors.Count > 0 ? 1 : 0; } } catch (Exception ex) when (ex is not OperationCanceledException && !IsFatal(ex)) { failedScenes++; await PersistFailedSceneAsync(run, chapterResultId, block, scenePromptVersion, ex.Message); logger.LogWarning(ex, "Story Intelligence scene failed for run {StoryIntelligenceRunID}, scene {TemporarySceneNumber}.", run.StoryIntelligenceRunID, block.TemporarySceneNumber); } await repository.UpdateProgressAsync( run.StoryIntelligenceRunID, completedScenes: completedScenes, failedScenes: failedScenes, totalInputTokens: totals.InputTokens, totalOutputTokens: totals.OutputTokens, totalTokens: totals.TotalTokens, totalDurationMs: stopwatch.ElapsedMilliseconds, estimatedCostUSD: EstimateUsd(totals), estimatedCostGBP: EstimateGbp(totals)); } var finalStatus = failedScenes > 0 || hasWarnings ? StoryIntelligenceRunStatuses.CompletedWithWarnings : StoryIntelligenceRunStatuses.Completed; await repository.CompleteRunAsync( run.StoryIntelligenceRunID, finalStatus, totals.InputTokens, totals.OutputTokens, totals.TotalTokens, stopwatch.ElapsedMilliseconds, EstimateGbp(totals), EstimateUsd(totals)); } catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { await repository.CancelRunAsync(run.StoryIntelligenceRunID, stopwatch.ElapsedMilliseconds); } catch (StoryIntelligenceRunFailureException ex) { await repository.FailRunAsync(run.StoryIntelligenceRunID, ex.FailureStage, ex.Message, ex.Detail, stopwatch.ElapsedMilliseconds); } catch (Exception ex) when (ex is not OperationCanceledException) { await repository.FailRunAsync( run.StoryIntelligenceRunID, ClassifyFailureStage(ex), ex.Message, ex.ToString(), stopwatch.ElapsedMilliseconds); } } private async Task ThrowIfCancellationRequestedAsync(int runId, long elapsedMs, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); var latest = await repository.GetRunAsync(runId); if (latest?.CancellationRequestedUtc.HasValue == true || string.Equals(latest?.Status, StoryIntelligenceRunStatuses.Cancelled, StringComparison.Ordinal)) { await repository.CancelRunAsync(runId, elapsedMs); throw new OperationCanceledException(); } } private async Task PersistFailedSceneAsync( StoryIntelligenceQueuedRun run, int? chapterResultId, StorySceneTextBlock block, string scenePromptVersion, string message) { await repository.SaveSceneResultAsync( run.StoryIntelligenceRunID, chapterResultId, new StoryIntelligenceSceneResultSaveRequest { ProjectID = run.ProjectID, BookID = run.BookID, ChapterID = run.ChapterID, SceneID = null, TemporarySceneNumber = block.TemporarySceneNumber, StartParagraph = block.StartParagraph, EndParagraph = block.EndParagraph, SourceLabel = block.SourceLabel, PromptVersion = scenePromptVersion, Model = run.Model, RawResponseJson = string.Empty, OutputTextJson = string.Empty, ParsedJson = JsonSerializer.Serialize(new { error = message }, JsonOptions), ValidationErrorsCount = 1, ValidationWarningsCount = 0 }); } private decimal? EstimateUsd(TokenTotals totals) { if (!pricing.InputPerMillionUsd.HasValue || !pricing.OutputPerMillionUsd.HasValue) { return null; } var inputCost = (totals.InputTokens ?? 0) / 1_000_000m * pricing.InputPerMillionUsd.Value; var outputCost = (totals.OutputTokens ?? 0) / 1_000_000m * pricing.OutputPerMillionUsd.Value; return inputCost + outputCost; } private decimal? EstimateGbp(TokenTotals totals) { var usd = EstimateUsd(totals); return usd.HasValue && pricing.UsdToGbpRate.HasValue ? usd.Value * pricing.UsdToGbpRate.Value : null; } private static StorySceneTextBlock BuildSceneBlock(StoryIntelligenceQueuedRun run, IReadOnlyList paragraphs, ChapterSceneBoundary boundary) { var sceneNumber = boundary.SceneNumber ?? 0; var start = boundary.StartParagraph ?? 0; var end = boundary.EndParagraph ?? 0; var sourceLabel = $"{SourceLabel(run)}, suggested scene {sceneNumber}"; if (sceneNumber <= 0 || start <= 0 || end <= 0 || start > end || end > paragraphs.Count) { return new StorySceneTextBlock(sceneNumber, start, end, sourceLabel, string.Empty, string.Empty, false); } var sceneText = string.Join(Environment.NewLine + Environment.NewLine, paragraphs.Skip(start - 1).Take(end - start + 1)); return new StorySceneTextBlock( sceneNumber, start, end, sourceLabel, sceneText, BuildSceneContextJson(run, boundary), true); } private static string BuildChapterContextJson(StoryIntelligenceQueuedRun run) => JsonSerializer.Serialize(new { projectId = run.ProjectID, bookId = run.BookID, chapterId = run.ChapterID, chapterNumber = ChapterNumber(run), sourceLabel = SourceLabel(run) }, JsonOptions); private static string BuildSceneContextJson(StoryIntelligenceQueuedRun run, ChapterSceneBoundary boundary) => JsonSerializer.Serialize(new { projectId = run.ProjectID, bookId = run.BookID, chapterId = run.ChapterID, sceneId = (int?)null, chapterNumber = ChapterNumber(run), sceneNumber = boundary.SceneNumber, sourceLabel = $"{SourceLabel(run)}, suggested scene {boundary.SceneNumber}" }, JsonOptions); private static string BuildChapterPrompt(string promptTemplate, string chapterContextJson, string chapterText) => promptTemplate .Replace("{{CHAPTER_CONTEXT_JSON}}", chapterContextJson ?? string.Empty, StringComparison.Ordinal) .Replace("{{CHAPTER_TEXT}}", chapterText ?? string.Empty, StringComparison.Ordinal); private static string ExtractOutputText(string rawResponseText) { var response = JsonSerializer.Deserialize(rawResponseText, JsonOptions) ?? throw new JsonException("OpenAI response envelope was empty."); var outputText = response.Output? .SelectMany(item => item.Content ?? []) .FirstOrDefault(content => string.Equals(content.Type, "output_text", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(content.Text)) ?.Text; return string.IsNullOrWhiteSpace(outputText) ? throw new JsonException("OpenAI response did not contain output_text content.") : outputText.Trim(); } private static List SplitParagraphs(string chapterText) => chapterText.Split(["\r\n\r\n", "\n\n", "\r\r"], StringSplitOptions.RemoveEmptyEntries) .Select(paragraph => paragraph.Trim()) .Where(paragraph => !string.IsNullOrWhiteSpace(paragraph)) .ToList(); private static string SerializeParsed(T? value) => value is null ? string.Empty : JsonSerializer.Serialize(value, JsonOptions); private static int? SumTokens(int? inputTokens, int? outputTokens) => inputTokens.HasValue || outputTokens.HasValue ? (inputTokens ?? 0) + (outputTokens ?? 0) : null; private static string SourceLabel(StoryIntelligenceQueuedRun run) => string.IsNullOrWhiteSpace(run.SourceLabel) ? "Admin persisted Story Intelligence chapter" : run.SourceLabel.Trim(); private static decimal ChapterNumber(StoryIntelligenceQueuedRun run) => run.ChapterNumber ?? 1; private static string BuildValidationDetail(PlotLine.Models.StoryIntelligence.ValidationResult validation) => string.Join( Environment.NewLine, validation.Errors.Concat(validation.Warnings).Select(issue => $"{issue.Severity} {issue.Path}: {issue.Message}")); private static bool IsFatal(Exception ex) => ex is InvalidOperationException invalid && (invalid.Message.Contains("API key", StringComparison.OrdinalIgnoreCase) || invalid.Message.Contains("model is not configured", StringComparison.OrdinalIgnoreCase) || invalid.Message.Contains("prompt", StringComparison.OrdinalIgnoreCase)); private static string ClassifyFailureStage(Exception ex) => ex is FileNotFoundException ? StoryIntelligenceFailureStages.ChapterStructure : ex is JsonException ? StoryIntelligenceFailureStages.Validation : StoryIntelligenceFailureStages.Persistence; private sealed class StoryIntelligenceRunFailureException(string failureStage, string message, string? detail = null) : Exception(message) { public string FailureStage { get; } = failureStage; public string? Detail { get; } = detail; } private sealed record StorySceneTextBlock( int TemporarySceneNumber, int StartParagraph, int EndParagraph, string SourceLabel, string SceneText, string SceneContextJson, bool SplitValid); private sealed class TokenTotals { public int? InputTokens { get; private set; } public int? OutputTokens { get; private set; } public int? TotalTokens => InputTokens.HasValue || OutputTokens.HasValue ? (InputTokens ?? 0) + (OutputTokens ?? 0) : null; public void Add(StoryIntelligenceClientResult result) { InputTokens = SumNullable(InputTokens, result.InputTokens); OutputTokens = SumNullable(OutputTokens, result.OutputTokens); } private static int? SumNullable(int? current, int? next) => current.HasValue || next.HasValue ? (current ?? 0) + (next ?? 0) : null; } }