using System.Text.Json; using PlotLine.Models; using PlotLine.Models.StoryIntelligence; namespace PlotLine.Services; public interface IManuscriptStructureAnalysisService { Task AnalyseChapterAsync(ManuscriptStructureAnalysisRequest request, CancellationToken cancellationToken = default); } public sealed class ManuscriptStructureAnalysisService( IStoryPromptRepository prompts, IStoryPromptVersionService versions, IStoryIntelligenceClient client, IChapterStructureValidator validator, ILogger logger) : IManuscriptStructureAnalysisService { private const string ChapterPromptFile = "Chapter-Structure-Prompt-V2.md"; private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, PropertyNameCaseInsensitive = true, WriteIndented = true }; public async Task AnalyseChapterAsync(ManuscriptStructureAnalysisRequest request, CancellationToken cancellationToken = default) { var text = request.ChapterText?.Trim() ?? string.Empty; var paragraphs = StoryIntelligenceParagraphs.Split(text); if (paragraphs.Count == 0) { return ManuscriptStructureAnalysisResult.Failed( request.TemporaryChapterKey, "The approved chapter has no readable text for scene-boundary detection."); } try { var promptVersion = versions.GetPromptVersion(ChapterPromptFile); var clientStatus = client.GetConfigurationStatus(); var template = await prompts.LoadPromptAsync(ChapterPromptFile, cancellationToken); var prompt = BuildChapterPrompt(template, BuildChapterContextJson(request), StoryIntelligenceParagraphs.Number(paragraphs)); var result = await client.ExecutePromptAsync(prompt, promptVersion, cancellationToken, clientStatus.ChapterStructureModel); var chapterJson = ExtractOutputText(result.RawResponseText); var parseResult = StoryIntelligenceAiJsonParser.Parse(chapterJson, JsonOptions); if (!parseResult.Success) { return ManuscriptStructureAnalysisResult.Failed( request.TemporaryChapterKey, parseResult.ErrorMessage ?? "Chapter Structure JSON could not be parsed.", parseResult.RawJson); } if (parseResult.Warnings.Count > 0) { logger.LogWarning( "Repaired structural scene-boundary JSON. ProjectID={ProjectID} BookID={BookID} TemporaryChapterKey={TemporaryChapterKey} Warnings={Warnings}", request.ProjectID, request.BookID, request.TemporaryChapterKey, string.Join("; ", parseResult.Warnings)); } var parsed = parseResult.Parsed; var normalisation = ChapterStructureBoundaryNormaliser.Normalise(parsed, paragraphs.Count); var chapter = normalisation.ChapterStructure; var validation = validator.Validate(chapter, paragraphs.Count); ChapterStructureBoundaryNormaliser.AddIssuesTo(validation, normalisation); var persistedJson = normalisation.Issues.Count > 0 ? ChapterStructureBoundaryNormaliser.SerialiseParsedModel(chapter, normalisation, JsonOptions) : parseResult.RepairedJson ?? parseResult.RawJson; if (!validation.IsValid || chapter?.SceneBoundaries is null || chapter.SceneBoundaries.Count == 0) { var diagnostic = BuildBoundaryDiagnostic(chapter, request.ParagraphMap, paragraphs.Count); logger.LogWarning( "Structural scene-boundary validation failed. ProjectID={ProjectID} BookID={BookID} TemporaryChapterKey={TemporaryChapterKey} StructuralParagraphCount={StructuralParagraphCount} Diagnostic={Diagnostic}", request.ProjectID, request.BookID, request.TemporaryChapterKey, paragraphs.Count, diagnostic); return ManuscriptStructureAnalysisResult.Failed( request.TemporaryChapterKey, $"{BuildValidationMessage(validation)} {diagnostic}", persistedJson); } var scenes = new List(); foreach (var boundary in chapter.SceneBoundaries.OrderBy(item => item.SceneNumber ?? int.MaxValue)) { 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 ManuscriptStructureAnalysisResult.Failed( request.TemporaryChapterKey, $"Scene {sceneNumber:N0} has an invalid paragraph range.", persistedJson); } var startMap = request.ParagraphMap.FirstOrDefault(item => item.StructuralParagraphOrdinal == start); var endMap = request.ParagraphMap.FirstOrDefault(item => item.StructuralParagraphOrdinal == end); if (startMap is null || endMap is null || startMap.WordParagraphIndex < 0 || endMap.WordParagraphIndex < 0) { return ManuscriptStructureAnalysisResult.Failed( request.TemporaryChapterKey, $"Scene {sceneNumber:N0} could not be mapped back to a Word paragraph.", persistedJson); } var sceneParagraphs = paragraphs.Skip(start - 1).Take(end - start + 1).ToList(); var sourceText = BuildSourceText(request.SourceParagraphs, startMap.WordParagraphIndex, endMap.WordParagraphIndex); scenes.Add(new ManuscriptStructureSceneBoundary { TemporarySceneKey = $"{request.TemporaryChapterKey}-ai-scene-{sceneNumber}", TemporaryChapterKey = request.TemporaryChapterKey, SceneNumberWithinChapter = sceneNumber, Title = $"Scene {sceneNumber:N0}", WordCount = sceneParagraphs.Sum(CountWords), SourceText = sourceText, StartParagraph = start, EndParagraph = end, WordStartPosition = startMap.WordParagraphIndex, WordEndPosition = endMap.WordParagraphIndex, StructuralSummary = Trim(boundary.StructuralSummary, 600), Reason = boundary.Reason, Confidence = boundary.Confidence }); } logger.LogInformation( "Structural scene-boundary analysis completed. ProjectID={ProjectID} BookID={BookID} TemporaryChapterKey={TemporaryChapterKey} Scenes={SceneCount}", request.ProjectID, request.BookID, request.TemporaryChapterKey, scenes.Count); return ManuscriptStructureAnalysisResult.Succeeded(request.TemporaryChapterKey, scenes, persistedJson); } catch (Exception ex) when (ex is not OperationCanceledException) { logger.LogWarning( ex, "Structural scene-boundary analysis failed. ProjectID={ProjectID} BookID={BookID} TemporaryChapterKey={TemporaryChapterKey}", request.ProjectID, request.BookID, request.TemporaryChapterKey); return ManuscriptStructureAnalysisResult.Failed(request.TemporaryChapterKey, ex.Message); } } private static string BuildChapterContextJson(ManuscriptStructureAnalysisRequest request) => JsonSerializer.Serialize(new { projectId = request.ProjectID, bookId = request.BookID, chapterId = (int?)null, chapterNumber = request.ChapterNumber, sourceLabel = request.SourceLabel }, 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 BuildSourceText(IReadOnlyList sourceParagraphs, int wordStartPosition, int wordEndPosition) { var lines = sourceParagraphs .Where(item => item.WordParagraphIndex >= wordStartPosition && item.WordParagraphIndex <= wordEndPosition && item.IncludeInSourceText) .OrderBy(item => item.WordParagraphIndex) .Select(item => item.IsBlank ? string.Empty : item.Text.Trim()) .ToList(); while (lines.Count > 0 && string.IsNullOrWhiteSpace(lines[0])) { lines.RemoveAt(0); } while (lines.Count > 0 && string.IsNullOrWhiteSpace(lines[^1])) { lines.RemoveAt(lines.Count - 1); } return string.Join(Environment.NewLine + Environment.NewLine, lines); } private static string BuildValidationMessage(ValidationResult validation) { var first = validation.Errors.FirstOrDefault()?.Message ?? validation.Warnings.FirstOrDefault()?.Message ?? "Chapter Structure validation failed."; return first; } private static string BuildBoundaryDiagnostic( ChapterStructureModel? chapter, IReadOnlyList paragraphMap, int structuralParagraphCount) { if (chapter?.SceneBoundaries is null || chapter.SceneBoundaries.Count == 0) { return $"Structural paragraphs: 1..{structuralParagraphCount:N0}; no scene boundaries were returned."; } var finalMap = paragraphMap .Where(item => item.StructuralParagraphOrdinal == structuralParagraphCount) .Select(item => item.WordParagraphIndex) .FirstOrDefault(); var ranges = chapter.SceneBoundaries .OrderBy(boundary => boundary.SceneNumber ?? int.MaxValue) .Select(boundary => { var number = boundary.SceneNumber?.ToString("N0") ?? "?"; var start = boundary.StartParagraph; var end = boundary.EndParagraph; var startWord = start.HasValue ? paragraphMap.FirstOrDefault(item => item.StructuralParagraphOrdinal == start.Value)?.WordParagraphIndex : null; var endWord = end.HasValue ? paragraphMap.FirstOrDefault(item => item.StructuralParagraphOrdinal == end.Value)?.WordParagraphIndex : null; return $"scene {number}: structural {Display(start)}-{Display(end)}, Word {Display(startWord)}-{Display(endWord)}"; }); return $"Structural paragraphs: 1..{structuralParagraphCount:N0}; final structural paragraph maps to Word paragraph {finalMap:N0}; returned {string.Join("; ", ranges)}."; } private static string Display(int? value) => value.HasValue ? value.Value.ToString("N0") : "?"; private static int CountWords(string value) => string.IsNullOrWhiteSpace(value) ? 0 : value.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries).Length; private static string? Trim(string? value, int maxLength) { var clean = value?.Trim(); if (string.IsNullOrWhiteSpace(clean)) { return null; } return clean.Length <= maxLength ? clean : clean[..maxLength].Trim(); } } public sealed class ManuscriptStructureAnalysisRequest { public int ProjectID { get; init; } public int BookID { get; init; } public string TemporaryChapterKey { get; init; } = string.Empty; public int ChapterNumber { get; init; } public string SourceLabel { get; init; } = string.Empty; public string ChapterText { get; init; } = string.Empty; public IReadOnlyList ParagraphMap { get; init; } = []; public IReadOnlyList SourceParagraphs { get; init; } = []; } public sealed class ManuscriptStructureAnalysisResult { public string TemporaryChapterKey { get; init; } = string.Empty; public bool Success { get; init; } public string? ErrorMessage { get; init; } public string? ParsedJson { get; init; } public IReadOnlyList Scenes { get; init; } = []; public static ManuscriptStructureAnalysisResult Succeeded(string temporaryChapterKey, IReadOnlyList scenes, string? parsedJson) => new() { TemporaryChapterKey = temporaryChapterKey, Success = true, Scenes = scenes, ParsedJson = parsedJson }; public static ManuscriptStructureAnalysisResult Failed(string temporaryChapterKey, string errorMessage, string? parsedJson = null) => new() { TemporaryChapterKey = temporaryChapterKey, Success = false, ErrorMessage = errorMessage, ParsedJson = parsedJson }; } public sealed class ManuscriptStructureSceneBoundary { public string TemporarySceneKey { get; init; } = string.Empty; public string TemporaryChapterKey { get; init; } = string.Empty; public int SceneNumberWithinChapter { get; init; } public string Title { get; init; } = string.Empty; public int WordCount { get; init; } public string SourceText { get; init; } = string.Empty; public int StartParagraph { get; init; } public int EndParagraph { get; init; } public int WordStartPosition { get; init; } public int WordEndPosition { get; init; } public string? StructuralSummary { get; init; } public string? Reason { get; init; } public decimal? Confidence { get; init; } }