Enrich existing structural scenes with Story Intelligence

This commit is contained in:
Nick Beckley 2026-08-25 12:35:07 +00:00
parent adf93a075c
commit 998c301d65
4 changed files with 115 additions and 21 deletions

View File

@ -62,6 +62,10 @@ var tests = new (string Name, Action Test)[]
("Illustration assignment repair enforces explicit evidence groups and strict subtypes", IllustrationAssignmentRepairEnforcesExplicitEvidenceGroupsAndStrictSubtypes),
("Lean import removes replay links while retaining progress", LeanImportRemovesReplayLinksWhileRetainingProgress),
("Story Intelligence progress does not prematurely report safe close", StoryIntelligenceProgressDoesNotPrematurelyReportSafeClose),
("Structural import creates scenes before Word sync", StructuralImportCreatesScenesBeforeWordSync),
("Structural import persists paragraph mapping and source text", StructuralImportPersistsParagraphMappingAndSourceText),
("Word sync requires save acknowledgement", WordSyncRequiresSaveAcknowledgement),
("Story Intelligence enriches existing structural scenes", StoryIntelligenceEnrichesExistingStructuralScenes),
("Phase 21T repairs durable character understanding", Phase21TRepairsDurableCharacterUnderstanding),
("Phase 21U extracts mandatory character appearance fixtures", Phase21UExtractsMandatoryCharacterAppearanceFixtures),
("Character sex hints are conservative and reviewable", CharacterSexHintsAreConservativeAndReviewable),
@ -1324,11 +1328,66 @@ static void StoryIntelligenceProgressDoesNotPrematurelyReportSafeClose()
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var progressView = File.ReadAllText(Path.Combine(root, "Views/Onboarding/StoryIntelligenceProgress.cshtml"));
Assert(!progressView.Contains("You can close Word", StringComparison.OrdinalIgnoreCase), "Progress page must not say Word can close before scene markers are synchronised.");
Assert(!progressView.Contains("close this browser tab", StringComparison.OrdinalIgnoreCase), "Progress page must not give a blanket browser safe-close instruction before Word sync acknowledgement exists.");
Assert(!progressView.Contains("safely leave this page", StringComparison.OrdinalIgnoreCase), "Progress page must not infer safe-close from queued/background analysis alone.");
Assert(progressView.Contains("Keep Word and the Companion open", StringComparison.Ordinal), "Progress page should tell the user Word/Companion are still needed for scene marker safety.");
Assert(progressView.Contains("Do not close Word yet", StringComparison.Ordinal), "Progress page should explicitly block premature Word closure.");
Assert(progressView.Contains("Word markers are already saved", StringComparison.Ordinal), "Story Intelligence progress should reflect that structural Word sync has already completed.");
Assert(progressView.Contains("Word does not need to remain open", StringComparison.Ordinal), "Optional Story Intelligence should not depend on Word remaining open.");
}
static void StructuralImportCreatesScenesBeforeWordSync()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var onboarding = File.ReadAllText(Path.Combine(root, "Services/OnboardingService.cs"));
var buildSql = File.ReadAllText(Path.Combine(root, "Sql/113_Phase20F_OnboardingManuscriptBuild.sql"));
Assert(onboarding.Contains("structureAnalysis.AnalyseChapterAsync", StringComparison.Ordinal), "Approved onboarding import should run structural scene-boundary analysis.");
Assert(onboarding.Contains("Scenes = structuralScenes.Select", StringComparison.Ordinal), "Validated structural boundaries should be passed to the onboarding build.");
Assert(buildSql.Contains("INSERT dbo.Scenes", StringComparison.Ordinal), "Onboarding build SQL should create canonical Scene records.");
Assert(onboarding.Contains("BuildSceneMarkerInstructions(structuralScenes, result.SceneMappings)", StringComparison.Ordinal), "Permanent SceneIDs should exist before Word marker instructions are built.");
}
static void StructuralImportPersistsParagraphMappingAndSourceText()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var models = File.ReadAllText(Path.Combine(root, "Models/ManuscriptScanModels.cs"));
var companion = File.ReadAllText(Path.Combine(root, "wwwroot/js/word-companion-host.js"));
var migration = File.ReadAllText(Path.Combine(root, "Sql/149_Phase24_StructuralImportMarkerSync.sql"));
var onboarding = File.ReadAllText(Path.Combine(root, "Services/OnboardingService.cs"));
Assert(models.Contains("StructuralParagraphOrdinal", StringComparison.Ordinal), "Scan payload should carry structural paragraph ordinals.");
Assert(models.Contains("WordParagraphIndex", StringComparison.Ordinal), "Scan payload should carry Word paragraph indexes.");
Assert(companion.Contains("structuralParagraphOrdinal", StringComparison.Ordinal), "Word scan should emit explicit structural paragraph mapping.");
Assert(migration.Contains("ManuscriptStructuralChapterSources", StringComparison.Ordinal), "Structural import should persist chapter source text.");
Assert(onboarding.Contains("SaveStructuralChapterSourcesAsync", StringComparison.Ordinal), "Onboarding import should save source text after canonical ChapterIDs exist.");
}
static void WordSyncRequiresSaveAcknowledgement()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var companion = File.ReadAllText(Path.Combine(root, "wwwroot/js/word-companion-host.js"));
var hub = File.ReadAllText(Path.Combine(root, "Hubs/WordCompanionFollowHub.cs"));
var presence = File.ReadAllText(Path.Combine(root, "wwwroot/js/word-companion-presence.js"));
var migration = File.ReadAllText(Path.Combine(root, "Sql/149_Phase24_StructuralImportMarkerSync.sql"));
Assert(companion.Contains("saveWordDocumentIfAvailable", StringComparison.Ordinal), "Companion should save Word before acknowledging marker sync.");
Assert(companion.Contains("CompleteOnboardingBuildMarkerSync", StringComparison.Ordinal), "Companion should acknowledge successful marker sync.");
Assert(companion.Contains("FailOnboardingBuildMarkerSync", StringComparison.Ordinal), "Companion should report failed marker sync.");
Assert(hub.Contains("MarkMarkerSyncCompletedAsync", StringComparison.Ordinal), "Server should persist completed marker sync.");
Assert(presence.Contains("OnboardingBuildMarkerSyncCompleted", StringComparison.Ordinal), "Browser should wait for marker sync completion before redirecting.");
Assert(migration.Contains("MarkerSyncCompletedUtc", StringComparison.Ordinal), "Database should persist marker sync completion time.");
}
static void StoryIntelligenceEnrichesExistingStructuralScenes()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var repository = File.ReadAllText(Path.Combine(root, "Data/StoryIntelligenceResultRepository.cs"));
var runner = File.ReadAllText(Path.Combine(root, "Services/PersistedStoryIntelligenceRunner.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(repository.Contains("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(commit.Contains("readiness.ActiveSceneCount != importScenes.Count", StringComparison.Ordinal), "Review readiness should allow matching structural scenes while blocking mismatches.");
}
static void StoryIntelligenceDoesNotRetryExhaustedBillingQuota()

View File

@ -34,6 +34,7 @@ public interface IStoryIntelligenceResultRepository
Task<StoryIntelligenceSavedRun?> GetRunAsync(int runId);
Task<StoryIntelligenceSavedChapterResult?> GetChapterResultAsync(int runId);
Task<IReadOnlyList<StoryIntelligenceSavedSceneResult>> ListSceneResultsAsync(int runId);
Task<IReadOnlyList<Scene>> ListChapterScenesAsync(int chapterId);
Task<StoryIntelligenceImportCommit?> GetImportCommitAsync(int runId);
Task<StoryIntelligenceChapterImportReadiness> GetChapterImportReadinessAsync(int chapterId);
Task<StoryIntelligenceImportCommitResult> CommitImportAsync(StoryIntelligenceImportCommitRequest request);
@ -517,6 +518,20 @@ public sealed class StoryIntelligenceResultRepository(ISqlConnectionFactory conn
return rows.ToList();
}
public async Task<IReadOnlyList<Scene>> ListChapterScenesAsync(int chapterId)
{
using var connection = connectionFactory.CreateConnection();
var rows = await connection.QueryAsync<Scene>(
"dbo.Scene_ListByChapter",
new { ChapterID = chapterId },
commandType: CommandType.StoredProcedure);
return rows.Where(scene => !scene.IsArchived)
.OrderBy(scene => scene.SortOrder)
.ThenBy(scene => scene.SceneNumber)
.ThenBy(scene => scene.SceneID)
.ToList();
}
public async Task<StoryIntelligenceImportCommit?> GetImportCommitAsync(int runId)
{
using var connection = connectionFactory.CreateConnection();
@ -566,14 +581,30 @@ public sealed class StoryIntelligenceResultRepository(ISqlConnectionFactory conn
new { request.ChapterID },
transaction,
commandType: CommandType.StoredProcedure);
var existingScenes = new List<Scene>();
var orderedScenes = request.Scenes.OrderBy(scene => scene.TemporarySceneNumber).ToList();
if (!readiness.CanCommit)
{
transaction.Rollback();
return new StoryIntelligenceImportCommitResult
existingScenes = (await connection.QueryAsync<Scene>(
"dbo.Scene_ListByChapter",
new { request.ChapterID },
transaction,
commandType: CommandType.StoredProcedure))
.Where(scene => !scene.IsArchived)
.OrderBy(scene => scene.SortOrder)
.ThenBy(scene => scene.SceneNumber)
.ThenBy(scene => scene.SceneID)
.ToList();
if (existingScenes.Count != orderedScenes.Count)
{
Success = false,
Message = readiness.BlockReason ?? "This chapter already contains scenes."
};
transaction.Rollback();
return new StoryIntelligenceImportCommitResult
{
Success = false,
Message = $"This chapter contains {existingScenes.Count:N0} existing scene(s), but Story Intelligence produced {orderedScenes.Count:N0} scene result(s)."
};
}
}
if (!string.IsNullOrWhiteSpace(request.ChapterSummary) || request.ChapterPurposeID.HasValue)
@ -590,7 +621,6 @@ public sealed class StoryIntelligenceResultRepository(ISqlConnectionFactory conn
commandType: CommandType.StoredProcedure);
}
var orderedScenes = request.Scenes.OrderBy(scene => scene.TemporarySceneNumber).ToList();
for (var index = 0; index < orderedScenes.Count; index++)
{
var item = orderedScenes[index];
@ -612,7 +642,7 @@ public sealed class StoryIntelligenceResultRepository(ISqlConnectionFactory conn
"dbo.Scene_Save",
new
{
SceneID = (int?)null,
SceneID = existingScenes.Count > index ? existingScenes[index].SceneID : (int?)null,
ChapterID = request.ChapterID,
SceneNumber = Convert.ToDecimal(item.TemporarySceneNumber),
SceneTitle = sceneTitle,
@ -737,7 +767,7 @@ public sealed class StoryIntelligenceResultRepository(ISqlConnectionFactory conn
}
var notes = new List<string>();
notes.Add("Committed into empty chapter.");
notes.Add(existingScenes.Count > 0 ? "Enriched existing structural scenes." : "Committed into empty chapter.");
notes.AddRange(request.Warnings.Where(warning => !string.IsNullOrWhiteSpace(warning)));
notes.AddRange(commitWarnings.Where(warning => !string.IsNullOrWhiteSpace(warning)));
@ -764,7 +794,9 @@ public sealed class StoryIntelligenceResultRepository(ISqlConnectionFactory conn
CommitID = commitId,
ScenesCreated = scenesCreated,
MetricsCreated = metricsCreated,
Message = $"Created {scenesCreated:N0} scene(s)."
Message = existingScenes.Count > 0
? $"Enriched {scenesCreated:N0} existing scene(s)."
: $"Created {scenesCreated:N0} scene(s)."
};
}
catch (Exception ex)

View File

@ -237,6 +237,9 @@ public sealed class PersistedStoryIntelligenceRunner(
failedScenes: failedScenes,
totalTokens: totals.TotalTokens);
var existingScenes = run.ChapterID.HasValue
? await repository.ListChapterScenesAsync(run.ChapterID.Value)
: Array.Empty<Scene>();
var sceneTemplate = await prompts.LoadPromptAsync(ScenePromptFile, cancellationToken);
foreach (var block in sceneBlocks)
{
@ -319,7 +322,7 @@ public sealed class PersistedStoryIntelligenceRunner(
ProjectID = run.ProjectID,
BookID = run.BookID,
ChapterID = run.ChapterID,
SceneID = null,
SceneID = existingScenes.FirstOrDefault(scene => Convert.ToInt32(scene.SceneNumber) == block.TemporarySceneNumber)?.SceneID,
TemporarySceneNumber = block.TemporarySceneNumber,
StartParagraph = block.StartParagraph,
EndParagraph = block.EndParagraph,

View File

@ -134,14 +134,6 @@ public sealed class StoryIntelligenceImportCommitService(
blockers.Add("No saved scene results are available to import.");
}
var readiness = run.ChapterID.HasValue
? await storyRuns.GetChapterImportReadinessAsync(run.ChapterID.Value)
: null;
if (readiness is not null && !readiness.CanCommit)
{
blockers.Add(readiness.BlockReason ?? "This chapter already contains scenes.");
}
if (existingCommit is not null && string.Equals(existingCommit.Status, StoryIntelligenceImportCommitStatuses.Completed, StringComparison.OrdinalIgnoreCase))
{
blockers.Add("This Story Intelligence run has already been committed.");
@ -203,6 +195,14 @@ public sealed class StoryIntelligenceImportCommitService(
blockers.Add("No scenes are ready to create for this chapter.");
}
var readiness = run.ChapterID.HasValue
? await storyRuns.GetChapterImportReadinessAsync(run.ChapterID.Value)
: null;
if (readiness is not null && !readiness.CanCommit && readiness.ActiveSceneCount != importScenes.Count)
{
blockers.Add($"This chapter contains {readiness.ActiveSceneCount:N0} existing scene(s), but Story Intelligence produced {importScenes.Count:N0} scene result(s).");
}
var projectName = run.ProjectID.HasValue ? (await projects.GetAsync(run.ProjectID.Value))?.ProjectName ?? $"Project {run.ProjectID.Value:N0}" : "None";
var bookTitle = run.BookID.HasValue ? (await books.GetAsync(run.BookID.Value))?.BookDisplayTitle ?? $"Book {run.BookID.Value:N0}" : "None";
var chapter = run.ChapterID.HasValue ? await chapters.GetAsync(run.ChapterID.Value) : null;