PlotDirector/PlotLine/Services/ChapterStructureBoundaryNormaliser.cs

165 lines
6.3 KiB
C#

using System.Text.Json;
using PlotLine.Models;
using PlotLine.Models.StoryIntelligence;
namespace PlotLine.Services;
public static class ChapterStructureBoundaryNormaliser
{
public static ChapterStructureBoundaryNormalisationResult Normalise(ChapterStructureModel? chapterStructure, int paragraphCount)
{
if (chapterStructure?.SceneBoundaries is null || chapterStructure.SceneBoundaries.Count < 2 || paragraphCount <= 0)
{
return new ChapterStructureBoundaryNormalisationResult(chapterStructure, []);
}
var boundaries = chapterStructure.SceneBoundaries;
var finalIndex = boundaries.Count - 1;
var final = boundaries[finalIndex];
var previous = boundaries[finalIndex - 1];
var phantomParagraph = paragraphCount + 1;
if (IsTrailingEmptyBoundary(final, previous, paragraphCount, phantomParagraph)
|| IsTrailingInvalidPlaceholder(final, previous, paragraphCount, phantomParagraph))
{
var normalisedBoundaries = boundaries
.Take(finalIndex)
.Select((boundary, index) => CopyBoundary(boundary, index + 1))
.ToList();
var normalised = new ChapterStructureModel
{
SchemaVersion = chapterStructure.SchemaVersion,
ChapterSummary = chapterStructure.ChapterSummary,
SceneBoundaries = normalisedBoundaries,
ExtensionData = chapterStructure.ExtensionData
};
var issue = new ValidationIssue
{
Severity = "Warning",
Path = $"sceneBoundaries[{finalIndex}]",
Message = BuildNormalisationMessage(final, finalIndex, paragraphCount, phantomParagraph),
SuggestedFix = "Do not return a final scene boundary unless it references an actual supplied paragraph."
};
return new ChapterStructureBoundaryNormalisationResult(normalised, [issue]);
}
return new ChapterStructureBoundaryNormalisationResult(chapterStructure, []);
}
public static void AddIssuesTo(ValidationResult validation, ChapterStructureBoundaryNormalisationResult normalisation)
{
foreach (var issue in normalisation.Issues)
{
validation.Warnings.Add(issue);
}
}
public static string SerialiseParsedModel(ChapterStructureModel? chapterStructure, ChapterStructureBoundaryNormalisationResult normalisation, JsonSerializerOptions jsonOptions)
{
if (chapterStructure is null)
{
return string.Empty;
}
if (normalisation.Issues.Count == 0)
{
return JsonSerializer.Serialize(chapterStructure, jsonOptions);
}
return JsonSerializer.Serialize(
new
{
chapterStructure.SchemaVersion,
chapterStructure.ChapterSummary,
chapterStructure.SceneBoundaries,
Normalisation = normalisation.Issues.Select(issue => new
{
issue.Severity,
issue.Path,
issue.Message,
issue.SuggestedFix
})
},
jsonOptions);
}
private static ChapterSceneBoundary CopyBoundary(ChapterSceneBoundary boundary, int sceneNumber)
=> new()
{
SceneNumber = sceneNumber,
StartParagraph = boundary.StartParagraph,
EndParagraph = boundary.EndParagraph,
Confidence = boundary.Confidence,
Reason = boundary.Reason,
ExtensionData = boundary.ExtensionData
};
private static bool IsTrailingEmptyBoundary(
ChapterSceneBoundary final,
ChapterSceneBoundary previous,
int paragraphCount,
int phantomParagraph)
=> final.StartParagraph == phantomParagraph
&& final.EndParagraph == phantomParagraph
&& previous.EndParagraph == paragraphCount
&& final.StartParagraph > paragraphCount
&& final.EndParagraph > paragraphCount;
private static bool IsTrailingInvalidPlaceholder(
ChapterSceneBoundary final,
ChapterSceneBoundary previous,
int paragraphCount,
int phantomParagraph)
{
if (!final.StartParagraph.HasValue || !final.EndParagraph.HasValue || previous.EndParagraph != paragraphCount)
{
return false;
}
var invalidTrailingRange = final.StartParagraph.Value > paragraphCount
&& (final.EndParagraph.Value <= paragraphCount
|| final.EndParagraph.Value == phantomParagraph
|| final.EndParagraph.Value < final.StartParagraph.Value);
return invalidTrailingRange && HasPlaceholderSignal(final);
}
private static bool HasPlaceholderSignal(ChapterSceneBoundary boundary)
{
if (boundary.Confidence <= 0.05m)
{
return true;
}
var reason = boundary.Reason ?? string.Empty;
return reason.Contains("invalid", StringComparison.OrdinalIgnoreCase)
|| reason.Contains("placeholder", StringComparison.OrdinalIgnoreCase)
|| reason.Contains("ensure coverage", StringComparison.OrdinalIgnoreCase)
|| reason.Contains("coverage", StringComparison.OrdinalIgnoreCase);
}
private static string BuildNormalisationMessage(
ChapterSceneBoundary final,
int finalIndex,
int paragraphCount,
int phantomParagraph)
{
var start = final.StartParagraph ?? 0;
var end = final.EndParagraph ?? 0;
if (start == phantomParagraph && end == phantomParagraph)
{
return $"Discarded trailing empty scene boundary {phantomParagraph}-{phantomParagraph} because submitted chapter has {paragraphCount} paragraphs and previous scene already ended at {paragraphCount}.";
}
var sceneNumber = final.SceneNumber ?? finalIndex + 1;
return $"Discarded trailing invalid placeholder scene {sceneNumber} with range {start}-{end} because previous scene already ended at paragraph {paragraphCount}.";
}
}
public sealed record ChapterStructureBoundaryNormalisationResult(
ChapterStructureModel? ChapterStructure,
IReadOnlyList<ValidationIssue> Issues);