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), ("Truncated JSON is rejected", RejectsTruncatedJson),
("Repaired JSON deserialises into SceneIntelligenceScene", RepairedJsonDeserialises), ("Repaired JSON deserialises into SceneIntelligenceScene", RepairedJsonDeserialises),
("Chapter Structure confidence 0. repairs and deserialises", ChapterStructureConfidenceRepairs), ("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), ("Shared parser reports raw chapter output on unrecoverable JSON", SharedParserReportsRawOutput),
("Character filtering rejects generic groups", CharacterFilteringRejectsGenericGroups), ("Character filtering rejects generic groups", CharacterFilteringRejectsGenericGroups),
("Character filtering preserves titled names", CharacterFilteringPreservesTitledNames), ("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."); 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() static void SharedParserReportsRawOutput()
{ {
const string raw = """{"schemaVersion":"1.0","sceneBoundaries":["""; 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(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.Contains("SaveSceneManuscriptSourcesAsync", StringComparison.Ordinal), "Structural import should persist scene source text before Word sync.");
Assert(onboarding.IndexOf("await SaveSceneManuscriptSourcesAsync(preview, structuralScenes, result)", StringComparison.Ordinal) 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("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."); 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")); 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("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("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("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("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(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 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("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("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(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("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() static void CoreImportRemapsCharactersAfterReview()
@ -1894,11 +1951,13 @@ static void OnboardingWizardKeepsCharacterPagesInsideOneMainStep()
var storyService = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Services/OnboardingStoryIntelligenceService.cs")); 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("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("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("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(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() static void StoryIntelligenceExperienceBootDoesNotSerialiseLiveModel()

View File

@ -213,9 +213,25 @@ public sealed class WordCompanionFollowHub(
PreviewID = previewId PreviewID = previewId
}); });
var result = await onboardingService.BuildApprovedStructureAsync( OnboardingManuscriptBuildResult? result;
try
{
result = await onboardingService.BuildApprovedStructureAsync(
previewId, previewId,
progress => Clients.Group(PresenceGroup(userId)).SendAsync("OnboardingBuildProgress", progress)); 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) if (result is null)
{ {
throw new HubException("The reviewed scan could not be found."); 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<OnboardingBuildCharacterMap> CharacterMappings { get; init; } = [];
public IReadOnlyList<OnboardingBuildMarkerInstruction> ChapterMarkerInstructions { get; init; } = []; public IReadOnlyList<OnboardingBuildMarkerInstruction> ChapterMarkerInstructions { get; init; } = [];
public IReadOnlyList<OnboardingBuildMarkerInstruction> SceneMarkerInstructions { get; init; } = []; public IReadOnlyList<OnboardingBuildMarkerInstruction> SceneMarkerInstructions { get; init; } = [];
public IReadOnlyList<OnboardingSceneStructureSummary> SceneStructure { get; init; } = [];
public string? MarkerWarning { 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 sealed class OnboardingBuildChapterMap
{ {
public string TemporaryChapterKey { get; init; } = string.Empty; public string TemporaryChapterKey { get; init; } = string.Empty;

View File

@ -13,6 +13,7 @@ public static class ChapterStructureBoundaryNormaliser
return new ChapterStructureBoundaryNormalisationResult(chapterStructure, []); return new ChapterStructureBoundaryNormalisationResult(chapterStructure, []);
} }
var issues = new List<ValidationIssue>();
var boundaries = chapterStructure.SceneBoundaries; var boundaries = chapterStructure.SceneBoundaries;
var finalIndex = boundaries.Count - 1; var finalIndex = boundaries.Count - 1;
var final = boundaries[finalIndex]; var final = boundaries[finalIndex];
@ -22,31 +23,23 @@ public static class ChapterStructureBoundaryNormaliser
if (IsTrailingEmptyBoundary(final, previous, paragraphCount, phantomParagraph) if (IsTrailingEmptyBoundary(final, previous, paragraphCount, phantomParagraph)
|| IsTrailingInvalidPlaceholder(final, previous, paragraphCount, phantomParagraph)) || IsTrailingInvalidPlaceholder(final, previous, paragraphCount, phantomParagraph))
{ {
var normalisedBoundaries = boundaries boundaries = boundaries
.Take(finalIndex) .Take(finalIndex)
.Select((boundary, index) => CopyBoundary(boundary, index + 1)) .Select((boundary, index) => CopyBoundary(boundary, index + 1))
.ToList(); .ToList();
var normalised = new ChapterStructureModel issues.Add(new ValidationIssue
{
SchemaVersion = chapterStructure.SchemaVersion,
ChapterSummary = chapterStructure.ChapterSummary,
SceneBoundaries = normalisedBoundaries,
ExtensionData = chapterStructure.ExtensionData
};
var issue = new ValidationIssue
{ {
Severity = "Warning", Severity = "Warning",
Path = $"sceneBoundaries[{finalIndex}]", Path = $"sceneBoundaries[{finalIndex}]",
Message = BuildNormalisationMessage(final, finalIndex, paragraphCount, phantomParagraph), Message = BuildNormalisationMessage(final, finalIndex, paragraphCount, phantomParagraph),
SuggestedFix = "Do not return a final scene boundary unless it references an actual supplied paragraph." 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) public static void AddIssuesTo(ValidationResult validation, ChapterStructureBoundaryNormalisationResult normalisation)
@ -98,6 +91,137 @@ public static class ChapterStructureBoundaryNormaliser
ExtensionData = boundary.ExtensionData 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( private static bool IsTrailingEmptyBoundary(
ChapterSceneBoundary final, ChapterSceneBoundary final,
ChapterSceneBoundary previous, ChapterSceneBoundary previous,

View File

@ -43,18 +43,40 @@ public sealed class ManuscriptStructureAnalysisService(
var prompt = BuildChapterPrompt(template, BuildChapterContextJson(request), StoryIntelligenceParagraphs.Number(paragraphs)); var prompt = BuildChapterPrompt(template, BuildChapterContextJson(request), StoryIntelligenceParagraphs.Number(paragraphs));
var result = await client.ExecutePromptAsync(prompt, promptVersion, cancellationToken, clientStatus.ChapterStructureModel); var result = await client.ExecutePromptAsync(prompt, promptVersion, cancellationToken, clientStatus.ChapterStructureModel);
var chapterJson = ExtractOutputText(result.RawResponseText); 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 normalisation = ChapterStructureBoundaryNormaliser.Normalise(parsed, paragraphs.Count);
var chapter = normalisation.ChapterStructure; var chapter = normalisation.ChapterStructure;
var validation = validator.Validate(chapter, paragraphs.Count); var validation = validator.Validate(chapter, paragraphs.Count);
ChapterStructureBoundaryNormaliser.AddIssuesTo(validation, normalisation); 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) if (!validation.IsValid || chapter?.SceneBoundaries is null || chapter.SceneBoundaries.Count == 0)
{ {
return ManuscriptStructureAnalysisResult.Failed( return ManuscriptStructureAnalysisResult.Failed(
request.TemporaryChapterKey, request.TemporaryChapterKey,
BuildValidationMessage(validation), BuildValidationMessage(validation),
chapterJson); persistedJson);
} }
var scenes = new List<ManuscriptStructureSceneBoundary>(); var scenes = new List<ManuscriptStructureSceneBoundary>();
@ -68,7 +90,7 @@ public sealed class ManuscriptStructureAnalysisService(
return ManuscriptStructureAnalysisResult.Failed( return ManuscriptStructureAnalysisResult.Failed(
request.TemporaryChapterKey, request.TemporaryChapterKey,
$"Scene {sceneNumber:N0} has an invalid paragraph range.", $"Scene {sceneNumber:N0} has an invalid paragraph range.",
chapterJson); persistedJson);
} }
var startMap = request.ParagraphMap.FirstOrDefault(item => item.StructuralParagraphOrdinal == start); var startMap = request.ParagraphMap.FirstOrDefault(item => item.StructuralParagraphOrdinal == start);
@ -78,7 +100,7 @@ public sealed class ManuscriptStructureAnalysisService(
return ManuscriptStructureAnalysisResult.Failed( return ManuscriptStructureAnalysisResult.Failed(
request.TemporaryChapterKey, request.TemporaryChapterKey,
$"Scene {sceneNumber:N0} could not be mapped back to a Word paragraph.", $"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(); var sceneParagraphs = paragraphs.Skip(start - 1).Take(end - start + 1).ToList();
@ -108,7 +130,7 @@ public sealed class ManuscriptStructureAnalysisService(
request.TemporaryChapterKey, request.TemporaryChapterKey,
scenes.Count); scenes.Count);
return ManuscriptStructureAnalysisResult.Succeeded(request.TemporaryChapterKey, scenes, chapterJson); return ManuscriptStructureAnalysisResult.Succeeded(request.TemporaryChapterKey, scenes, persistedJson);
} }
catch (Exception ex) when (ex is not OperationCanceledException) 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."); 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); ValidateScanStructure(review, preview);
var chapterDecisions = review.Chapters.Where(chapter => chapter.Include).OrderBy(chapter => chapter.ChapterNumber).ToList(); 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>(); 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) 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."); ?? throw new InvalidOperationException($"The approved chapter '{chapterDecision.Title}' could not be found in the scan preview.");
if (string.IsNullOrWhiteSpace(chapterPreview.ChapterText)) 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."); 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 var analysis = await structureAnalysis.AnalyseChapterAsync(new ManuscriptStructureAnalysisRequest
{ {
ProjectID = preview.ProjectID, ProjectID = preview.ProjectID,
@ -276,7 +284,7 @@ public sealed class OnboardingService(
structuralScenes.AddRange(analysis.Scenes); 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 var request = new OnboardingManuscriptBuildRequest
{ {
PreviewID = preview.PreviewID, PreviewID = preview.PreviewID,
@ -301,7 +309,7 @@ public sealed class OnboardingService(
Characters = [] 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) 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."); ?? 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 result = new OnboardingManuscriptBuildResult
@ -325,13 +333,14 @@ public sealed class OnboardingService(
CharacterMappings = result.CharacterMappings, CharacterMappings = result.CharacterMappings,
ChapterMarkerInstructions = BuildChapterMarkerInstructions(preview, result.ChapterMappings), ChapterMarkerInstructions = BuildChapterMarkerInstructions(preview, result.ChapterMappings),
SceneMarkerInstructions = BuildSceneMarkerInstructions(structuralScenes, result.SceneMappings), SceneMarkerInstructions = BuildSceneMarkerInstructions(structuralScenes, result.SceneMappings),
SceneStructure = BuildSceneStructureSummary(chapterDecisions, structuralScenes),
MarkerWarning = result.MarkerWarning MarkerWarning = result.MarkerWarning
}; };
await SaveStructuralChapterSourcesAsync(preview, result); await SaveStructuralChapterSourcesAsync(preview, result);
await SaveSceneManuscriptSourcesAsync(preview, structuralScenes, result); await SaveSceneManuscriptSourcesAsync(preview, structuralScenes, result);
await coreImportCharacterMapping.MapBookAsync(preview.ProjectID, preview.BookID); await coreImportCharacterMapping.MapBookAsync(preview.ProjectID, preview.BookID);
await scanPreviews.SaveBuildResultAsync(userId, result); 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; return result;
} }
@ -444,6 +453,7 @@ public sealed class OnboardingService(
CharacterMappings = characterMappings, CharacterMappings = characterMappings,
ChapterMarkerInstructions = build.ChapterMarkerInstructions, ChapterMarkerInstructions = build.ChapterMarkerInstructions,
SceneMarkerInstructions = build.SceneMarkerInstructions, SceneMarkerInstructions = build.SceneMarkerInstructions,
SceneStructure = build.SceneStructure,
MarkerWarning = build.MarkerWarning MarkerWarning = build.MarkerWarning
}; };
@ -500,6 +510,37 @@ public sealed class OnboardingService(
.ToList(); .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) private async Task SaveStructuralChapterSourcesAsync(ManuscriptScanPreview preview, OnboardingManuscriptBuildResult result)
{ {
var chapterIds = result.ChapterMappings.ToDictionary(item => item.TemporaryChapterKey, item => item.ChapterID, StringComparer.Ordinal); 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 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 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 return new ManuscriptScanReviewViewModel
{ {
@ -718,7 +762,7 @@ public sealed class OnboardingService(
SelectedChapterCount = selectedChapterKeys.Count, SelectedChapterCount = selectedChapterKeys.Count,
SelectedSceneCount = selectedScenes.Count, SelectedSceneCount = selectedScenes.Count,
SelectedCharacterCount = characters.Count(character => character.Include && !character.IsAlias && !string.Equals(character.Category, "Excluded", StringComparison.OrdinalIgnoreCase)), 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, Chapters = chapters,
CharacterCandidates = characters, CharacterCandidates = characters,
ExistingCharacterOptions = existingOptions ExistingCharacterOptions = existingOptions
@ -764,18 +808,12 @@ public sealed class OnboardingService(
private static void ValidateScanStructure(ManuscriptScanReviewDecision review, ManuscriptScanPreview preview) private static void ValidateScanStructure(ManuscriptScanReviewDecision review, ManuscriptScanPreview preview)
{ {
var previewChapters = preview.Chapters.ToDictionary(chapter => chapter.TemporaryChapterKey, StringComparer.Ordinal); 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); var includedChapterKeys = review.Chapters.Where(chapter => chapter.Include).Select(chapter => chapter.TemporaryChapterKey).ToHashSet(StringComparer.Ordinal);
if (includedChapterKeys.Count == 0) if (includedChapterKeys.Count == 0)
{ {
throw new InvalidOperationException("Choose at least one chapter before continuing."); 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))) if (review.Chapters.Where(chapter => chapter.Include).Any(chapter => string.IsNullOrWhiteSpace(chapter.Title)))
{ {
throw new InvalidOperationException("Every included chapter needs a 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."); 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) foreach (var chapter in review.Chapters)
{ {
if (!previewChapters.ContainsKey(chapter.TemporaryChapterKey)) if (!previewChapters.ContainsKey(chapter.TemporaryChapterKey))

View File

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

View File

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

View File

@ -6,12 +6,12 @@
<section class="onboarding-shell" aria-labelledby="build-complete-title"> <section class="onboarding-shell" aria-labelledby="build-complete-title">
<div class="onboarding-panel onboarding-review-panel"> <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"> <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"> <div class="onboarding-success-heading">
<span aria-hidden="true">&check;</span> <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> </div>
<p> <p>
@if (coreComplete) @if (coreComplete)
@ -24,7 +24,7 @@
} }
else 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. @: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> </p>
@if (!string.IsNullOrWhiteSpace(Model.MarkerWarning)) @if (!string.IsNullOrWhiteSpace(Model.MarkerWarning))
@ -52,6 +52,41 @@
</div> </div>
</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) @if (coreComplete)
{ {
<section class="onboarding-complete-next"> <section class="onboarding-complete-next">

View File

@ -239,11 +239,11 @@
else else
{ {
<div class="onboarding-copy"> <div class="onboarding-copy">
<p class="eyebrow">Step @currentJourneyOrder of 12</p> <p class="eyebrow">Step @currentJourneyOrder of 10</p>
@if (Model.IsMicrosoftWordPath) @if (Model.IsMicrosoftWordPath)
{ {
<h1 id="onboarding-title">@(structureReadyToImport ? "Import scenes and Word markers" : currentJourneyOrder >= 6 ? "Scan Manuscript" : "Connect your Word Companion")</h1> <h1 id="onboarding-title">@(structureReadyToImport ? "Detect Scenes" : 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> <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 else
{ {
@ -293,10 +293,6 @@
<span>Chapters</span> <span>Chapters</span>
<strong data-onboarding-scan-chapters>@Model.ScanState.ChapterCount</strong> <strong data-onboarding-scan-chapters>@Model.ScanState.ChapterCount</strong>
</div> </div>
<div>
<span>Scenes</span>
<strong data-onboarding-scan-scenes>@Model.ScanState.SceneCount</strong>
</div>
<div> <div>
<span>Characters</span> <span>Characters</span>
<strong data-onboarding-scan-characters>@Model.ScanState.CharacterCandidateCount</strong> <strong data-onboarding-scan-characters>@Model.ScanState.CharacterCandidateCount</strong>
@ -310,8 +306,8 @@
{ {
<div class="onboarding-build-panel" data-onboarding-build-panel> <div class="onboarding-build-panel" data-onboarding-build-panel>
<div> <div>
<p class="onboarding-scan-next">Review saved. PlotDirector can now import the approved manuscript structure.</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: detect scene boundaries, create PlotDirector scenes, then update and save Word markers.</p> <p class="onboarding-build-message" data-onboarding-build-message>Next: analyse the approved chapters and create the detected scenes.</p>
</div> </div>
<strong data-onboarding-build-percent></strong> <strong data-onboarding-build-percent></strong>
<div class="onboarding-scan-progress onboarding-build-progress" aria-hidden="true"> <div class="onboarding-scan-progress onboarding-build-progress" aria-hidden="true">
@ -340,7 +336,7 @@
type="button" type="button"
data-onboarding-build-start data-onboarding-build-start
data-preview-id="@Model.ScanState.PreviewID"> data-preview-id="@Model.ScanState.PreviewID">
Import manuscript Detect Scenes
</button> </button>
} }
else if (Model.IsMicrosoftWordPath && Model.ScanState.IsComplete) else if (Model.IsMicrosoftWordPath && Model.ScanState.IsComplete)
@ -461,13 +457,13 @@
OnboardingSteps.WritingSoftware => 3, OnboardingSteps.WritingSoftware => 3,
OnboardingSteps.Project => 4, OnboardingSteps.Project => 4,
OnboardingSteps.Book => 5, 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 string.Equals(model.ScanState.ReviewStatus, ManuscriptScanReviewStatuses.ReadyToImport, StringComparison.Ordinal) => 8,
OnboardingSteps.NextPathPreview when model.ScanState.IsComplete => 7, 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.ScanState.IsRunning || model.ScanState.IsFailed || model.ScanState.PercentComplete.HasValue => 6,
OnboardingSteps.NextPathPreview when model.CompanionPresence.IsConnected => 6, OnboardingSteps.NextPathPreview when model.CompanionPresence.IsConnected => 6,
OnboardingSteps.NextPathPreview => 6, OnboardingSteps.NextPathPreview => 6,
OnboardingSteps.Complete => 12, OnboardingSteps.Complete => 10,
_ => 1 _ => 1
}; };
} }

View File

@ -32,7 +32,7 @@
var characterPage = Math.Clamp(requestedCharacterPage, 1, characterReviewPageCount); var characterPage = Math.Clamp(requestedCharacterPage, 1, characterReviewPageCount);
var currentCharacters = primaryCharacters.Skip((characterPage - 1) * characterPageSize).Take(characterPageSize).ToList(); var currentCharacters = primaryCharacters.Skip((characterPage - 1) * characterPageSize).Take(characterPageSize).ToList();
var currentStep = isCharacterStep ? 9 : 7; var currentStep = isCharacterStep ? 9 : 7;
const int totalSteps = 12; const int totalSteps = 10;
var characterPageTitle = $"Review Characters {characterPage} of {primaryCharacterPageCount}"; var characterPageTitle = $"Review Characters {characterPage} of {primaryCharacterPageCount}";
var duplicateCanonicalNames = primaryCharacters var duplicateCanonicalNames = primaryCharacters
.Where(candidate => candidate.Include && string.IsNullOrWhiteSpace(candidate.AliasTargetTemporaryCharacterKey)) .Where(candidate => candidate.Include && string.IsNullOrWhiteSpace(candidate.AliasTargetTemporaryCharacterKey))
@ -67,7 +67,7 @@
<div class="onboarding-copy"> <div class="onboarding-copy">
<p class="eyebrow">Step @currentStep of @totalSteps</p> <p class="eyebrow">Step @currentStep of @totalSteps</p>
<h1 id="scan-review-title">@(isCharacterStep ? "Review Characters" : "Review Chapters")</h1> <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> </div>
@if (TempData["OnboardingReviewMessage"] is string reviewMessage) @if (TempData["OnboardingReviewMessage"] is string reviewMessage)
@ -87,10 +87,6 @@
<span>Chapters</span> <span>Chapters</span>
<strong>@Model.SelectedChapterCount / @Model.ChapterCount</strong> <strong>@Model.SelectedChapterCount / @Model.ChapterCount</strong>
</div> </div>
<div>
<span>Scenes</span>
<strong>@Model.SelectedSceneCount / @Model.SceneCount</strong>
</div>
<div> <div>
<span>Characters</span> <span>Characters</span>
<strong>@Model.SelectedCharacterCount / @visibleCharacterCount</strong> <strong>@Model.SelectedCharacterCount / @visibleCharacterCount</strong>
@ -106,8 +102,6 @@
<div class="onboarding-review-tools" data-review-tools> <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="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="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> <button class="btn btn-sm btn-outline-secondary" type="reset">Restore scan defaults</button>
</div> </div>
} }
@ -124,7 +118,6 @@
<div class="onboarding-review-chapters"> <div class="onboarding-review-chapters">
@{ @{
var chapterIndex = 0; var chapterIndex = 0;
var sceneIndex = 0;
} }
@foreach (var chapter in Model.Chapters) @foreach (var chapter in Model.Chapters)
{ {
@ -132,7 +125,7 @@
<summary> <summary>
<span>Chapter @chapter.ChapterNumber</span> <span>Chapter @chapter.ChapterNumber</span>
<strong>@chapter.ReviewTitle</strong> <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> </summary>
<input type="hidden" name="Chapters[@chapterIndex].TemporaryChapterKey" value="@chapter.TemporaryChapterKey" /> <input type="hidden" name="Chapters[@chapterIndex].TemporaryChapterKey" value="@chapter.TemporaryChapterKey" />
<input type="hidden" name="Chapters[@chapterIndex].ChapterNumber" value="@chapter.ChapterNumber" /> <input type="hidden" name="Chapters[@chapterIndex].ChapterNumber" value="@chapter.ChapterNumber" />
@ -145,55 +138,11 @@
Chapter title Chapter title
<input class="form-control" name="Chapters[@chapterIndex].Title" value="@chapter.ReviewTitle" /> <input class="form-control" name="Chapters[@chapterIndex].Title" value="@chapter.ReviewTitle" />
</label> </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> </details>
chapterIndex++; chapterIndex++;
} }
</div> </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> </div>
<aside class="onboarding-review-section" data-scan-review-panel="characters" hidden="@(!isCharacterStep)"> <aside class="onboarding-review-section" data-scan-review-panel="characters" hidden="@(!isCharacterStep)">
@ -364,7 +313,7 @@
else else
{ {
<a class="btn btn-outline-secondary" asp-controller="Onboarding" asp-action="Index">Back to setup</a> <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> </div>
</form> </form>

View File

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

View File

@ -226,7 +226,7 @@
node.classList.add("is-running"); node.classList.add("is-running");
}); });
document.querySelectorAll("[data-onboarding-build-message]").forEach((node) => { 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) => { document.querySelectorAll("[data-onboarding-build-percent]").forEach((node) => {
node.textContent = "0%"; node.textContent = "0%";
@ -238,7 +238,7 @@
const result = await connection.invoke("StartOnboardingProjectBuild", previewId); const result = await connection.invoke("StartOnboardingProjectBuild", previewId);
pendingBuildPreviewId = field(result, "previewID", "PreviewID") || previewId; pendingBuildPreviewId = field(result, "previewID", "PreviewID") || previewId;
document.querySelectorAll("[data-onboarding-build-message]").forEach((node) => { 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) { } catch (error) {
button.disabled = false; button.disabled = false;