347 lines
14 KiB
C#
347 lines
14 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 phantomParagraph = paragraphCount + 1;
|
|
|
|
var trimmedTrailingEmptyCount = 0;
|
|
while (boundaries.Count >= 2)
|
|
{
|
|
var finalIndex = boundaries.Count - 1;
|
|
var final = boundaries[finalIndex];
|
|
var previous = boundaries[finalIndex - 1];
|
|
if (!IsDiscardableTrailingBoundary(final, previous, paragraphCount, phantomParagraph))
|
|
{
|
|
break;
|
|
}
|
|
|
|
boundaries = boundaries
|
|
.Take(finalIndex)
|
|
.Select((boundary, index) => CopyBoundary(boundary, index + 1))
|
|
.ToList();
|
|
trimmedTrailingEmptyCount++;
|
|
}
|
|
|
|
if (trimmedTrailingEmptyCount > 0)
|
|
{
|
|
issues.Add(new ValidationIssue
|
|
{
|
|
Severity = "Warning",
|
|
Path = "sceneBoundaries",
|
|
Message = $"Discarded {trimmedTrailingEmptyCount:N0} empty trailing scene boundaries after paragraph {paragraphCount:N0}.",
|
|
SuggestedFix = $"Do not return scene boundaries after the final supplied paragraph {paragraphCount:N0} has already been covered."
|
|
});
|
|
}
|
|
|
|
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.DefaultPov,
|
|
chapterStructure.MixedPov,
|
|
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,
|
|
SuggestedTitle = boundary.SuggestedTitle,
|
|
StructuralSummary = boundary.StructuralSummary,
|
|
StructuralSetting = boundary.StructuralSetting,
|
|
PovOverride = boundary.PovOverride,
|
|
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 discardedTrailingEmptyCount = 0;
|
|
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 (expectedStart > paragraphCount && ContainsNoRemainingParagraphs(start, end, paragraphCount))
|
|
{
|
|
discardedTrailingEmptyCount++;
|
|
changed = true;
|
|
continue;
|
|
}
|
|
|
|
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,
|
|
SuggestedTitle = boundary.SuggestedTitle,
|
|
StructuralSummary = boundary.StructuralSummary,
|
|
StructuralSetting = boundary.StructuralSetting,
|
|
PovOverride = boundary.PovOverride,
|
|
Confidence = boundary.Confidence,
|
|
Reason = boundary.Reason,
|
|
ExtensionData = boundary.ExtensionData
|
|
});
|
|
expectedStart = end + 1;
|
|
}
|
|
|
|
if (discardedTrailingEmptyCount > 0)
|
|
{
|
|
issues.Add(new ValidationIssue
|
|
{
|
|
Severity = "Warning",
|
|
Path = "sceneBoundaries",
|
|
Message = $"Discarded {discardedTrailingEmptyCount:N0} empty trailing scene boundaries after paragraph {paragraphCount:N0}.",
|
|
SuggestedFix = $"Do not return scene boundaries after the final supplied paragraph {paragraphCount:N0} has already been covered."
|
|
});
|
|
|
|
for (var index = 0; index < repaired.Count; index++)
|
|
{
|
|
repaired[index] = CopyBoundary(repaired[index], index + 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,
|
|
SuggestedTitle = final.SuggestedTitle,
|
|
StructuralSummary = final.StructuralSummary,
|
|
StructuralSetting = final.StructuralSetting,
|
|
PovOverride = final.PovOverride,
|
|
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,
|
|
DefaultPov = chapterStructure.DefaultPov,
|
|
MixedPov = chapterStructure.MixedPov,
|
|
SceneBoundaries = repaired,
|
|
ExtensionData = chapterStructure.ExtensionData
|
|
}, issues);
|
|
}
|
|
|
|
private static bool IsDiscardableTrailingBoundary(
|
|
ChapterSceneBoundary final,
|
|
ChapterSceneBoundary previous,
|
|
int paragraphCount,
|
|
int phantomParagraph)
|
|
=> IsTrailingEmptyBoundary(final, previous, paragraphCount, phantomParagraph)
|
|
|| IsTrailingInvalidPlaceholder(final, previous, paragraphCount, phantomParagraph)
|
|
|| IsTrailingTerminalOffByOne(final, previous, paragraphCount, phantomParagraph);
|
|
|
|
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 bool ContainsNoRemainingParagraphs(int start, int end, int paragraphCount)
|
|
=> start > paragraphCount || end <= paragraphCount;
|
|
}
|
|
|
|
public sealed record ChapterStructureBoundaryNormalisationResult(
|
|
ChapterStructureModel? ChapterStructure,
|
|
IReadOnlyList<ValidationIssue> Issues);
|