PlotDirector/PlotLine/Services/ChapterStructureBoundaryNormaliser.cs

321 lines
13 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 issues = new List<ValidationIssue>();
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))
{
boundaries = boundaries
.Take(finalIndex)
.Select((boundary, index) => CopyBoundary(boundary, index + 1))
.ToList();
issues.Add(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."
});
}
else if (IsTrailingTerminalOffByOne(final, previous, paragraphCount, phantomParagraph))
{
boundaries = boundaries
.Take(finalIndex)
.Select((boundary, index) => CopyBoundary(boundary, index + 1))
.ToList();
issues.Add(new ValidationIssue
{
Severity = "Warning",
Path = $"sceneBoundaries[{finalIndex}]",
Message = $"Discarded trailing terminal off-by-one scene boundary {phantomParagraph}-{paragraphCount} because previous scene already ended at the final structural paragraph {paragraphCount}.",
SuggestedFix = $"Do not return a boundary starting after the final supplied paragraph {paragraphCount}."
});
}
var boundaryModel = ReferenceEquals(boundaries, chapterStructure.SceneBoundaries)
? chapterStructure
: new ChapterStructureModel
{
SchemaVersion = chapterStructure.SchemaVersion,
ChapterSummary = chapterStructure.ChapterSummary,
SceneBoundaries = boundaries.ToList(),
ExtensionData = chapterStructure.ExtensionData
};
var continuity = RepairContinuity(boundaryModel, boundaries, paragraphCount);
issues.AddRange(continuity.Issues);
return new ChapterStructureBoundaryNormalisationResult(continuity.ChapterStructure, issues);
}
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,
StructuralSummary = boundary.StructuralSummary,
Confidence = boundary.Confidence,
Reason = boundary.Reason,
ExtensionData = boundary.ExtensionData
};
private static ChapterStructureBoundaryNormalisationResult RepairContinuity(
ChapterStructureModel chapterStructure,
IReadOnlyList<ChapterSceneBoundary> sourceBoundaries,
int paragraphCount)
{
if (sourceBoundaries.Count == 0)
{
return new ChapterStructureBoundaryNormalisationResult(chapterStructure, []);
}
var issues = new List<ValidationIssue>();
var ordered = sourceBoundaries
.Select((boundary, index) => new { Boundary = boundary, OriginalIndex = index })
.OrderBy(item => item.Boundary.StartParagraph ?? int.MaxValue)
.ThenBy(item => item.Boundary.EndParagraph ?? int.MaxValue)
.ToList();
if (ordered.Where((item, index) => item.OriginalIndex != index).Any())
{
issues.Add(new ValidationIssue
{
Severity = "Warning",
Path = "sceneBoundaries",
Message = "Reordered scene boundaries by paragraph position before validation.",
SuggestedFix = "Return scene boundaries in reading order."
});
}
var changed = issues.Count > 0;
var repaired = new List<ChapterSceneBoundary>();
var expectedStart = 1;
for (var index = 0; index < ordered.Count; index++)
{
var boundary = ordered[index].Boundary;
if (!boundary.StartParagraph.HasValue || !boundary.EndParagraph.HasValue)
{
repaired.Add(CopyBoundary(boundary, index + 1));
continue;
}
var start = boundary.StartParagraph.Value;
var end = boundary.EndParagraph.Value;
if (start != expectedStart)
{
issues.Add(new ValidationIssue
{
Severity = "Warning",
Path = $"sceneBoundaries[{index}].startParagraph",
Message = $"Adjusted scene {index + 1:N0} start paragraph from {start:N0} to {expectedStart:N0} to remove an AI boundary gap or overlap.",
SuggestedFix = $"Use startParagraph {expectedStart:N0} for this boundary."
});
start = expectedStart;
changed = true;
}
if (end < start)
{
issues.Add(new ValidationIssue
{
Severity = "Warning",
Path = $"sceneBoundaries[{index}].endParagraph",
Message = $"Scene {index + 1:N0} has start paragraph {start:N0} after end paragraph {end:N0}; this cannot be safely normalised.",
SuggestedFix = "Return an endParagraph greater than or equal to startParagraph."
});
}
if (end > paragraphCount)
{
issues.Add(new ValidationIssue
{
Severity = "Warning",
Path = $"sceneBoundaries[{index}].endParagraph",
Message = $"Clamped scene {index + 1:N0} end paragraph from {end:N0} to the chapter paragraph count {paragraphCount:N0}.",
SuggestedFix = $"Use an endParagraph from 1 to {paragraphCount:N0}."
});
end = paragraphCount;
changed = true;
}
repaired.Add(new ChapterSceneBoundary
{
SceneNumber = index + 1,
StartParagraph = start,
EndParagraph = end,
StructuralSummary = boundary.StructuralSummary,
Confidence = boundary.Confidence,
Reason = boundary.Reason,
ExtensionData = boundary.ExtensionData
});
expectedStart = end + 1;
}
if (expectedStart <= paragraphCount && repaired.Count > 0)
{
var final = repaired[^1];
issues.Add(new ValidationIssue
{
Severity = "Warning",
Path = $"sceneBoundaries[{repaired.Count - 1}].endParagraph",
Message = $"Extended the final scene boundary to paragraph {paragraphCount:N0} so detected scenes cover the full chapter.",
SuggestedFix = $"End the final scene at paragraph {paragraphCount:N0}."
});
repaired[^1] = new ChapterSceneBoundary
{
SceneNumber = final.SceneNumber,
StartParagraph = final.StartParagraph,
EndParagraph = paragraphCount,
StructuralSummary = final.StructuralSummary,
Confidence = final.Confidence,
Reason = final.Reason,
ExtensionData = final.ExtensionData
};
changed = true;
}
if (!changed)
{
return new ChapterStructureBoundaryNormalisationResult(chapterStructure, []);
}
return new ChapterStructureBoundaryNormalisationResult(new ChapterStructureModel
{
SchemaVersion = chapterStructure.SchemaVersion,
ChapterSummary = chapterStructure.ChapterSummary,
SceneBoundaries = repaired,
ExtensionData = chapterStructure.ExtensionData
}, issues);
}
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 IsTrailingTerminalOffByOne(
ChapterSceneBoundary final,
ChapterSceneBoundary previous,
int paragraphCount,
int phantomParagraph)
=> final.StartParagraph == phantomParagraph
&& final.EndParagraph == paragraphCount
&& previous.EndParagraph == paragraphCount;
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);