Enrich existing structural scenes with Story Intelligence
This commit is contained in:
parent
adf93a075c
commit
998c301d65
@ -62,6 +62,10 @@ var tests = new (string Name, Action Test)[]
|
|||||||
("Illustration assignment repair enforces explicit evidence groups and strict subtypes", IllustrationAssignmentRepairEnforcesExplicitEvidenceGroupsAndStrictSubtypes),
|
("Illustration assignment repair enforces explicit evidence groups and strict subtypes", IllustrationAssignmentRepairEnforcesExplicitEvidenceGroupsAndStrictSubtypes),
|
||||||
("Lean import removes replay links while retaining progress", LeanImportRemovesReplayLinksWhileRetainingProgress),
|
("Lean import removes replay links while retaining progress", LeanImportRemovesReplayLinksWhileRetainingProgress),
|
||||||
("Story Intelligence progress does not prematurely report safe close", StoryIntelligenceProgressDoesNotPrematurelyReportSafeClose),
|
("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 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),
|
||||||
@ -1324,11 +1328,66 @@ static void StoryIntelligenceProgressDoesNotPrematurelyReportSafeClose()
|
|||||||
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
|
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
|
||||||
var progressView = File.ReadAllText(Path.Combine(root, "Views/Onboarding/StoryIntelligenceProgress.cshtml"));
|
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("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("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("Word markers are already saved", StringComparison.Ordinal), "Story Intelligence progress should reflect that structural Word sync has already completed.");
|
||||||
Assert(progressView.Contains("Do not close Word yet", StringComparison.Ordinal), "Progress page should explicitly block premature Word closure.");
|
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()
|
static void StoryIntelligenceDoesNotRetryExhaustedBillingQuota()
|
||||||
|
|||||||
@ -34,6 +34,7 @@ public interface IStoryIntelligenceResultRepository
|
|||||||
Task<StoryIntelligenceSavedRun?> GetRunAsync(int runId);
|
Task<StoryIntelligenceSavedRun?> GetRunAsync(int runId);
|
||||||
Task<StoryIntelligenceSavedChapterResult?> GetChapterResultAsync(int runId);
|
Task<StoryIntelligenceSavedChapterResult?> GetChapterResultAsync(int runId);
|
||||||
Task<IReadOnlyList<StoryIntelligenceSavedSceneResult>> ListSceneResultsAsync(int runId);
|
Task<IReadOnlyList<StoryIntelligenceSavedSceneResult>> ListSceneResultsAsync(int runId);
|
||||||
|
Task<IReadOnlyList<Scene>> ListChapterScenesAsync(int chapterId);
|
||||||
Task<StoryIntelligenceImportCommit?> GetImportCommitAsync(int runId);
|
Task<StoryIntelligenceImportCommit?> GetImportCommitAsync(int runId);
|
||||||
Task<StoryIntelligenceChapterImportReadiness> GetChapterImportReadinessAsync(int chapterId);
|
Task<StoryIntelligenceChapterImportReadiness> GetChapterImportReadinessAsync(int chapterId);
|
||||||
Task<StoryIntelligenceImportCommitResult> CommitImportAsync(StoryIntelligenceImportCommitRequest request);
|
Task<StoryIntelligenceImportCommitResult> CommitImportAsync(StoryIntelligenceImportCommitRequest request);
|
||||||
@ -517,6 +518,20 @@ public sealed class StoryIntelligenceResultRepository(ISqlConnectionFactory conn
|
|||||||
return rows.ToList();
|
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)
|
public async Task<StoryIntelligenceImportCommit?> GetImportCommitAsync(int runId)
|
||||||
{
|
{
|
||||||
using var connection = connectionFactory.CreateConnection();
|
using var connection = connectionFactory.CreateConnection();
|
||||||
@ -566,14 +581,30 @@ public sealed class StoryIntelligenceResultRepository(ISqlConnectionFactory conn
|
|||||||
new { request.ChapterID },
|
new { request.ChapterID },
|
||||||
transaction,
|
transaction,
|
||||||
commandType: CommandType.StoredProcedure);
|
commandType: CommandType.StoredProcedure);
|
||||||
|
var existingScenes = new List<Scene>();
|
||||||
|
var orderedScenes = request.Scenes.OrderBy(scene => scene.TemporarySceneNumber).ToList();
|
||||||
if (!readiness.CanCommit)
|
if (!readiness.CanCommit)
|
||||||
{
|
{
|
||||||
transaction.Rollback();
|
existingScenes = (await connection.QueryAsync<Scene>(
|
||||||
return new StoryIntelligenceImportCommitResult
|
"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,
|
transaction.Rollback();
|
||||||
Message = readiness.BlockReason ?? "This chapter already contains scenes."
|
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)
|
if (!string.IsNullOrWhiteSpace(request.ChapterSummary) || request.ChapterPurposeID.HasValue)
|
||||||
@ -590,7 +621,6 @@ public sealed class StoryIntelligenceResultRepository(ISqlConnectionFactory conn
|
|||||||
commandType: CommandType.StoredProcedure);
|
commandType: CommandType.StoredProcedure);
|
||||||
}
|
}
|
||||||
|
|
||||||
var orderedScenes = request.Scenes.OrderBy(scene => scene.TemporarySceneNumber).ToList();
|
|
||||||
for (var index = 0; index < orderedScenes.Count; index++)
|
for (var index = 0; index < orderedScenes.Count; index++)
|
||||||
{
|
{
|
||||||
var item = orderedScenes[index];
|
var item = orderedScenes[index];
|
||||||
@ -612,7 +642,7 @@ public sealed class StoryIntelligenceResultRepository(ISqlConnectionFactory conn
|
|||||||
"dbo.Scene_Save",
|
"dbo.Scene_Save",
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
SceneID = (int?)null,
|
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,
|
||||||
@ -737,7 +767,7 @@ public sealed class StoryIntelligenceResultRepository(ISqlConnectionFactory conn
|
|||||||
}
|
}
|
||||||
|
|
||||||
var notes = new List<string>();
|
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(request.Warnings.Where(warning => !string.IsNullOrWhiteSpace(warning)));
|
||||||
notes.AddRange(commitWarnings.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,
|
CommitID = commitId,
|
||||||
ScenesCreated = scenesCreated,
|
ScenesCreated = scenesCreated,
|
||||||
MetricsCreated = metricsCreated,
|
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)
|
catch (Exception ex)
|
||||||
|
|||||||
@ -237,6 +237,9 @@ public sealed class PersistedStoryIntelligenceRunner(
|
|||||||
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)
|
||||||
{
|
{
|
||||||
@ -319,7 +322,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 = existingScenes.FirstOrDefault(scene => Convert.ToInt32(scene.SceneNumber) == block.TemporarySceneNumber)?.SceneID,
|
||||||
TemporarySceneNumber = block.TemporarySceneNumber,
|
TemporarySceneNumber = block.TemporarySceneNumber,
|
||||||
StartParagraph = block.StartParagraph,
|
StartParagraph = block.StartParagraph,
|
||||||
EndParagraph = block.EndParagraph,
|
EndParagraph = block.EndParagraph,
|
||||||
|
|||||||
@ -134,14 +134,6 @@ public sealed class StoryIntelligenceImportCommitService(
|
|||||||
blockers.Add("No saved scene results are available to import.");
|
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))
|
if (existingCommit is not null && string.Equals(existingCommit.Status, StoryIntelligenceImportCommitStatuses.Completed, StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
blockers.Add("This Story Intelligence run has already been committed.");
|
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.");
|
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 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 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;
|
var chapter = run.ChapterID.HasValue ? await chapters.GetAsync(run.ChapterID.Value) : null;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user