PlotDirector/PlotLine/Services/ManuscriptStructureAnalysisService.cs

243 lines
11 KiB
C#

using System.Text.Json;
using PlotLine.Models;
using PlotLine.Models.StoryIntelligence;
namespace PlotLine.Services;
public interface IManuscriptStructureAnalysisService
{
Task<ManuscriptStructureAnalysisResult> AnalyseChapterAsync(ManuscriptStructureAnalysisRequest request, CancellationToken cancellationToken = default);
}
public sealed class ManuscriptStructureAnalysisService(
IStoryPromptRepository prompts,
IStoryPromptVersionService versions,
IStoryIntelligenceClient client,
IChapterStructureValidator validator,
ILogger<ManuscriptStructureAnalysisService> 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<ManuscriptStructureAnalysisResult> 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<ChapterStructureModel>(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<ManuscriptStructureSceneBoundary>();
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);
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.",
chapterJson);
}
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,
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<OpenAIResponseEnvelope>(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<ManuscriptScanSourceParagraph> 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 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<ManuscriptScanParagraphMap> ParagraphMap { get; init; } = [];
public IReadOnlyList<ManuscriptScanSourceParagraph> 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<ManuscriptStructureSceneBoundary> Scenes { get; init; } = [];
public static ManuscriptStructureAnalysisResult Succeeded(string temporaryChapterKey, IReadOnlyList<ManuscriptStructureSceneBoundary> 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? Reason { get; init; }
public decimal? Confidence { get; init; }
}