From ea41ec3aac329168951239d568b8036d79a8aa06 Mon Sep 17 00:00:00 2001 From: Nick Beckley Date: Thu, 27 Aug 2026 19:03:54 +0000 Subject: [PATCH] Add deterministic manuscript text statistics --- PlotLine.Tests/Program.cs | 194 ++++++++++++++++ .../ManuscriptTextStatisticsRepository.cs | 81 +++++++ PlotLine/Models/CoreModels.cs | 20 ++ PlotLine/Program.cs | 2 + PlotLine/Services/CoreServices.cs | 12 + .../ManuscriptTextStatisticsService.cs | 215 ++++++++++++++++++ PlotLine/Services/OnboardingService.cs | 3 + .../Sql/164_Phase25I_SceneTextStatistics.sql | 125 ++++++++++ PlotLine/ViewModels/CoreViewModels.cs | 3 + PlotLine/Views/Chapters/Details.cshtml | 4 + .../_SceneOverviewCard.cshtml | 5 + .../Shared/_ManuscriptTextBalance.cshtml | 48 ++++ PlotLine/wwwroot/css/site.css | 67 ++++++ 13 files changed, 779 insertions(+) create mode 100644 PlotLine/Data/ManuscriptTextStatisticsRepository.cs create mode 100644 PlotLine/Services/ManuscriptTextStatisticsService.cs create mode 100644 PlotLine/Sql/164_Phase25I_SceneTextStatistics.sql create mode 100644 PlotLine/Views/Shared/_ManuscriptTextBalance.cshtml diff --git a/PlotLine.Tests/Program.cs b/PlotLine.Tests/Program.cs index 25bc992..9d67d4d 100644 --- a/PlotLine.Tests/Program.cs +++ b/PlotLine.Tests/Program.cs @@ -98,6 +98,20 @@ var tests = new (string Name, Action Test)[] ("Story Intelligence starts from core import complete", StoryIntelligenceStartsFromCoreImportComplete), ("Generated scene titles persist with provenance", GeneratedSceneTitlesPersistWithProvenance), ("Scene title display keeps scene numbers visible", SceneTitleDisplayKeepsSceneNumbersVisible), + ("Manuscript text statistics handles pure prose", ManuscriptTextStatisticsHandlesPureProse), + ("Manuscript text statistics handles pure dialogue", ManuscriptTextStatisticsHandlesPureDialogue), + ("Manuscript text statistics splits narrative before dialogue", ManuscriptTextStatisticsSplitsNarrativeBeforeDialogue), + ("Manuscript text statistics splits dialogue before narrative", ManuscriptTextStatisticsSplitsDialogueBeforeNarrative), + ("Manuscript text statistics handles multiple dialogue passages", ManuscriptTextStatisticsHandlesMultipleDialoguePassages), + ("Manuscript text statistics supports smart quotes", ManuscriptTextStatisticsSupportsSmartQuotes), + ("Manuscript text statistics supports straight quotes", ManuscriptTextStatisticsSupportsStraightQuotes), + ("Manuscript text statistics carries multi-paragraph dialogue", ManuscriptTextStatisticsCarriesMultiParagraphDialogue), + ("Manuscript text statistics returns to prose after continued dialogue", ManuscriptTextStatisticsReturnsToProseAfterContinuedDialogue), + ("Manuscript text statistics ignores empty paragraphs", ManuscriptTextStatisticsIgnoresEmptyParagraphs), + ("Manuscript text statistics reconciles scene totals", ManuscriptTextStatisticsReconcilesSceneTotals), + ("Manuscript text statistics aggregates chapter scenes", ManuscriptTextStatisticsAggregatesChapterScenes), + ("Manuscript text statistics recalculates existing scene sources", ManuscriptTextStatisticsRecalculatesExistingSceneSources), + ("Manuscript text statistics does not invoke AI", ManuscriptTextStatisticsDoesNotInvokeAi), ("Phase 21T repairs durable character understanding", Phase21TRepairsDurableCharacterUnderstanding), ("Phase 21U extracts mandatory character appearance fixtures", Phase21UExtractsMandatoryCharacterAppearanceFixtures), ("Character sex hints are conservative and reviewable", CharacterSexHintsAreConservativeAndReviewable), @@ -2633,6 +2647,136 @@ static void SceneTitleDisplayKeepsSceneNumbersVisible() Assert(SceneTitleDisplay.Format(7, "Behind the Fire Exit") == "Scene 7 \u00b7 Behind the Fire Exit", "Meaningful titles should render after the scene number."); } +static void ManuscriptTextStatisticsHandlesPureProse() +{ + var stats = CreateTextStatisticsService().Calculate("Beth crossed the car park and kept her phone hidden in her sleeve."); + + AssertStats(stats, total: 13, dialogue: 0, prose: 13, paragraphs: 1, dialogueParagraphs: 0, proseParagraphs: 1); +} + +static void ManuscriptTextStatisticsHandlesPureDialogue() +{ + var stats = CreateTextStatisticsService().Calculate("\u201cI know exactly what happened after the party.\u201d"); + + AssertStats(stats, total: 8, dialogue: 8, prose: 0, paragraphs: 1, dialogueParagraphs: 1, proseParagraphs: 0); +} + +static void ManuscriptTextStatisticsSplitsNarrativeBeforeDialogue() +{ + var stats = CreateTextStatisticsService().Calculate("Beth looked at Rosie. \u201cYou're not seriously suggesting that?\u201d"); + + AssertStats(stats, total: 9, dialogue: 5, prose: 4, paragraphs: 1, dialogueParagraphs: 1, proseParagraphs: 1); +} + +static void ManuscriptTextStatisticsSplitsDialogueBeforeNarrative() +{ + var stats = CreateTextStatisticsService().Calculate("\u201cI'm completely serious,\u201d Rosie said, folding her arms."); + + AssertStats(stats, total: 8, dialogue: 3, prose: 5, paragraphs: 1, dialogueParagraphs: 1, proseParagraphs: 1); +} + +static void ManuscriptTextStatisticsHandlesMultipleDialoguePassages() +{ + var stats = CreateTextStatisticsService().Calculate("\u201cI'm completely serious,\u201d Rosie said. \u201cAnd you know I'm right.\u201d"); + + AssertStats(stats, total: 10, dialogue: 8, prose: 2, paragraphs: 1, dialogueParagraphs: 1, proseParagraphs: 1); +} + +static void ManuscriptTextStatisticsSupportsSmartQuotes() +{ + var stats = CreateTextStatisticsService().Calculate("Judy whispered, \u201cThe office light is still on.\u201d"); + + AssertStats(stats, total: 8, dialogue: 6, prose: 2, paragraphs: 1, dialogueParagraphs: 1, proseParagraphs: 1); +} + +static void ManuscriptTextStatisticsSupportsStraightQuotes() +{ + var stats = CreateTextStatisticsService().Calculate("Judy whispered, \"The office light is still on.\""); + + AssertStats(stats, total: 8, dialogue: 6, prose: 2, paragraphs: 1, dialogueParagraphs: 1, proseParagraphs: 1); +} + +static void ManuscriptTextStatisticsCarriesMultiParagraphDialogue() +{ + var stats = CreateTextStatisticsService().Calculate(""" + "This is the first paragraph of a long speech. + "This is the second paragraph. + "And this is the final paragraph." + """); + + AssertStats(stats, total: 20, dialogue: 20, prose: 0, paragraphs: 3, dialogueParagraphs: 3, proseParagraphs: 0); +} + +static void ManuscriptTextStatisticsReturnsToProseAfterContinuedDialogue() +{ + var stats = CreateTextStatisticsService().Calculate(""" + "This is the first paragraph of a long speech. + "This is the final paragraph." + Rosie shut the notebook and waited. + """); + + AssertStats(stats, total: 20, dialogue: 14, prose: 6, paragraphs: 3, dialogueParagraphs: 2, proseParagraphs: 1); +} + +static void ManuscriptTextStatisticsIgnoresEmptyParagraphs() +{ + var stats = CreateTextStatisticsService().Calculate(""" + Beth waited. + + + "Say something," Rosie said. + """); + + AssertStats(stats, total: 6, dialogue: 2, prose: 4, paragraphs: 2, dialogueParagraphs: 1, proseParagraphs: 2); +} + +static void ManuscriptTextStatisticsReconcilesSceneTotals() +{ + var stats = CreateTextStatisticsService().Calculate("Beth shook her head. \u201cAbsolutely not.\u201d Rosie smiled anyway."); + + Assert(stats.DialogueWordCount + stats.ProseWordCount == stats.TotalWordCount, "Dialogue and prose words should reconcile to total words."); + AssertStats(stats, total: 9, dialogue: 2, prose: 7, paragraphs: 1, dialogueParagraphs: 1, proseParagraphs: 1); +} + +static void ManuscriptTextStatisticsAggregatesChapterScenes() +{ + var service = CreateTextStatisticsService(); + var sceneOne = service.Calculate("Beth stood by the door. \u201cWe leave now.\u201d"); + var sceneTwo = service.Calculate("The corridor was empty and cold."); + var aggregate = service.Aggregate([sceneOne, sceneTwo]); + + AssertStats(aggregate, total: 14, dialogue: 3, prose: 11, paragraphs: 2, dialogueParagraphs: 1, proseParagraphs: 2); +} + +static void ManuscriptTextStatisticsRecalculatesExistingSceneSources() +{ + var repository = new FakeManuscriptTextStatisticsRepository("Beth waited. \u201cTell me the truth.\u201d"); + var service = new ManuscriptTextStatisticsService(repository, null!); + + service.RecalculateSceneAsync(42).GetAwaiter().GetResult(); + + Assert(repository.SavedSceneID == 42, "Recalculation should save statistics for the requested SceneID."); + Assert(repository.SavedStatistics is not null, "Recalculation should persist calculated statistics."); + AssertStats(repository.SavedStatistics!, total: 6, dialogue: 4, prose: 2, paragraphs: 1, dialogueParagraphs: 1, proseParagraphs: 1); +} + +static void ManuscriptTextStatisticsDoesNotInvokeAi() +{ + var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); + var service = File.ReadAllText(Path.Combine(root, "Services/ManuscriptTextStatisticsService.cs")); + var onboarding = File.ReadAllText(Path.Combine(root, "Services/OnboardingService.cs")); + var repository = File.ReadAllText(Path.Combine(root, "Data/ManuscriptTextStatisticsRepository.cs")); + var migration = File.ReadAllText(Path.Combine(root, "Sql/164_Phase25I_SceneTextStatistics.sql")); + + Assert(!service.Contains("IStoryIntelligence", StringComparison.Ordinal), "Text statistics service should not depend on Story Intelligence."); + Assert(!service.Contains("OpenAI", StringComparison.Ordinal), "Text statistics service should not reference OpenAI."); + Assert(!repository.Contains("CommandType.Text", StringComparison.Ordinal), "Text statistics repository should remain stored-procedure backed."); + Assert(onboarding.Contains("manuscriptTextStatistics.RecalculateSceneAsync(sceneId)", StringComparison.Ordinal), "Core import should calculate stats after canonical source persistence."); + Assert(migration.Contains("CREATE TABLE dbo.SceneTextStatistics", StringComparison.Ordinal), "Scene text statistics should be persisted."); + Assert(migration.Contains("CREATE OR ALTER PROCEDURE dbo.SceneTextStatistics_Save", StringComparison.Ordinal), "Scene text statistics should be saved by stored procedure."); + Assert(migration.Contains("ON DELETE CASCADE", StringComparison.Ordinal), "Scene text statistics should not block permanent scene deletion."); +} + static void Phase21TRepairsDurableCharacterUnderstanding() { var maggie = StoryMemoryService.ResolveCharacterEvidenceFromAttributes( @@ -3366,6 +3510,27 @@ static JsonSerializerOptions JsonOptions() PropertyNameCaseInsensitive = true }; +static ManuscriptTextStatisticsService CreateTextStatisticsService() + => new(new FakeManuscriptTextStatisticsRepository(string.Empty), null!); + +static void AssertStats( + ManuscriptTextStatistics stats, + int total, + int dialogue, + int prose, + int paragraphs, + int dialogueParagraphs, + int proseParagraphs) +{ + Assert(stats.TotalWordCount == total, $"Expected {total:N0} total words, got {stats.TotalWordCount:N0}."); + Assert(stats.DialogueWordCount == dialogue, $"Expected {dialogue:N0} dialogue words, got {stats.DialogueWordCount:N0}."); + Assert(stats.ProseWordCount == prose, $"Expected {prose:N0} prose words, got {stats.ProseWordCount:N0}."); + Assert(stats.TotalParagraphCount == paragraphs, $"Expected {paragraphs:N0} paragraphs, got {stats.TotalParagraphCount:N0}."); + Assert(stats.DialogueParagraphCount == dialogueParagraphs, $"Expected {dialogueParagraphs:N0} dialogue paragraphs, got {stats.DialogueParagraphCount:N0}."); + Assert(stats.ProseParagraphCount == proseParagraphs, $"Expected {proseParagraphs:N0} prose paragraphs, got {stats.ProseParagraphCount:N0}."); + Assert(stats.DialogueWordCount + stats.ProseWordCount == stats.TotalWordCount, "Dialogue and prose words should reconcile to total words."); +} + static void Assert(bool condition, string message) { if (!condition) @@ -3379,3 +3544,32 @@ internal sealed class CaptureImageRequestHandler(Func SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => Task.FromResult(respond(request)); } + +internal sealed class FakeManuscriptTextStatisticsRepository(string sourceText) : IManuscriptTextStatisticsRepository +{ + public int? SavedSceneID { get; private set; } + public ManuscriptTextStatistics? SavedStatistics { get; private set; } + + public Task SaveAsync(int sceneId, ManuscriptTextStatistics statistics) + { + SavedSceneID = sceneId; + SavedStatistics = statistics; + return Task.CompletedTask; + } + + public Task GetBySceneAsync(int sceneId) + => Task.FromResult(SavedStatistics); + + public Task> ListByChapterAsync(int chapterId) + => Task.FromResult>(new Dictionary()); + + public Task GetSourceBySceneAsync(int sceneId) + => Task.FromResult(new SceneManuscriptSourceRecord + { + SceneID = sceneId, + ProjectID = 10, + BookID = 20, + ChapterID = 30, + SourceText = sourceText + }); +} diff --git a/PlotLine/Data/ManuscriptTextStatisticsRepository.cs b/PlotLine/Data/ManuscriptTextStatisticsRepository.cs new file mode 100644 index 0000000..fa520ad --- /dev/null +++ b/PlotLine/Data/ManuscriptTextStatisticsRepository.cs @@ -0,0 +1,81 @@ +using System.Data; +using Dapper; +using PlotLine.Models; + +namespace PlotLine.Data; + +public interface IManuscriptTextStatisticsRepository +{ + Task SaveAsync(int sceneId, ManuscriptTextStatistics statistics); + Task GetBySceneAsync(int sceneId); + Task> ListByChapterAsync(int chapterId); + Task GetSourceBySceneAsync(int sceneId); +} + +public sealed class ManuscriptTextStatisticsRepository(ISqlConnectionFactory connectionFactory) : IManuscriptTextStatisticsRepository +{ + public async Task SaveAsync(int sceneId, ManuscriptTextStatistics statistics) + { + using var connection = connectionFactory.CreateConnection(); + await connection.ExecuteAsync( + "dbo.SceneTextStatistics_Save", + new + { + SceneID = sceneId, + statistics.TotalWordCount, + statistics.DialogueWordCount, + statistics.ProseWordCount, + statistics.TotalParagraphCount, + statistics.DialogueParagraphCount, + statistics.ProseParagraphCount + }, + commandType: CommandType.StoredProcedure); + } + + public async Task GetBySceneAsync(int sceneId) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleOrDefaultAsync( + "dbo.SceneTextStatistics_GetByScene", + new { SceneID = sceneId }, + commandType: CommandType.StoredProcedure); + } + + public async Task> ListByChapterAsync(int chapterId) + { + using var connection = connectionFactory.CreateConnection(); + var rows = await connection.QueryAsync( + "dbo.SceneTextStatistics_ListByChapter", + new { ChapterID = chapterId }, + commandType: CommandType.StoredProcedure); + return rows.ToDictionary(row => row.SceneID, row => new ManuscriptTextStatistics + { + TotalWordCount = row.TotalWordCount, + DialogueWordCount = row.DialogueWordCount, + ProseWordCount = row.ProseWordCount, + TotalParagraphCount = row.TotalParagraphCount, + DialogueParagraphCount = row.DialogueParagraphCount, + ProseParagraphCount = row.ProseParagraphCount + }); + } + + public async Task GetSourceBySceneAsync(int sceneId) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleOrDefaultAsync( + "dbo.SceneManuscriptSource_GetByScene", + new { SceneID = sceneId }, + commandType: CommandType.StoredProcedure); + } + + private sealed class SceneTextStatisticsRow + { + public int SceneID { get; init; } + public int TotalWordCount { get; init; } + public int DialogueWordCount { get; init; } + public int ProseWordCount { get; init; } + public int TotalParagraphCount { get; init; } + public int DialogueParagraphCount { get; init; } + public int ProseParagraphCount { get; init; } + } +} diff --git a/PlotLine/Models/CoreModels.cs b/PlotLine/Models/CoreModels.cs index a31ea9a..7d742c4 100644 --- a/PlotLine/Models/CoreModels.cs +++ b/PlotLine/Models/CoreModels.cs @@ -470,6 +470,7 @@ public sealed class Scene public int PendingCharacterSuggestionCount { get; set; } public int PendingAssetSuggestionCount { get; set; } public int PendingLocationSuggestionCount { get; set; } + public ManuscriptTextStatistics? TextStatistics { get; set; } public string TimeLabel { @@ -485,6 +486,25 @@ public sealed class Scene } } +public sealed class ManuscriptTextStatistics +{ + public int TotalWordCount { get; set; } + public int DialogueWordCount { get; set; } + public int ProseWordCount { get; set; } + public int TotalParagraphCount { get; set; } + public int DialogueParagraphCount { get; set; } + public int ProseParagraphCount { get; set; } + public decimal DialoguePercentage => TotalWordCount <= 0 + ? 0m + : Math.Round(DialogueWordCount * 100m / TotalWordCount, 1); + public decimal? AverageDialogueParagraphLength => DialogueParagraphCount <= 0 + ? null + : Math.Round(DialogueWordCount * 1m / DialogueParagraphCount, 1); + public decimal? AverageProseParagraphLength => ProseParagraphCount <= 0 + ? null + : Math.Round(ProseWordCount * 1m / ProseParagraphCount, 1); +} + public sealed class RevisionStatus { public int RevisionStatusID { get; set; } diff --git a/PlotLine/Program.cs b/PlotLine/Program.cs index e2ae28c..c8b418a 100644 --- a/PlotLine/Program.cs +++ b/PlotLine/Program.cs @@ -145,6 +145,7 @@ public class Program builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -217,6 +218,7 @@ public class Program builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/PlotLine/Services/CoreServices.cs b/PlotLine/Services/CoreServices.cs index 41bee1c..c9ddf31 100644 --- a/PlotLine/Services/CoreServices.cs +++ b/PlotLine/Services/CoreServices.cs @@ -1378,6 +1378,8 @@ public sealed class ChapterService( ILookupRepository lookups, ISubscriptionService subscriptions, IProjectActivityService activity, + IManuscriptTextStatisticsRepository manuscriptTextStatisticsRepository, + IManuscriptTextStatisticsService manuscriptTextStatistics, ICurrentUserService currentUser) : IChapterService { public async Task GetDetailAsync(int chapterId) @@ -1396,6 +1398,12 @@ public sealed class ChapterService( } var sceneRows = await scenes.ListByChapterAsync(chapterId); + var textStatisticsByScene = await manuscriptTextStatisticsRepository.ListByChapterAsync(chapterId); + foreach (var scene in sceneRows) + { + scene.TextStatistics = textStatisticsByScene.GetValueOrDefault(scene.SceneID); + } + if (currentUser.UserId is int userId) { var suggestionCounts = await characters.CountSceneCharacterSuggestionsByChapterAsync(chapterId, userId); @@ -1418,6 +1426,7 @@ public sealed class ChapterService( Book = book, Chapter = chapter, Scenes = sceneRows, + TextStatistics = manuscriptTextStatistics.Aggregate(sceneRows.Select(scene => scene.TextStatistics)), PendingCharacterSuggestionCount = sceneRows.Sum(x => x.PendingCharacterSuggestionCount), PendingAssetSuggestionCount = sceneRows.Sum(x => x.PendingAssetSuggestionCount), PendingLocationSuggestionCount = sceneRows.Sum(x => x.PendingLocationSuggestionCount) @@ -1545,6 +1554,7 @@ public sealed class SceneService( IProjectActivityService activity, IStorageService storage, IStoryStateService storyState, + IManuscriptTextStatisticsRepository manuscriptTextStatistics, ICurrentUserService currentUser) : ISceneService { public async Task GetCreateAsync(int chapterId) @@ -1586,6 +1596,7 @@ public sealed class SceneService( } var lookupData = await lookups.GetAllAsync(); + var textStatistics = await manuscriptTextStatistics.GetBySceneAsync(sceneId); return await PopulateWorkspaceDataAsync(PopulateLists(new SceneEditViewModel { SceneID = scene.SceneID, @@ -1609,6 +1620,7 @@ public sealed class SceneService( PrimaryLocationID = scene.PrimaryLocationID, FloorPlanID = scene.FloorPlanID, InitialFloorPlanFloorID = scene.InitialFloorPlanFloorID, + TextStatistics = textStatistics, SelectedPurposeTypeIds = scene.SelectedPurposeTypeIds, Metrics = scene.Metrics.Select(metric => new SceneMetricInputViewModel { diff --git a/PlotLine/Services/ManuscriptTextStatisticsService.cs b/PlotLine/Services/ManuscriptTextStatisticsService.cs new file mode 100644 index 0000000..0762e29 --- /dev/null +++ b/PlotLine/Services/ManuscriptTextStatisticsService.cs @@ -0,0 +1,215 @@ +using PlotLine.Data; +using PlotLine.Models; + +namespace PlotLine.Services; + +public interface IManuscriptTextStatisticsService +{ + ManuscriptTextStatistics Calculate(string? sourceText); + ManuscriptTextStatistics Aggregate(IEnumerable statistics); + Task RecalculateSceneAsync(int sceneId); + Task RecalculateChapterAsync(int chapterId); + Task RecalculateBookAsync(int bookId); +} + +public sealed class ManuscriptTextStatisticsService( + IManuscriptTextStatisticsRepository repository, + IOnboardingBuildRepository manuscriptSources) : IManuscriptTextStatisticsService +{ + public ManuscriptTextStatistics Calculate(string? sourceText) + { + var paragraphs = StoryIntelligenceParagraphs.Split(sourceText); + var totalWords = 0; + var dialogueWords = 0; + var proseWords = 0; + var dialogueParagraphs = 0; + var proseParagraphs = 0; + var inDialogue = false; + + foreach (var paragraph in paragraphs) + { + var paragraphStartInDialogue = inDialogue; + var paragraphHasDialogue = paragraphStartInDialogue; + var paragraphHasProse = false; + var firstContentIndex = FirstNonWhitespaceIndex(paragraph); + var segmentStart = 0; + + for (var index = 0; index < paragraph.Length; index++) + { + var quote = QuoteKind(paragraph[index]); + if (quote == ManuscriptQuoteKind.None) + { + continue; + } + + if (inDialogue && IsContinuationOpeningQuote(quote, index, firstContentIndex)) + { + AddSegment(paragraph, segmentStart, index, inDialogue, ref totalWords, ref dialogueWords, ref proseWords, ref paragraphHasDialogue, ref paragraphHasProse); + segmentStart = index + 1; + continue; + } + + AddSegment(paragraph, segmentStart, index, inDialogue, ref totalWords, ref dialogueWords, ref proseWords, ref paragraphHasDialogue, ref paragraphHasProse); + + if (quote == ManuscriptQuoteKind.SmartOpen) + { + inDialogue = true; + paragraphHasDialogue = true; + } + else if (quote == ManuscriptQuoteKind.SmartClose) + { + inDialogue = false; + } + else + { + inDialogue = !inDialogue; + if (inDialogue) + { + paragraphHasDialogue = true; + } + } + + segmentStart = index + 1; + } + + AddSegment(paragraph, segmentStart, paragraph.Length, inDialogue, ref totalWords, ref dialogueWords, ref proseWords, ref paragraphHasDialogue, ref paragraphHasProse); + + if (paragraphHasDialogue) + { + dialogueParagraphs++; + } + + if (paragraphHasProse) + { + proseParagraphs++; + } + } + + return new ManuscriptTextStatistics + { + TotalWordCount = totalWords, + DialogueWordCount = dialogueWords, + ProseWordCount = proseWords, + TotalParagraphCount = paragraphs.Count, + DialogueParagraphCount = dialogueParagraphs, + ProseParagraphCount = proseParagraphs + }; + } + + public ManuscriptTextStatistics Aggregate(IEnumerable statistics) + { + var rows = statistics.Where(row => row is not null).Select(row => row!).ToList(); + return new ManuscriptTextStatistics + { + TotalWordCount = rows.Sum(row => row.TotalWordCount), + DialogueWordCount = rows.Sum(row => row.DialogueWordCount), + ProseWordCount = rows.Sum(row => row.ProseWordCount), + TotalParagraphCount = rows.Sum(row => row.TotalParagraphCount), + DialogueParagraphCount = rows.Sum(row => row.DialogueParagraphCount), + ProseParagraphCount = rows.Sum(row => row.ProseParagraphCount) + }; + } + + public async Task RecalculateSceneAsync(int sceneId) + { + var source = await repository.GetSourceBySceneAsync(sceneId); + if (source is null) + { + return; + } + + await repository.SaveAsync(source.SceneID, Calculate(source.SourceText)); + } + + public async Task RecalculateChapterAsync(int chapterId) + { + var sources = await manuscriptSources.ListSceneManuscriptSourcesByChapterAsync(chapterId); + foreach (var source in sources) + { + await repository.SaveAsync(source.SceneID, Calculate(source.SourceText)); + } + } + + public async Task RecalculateBookAsync(int bookId) + { + var sources = await manuscriptSources.ListSceneManuscriptSourcesByBookAsync(bookId); + foreach (var source in sources) + { + await repository.SaveAsync(source.SceneID, Calculate(source.SourceText)); + } + } + + private static void AddSegment( + string paragraph, + int start, + int end, + bool isDialogue, + ref int totalWords, + ref int dialogueWords, + ref int proseWords, + ref bool paragraphHasDialogue, + ref bool paragraphHasProse) + { + if (end <= start) + { + return; + } + + var words = CountWords(paragraph[start..end]); + if (words == 0) + { + return; + } + + totalWords += words; + if (isDialogue) + { + dialogueWords += words; + paragraphHasDialogue = true; + } + else + { + proseWords += words; + paragraphHasProse = true; + } + } + + private static int CountWords(string value) + => string.IsNullOrWhiteSpace(value) + ? 0 + : value.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries).Length; + + private static int FirstNonWhitespaceIndex(string paragraph) + { + for (var index = 0; index < paragraph.Length; index++) + { + if (!char.IsWhiteSpace(paragraph[index])) + { + return index; + } + } + + return -1; + } + + private static bool IsContinuationOpeningQuote(ManuscriptQuoteKind quote, int index, int firstContentIndex) + => index == firstContentIndex + && (quote == ManuscriptQuoteKind.SmartOpen || quote == ManuscriptQuoteKind.Straight); + + private static ManuscriptQuoteKind QuoteKind(char value) + => value switch + { + '"' => ManuscriptQuoteKind.Straight, + '\u201c' => ManuscriptQuoteKind.SmartOpen, + '\u201d' => ManuscriptQuoteKind.SmartClose, + _ => ManuscriptQuoteKind.None + }; + + private enum ManuscriptQuoteKind + { + None, + Straight, + SmartOpen, + SmartClose + } +} diff --git a/PlotLine/Services/OnboardingService.cs b/PlotLine/Services/OnboardingService.cs index 3305af8..ea4eff0 100644 --- a/PlotLine/Services/OnboardingService.cs +++ b/PlotLine/Services/OnboardingService.cs @@ -40,6 +40,7 @@ public sealed class OnboardingService( ICharacterRepository characters, IOnboardingBuildRepository builds, IManuscriptStructureAnalysisService structureAnalysis, + IManuscriptTextStatisticsService manuscriptTextStatistics, ICoreImportCharacterMappingService coreImportCharacterMapping, ICurrentUserService currentUser) : IOnboardingService { @@ -745,6 +746,8 @@ public sealed class OnboardingService( WordEndParagraphIndex = scene.WordEndPosition }); + await manuscriptTextStatistics.RecalculateSceneAsync(sceneId); + if (!string.IsNullOrWhiteSpace(scene.StructuralSummary)) { await builds.SaveSceneStructuralSummaryAsync(sceneId, scene.StructuralSummary); diff --git a/PlotLine/Sql/164_Phase25I_SceneTextStatistics.sql b/PlotLine/Sql/164_Phase25I_SceneTextStatistics.sql new file mode 100644 index 0000000..d6f2e40 --- /dev/null +++ b/PlotLine/Sql/164_Phase25I_SceneTextStatistics.sql @@ -0,0 +1,125 @@ +SET ANSI_NULLS ON; +GO +SET QUOTED_IDENTIFIER ON; +GO + +IF OBJECT_ID(N'dbo.SceneTextStatistics', N'U') IS NULL +BEGIN + CREATE TABLE dbo.SceneTextStatistics + ( + SceneID int NOT NULL CONSTRAINT PK_SceneTextStatistics PRIMARY KEY, + TotalWordCount int NOT NULL CONSTRAINT DF_SceneTextStatistics_TotalWordCount DEFAULT 0, + DialogueWordCount int NOT NULL CONSTRAINT DF_SceneTextStatistics_DialogueWordCount DEFAULT 0, + ProseWordCount int NOT NULL CONSTRAINT DF_SceneTextStatistics_ProseWordCount DEFAULT 0, + TotalParagraphCount int NOT NULL CONSTRAINT DF_SceneTextStatistics_TotalParagraphCount DEFAULT 0, + DialogueParagraphCount int NOT NULL CONSTRAINT DF_SceneTextStatistics_DialogueParagraphCount DEFAULT 0, + ProseParagraphCount int NOT NULL CONSTRAINT DF_SceneTextStatistics_ProseParagraphCount DEFAULT 0, + CalculatedUtc datetime2 NOT NULL CONSTRAINT DF_SceneTextStatistics_CalculatedUtc DEFAULT SYSUTCDATETIME(), + UpdatedUtc datetime2 NOT NULL CONSTRAINT DF_SceneTextStatistics_UpdatedUtc DEFAULT SYSUTCDATETIME(), + CONSTRAINT CK_SceneTextStatistics_NonNegative CHECK + ( + TotalWordCount >= 0 + AND DialogueWordCount >= 0 + AND ProseWordCount >= 0 + AND TotalParagraphCount >= 0 + AND DialogueParagraphCount >= 0 + AND ProseParagraphCount >= 0 + ), + CONSTRAINT CK_SceneTextStatistics_WordReconciliation CHECK (DialogueWordCount + ProseWordCount = TotalWordCount), + CONSTRAINT FK_SceneTextStatistics_Scenes FOREIGN KEY (SceneID) REFERENCES dbo.Scenes(SceneID) ON DELETE CASCADE + ); +END; +GO + +CREATE OR ALTER PROCEDURE dbo.SceneTextStatistics_Save + @SceneID int, + @TotalWordCount int, + @DialogueWordCount int, + @ProseWordCount int, + @TotalParagraphCount int, + @DialogueParagraphCount int, + @ProseParagraphCount int +AS +BEGIN + SET NOCOUNT ON; + + IF @DialogueWordCount + @ProseWordCount <> @TotalWordCount + THROW 51000, 'Dialogue and prose word counts must reconcile to the total word count.', 1; + + MERGE dbo.SceneTextStatistics AS target + USING (SELECT @SceneID AS SceneID) AS source + ON target.SceneID = source.SceneID + WHEN MATCHED THEN + UPDATE SET TotalWordCount = @TotalWordCount, + DialogueWordCount = @DialogueWordCount, + ProseWordCount = @ProseWordCount, + TotalParagraphCount = @TotalParagraphCount, + DialogueParagraphCount = @DialogueParagraphCount, + ProseParagraphCount = @ProseParagraphCount, + CalculatedUtc = SYSUTCDATETIME(), + UpdatedUtc = SYSUTCDATETIME() + WHEN NOT MATCHED THEN + INSERT + ( + SceneID, TotalWordCount, DialogueWordCount, ProseWordCount, + TotalParagraphCount, DialogueParagraphCount, ProseParagraphCount + ) + VALUES + ( + @SceneID, @TotalWordCount, @DialogueWordCount, @ProseWordCount, + @TotalParagraphCount, @DialogueParagraphCount, @ProseParagraphCount + ); +END; +GO + +CREATE OR ALTER PROCEDURE dbo.SceneTextStatistics_GetByScene + @SceneID int +AS +BEGIN + SET NOCOUNT ON; + + SELECT TotalWordCount, DialogueWordCount, ProseWordCount, + TotalParagraphCount, DialogueParagraphCount, ProseParagraphCount + FROM dbo.SceneTextStatistics + WHERE SceneID = @SceneID; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.SceneTextStatistics_ListByChapter + @ChapterID int +AS +BEGIN + SET NOCOUNT ON; + + SELECT stats.SceneID, stats.TotalWordCount, stats.DialogueWordCount, stats.ProseWordCount, + stats.TotalParagraphCount, stats.DialogueParagraphCount, stats.ProseParagraphCount + FROM dbo.SceneTextStatistics stats + INNER JOIN dbo.Scenes scene ON scene.SceneID = stats.SceneID + WHERE scene.ChapterID = @ChapterID + AND scene.IsArchived = 0 + ORDER BY scene.SortOrder, scene.SceneNumber, scene.SceneID; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.SceneTextStatistics_ListMissingByBook + @BookID int +AS +BEGIN + SET NOCOUNT ON; + + SELECT source.SceneID, source.ProjectID, source.BookID, source.ChapterID, + source.SourceWordCount, source.SourceCharacterCount, source.SourceKind, + source.CreatedUtc, source.UpdatedUtc + FROM dbo.SceneManuscriptSources source + INNER JOIN dbo.Scenes scene ON scene.SceneID = source.SceneID + WHERE source.BookID = @BookID + AND scene.IsArchived = 0 + AND NOT EXISTS + ( + SELECT 1 + FROM dbo.SceneTextStatistics stats + WHERE stats.SceneID = source.SceneID + ) + ORDER BY source.ChapterID, scene.SortOrder, scene.SceneNumber, scene.SceneID; +END; +GO diff --git a/PlotLine/ViewModels/CoreViewModels.cs b/PlotLine/ViewModels/CoreViewModels.cs index e4b7e4c..f3a5917 100644 --- a/PlotLine/ViewModels/CoreViewModels.cs +++ b/PlotLine/ViewModels/CoreViewModels.cs @@ -292,6 +292,7 @@ public sealed class ChapterDetailViewModel public Book Book { get; set; } = new(); public Chapter Chapter { get; set; } = new(); public IReadOnlyList Scenes { get; set; } = []; + public ManuscriptTextStatistics? TextStatistics { get; set; } public int PendingCharacterSuggestionCount { get; set; } public int PendingAssetSuggestionCount { get; set; } public int PendingLocationSuggestionCount { get; set; } @@ -357,6 +358,8 @@ public sealed class SceneEditViewModel [Display(Name = "Initial floor")] public int? InitialFloorPlanFloorID { get; set; } + public ManuscriptTextStatistics? TextStatistics { get; set; } + [Display(Name = "Scene purposes")] public List SelectedPurposeTypeIds { get; set; } = []; diff --git a/PlotLine/Views/Chapters/Details.cshtml b/PlotLine/Views/Chapters/Details.cshtml index d05b197..1dd0c3f 100644 --- a/PlotLine/Views/Chapters/Details.cshtml +++ b/PlotLine/Views/Chapters/Details.cshtml @@ -76,6 +76,10 @@

Scenes

+ @if (Model.TextStatistics is not null && Model.TextStatistics.TotalWordCount > 0) + { + + } @if (!Model.Scenes.Any()) {

No scenes yet.

diff --git a/PlotLine/Views/Shared/SceneInspectorV2/_SceneOverviewCard.cshtml b/PlotLine/Views/Shared/SceneInspectorV2/_SceneOverviewCard.cshtml index c6de35d..c339507 100644 --- a/PlotLine/Views/Shared/SceneInspectorV2/_SceneOverviewCard.cshtml +++ b/PlotLine/Views/Shared/SceneInspectorV2/_SceneOverviewCard.cshtml @@ -54,6 +54,11 @@ + @if (Model.TextStatistics is not null && Model.TextStatistics.TotalWordCount > 0) + { + + } +
Synopsis @if (string.IsNullOrWhiteSpace(Model.Summary)) diff --git a/PlotLine/Views/Shared/_ManuscriptTextBalance.cshtml b/PlotLine/Views/Shared/_ManuscriptTextBalance.cshtml new file mode 100644 index 0000000..d55937f --- /dev/null +++ b/PlotLine/Views/Shared/_ManuscriptTextBalance.cshtml @@ -0,0 +1,48 @@ +@model PlotLine.Models.ManuscriptTextStatistics +@{ + var dialoguePercent = Math.Clamp(Model.DialoguePercentage, 0m, 100m); + var balanceLabel = BalanceLabel(dialoguePercent); +} + +
+
+ @balanceLabel + @dialoguePercent.ToString("0.#")% dialogue +
+ +

+ @Model.TotalWordCount.ToString("N0") words · @Model.DialogueWordCount.ToString("N0") dialogue · @Model.ProseWordCount.ToString("N0") prose + @if (Model.TotalParagraphCount > 0) + { + · @Model.TotalParagraphCount.ToString("N0") paragraphs · @Model.DialogueParagraphCount.ToString("N0") dialogue + } +

+
+ +@functions { + private static string BalanceLabel(decimal dialoguePercent) + { + if (dialoguePercent < 25m) + { + return "Very prose-heavy"; + } + + if (dialoguePercent < 45m) + { + return "Prose-heavy"; + } + + if (dialoguePercent <= 55m) + { + return "Balanced"; + } + + return dialoguePercent <= 75m ? "Dialogue-heavy" : "Very dialogue-heavy"; + } +} diff --git a/PlotLine/wwwroot/css/site.css b/PlotLine/wwwroot/css/site.css index aabfc27..181034d 100644 --- a/PlotLine/wwwroot/css/site.css +++ b/PlotLine/wwwroot/css/site.css @@ -5468,6 +5468,73 @@ body.dragging-location [data-drag-type="location"] { -webkit-box-orient: vertical; } +.manuscript-balance { + display: grid; + gap: 6px; + min-width: 0; + border: 1px solid rgba(47, 111, 99, 0.13); + border-radius: 8px; + background: rgba(47, 111, 99, 0.045); + padding: 10px 12px; +} + +.manuscript-balance__heading, +.manuscript-balance__scale { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + min-width: 0; +} + +.manuscript-balance__heading span { + color: var(--plotline-accent-dark); + font-size: 0.84rem; + font-weight: 800; +} + +.manuscript-balance__heading strong { + color: var(--plotline-ink); + font-size: 0.92rem; + font-weight: 900; + white-space: nowrap; +} + +.manuscript-balance__scale span { + flex: 0 0 auto; + color: var(--plotline-muted); + font-size: 0.75rem; + font-weight: 800; +} + +.manuscript-balance__track { + position: relative; + flex: 1 1 auto; + min-width: 72px; + height: 8px; + border-radius: 999px; + background: linear-gradient(90deg, rgba(97, 107, 88, 0.55), rgba(47, 111, 99, 0.66)); +} + +.manuscript-balance__track i { + position: absolute; + top: 50%; + width: 14px; + height: 14px; + border: 2px solid var(--plotline-surface-elevated); + border-radius: 999px; + background: var(--plotline-accent-dark); + box-shadow: 0 2px 5px rgba(36, 31, 27, 0.2); + transform: translate(-50%, -50%); +} + +.manuscript-balance p { + margin: 0; + color: var(--plotline-muted); + font-size: 0.84rem; + font-weight: 700; +} + .scene-inspector-editor-overlay { position: absolute; inset: 0;