Fix core import scene detection flow

This commit is contained in:
Nick Beckley 2026-08-25 19:40:54 +00:00
parent 24f2120cb2
commit 4214779883
13 changed files with 382 additions and 142 deletions

View File

@ -20,6 +20,7 @@ var tests = new (string Name, Action Test)[]
("Truncated JSON is rejected", RejectsTruncatedJson),
("Repaired JSON deserialises into SceneIntelligenceScene", RepairedJsonDeserialises),
("Chapter Structure confidence 0. repairs and deserialises", ChapterStructureConfidenceRepairs),
("Chapter Structure boundary normaliser repairs gaps and overlaps", ChapterStructureBoundaryNormaliserRepairsGapsAndOverlaps),
("Shared parser reports raw chapter output on unrecoverable JSON", SharedParserReportsRawOutput),
("Character filtering rejects generic groups", CharacterFilteringRejectsGenericGroups),
("Character filtering preserves titled names", CharacterFilteringPreservesTitledNames),
@ -858,6 +859,54 @@ static void ChapterStructureConfidenceRepairs()
Assert(result.Warnings.Any(warning => warning.Contains("$.sceneBoundaries[0].confidence", StringComparison.Ordinal)), "Repair warning did not include JSON path.");
}
static void ChapterStructureBoundaryNormaliserRepairsGapsAndOverlaps()
{
var chapter = new ChapterStructureModel
{
SchemaVersion = "1.0",
ChapterSummary = "A chapter with several scene movements.",
SceneBoundaries =
[
new ChapterSceneBoundary
{
SceneNumber = 1,
StartParagraph = 1,
EndParagraph = 4,
StructuralSummary = "Opening scene summary with enough context for validation.",
Confidence = 0.8m,
Reason = "Opening location."
},
new ChapterSceneBoundary
{
SceneNumber = 2,
StartParagraph = 4,
EndParagraph = 7,
StructuralSummary = "Second scene summary with enough context for validation.",
Confidence = 0.8m,
Reason = "A new exchange begins."
},
new ChapterSceneBoundary
{
SceneNumber = 3,
StartParagraph = 9,
EndParagraph = 10,
StructuralSummary = "Final scene summary with enough context for validation.",
Confidence = 0.8m,
Reason = "Final turn."
}
]
};
var result = ChapterStructureBoundaryNormaliser.Normalise(chapter, 10);
var boundaries = result.ChapterStructure?.SceneBoundaries;
Assert(boundaries is { Count: 3 }, "Boundary normaliser should preserve the detected scene count.");
Assert(boundaries[0].StartParagraph == 1 && boundaries[0].EndParagraph == 4, "First boundary should remain unchanged.");
Assert(boundaries[1].StartParagraph == 5 && boundaries[1].EndParagraph == 7, "Overlap should be repaired by moving scene 2 start to paragraph 5.");
Assert(boundaries[2].StartParagraph == 8 && boundaries[2].EndParagraph == 10, "Gap should be repaired by moving scene 3 start to paragraph 8.");
Assert(result.Issues.Count >= 2, "Boundary repairs should be reported as warnings.");
}
static void SharedParserReportsRawOutput()
{
const string raw = """{"schemaVersion":"1.0","sceneBoundaries":[""";
@ -1534,7 +1583,7 @@ static void StructuralImportPersistsCanonicalSceneSourceText()
Assert(migration.Contains("IX_SceneManuscriptSources_BookChapter", StringComparison.Ordinal), "Scene source text should be indexed for book/chapter retrieval.");
Assert(onboarding.Contains("SaveSceneManuscriptSourcesAsync", StringComparison.Ordinal), "Structural import should persist scene source text before Word sync.");
Assert(onboarding.IndexOf("await SaveSceneManuscriptSourcesAsync(preview, structuralScenes, result)", StringComparison.Ordinal)
< onboarding.IndexOf("await PublishBuildProgress(userId, preview.PreviewID, \"Updating and saving Word markers", StringComparison.Ordinal), "Scene source text should be persisted before marker sync can complete.");
< onboarding.IndexOf("await PublishBuildProgress(userId, preview.PreviewID, \"Updating Word document with scene markers", StringComparison.Ordinal), "Scene source text should be persisted before marker sync can complete.");
Assert(analyzer.Contains("string.Join(Environment.NewLine + Environment.NewLine", StringComparison.Ordinal), "Scene source text should preserve paragraph separation.");
Assert(analyzer.Contains("item.IncludeInSourceText", StringComparison.Ordinal), "Scene source text should exclude non-prose structural paragraphs such as hidden markers/separators.");
}
@ -1588,16 +1637,24 @@ static void CoreImportWizardOrdersScenesBeforeCharacters()
var onboarding = File.ReadAllText(Path.Combine(root, "Services/OnboardingService.cs"));
Assert(review.Contains("value=\"structure\"", StringComparison.Ordinal), "Chapter review should submit to structural import, not character review.");
Assert(review.Contains("Continue to Scene Detection", StringComparison.Ordinal), "Chapter review should move to scene detection with clear wording.");
Assert(!review.Contains("Include all scenes", StringComparison.Ordinal), "Chapter review should not show provisional scene bulk controls.");
Assert(!review.Contains("Exclude all scenes", StringComparison.Ordinal), "Chapter review should not show provisional scene bulk controls.");
Assert(!review.Contains("Include scene", StringComparison.Ordinal), "Chapter review should not show provisional scene selectors.");
Assert(!review.Contains("No scene breaks were detected. PlotDirector will treat this chapter as one scene for now.", StringComparison.Ordinal), "Chapter review should not expose one-scene-per-chapter fallback text.");
Assert(review.Split("name=\"intent\" value=\"structure\"", StringSplitOptions.None).Length - 1 == 1, "Chapter review should have exactly one structure progression action.");
Assert(controller.Contains("var readyToImport = string.Equals(intent, \"structure\"", StringComparison.Ordinal), "Controller should treat structure intent as the scene-creation gate.");
Assert(controller.Contains("Import the approved chapters and scenes before reviewing characters.", StringComparison.Ordinal), "Character review should be guarded until structural import exists.");
Assert(controller.Contains("continueToCharacters || continueCharacterReview || backCharacterReview || completeCoreImport", StringComparison.Ordinal), "Structure-save errors should remain on chapter review rather than jumping to characters.");
Assert(controller.Contains("FinaliseCharacterReviewAsync", StringComparison.Ordinal), "Character review should have a distinct post-structure finalisation path.");
Assert(onboarding.Contains("ValidateScanStructure(review, preview);\n review = new ManuscriptScanReviewDecision", StringComparison.Ordinal), "Saving chapter review should validate only structure before scene import.");
Assert(onboarding.Contains("ValidateScanStructure(review, preview);\n\n var chapterDecisions", StringComparison.Ordinal), "Building approved structure should not validate unresolved character decisions.");
Assert(onboarding.Contains("BuildSceneStructureSummary(chapterDecisions, structuralScenes)", StringComparison.Ordinal), "Detected scene results should be preserved for review before characters.");
Assert(onboarding.Contains("ValidateScanReview(new ManuscriptScanReviewDecision", StringComparison.Ordinal), "Character validation should run when finalising character review.");
Assert(onboarding.Contains("Import the approved chapters and scenes before reviewing characters.", StringComparison.Ordinal), "Character finalisation should refuse to run before structural scenes exist.");
Assert(complete.Contains("Detected scene structure", StringComparison.Ordinal), "Scene detection result page should show detected counts by chapter.");
Assert(complete.Contains("Review characters", StringComparison.Ordinal), "After Word sync, the next onboarding step should be character review.");
Assert(complete.Contains("Word may now be closed; keep this browser workflow open", StringComparison.Ordinal), "Word safe-close must be distinct from browser/core-import completion.");
Assert(complete.Contains("Word may now be closed if you wish; keep this browser workflow open", StringComparison.Ordinal), "Word safe-close must be distinct from browser/core-import completion.");
}
static void CoreImportRemapsCharactersAfterReview()
@ -1894,11 +1951,13 @@ static void OnboardingWizardKeepsCharacterPagesInsideOneMainStep()
var storyService = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Services/OnboardingStoryIntelligenceService.cs"));
Assert(!header.Contains("BuildDynamicSteps", StringComparison.Ordinal), "Onboarding header should not create one top-level step per character page.");
Assert(header.Contains("new(\"Import Scenes\", 8)", StringComparison.Ordinal), "Onboarding header should include scene import as a distinct main step.");
Assert(header.Contains("new(\"Detect Scenes\", 8)", StringComparison.Ordinal), "Onboarding header should include scene detection as a distinct main step.");
Assert(header.Contains("new(\"Review Characters\", 9)", StringComparison.Ordinal), "Onboarding header should keep character review as one main step.");
Assert(!header.Contains("Story Summary", StringComparison.Ordinal), "Core onboarding header should not present Story Summary as a mandatory import step.");
Assert(!header.Contains("Story Context", StringComparison.Ordinal), "Core onboarding header should not present Story Context as a mandatory import step.");
Assert(review.Contains("var currentStep = isCharacterStep ? 9 : 7;", StringComparison.Ordinal), "Scan review should keep every character page on the same main wizard step.");
Assert(review.Contains("onboarding-subprogress", StringComparison.Ordinal), "Character pagination should be shown as section-level progress.");
Assert(storyService.Contains("const int totalWizardSteps = 12;", StringComparison.Ordinal), "Story Intelligence summary/context should use the fixed wizard step count.");
Assert(storyService.Contains("const int totalWizardSteps = 10;", StringComparison.Ordinal), "Story Intelligence summary/context should use the fixed core wizard step count.");
}
static void StoryIntelligenceExperienceBootDoesNotSerialiseLiveModel()

View File

@ -213,9 +213,25 @@ public sealed class WordCompanionFollowHub(
PreviewID = previewId
});
var result = await onboardingService.BuildApprovedStructureAsync(
previewId,
progress => Clients.Group(PresenceGroup(userId)).SendAsync("OnboardingBuildProgress", progress));
OnboardingManuscriptBuildResult? result;
try
{
result = await onboardingService.BuildApprovedStructureAsync(
previewId,
progress => Clients.Group(PresenceGroup(userId)).SendAsync("OnboardingBuildProgress", progress));
}
catch (InvalidOperationException ex)
{
await Clients.Group(PresenceGroup(userId)).SendAsync("OnboardingBuildProgress", new OnboardingBuildProgress
{
Status = "Failed",
Message = ex.Message,
PercentComplete = 25,
PreviewID = previewId
});
throw new HubException(ex.Message);
}
if (result is null)
{
throw new HubException("The reviewed scan could not be found.");

View File

@ -199,9 +199,30 @@ public sealed class OnboardingManuscriptBuildResult
public IReadOnlyList<OnboardingBuildCharacterMap> CharacterMappings { get; init; } = [];
public IReadOnlyList<OnboardingBuildMarkerInstruction> ChapterMarkerInstructions { get; init; } = [];
public IReadOnlyList<OnboardingBuildMarkerInstruction> SceneMarkerInstructions { get; init; } = [];
public IReadOnlyList<OnboardingSceneStructureSummary> SceneStructure { get; init; } = [];
public string? MarkerWarning { get; init; }
}
public sealed class OnboardingSceneStructureSummary
{
public string TemporaryChapterKey { get; init; } = string.Empty;
public int ChapterNumber { get; init; }
public string ChapterTitle { get; init; } = string.Empty;
public int SceneCount { get; init; }
public int WordCount { get; init; }
public IReadOnlyList<OnboardingSceneStructureDetail> Scenes { get; init; } = [];
}
public sealed class OnboardingSceneStructureDetail
{
public int SceneNumberWithinChapter { get; init; }
public string Title { get; init; } = string.Empty;
public int WordCount { get; init; }
public string? StructuralSummary { get; init; }
public int StartParagraph { get; init; }
public int EndParagraph { get; init; }
}
public sealed class OnboardingBuildChapterMap
{
public string TemporaryChapterKey { get; init; } = string.Empty;

View File

@ -13,6 +13,7 @@ public static class ChapterStructureBoundaryNormaliser
return new ChapterStructureBoundaryNormalisationResult(chapterStructure, []);
}
var issues = new List<ValidationIssue>();
var boundaries = chapterStructure.SceneBoundaries;
var finalIndex = boundaries.Count - 1;
var final = boundaries[finalIndex];
@ -22,31 +23,23 @@ public static class ChapterStructureBoundaryNormaliser
if (IsTrailingEmptyBoundary(final, previous, paragraphCount, phantomParagraph)
|| IsTrailingInvalidPlaceholder(final, previous, paragraphCount, phantomParagraph))
{
var normalisedBoundaries = boundaries
boundaries = boundaries
.Take(finalIndex)
.Select((boundary, index) => CopyBoundary(boundary, index + 1))
.ToList();
var normalised = new ChapterStructureModel
{
SchemaVersion = chapterStructure.SchemaVersion,
ChapterSummary = chapterStructure.ChapterSummary,
SceneBoundaries = normalisedBoundaries,
ExtensionData = chapterStructure.ExtensionData
};
var issue = new ValidationIssue
issues.Add(new ValidationIssue
{
Severity = "Warning",
Path = $"sceneBoundaries[{finalIndex}]",
Message = BuildNormalisationMessage(final, finalIndex, paragraphCount, phantomParagraph),
SuggestedFix = "Do not return a final scene boundary unless it references an actual supplied paragraph."
};
return new ChapterStructureBoundaryNormalisationResult(normalised, [issue]);
});
}
return new ChapterStructureBoundaryNormalisationResult(chapterStructure, []);
var continuity = RepairContinuity(chapterStructure, boundaries, paragraphCount);
issues.AddRange(continuity.Issues);
return new ChapterStructureBoundaryNormalisationResult(continuity.ChapterStructure, issues);
}
public static void AddIssuesTo(ValidationResult validation, ChapterStructureBoundaryNormalisationResult normalisation)
@ -98,6 +91,137 @@ public static class ChapterStructureBoundaryNormaliser
ExtensionData = boundary.ExtensionData
};
private static ChapterStructureBoundaryNormalisationResult RepairContinuity(
ChapterStructureModel chapterStructure,
IReadOnlyList<ChapterSceneBoundary> sourceBoundaries,
int paragraphCount)
{
if (sourceBoundaries.Count == 0)
{
return new ChapterStructureBoundaryNormalisationResult(chapterStructure, []);
}
var issues = new List<ValidationIssue>();
var ordered = sourceBoundaries
.Select((boundary, index) => new { Boundary = boundary, OriginalIndex = index })
.OrderBy(item => item.Boundary.StartParagraph ?? int.MaxValue)
.ThenBy(item => item.Boundary.EndParagraph ?? int.MaxValue)
.ToList();
if (ordered.Where((item, index) => item.OriginalIndex != index).Any())
{
issues.Add(new ValidationIssue
{
Severity = "Warning",
Path = "sceneBoundaries",
Message = "Reordered scene boundaries by paragraph position before validation.",
SuggestedFix = "Return scene boundaries in reading order."
});
}
var changed = issues.Count > 0;
var repaired = new List<ChapterSceneBoundary>();
var expectedStart = 1;
for (var index = 0; index < ordered.Count; index++)
{
var boundary = ordered[index].Boundary;
if (!boundary.StartParagraph.HasValue || !boundary.EndParagraph.HasValue)
{
repaired.Add(CopyBoundary(boundary, index + 1));
continue;
}
var start = boundary.StartParagraph.Value;
var end = boundary.EndParagraph.Value;
if (start != expectedStart)
{
issues.Add(new ValidationIssue
{
Severity = "Warning",
Path = $"sceneBoundaries[{index}].startParagraph",
Message = $"Adjusted scene {index + 1:N0} start paragraph from {start:N0} to {expectedStart:N0} to remove an AI boundary gap or overlap.",
SuggestedFix = $"Use startParagraph {expectedStart:N0} for this boundary."
});
start = expectedStart;
changed = true;
}
if (end < start)
{
issues.Add(new ValidationIssue
{
Severity = "Warning",
Path = $"sceneBoundaries[{index}].endParagraph",
Message = $"Adjusted scene {index + 1:N0} end paragraph from {end:N0} to {start:N0} after boundary continuity repair.",
SuggestedFix = "Return an endParagraph greater than or equal to startParagraph."
});
end = start;
changed = true;
}
if (end > paragraphCount)
{
issues.Add(new ValidationIssue
{
Severity = "Warning",
Path = $"sceneBoundaries[{index}].endParagraph",
Message = $"Clamped scene {index + 1:N0} end paragraph from {end:N0} to the chapter paragraph count {paragraphCount:N0}.",
SuggestedFix = $"Use an endParagraph from 1 to {paragraphCount:N0}."
});
end = paragraphCount;
changed = true;
}
repaired.Add(new ChapterSceneBoundary
{
SceneNumber = index + 1,
StartParagraph = start,
EndParagraph = end,
StructuralSummary = boundary.StructuralSummary,
Confidence = boundary.Confidence,
Reason = boundary.Reason,
ExtensionData = boundary.ExtensionData
});
expectedStart = end + 1;
}
if (expectedStart <= paragraphCount && repaired.Count > 0)
{
var final = repaired[^1];
issues.Add(new ValidationIssue
{
Severity = "Warning",
Path = $"sceneBoundaries[{repaired.Count - 1}].endParagraph",
Message = $"Extended the final scene boundary to paragraph {paragraphCount:N0} so detected scenes cover the full chapter.",
SuggestedFix = $"End the final scene at paragraph {paragraphCount:N0}."
});
repaired[^1] = new ChapterSceneBoundary
{
SceneNumber = final.SceneNumber,
StartParagraph = final.StartParagraph,
EndParagraph = paragraphCount,
StructuralSummary = final.StructuralSummary,
Confidence = final.Confidence,
Reason = final.Reason,
ExtensionData = final.ExtensionData
};
changed = true;
}
if (!changed)
{
return new ChapterStructureBoundaryNormalisationResult(chapterStructure, []);
}
return new ChapterStructureBoundaryNormalisationResult(new ChapterStructureModel
{
SchemaVersion = chapterStructure.SchemaVersion,
ChapterSummary = chapterStructure.ChapterSummary,
SceneBoundaries = repaired,
ExtensionData = chapterStructure.ExtensionData
}, issues);
}
private static bool IsTrailingEmptyBoundary(
ChapterSceneBoundary final,
ChapterSceneBoundary previous,

View File

@ -43,18 +43,40 @@ public sealed class ManuscriptStructureAnalysisService(
var prompt = BuildChapterPrompt(template, BuildChapterContextJson(request), StoryIntelligenceParagraphs.Number(paragraphs));
var result = await client.ExecutePromptAsync(prompt, promptVersion, cancellationToken, clientStatus.ChapterStructureModel);
var chapterJson = ExtractOutputText(result.RawResponseText);
var parsed = JsonSerializer.Deserialize<ChapterStructureModel>(chapterJson, JsonOptions);
var parseResult = StoryIntelligenceAiJsonParser.Parse<ChapterStructureModel>(chapterJson, JsonOptions);
if (!parseResult.Success)
{
return ManuscriptStructureAnalysisResult.Failed(
request.TemporaryChapterKey,
parseResult.ErrorMessage ?? "Chapter Structure JSON could not be parsed.",
parseResult.RawJson);
}
if (parseResult.Warnings.Count > 0)
{
logger.LogWarning(
"Repaired structural scene-boundary JSON. ProjectID={ProjectID} BookID={BookID} TemporaryChapterKey={TemporaryChapterKey} Warnings={Warnings}",
request.ProjectID,
request.BookID,
request.TemporaryChapterKey,
string.Join("; ", parseResult.Warnings));
}
var parsed = parseResult.Parsed;
var normalisation = ChapterStructureBoundaryNormaliser.Normalise(parsed, paragraphs.Count);
var chapter = normalisation.ChapterStructure;
var validation = validator.Validate(chapter, paragraphs.Count);
ChapterStructureBoundaryNormaliser.AddIssuesTo(validation, normalisation);
var persistedJson = normalisation.Issues.Count > 0
? ChapterStructureBoundaryNormaliser.SerialiseParsedModel(chapter, normalisation, JsonOptions)
: parseResult.RepairedJson ?? parseResult.RawJson;
if (!validation.IsValid || chapter?.SceneBoundaries is null || chapter.SceneBoundaries.Count == 0)
{
return ManuscriptStructureAnalysisResult.Failed(
request.TemporaryChapterKey,
BuildValidationMessage(validation),
chapterJson);
persistedJson);
}
var scenes = new List<ManuscriptStructureSceneBoundary>();
@ -68,7 +90,7 @@ public sealed class ManuscriptStructureAnalysisService(
return ManuscriptStructureAnalysisResult.Failed(
request.TemporaryChapterKey,
$"Scene {sceneNumber:N0} has an invalid paragraph range.",
chapterJson);
persistedJson);
}
var startMap = request.ParagraphMap.FirstOrDefault(item => item.StructuralParagraphOrdinal == start);
@ -78,7 +100,7 @@ public sealed class ManuscriptStructureAnalysisService(
return ManuscriptStructureAnalysisResult.Failed(
request.TemporaryChapterKey,
$"Scene {sceneNumber:N0} could not be mapped back to a Word paragraph.",
chapterJson);
persistedJson);
}
var sceneParagraphs = paragraphs.Skip(start - 1).Take(end - start + 1).ToList();
@ -108,7 +130,7 @@ public sealed class ManuscriptStructureAnalysisService(
request.TemporaryChapterKey,
scenes.Count);
return ManuscriptStructureAnalysisResult.Succeeded(request.TemporaryChapterKey, scenes, chapterJson);
return ManuscriptStructureAnalysisResult.Succeeded(request.TemporaryChapterKey, scenes, persistedJson);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{

View File

@ -235,15 +235,16 @@ public sealed class OnboardingService(
throw new InvalidOperationException("Review the scan and choose Save and continue before preparing chapters.");
}
await PublishBuildProgress(userId, preview.PreviewID, "Creating chapters...", 10, progress);
await PublishBuildProgress(userId, preview.PreviewID, "Preparing approved chapters for scene detection...", 10, progress);
ValidateScanStructure(review, preview);
var chapterDecisions = review.Chapters.Where(chapter => chapter.Include).OrderBy(chapter => chapter.ChapterNumber).ToList();
await PublishBuildProgress(userId, preview.PreviewID, "Detecting scenes...", 25, progress);
await PublishBuildProgress(userId, preview.PreviewID, "Detecting scene boundaries...", 20, progress);
var structuralScenes = new List<ManuscriptStructureSceneBoundary>();
foreach (var chapterDecision in chapterDecisions)
for (var chapterIndex = 0; chapterIndex < chapterDecisions.Count; chapterIndex++)
{
var chapterDecision = chapterDecisions[chapterIndex];
var chapterPreview = preview.Chapters.FirstOrDefault(item => item.TemporaryChapterKey == chapterDecision.TemporaryChapterKey)
?? throw new InvalidOperationException($"The approved chapter '{chapterDecision.Title}' could not be found in the scan preview.");
if (string.IsNullOrWhiteSpace(chapterPreview.ChapterText))
@ -256,6 +257,13 @@ public sealed class OnboardingService(
throw new InvalidOperationException($"The approved chapter '{chapterDecision.Title}' does not have paragraph mapping. Scan the manuscript again before importing.");
}
var analysisPercent = 20 + (chapterIndex * 20 / Math.Max(1, chapterDecisions.Count));
await PublishBuildProgress(
userId,
preview.PreviewID,
$"Analysing Chapter {chapterIndex + 1:N0} of {chapterDecisions.Count:N0} - {chapterDecision.Title}",
analysisPercent,
progress);
var analysis = await structureAnalysis.AnalyseChapterAsync(new ManuscriptStructureAnalysisRequest
{
ProjectID = preview.ProjectID,
@ -276,7 +284,7 @@ public sealed class OnboardingService(
structuralScenes.AddRange(analysis.Scenes);
}
await PublishBuildProgress(userId, preview.PreviewID, "Preparing chapters and scenes for import...", 45, progress);
await PublishBuildProgress(userId, preview.PreviewID, "Validating detected scene structure...", 45, progress);
var request = new OnboardingManuscriptBuildRequest
{
PreviewID = preview.PreviewID,
@ -301,7 +309,7 @@ public sealed class OnboardingService(
Characters = []
};
await PublishBuildProgress(userId, preview.PreviewID, "Creating PlotDirector structure...", 60, progress);
await PublishBuildProgress(userId, preview.PreviewID, "Creating PlotDirector scenes...", 60, progress);
var result = await builds.BuildAsync(userId, request)
?? throw new InvalidOperationException($"The project chapters could not be prepared. Approved chapters: {request.Chapters.Count}. Return to review, save your selections, and try again.");
result = new OnboardingManuscriptBuildResult
@ -325,13 +333,14 @@ public sealed class OnboardingService(
CharacterMappings = result.CharacterMappings,
ChapterMarkerInstructions = BuildChapterMarkerInstructions(preview, result.ChapterMappings),
SceneMarkerInstructions = BuildSceneMarkerInstructions(structuralScenes, result.SceneMappings),
SceneStructure = BuildSceneStructureSummary(chapterDecisions, structuralScenes),
MarkerWarning = result.MarkerWarning
};
await SaveStructuralChapterSourcesAsync(preview, result);
await SaveSceneManuscriptSourcesAsync(preview, structuralScenes, result);
await coreImportCharacterMapping.MapBookAsync(preview.ProjectID, preview.BookID);
await scanPreviews.SaveBuildResultAsync(userId, result);
await PublishBuildProgress(userId, preview.PreviewID, "Updating and saving Word markers. Keep Word and this browser page open.", 90, progress);
await PublishBuildProgress(userId, preview.PreviewID, "Updating Word document with scene markers. Keep Word open while the document is saved.", 90, progress);
return result;
}
@ -444,6 +453,7 @@ public sealed class OnboardingService(
CharacterMappings = characterMappings,
ChapterMarkerInstructions = build.ChapterMarkerInstructions,
SceneMarkerInstructions = build.SceneMarkerInstructions,
SceneStructure = build.SceneStructure,
MarkerWarning = build.MarkerWarning
};
@ -500,6 +510,37 @@ public sealed class OnboardingService(
.ToList();
}
private static IReadOnlyList<OnboardingSceneStructureSummary> BuildSceneStructureSummary(
IReadOnlyList<ManuscriptScanChapterReviewDecision> chapters,
IReadOnlyList<ManuscriptStructureSceneBoundary> scenes)
=> chapters
.OrderBy(chapter => chapter.ChapterNumber)
.Select(chapter =>
{
var chapterScenes = scenes
.Where(scene => string.Equals(scene.TemporaryChapterKey, chapter.TemporaryChapterKey, StringComparison.Ordinal))
.OrderBy(scene => scene.SceneNumberWithinChapter)
.ToList();
return new OnboardingSceneStructureSummary
{
TemporaryChapterKey = chapter.TemporaryChapterKey,
ChapterNumber = chapter.ChapterNumber,
ChapterTitle = chapter.Title,
SceneCount = chapterScenes.Count,
WordCount = chapterScenes.Sum(scene => scene.WordCount),
Scenes = chapterScenes.Select(scene => new OnboardingSceneStructureDetail
{
SceneNumberWithinChapter = scene.SceneNumberWithinChapter,
Title = scene.Title,
WordCount = scene.WordCount,
StructuralSummary = scene.StructuralSummary,
StartParagraph = scene.StartParagraph,
EndParagraph = scene.EndParagraph
}).ToList()
};
})
.ToList();
private async Task SaveStructuralChapterSourcesAsync(ManuscriptScanPreview preview, OnboardingManuscriptBuildResult result)
{
var chapterIds = result.ChapterMappings.ToDictionary(item => item.TemporaryChapterKey, item => item.ChapterID, StringComparer.Ordinal);
@ -703,6 +744,9 @@ public sealed class OnboardingService(
var selectedChapterKeys = chapters.Where(chapter => chapter.Include).Select(chapter => chapter.TemporaryChapterKey).ToHashSet(StringComparer.Ordinal);
var selectedScenes = chapters.SelectMany(chapter => chapter.Scenes).Where(scene => scene.Include && selectedChapterKeys.Contains(scene.TemporaryChapterKey)).ToList();
var selectedChapterWordCount = chapters
.Where(chapter => chapter.Include)
.Sum(chapter => chapter.WordCount);
return new ManuscriptScanReviewViewModel
{
@ -718,7 +762,7 @@ public sealed class OnboardingService(
SelectedChapterCount = selectedChapterKeys.Count,
SelectedSceneCount = selectedScenes.Count,
SelectedCharacterCount = characters.Count(character => character.Include && !character.IsAlias && !string.Equals(character.Category, "Excluded", StringComparison.OrdinalIgnoreCase)),
SelectedWordCount = selectedScenes.Sum(scene => scene.WordCount),
SelectedWordCount = selectedChapterWordCount,
Chapters = chapters,
CharacterCandidates = characters,
ExistingCharacterOptions = existingOptions
@ -764,18 +808,12 @@ public sealed class OnboardingService(
private static void ValidateScanStructure(ManuscriptScanReviewDecision review, ManuscriptScanPreview preview)
{
var previewChapters = preview.Chapters.ToDictionary(chapter => chapter.TemporaryChapterKey, StringComparer.Ordinal);
var previewScenes = preview.Scenes.ToDictionary(scene => scene.TemporarySceneKey, StringComparer.Ordinal);
var includedChapterKeys = review.Chapters.Where(chapter => chapter.Include).Select(chapter => chapter.TemporaryChapterKey).ToHashSet(StringComparer.Ordinal);
if (includedChapterKeys.Count == 0)
{
throw new InvalidOperationException("Choose at least one chapter before continuing.");
}
if (!review.Scenes.Any(scene => scene.Include && includedChapterKeys.Contains(scene.TemporaryChapterKey)))
{
throw new InvalidOperationException("Choose at least one scene before continuing.");
}
if (review.Chapters.Where(chapter => chapter.Include).Any(chapter => string.IsNullOrWhiteSpace(chapter.Title)))
{
throw new InvalidOperationException("Every included chapter needs a title.");
@ -786,24 +824,6 @@ public sealed class OnboardingService(
throw new InvalidOperationException("Chapter order is not valid. Restore the scan defaults and try again.");
}
foreach (var scene in review.Scenes.Where(scene => scene.Include))
{
if (!includedChapterKeys.Contains(scene.TemporaryChapterKey))
{
throw new InvalidOperationException("Included scenes must belong to included chapters.");
}
if (!previewScenes.TryGetValue(scene.TemporarySceneKey, out var previewScene))
{
throw new InvalidOperationException("One of the selected scenes no longer exists in the scan preview.");
}
if (previewScene.WordCount <= 0 && !scene.AllowZeroWords)
{
throw new InvalidOperationException("Included scenes with no words must be explicitly allowed.");
}
}
foreach (var chapter in review.Chapters)
{
if (!previewChapters.ContainsKey(chapter.TemporaryChapterKey))

View File

@ -72,8 +72,8 @@ public sealed class OnboardingStoryIntelligenceService(
var existingBatch = await batchStore.GetLatestForPreviewAsync(userId, preview.PreviewID);
var missingTextCount = included.Count(chapter => string.IsNullOrWhiteSpace(chapter.Preview.ChapterText));
const int storySummaryStep = 10;
const int storyContextStep = 11;
const int totalWizardSteps = 12;
const int storyContextStep = 10;
const int totalWizardSteps = 10;
return new StoryIntelligenceOverviewViewModel
{
PreviewID = preview.PreviewID,

View File

@ -128,8 +128,8 @@ public sealed class StoryIntelligenceOverviewViewModel
public int ApprovedWordCount { get; init; }
public int MissingChapterTextCount { get; init; }
public int WizardCurrentStep { get; init; } = 10;
public int WizardContextStep { get; init; } = 11;
public int WizardTotalSteps { get; init; } = 12;
public int WizardContextStep { get; init; } = 10;
public int WizardTotalSteps { get; init; } = 10;
public string? Message { get; init; }
public Guid? ExistingBatchID { get; init; }
public StoryIntelligenceJobProgress? ExistingJob { get; init; }

View File

@ -6,12 +6,12 @@
<section class="onboarding-shell" aria-labelledby="build-complete-title">
<div class="onboarding-panel onboarding-review-panel">
<partial name="_OnboardingJourneyHeader" model="@(new OnboardingJourneyHeaderViewModel { CurrentStep = coreComplete ? 12 : 9, TotalSteps = 12 })" />
<partial name="_OnboardingJourneyHeader" model="@(new OnboardingJourneyHeaderViewModel { CurrentStep = coreComplete ? 10 : 8, TotalSteps = 10 })" />
<div class="onboarding-copy">
<p class="eyebrow">Core Import Complete</p>
<p class="eyebrow">@(coreComplete ? "Core Import Complete" : "Scene Detection Complete")</p>
<div class="onboarding-success-heading">
<span aria-hidden="true">&check;</span>
<h1 id="build-complete-title">Your manuscript has been imported.</h1>
<h1 id="build-complete-title">@(coreComplete ? "Core import is complete." : "Scene structure has been detected.")</h1>
</div>
<p>
@if (coreComplete)
@ -22,11 +22,11 @@
{
@:This manuscript structure has already been created in PlotDirector, so nothing was duplicated. Review characters to complete the core import.
}
else
{
@:PlotDirector created the approved chapters and scenes, persisted their manuscript text, updated the Word markers, and saved the manuscript. Word may now be closed; keep this browser workflow open to review characters.
}
</p>
else
{
@:PlotDirector created the approved chapters and detected scenes, persisted their manuscript text, updated the Word markers, and saved the manuscript. Word may now be closed if you wish; keep this browser workflow open to review characters.
}
</p>
@if (!string.IsNullOrWhiteSpace(Model.MarkerWarning))
{
<p class="text-warning">@Model.MarkerWarning</p>
@ -52,6 +52,41 @@
</div>
</div>
@if (Model.SceneStructure.Any())
{
<section class="onboarding-complete-next" aria-label="Detected scene structure">
<h2>Detected scene structure</h2>
<div class="onboarding-review-chapters">
@foreach (var chapter in Model.SceneStructure)
{
<details class="onboarding-review-chapter">
<summary>
<span>Chapter @chapter.ChapterNumber</span>
<strong>@chapter.ChapterTitle</strong>
<small>@chapter.SceneCount.ToString("N0") scene@(chapter.SceneCount == 1 ? string.Empty : "s") / @chapter.WordCount.ToString("N0") words</small>
</summary>
@if (chapter.Scenes.Any(scene => !string.IsNullOrWhiteSpace(scene.StructuralSummary)))
{
<ol>
@foreach (var scene in chapter.Scenes)
{
<li>
<strong>@scene.Title</strong>
<span>@scene.WordCount.ToString("N0") words / paragraphs @scene.StartParagraph-@scene.EndParagraph</span>
@if (!string.IsNullOrWhiteSpace(scene.StructuralSummary))
{
<p>@scene.StructuralSummary</p>
}
</li>
}
</ol>
}
</details>
}
</div>
</section>
}
@if (coreComplete)
{
<section class="onboarding-complete-next">

View File

@ -239,11 +239,11 @@
else
{
<div class="onboarding-copy">
<p class="eyebrow">Step @currentJourneyOrder of 12</p>
<p class="eyebrow">Step @currentJourneyOrder of 10</p>
@if (Model.IsMicrosoftWordPath)
{
<h1 id="onboarding-title">@(structureReadyToImport ? "Import scenes and Word markers" : currentJourneyOrder >= 6 ? "Scan Manuscript" : "Connect your Word Companion")</h1>
<p>@(structureReadyToImport ? "Keep Word and this browser page open while PlotDirector detects scene boundaries, creates the manuscript structure, inserts bookmark IDs, and saves the document." : "Open your manuscript in Word and start the Companion. When it is connected, use the wizard button below to scan and continue.")</p>
<h1 id="onboarding-title">@(structureReadyToImport ? "Detect Scenes" : currentJourneyOrder >= 6 ? "Scan Manuscript" : "Connect your Word Companion")</h1>
<p>@(structureReadyToImport ? "PlotDirector will now analyse the approved chapters to determine where each scene begins and ends. Keep Word open while scene markers are added and the document is saved." : "Open your manuscript in Word and start the Companion. When it is connected, use the wizard button below to scan and continue.")</p>
}
else
{
@ -293,10 +293,6 @@
<span>Chapters</span>
<strong data-onboarding-scan-chapters>@Model.ScanState.ChapterCount</strong>
</div>
<div>
<span>Scenes</span>
<strong data-onboarding-scan-scenes>@Model.ScanState.SceneCount</strong>
</div>
<div>
<span>Characters</span>
<strong data-onboarding-scan-characters>@Model.ScanState.CharacterCandidateCount</strong>
@ -310,8 +306,8 @@
{
<div class="onboarding-build-panel" data-onboarding-build-panel>
<div>
<p class="onboarding-scan-next">Review saved. PlotDirector can now import the approved manuscript structure.</p>
<p class="onboarding-build-message" data-onboarding-build-message>Next: detect scene boundaries, create PlotDirector scenes, then update and save Word markers.</p>
<p class="onboarding-scan-next">Chapter review saved. PlotDirector can now detect the scene structure.</p>
<p class="onboarding-build-message" data-onboarding-build-message>Next: analyse the approved chapters and create the detected scenes.</p>
</div>
<strong data-onboarding-build-percent></strong>
<div class="onboarding-scan-progress onboarding-build-progress" aria-hidden="true">
@ -340,7 +336,7 @@
type="button"
data-onboarding-build-start
data-preview-id="@Model.ScanState.PreviewID">
Import manuscript
Detect Scenes
</button>
}
else if (Model.IsMicrosoftWordPath && Model.ScanState.IsComplete)
@ -461,13 +457,13 @@
OnboardingSteps.WritingSoftware => 3,
OnboardingSteps.Project => 4,
OnboardingSteps.Book => 5,
OnboardingSteps.NextPathPreview when !model.IsMicrosoftWordPath => 11,
OnboardingSteps.NextPathPreview when !model.IsMicrosoftWordPath => 10,
OnboardingSteps.NextPathPreview when string.Equals(model.ScanState.ReviewStatus, ManuscriptScanReviewStatuses.ReadyToImport, StringComparison.Ordinal) => 8,
OnboardingSteps.NextPathPreview when model.ScanState.IsComplete => 7,
OnboardingSteps.NextPathPreview when model.ScanState.IsRunning || model.ScanState.IsFailed || model.ScanState.PercentComplete.HasValue => 6,
OnboardingSteps.NextPathPreview when model.CompanionPresence.IsConnected => 6,
OnboardingSteps.NextPathPreview => 6,
OnboardingSteps.Complete => 12,
OnboardingSteps.Complete => 10,
_ => 1
};
}

View File

@ -32,7 +32,7 @@
var characterPage = Math.Clamp(requestedCharacterPage, 1, characterReviewPageCount);
var currentCharacters = primaryCharacters.Skip((characterPage - 1) * characterPageSize).Take(characterPageSize).ToList();
var currentStep = isCharacterStep ? 9 : 7;
const int totalSteps = 12;
const int totalSteps = 10;
var characterPageTitle = $"Review Characters {characterPage} of {primaryCharacterPageCount}";
var duplicateCanonicalNames = primaryCharacters
.Where(candidate => candidate.Include && string.IsNullOrWhiteSpace(candidate.AliasTargetTemporaryCharacterKey))
@ -67,7 +67,7 @@
<div class="onboarding-copy">
<p class="eyebrow">Step @currentStep of @totalSteps</p>
<h1 id="scan-review-title">@(isCharacterStep ? "Review Characters" : "Review Chapters")</h1>
<p>@(isCharacterStep ? "Review this page of detected names and aliases, then continue through the character section." : "Confirm the chapter structure before PlotDirector detects scenes with AI and updates Word markers.")</p>
<p>@(isCharacterStep ? "Review this page of detected names and aliases, then continue through the character section." : "Confirm which chapters should be imported before PlotDirector analyses their scene structure.")</p>
</div>
@if (TempData["OnboardingReviewMessage"] is string reviewMessage)
@ -87,10 +87,6 @@
<span>Chapters</span>
<strong>@Model.SelectedChapterCount / @Model.ChapterCount</strong>
</div>
<div>
<span>Scenes</span>
<strong>@Model.SelectedSceneCount / @Model.SceneCount</strong>
</div>
<div>
<span>Characters</span>
<strong>@Model.SelectedCharacterCount / @visibleCharacterCount</strong>
@ -106,8 +102,6 @@
<div class="onboarding-review-tools" data-review-tools>
<button class="btn btn-sm btn-outline-secondary" type="button" data-bulk-target="chapter" data-bulk-state="true">Include all chapters</button>
<button class="btn btn-sm btn-outline-secondary" type="button" data-bulk-target="chapter" data-bulk-state="false">Exclude all chapters</button>
<button class="btn btn-sm btn-outline-secondary" type="button" data-bulk-target="scene" data-bulk-state="true">Include all scenes</button>
<button class="btn btn-sm btn-outline-secondary" type="button" data-bulk-target="scene" data-bulk-state="false">Exclude all scenes</button>
<button class="btn btn-sm btn-outline-secondary" type="reset">Restore scan defaults</button>
</div>
}
@ -124,7 +118,6 @@
<div class="onboarding-review-chapters">
@{
var chapterIndex = 0;
var sceneIndex = 0;
}
@foreach (var chapter in Model.Chapters)
{
@ -132,7 +125,7 @@
<summary>
<span>Chapter @chapter.ChapterNumber</span>
<strong>@chapter.ReviewTitle</strong>
<small>@chapter.WordCount.ToString("N0") words / @chapter.Scenes.Count scene@(chapter.Scenes.Count == 1 ? string.Empty : "s")</small>
<small>@chapter.WordCount.ToString("N0") words</small>
</summary>
<input type="hidden" name="Chapters[@chapterIndex].TemporaryChapterKey" value="@chapter.TemporaryChapterKey" />
<input type="hidden" name="Chapters[@chapterIndex].ChapterNumber" value="@chapter.ChapterNumber" />
@ -145,55 +138,11 @@
Chapter title
<input class="form-control" name="Chapters[@chapterIndex].Title" value="@chapter.ReviewTitle" />
</label>
@if (chapter.Scenes.Any())
{
<ol>
@foreach (var scene in chapter.Scenes)
{
<li>
<input type="hidden" name="Scenes[@sceneIndex].TemporarySceneKey" value="@scene.TemporarySceneKey" />
<input type="hidden" name="Scenes[@sceneIndex].TemporaryChapterKey" value="@scene.TemporaryChapterKey" />
<input type="hidden" name="Scenes[@sceneIndex].SceneNumberWithinChapter" value="@scene.SceneNumberWithinChapter" />
<label class="onboarding-review-toggle">
<input type="checkbox" name="Scenes[@sceneIndex].Include" value="true" checked="@scene.Include" data-review-kind="scene" />
<input type="hidden" name="Scenes[@sceneIndex].Include" value="false" />
Include scene
</label>
<label class="onboarding-review-field">
Scene title
<input class="form-control" name="Scenes[@sceneIndex].Title" value="@scene.ReviewTitle" placeholder="Scene @scene.SceneNumberWithinChapter" />
</label>
<span>@scene.WordCount.ToString("N0") words</span>
@if (scene.WordCount == 0)
{
<label class="onboarding-review-toggle">
<input type="checkbox" name="Scenes[@sceneIndex].AllowZeroWords" value="true" />
<input type="hidden" name="Scenes[@sceneIndex].AllowZeroWords" value="false" />
Allow empty scene
</label>
}
@if (!string.IsNullOrWhiteSpace(scene.OpeningTextPreview))
{
<p>@scene.OpeningTextPreview</p>
}
</li>
sceneIndex++;
}
</ol>
}
else
{
<p>No scene breaks were detected. PlotDirector will treat this chapter as one scene for now.</p>
}
</details>
chapterIndex++;
}
</div>
}
<div class="onboarding-actions onboarding-actions--inline">
<button class="btn btn-primary" type="submit" name="intent" value="structure">Save chapter review</button>
</div>
</div>
<aside class="onboarding-review-section" data-scan-review-panel="characters" hidden="@(!isCharacterStep)">
@ -364,7 +313,7 @@
else
{
<a class="btn btn-outline-secondary" asp-controller="Onboarding" asp-action="Index">Back to setup</a>
<button class="btn btn-primary" type="submit" name="intent" value="structure">Save chapter review</button>
<button class="btn btn-primary" type="submit" name="intent" value="structure">Continue to Scene Detection</button>
}
</div>
</form>

View File

@ -36,11 +36,9 @@
new("Book", 5),
new("Connect Word", 6),
new("Review Chapters", 7),
new("Import Scenes", 8),
new("Detect Scenes", 8),
new("Review Characters", 9),
new("Story Summary", 10),
new("Story Context", 11),
new("Complete", 12)
new("Complete", 10)
];
private static string StepClass(int order, int currentStep)

View File

@ -226,7 +226,7 @@
node.classList.add("is-running");
});
document.querySelectorAll("[data-onboarding-build-message]").forEach((node) => {
node.textContent = "Preparing the approved chapters...";
node.textContent = "Preparing approved chapters for scene detection...";
});
document.querySelectorAll("[data-onboarding-build-percent]").forEach((node) => {
node.textContent = "0%";
@ -238,7 +238,7 @@
const result = await connection.invoke("StartOnboardingProjectBuild", previewId);
pendingBuildPreviewId = field(result, "previewID", "PreviewID") || previewId;
document.querySelectorAll("[data-onboarding-build-message]").forEach((node) => {
node.textContent = "Updating and saving Word markers. Keep Word and this browser page open.";
node.textContent = "Updating Word document with scene markers. Keep Word open while the document is saved.";
});
} catch (error) {
button.disabled = false;