213 lines
9.8 KiB
C#
213 lines
9.8 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);
|
|
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<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 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 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 int StartParagraph { get; init; }
|
|
public int EndParagraph { get; init; }
|
|
public int WordStartPosition { get; init; }
|
|
public string? Reason { get; init; }
|
|
public decimal? Confidence { get; init; }
|
|
}
|