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 options, IOptions pricingOptions, ILogger logger) : IPersistedStoryIntelligenceRunner { private const string ChapterPromptFile = "Chapter-Structure-Prompt-V2.md"; private const string ScenePromptFile = "Scene-Prompt-V2.md"; private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, PropertyNameCaseInsensitive = true, WriteIndented = true }; private readonly StoryIntelligenceOptions settings = options.Value; 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); var stageModels = ResolveStageModels(run); int? chapterResultId = null; var failedScenes = 0; var completedScenes = 0; var hasWarnings = false; var currentFailureStage = StoryIntelligenceFailureStages.DocumentRead; 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 = StoryIntelligenceParagraphs.Split(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 numberedChapterText = StoryIntelligenceParagraphs.Number(paragraphs); var chapterPrompt = BuildChapterPrompt(chapterTemplate, chapterContextJson, numberedChapterText); currentFailureStage = StoryIntelligenceFailureStages.ChapterStructure; await repository.UpdateProgressAsync( run.StoryIntelligenceRunID, currentStage: StoryIntelligenceFailureStages.ChapterStructure, currentMessage: "Running Chapter Structure analysis.", totalDurationMs: stopwatch.ElapsedMilliseconds); var chapterClientResult = await client.ExecutePromptAsync( chapterPrompt, chapterPromptVersion, cancellationToken, stageModels.ChapterStructureModel); totals.Add(chapterClientResult, pricing); AiJsonParseResult chapterAttempt; try { chapterAttempt = await ParseAiJsonWithRetryAsync( chapterPrompt, chapterPromptVersion, stageModels.ChapterStructureModel, chapterClientResult, totals, "Chapter Structure", cancellationToken); } catch (AiJsonParseException ex) { 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 = stageModels.ChapterStructureModel, RawResponseJson = ex.RawResponseJson, OutputTextJson = ex.RawAssistantOutput, ParsedJson = SerialiseFailedAiJsonParse(ex), ValidationErrorsCount = 1, ValidationWarningsCount = ex.RetryAttempted ? 1 : 0, InputTokens = ex.InputTokens, OutputTokens = ex.OutputTokens, TotalTokens = SumTokens(ex.InputTokens, ex.OutputTokens), DurationMs = Convert.ToInt64(ex.Duration.TotalMilliseconds) }); throw new StoryIntelligenceRunFailureException( StoryIntelligenceFailureStages.Validation, "Chapter Structure JSON could not be parsed after one retry.", ex.ToString()); } var parsedChapter = chapterAttempt.Parsed; var chapterNormalisation = ChapterStructureBoundaryNormaliser.Normalise(parsedChapter, paragraphs.Count); var chapterForProcessing = chapterNormalisation.ChapterStructure; var chapterValidation = chapterValidator.Validate(chapterForProcessing, paragraphs.Count); foreach (var warning in chapterAttempt.Warnings) { chapterValidation.Warnings.Add(new PlotLine.Models.StoryIntelligence.ValidationIssue { Severity = "Warning", Path = "$", Message = warning, SuggestedFix = "Review the raw and repaired Chapter Structure JSON." }); } ChapterStructureBoundaryNormaliser.AddIssuesTo(chapterValidation, chapterNormalisation); 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 = chapterAttempt.Model, RawResponseJson = chapterAttempt.RawResponseJson, OutputTextJson = chapterAttempt.RawAssistantOutput, ParsedJson = SerialiseChapterParseAudit(chapterForProcessing, chapterNormalisation, chapterAttempt), ValidationErrorsCount = chapterValidation.Errors.Count, ValidationWarningsCount = chapterValidation.Warnings.Count, InputTokens = chapterAttempt.InputTokens, OutputTokens = chapterAttempt.OutputTokens, TotalTokens = SumTokens(chapterAttempt.InputTokens, chapterAttempt.OutputTokens), DurationMs = Convert.ToInt64(chapterAttempt.Duration.TotalMilliseconds) }); if (!chapterValidation.IsValid || chapterForProcessing?.SceneBoundaries is null) { throw new StoryIntelligenceRunFailureException( StoryIntelligenceFailureStages.ChapterStructure, "Chapter Structure validation failed.", BuildValidationDetail(chapterValidation)); } var sceneBlocks = chapterForProcessing.SceneBoundaries .Select(boundary => BuildSceneBlock(run, paragraphs, boundary)) .ToList(); failedScenes += sceneBlocks.Count(block => !block.SplitValid); currentFailureStage = StoryIntelligenceFailureStages.SceneSplit; 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: totals.EstimatedCostUSD, estimatedCostGBP: totals.EstimatedCostGBP); 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 { currentFailureStage = StoryIntelligenceFailureStages.SceneIntelligence; var completedScenePrompt = scenePromptBuilder.BuildPrompt(sceneTemplate, block.SceneContextJson, block.SceneText); var sceneClientResult = await client.ExecutePromptAsync( completedScenePrompt, scenePromptVersion, cancellationToken, stageModels.SceneIntelligenceModel, SceneIntelligenceMaxOutputTokens()); totals.Add(sceneClientResult, pricing); var sceneAttempt = await ParseAiJsonWithRetryAsync( completedScenePrompt, scenePromptVersion, stageModels.SceneIntelligenceModel, sceneClientResult, totals, "Scene Intelligence", cancellationToken); var validation = sceneValidator.Validate(sceneAttempt.Parsed); foreach (var warning in sceneAttempt.Warnings) { validation.Warnings.Add(new PlotLine.Models.StoryIntelligence.ValidationIssue { Severity = "Warning", Path = "$", Message = warning, SuggestedFix = "Review the raw and repaired Scene Intelligence JSON." }); } 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 = sceneAttempt.RawResponseJson, OutputTextJson = sceneAttempt.RawAssistantOutput, ParsedJson = SerialiseSceneParseAudit(sceneAttempt, validation.IsValid), ValidationErrorsCount = validation.Errors.Count, ValidationWarningsCount = validation.Warnings.Count, InputTokens = sceneAttempt.InputTokens, OutputTokens = sceneAttempt.OutputTokens, TotalTokens = SumTokens(sceneAttempt.InputTokens, sceneAttempt.OutputTokens), DurationMs = Convert.ToInt64(sceneAttempt.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, ex as AiJsonParseException); 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: totals.EstimatedCostUSD, estimatedCostGBP: totals.EstimatedCostGBP); } var finalStatus = failedScenes > 0 || hasWarnings ? StoryIntelligenceRunStatuses.CompletedWithWarnings : StoryIntelligenceRunStatuses.Completed; await repository.CompleteRunAsync( run.StoryIntelligenceRunID, finalStatus, totals.InputTokens, totals.OutputTokens, totals.TotalTokens, stopwatch.ElapsedMilliseconds, totals.EstimatedCostGBP, totals.EstimatedCostUSD); } catch (StoryIntelligenceRunCancelledException) { await repository.CancelRunAsync(run.StoryIntelligenceRunID, stopwatch.ElapsedMilliseconds); } catch (OperationCanceledException ex) when (!cancellationToken.IsCancellationRequested) { await repository.FailRunAsync( run.StoryIntelligenceRunID, currentFailureStage, "Story Intelligence request timed out or was cancelled before the run completed.", ex.ToString(), 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 StoryIntelligenceRunCancelledException(); } } private async Task> ParseAiJsonWithRetryAsync( string completedPrompt, string promptVersion, string model, StoryIntelligenceClientResult initialClientResult, TokenTotals totals, string label, CancellationToken cancellationToken) { var initialRawOutput = TryExtractOutputText(initialClientResult.RawResponseText, out var initialExtractError); var initialParse = StoryIntelligenceAiJsonParser.Parse(initialRawOutput, JsonOptions); if (initialParse.Success) { return AiJsonParseResult.Success( initialClientResult.Model, initialClientResult.RawResponseText, initialRawOutput, initialParse.RawJson, initialParse.RepairedJson, initialParse.Parsed, initialClientResult.InputTokens, initialClientResult.OutputTokens, initialClientResult.Duration, retryAttempted: false, warnings: initialParse.Warnings); } var initialError = initialExtractError ?? initialParse.ErrorMessage ?? $"{label} JSON could not be parsed."; var retryPrompt = BuildJsonRetryPrompt(completedPrompt, label, initialError, initialRawOutput); var retryClientResult = await client.ExecutePromptAsync( retryPrompt, promptVersion, cancellationToken, model, MaxOutputTokensForLabel(label)); totals.Add(retryClientResult, pricing); var retryRawOutput = TryExtractOutputText(retryClientResult.RawResponseText, out var retryExtractError); var retryParse = StoryIntelligenceAiJsonParser.Parse(retryRawOutput, JsonOptions); if (retryParse.Success) { var warnings = new List { $"{label} JSON parse failed once and succeeded after one retry." }; warnings.AddRange(initialParse.Warnings); warnings.AddRange(retryParse.Warnings); return AiJsonParseResult.Success( retryClientResult.Model, CombineRawResponses(initialClientResult.RawResponseText, retryClientResult.RawResponseText), CombineAssistantOutputs(initialRawOutput, retryRawOutput), retryParse.RawJson, retryParse.RepairedJson, retryParse.Parsed, SumTokens(initialClientResult.InputTokens, retryClientResult.InputTokens), SumTokens(initialClientResult.OutputTokens, retryClientResult.OutputTokens), initialClientResult.Duration + retryClientResult.Duration, retryAttempted: true, warnings: warnings, initialError: initialError); } var retryError = retryExtractError ?? retryParse.ErrorMessage ?? $"{label} retry JSON could not be parsed."; throw new AiJsonParseException( $"{label} JSON could not be parsed after one retry.", CombineRawResponses(initialClientResult.RawResponseText, retryClientResult.RawResponseText), CombineAssistantOutputs(initialRawOutput, retryRawOutput), initialError, retryAttempted: true, retryError: retryError, inputTokens: SumTokens(initialClientResult.InputTokens, retryClientResult.InputTokens), outputTokens: SumTokens(initialClientResult.OutputTokens, retryClientResult.OutputTokens), duration: initialClientResult.Duration + retryClientResult.Duration); } private static string TryExtractOutputText(string rawResponseText, out string? error) { try { error = null; return ExtractOutputText(rawResponseText); } catch (JsonException ex) { error = ex.Message; return string.Empty; } } private async Task PersistFailedSceneAsync( StoryIntelligenceQueuedRun run, int? chapterResultId, StorySceneTextBlock block, string scenePromptVersion, string message, AiJsonParseException? parseException = null) { 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 = ResolveStageModels(run).SceneIntelligenceModel, RawResponseJson = parseException?.RawResponseJson ?? string.Empty, OutputTextJson = parseException?.RawAssistantOutput ?? string.Empty, ParsedJson = JsonSerializer.Serialize(new { error = message, parseError = parseException?.InitialError, retryAttempted = parseException?.RetryAttempted ?? false, retryError = parseException?.RetryError }, JsonOptions), ValidationErrorsCount = 1, ValidationWarningsCount = parseException?.RetryAttempted == true ? 1 : 0, InputTokens = parseException?.InputTokens, OutputTokens = parseException?.OutputTokens, TotalTokens = SumTokens(parseException?.InputTokens, parseException?.OutputTokens), DurationMs = parseException is null ? null : Convert.ToInt64(parseException.Duration.TotalMilliseconds) }); } private StoryIntelligenceStageModels ResolveStageModels(StoryIntelligenceQueuedRun run) => StoryIntelligenceOptions.ParseModelSummary( run.Model, string.IsNullOrWhiteSpace(settings.EffectiveChapterStructureModel) ? settings.EffectiveModel : settings.EffectiveChapterStructureModel, string.IsNullOrWhiteSpace(settings.EffectiveSceneIntelligenceModel) ? settings.EffectiveModel : settings.EffectiveSceneIntelligenceModel); private int SceneIntelligenceMaxOutputTokens() => settings.SceneIntelligenceMaxOutputTokens.GetValueOrDefault(settings.MaxOutputTokens) > 0 ? settings.SceneIntelligenceMaxOutputTokens.GetValueOrDefault(settings.MaxOutputTokens) : settings.MaxOutputTokens; private int MaxOutputTokensForLabel(string label) => label.Contains("Scene", StringComparison.OrdinalIgnoreCase) ? SceneIntelligenceMaxOutputTokens() : settings.MaxOutputTokens; 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 string SerializeParsed(T? value) => value is null ? string.Empty : JsonSerializer.Serialize(value, JsonOptions); private static string SerialiseChapterParseAudit( ChapterStructureModel? chapterStructure, ChapterStructureBoundaryNormalisationResult normalisation, AiJsonParseResult parseResult) { if (!parseResult.RepairApplied && !parseResult.RetryAttempted && normalisation.Issues.Count == 0) { return SerializeParsed(chapterStructure); } return JsonSerializer.Serialize(new { chapterStructure?.SchemaVersion, chapterStructure?.ChapterSummary, chapterStructure?.SceneBoundaries, repairedJson = parseResult.RepairApplied ? parseResult.RepairedJson : null, repairApplied = parseResult.RepairApplied, retryAttempted = parseResult.RetryAttempted, initialParseError = parseResult.InitialError, jsonWarnings = parseResult.Warnings, normalisation = normalisation.Issues.Select(issue => new { issue.Severity, issue.Path, issue.Message, issue.SuggestedFix }) }, JsonOptions); } private static string SerialiseSceneParseAudit(AiJsonParseResult parseResult, bool validationPassed) { if (!parseResult.RepairApplied && !parseResult.RetryAttempted) { return SerializeParsed(parseResult.Parsed); } return JsonSerializer.Serialize(new { parsedScene = parseResult.Parsed, repairedJson = parseResult.RepairApplied ? parseResult.RepairedJson : null, repairApplied = parseResult.RepairApplied, retryAttempted = parseResult.RetryAttempted, initialParseError = parseResult.InitialError, validationPassed, warnings = parseResult.Warnings }, JsonOptions); } private static string SerialiseFailedAiJsonParse(AiJsonParseException parseException) => JsonSerializer.Serialize(new { error = parseException.Message, parseError = parseException.InitialError, retryAttempted = parseException.RetryAttempted, retryError = parseException.RetryError }, JsonOptions); private static string BuildJsonRetryPrompt(string originalPrompt, string label, string parseError, string malformedJson) => string.Concat( originalPrompt, Environment.NewLine, Environment.NewLine, "## JSON Repair Retry", Environment.NewLine, $"Your previous response was not valid JSON. Return the same {label} object as valid JSON only. Do not include markdown. Do not truncate. All numeric confidence values must be valid JSON numbers such as 0.9 or 0.0.", Environment.NewLine, "Parse error:", Environment.NewLine, parseError, Environment.NewLine, "Malformed JSON returned previously:", Environment.NewLine, "```json", Environment.NewLine, malformedJson, Environment.NewLine, "```"); private static string CombineRawResponses(string initialRawResponse, string retryRawResponse) => JsonSerializer.Serialize(new { initialRawResponseJson = initialRawResponse, retryRawResponseJson = retryRawResponse }, JsonOptions); private static string CombineAssistantOutputs(string initialOutput, string retryOutput) => JsonSerializer.Serialize(new { initialAssistantOutput = initialOutput, retryAssistantOutput = retryOutput }, 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 class AiJsonParseException( string message, string rawResponseJson, string rawAssistantOutput, string initialError, bool retryAttempted, string? retryError, int? inputTokens, int? outputTokens, TimeSpan duration) : Exception(message) { public string RawResponseJson { get; } = rawResponseJson; public string RawAssistantOutput { get; } = rawAssistantOutput; public string InitialError { get; } = initialError; public bool RetryAttempted { get; } = retryAttempted; public string? RetryError { get; } = retryError; public int? InputTokens { get; } = inputTokens; public int? OutputTokens { get; } = outputTokens; public TimeSpan Duration { get; } = duration; } private sealed record AiJsonParseResult( string Model, string RawResponseJson, string RawAssistantOutput, string RawJson, string? RepairedJson, T? Parsed, int? InputTokens, int? OutputTokens, TimeSpan Duration, bool RetryAttempted, IReadOnlyList Warnings, string? InitialError) { public bool RepairApplied => Warnings.Any(warning => warning.Contains("markdown", StringComparison.OrdinalIgnoreCase) || warning.Contains("Normalised", StringComparison.OrdinalIgnoreCase) || warning.Contains("Escaped", StringComparison.OrdinalIgnoreCase) || warning.Contains("Trimmed", StringComparison.OrdinalIgnoreCase)); public static AiJsonParseResult Success( string model, string rawResponseJson, string rawAssistantOutput, string rawJson, string? repairedJson, T? parsed, int? inputTokens, int? outputTokens, TimeSpan duration, bool retryAttempted, IReadOnlyList warnings, string? initialError = null) => new( model, rawResponseJson, rawAssistantOutput, rawJson, repairedJson, parsed, inputTokens, outputTokens, duration, retryAttempted, warnings, initialError); } private sealed class StoryIntelligenceRunCancelledException : Exception { } 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 decimal? EstimatedCostUSD { get; private set; } public decimal? EstimatedCostGBP { get; private set; } public void Add(StoryIntelligenceClientResult result, StoryIntelligencePricingOptions pricing) { InputTokens = SumNullable(InputTokens, result.InputTokens); OutputTokens = SumNullable(OutputTokens, result.OutputTokens); AddCost(result, pricing); } private static int? SumNullable(int? current, int? next) => current.HasValue || next.HasValue ? (current ?? 0) + (next ?? 0) : null; private void AddCost(StoryIntelligenceClientResult result, StoryIntelligencePricingOptions pricing) { var modelPricing = pricing.GetPricingForModel(result.Model); if (modelPricing?.InputPerMillionUsd is not decimal inputPrice || modelPricing.OutputPerMillionUsd is not decimal outputPrice || result.InputTokens is not int inputTokens || result.OutputTokens is not int outputTokens) { EstimatedCostUSD = null; EstimatedCostGBP = null; return; } var requestUsd = (inputTokens / 1_000_000m * inputPrice) + (outputTokens / 1_000_000m * outputPrice); EstimatedCostUSD = (EstimatedCostUSD ?? 0m) + requestUsd; EstimatedCostGBP = pricing.UsdToGbpRate.HasValue ? (EstimatedCostGBP ?? 0m) + (requestUsd * pricing.UsdToGbpRate.Value) : null; } } }