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 parsed = JsonSerializer.Deserialize(chapterJson, JsonOptions); var normalisation = ChapterStructureBoundaryNormaliser.Normalise(parsed, paragraphs.Count); var chapter = normalisation.ChapterStructure; var validation = validator.Validate(chapter, paragraphs.Count); ChapterStructureBoundaryNormaliser.AddIssuesTo(validation, normalisation); if (!validation.IsValid || chapter?.SceneBoundaries is null || chapter.SceneBoundaries.Count == 0) { return ManuscriptStructureAnalysisResult.Failed( request.TemporaryChapterKey, BuildValidationMessage(validation), chapterJson); } 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.", chapterJson); } var startMap = request.ParagraphMap.FirstOrDefault(item => item.StructuralParagraphOrdinal == start); if (startMap is null || startMap.WordParagraphIndex < 0) { return ManuscriptStructureAnalysisResult.Failed( request.TemporaryChapterKey, $"Scene {sceneNumber:N0} could not be mapped back to a Word paragraph.", chapterJson); } var sceneParagraphs = paragraphs.Skip(start - 1).Take(end - start + 1).ToList(); scenes.Add(new ManuscriptStructureSceneBoundary { TemporarySceneKey = $"{request.TemporaryChapterKey}-ai-scene-{sceneNumber}", TemporaryChapterKey = request.TemporaryChapterKey, SceneNumberWithinChapter = sceneNumber, Title = $"Scene {sceneNumber:N0}", WordCount = sceneParagraphs.Sum(CountWords), StartParagraph = start, EndParagraph = end, WordStartPosition = startMap.WordParagraphIndex, 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, chapterJson); } 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 BuildValidationMessage(ValidationResult validation) { var first = validation.Errors.FirstOrDefault()?.Message ?? validation.Warnings.FirstOrDefault()?.Message ?? "Chapter Structure validation failed."; return first; } private static int CountWords(string value) => string.IsNullOrWhiteSpace(value) ? 0 : value.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries).Length; } 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 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 int StartParagraph { get; init; } public int EndParagraph { get; init; } public int WordStartPosition { get; init; } public string? Reason { get; init; } public decimal? Confidence { get; init; } }