Refactor Story Intelligence to use canonical scenes

This commit is contained in:
Nick Beckley 2026-08-26 21:12:06 +00:00
parent dc5232378f
commit b6aa53ee12
15 changed files with 502 additions and 166 deletions

View File

@ -83,6 +83,8 @@ var tests = new (string Name, Action Test)[]
("Core import remaps characters after review", CoreImportRemapsCharactersAfterReview), ("Core import remaps characters after review", CoreImportRemapsCharactersAfterReview),
("Scan review separates known matches from unresolved names", ScanReviewSeparatesKnownMatchesFromUnresolvedNames), ("Scan review separates known matches from unresolved names", ScanReviewSeparatesKnownMatchesFromUnresolvedNames),
("Story Intelligence uses persisted scene source after restart", StoryIntelligenceUsesPersistedSceneSourceAfterRestart), ("Story Intelligence uses persisted scene source after restart", StoryIntelligenceUsesPersistedSceneSourceAfterRestart),
("Story Intelligence canonical mode bypasses duplicate boundary analysis", StoryIntelligenceCanonicalModeBypassesDuplicateBoundaryAnalysis),
("Story Intelligence downstream imports prefer canonical SceneIDs", StoryIntelligenceDownstreamImportsPreferCanonicalSceneIds),
("Word sync requires save acknowledgement", WordSyncRequiresSaveAcknowledgement), ("Word sync requires save acknowledgement", WordSyncRequiresSaveAcknowledgement),
("Word sync retry resumes existing structure", WordSyncRetryResumesExistingStructure), ("Word sync retry resumes existing structure", WordSyncRetryResumesExistingStructure),
("Story Intelligence enriches existing structural scenes", StoryIntelligenceEnrichesExistingStructuralScenes), ("Story Intelligence enriches existing structural scenes", StoryIntelligenceEnrichesExistingStructuralScenes),
@ -1981,7 +1983,56 @@ static void StoryIntelligenceUsesPersistedSceneSourceAfterRestart()
Assert(onboardingStory.Contains("durableSource = await sources.GetChapterSourceAsync", StringComparison.Ordinal), "Onboarding Story Intelligence should read durable chapter text from scene sources."); Assert(onboardingStory.Contains("durableSource = await sources.GetChapterSourceAsync", StringComparison.Ordinal), "Onboarding Story Intelligence should read durable chapter text from scene sources.");
Assert(onboardingStory.Contains("CoreImportComplete", StringComparison.Ordinal), "Optional onboarding Story Intelligence should require core import completion."); Assert(onboardingStory.Contains("CoreImportComplete", StringComparison.Ordinal), "Optional onboarding Story Intelligence should require core import completion.");
Assert(existingQueue.Contains("sources.GetChapterSourceAsync", StringComparison.Ordinal), "Later Story Intelligence queueing should start from persisted chapter/scene source, not onboarding DTOs."); Assert(existingQueue.Contains("sources.GetChapterSourceAsync", StringComparison.Ordinal), "Later Story Intelligence queueing should start from persisted chapter/scene source, not onboarding DTOs.");
Assert(runner.Contains("structuralSummary = boundary.StructuralSummary", StringComparison.Ordinal), "Scene Intelligence context should receive structural summaries from the boundary pass."); Assert(runner.Contains("FirstNonBlank(source.StructuralSummary, scene.StructuralSummary)", StringComparison.Ordinal), "Scene Intelligence context should receive structural summaries from canonical scene sources.");
}
static void StoryIntelligenceCanonicalModeBypassesDuplicateBoundaryAnalysis()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var runner = File.ReadAllText(Path.Combine(root, "Services/PersistedStoryIntelligenceRunner.cs"));
var repository = File.ReadAllText(Path.Combine(root, "Data/OnboardingBuildRepository.cs"));
var progressView = File.ReadAllText(Path.Combine(root, "Views/Onboarding/StoryIntelligenceProgress.cshtml"));
var progressJs = File.ReadAllText(Path.Combine(root, "wwwroot/js/story-intelligence-progress.js"));
var migration = File.ReadAllText(Path.Combine(root, "Sql/161_Phase25F_CanonicalSceneSourceStoryIntelligence.sql"));
Assert(runner.Contains("\"CanonicalSceneSource\"", StringComparison.Ordinal), "Runner should have an explicit canonical scene source analysis mode.");
Assert(runner.Contains("BuildCanonicalSceneBlock", StringComparison.Ordinal), "Canonical mode should build scene prompts from existing scenes.");
Assert(runner.Contains("ProcessLegacyChapterAsync", StringComparison.Ordinal), "Legacy Chapter Structure analysis should remain isolated in its fallback path.");
Assert(runner.Contains("builds.ListSceneManuscriptSourcesByChapterAsync", StringComparison.Ordinal), "Canonical mode should load scene source rows directly.");
Assert(runner.Contains("throw new StoryIntelligenceRunFailureException(\n StoryIntelligenceFailureStages.DocumentRead", StringComparison.Ordinal), "Partial canonical source coverage should fail as a data integrity issue.");
Assert(runner.Contains("SceneID = block.SceneID", StringComparison.Ordinal), "Saved scene results should retain canonical SceneIDs.");
Assert(!runner.Contains("existingScenes.FirstOrDefault(scene => Convert.ToInt32(scene.SceneNumber) == block.TemporarySceneNumber)", StringComparison.Ordinal), "Runner should not remap canonical scene results by temporary scene number.");
Assert(repository.Contains("SceneManuscriptSource_ListByChapter", StringComparison.Ordinal), "Canonical source lookup should use a stored procedure.");
Assert(migration.Contains("CREATE OR ALTER PROCEDURE dbo.SceneManuscriptSource_ListByChapter", StringComparison.Ordinal), "Forward migration should provide the canonical scene source procedure.");
Assert(progressView.Contains("Loading scenes...", StringComparison.Ordinal), "Canonical progress should load scenes rather than claim to find them.");
Assert(progressJs.Contains("Loading scenes...", StringComparison.Ordinal), "Live canonical progress should load scenes rather than claim to find them.");
}
static void StoryIntelligenceDownstreamImportsPreferCanonicalSceneIds()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var commit = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceImportCommitService.cs"));
var repository = File.ReadAllText(Path.Combine(root, "Data/StoryIntelligenceResultRepository.cs"));
var model = File.ReadAllText(Path.Combine(root, "Models/StoryIntelligencePersistenceModels.cs"));
var importServices = new[]
{
"StoryIntelligenceCharacterImportService.cs",
"StoryIntelligenceRelationshipImportService.cs",
"StoryIntelligenceLocationImportService.cs",
"StoryIntelligenceKnowledgeImportService.cs",
"StoryIntelligenceAssetImportService.cs",
"StoryIntelligenceNarrativeReviewService.cs"
};
Assert(model.Contains("public int? SceneID { get; init; }", StringComparison.Ordinal), "Import preview items should carry canonical SceneID.");
Assert(commit.Contains("SceneID = sceneResult.SceneID", StringComparison.Ordinal), "Import preview should preserve the saved scene result ID.");
Assert(repository.Contains("SceneID = item.SceneID ??", StringComparison.Ordinal), "Commit should update canonical scenes by SceneID before falling back to order.");
foreach (var file in importServices)
{
var source = File.ReadAllText(Path.Combine(root, "Services", file));
Assert(source.Contains("var importedById = importedScenes.ToDictionary(scene => scene.SceneID, scene => scene);", StringComparison.Ordinal), $"{file} should index imported scenes by canonical SceneID.");
Assert(source.Contains("sceneResult.SceneID.HasValue", StringComparison.Ordinal), $"{file} should resolve scene evidence by canonical SceneID before range/number fallbacks.");
}
} }
static void WordSyncRequiresSaveAcknowledgement() static void WordSyncRequiresSaveAcknowledgement()
@ -2047,7 +2098,7 @@ static void StoryIntelligenceEnrichesExistingStructuralScenes()
var commit = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceImportCommitService.cs")); var commit = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceImportCommitService.cs"));
Assert(runner.Contains("ListChapterScenesAsync", StringComparison.Ordinal), "Story Intelligence scene results should be linked to existing SceneIDs."); Assert(runner.Contains("ListChapterScenesAsync", StringComparison.Ordinal), "Story Intelligence scene results should be linked to existing SceneIDs.");
Assert(repository.Contains("SceneID = existingScenes.Count > index", StringComparison.Ordinal), "Story Intelligence commit should update existing structural scenes when present."); Assert(repository.Contains("SceneID = item.SceneID ?? (existingScenes.Count > index", StringComparison.Ordinal), "Story Intelligence commit should update existing structural scenes when present.");
Assert(repository.Contains("Enriched existing structural scenes", StringComparison.Ordinal), "Commit audit should distinguish enrichment from scene creation."); Assert(repository.Contains("Enriched existing structural scenes", StringComparison.Ordinal), "Commit audit should distinguish enrichment from scene creation.");
Assert(commit.Contains("readiness.ActiveSceneCount != importScenes.Count", StringComparison.Ordinal), "Review readiness should allow matching structural scenes while blocking mismatches."); Assert(commit.Contains("readiness.ActiveSceneCount != importScenes.Count", StringComparison.Ordinal), "Review readiness should allow matching structural scenes while blocking mismatches.");
} }

View File

@ -16,6 +16,7 @@ public interface IOnboardingBuildRepository
Task SaveStructuralChapterSourceAsync(StructuralChapterSourceSaveRequest request); Task SaveStructuralChapterSourceAsync(StructuralChapterSourceSaveRequest request);
Task SaveSceneManuscriptSourceAsync(SceneManuscriptSourceSaveRequest request); Task SaveSceneManuscriptSourceAsync(SceneManuscriptSourceSaveRequest request);
Task SaveSceneStructuralSummaryAsync(int sceneId, string structuralSummary); Task SaveSceneStructuralSummaryAsync(int sceneId, string structuralSummary);
Task<IReadOnlyList<SceneManuscriptSourceRecord>> ListSceneManuscriptSourcesByChapterAsync(int chapterId);
Task<IReadOnlyList<SceneManuscriptSourceRecord>> ListSceneManuscriptSourcesByBookAsync(int bookId); Task<IReadOnlyList<SceneManuscriptSourceRecord>> ListSceneManuscriptSourcesByBookAsync(int bookId);
} }
@ -182,6 +183,16 @@ public sealed class OnboardingBuildRepository(ISqlConnectionFactory connectionFa
commandType: CommandType.StoredProcedure); commandType: CommandType.StoredProcedure);
return rows.ToList(); return rows.ToList();
} }
public async Task<IReadOnlyList<SceneManuscriptSourceRecord>> ListSceneManuscriptSourcesByChapterAsync(int chapterId)
{
using var connection = connectionFactory.CreateConnection();
var rows = await connection.QueryAsync<SceneManuscriptSourceRecord>(
"dbo.SceneManuscriptSource_ListByChapter",
new { ChapterID = chapterId },
commandType: CommandType.StoredProcedure);
return rows.ToList();
}
} }
public sealed class OnboardingManuscriptBuildRequest public sealed class OnboardingManuscriptBuildRequest

View File

@ -642,7 +642,7 @@ public sealed class StoryIntelligenceResultRepository(ISqlConnectionFactory conn
"dbo.Scene_Save", "dbo.Scene_Save",
new new
{ {
SceneID = existingScenes.Count > index ? existingScenes[index].SceneID : (int?)null, SceneID = item.SceneID ?? (existingScenes.Count > index ? existingScenes[index].SceneID : (int?)null),
ChapterID = request.ChapterID, ChapterID = request.ChapterID,
SceneNumber = Convert.ToDecimal(item.TemporarySceneNumber), SceneNumber = Convert.ToDecimal(item.TemporarySceneNumber),
SceneTitle = sceneTitle, SceneTitle = sceneTitle,

View File

@ -490,6 +490,7 @@ public sealed class StoryIntelligenceImportCommit
public sealed class StoryIntelligenceSceneImportItem public sealed class StoryIntelligenceSceneImportItem
{ {
public int SceneResultID { get; init; } public int SceneResultID { get; init; }
public int? SceneID { get; init; }
public int TemporarySceneNumber { get; init; } public int TemporarySceneNumber { get; init; }
public int? StartParagraph { get; init; } public int? StartParagraph { get; init; }
public int? EndParagraph { get; init; } public int? EndParagraph { get; init; }

View File

@ -14,6 +14,7 @@ public interface IPersistedStoryIntelligenceRunner
public sealed class PersistedStoryIntelligenceRunner( public sealed class PersistedStoryIntelligenceRunner(
IStoryIntelligenceResultRepository repository, IStoryIntelligenceResultRepository repository,
IStoryIntelligencePipelineRepository pipelines, IStoryIntelligencePipelineRepository pipelines,
IOnboardingBuildRepository builds,
ISceneMetricTypeRepository metricTypes, ISceneMetricTypeRepository metricTypes,
IPlotRepository plots, IPlotRepository plots,
IStoryPromptRepository prompts, IStoryPromptRepository prompts,
@ -77,138 +78,9 @@ public sealed class PersistedStoryIntelligenceRunner(
"Story Intelligence has started reading this chapter.", "Story Intelligence has started reading this chapter.",
stopwatch.ElapsedMilliseconds); stopwatch.ElapsedMilliseconds);
if (string.IsNullOrWhiteSpace(run.SourceText))
{
throw new StoryIntelligenceRunFailureException(
StoryIntelligenceFailureStages.DocumentRead,
"Story Intelligence run has no source text.");
}
await entitlement.EnsureCanExecuteAsync(run.UserID); await entitlement.EnsureCanExecuteAsync(run.UserID);
await ThrowIfCancellationRequestedAsync(run.StoryIntelligenceRunID, stopwatch.ElapsedMilliseconds, cancellationToken); await ThrowIfCancellationRequestedAsync(run.StoryIntelligenceRunID, stopwatch.ElapsedMilliseconds, cancellationToken);
var paragraphs = StoryIntelligenceParagraphs.Split(run.SourceText);
if (paragraphs.Count == 0)
{
throw new StoryIntelligenceRunFailureException(
StoryIntelligenceFailureStages.DocumentRead,
"Story Intelligence run source text does not contain readable paragraphs.");
}
var chapterContextJson = BuildChapterContextJson(run);
var chapterTemplate = await prompts.LoadPromptAsync(ChapterPromptFile, cancellationToken);
var numberedChapterText = StoryIntelligenceParagraphs.Number(paragraphs);
var chapterPrompt = BuildChapterPrompt(chapterTemplate, chapterContextJson, numberedChapterText);
currentFailureStage = StoryIntelligenceFailureStages.ChapterStructure;
await repository.UpdateProgressAsync(
run.StoryIntelligenceRunID,
currentStage: StoryIntelligenceFailureStages.ChapterStructure,
currentMessage: "Running Chapter Structure analysis.",
totalDurationMs: stopwatch.ElapsedMilliseconds);
await PublishAsync(
run,
"Chapter started",
StoryIntelligenceRunStatuses.Running,
StoryIntelligenceFailureStages.ChapterStructure,
"PlotDirector is finding the scene boundaries in this chapter.",
stopwatch.ElapsedMilliseconds);
var chapterClientResult = await client.ExecutePromptAsync(
chapterPrompt,
chapterPromptVersion,
cancellationToken,
stageModels.ChapterStructureModel);
totals.Add(chapterClientResult, pricing);
AiJsonParseResult<ChapterStructureModel> chapterAttempt;
try
{
chapterAttempt = await ParseAiJsonWithRetryAsync<ChapterStructureModel>(
chapterPrompt,
chapterPromptVersion,
stageModels.ChapterStructureModel,
chapterClientResult,
totals,
"Chapter Structure",
cancellationToken);
}
catch (AiJsonParseException ex)
{
await repository.SaveChapterResultAsync(
run.StoryIntelligenceRunID,
new StoryIntelligenceChapterResultSaveRequest
{
ProjectID = run.ProjectID,
BookID = run.BookID,
ChapterID = run.ChapterID,
ChapterNumber = ChapterNumber(run),
SourceLabel = SourceLabel(run),
PromptVersion = chapterPromptVersion,
Model = stageModels.ChapterStructureModel,
RawResponseJson = ex.RawResponseJson,
OutputTextJson = ex.RawAssistantOutput,
ParsedJson = SerialiseFailedAiJsonParse(ex),
ValidationErrorsCount = 1,
ValidationWarningsCount = ex.RetryAttempted ? 1 : 0,
InputTokens = ex.InputTokens,
OutputTokens = ex.OutputTokens,
TotalTokens = SumTokens(ex.InputTokens, ex.OutputTokens),
DurationMs = Convert.ToInt64(ex.Duration.TotalMilliseconds)
});
throw new StoryIntelligenceRunFailureException(
StoryIntelligenceFailureStages.Validation,
"Chapter Structure JSON could not be parsed after one retry.",
ex.ToString());
}
var parsedChapter = chapterAttempt.Parsed;
var chapterNormalisation = ChapterStructureBoundaryNormaliser.Normalise(parsedChapter, paragraphs.Count);
var chapterForProcessing = chapterNormalisation.ChapterStructure;
var chapterValidation = chapterValidator.Validate(chapterForProcessing, paragraphs.Count);
foreach (var warning in chapterAttempt.Warnings)
{
chapterValidation.Warnings.Add(new PlotLine.Models.StoryIntelligence.ValidationIssue
{
Severity = "Warning",
Path = "$",
Message = warning,
SuggestedFix = "Review the raw and repaired Chapter Structure JSON."
});
}
ChapterStructureBoundaryNormaliser.AddIssuesTo(chapterValidation, chapterNormalisation);
hasWarnings = chapterValidation.Warnings.Count > 0;
chapterResultId = await repository.SaveChapterResultAsync(
run.StoryIntelligenceRunID,
new StoryIntelligenceChapterResultSaveRequest
{
ProjectID = run.ProjectID,
BookID = run.BookID,
ChapterID = run.ChapterID,
ChapterNumber = ChapterNumber(run),
SourceLabel = SourceLabel(run),
PromptVersion = chapterPromptVersion,
Model = chapterAttempt.Model,
RawResponseJson = chapterAttempt.RawResponseJson,
OutputTextJson = chapterAttempt.RawAssistantOutput,
ParsedJson = SerialiseChapterParseAudit(chapterForProcessing, chapterNormalisation, chapterAttempt),
ValidationErrorsCount = chapterValidation.Errors.Count,
ValidationWarningsCount = chapterValidation.Warnings.Count,
InputTokens = chapterAttempt.InputTokens,
OutputTokens = chapterAttempt.OutputTokens,
TotalTokens = SumTokens(chapterAttempt.InputTokens, chapterAttempt.OutputTokens),
DurationMs = Convert.ToInt64(chapterAttempt.Duration.TotalMilliseconds)
});
if (!chapterValidation.IsValid || chapterForProcessing?.SceneBoundaries is null)
{
throw new StoryIntelligenceRunFailureException(
StoryIntelligenceFailureStages.ChapterStructure,
"Chapter Structure validation failed.",
BuildValidationDetail(chapterValidation));
}
var configuredMetrics = run.ProjectID.HasValue var configuredMetrics = run.ProjectID.HasValue
? (await metricTypes.ListForManagementAsync(run.ProjectID.Value)) ? (await metricTypes.ListForManagementAsync(run.ProjectID.Value))
.Where(metric => metric.IsActive && metric.IsEnabledForProject) .Where(metric => metric.IsActive && metric.IsEnabledForProject)
@ -223,16 +95,34 @@ public sealed class PersistedStoryIntelligenceRunner(
.ThenBy(plotLine => plotLine.PlotLineName) .ThenBy(plotLine => plotLine.PlotLineName)
.ToList() .ToList()
: []; : [];
var sceneBlocks = chapterForProcessing.SceneBoundaries var mode = await SelectSceneAnalysisModeAsync(
.Select(boundary => BuildSceneBlock(run, paragraphs, boundary, configuredMetrics, existingPlotLines)) run,
.ToList(); configuredMetrics,
existingPlotLines,
chapterPromptVersion,
stageModels,
totals,
stopwatch.ElapsedMilliseconds,
cancellationToken);
var sceneBlocks = mode.SceneBlocks;
chapterResultId = mode.ChapterResultId;
hasWarnings = mode.HasWarnings;
failedScenes += sceneBlocks.Count(block => !block.SplitValid); failedScenes += sceneBlocks.Count(block => !block.SplitValid);
currentFailureStage = StoryIntelligenceFailureStages.SceneSplit; currentFailureStage = StoryIntelligenceFailureStages.SceneSplit;
logger.LogInformation(
"Story Intelligence mode: {Mode}. RunID={RunID} ProjectID={ProjectID} BookID={BookID} ChapterID={ChapterID} SceneCount={SceneCount}",
mode.Mode,
run.StoryIntelligenceRunID,
run.ProjectID,
run.BookID,
run.ChapterID,
sceneBlocks.Count);
await repository.UpdateProgressAsync( await repository.UpdateProgressAsync(
run.StoryIntelligenceRunID, run.StoryIntelligenceRunID,
currentStage: StoryIntelligenceFailureStages.SceneSplit, currentStage: StoryIntelligenceFailureStages.SceneSplit,
currentMessage: $"Detected {sceneBlocks.Count:N0} suggested scene(s).", currentMessage: mode.PreparationMessage,
totalDetectedScenes: sceneBlocks.Count, totalDetectedScenes: sceneBlocks.Count,
failedScenes: failedScenes, failedScenes: failedScenes,
totalInputTokens: totals.InputTokens, totalInputTokens: totals.InputTokens,
@ -243,19 +133,16 @@ public sealed class PersistedStoryIntelligenceRunner(
estimatedCostGBP: totals.EstimatedCostGBP); estimatedCostGBP: totals.EstimatedCostGBP);
await PublishAsync( await PublishAsync(
run, run,
"Scene boundaries detected", mode.PreparedEventType,
StoryIntelligenceRunStatuses.Running, StoryIntelligenceRunStatuses.Running,
StoryIntelligenceFailureStages.SceneSplit, StoryIntelligenceFailureStages.SceneSplit,
$"Detected {sceneBlocks.Count:N0} suggested scene(s).", mode.PreparationMessage,
stopwatch.ElapsedMilliseconds, stopwatch.ElapsedMilliseconds,
totalDetectedScenes: sceneBlocks.Count, totalDetectedScenes: sceneBlocks.Count,
completedScenes: completedScenes, completedScenes: completedScenes,
failedScenes: failedScenes, failedScenes: failedScenes,
totalTokens: totals.TotalTokens); totalTokens: totals.TotalTokens);
var existingScenes = run.ChapterID.HasValue
? await repository.ListChapterScenesAsync(run.ChapterID.Value)
: Array.Empty<Scene>();
var sceneTemplate = await prompts.LoadPromptAsync(ScenePromptFile, cancellationToken); var sceneTemplate = await prompts.LoadPromptAsync(ScenePromptFile, cancellationToken);
foreach (var block in sceneBlocks) foreach (var block in sceneBlocks)
{ {
@ -268,7 +155,7 @@ public sealed class PersistedStoryIntelligenceRunner(
"Scene analysis failed", "Scene analysis failed",
StoryIntelligenceRunStatuses.Running, StoryIntelligenceRunStatuses.Running,
StoryIntelligenceFailureStages.SceneIntelligence, StoryIntelligenceFailureStages.SceneIntelligence,
$"Suggested scene {block.TemporarySceneNumber:N0} could not be analysed because its paragraph range was invalid.", $"{SceneProgressLabel(block)} could not be analysed because its paragraph range was invalid.",
stopwatch.ElapsedMilliseconds, stopwatch.ElapsedMilliseconds,
totalDetectedScenes: sceneBlocks.Count, totalDetectedScenes: sceneBlocks.Count,
completedScenes: completedScenes, completedScenes: completedScenes,
@ -280,7 +167,7 @@ public sealed class PersistedStoryIntelligenceRunner(
await repository.UpdateProgressAsync( await repository.UpdateProgressAsync(
run.StoryIntelligenceRunID, run.StoryIntelligenceRunID,
currentStage: StoryIntelligenceFailureStages.SceneIntelligence, currentStage: StoryIntelligenceFailureStages.SceneIntelligence,
currentMessage: $"Running Scene Intelligence for suggested scene {block.TemporarySceneNumber:N0}.", currentMessage: $"Analysing {SceneProgressLabel(block)} of {sceneBlocks.Count:N0}.",
completedScenes: completedScenes, completedScenes: completedScenes,
failedScenes: failedScenes, failedScenes: failedScenes,
totalDurationMs: stopwatch.ElapsedMilliseconds); totalDurationMs: stopwatch.ElapsedMilliseconds);
@ -289,7 +176,7 @@ public sealed class PersistedStoryIntelligenceRunner(
"Scene analysis started", "Scene analysis started",
StoryIntelligenceRunStatuses.Running, StoryIntelligenceRunStatuses.Running,
StoryIntelligenceFailureStages.SceneIntelligence, StoryIntelligenceFailureStages.SceneIntelligence,
$"Reading scene {block.TemporarySceneNumber:N0}.", $"Analysing {SceneProgressLabel(block)}.",
stopwatch.ElapsedMilliseconds, stopwatch.ElapsedMilliseconds,
totalDetectedScenes: sceneBlocks.Count, totalDetectedScenes: sceneBlocks.Count,
completedScenes: completedScenes, completedScenes: completedScenes,
@ -338,7 +225,7 @@ public sealed class PersistedStoryIntelligenceRunner(
ProjectID = run.ProjectID, ProjectID = run.ProjectID,
BookID = run.BookID, BookID = run.BookID,
ChapterID = run.ChapterID, ChapterID = run.ChapterID,
SceneID = existingScenes.FirstOrDefault(scene => Convert.ToInt32(scene.SceneNumber) == block.TemporarySceneNumber)?.SceneID, SceneID = block.SceneID,
TemporarySceneNumber = block.TemporarySceneNumber, TemporarySceneNumber = block.TemporarySceneNumber,
StartParagraph = block.StartParagraph, StartParagraph = block.StartParagraph,
EndParagraph = block.EndParagraph, EndParagraph = block.EndParagraph,
@ -549,6 +436,238 @@ public sealed class PersistedStoryIntelligenceRunner(
} }
} }
private async Task<SceneAnalysisModeSelection> SelectSceneAnalysisModeAsync(
StoryIntelligenceQueuedRun run,
IReadOnlyList<SceneMetricType> configuredMetrics,
IReadOnlyList<PlotLineItem> existingPlotLines,
string chapterPromptVersion,
StoryIntelligenceStageModels stageModels,
TokenTotals totals,
long elapsedMs,
CancellationToken cancellationToken)
{
if (!run.ChapterID.HasValue)
{
return await ProcessLegacyChapterAsync(run, configuredMetrics, existingPlotLines, chapterPromptVersion, stageModels, totals, elapsedMs, cancellationToken);
}
var canonicalScenes = await repository.ListChapterScenesAsync(run.ChapterID.Value);
if (canonicalScenes.Count == 0)
{
logger.LogInformation(
"Story Intelligence mode: LegacyBoundaryDetection. RunID={RunID} ChapterID={ChapterID} has no active canonical scenes.",
run.StoryIntelligenceRunID,
run.ChapterID);
return await ProcessLegacyChapterAsync(run, configuredMetrics, existingPlotLines, chapterPromptVersion, stageModels, totals, elapsedMs, cancellationToken);
}
var sourceRows = await builds.ListSceneManuscriptSourcesByChapterAsync(run.ChapterID.Value);
if (sourceRows.Count == 0)
{
logger.LogInformation(
"Story Intelligence mode: LegacyBoundaryDetection. RunID={RunID} ChapterID={ChapterID} has active scenes but no canonical scene source rows.",
run.StoryIntelligenceRunID,
run.ChapterID);
return await ProcessLegacyChapterAsync(run, configuredMetrics, existingPlotLines, chapterPromptVersion, stageModels, totals, elapsedMs, cancellationToken);
}
var sourcesByScene = sourceRows.ToDictionary(source => source.SceneID);
var missingSources = canonicalScenes
.Where(scene => !sourcesByScene.TryGetValue(scene.SceneID, out var source)
|| string.IsNullOrWhiteSpace(source.SourceText))
.Select(scene => scene.SceneID)
.ToList();
var extraSources = sourceRows
.Where(source => canonicalScenes.All(scene => scene.SceneID != source.SceneID))
.Select(source => source.SceneID)
.ToList();
if (missingSources.Count > 0 || extraSources.Count > 0)
{
var detail = $"Canonical scene source coverage is incomplete for ChapterID {run.ChapterID.Value}. "
+ $"Active scenes: {canonicalScenes.Count:N0}; source rows: {sourceRows.Count:N0}; "
+ $"missing/blank source SceneIDs: {string.Join(", ", missingSources)}; extra source SceneIDs: {string.Join(", ", extraSources)}.";
throw new StoryIntelligenceRunFailureException(
StoryIntelligenceFailureStages.DocumentRead,
$"Canonical scene source is missing for ChapterID {run.ChapterID.Value}. Story Intelligence cannot analyse this chapter until source data is repaired.",
detail);
}
var sceneBlocks = canonicalScenes
.Select((scene, index) => BuildCanonicalSceneBlock(run, scene, sourcesByScene[scene.SceneID], index + 1, configuredMetrics, existingPlotLines))
.ToList();
if (sceneBlocks.Any(block => !block.SplitValid))
{
var invalidSceneIds = sceneBlocks
.Where(block => !block.SplitValid)
.Select(block => block.SceneID?.ToString() ?? block.TemporarySceneNumber.ToString("N0"))
.ToList();
throw new StoryIntelligenceRunFailureException(
StoryIntelligenceFailureStages.DocumentRead,
$"Canonical scene source is missing for SceneID {invalidSceneIds.FirstOrDefault() ?? "unknown"}. Story Intelligence cannot analyse this scene until source data is repaired.",
$"Invalid canonical scene source rows: {string.Join(", ", invalidSceneIds)}.");
}
return new SceneAnalysisModeSelection(
"CanonicalSceneSource",
sceneBlocks,
null,
false,
$"Loaded {sceneBlocks.Count:N0} canonical scene(s) for analysis.",
"Canonical scenes loaded");
}
private async Task<SceneAnalysisModeSelection> ProcessLegacyChapterAsync(
StoryIntelligenceQueuedRun run,
IReadOnlyList<SceneMetricType> configuredMetrics,
IReadOnlyList<PlotLineItem> existingPlotLines,
string chapterPromptVersion,
StoryIntelligenceStageModels stageModels,
TokenTotals totals,
long elapsedMs,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(run.SourceText))
{
throw new StoryIntelligenceRunFailureException(
StoryIntelligenceFailureStages.DocumentRead,
"Story Intelligence run has no source text.");
}
var paragraphs = StoryIntelligenceParagraphs.Split(run.SourceText);
if (paragraphs.Count == 0)
{
throw new StoryIntelligenceRunFailureException(
StoryIntelligenceFailureStages.DocumentRead,
"Story Intelligence run source text does not contain readable paragraphs.");
}
var chapterContextJson = BuildChapterContextJson(run);
var chapterTemplate = await prompts.LoadPromptAsync(ChapterPromptFile, cancellationToken);
var numberedChapterText = StoryIntelligenceParagraphs.Number(paragraphs);
var chapterPrompt = BuildChapterPrompt(chapterTemplate, chapterContextJson, numberedChapterText);
await repository.UpdateProgressAsync(
run.StoryIntelligenceRunID,
currentStage: StoryIntelligenceFailureStages.ChapterStructure,
currentMessage: "Running legacy Chapter Structure analysis.",
totalDurationMs: elapsedMs);
await PublishAsync(
run,
"Legacy chapter structure started",
StoryIntelligenceRunStatuses.Running,
StoryIntelligenceFailureStages.ChapterStructure,
"PlotDirector is using legacy scene-boundary detection for this chapter.",
elapsedMs);
var chapterClientResult = await client.ExecutePromptAsync(
chapterPrompt,
chapterPromptVersion,
cancellationToken,
stageModels.ChapterStructureModel);
totals.Add(chapterClientResult, pricing);
AiJsonParseResult<ChapterStructureModel> chapterAttempt;
try
{
chapterAttempt = await ParseAiJsonWithRetryAsync<ChapterStructureModel>(
chapterPrompt,
chapterPromptVersion,
stageModels.ChapterStructureModel,
chapterClientResult,
totals,
"Chapter Structure",
cancellationToken);
}
catch (AiJsonParseException ex)
{
await repository.SaveChapterResultAsync(
run.StoryIntelligenceRunID,
new StoryIntelligenceChapterResultSaveRequest
{
ProjectID = run.ProjectID,
BookID = run.BookID,
ChapterID = run.ChapterID,
ChapterNumber = ChapterNumber(run),
SourceLabel = SourceLabel(run),
PromptVersion = chapterPromptVersion,
Model = stageModels.ChapterStructureModel,
RawResponseJson = ex.RawResponseJson,
OutputTextJson = ex.RawAssistantOutput,
ParsedJson = SerialiseFailedAiJsonParse(ex),
ValidationErrorsCount = 1,
ValidationWarningsCount = ex.RetryAttempted ? 1 : 0,
InputTokens = ex.InputTokens,
OutputTokens = ex.OutputTokens,
TotalTokens = SumTokens(ex.InputTokens, ex.OutputTokens),
DurationMs = Convert.ToInt64(ex.Duration.TotalMilliseconds)
});
throw new StoryIntelligenceRunFailureException(
StoryIntelligenceFailureStages.Validation,
"Chapter Structure JSON could not be parsed after one retry.",
ex.ToString());
}
var parsedChapter = chapterAttempt.Parsed;
var chapterNormalisation = ChapterStructureBoundaryNormaliser.Normalise(parsedChapter, paragraphs.Count);
var chapterForProcessing = chapterNormalisation.ChapterStructure;
var chapterValidation = chapterValidator.Validate(chapterForProcessing, paragraphs.Count);
foreach (var warning in chapterAttempt.Warnings)
{
chapterValidation.Warnings.Add(new PlotLine.Models.StoryIntelligence.ValidationIssue
{
Severity = "Warning",
Path = "$",
Message = warning,
SuggestedFix = "Review the raw and repaired Chapter Structure JSON."
});
}
ChapterStructureBoundaryNormaliser.AddIssuesTo(chapterValidation, chapterNormalisation);
var chapterResultId = await repository.SaveChapterResultAsync(
run.StoryIntelligenceRunID,
new StoryIntelligenceChapterResultSaveRequest
{
ProjectID = run.ProjectID,
BookID = run.BookID,
ChapterID = run.ChapterID,
ChapterNumber = ChapterNumber(run),
SourceLabel = SourceLabel(run),
PromptVersion = chapterPromptVersion,
Model = chapterAttempt.Model,
RawResponseJson = chapterAttempt.RawResponseJson,
OutputTextJson = chapterAttempt.RawAssistantOutput,
ParsedJson = SerialiseChapterParseAudit(chapterForProcessing, chapterNormalisation, chapterAttempt),
ValidationErrorsCount = chapterValidation.Errors.Count,
ValidationWarningsCount = chapterValidation.Warnings.Count,
InputTokens = chapterAttempt.InputTokens,
OutputTokens = chapterAttempt.OutputTokens,
TotalTokens = SumTokens(chapterAttempt.InputTokens, chapterAttempt.OutputTokens),
DurationMs = Convert.ToInt64(chapterAttempt.Duration.TotalMilliseconds)
});
if (!chapterValidation.IsValid || chapterForProcessing?.SceneBoundaries is null)
{
throw new StoryIntelligenceRunFailureException(
StoryIntelligenceFailureStages.ChapterStructure,
"Chapter Structure validation failed.",
BuildValidationDetail(chapterValidation));
}
var sceneBlocks = chapterForProcessing.SceneBoundaries
.Select(boundary => BuildLegacySceneBlock(run, paragraphs, boundary, configuredMetrics, existingPlotLines))
.ToList();
return new SceneAnalysisModeSelection(
"LegacyBoundaryDetection",
sceneBlocks,
chapterResultId,
chapterValidation.Warnings.Count > 0,
$"Prepared {sceneBlocks.Count:N0} legacy scene-boundary result(s).",
"Legacy scene boundaries detected");
}
private async Task ThrowIfCancellationRequestedAsync(int runId, long elapsedMs, CancellationToken cancellationToken) private async Task ThrowIfCancellationRequestedAsync(int runId, long elapsedMs, CancellationToken cancellationToken)
{ {
cancellationToken.ThrowIfCancellationRequested(); cancellationToken.ThrowIfCancellationRequested();
@ -810,7 +929,7 @@ public sealed class PersistedStoryIntelligenceRunner(
ProjectID = run.ProjectID, ProjectID = run.ProjectID,
BookID = run.BookID, BookID = run.BookID,
ChapterID = run.ChapterID, ChapterID = run.ChapterID,
SceneID = null, SceneID = block.SceneID,
TemporarySceneNumber = block.TemporarySceneNumber, TemporarySceneNumber = block.TemporarySceneNumber,
StartParagraph = block.StartParagraph, StartParagraph = block.StartParagraph,
EndParagraph = block.EndParagraph, EndParagraph = block.EndParagraph,
@ -859,7 +978,45 @@ public sealed class PersistedStoryIntelligenceRunner(
? SceneIntelligenceMaxOutputTokens() ? SceneIntelligenceMaxOutputTokens()
: settings.MaxOutputTokens; : settings.MaxOutputTokens;
private static StorySceneTextBlock BuildSceneBlock( private static StorySceneTextBlock BuildCanonicalSceneBlock(
StoryIntelligenceQueuedRun run,
Scene scene,
SceneManuscriptSourceRecord source,
int ordinal,
IReadOnlyList<SceneMetricType> configuredMetrics,
IReadOnlyList<PlotLineItem> existingPlotLines)
{
var sceneNumber = SceneDisplayNumber(scene.SceneNumber, ordinal);
var start = source.SourceStartParagraph ?? source.WordStartParagraphIndex ?? 0;
var end = source.SourceEndParagraph ?? source.WordEndParagraphIndex ?? 0;
var sourceLabel = $"{SourceLabel(run)}, scene {sceneNumber:N0}";
var sceneText = (source.SourceText ?? string.Empty).Trim();
var splitValid = scene.SceneID > 0
&& !string.IsNullOrWhiteSpace(sceneText)
&& (!source.SourceStartParagraph.HasValue || !source.SourceEndParagraph.HasValue || source.SourceStartParagraph <= source.SourceEndParagraph)
&& (!source.WordStartParagraphIndex.HasValue || !source.WordEndParagraphIndex.HasValue || source.WordStartParagraphIndex <= source.WordEndParagraphIndex);
return new StorySceneTextBlock(
scene.SceneID,
sceneNumber,
start,
end,
sourceLabel,
sceneText,
splitValid
? BuildSceneContextJson(
run,
scene.SceneID,
scene.SceneNumber,
FirstNonBlank(source.StructuralSummary, scene.StructuralSummary),
sourceLabel,
configuredMetrics,
existingPlotLines)
: string.Empty,
splitValid);
}
private static StorySceneTextBlock BuildLegacySceneBlock(
StoryIntelligenceQueuedRun run, StoryIntelligenceQueuedRun run,
IReadOnlyList<string> paragraphs, IReadOnlyList<string> paragraphs,
ChapterSceneBoundary boundary, ChapterSceneBoundary boundary,
@ -873,17 +1030,25 @@ public sealed class PersistedStoryIntelligenceRunner(
if (sceneNumber <= 0 || start <= 0 || end <= 0 || start > end || end > paragraphs.Count) if (sceneNumber <= 0 || start <= 0 || end <= 0 || start > end || end > paragraphs.Count)
{ {
return new StorySceneTextBlock(sceneNumber, start, end, sourceLabel, string.Empty, string.Empty, false); return new StorySceneTextBlock(null, sceneNumber, start, end, sourceLabel, string.Empty, string.Empty, false);
} }
var sceneText = string.Join(Environment.NewLine + Environment.NewLine, paragraphs.Skip(start - 1).Take(end - start + 1)); var sceneText = string.Join(Environment.NewLine + Environment.NewLine, paragraphs.Skip(start - 1).Take(end - start + 1));
return new StorySceneTextBlock( return new StorySceneTextBlock(
null,
sceneNumber, sceneNumber,
start, start,
end, end,
sourceLabel, sourceLabel,
sceneText, sceneText,
BuildSceneContextJson(run, boundary, configuredMetrics, existingPlotLines), BuildSceneContextJson(
run,
null,
boundary.SceneNumber,
boundary.StructuralSummary,
sourceLabel,
configuredMetrics,
existingPlotLines),
true); true);
} }
@ -900,7 +1065,10 @@ public sealed class PersistedStoryIntelligenceRunner(
private static string BuildSceneContextJson( private static string BuildSceneContextJson(
StoryIntelligenceQueuedRun run, StoryIntelligenceQueuedRun run,
ChapterSceneBoundary boundary, int? sceneId,
decimal? sceneNumber,
string? structuralSummary,
string sourceLabel,
IReadOnlyList<SceneMetricType> configuredMetrics, IReadOnlyList<SceneMetricType> configuredMetrics,
IReadOnlyList<PlotLineItem> existingPlotLines) IReadOnlyList<PlotLineItem> existingPlotLines)
=> JsonSerializer.Serialize(new => JsonSerializer.Serialize(new
@ -908,11 +1076,11 @@ public sealed class PersistedStoryIntelligenceRunner(
projectId = run.ProjectID, projectId = run.ProjectID,
bookId = run.BookID, bookId = run.BookID,
chapterId = run.ChapterID, chapterId = run.ChapterID,
sceneId = (int?)null, sceneId,
chapterNumber = ChapterNumber(run), chapterNumber = ChapterNumber(run),
sceneNumber = boundary.SceneNumber, sceneNumber,
structuralSummary = boundary.StructuralSummary, structuralSummary,
sourceLabel = $"{SourceLabel(run)}, suggested scene {boundary.SceneNumber}", sourceLabel,
knownCharacters = KnownCharacters(run), knownCharacters = KnownCharacters(run),
configuredMetrics = configuredMetrics.Select(metric => new configuredMetrics = configuredMetrics.Select(metric => new
{ {
@ -934,6 +1102,17 @@ public sealed class PersistedStoryIntelligenceRunner(
}) })
}, JsonOptions); }, JsonOptions);
private static int SceneDisplayNumber(decimal sceneNumber, int ordinal)
=> decimal.Truncate(sceneNumber) == sceneNumber && sceneNumber > 0 && sceneNumber <= int.MaxValue
? Convert.ToInt32(sceneNumber)
: ordinal;
private static string? FirstNonBlank(params string?[] values)
=> values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))?.Trim();
private static string SceneProgressLabel(StorySceneTextBlock block)
=> $"scene {block.TemporarySceneNumber:N0}";
private static string MetricContextKey(SceneMetricType metric) private static string MetricContextKey(SceneMetricType metric)
=> $"metric_{metric.MetricTypeID}"; => $"metric_{metric.MetricTypeID}";
@ -1177,6 +1356,7 @@ public sealed class PersistedStoryIntelligenceRunner(
} }
private sealed record StorySceneTextBlock( private sealed record StorySceneTextBlock(
int? SceneID,
int TemporarySceneNumber, int TemporarySceneNumber,
int StartParagraph, int StartParagraph,
int EndParagraph, int EndParagraph,
@ -1185,6 +1365,14 @@ public sealed class PersistedStoryIntelligenceRunner(
string SceneContextJson, string SceneContextJson,
bool SplitValid); bool SplitValid);
private sealed record SceneAnalysisModeSelection(
string Mode,
IReadOnlyList<StorySceneTextBlock> SceneBlocks,
int? ChapterResultId,
bool HasWarnings,
string PreparationMessage,
string PreparedEventType);
private sealed class TokenTotals private sealed class TokenTotals
{ {
public int? InputTokens { get; private set; } public int? InputTokens { get; private set; }

View File

@ -320,6 +320,7 @@ public sealed class StoryIntelligenceAssetImportService(
var importedScenes = (await scenes.ListByChapterAsync(item.ChapterID)) var importedScenes = (await scenes.ListByChapterAsync(item.ChapterID))
.Where(scene => scene.ImportRunID == item.RunID) .Where(scene => scene.ImportRunID == item.RunID)
.ToList(); .ToList();
var importedById = importedScenes.ToDictionary(scene => scene.SceneID, scene => scene);
var importedByRange = importedScenes var importedByRange = importedScenes
.Where(scene => scene.SourceStartParagraph.HasValue && scene.SourceEndParagraph.HasValue) .Where(scene => scene.SourceStartParagraph.HasValue && scene.SourceEndParagraph.HasValue)
.ToDictionary(scene => $"{scene.SourceStartParagraph}-{scene.SourceEndParagraph}", scene => scene, StringComparer.OrdinalIgnoreCase); .ToDictionary(scene => $"{scene.SourceStartParagraph}-{scene.SourceEndParagraph}", scene => scene, StringComparer.OrdinalIgnoreCase);
@ -334,7 +335,7 @@ public sealed class StoryIntelligenceAssetImportService(
continue; continue;
} }
var importedScene = ResolveImportedScene(sceneResult, importedByRange, importedByNumber); var importedScene = ResolveImportedScene(sceneResult, importedById, importedByRange, importedByNumber);
if (importedScene is null) if (importedScene is null)
{ {
continue; continue;
@ -492,9 +493,15 @@ public sealed class StoryIntelligenceAssetImportService(
private static Scene? ResolveImportedScene( private static Scene? ResolveImportedScene(
StoryIntelligenceSavedSceneResult sceneResult, StoryIntelligenceSavedSceneResult sceneResult,
IReadOnlyDictionary<int, Scene> byId,
IReadOnlyDictionary<string, Scene> byRange, IReadOnlyDictionary<string, Scene> byRange,
IReadOnlyDictionary<int, Scene> byNumber) IReadOnlyDictionary<int, Scene> byNumber)
{ {
if (sceneResult.SceneID.HasValue && byId.TryGetValue(sceneResult.SceneID.Value, out var idMatch))
{
return idMatch;
}
if (sceneResult.StartParagraph.HasValue if (sceneResult.StartParagraph.HasValue
&& sceneResult.EndParagraph.HasValue && sceneResult.EndParagraph.HasValue
&& byRange.TryGetValue($"{sceneResult.StartParagraph}-{sceneResult.EndParagraph}", out var rangeMatch)) && byRange.TryGetValue($"{sceneResult.StartParagraph}-{sceneResult.EndParagraph}", out var rangeMatch))

View File

@ -394,6 +394,7 @@ public sealed class StoryIntelligenceCharacterImportService(
var importedScenes = (await scenes.ListByChapterAsync(item.ChapterID)) var importedScenes = (await scenes.ListByChapterAsync(item.ChapterID))
.Where(scene => scene.ImportRunID == item.RunID) .Where(scene => scene.ImportRunID == item.RunID)
.ToList(); .ToList();
var importedById = importedScenes.ToDictionary(scene => scene.SceneID, scene => scene);
var importedByRange = importedScenes var importedByRange = importedScenes
.Where(scene => scene.SourceStartParagraph.HasValue && scene.SourceEndParagraph.HasValue) .Where(scene => scene.SourceStartParagraph.HasValue && scene.SourceEndParagraph.HasValue)
.ToDictionary(scene => $"{scene.SourceStartParagraph}-{scene.SourceEndParagraph}", scene => scene, StringComparer.OrdinalIgnoreCase); .ToDictionary(scene => $"{scene.SourceStartParagraph}-{scene.SourceEndParagraph}", scene => scene, StringComparer.OrdinalIgnoreCase);
@ -408,7 +409,7 @@ public sealed class StoryIntelligenceCharacterImportService(
continue; continue;
} }
var importedScene = ResolveImportedScene(sceneResult, importedByRange, importedByNumber); var importedScene = ResolveImportedScene(sceneResult, importedById, importedByRange, importedByNumber);
if (importedScene is null) if (importedScene is null)
{ {
continue; continue;
@ -645,9 +646,15 @@ public sealed class StoryIntelligenceCharacterImportService(
private static Scene? ResolveImportedScene( private static Scene? ResolveImportedScene(
StoryIntelligenceSavedSceneResult sceneResult, StoryIntelligenceSavedSceneResult sceneResult,
IReadOnlyDictionary<int, Scene> byId,
IReadOnlyDictionary<string, Scene> byRange, IReadOnlyDictionary<string, Scene> byRange,
IReadOnlyDictionary<int, Scene> byNumber) IReadOnlyDictionary<int, Scene> byNumber)
{ {
if (sceneResult.SceneID.HasValue && byId.TryGetValue(sceneResult.SceneID.Value, out var idMatch))
{
return idMatch;
}
if (sceneResult.StartParagraph.HasValue if (sceneResult.StartParagraph.HasValue
&& sceneResult.EndParagraph.HasValue && sceneResult.EndParagraph.HasValue
&& byRange.TryGetValue($"{sceneResult.StartParagraph}-{sceneResult.EndParagraph}", out var rangeMatch)) && byRange.TryGetValue($"{sceneResult.StartParagraph}-{sceneResult.EndParagraph}", out var rangeMatch))

View File

@ -258,6 +258,7 @@ public sealed class StoryIntelligenceImportCommitService(
return new StoryIntelligenceSceneImportItem return new StoryIntelligenceSceneImportItem
{ {
SceneResultID = sceneResult.SceneResultID, SceneResultID = sceneResult.SceneResultID,
SceneID = sceneResult.SceneID,
TemporarySceneNumber = sceneResult.TemporarySceneNumber, TemporarySceneNumber = sceneResult.TemporarySceneNumber,
StartParagraph = sceneResult.StartParagraph, StartParagraph = sceneResult.StartParagraph,
EndParagraph = sceneResult.EndParagraph, EndParagraph = sceneResult.EndParagraph,
@ -313,6 +314,7 @@ public sealed class StoryIntelligenceImportCommitService(
return new StoryIntelligenceSceneImportItem return new StoryIntelligenceSceneImportItem
{ {
SceneResultID = sceneResult.SceneResultID, SceneResultID = sceneResult.SceneResultID,
SceneID = sceneResult.SceneID,
TemporarySceneNumber = sceneResult.TemporarySceneNumber, TemporarySceneNumber = sceneResult.TemporarySceneNumber,
StartParagraph = sceneResult.StartParagraph, StartParagraph = sceneResult.StartParagraph,
EndParagraph = sceneResult.EndParagraph, EndParagraph = sceneResult.EndParagraph,

View File

@ -237,6 +237,7 @@ public sealed class StoryIntelligenceKnowledgeImportService(
var importedScenes = (await scenes.ListByChapterAsync(item.ChapterID)) var importedScenes = (await scenes.ListByChapterAsync(item.ChapterID))
.Where(scene => scene.ImportRunID == item.RunID) .Where(scene => scene.ImportRunID == item.RunID)
.ToList(); .ToList();
var importedById = importedScenes.ToDictionary(scene => scene.SceneID, scene => scene);
var importedByRange = importedScenes var importedByRange = importedScenes
.Where(scene => scene.SourceStartParagraph.HasValue && scene.SourceEndParagraph.HasValue) .Where(scene => scene.SourceStartParagraph.HasValue && scene.SourceEndParagraph.HasValue)
.ToDictionary(scene => $"{scene.SourceStartParagraph}-{scene.SourceEndParagraph}", scene => scene, StringComparer.OrdinalIgnoreCase); .ToDictionary(scene => $"{scene.SourceStartParagraph}-{scene.SourceEndParagraph}", scene => scene, StringComparer.OrdinalIgnoreCase);
@ -251,7 +252,7 @@ public sealed class StoryIntelligenceKnowledgeImportService(
continue; continue;
} }
var importedScene = ResolveImportedScene(sceneResult, importedByRange, importedByNumber); var importedScene = ResolveImportedScene(sceneResult, importedById, importedByRange, importedByNumber);
if (importedScene is null) if (importedScene is null)
{ {
continue; continue;
@ -418,9 +419,15 @@ public sealed class StoryIntelligenceKnowledgeImportService(
private static Scene? ResolveImportedScene( private static Scene? ResolveImportedScene(
StoryIntelligenceSavedSceneResult sceneResult, StoryIntelligenceSavedSceneResult sceneResult,
IReadOnlyDictionary<int, Scene> byId,
IReadOnlyDictionary<string, Scene> byRange, IReadOnlyDictionary<string, Scene> byRange,
IReadOnlyDictionary<int, Scene> byNumber) IReadOnlyDictionary<int, Scene> byNumber)
{ {
if (sceneResult.SceneID.HasValue && byId.TryGetValue(sceneResult.SceneID.Value, out var idMatch))
{
return idMatch;
}
if (sceneResult.StartParagraph.HasValue if (sceneResult.StartParagraph.HasValue
&& sceneResult.EndParagraph.HasValue && sceneResult.EndParagraph.HasValue
&& byRange.TryGetValue($"{sceneResult.StartParagraph}-{sceneResult.EndParagraph}", out var rangeMatch)) && byRange.TryGetValue($"{sceneResult.StartParagraph}-{sceneResult.EndParagraph}", out var rangeMatch))

View File

@ -276,6 +276,7 @@ public sealed class StoryIntelligenceLocationImportService(
var importedScenes = (await scenes.ListByChapterAsync(item.ChapterID)) var importedScenes = (await scenes.ListByChapterAsync(item.ChapterID))
.Where(scene => scene.ImportRunID == item.RunID) .Where(scene => scene.ImportRunID == item.RunID)
.ToList(); .ToList();
var importedById = importedScenes.ToDictionary(scene => scene.SceneID, scene => scene);
var importedByRange = importedScenes var importedByRange = importedScenes
.Where(scene => scene.SourceStartParagraph.HasValue && scene.SourceEndParagraph.HasValue) .Where(scene => scene.SourceStartParagraph.HasValue && scene.SourceEndParagraph.HasValue)
.ToDictionary(scene => $"{scene.SourceStartParagraph}-{scene.SourceEndParagraph}", scene => scene, StringComparer.OrdinalIgnoreCase); .ToDictionary(scene => $"{scene.SourceStartParagraph}-{scene.SourceEndParagraph}", scene => scene, StringComparer.OrdinalIgnoreCase);
@ -290,7 +291,7 @@ public sealed class StoryIntelligenceLocationImportService(
continue; continue;
} }
var importedScene = ResolveImportedScene(sceneResult, importedByRange, importedByNumber); var importedScene = ResolveImportedScene(sceneResult, importedById, importedByRange, importedByNumber);
if (importedScene is null) if (importedScene is null)
{ {
continue; continue;
@ -434,9 +435,15 @@ public sealed class StoryIntelligenceLocationImportService(
private static Scene? ResolveImportedScene( private static Scene? ResolveImportedScene(
StoryIntelligenceSavedSceneResult sceneResult, StoryIntelligenceSavedSceneResult sceneResult,
IReadOnlyDictionary<int, Scene> byId,
IReadOnlyDictionary<string, Scene> byRange, IReadOnlyDictionary<string, Scene> byRange,
IReadOnlyDictionary<int, Scene> byNumber) IReadOnlyDictionary<int, Scene> byNumber)
{ {
if (sceneResult.SceneID.HasValue && byId.TryGetValue(sceneResult.SceneID.Value, out var idMatch))
{
return idMatch;
}
if (sceneResult.StartParagraph.HasValue if (sceneResult.StartParagraph.HasValue
&& sceneResult.EndParagraph.HasValue && sceneResult.EndParagraph.HasValue
&& byRange.TryGetValue($"{sceneResult.StartParagraph}-{sceneResult.EndParagraph}", out var rangeMatch)) && byRange.TryGetValue($"{sceneResult.StartParagraph}-{sceneResult.EndParagraph}", out var rangeMatch))

View File

@ -206,6 +206,7 @@ public sealed class StoryIntelligenceNarrativeReviewService(
var importedScenes = (await scenes.ListByChapterAsync(item.ChapterID)) var importedScenes = (await scenes.ListByChapterAsync(item.ChapterID))
.Where(scene => scene.ImportRunID == item.RunID) .Where(scene => scene.ImportRunID == item.RunID)
.ToList(); .ToList();
var importedById = importedScenes.ToDictionary(scene => scene.SceneID, scene => scene);
var importedByRange = importedScenes var importedByRange = importedScenes
.Where(scene => scene.SourceStartParagraph.HasValue && scene.SourceEndParagraph.HasValue) .Where(scene => scene.SourceStartParagraph.HasValue && scene.SourceEndParagraph.HasValue)
.ToDictionary(scene => $"{scene.SourceStartParagraph}-{scene.SourceEndParagraph}", scene => scene, StringComparer.OrdinalIgnoreCase); .ToDictionary(scene => $"{scene.SourceStartParagraph}-{scene.SourceEndParagraph}", scene => scene, StringComparer.OrdinalIgnoreCase);
@ -214,7 +215,7 @@ public sealed class StoryIntelligenceNarrativeReviewService(
foreach (var sceneResult in sceneResults) foreach (var sceneResult in sceneResults)
{ {
var importedScene = ResolveImportedScene(sceneResult, importedByRange, importedByNumber); var importedScene = ResolveImportedScene(sceneResult, importedById, importedByRange, importedByNumber);
var parsed = TryReadScene(sceneResult); var parsed = TryReadScene(sceneResult);
if (importedScene is null || parsed is null) if (importedScene is null || parsed is null)
{ {
@ -337,9 +338,15 @@ public sealed class StoryIntelligenceNarrativeReviewService(
private static Scene? ResolveImportedScene( private static Scene? ResolveImportedScene(
StoryIntelligenceSavedSceneResult sceneResult, StoryIntelligenceSavedSceneResult sceneResult,
IReadOnlyDictionary<int, Scene> importedById,
IReadOnlyDictionary<string, Scene> importedByRange, IReadOnlyDictionary<string, Scene> importedByRange,
IReadOnlyDictionary<int, Scene> importedByNumber) IReadOnlyDictionary<int, Scene> importedByNumber)
{ {
if (sceneResult.SceneID.HasValue && importedById.TryGetValue(sceneResult.SceneID.Value, out var byId))
{
return byId;
}
if (sceneResult.StartParagraph.HasValue if (sceneResult.StartParagraph.HasValue
&& sceneResult.EndParagraph.HasValue && sceneResult.EndParagraph.HasValue
&& importedByRange.TryGetValue($"{sceneResult.StartParagraph}-{sceneResult.EndParagraph}", out var byRange)) && importedByRange.TryGetValue($"{sceneResult.StartParagraph}-{sceneResult.EndParagraph}", out var byRange))

View File

@ -270,6 +270,7 @@ public sealed class StoryIntelligenceRelationshipImportService(
var importedScenes = (await scenes.ListByChapterAsync(item.ChapterID)) var importedScenes = (await scenes.ListByChapterAsync(item.ChapterID))
.Where(scene => scene.ImportRunID == item.RunID) .Where(scene => scene.ImportRunID == item.RunID)
.ToList(); .ToList();
var importedById = importedScenes.ToDictionary(scene => scene.SceneID, scene => scene);
var importedByRange = importedScenes var importedByRange = importedScenes
.Where(scene => scene.SourceStartParagraph.HasValue && scene.SourceEndParagraph.HasValue) .Where(scene => scene.SourceStartParagraph.HasValue && scene.SourceEndParagraph.HasValue)
.ToDictionary(scene => $"{scene.SourceStartParagraph}-{scene.SourceEndParagraph}", scene => scene, StringComparer.OrdinalIgnoreCase); .ToDictionary(scene => $"{scene.SourceStartParagraph}-{scene.SourceEndParagraph}", scene => scene, StringComparer.OrdinalIgnoreCase);
@ -284,7 +285,7 @@ public sealed class StoryIntelligenceRelationshipImportService(
continue; continue;
} }
var importedScene = ResolveImportedScene(sceneResult, importedByRange, importedByNumber); var importedScene = ResolveImportedScene(sceneResult, importedById, importedByRange, importedByNumber);
if (importedScene is null) if (importedScene is null)
{ {
continue; continue;
@ -415,9 +416,15 @@ public sealed class StoryIntelligenceRelationshipImportService(
private static Scene? ResolveImportedScene( private static Scene? ResolveImportedScene(
StoryIntelligenceSavedSceneResult sceneResult, StoryIntelligenceSavedSceneResult sceneResult,
IReadOnlyDictionary<int, Scene> byId,
IReadOnlyDictionary<string, Scene> byRange, IReadOnlyDictionary<string, Scene> byRange,
IReadOnlyDictionary<int, Scene> byNumber) IReadOnlyDictionary<int, Scene> byNumber)
{ {
if (sceneResult.SceneID.HasValue && byId.TryGetValue(sceneResult.SceneID.Value, out var idMatch))
{
return idMatch;
}
if (sceneResult.StartParagraph.HasValue if (sceneResult.StartParagraph.HasValue
&& sceneResult.EndParagraph.HasValue && sceneResult.EndParagraph.HasValue
&& byRange.TryGetValue($"{sceneResult.StartParagraph}-{sceneResult.EndParagraph}", out var rangeMatch)) && byRange.TryGetValue($"{sceneResult.StartParagraph}-{sceneResult.EndParagraph}", out var rangeMatch))

View File

@ -0,0 +1,41 @@
SET ANSI_NULLS ON;
GO
SET QUOTED_IDENTIFIER ON;
GO
CREATE OR ALTER PROCEDURE dbo.SceneManuscriptSource_ListByChapter
@ChapterID int
AS
BEGIN
SET NOCOUNT ON;
SELECT source.SceneID, source.ProjectID, source.BookID, source.ChapterID, source.SourceText,
source.SourceWordCount, source.SourceCharacterCount, source.SourceStartParagraph, source.SourceEndParagraph,
source.WordStartParagraphIndex, source.WordEndParagraphIndex, source.SourceKind, scene.StructuralSummary,
source.CreatedUtc, source.UpdatedUtc
FROM dbo.SceneManuscriptSources source
INNER JOIN dbo.Scenes scene ON scene.SceneID = source.SceneID
WHERE source.ChapterID = @ChapterID
AND scene.IsArchived = 0
ORDER BY scene.SortOrder, scene.SceneNumber, scene.SceneID;
END;
GO
CREATE OR ALTER PROCEDURE dbo.SceneManuscriptSource_ListByBook
@BookID int
AS
BEGIN
SET NOCOUNT ON;
SELECT source.SceneID, source.ProjectID, source.BookID, source.ChapterID, source.SourceText,
source.SourceWordCount, source.SourceCharacterCount, source.SourceStartParagraph, source.SourceEndParagraph,
source.WordStartParagraphIndex, source.WordEndParagraphIndex, source.SourceKind, scene.StructuralSummary,
source.CreatedUtc, source.UpdatedUtc
FROM dbo.SceneManuscriptSources source
INNER JOIN dbo.Scenes scene ON scene.SceneID = source.SceneID
INNER JOIN dbo.Chapters chapter ON chapter.ChapterID = scene.ChapterID
WHERE source.BookID = @BookID
AND scene.IsArchived = 0
ORDER BY chapter.SortOrder, chapter.ChapterNumber, scene.SortOrder, scene.SceneNumber, scene.SceneID;
END;
GO

View File

@ -190,7 +190,7 @@
return stage switch return stage switch
{ {
"Queued" => "Waiting", "Queued" => "Waiting",
StoryIntelligenceFailureStages.ChapterStructure => "Finding scenes", StoryIntelligenceFailureStages.ChapterStructure => "Legacy boundary detection",
StoryIntelligenceFailureStages.SceneIntelligence => "Reading scenes", StoryIntelligenceFailureStages.SceneIntelligence => "Reading scenes",
StoryIntelligenceFailureStages.SceneSplit => "Preparing review", StoryIntelligenceFailureStages.SceneSplit => "Preparing review",
StoryIntelligenceFailureStages.Validation => "Preparing review", StoryIntelligenceFailureStages.Validation => "Preparing review",
@ -219,7 +219,7 @@
return chapter.CurrentStage switch return chapter.CurrentStage switch
{ {
"Queued" => "Waiting to read this chapter.", "Queued" => "Waiting to read this chapter.",
StoryIntelligenceFailureStages.ChapterStructure => "Finding scenes in this chapter.", StoryIntelligenceFailureStages.ChapterStructure => "Running legacy scene-boundary detection for this chapter.",
StoryIntelligenceFailureStages.SceneSplit => "Preparing the detected scenes.", StoryIntelligenceFailureStages.SceneSplit => "Preparing the detected scenes.",
StoryIntelligenceFailureStages.SceneIntelligence => "Reading the detected scenes.", StoryIntelligenceFailureStages.SceneIntelligence => "Reading the detected scenes.",
StoryIntelligenceFailureStages.Validation => "Checking the results.", StoryIntelligenceFailureStages.Validation => "Checking the results.",
@ -343,7 +343,7 @@
{ {
if ((chapter?.TotalDetectedScenes ?? 0) <= 0) if ((chapter?.TotalDetectedScenes ?? 0) <= 0)
{ {
return "Finding scenes..."; return "Loading scenes...";
} }
var completed = Math.Clamp(chapter?.CompletedScenes ?? 0, 0, chapter?.TotalDetectedScenes ?? 0); var completed = Math.Clamp(chapter?.CompletedScenes ?? 0, 0, chapter?.TotalDetectedScenes ?? 0);

View File

@ -56,7 +56,7 @@
case "DocumentRead": case "DocumentRead":
return "Preparing chapter"; return "Preparing chapter";
case "ChapterStructure": case "ChapterStructure":
return "Finding scenes"; return "Legacy boundary detection";
case "SceneSplit": case "SceneSplit":
return "Preparing scenes"; return "Preparing scenes";
case "SceneIntelligence": case "SceneIntelligence":
@ -148,7 +148,7 @@
case "DocumentRead": case "DocumentRead":
return "Preparing this chapter."; return "Preparing this chapter.";
case "ChapterStructure": case "ChapterStructure":
return "Finding scenes in this chapter."; return "Running legacy scene-boundary detection for this chapter.";
case "SceneSplit": case "SceneSplit":
return "Preparing the detected scenes."; return "Preparing the detected scenes.";
case "SceneIntelligence": case "SceneIntelligence":
@ -303,7 +303,7 @@
root.querySelectorAll("[data-story-chapter-progress-text]").forEach((node) => { root.querySelectorAll("[data-story-chapter-progress-text]").forEach((node) => {
node.textContent = totalNumber > 0 node.textContent = totalNumber > 0
? `Scene ${numberText(Math.min(Math.max(currentSceneDisplay, 0), totalNumber))} of ${numberText(totalNumber)}` ? `Scene ${numberText(Math.min(Math.max(currentSceneDisplay, 0), totalNumber))} of ${numberText(totalNumber)}`
: "Finding scenes..."; : "Loading scenes...";
}); });
root.querySelectorAll("[data-story-current-message]").forEach((node) => { root.querySelectorAll("[data-story-current-message]").forEach((node) => {
node.textContent = message; node.textContent = message;