Add deterministic manuscript text statistics
This commit is contained in:
parent
7d33323de7
commit
ea41ec3aac
@ -98,6 +98,20 @@ var tests = new (string Name, Action Test)[]
|
|||||||
("Story Intelligence starts from core import complete", StoryIntelligenceStartsFromCoreImportComplete),
|
("Story Intelligence starts from core import complete", StoryIntelligenceStartsFromCoreImportComplete),
|
||||||
("Generated scene titles persist with provenance", GeneratedSceneTitlesPersistWithProvenance),
|
("Generated scene titles persist with provenance", GeneratedSceneTitlesPersistWithProvenance),
|
||||||
("Scene title display keeps scene numbers visible", SceneTitleDisplayKeepsSceneNumbersVisible),
|
("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 21T repairs durable character understanding", Phase21TRepairsDurableCharacterUnderstanding),
|
||||||
("Phase 21U extracts mandatory character appearance fixtures", Phase21UExtractsMandatoryCharacterAppearanceFixtures),
|
("Phase 21U extracts mandatory character appearance fixtures", Phase21UExtractsMandatoryCharacterAppearanceFixtures),
|
||||||
("Character sex hints are conservative and reviewable", CharacterSexHintsAreConservativeAndReviewable),
|
("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.");
|
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()
|
static void Phase21TRepairsDurableCharacterUnderstanding()
|
||||||
{
|
{
|
||||||
var maggie = StoryMemoryService.ResolveCharacterEvidenceFromAttributes(
|
var maggie = StoryMemoryService.ResolveCharacterEvidenceFromAttributes(
|
||||||
@ -3366,6 +3510,27 @@ static JsonSerializerOptions JsonOptions()
|
|||||||
PropertyNameCaseInsensitive = true
|
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)
|
static void Assert(bool condition, string message)
|
||||||
{
|
{
|
||||||
if (!condition)
|
if (!condition)
|
||||||
@ -3379,3 +3544,32 @@ internal sealed class CaptureImageRequestHandler(Func<HttpRequestMessage, HttpRe
|
|||||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||||
=> Task.FromResult(respond(request));
|
=> 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
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
81
PlotLine/Data/ManuscriptTextStatisticsRepository.cs
Normal file
81
PlotLine/Data/ManuscriptTextStatisticsRepository.cs
Normal 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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -470,6 +470,7 @@ public sealed class Scene
|
|||||||
public int PendingCharacterSuggestionCount { get; set; }
|
public int PendingCharacterSuggestionCount { get; set; }
|
||||||
public int PendingAssetSuggestionCount { get; set; }
|
public int PendingAssetSuggestionCount { get; set; }
|
||||||
public int PendingLocationSuggestionCount { get; set; }
|
public int PendingLocationSuggestionCount { get; set; }
|
||||||
|
public ManuscriptTextStatistics? TextStatistics { get; set; }
|
||||||
|
|
||||||
public string TimeLabel
|
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 sealed class RevisionStatus
|
||||||
{
|
{
|
||||||
public int RevisionStatusID { get; set; }
|
public int RevisionStatusID { get; set; }
|
||||||
|
|||||||
@ -145,6 +145,7 @@ public class Program
|
|||||||
builder.Services.AddScoped<IWordCompanionRepository, WordCompanionRepository>();
|
builder.Services.AddScoped<IWordCompanionRepository, WordCompanionRepository>();
|
||||||
builder.Services.AddScoped<IOnboardingRepository, OnboardingRepository>();
|
builder.Services.AddScoped<IOnboardingRepository, OnboardingRepository>();
|
||||||
builder.Services.AddScoped<IOnboardingBuildRepository, OnboardingBuildRepository>();
|
builder.Services.AddScoped<IOnboardingBuildRepository, OnboardingBuildRepository>();
|
||||||
|
builder.Services.AddScoped<IManuscriptTextStatisticsRepository, ManuscriptTextStatisticsRepository>();
|
||||||
builder.Services.AddScoped<IStoryIntelligenceRepository, StoryIntelligenceRepository>();
|
builder.Services.AddScoped<IStoryIntelligenceRepository, StoryIntelligenceRepository>();
|
||||||
builder.Services.AddScoped<IStoryIntelligenceResultRepository, StoryIntelligenceResultRepository>();
|
builder.Services.AddScoped<IStoryIntelligenceResultRepository, StoryIntelligenceResultRepository>();
|
||||||
builder.Services.AddScoped<IStoryIntelligenceSourceRepository, StoryIntelligenceSourceRepository>();
|
builder.Services.AddScoped<IStoryIntelligenceSourceRepository, StoryIntelligenceSourceRepository>();
|
||||||
@ -217,6 +218,7 @@ public class Program
|
|||||||
builder.Services.AddScoped<IChapterStructureDryRunService, ChapterStructureDryRunService>();
|
builder.Services.AddScoped<IChapterStructureDryRunService, ChapterStructureDryRunService>();
|
||||||
builder.Services.AddScoped<IChapterStoryIntelligenceDryRunService, ChapterStoryIntelligenceDryRunService>();
|
builder.Services.AddScoped<IChapterStoryIntelligenceDryRunService, ChapterStoryIntelligenceDryRunService>();
|
||||||
builder.Services.AddScoped<IManuscriptStructureAnalysisService, ManuscriptStructureAnalysisService>();
|
builder.Services.AddScoped<IManuscriptStructureAnalysisService, ManuscriptStructureAnalysisService>();
|
||||||
|
builder.Services.AddScoped<IManuscriptTextStatisticsService, ManuscriptTextStatisticsService>();
|
||||||
builder.Services.AddScoped<ICoreImportCharacterMappingService, CoreImportCharacterMappingService>();
|
builder.Services.AddScoped<ICoreImportCharacterMappingService, CoreImportCharacterMappingService>();
|
||||||
builder.Services.AddScoped<IStoryIntelligenceResultPersistenceService, StoryIntelligenceResultPersistenceService>();
|
builder.Services.AddScoped<IStoryIntelligenceResultPersistenceService, StoryIntelligenceResultPersistenceService>();
|
||||||
builder.Services.AddScoped<IStoryIntelligenceExistingChapterQueueService, StoryIntelligenceExistingChapterQueueService>();
|
builder.Services.AddScoped<IStoryIntelligenceExistingChapterQueueService, StoryIntelligenceExistingChapterQueueService>();
|
||||||
|
|||||||
@ -1378,6 +1378,8 @@ public sealed class ChapterService(
|
|||||||
ILookupRepository lookups,
|
ILookupRepository lookups,
|
||||||
ISubscriptionService subscriptions,
|
ISubscriptionService subscriptions,
|
||||||
IProjectActivityService activity,
|
IProjectActivityService activity,
|
||||||
|
IManuscriptTextStatisticsRepository manuscriptTextStatisticsRepository,
|
||||||
|
IManuscriptTextStatisticsService manuscriptTextStatistics,
|
||||||
ICurrentUserService currentUser) : IChapterService
|
ICurrentUserService currentUser) : IChapterService
|
||||||
{
|
{
|
||||||
public async Task<ChapterDetailViewModel?> GetDetailAsync(int chapterId)
|
public async Task<ChapterDetailViewModel?> GetDetailAsync(int chapterId)
|
||||||
@ -1396,6 +1398,12 @@ public sealed class ChapterService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
var sceneRows = await scenes.ListByChapterAsync(chapterId);
|
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)
|
if (currentUser.UserId is int userId)
|
||||||
{
|
{
|
||||||
var suggestionCounts = await characters.CountSceneCharacterSuggestionsByChapterAsync(chapterId, userId);
|
var suggestionCounts = await characters.CountSceneCharacterSuggestionsByChapterAsync(chapterId, userId);
|
||||||
@ -1418,6 +1426,7 @@ public sealed class ChapterService(
|
|||||||
Book = book,
|
Book = book,
|
||||||
Chapter = chapter,
|
Chapter = chapter,
|
||||||
Scenes = sceneRows,
|
Scenes = sceneRows,
|
||||||
|
TextStatistics = manuscriptTextStatistics.Aggregate(sceneRows.Select(scene => scene.TextStatistics)),
|
||||||
PendingCharacterSuggestionCount = sceneRows.Sum(x => x.PendingCharacterSuggestionCount),
|
PendingCharacterSuggestionCount = sceneRows.Sum(x => x.PendingCharacterSuggestionCount),
|
||||||
PendingAssetSuggestionCount = sceneRows.Sum(x => x.PendingAssetSuggestionCount),
|
PendingAssetSuggestionCount = sceneRows.Sum(x => x.PendingAssetSuggestionCount),
|
||||||
PendingLocationSuggestionCount = sceneRows.Sum(x => x.PendingLocationSuggestionCount)
|
PendingLocationSuggestionCount = sceneRows.Sum(x => x.PendingLocationSuggestionCount)
|
||||||
@ -1545,6 +1554,7 @@ public sealed class SceneService(
|
|||||||
IProjectActivityService activity,
|
IProjectActivityService activity,
|
||||||
IStorageService storage,
|
IStorageService storage,
|
||||||
IStoryStateService storyState,
|
IStoryStateService storyState,
|
||||||
|
IManuscriptTextStatisticsRepository manuscriptTextStatistics,
|
||||||
ICurrentUserService currentUser) : ISceneService
|
ICurrentUserService currentUser) : ISceneService
|
||||||
{
|
{
|
||||||
public async Task<SceneEditViewModel?> GetCreateAsync(int chapterId)
|
public async Task<SceneEditViewModel?> GetCreateAsync(int chapterId)
|
||||||
@ -1586,6 +1596,7 @@ public sealed class SceneService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
var lookupData = await lookups.GetAllAsync();
|
var lookupData = await lookups.GetAllAsync();
|
||||||
|
var textStatistics = await manuscriptTextStatistics.GetBySceneAsync(sceneId);
|
||||||
return await PopulateWorkspaceDataAsync(PopulateLists(new SceneEditViewModel
|
return await PopulateWorkspaceDataAsync(PopulateLists(new SceneEditViewModel
|
||||||
{
|
{
|
||||||
SceneID = scene.SceneID,
|
SceneID = scene.SceneID,
|
||||||
@ -1609,6 +1620,7 @@ public sealed class SceneService(
|
|||||||
PrimaryLocationID = scene.PrimaryLocationID,
|
PrimaryLocationID = scene.PrimaryLocationID,
|
||||||
FloorPlanID = scene.FloorPlanID,
|
FloorPlanID = scene.FloorPlanID,
|
||||||
InitialFloorPlanFloorID = scene.InitialFloorPlanFloorID,
|
InitialFloorPlanFloorID = scene.InitialFloorPlanFloorID,
|
||||||
|
TextStatistics = textStatistics,
|
||||||
SelectedPurposeTypeIds = scene.SelectedPurposeTypeIds,
|
SelectedPurposeTypeIds = scene.SelectedPurposeTypeIds,
|
||||||
Metrics = scene.Metrics.Select(metric => new SceneMetricInputViewModel
|
Metrics = scene.Metrics.Select(metric => new SceneMetricInputViewModel
|
||||||
{
|
{
|
||||||
|
|||||||
215
PlotLine/Services/ManuscriptTextStatisticsService.cs
Normal file
215
PlotLine/Services/ManuscriptTextStatisticsService.cs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -40,6 +40,7 @@ public sealed class OnboardingService(
|
|||||||
ICharacterRepository characters,
|
ICharacterRepository characters,
|
||||||
IOnboardingBuildRepository builds,
|
IOnboardingBuildRepository builds,
|
||||||
IManuscriptStructureAnalysisService structureAnalysis,
|
IManuscriptStructureAnalysisService structureAnalysis,
|
||||||
|
IManuscriptTextStatisticsService manuscriptTextStatistics,
|
||||||
ICoreImportCharacterMappingService coreImportCharacterMapping,
|
ICoreImportCharacterMappingService coreImportCharacterMapping,
|
||||||
ICurrentUserService currentUser) : IOnboardingService
|
ICurrentUserService currentUser) : IOnboardingService
|
||||||
{
|
{
|
||||||
@ -745,6 +746,8 @@ public sealed class OnboardingService(
|
|||||||
WordEndParagraphIndex = scene.WordEndPosition
|
WordEndParagraphIndex = scene.WordEndPosition
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await manuscriptTextStatistics.RecalculateSceneAsync(sceneId);
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(scene.StructuralSummary))
|
if (!string.IsNullOrWhiteSpace(scene.StructuralSummary))
|
||||||
{
|
{
|
||||||
await builds.SaveSceneStructuralSummaryAsync(sceneId, scene.StructuralSummary);
|
await builds.SaveSceneStructuralSummaryAsync(sceneId, scene.StructuralSummary);
|
||||||
|
|||||||
125
PlotLine/Sql/164_Phase25I_SceneTextStatistics.sql
Normal file
125
PlotLine/Sql/164_Phase25I_SceneTextStatistics.sql
Normal 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
|
||||||
@ -292,6 +292,7 @@ public sealed class ChapterDetailViewModel
|
|||||||
public Book Book { get; set; } = new();
|
public Book Book { get; set; } = new();
|
||||||
public Chapter Chapter { get; set; } = new();
|
public Chapter Chapter { get; set; } = new();
|
||||||
public IReadOnlyList<Scene> Scenes { get; set; } = [];
|
public IReadOnlyList<Scene> Scenes { get; set; } = [];
|
||||||
|
public ManuscriptTextStatistics? TextStatistics { get; set; }
|
||||||
public int PendingCharacterSuggestionCount { get; set; }
|
public int PendingCharacterSuggestionCount { get; set; }
|
||||||
public int PendingAssetSuggestionCount { get; set; }
|
public int PendingAssetSuggestionCount { get; set; }
|
||||||
public int PendingLocationSuggestionCount { get; set; }
|
public int PendingLocationSuggestionCount { get; set; }
|
||||||
@ -357,6 +358,8 @@ public sealed class SceneEditViewModel
|
|||||||
[Display(Name = "Initial floor")]
|
[Display(Name = "Initial floor")]
|
||||||
public int? InitialFloorPlanFloorID { get; set; }
|
public int? InitialFloorPlanFloorID { get; set; }
|
||||||
|
|
||||||
|
public ManuscriptTextStatistics? TextStatistics { get; set; }
|
||||||
|
|
||||||
[Display(Name = "Scene purposes")]
|
[Display(Name = "Scene purposes")]
|
||||||
public List<int> SelectedPurposeTypeIds { get; set; } = [];
|
public List<int> SelectedPurposeTypeIds { get; set; } = [];
|
||||||
|
|
||||||
|
|||||||
@ -76,6 +76,10 @@
|
|||||||
|
|
||||||
<section class="list-section">
|
<section class="list-section">
|
||||||
<h2>Scenes <help-icon key="chapters.scenes" /></h2>
|
<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())
|
@if (!Model.Scenes.Any())
|
||||||
{
|
{
|
||||||
<p class="muted">No scenes yet.</p>
|
<p class="muted">No scenes yet.</p>
|
||||||
|
|||||||
@ -54,6 +54,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</dl>
|
</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">
|
<div class="scene-inspector-v2-summary scene-overview-v2-summary">
|
||||||
<span>Synopsis</span>
|
<span>Synopsis</span>
|
||||||
@if (string.IsNullOrWhiteSpace(Model.Summary))
|
@if (string.IsNullOrWhiteSpace(Model.Summary))
|
||||||
|
|||||||
48
PlotLine/Views/Shared/_ManuscriptTextBalance.cshtml
Normal file
48
PlotLine/Views/Shared/_ManuscriptTextBalance.cshtml
Normal 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";
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -5468,6 +5468,73 @@ body.dragging-location [data-drag-type="location"] {
|
|||||||
-webkit-box-orient: vertical;
|
-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 {
|
.scene-inspector-editor-overlay {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user