using System.Diagnostics; using System.Text.Json; using PlotLine.Models; using PlotLine.ViewModels; namespace PlotLine.Services; public interface IChapterStoryIntelligenceDryRunService { ChapterStoryIntelligenceDryRunViewModel GetDefault(); Task ExecuteAsync(ChapterStoryIntelligenceDryRunForm form, CancellationToken cancellationToken); } public sealed class ChapterStoryIntelligenceDryRunService( IStoryPromptRepository prompts, IStoryPromptVersionService versions, IStoryPromptBuilder scenePromptBuilder, IStoryIntelligenceClient client, IChapterStructureValidator chapterValidator, IStorySceneValidator sceneValidator, ILogger logger) : IChapterStoryIntelligenceDryRunService { 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 }; public ChapterStoryIntelligenceDryRunViewModel GetDefault() => new(); public async Task ExecuteAsync(ChapterStoryIntelligenceDryRunForm form, CancellationToken cancellationToken) { var model = new ChapterStoryIntelligenceDryRunViewModel { Form = form }; var totalStopwatch = Stopwatch.StartNew(); var chapterPromptVersion = versions.GetPromptVersion(ChapterPromptFile); var scenePromptVersion = versions.GetPromptVersion(ScenePromptFile); var clientStatus = client.GetConfigurationStatus(); var paragraphs = StoryIntelligenceParagraphs.Split(form.ChapterText); var chapterContextJson = BuildChapterContextJson(form); try { if (paragraphs.Count == 0) { throw new InvalidOperationException("Enter fictional chapter text before running the combined pipeline test."); } var chapterTemplate = await prompts.LoadPromptAsync(ChapterPromptFile, cancellationToken); var numberedChapterText = StoryIntelligenceParagraphs.Number(paragraphs); var chapterPrompt = BuildChapterPrompt(chapterTemplate, chapterContextJson, numberedChapterText); var chapterResult = await client.ExecutePromptAsync( chapterPrompt, chapterPromptVersion, cancellationToken, clientStatus.ChapterStructureModel); var chapterJson = ExtractOutputText(chapterResult.RawResponseText); var parsedChapter = JsonSerializer.Deserialize(chapterJson, JsonOptions); var chapterNormalisation = ChapterStructureBoundaryNormaliser.Normalise(parsedChapter, paragraphs.Count); var chapterForProcessing = chapterNormalisation.ChapterStructure; var chapterValidation = chapterValidator.Validate(chapterForProcessing, paragraphs.Count); ChapterStructureBoundaryNormaliser.AddIssuesTo(chapterValidation, chapterNormalisation); var sceneBlocks = new List(); if (!chapterValidation.IsValid || chapterForProcessing?.SceneBoundaries is null) { totalStopwatch.Stop(); var stoppedResult = new ChapterStoryIntelligenceDryRunResultViewModel { Success = false, PipelineStopped = true, StopReason = "Chapter Structure validation failed. Scene Intelligence was not run.", ChapterPromptVersion = chapterPromptVersion, ScenePromptVersion = scenePromptVersion, Model = StoryIntelligenceOptions.BuildModelSummary( chapterResult.Model, clientStatus.SceneIntelligenceModel), ChapterContextJson = chapterContextJson, ParagraphCount = paragraphs.Count, TotalDuration = FormatDuration(totalStopwatch.Elapsed), TotalDurationMs = totalStopwatch.ElapsedMilliseconds, TotalInputTokens = chapterResult.InputTokens, TotalOutputTokens = chapterResult.OutputTokens, ChapterDuration = FormatDuration(chapterResult.Duration), ChapterDurationMs = Convert.ToInt64(chapterResult.Duration.TotalMilliseconds), ChapterRetryCount = chapterResult.RetryCount, ChapterInputTokens = chapterResult.InputTokens, ChapterOutputTokens = chapterResult.OutputTokens, ChapterRawResponseText = chapterResult.RawResponseText, ChapterJsonText = chapterJson, ParsedChapter = chapterForProcessing, ChapterValidation = chapterValidation, SceneBlocks = sceneBlocks }; LogPipelineResult(stoppedResult); model.Result = stoppedResult; return model; } var sceneTemplate = await prompts.LoadPromptAsync(ScenePromptFile, cancellationToken); foreach (var boundary in chapterForProcessing.SceneBoundaries) { var block = BuildSceneBlock(form, paragraphs, boundary); if (!block.SplitValid) { sceneBlocks.Add(block); continue; } var sceneContextJson = BuildSceneContextJson(form, boundary); var completedScenePrompt = scenePromptBuilder.BuildPrompt(sceneTemplate, sceneContextJson, block.SceneText); sceneBlocks.Add(await ExecuteSceneAsync( block, sceneContextJson, completedScenePrompt, scenePromptVersion, clientStatus.SceneIntelligenceModel, cancellationToken)); } totalStopwatch.Stop(); var finalResult = new ChapterStoryIntelligenceDryRunResultViewModel { Success = sceneBlocks.All(block => block.Success || !block.SplitValid), PipelineStopped = false, ChapterPromptVersion = chapterPromptVersion, ScenePromptVersion = scenePromptVersion, Model = StoryIntelligenceOptions.BuildModelSummary( chapterResult.Model, clientStatus.SceneIntelligenceModel), ChapterContextJson = chapterContextJson, ParagraphCount = paragraphs.Count, TotalDuration = FormatDuration(totalStopwatch.Elapsed), TotalDurationMs = totalStopwatch.ElapsedMilliseconds, TotalInputTokens = SumTokens(chapterResult.InputTokens, sceneBlocks.Select(block => block.InputTokens)), TotalOutputTokens = SumTokens(chapterResult.OutputTokens, sceneBlocks.Select(block => block.OutputTokens)), ChapterDuration = FormatDuration(chapterResult.Duration), ChapterDurationMs = Convert.ToInt64(chapterResult.Duration.TotalMilliseconds), ChapterRetryCount = chapterResult.RetryCount, ChapterInputTokens = chapterResult.InputTokens, ChapterOutputTokens = chapterResult.OutputTokens, ChapterRawResponseText = chapterResult.RawResponseText, ChapterJsonText = chapterJson, ParsedChapter = chapterForProcessing, ChapterValidation = chapterValidation, SceneBlocks = sceneBlocks }; LogPipelineResult(finalResult); model.Result = finalResult; } catch (Exception ex) when (ex is not OperationCanceledException) { totalStopwatch.Stop(); model.Result = new ChapterStoryIntelligenceDryRunResultViewModel { Success = false, PipelineStopped = true, StopReason = "The combined pipeline could not complete.", ChapterPromptVersion = chapterPromptVersion, ScenePromptVersion = scenePromptVersion, Model = StoryIntelligenceOptions.BuildModelSummary( clientStatus.ChapterStructureModel, clientStatus.SceneIntelligenceModel), ChapterContextJson = chapterContextJson, ParagraphCount = paragraphs.Count, TotalDuration = FormatDuration(totalStopwatch.Elapsed), TotalDurationMs = totalStopwatch.ElapsedMilliseconds, ErrorMessage = ex.Message }; logger.LogWarning( ex, "Admin Chapter Story Intelligence pipeline failed. ChapterPromptVersion={ChapterPromptVersion} ScenePromptVersion={ScenePromptVersion} Model={Model} TotalDurationMs={TotalDurationMs} Success={Success}", chapterPromptVersion, scenePromptVersion, StoryIntelligenceOptions.BuildModelSummary( clientStatus.ChapterStructureModel, clientStatus.SceneIntelligenceModel), totalStopwatch.ElapsedMilliseconds, false); } return model; } private async Task ExecuteSceneAsync( ChapterStorySceneBlockViewModel block, string sceneContextJson, string completedScenePrompt, string scenePromptVersion, string sceneModel, CancellationToken cancellationToken) { try { var result = await client.ExecutePromptAsync( completedScenePrompt, scenePromptVersion, cancellationToken, sceneModel); var sceneJson = ExtractOutputText(result.RawResponseText); var parsedScene = JsonSerializer.Deserialize(sceneJson, JsonOptions); var validation = sceneValidator.Validate(parsedScene); logger.LogInformation( "Admin Chapter Story Intelligence scene completed. TemporarySceneNumber={TemporarySceneNumber} DurationMs={DurationMs} InputTokens={InputTokens} OutputTokens={OutputTokens} ValidationPassed={ValidationPassed} ValidationWarnings={ValidationWarnings} ValidationErrors={ValidationErrors} Success={Success}", block.TemporarySceneNumber, result.Duration.TotalMilliseconds, result.InputTokens, result.OutputTokens, validation.IsValid, validation.Warnings.Count, validation.Errors.Count, true); return block with { Success = true, SceneContextJson = sceneContextJson, Duration = FormatDuration(result.Duration), DurationMs = Convert.ToInt64(result.Duration.TotalMilliseconds), RetryCount = result.RetryCount, InputTokens = result.InputTokens, OutputTokens = result.OutputTokens, RawResponseText = result.RawResponseText, SceneJsonText = sceneJson, ParsedScene = parsedScene, Validation = validation }; } catch (Exception ex) when (ex is not OperationCanceledException) { logger.LogWarning( ex, "Admin Chapter Story Intelligence scene failed. TemporarySceneNumber={TemporarySceneNumber} Success={Success}", block.TemporarySceneNumber, false); return block with { Success = false, SceneContextJson = sceneContextJson, ErrorMessage = ex.Message }; } } private static ChapterStorySceneBlockViewModel BuildSceneBlock( ChapterStoryIntelligenceDryRunForm form, IReadOnlyList paragraphs, ChapterSceneBoundary boundary) { var sceneNumber = boundary.SceneNumber ?? 0; var start = boundary.StartParagraph ?? 0; var end = boundary.EndParagraph ?? 0; if (sceneNumber <= 0 || start <= 0 || end <= 0 || start > end || end > paragraphs.Count) { return new ChapterStorySceneBlockViewModel { TemporarySceneNumber = sceneNumber, StartParagraph = start, EndParagraph = end, BoundaryReason = boundary.Reason ?? string.Empty, BoundaryConfidence = boundary.Confidence, SplitValid = false, SplitErrorMessage = "Scene boundary range is invalid for the submitted chapter paragraphs." }; } var sceneParagraphs = paragraphs.Skip(start - 1).Take(end - start + 1).ToList(); var sceneText = string.Join(Environment.NewLine + Environment.NewLine, sceneParagraphs); return new ChapterStorySceneBlockViewModel { TemporarySceneNumber = sceneNumber, StartParagraph = start, EndParagraph = end, BoundaryReason = boundary.Reason ?? string.Empty, BoundaryConfidence = boundary.Confidence, SceneText = sceneText, SceneTextPreview = Preview(sceneText), SplitValid = true, SceneContextJson = BuildSceneContextJson(form, boundary) }; } private static string BuildChapterContextJson(ChapterStoryIntelligenceDryRunForm form) => JsonSerializer.Serialize(new { projectId = form.ProjectID, bookId = form.BookID, chapterId = form.ChapterID, chapterNumber = form.ChapterNumber, sourceLabel = string.IsNullOrWhiteSpace(form.SourceLabel) ? "Admin pipeline test chapter" : form.SourceLabel.Trim() }, JsonOptions); private static string BuildSceneContextJson(ChapterStoryIntelligenceDryRunForm form, ChapterSceneBoundary boundary) => JsonSerializer.Serialize(new { projectId = form.ProjectID, bookId = form.BookID, chapterId = form.ChapterID, sceneId = (int?)null, chapterNumber = form.ChapterNumber, sceneNumber = boundary.SceneNumber, sourceLabel = $"{(string.IsNullOrWhiteSpace(form.SourceLabel) ? "Admin pipeline test chapter" : form.SourceLabel.Trim())}, suggested scene {boundary.SceneNumber}" }, JsonOptions); private static string BuildChapterPrompt(string promptTemplate, string chapterContextJson, string chapterText) { if (string.IsNullOrWhiteSpace(promptTemplate)) { throw new InvalidOperationException("Prompt template is empty."); } return 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; if (string.IsNullOrWhiteSpace(outputText)) { throw new JsonException("OpenAI response did not contain output_text content."); } return outputText.Trim(); } private static string Preview(string value) { var cleaned = value.ReplaceLineEndings(" ").Trim(); return cleaned.Length <= 260 ? cleaned : $"{cleaned[..260]}..."; } private static int? SumTokens(int? first, IEnumerable rest) { var values = new[] { first }.Concat(rest).ToList(); return values.Any(value => value.HasValue) ? values.Sum(value => value ?? 0) : null; } private static string FormatDuration(TimeSpan duration) => duration.TotalSeconds < 1 ? $"{duration.TotalMilliseconds:0} ms" : $"{duration.TotalSeconds:0.00} sec"; private void LogPipelineResult(ChapterStoryIntelligenceDryRunResultViewModel result) { logger.LogInformation( "Admin Chapter Story Intelligence pipeline completed. ChapterPromptVersion={ChapterPromptVersion} ScenePromptVersion={ScenePromptVersion} Model={Model} TotalDuration={TotalDuration} TotalInputTokens={TotalInputTokens} TotalOutputTokens={TotalOutputTokens} ChapterValidationErrors={ChapterValidationErrors} ChapterValidationWarnings={ChapterValidationWarnings} SceneCount={SceneCount} SceneValidationErrors={SceneValidationErrors} SceneValidationWarnings={SceneValidationWarnings} Success={Success}", result.ChapterPromptVersion, result.ScenePromptVersion, result.Model, result.TotalDuration, result.TotalInputTokens, result.TotalOutputTokens, result.ChapterValidation?.Errors.Count, result.ChapterValidation?.Warnings.Count, result.SceneBlocks.Count, result.SceneBlocks.Sum(block => block.Validation?.Errors.Count ?? 0), result.SceneBlocks.Sum(block => block.Validation?.Warnings.Count ?? 0), result.Success); } }