Add deterministic manuscript text statistics

This commit is contained in:
Nick Beckley 2026-08-27 19:03:54 +00:00
parent 7d33323de7
commit ea41ec3aac
13 changed files with 779 additions and 0 deletions

View File

@ -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<HttpRequestMessage, HttpRe
protected override Task<HttpResponseMessage> 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<ManuscriptTextStatistics?> GetBySceneAsync(int sceneId)
=> Task.FromResult<ManuscriptTextStatistics?>(SavedStatistics);
public Task<IReadOnlyDictionary<int, ManuscriptTextStatistics>> ListByChapterAsync(int chapterId)
=> Task.FromResult<IReadOnlyDictionary<int, ManuscriptTextStatistics>>(new Dictionary<int, ManuscriptTextStatistics>());
public Task<SceneManuscriptSourceRecord?> GetSourceBySceneAsync(int sceneId)
=> Task.FromResult<SceneManuscriptSourceRecord?>(new SceneManuscriptSourceRecord
{
SceneID = sceneId,
ProjectID = 10,
BookID = 20,
ChapterID = 30,
SourceText = sourceText
});
}

View File

@ -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<ManuscriptTextStatistics?> GetBySceneAsync(int sceneId);
Task<IReadOnlyDictionary<int, ManuscriptTextStatistics>> ListByChapterAsync(int chapterId);
Task<SceneManuscriptSourceRecord?> 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<ManuscriptTextStatistics?> GetBySceneAsync(int sceneId)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleOrDefaultAsync<ManuscriptTextStatistics>(
"dbo.SceneTextStatistics_GetByScene",
new { SceneID = sceneId },
commandType: CommandType.StoredProcedure);
}
public async Task<IReadOnlyDictionary<int, ManuscriptTextStatistics>> ListByChapterAsync(int chapterId)
{
using var connection = connectionFactory.CreateConnection();
var rows = await connection.QueryAsync<SceneTextStatisticsRow>(
"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<SceneManuscriptSourceRecord?> GetSourceBySceneAsync(int sceneId)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleOrDefaultAsync<SceneManuscriptSourceRecord>(
"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; }
}
}

View File

@ -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; }

View File

@ -145,6 +145,7 @@ public class Program
builder.Services.AddScoped<IWordCompanionRepository, WordCompanionRepository>();
builder.Services.AddScoped<IOnboardingRepository, OnboardingRepository>();
builder.Services.AddScoped<IOnboardingBuildRepository, OnboardingBuildRepository>();
builder.Services.AddScoped<IManuscriptTextStatisticsRepository, ManuscriptTextStatisticsRepository>();
builder.Services.AddScoped<IStoryIntelligenceRepository, StoryIntelligenceRepository>();
builder.Services.AddScoped<IStoryIntelligenceResultRepository, StoryIntelligenceResultRepository>();
builder.Services.AddScoped<IStoryIntelligenceSourceRepository, StoryIntelligenceSourceRepository>();
@ -217,6 +218,7 @@ public class Program
builder.Services.AddScoped<IChapterStructureDryRunService, ChapterStructureDryRunService>();
builder.Services.AddScoped<IChapterStoryIntelligenceDryRunService, ChapterStoryIntelligenceDryRunService>();
builder.Services.AddScoped<IManuscriptStructureAnalysisService, ManuscriptStructureAnalysisService>();
builder.Services.AddScoped<IManuscriptTextStatisticsService, ManuscriptTextStatisticsService>();
builder.Services.AddScoped<ICoreImportCharacterMappingService, CoreImportCharacterMappingService>();
builder.Services.AddScoped<IStoryIntelligenceResultPersistenceService, StoryIntelligenceResultPersistenceService>();
builder.Services.AddScoped<IStoryIntelligenceExistingChapterQueueService, StoryIntelligenceExistingChapterQueueService>();

View File

@ -1378,6 +1378,8 @@ public sealed class ChapterService(
ILookupRepository lookups,
ISubscriptionService subscriptions,
IProjectActivityService activity,
IManuscriptTextStatisticsRepository manuscriptTextStatisticsRepository,
IManuscriptTextStatisticsService manuscriptTextStatistics,
ICurrentUserService currentUser) : IChapterService
{
public async Task<ChapterDetailViewModel?> 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<SceneEditViewModel?> 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
{

View File

@ -0,0 +1,215 @@
using PlotLine.Data;
using PlotLine.Models;
namespace PlotLine.Services;
public interface IManuscriptTextStatisticsService
{
ManuscriptTextStatistics Calculate(string? sourceText);
ManuscriptTextStatistics Aggregate(IEnumerable<ManuscriptTextStatistics?> 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<ManuscriptTextStatistics?> 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
}
}

View File

@ -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);

View File

@ -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

View File

@ -292,6 +292,7 @@ public sealed class ChapterDetailViewModel
public Book Book { get; set; } = new();
public Chapter Chapter { get; set; } = new();
public IReadOnlyList<Scene> 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<int> SelectedPurposeTypeIds { get; set; } = [];

View File

@ -76,6 +76,10 @@
<section class="list-section">
<h2>Scenes <help-icon key="chapters.scenes" /></h2>
@if (Model.TextStatistics is not null && Model.TextStatistics.TotalWordCount > 0)
{
<partial name="~/Views/Shared/_ManuscriptTextBalance.cshtml" model="Model.TextStatistics" />
}
@if (!Model.Scenes.Any())
{
<p class="muted">No scenes yet.</p>

View File

@ -54,6 +54,11 @@
</div>
</dl>
@if (Model.TextStatistics is not null && Model.TextStatistics.TotalWordCount > 0)
{
<partial name="~/Views/Shared/_ManuscriptTextBalance.cshtml" model="Model.TextStatistics" />
}
<div class="scene-inspector-v2-summary scene-overview-v2-summary">
<span>Synopsis</span>
@if (string.IsNullOrWhiteSpace(Model.Summary))

View File

@ -0,0 +1,48 @@
@model PlotLine.Models.ManuscriptTextStatistics
@{
var dialoguePercent = Math.Clamp(Model.DialoguePercentage, 0m, 100m);
var balanceLabel = BalanceLabel(dialoguePercent);
}
<div class="manuscript-balance" aria-label="Dialogue and prose balance">
<div class="manuscript-balance__heading">
<span>@balanceLabel</span>
<strong>@dialoguePercent.ToString("0.#")% dialogue</strong>
</div>
<div class="manuscript-balance__scale" aria-hidden="true">
<span>Prose-heavy</span>
<div class="manuscript-balance__track">
<i style="left: @dialoguePercent.ToString("0.#")%"></i>
</div>
<span>Dialogue-heavy</span>
</div>
<p>
@Model.TotalWordCount.ToString("N0") words · @Model.DialogueWordCount.ToString("N0") dialogue · @Model.ProseWordCount.ToString("N0") prose
@if (Model.TotalParagraphCount > 0)
{
<span> · @Model.TotalParagraphCount.ToString("N0") paragraphs · @Model.DialogueParagraphCount.ToString("N0") dialogue</span>
}
</p>
</div>
@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";
}
}

View File

@ -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;