From f728168173c8df10b6888f7ae525af56aa798298 Mon Sep 17 00:00:00 2001 From: Nick Beckley Date: Sat, 4 Jul 2026 22:32:59 +0100 Subject: [PATCH] Phase 20R.1 --- .../Data/StoryIntelligenceResultRepository.cs | 1 + .../Docs/AI/Chapter-Structure-Prompt-V1.md | 19 +++- .../StoryIntelligencePersistenceModels.cs | 4 + .../ChapterStoryIntelligenceDryRunService.cs | 11 +- .../PersistedStoryIntelligenceRunner.cs | 11 +- ...IntelligenceExistingChapterQueueService.cs | 1 + ...StoryIntelligenceFullChapterTestService.cs | 9 +- .../StoryIntelligenceOpenAIInfrastructure.cs | 10 +- .../Services/StoryIntelligenceParagraphs.cs | 33 ++++++ ...oryIntelligenceResultPersistenceService.cs | 1 + ...hase20R_DeterministicChapterParagraphs.sql | 102 ++++++++++++++++++ .../Admin/StoryIntelligenceRunDetails.cshtml | 68 ++++++++++++ 12 files changed, 238 insertions(+), 32 deletions(-) create mode 100644 PlotLine/Services/StoryIntelligenceParagraphs.cs create mode 100644 PlotLine/Sql/120_Phase20R_DeterministicChapterParagraphs.sql diff --git a/PlotLine/Data/StoryIntelligenceResultRepository.cs b/PlotLine/Data/StoryIntelligenceResultRepository.cs index 58ee0a7..c96a1a6 100644 --- a/PlotLine/Data/StoryIntelligenceResultRepository.cs +++ b/PlotLine/Data/StoryIntelligenceResultRepository.cs @@ -172,6 +172,7 @@ public sealed class StoryIntelligenceResultRepository(ISqlConnectionFactory conn request.SourceFileSizeBytes, request.SourceWordCount, request.SourceCharacterCount, + request.SourceParagraphCount, request.SourceChapterCount, request.Model, request.PromptVersionsSummary diff --git a/PlotLine/Docs/AI/Chapter-Structure-Prompt-V1.md b/PlotLine/Docs/AI/Chapter-Structure-Prompt-V1.md index 978b582..37982a7 100644 --- a/PlotLine/Docs/AI/Chapter-Structure-Prompt-V1.md +++ b/PlotLine/Docs/AI/Chapter-Structure-Prompt-V1.md @@ -37,11 +37,23 @@ Do not create boundaries for ordinary paragraph changes, small action beats, dia ## Paragraph Numbering -Treat the supplied chapter text as a sequence of non-empty paragraphs. +The supplied chapter text is already split into numbered paragraphs. -The first non-empty paragraph is paragraph `1`. +Each paragraph begins with an explicit marker such as: -Each `startParagraph` and `endParagraph` must refer to these paragraph numbers. +```text +[1] First paragraph text... +[2] Second paragraph text... +[3] Third paragraph text... +``` + +Use only the supplied paragraph numbers. + +Each `startParagraph` and `endParagraph` must refer to those explicit paragraph numbers. + +Never invent paragraph numbers. + +Never return an `endParagraph` greater than the highest paragraph number supplied. Scene boundaries must: @@ -49,6 +61,7 @@ Scene boundaries must: - cover the chapter in order; - not overlap; - not leave gaps; +- be contiguous and non-overlapping where possible; - use increasing `sceneNumber` values starting at `1`. ## Output Requirements diff --git a/PlotLine/Models/StoryIntelligencePersistenceModels.cs b/PlotLine/Models/StoryIntelligencePersistenceModels.cs index 51916f7..cdac7fa 100644 --- a/PlotLine/Models/StoryIntelligencePersistenceModels.cs +++ b/PlotLine/Models/StoryIntelligencePersistenceModels.cs @@ -32,6 +32,7 @@ public sealed class StoryIntelligenceRunSaveRequest public long? SourceFileSizeBytes { get; init; } public int? SourceWordCount { get; init; } public int? SourceCharacterCount { get; init; } + public int? SourceParagraphCount { get; init; } public int? SourceChapterCount { get; init; } public int? SourceDetectedImagesCount { get; init; } public int? SourceDetectedTablesCount { get; init; } @@ -69,6 +70,7 @@ public sealed class StoryIntelligenceRunQueueRequest public long? SourceFileSizeBytes { get; init; } public int? SourceWordCount { get; init; } public int? SourceCharacterCount { get; init; } + public int? SourceParagraphCount { get; init; } public int? SourceChapterCount { get; init; } public string Model { get; init; } = string.Empty; public string PromptVersionsSummary { get; init; } = string.Empty; @@ -156,6 +158,7 @@ public sealed class StoryIntelligenceSavedRun public long? SourceFileSizeBytes { get; init; } public int? SourceWordCount { get; init; } public int? SourceCharacterCount { get; init; } + public int? SourceParagraphCount { get; init; } public int? SourceChapterCount { get; init; } public int? SourceDetectedImagesCount { get; init; } public int? SourceDetectedTablesCount { get; init; } @@ -200,6 +203,7 @@ public sealed class StoryIntelligenceQueuedRun public string? SourceText { get; init; } public int? SourceWordCount { get; init; } public int? SourceCharacterCount { get; init; } + public int? SourceParagraphCount { get; init; } public int? SourceChapterCount { get; init; } public string PromptVersion { get; init; } = string.Empty; public string? PromptVersionsSummary { get; init; } diff --git a/PlotLine/Services/ChapterStoryIntelligenceDryRunService.cs b/PlotLine/Services/ChapterStoryIntelligenceDryRunService.cs index ca2a1da..33d048a 100644 --- a/PlotLine/Services/ChapterStoryIntelligenceDryRunService.cs +++ b/PlotLine/Services/ChapterStoryIntelligenceDryRunService.cs @@ -39,7 +39,7 @@ public sealed class ChapterStoryIntelligenceDryRunService( var chapterPromptVersion = versions.GetPromptVersion(ChapterPromptFile); var scenePromptVersion = versions.GetPromptVersion(ScenePromptFile); var clientStatus = client.GetConfigurationStatus(); - var paragraphs = SplitParagraphs(form.ChapterText); + var paragraphs = StoryIntelligenceParagraphs.Split(form.ChapterText); var chapterContextJson = BuildChapterContextJson(form); try @@ -50,7 +50,8 @@ public sealed class ChapterStoryIntelligenceDryRunService( } var chapterTemplate = await prompts.LoadPromptAsync(ChapterPromptFile, cancellationToken); - var chapterPrompt = BuildChapterPrompt(chapterTemplate, chapterContextJson, form.ChapterText); + var numberedChapterText = StoryIntelligenceParagraphs.Number(paragraphs); + var chapterPrompt = BuildChapterPrompt(chapterTemplate, chapterContextJson, numberedChapterText); var chapterResult = await client.ExecutePromptAsync(chapterPrompt, chapterPromptVersion, cancellationToken); var chapterJson = ExtractOutputText(chapterResult.RawResponseText); var parsedChapter = JsonSerializer.Deserialize(chapterJson, JsonOptions); @@ -313,12 +314,6 @@ public sealed class ChapterStoryIntelligenceDryRunService( return outputText.Trim(); } - private static List SplitParagraphs(string chapterText) - => chapterText.Split(["\r\n\r\n", "\n\n", "\r\r"], StringSplitOptions.RemoveEmptyEntries) - .Select(paragraph => paragraph.Trim()) - .Where(paragraph => !string.IsNullOrWhiteSpace(paragraph)) - .ToList(); - private static string Preview(string value) { var cleaned = value.ReplaceLineEndings(" ").Trim(); diff --git a/PlotLine/Services/PersistedStoryIntelligenceRunner.cs b/PlotLine/Services/PersistedStoryIntelligenceRunner.cs index 1b5b932..2611ad4 100644 --- a/PlotLine/Services/PersistedStoryIntelligenceRunner.cs +++ b/PlotLine/Services/PersistedStoryIntelligenceRunner.cs @@ -66,7 +66,7 @@ public sealed class PersistedStoryIntelligenceRunner( } await ThrowIfCancellationRequestedAsync(run.StoryIntelligenceRunID, stopwatch.ElapsedMilliseconds, cancellationToken); - var paragraphs = SplitParagraphs(run.SourceText); + var paragraphs = StoryIntelligenceParagraphs.Split(run.SourceText); if (paragraphs.Count == 0) { throw new StoryIntelligenceRunFailureException( @@ -76,7 +76,8 @@ public sealed class PersistedStoryIntelligenceRunner( var chapterContextJson = BuildChapterContextJson(run); var chapterTemplate = await prompts.LoadPromptAsync(ChapterPromptFile, cancellationToken); - var chapterPrompt = BuildChapterPrompt(chapterTemplate, chapterContextJson, run.SourceText); + var numberedChapterText = StoryIntelligenceParagraphs.Number(paragraphs); + var chapterPrompt = BuildChapterPrompt(chapterTemplate, chapterContextJson, numberedChapterText); currentFailureStage = StoryIntelligenceFailureStages.ChapterStructure; await repository.UpdateProgressAsync( @@ -386,12 +387,6 @@ public sealed class PersistedStoryIntelligenceRunner( : outputText.Trim(); } - private static List SplitParagraphs(string chapterText) - => chapterText.Split(["\r\n\r\n", "\n\n", "\r\r"], StringSplitOptions.RemoveEmptyEntries) - .Select(paragraph => paragraph.Trim()) - .Where(paragraph => !string.IsNullOrWhiteSpace(paragraph)) - .ToList(); - private static string SerializeParsed(T? value) => value is null ? string.Empty : JsonSerializer.Serialize(value, JsonOptions); diff --git a/PlotLine/Services/StoryIntelligenceExistingChapterQueueService.cs b/PlotLine/Services/StoryIntelligenceExistingChapterQueueService.cs index 079ea80..0ad3acc 100644 --- a/PlotLine/Services/StoryIntelligenceExistingChapterQueueService.cs +++ b/PlotLine/Services/StoryIntelligenceExistingChapterQueueService.cs @@ -70,6 +70,7 @@ public sealed class StoryIntelligenceExistingChapterQueueService( ChapterText = sourceText, SourceWordCount = selected.SourceWordCount ?? CountWords(sourceText), SourceCharacterCount = selected.SourceCharacterCount ?? sourceText.Length, + SourceParagraphCount = StoryIntelligenceParagraphs.Split(sourceText).Count, SourceChapterCount = 1, Model = clientStatus.Model, PromptVersionsSummary = "Chapter=Chapter-Structure-Prompt-V1; Scene=Scene-Prompt-V1" diff --git a/PlotLine/Services/StoryIntelligenceFullChapterTestService.cs b/PlotLine/Services/StoryIntelligenceFullChapterTestService.cs index 2e192ab..8d49638 100644 --- a/PlotLine/Services/StoryIntelligenceFullChapterTestService.cs +++ b/PlotLine/Services/StoryIntelligenceFullChapterTestService.cs @@ -73,6 +73,7 @@ public sealed class StoryIntelligenceFullChapterTestService( SourceFileSizeBytes = source.SourceFileSizeBytes, SourceWordCount = preflight.WordCount, SourceCharacterCount = preflight.CharacterCount, + SourceParagraphCount = preflight.ParagraphCount, SourceChapterCount = 1, Model = clientStatus.Model, PromptVersionsSummary = PromptVersionsSummary @@ -117,7 +118,7 @@ public sealed class StoryIntelligenceFullChapterTestService( { var characterCount = text.Length; var wordCount = CountWords(text); - var paragraphCount = SplitParagraphs(text).Count; + var paragraphCount = StoryIntelligenceParagraphs.Split(text).Count; var estimatedInputTokens = EstimateTokens(text); var estimatedPipelineInputTokens = estimatedInputTokens * 2; var warnings = BuildWarnings(text, characterCount, paragraphCount); @@ -211,12 +212,6 @@ public sealed class StoryIntelligenceFullChapterTestService( ? 0 : value.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries).Length; - private static IReadOnlyList SplitParagraphs(string value) - => value.Split(["\r\n\r\n", "\n\n", "\r\r"], StringSplitOptions.RemoveEmptyEntries) - .Select(paragraph => paragraph.Trim()) - .Where(paragraph => !string.IsNullOrWhiteSpace(paragraph)) - .ToList(); - private static int EstimateTokens(string value) => Math.Max(1, (int)Math.Ceiling(value.Length / 4m)); diff --git a/PlotLine/Services/StoryIntelligenceOpenAIInfrastructure.cs b/PlotLine/Services/StoryIntelligenceOpenAIInfrastructure.cs index a9149a8..992d5bf 100644 --- a/PlotLine/Services/StoryIntelligenceOpenAIInfrastructure.cs +++ b/PlotLine/Services/StoryIntelligenceOpenAIInfrastructure.cs @@ -517,7 +517,8 @@ public sealed class ChapterStructureDryRunService( var promptVersion = versions.GetPromptVersion(ChapterPromptFile); var clientStatus = client.GetConfigurationStatus(); var contextJson = BuildChapterContextJson(form); - var paragraphCount = CountParagraphs(form.ChapterText); + var paragraphs = StoryIntelligenceParagraphs.Split(form.ChapterText); + var paragraphCount = paragraphs.Count; try { @@ -527,7 +528,8 @@ public sealed class ChapterStructureDryRunService( } var template = await prompts.LoadPromptAsync(ChapterPromptFile, cancellationToken); - var completedPrompt = BuildChapterPrompt(template, contextJson, form.ChapterText); + var numberedChapterText = StoryIntelligenceParagraphs.Number(paragraphs); + var completedPrompt = BuildChapterPrompt(template, contextJson, numberedChapterText); var result = await client.ExecutePromptAsync(completedPrompt, promptVersion, cancellationToken); var chapterJson = ExtractOutputText(result.RawResponseText); var parsedChapter = DeserializeChapter(chapterJson); @@ -649,10 +651,6 @@ public sealed class ChapterStructureDryRunService( private static ChapterStructureModel? DeserializeChapter(string chapterJson) => JsonSerializer.Deserialize(chapterJson, JsonOptions); - private static int CountParagraphs(string chapterText) - => chapterText.Split(["\r\n\r\n", "\n\n", "\r\r"], StringSplitOptions.RemoveEmptyEntries) - .Count(paragraph => !string.IsNullOrWhiteSpace(paragraph)); - private static string FormatDuration(TimeSpan duration) => duration.TotalSeconds < 1 ? $"{duration.TotalMilliseconds:0} ms" diff --git a/PlotLine/Services/StoryIntelligenceParagraphs.cs b/PlotLine/Services/StoryIntelligenceParagraphs.cs new file mode 100644 index 0000000..4fdfaee --- /dev/null +++ b/PlotLine/Services/StoryIntelligenceParagraphs.cs @@ -0,0 +1,33 @@ +using System.Text; + +namespace PlotLine.Services; + +public static class StoryIntelligenceParagraphs +{ + public static IReadOnlyList Split(string? chapterText) + => (chapterText ?? string.Empty) + .Split(["\r\n\r\n", "\n\n", "\r\r"], StringSplitOptions.RemoveEmptyEntries) + .Select(paragraph => paragraph.Trim()) + .Where(paragraph => !string.IsNullOrWhiteSpace(paragraph)) + .ToList(); + + public static string Number(IReadOnlyList paragraphs) + { + var builder = new StringBuilder(); + for (var index = 0; index < paragraphs.Count; index++) + { + if (index > 0) + { + builder.AppendLine(); + builder.AppendLine(); + } + + builder.Append('[') + .Append(index + 1) + .Append("] ") + .Append(paragraphs[index]); + } + + return builder.ToString(); + } +} diff --git a/PlotLine/Services/StoryIntelligenceResultPersistenceService.cs b/PlotLine/Services/StoryIntelligenceResultPersistenceService.cs index c8e2ad2..89de08b 100644 --- a/PlotLine/Services/StoryIntelligenceResultPersistenceService.cs +++ b/PlotLine/Services/StoryIntelligenceResultPersistenceService.cs @@ -81,6 +81,7 @@ public sealed class StoryIntelligenceResultPersistenceService( ChapterText = form.ChapterText, SourceWordCount = CountWords(form.ChapterText), SourceCharacterCount = form.ChapterText.Length, + SourceParagraphCount = StoryIntelligenceParagraphs.Split(form.ChapterText).Count, SourceChapterCount = 1, Model = clientStatus.Model, PromptVersionsSummary = promptVersionsSummary diff --git a/PlotLine/Sql/120_Phase20R_DeterministicChapterParagraphs.sql b/PlotLine/Sql/120_Phase20R_DeterministicChapterParagraphs.sql new file mode 100644 index 0000000..65c8817 --- /dev/null +++ b/PlotLine/Sql/120_Phase20R_DeterministicChapterParagraphs.sql @@ -0,0 +1,102 @@ +IF COL_LENGTH(N'dbo.StoryIntelligenceRuns', N'SourceParagraphCount') IS NULL +BEGIN + ALTER TABLE dbo.StoryIntelligenceRuns ADD SourceParagraphCount int NULL; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceRun_QueueAdminText + @UserID int, + @ProjectID int = NULL, + @BookID int = NULL, + @ChapterID int = NULL, + @ChapterNumber decimal(9, 2) = NULL, + @SourceType nvarchar(50), + @SourceLabel nvarchar(300), + @SourceText nvarchar(max), + @SourceFileName nvarchar(260) = NULL, + @SourceFileSizeBytes bigint = NULL, + @SourceWordCount int = NULL, + @SourceCharacterCount int = NULL, + @SourceParagraphCount int = NULL, + @SourceChapterCount int = NULL, + @Model nvarchar(100), + @PromptVersionsSummary nvarchar(500) +AS +BEGIN + SET NOCOUNT ON; + + INSERT dbo.StoryIntelligenceRuns + ( + UserID, ProjectID, BookID, ChapterID, ChapterNumber, Status, SourceType, SourceLabel, SourceText, + SourceFileName, SourceFileSizeBytes, SourceWordCount, SourceCharacterCount, SourceParagraphCount, + SourceChapterCount, Model, PromptVersion, PromptVersionsSummary, StartedUtc, CurrentStage, CurrentMessage, + CompletedScenes, FailedScenes + ) + VALUES + ( + @UserID, @ProjectID, @BookID, @ChapterID, @ChapterNumber, N'Pending', @SourceType, @SourceLabel, @SourceText, + @SourceFileName, @SourceFileSizeBytes, @SourceWordCount, @SourceCharacterCount, @SourceParagraphCount, + @SourceChapterCount, @Model, @PromptVersionsSummary, @PromptVersionsSummary, SYSUTCDATETIME(), N'Pending', + N'Queued for Story Intelligence processing.', 0, 0 + ); + + SELECT CAST(SCOPE_IDENTITY() AS int) AS StoryIntelligenceRunID; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceRun_GetAdmin + @StoryIntelligenceRunID int +AS +BEGIN + SET NOCOUNT ON; + + SELECT + r.StoryIntelligenceRunID, + r.UserID, + r.ProjectID, + p.ProjectName AS ProjectTitle, + r.BookID, + b.BookTitle, + r.ChapterID, + r.ChapterNumber, + r.Status, + r.SourceType, + r.SourceFileName, + r.SourceFileSizeBytes, + r.SourceWordCount, + r.SourceCharacterCount, + r.SourceParagraphCount, + r.SourceChapterCount, + r.SourceDetectedImagesCount, + r.SourceDetectedTablesCount, + r.SourceDetectedFootnotesCount, + r.SourceDetectedCommentsCount, + r.CurrentStage, + r.CurrentMessage, + r.TotalDetectedScenes, + r.CompletedScenes, + r.FailedScenes, + r.CancellationRequestedUtc, + r.CancelledUtc, + r.PromptVersion, + r.PromptVersionsSummary, + r.Model, + r.StartedUtc, + r.CompletedUtc, + r.FailureStage, + r.TotalInputTokens, + r.TotalOutputTokens, + r.TotalTokens, + r.TotalDurationMs, + r.EstimatedCostGBP, + r.EstimatedCostUSD, + r.ErrorMessage, + r.ErrorDetail, + r.CreatedUtc, + r.UpdatedUtc + FROM dbo.StoryIntelligenceRuns r + LEFT JOIN dbo.Projects p ON p.ProjectID = r.ProjectID + LEFT JOIN dbo.Books b ON b.BookID = r.BookID + WHERE r.StoryIntelligenceRunID = @StoryIntelligenceRunID; +END; +GO diff --git a/PlotLine/Views/Admin/StoryIntelligenceRunDetails.cshtml b/PlotLine/Views/Admin/StoryIntelligenceRunDetails.cshtml index 75ffcb0..a69316c 100644 --- a/PlotLine/Views/Admin/StoryIntelligenceRunDetails.cshtml +++ b/PlotLine/Views/Admin/StoryIntelligenceRunDetails.cshtml @@ -93,6 +93,8 @@ else
@Display(run.SourceWordCount)
Source character count
@Display(run.SourceCharacterCount)
+
Submitted paragraph count
+
@Display(run.SourceParagraphCount)
Source chapter count
@Display(run.SourceChapterCount)
Detected document features
@@ -169,6 +171,18 @@ else +
+
Highest returned endParagraph
+
@Display(HighestReturnedEndParagraph(chapter))
+
Returned scene boundary ranges
+
@DisplayBoundaryRanges(chapter)
+
+ @if (ReturnedEndParagraphExceedsSubmitted(chapter, run)) + { +
+ The model returned paragraph numbers beyond the submitted chapter. This usually means the prompt was not using explicit numbered paragraphs. +
+ } @@ -300,4 +314,58 @@ else return "Scene failed. See parsed JSON for details."; } + + private static int? HighestReturnedEndParagraph(StoryIntelligenceSavedChapterResult chapter) + => ReadBoundaryRanges(chapter).Select(range => range.End).DefaultIfEmpty().Max() is var max && max > 0 ? max : null; + + private static string DisplayBoundaryRanges(StoryIntelligenceSavedChapterResult chapter) + { + var ranges = ReadBoundaryRanges(chapter).ToList(); + return ranges.Count == 0 + ? "None" + : string.Join(", ", ranges.Select(range => $"{range.Start}-{range.End}")); + } + + private static bool ReturnedEndParagraphExceedsSubmitted(StoryIntelligenceSavedChapterResult chapter, StoryIntelligenceSavedRun run) + { + var highest = HighestReturnedEndParagraph(chapter); + var submitted = run.SourceParagraphCount; + return highest.HasValue && submitted.HasValue && highest.Value > submitted.Value; + } + + private static IReadOnlyList<(int Start, int End)> ReadBoundaryRanges(StoryIntelligenceSavedChapterResult chapter) + { + if (string.IsNullOrWhiteSpace(chapter.ParsedJson)) + { + return []; + } + + try + { + using var document = System.Text.Json.JsonDocument.Parse(chapter.ParsedJson); + if (!document.RootElement.TryGetProperty("sceneBoundaries", out var boundaries) + || boundaries.ValueKind != System.Text.Json.JsonValueKind.Array) + { + return []; + } + + var ranges = new List<(int Start, int End)>(); + foreach (var boundary in boundaries.EnumerateArray()) + { + var start = boundary.TryGetProperty("startParagraph", out var startElement) && startElement.TryGetInt32(out var startValue) + ? startValue + : 0; + var end = boundary.TryGetProperty("endParagraph", out var endElement) && endElement.TryGetInt32(out var endValue) + ? endValue + : 0; + ranges.Add((start, end)); + } + + return ranges; + } + catch (System.Text.Json.JsonException) + { + return []; + } + } }