PlotDirector/PlotLine/Services/ManuscriptStructureAnalysisService.cs

627 lines
28 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.Text.Json;
using System.Text.RegularExpressions;
using PlotLine.Models;
using PlotLine.Models.StoryIntelligence;
namespace PlotLine.Services;
public interface IManuscriptStructureAnalysisService
{
Task<ManuscriptStructureAnalysisResult> AnalyseChapterAsync(ManuscriptStructureAnalysisRequest request, CancellationToken cancellationToken = default);
}
public sealed partial 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 parseResult = StoryIntelligenceAiJsonParser.Parse<ChapterStructureModel>(chapterJson, JsonOptions);
if (!parseResult.Success)
{
LogStructuralJsonParseFailure(request, parseResult, repairSucceeded: false, retryAttempted: false);
var retryPrompt = BuildJsonRetryPrompt(
prompt,
parseResult.ErrorMessage ?? parseResult.InitialErrorMessage ?? "Chapter Structure JSON could not be parsed.",
chapterJson);
var retryResult = await client.ExecutePromptAsync(retryPrompt, promptVersion, cancellationToken, clientStatus.ChapterStructureModel);
var retryJson = ExtractOutputText(retryResult.RawResponseText);
var retryParseResult = StoryIntelligenceAiJsonParser.Parse<ChapterStructureModel>(retryJson, JsonOptions);
if (!retryParseResult.Success)
{
LogStructuralJsonParseFailure(request, retryParseResult, repairSucceeded: false, retryAttempted: true);
return ManuscriptStructureAnalysisResult.Failed(
request.TemporaryChapterKey,
BuildUserJsonParseFailureMessage(request),
retryParseResult.RawJson);
}
var retryWarnings = new List<string> { "Chapter Structure JSON parse failed once and succeeded after one retry." };
retryWarnings.AddRange(retryParseResult.Warnings);
parseResult = retryParseResult with { Warnings = retryWarnings };
}
if (parseResult.Warnings.Count > 0)
{
logger.LogWarning(
"Repaired structural scene-boundary JSON. ProjectID={ProjectID} BookID={BookID} PreviewID={PreviewID} TemporaryChapterKey={TemporaryChapterKey} SourceLabel={SourceLabel} ParserFailure={ParserFailure} DiagnosticExcerpt={DiagnosticExcerpt} RepairSucceeded={RepairSucceeded} RetryAttempted={RetryAttempted} Warnings={Warnings}",
request.ProjectID,
request.BookID,
request.PreviewID,
request.TemporaryChapterKey,
request.SourceLabel,
parseResult.InitialErrorMessage,
BuildJsonDiagnosticExcerpt(parseResult.RawJson, parseResult.InitialErrorMessage),
parseResult.RepairedJson is not null,
parseResult.Warnings.Any(warning => warning.Contains("retry", StringComparison.OrdinalIgnoreCase)),
string.Join("; ", parseResult.Warnings));
}
var parsed = parseResult.Parsed;
var normalisation = ChapterStructureBoundaryNormaliser.Normalise(parsed, paragraphs.Count);
var chapter = normalisation.ChapterStructure;
var explicitPov = DetectExplicitNarrator(text);
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<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.",
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);
var suggestedTitle = CleanSuggestedTitle(boundary.SuggestedTitle, sceneNumber);
var aiDefaultPov = CleanPovSuggestion(chapter.DefaultPov);
var pov = CleanPovSuggestion(boundary.PovOverride) ?? explicitPov ?? aiDefaultPov;
scenes.Add(new ManuscriptStructureSceneBoundary
{
TemporarySceneKey = $"{request.TemporaryChapterKey}-ai-scene-{sceneNumber}",
TemporaryChapterKey = request.TemporaryChapterKey,
SceneNumberWithinChapter = sceneNumber,
Title = suggestedTitle ?? string.Empty,
WordCount = sceneParagraphs.Sum(CountWords),
SourceText = sourceText,
StartParagraph = start,
EndParagraph = end,
WordStartPosition = startMap.WordParagraphIndex,
WordEndPosition = endMap.WordParagraphIndex,
StructuralSummary = Trim(boundary.StructuralSummary, 600),
StructuralSetting = Trim(boundary.StructuralSetting, 300),
Reason = boundary.Reason,
Confidence = boundary.Confidence,
SuggestedPovName = pov?.CharacterName,
SuggestedPovConfidence = pov?.Confidence,
SuggestedPovEvidence = pov?.Evidence,
SuggestedPovIsOverride = boundary.PovOverride is not null && !string.IsNullOrWhiteSpace(boundary.PovOverride.CharacterName),
ChapterParsedJson = persistedJson,
ExplicitNarratorName = explicitPov?.CharacterName
});
}
var rawPov = CleanPovSuggestion(chapter.DefaultPov);
logger.LogInformation(
"Structural POV audit. ProjectID={ProjectID} BookID={BookID} PreviewID={PreviewID} TemporaryChapterKey={TemporaryChapterKey} ExplicitNarrator={ExplicitNarrator} AiDefaultPov={AiDefaultPov} MixedPov={MixedPov} RawStructuralJson={RawStructuralJson}",
request.ProjectID,
request.BookID,
request.PreviewID,
request.TemporaryChapterKey,
explicitPov?.CharacterName,
rawPov?.CharacterName,
chapter.MixedPov,
parseResult.RawJson);
logger.LogInformation(
"Structural scene-boundary analysis completed. ProjectID={ProjectID} BookID={BookID} TemporaryChapterKey={TemporaryChapterKey} Scenes={SceneCount}",
request.ProjectID,
request.BookID,
request.TemporaryChapterKey,
scenes.Count);
var defaultPov = explicitPov ?? CleanPovSuggestion(chapter.DefaultPov);
return ManuscriptStructureAnalysisResult.Succeeded(
request.TemporaryChapterKey,
scenes,
persistedJson,
defaultPov?.CharacterName,
defaultPov?.Confidence,
defaultPov?.Evidence,
chapter.MixedPov == true);
}
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 void LogStructuralJsonParseFailure(
ManuscriptStructureAnalysisRequest request,
StoryIntelligenceAiJsonParseResult<ChapterStructureModel> parseResult,
bool repairSucceeded,
bool retryAttempted)
{
var parserFailure = parseResult.InitialErrorMessage ?? parseResult.ErrorMessage;
logger.LogWarning(
"Structural scene-boundary JSON parse failed. ProjectID={ProjectID} BookID={BookID} PreviewID={PreviewID} TemporaryChapterKey={TemporaryChapterKey} SourceLabel={SourceLabel} ParserFailure={ParserFailure} DiagnosticExcerpt={DiagnosticExcerpt} RepairSucceeded={RepairSucceeded} RetryAttempted={RetryAttempted}",
request.ProjectID,
request.BookID,
request.PreviewID,
request.TemporaryChapterKey,
request.SourceLabel,
parserFailure,
BuildJsonDiagnosticExcerpt(parseResult.RawJson, parserFailure),
repairSucceeded,
retryAttempted);
}
private static string BuildChapterContextJson(ManuscriptStructureAnalysisRequest request)
=> JsonSerializer.Serialize(new
{
projectId = request.ProjectID,
bookId = request.BookID,
chapterId = (int?)null,
chapterNumber = request.ChapterNumber,
sourceLabel = request.SourceLabel,
storyEra = request.StoryEra,
primaryLocale = request.PrimaryLocale,
knownCharacters = request.KnownCharacters.Take(80)
}, 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 BuildJsonRetryPrompt(string originalPrompt, string parseError, string malformedJson)
=> string.Concat(
originalPrompt,
Environment.NewLine,
Environment.NewLine,
"## JSON Repair Retry",
Environment.NewLine,
"Your previous response was not valid JSON. Return the same Chapter Structure object as valid JSON only. Do not include markdown. Do not truncate. Numeric confidence values must be valid JSON numbers such as 0.8 or 0.0, with no spaces inside numbers.",
Environment.NewLine,
"Parse error:",
Environment.NewLine,
parseError,
Environment.NewLine,
"Malformed JSON returned previously:",
Environment.NewLine,
"```json",
Environment.NewLine,
malformedJson,
Environment.NewLine,
"```");
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 string BuildUserJsonParseFailureMessage(ManuscriptStructureAnalysisRequest request)
{
var label = ExtractChapterLabel(request.SourceLabel);
return string.IsNullOrWhiteSpace(label)
? "PlotDirector could not read the scene structure returned by the analysis. The analysis has been stopped and can be retried."
: $"PlotDirector could not read the scene structure returned for \"{label}\". The analysis has been stopped and can be retried.";
}
private static string ExtractChapterLabel(string sourceLabel)
{
if (string.IsNullOrWhiteSpace(sourceLabel))
{
return string.Empty;
}
var separator = sourceLabel.LastIndexOf(':');
return separator >= 0 && separator + 1 < sourceLabel.Length
? sourceLabel[(separator + 1)..].Trim()
: sourceLabel.Trim();
}
private static string BuildJsonDiagnosticExcerpt(string rawJson, string? parseError)
{
if (string.IsNullOrWhiteSpace(rawJson))
{
return "No raw JSON was available.";
}
if (!TryReadJsonErrorLocation(parseError, out var lineNumber, out var bytePosition))
{
return TrimDiagnostic(rawJson.ReplaceLineEndings(" "), 180);
}
var lines = rawJson.ReplaceLineEndings("\n").Split('\n');
if (lineNumber < 0 || lineNumber >= lines.Length)
{
return TrimDiagnostic(rawJson.ReplaceLineEndings(" "), 180);
}
var line = lines[lineNumber].Trim();
if (line.Length <= 180)
{
return line;
}
var start = Math.Max(0, Math.Min(bytePosition, line.Length) - 70);
var length = Math.Min(160, line.Length - start);
return TrimDiagnostic(line.Substring(start, length), 180);
}
private static bool TryReadJsonErrorLocation(string? parseError, out int lineNumber, out int bytePosition)
{
lineNumber = -1;
bytePosition = -1;
if (string.IsNullOrWhiteSpace(parseError))
{
return false;
}
const string lineMarker = "LineNumber: ";
const string byteMarker = "BytePositionInLine: ";
var lineStart = parseError.IndexOf(lineMarker, StringComparison.Ordinal);
var byteStart = parseError.IndexOf(byteMarker, StringComparison.Ordinal);
if (lineStart < 0 || byteStart < 0)
{
return false;
}
lineStart += lineMarker.Length;
byteStart += byteMarker.Length;
var lineEnd = parseError.IndexOfAny([' ', '|', '.'], lineStart);
var byteEnd = parseError.IndexOfAny([' ', '|', '.'], byteStart);
var lineValue = lineEnd > lineStart ? parseError[lineStart..lineEnd] : parseError[lineStart..];
var byteValue = byteEnd > byteStart ? parseError[byteStart..byteEnd] : parseError[byteStart..];
return int.TryParse(lineValue, out lineNumber)
&& int.TryParse(byteValue, out bytePosition);
}
private static string TrimDiagnostic(string value, int maxLength)
{
var clean = value.Trim();
return clean.Length <= maxLength ? clean : clean[..maxLength].TrimEnd();
}
private static string BuildBoundaryDiagnostic(
ChapterStructureModel? chapter,
IReadOnlyList<ManuscriptScanParagraphMap> 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();
}
private static string? CleanSuggestedTitle(string? value, int sceneNumber)
{
var clean = Trim(value, 80);
if (string.IsNullOrWhiteSpace(clean))
{
return null;
}
clean = clean.Trim(' ', '.', ':', ';', '-', '"', '\'');
if (string.IsNullOrWhiteSpace(clean)
|| string.Equals(clean, $"Scene {sceneNumber}", StringComparison.OrdinalIgnoreCase)
|| clean.StartsWith("Untitled", StringComparison.OrdinalIgnoreCase))
{
return null;
}
var wordCount = clean.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries).Length;
return wordCount is >= 2 and <= 6 ? clean : null;
}
private static ChapterStructurePovSuggestion? CleanPovSuggestion(ChapterStructurePovSuggestion? suggestion)
{
var name = Trim(suggestion?.CharacterName, 120);
if (string.IsNullOrWhiteSpace(name)
|| string.Equals(name, "Unknown", StringComparison.OrdinalIgnoreCase)
|| string.Equals(name, "Mixed", StringComparison.OrdinalIgnoreCase))
{
return null;
}
return new ChapterStructurePovSuggestion
{
CharacterName = name,
Confidence = suggestion?.Confidence,
Evidence = Trim(suggestion?.Evidence, 400),
IsExplicit = suggestion?.IsExplicit
};
}
public static ChapterStructurePovSuggestion? DetectExplicitNarrator(string? chapterText)
{
if (string.IsNullOrWhiteSpace(chapterText))
{
return null;
}
var paragraphs = StoryIntelligenceParagraphs.Split(chapterText).Take(6);
foreach (var paragraph in paragraphs)
{
var match = ExplicitNarratorRegex().Match(paragraph.Trim());
if (!match.Success)
{
continue;
}
var name = CleanPovName(match.Groups["name"].Value);
if (string.IsNullOrWhiteSpace(name))
{
continue;
}
return new ChapterStructurePovSuggestion
{
CharacterName = name,
Confidence = 0.99m,
Evidence = match.Value.Trim(),
IsExplicit = true
};
}
return null;
}
[GeneratedRegex(@"^\s*(?:narrated\s+by|narrated\s+by\s*:|narrator\s*:|pov\s*:)\s*(?<name>[A-Z][\p{L}'’.-]*(?:\s+[A-Z][\p{L}'’.-]*){0,3})", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
private static partial Regex ExplicitNarratorRegex();
private static string? CleanPovName(string? name)
{
var clean = Trim(name, 200);
if (string.IsNullOrWhiteSpace(clean))
{
return null;
}
clean = Regex.Replace(clean, @"\s*[–—-]\s*.*$", string.Empty).Trim();
clean = Regex.Replace(clean, @"\s+\d{1,2}(?:st|nd|rd|th)?\b.*$", string.Empty, RegexOptions.IgnoreCase).Trim();
clean = clean.Trim(':', '-', '–', '—', '.', ' ');
return string.IsNullOrWhiteSpace(clean) ? null : clean;
}
}
public sealed class ManuscriptStructureAnalysisRequest
{
public int ProjectID { get; init; }
public int BookID { get; init; }
public Guid PreviewID { get; init; }
public string TemporaryChapterKey { get; init; } = string.Empty;
public int ChapterNumber { get; init; }
public string SourceLabel { get; init; } = string.Empty;
public string? StoryEra { get; init; }
public string? PrimaryLocale { get; init; }
public IReadOnlyList<string> KnownCharacters { get; init; } = [];
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 string? DefaultPovName { get; init; }
public decimal? DefaultPovConfidence { get; init; }
public string? DefaultPovEvidence { get; init; }
public bool MixedPov { get; init; }
public IReadOnlyList<ManuscriptStructureSceneBoundary> Scenes { get; init; } = [];
public static ManuscriptStructureAnalysisResult Succeeded(
string temporaryChapterKey,
IReadOnlyList<ManuscriptStructureSceneBoundary> scenes,
string? parsedJson,
string? defaultPovName = null,
decimal? defaultPovConfidence = null,
string? defaultPovEvidence = null,
bool mixedPov = false)
=> new()
{
TemporaryChapterKey = temporaryChapterKey,
Success = true,
Scenes = scenes,
ParsedJson = parsedJson,
DefaultPovName = defaultPovName,
DefaultPovConfidence = defaultPovConfidence,
DefaultPovEvidence = defaultPovEvidence,
MixedPov = mixedPov
};
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 string? SuggestedTitle => SceneTitleDisplay.Clean(Title);
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? StructuralSetting { get; init; }
public string? Reason { get; init; }
public decimal? Confidence { get; init; }
public string? SuggestedPovName { get; init; }
public decimal? SuggestedPovConfidence { get; init; }
public string? SuggestedPovEvidence { get; init; }
public bool SuggestedPovIsOverride { get; init; }
public string? ChapterParsedJson { get; init; }
public string? ExplicitNarratorName { get; init; }
}