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, IStoryIntelligencePipelineRepository pipelines, IStoryPromptRepository prompts, IStoryPromptVersionService versions, IStoryPromptBuilder scenePromptBuilder, IStoryIntelligenceClient client, IChapterStructureValidator chapterValidator, IStorySceneValidator sceneValidator, IOptions options, IOptions pricingOptions, IStoryIntelligenceProgressNotifier notifier, IStoryMemoryService storyMemory, 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 { await PublishAsync( run, "Import started", StoryIntelligenceRunStatuses.Running, StoryIntelligenceFailureStages.DocumentRead, "Story Intelligence has started reading this chapter.", stopwatch.ElapsedMilliseconds); 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); await PublishAsync( run, "Chapter started", StoryIntelligenceRunStatuses.Running, StoryIntelligenceFailureStages.ChapterStructure, "PlotDirector is finding the scene boundaries in this chapter.", 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); await PublishAsync( run, "Scene boundaries detected", StoryIntelligenceRunStatuses.Running, StoryIntelligenceFailureStages.SceneSplit, $"Detected {sceneBlocks.Count:N0} suggested scene(s).", stopwatch.ElapsedMilliseconds, totalDetectedScenes: sceneBlocks.Count, completedScenes: completedScenes, failedScenes: failedScenes, totalTokens: totals.TotalTokens); 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."); await PublishAsync( run, "Scene analysis failed", StoryIntelligenceRunStatuses.Running, StoryIntelligenceFailureStages.SceneIntelligence, $"Suggested scene {block.TemporarySceneNumber:N0} could not be analysed because its paragraph range was invalid.", stopwatch.ElapsedMilliseconds, totalDetectedScenes: sceneBlocks.Count, completedScenes: completedScenes, failedScenes: failedScenes, totalTokens: totals.TotalTokens); 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); await PublishAsync( run, "Scene analysis started", StoryIntelligenceRunStatuses.Running, StoryIntelligenceFailureStages.SceneIntelligence, $"Reading scene {block.TemporarySceneNumber:N0}.", stopwatch.ElapsedMilliseconds, totalDetectedScenes: sceneBlocks.Count, completedScenes: completedScenes, failedScenes: failedScenes, totalTokens: totals.TotalTokens, currentSceneNumber: block.TemporarySceneNumber); 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; var sceneResultId = 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) }); var importSessionId = run.BookID.HasValue ? (await pipelines.GetByBookAsync(run.BookID.Value))?.StoryIntelligenceBookPipelineID : null; logger.LogInformation( "Story Intelligence importer synchronisation: import session {ImportSessionId}; run {RunId}; current chapter {ChapterNumber}; current scene {SceneNumber}; scene result {SceneResultId}; snapshot version ImportPipeline; change token n/a; status analysed.", importSessionId, run.StoryIntelligenceRunID, run.ChapterNumber, block.TemporarySceneNumber, sceneResultId); if (importSessionId.HasValue) { try { await storyMemory.ProcessSceneResultAsync( importSessionId.Value, run.UserID, sceneResultId, new StoryMemoryResolveOptions { QueueDemand = true, MaxDemandToQueue = 24 }, cancellationToken); } catch (Exception ex) when (ex is not OperationCanceledException && !IsFatal(ex)) { logger.LogError(ex, "Story Memory processing failed for import session {ImportSessionId}, scene result {SceneResultId}.", importSessionId, sceneResultId); } } completedScenes++; await PublishAsync( run, "Scene analysis completed", StoryIntelligenceRunStatuses.Running, StoryIntelligenceFailureStages.SceneIntelligence, $"Scene {block.TemporarySceneNumber:N0} analysed.", stopwatch.ElapsedMilliseconds, totalDetectedScenes: sceneBlocks.Count, completedScenes: completedScenes, failedScenes: failedScenes, totalTokens: totals.TotalTokens, currentSceneNumber: block.TemporarySceneNumber, latestSceneSummary: SceneSummaryText(sceneAttempt.Parsed), recentDiscoveries: BuildDiscoveries(sceneAttempt.Parsed)); 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); await PublishAsync( run, "Scene analysis failed", StoryIntelligenceRunStatuses.Running, StoryIntelligenceFailureStages.SceneIntelligence, $"Scene {block.TemporarySceneNumber:N0} needs attention.", stopwatch.ElapsedMilliseconds, totalDetectedScenes: sceneBlocks.Count, completedScenes: completedScenes, failedScenes: failedScenes, totalTokens: totals.TotalTokens, currentSceneNumber: block.TemporarySceneNumber, errorMessage: 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: 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); await PublishAsync( run, finalStatus == StoryIntelligenceRunStatuses.Completed ? "Import completed" : "Chapter completed", finalStatus, StoryIntelligenceRunStatuses.Completed, finalStatus == StoryIntelligenceRunStatuses.Completed ? "Chapter analysis complete." : "Chapter analysis complete with notes to review.", stopwatch.ElapsedMilliseconds, totalDetectedScenes: sceneBlocks.Count, completedScenes: completedScenes, failedScenes: failedScenes, totalTokens: totals.TotalTokens); } catch (StoryIntelligenceRunCancelledException) { await repository.CancelRunAsync(run.StoryIntelligenceRunID, stopwatch.ElapsedMilliseconds); await PublishAsync( run, "Import cancelled", StoryIntelligenceRunStatuses.Cancelled, currentFailureStage, "Analysis was cancelled.", 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); await PublishAsync( run, "Import failed", StoryIntelligenceRunStatuses.Failed, currentFailureStage, "Analysis could not finish.", stopwatch.ElapsedMilliseconds, errorMessage: ex.Message); } catch (StoryIntelligenceRunFailureException ex) { await repository.FailRunAsync(run.StoryIntelligenceRunID, ex.FailureStage, ex.Message, ex.Detail, stopwatch.ElapsedMilliseconds); await PublishAsync( run, "Import failed", StoryIntelligenceRunStatuses.Failed, ex.FailureStage, "Analysis could not finish.", stopwatch.ElapsedMilliseconds, errorMessage: ex.Message); } catch (Exception ex) when (ex is not OperationCanceledException) { await repository.FailRunAsync( run.StoryIntelligenceRunID, ClassifyFailureStage(ex), ex.Message, ex.ToString(), stopwatch.ElapsedMilliseconds); await PublishAsync( run, "Import failed", StoryIntelligenceRunStatuses.Failed, ClassifyFailureStage(ex), "Analysis could not finish.", stopwatch.ElapsedMilliseconds, errorMessage: ex.Message); } } 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 Task PublishAsync( StoryIntelligenceQueuedRun run, string eventType, string status, string currentStage, string currentMessage, long elapsedMs, int? totalDetectedScenes = null, int? completedScenes = null, int? failedScenes = null, int? totalTokens = null, int? currentSceneNumber = null, string? latestSceneSummary = null, IReadOnlyList? recentDiscoveries = null, string? errorMessage = null) => notifier.PublishAsync(new StoryIntelligenceRunProgressEvent { RunID = run.StoryIntelligenceRunID, UserID = run.UserID, ProjectID = run.ProjectID, BookID = run.BookID, ChapterID = run.ChapterID, ChapterNumber = run.ChapterNumber, Status = status, CurrentStage = currentStage, CurrentMessage = currentMessage, EventType = eventType, TotalDetectedScenes = totalDetectedScenes, CompletedScenes = completedScenes, FailedScenes = failedScenes, TotalTokens = totalTokens, TotalDurationMs = elapsedMs, CurrentSceneNumber = currentSceneNumber, ElapsedTime = FormatDuration(elapsedMs), EstimatedRemaining = EstimateRemaining(elapsedMs, completedScenes ?? 0, totalDetectedScenes ?? 0), LatestSceneSummary = latestSceneSummary, RecentDiscoveries = recentDiscoveries ?? [], ErrorMessage = errorMessage, UpdatedUtc = DateTime.UtcNow, IsActive = status is StoryIntelligenceRunStatuses.Pending or StoryIntelligenceRunStatuses.Running, IsCompleted = status is StoryIntelligenceRunStatuses.Completed or StoryIntelligenceRunStatuses.CompletedWithWarnings }); private static string FormatDuration(long? milliseconds) { var elapsed = TimeSpan.FromMilliseconds(Math.Max(0, milliseconds ?? 0)); if (elapsed.TotalMinutes >= 1) { return $"{Math.Max(1, (int)Math.Round(elapsed.TotalMinutes)):N0} min"; } return $"{Math.Max(0, (int)Math.Round(elapsed.TotalSeconds)):N0} sec"; } private static string? EstimateRemaining(long elapsedMs, int completedScenes, int totalScenes) { if (totalScenes > 0 && completedScenes >= totalScenes) { return "Nearly finished"; } if (completedScenes <= 0 || totalScenes <= 0) { return "Calculating"; } var remainingScenes = Math.Max(0, totalScenes - completedScenes); var averageSceneMs = elapsedMs / Math.Max(1, completedScenes); return FormatDuration(averageSceneMs * remainingScenes); } private static IReadOnlyList BuildDiscoveries(SceneIntelligenceScene? scene) { if (scene is null) { return []; } var discoveries = new List(); discoveries.AddRange((scene.Characters ?? []) .Select(character => TrimOptional(character.Name, 120)) .Where(name => !string.IsNullOrWhiteSpace(name)) .Distinct(StringComparer.OrdinalIgnoreCase) .Take(3) .Select(name => $"Possible character found: {name}")); discoveries.AddRange((scene.Locations ?? []) .Select(location => TrimOptional(location.Name, 120)) .Where(name => !string.IsNullOrWhiteSpace(name)) .Distinct(StringComparer.OrdinalIgnoreCase) .Take(3) .Select(name => $"Possible location found: {name}")); discoveries.AddRange((scene.Assets ?? []) .Select(asset => TrimOptional(asset.Name, 120)) .Where(name => !string.IsNullOrWhiteSpace(name)) .Distinct(StringComparer.OrdinalIgnoreCase) .Take(2) .Select(name => $"Possible asset found: {name}")); discoveries.AddRange((scene.Relationships ?? []) .Select(relationship => BuildRelationshipDiscovery(relationship)) .Where(text => !string.IsNullOrWhiteSpace(text)) .OfType() .Take(2)); discoveries.AddRange((scene.TimelineClues ?? []) .Select(clue => TrimOptional(clue.Clue, 160)) .Where(text => !string.IsNullOrWhiteSpace(text)) .Take(2) .Select(text => $"Timeline clue found: {text}")); return discoveries .Distinct(StringComparer.OrdinalIgnoreCase) .Take(8) .ToList(); } private static string? BuildRelationshipDiscovery(SceneIntelligenceRelationship relationship) { var characterA = TrimOptional(relationship.CharacterA, 80); var characterB = TrimOptional(relationship.CharacterB, 80); var signal = TrimOptional(relationship.RelationshipSignal, 120); if (string.IsNullOrWhiteSpace(characterA) || string.IsNullOrWhiteSpace(characterB)) { return null; } return string.IsNullOrWhiteSpace(signal) ? $"Relationship signal found: {characterA} and {characterB}" : $"Relationship signal found: {characterA} and {characterB} - {signal}"; } private static string? SceneSummaryText(SceneIntelligenceScene? scene) => TrimOptional( FirstConfigured(scene?.Summary?.Short, scene?.Summary?.Detailed), 220); private static string? TrimOptional(string? value, int maxLength) { if (string.IsNullOrWhiteSpace(value)) { return null; } var trimmed = value.Trim(); return trimmed.Length <= maxLength ? trimmed : trimmed[..maxLength].TrimEnd(); } private static string? FirstConfigured(params string?[] values) => values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value)); 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) { var sceneResultId = 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) }); var importSessionId = run.BookID.HasValue ? (await pipelines.GetByBookAsync(run.BookID.Value))?.StoryIntelligenceBookPipelineID : null; logger.LogInformation( "Story Intelligence importer synchronisation: import session {ImportSessionId}; run {RunId}; current chapter {ChapterNumber}; current scene {SceneNumber}; scene result {SceneResultId}; snapshot version ImportPipeline; change token n/a; status failed.", importSessionId, run.StoryIntelligenceRunID, run.ChapterNumber, block.TemporarySceneNumber, sceneResultId); } 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; } } }