Enforce staged core import scan state

This commit is contained in:
Nick Beckley 2026-08-25 20:25:36 +00:00
parent 4214779883
commit fbb973574c
12 changed files with 325 additions and 108 deletions

View File

@ -21,6 +21,8 @@ var tests = new (string Name, Action Test)[]
("Repaired JSON deserialises into SceneIntelligenceScene", RepairedJsonDeserialises),
("Chapter Structure confidence 0. repairs and deserialises", ChapterStructureConfidenceRepairs),
("Chapter Structure boundary normaliser repairs gaps and overlaps", ChapterStructureBoundaryNormaliserRepairsGapsAndOverlaps),
("Chapter Structure boundary normaliser handles terminal ghost paragraph", ChapterStructureBoundaryNormaliserHandlesTerminalGhostParagraph),
("Chapter Structure boundary normaliser preserves genuine inverted ranges for validation", ChapterStructureBoundaryNormaliserPreservesGenuineInvertedRanges),
("Shared parser reports raw chapter output on unrecoverable JSON", SharedParserReportsRawOutput),
("Character filtering rejects generic groups", CharacterFilteringRejectsGenericGroups),
("Character filtering preserves titled names", CharacterFilteringPreservesTitledNames),
@ -67,6 +69,7 @@ var tests = new (string Name, Action Test)[]
("Structural import persists paragraph mapping and source text", StructuralImportPersistsParagraphMappingAndSourceText),
("Structural import persists canonical scene source text", StructuralImportPersistsCanonicalSceneSourceText),
("Structural import adds summaries without extra scene AI calls", StructuralImportAddsSummariesWithoutExtraSceneAiCalls),
("Core import scan stage defers scenes and characters", CoreImportScanStageDefersScenesAndCharacters),
("Core import deterministically maps known characters", CoreImportDeterministicallyMapsKnownCharacters),
("Core import wizard orders scenes before characters", CoreImportWizardOrdersScenesBeforeCharacters),
("Core import remaps characters after review", CoreImportRemapsCharactersAfterReview),
@ -907,6 +910,89 @@ static void ChapterStructureBoundaryNormaliserRepairsGapsAndOverlaps()
Assert(result.Issues.Count >= 2, "Boundary repairs should be reported as warnings.");
}
static void ChapterStructureBoundaryNormaliserHandlesTerminalGhostParagraph()
{
var chapter = new ChapterStructureModel
{
SchemaVersion = "1.0",
ChapterSummary = "A chapter with a terminal ghost paragraph response.",
SceneBoundaries =
[
new ChapterSceneBoundary
{
SceneNumber = 1,
StartParagraph = 1,
EndParagraph = 618,
StructuralSummary = "The only real scene covers the valid structural paragraphs.",
Confidence = 0.8m,
Reason = "Continuous scene."
},
new ChapterSceneBoundary
{
SceneNumber = 2,
StartParagraph = 619,
EndParagraph = 618,
StructuralSummary = "Invalid terminal ghost paragraph.",
Confidence = 0.1m,
Reason = "Terminal off-by-one."
}
]
};
var result = ChapterStructureBoundaryNormaliser.Normalise(chapter, 618);
var boundaries = result.ChapterStructure?.SceneBoundaries;
Assert(boundaries is { Count: 1 }, "Terminal 619 of 618 ghost boundary should be discarded.");
Assert(boundaries[0].StartParagraph == 1 && boundaries[0].EndParagraph == 618, "Final valid scene should still end at structural paragraph 618.");
Assert(result.Issues.Any(issue => issue.Message.Contains("terminal off-by-one", StringComparison.OrdinalIgnoreCase)), "Terminal off-by-one repair should be reported.");
}
static void ChapterStructureBoundaryNormaliserPreservesGenuineInvertedRanges()
{
var chapter = new ChapterStructureModel
{
SchemaVersion = "1.0",
ChapterSummary = "A chapter with a malformed middle boundary.",
SceneBoundaries =
[
new ChapterSceneBoundary
{
SceneNumber = 1,
StartParagraph = 1,
EndParagraph = 10,
StructuralSummary = "Opening valid scene summary with enough detail.",
Confidence = 0.8m,
Reason = "Opening."
},
new ChapterSceneBoundary
{
SceneNumber = 2,
StartParagraph = 11,
EndParagraph = 10,
StructuralSummary = "Malformed scene summary with inverted range.",
Confidence = 0.8m,
Reason = "Malformed."
},
new ChapterSceneBoundary
{
SceneNumber = 3,
StartParagraph = 12,
EndParagraph = 20,
StructuralSummary = "Final valid scene summary with enough detail.",
Confidence = 0.8m,
Reason = "Ending."
}
]
};
var normalised = ChapterStructureBoundaryNormaliser.Normalise(chapter, 20);
var validation = new ChapterStructureValidator().Validate(normalised.ChapterStructure, 20);
ChapterStructureBoundaryNormaliser.AddIssuesTo(validation, normalised);
Assert(!validation.IsValid, "A genuine inverted middle range should still fail validation.");
Assert(validation.Errors.Any(error => error.Message.Contains("Start paragraph cannot be after end paragraph", StringComparison.Ordinal)), "Validation should report the inverted range.");
}
static void SharedParserReportsRawOutput()
{
const string raw = """{"schemaVersion":"1.0","sceneBoundaries":[""";
@ -1606,6 +1692,40 @@ static void StructuralImportAddsSummariesWithoutExtraSceneAiCalls()
Assert(analyzer.Split("ExecutePromptAsync", StringSplitOptions.None).Length - 1 == 1, "Structural summaries must be produced by the existing chapter-boundary call, not extra scene calls.");
}
static void CoreImportScanStageDefersScenesAndCharacters()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var companion = File.ReadAllText(Path.Combine(root, "wwwroot/js/word-companion-host.js"));
var presence = File.ReadAllText(Path.Combine(root, "wwwroot/js/word-companion-presence.js"));
var hub = File.ReadAllText(Path.Combine(root, "Hubs/WordCompanionFollowHub.cs"));
var previewStore = File.ReadAllText(Path.Combine(root, "Services/ManuscriptScanPreviewStore.cs"));
var scanReview = File.ReadAllText(Path.Combine(root, "Views/Onboarding/ScanReview.cshtml"));
var index = File.ReadAllText(Path.Combine(root, "Views/Onboarding/Index.cshtml"));
var onboarding = File.ReadAllText(Path.Combine(root, "Services/OnboardingService.cs"));
var wordService = File.ReadAllText(Path.Combine(root, "Services/WordCompanionService.cs"));
var onboardingScan = companion[companion.IndexOf("const buildOnboardingScanPreview", StringComparison.Ordinal)..companion.IndexOf("const scanCommandValue", StringComparison.Ordinal)];
Assert(onboardingScan.Contains("sceneCount: 0", StringComparison.Ordinal), "Initial onboarding scan should report zero scenes.");
Assert(onboardingScan.Contains("characterCandidateCount: 0", StringComparison.Ordinal), "Initial onboarding scan should report zero characters.");
Assert(onboardingScan.Contains("scenes: []", StringComparison.Ordinal), "Initial onboarding scan should not send provisional scene rows.");
Assert(onboardingScan.Contains("characterCandidates: []", StringComparison.Ordinal), "Initial onboarding scan should not send provisional character rows.");
Assert(!onboardingScan.Contains("documentTextForDiscovery", StringComparison.Ordinal), "Initial onboarding scan should not prepare text for character discovery.");
Assert(!onboardingScan.Contains("/api/word-companion/manuscript/discover-characters", StringComparison.Ordinal), "Onboarding scan should not call character discovery.");
Assert(hub.Contains("SceneCount = 0", StringComparison.Ordinal), "Hub normalisation should discard scan-time scene counts.");
Assert(hub.Contains("CharacterCandidateCount = 0", StringComparison.Ordinal), "Hub normalisation should discard scan-time character counts.");
Assert(previewStore.Contains("Scenes = []", StringComparison.Ordinal), "Preview storage should keep scan-time scenes empty.");
Assert(previewStore.Contains("CharacterCandidates = []", StringComparison.Ordinal), "Preview storage should keep scan-time character candidates empty.");
Assert(scanReview.Contains("<span>Scenes</span>", StringComparison.Ordinal), "Review Chapters should show the Scenes status card.");
Assert(scanReview.Contains("var displayedSceneCount = isCharacterStep ? Model.SelectedSceneCount : 0;", StringComparison.Ordinal), "Review Chapters should show zero scenes before scene detection.");
Assert(scanReview.Contains("var displayedCharacterCount = isCharacterStep ? Model.SelectedCharacterCount : 0;", StringComparison.Ordinal), "Review Chapters should show zero characters before character review.");
Assert(scanReview.Contains("<h2>Chapters</h2>", StringComparison.Ordinal), "Review Chapters section heading should not mention scenes.");
Assert(index.Contains("data-onboarding-scan-scenes", StringComparison.Ordinal), "Scan page should display the Scenes counter.");
Assert(presence.Contains("Opening chapter review...", StringComparison.Ordinal), "Successful scan should show the immediate auto-navigation state.");
Assert(onboarding.Contains("DiscoverCoreImportCharacterCandidates(canonicalSceneSources)", StringComparison.Ordinal), "Character candidates should be generated after canonical scene sources exist.");
Assert(wordService.Contains("DiscoverCoreImportCharacterCandidates", StringComparison.Ordinal), "Post-scene character discovery should be explicit.");
Assert(wordService.Contains("Source SceneIDs", StringComparison.Ordinal), "Post-scene character candidates should carry canonical scene evidence.");
}
static void CoreImportDeterministicallyMapsKnownCharacters()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");

View File

@ -86,6 +86,7 @@ Each `startParagraph` and `endParagraph` must refer to those explicit paragraph
Never invent paragraph numbers.
Never return an `endParagraph` greater than the highest paragraph number supplied.
The final scene must end at the highest paragraph number supplied.
Do not create a separate final scene for a closing line unless it corresponds to an actual supplied paragraph number. If the final beat belongs to the previous scene, include it in the previous scene.

View File

@ -345,7 +345,6 @@ public sealed class WordCompanionFollowHub(
private static ManuscriptScanPreview NormalisePreviewContext(ManuscriptScanPreview preview, int userId, UserOnboardingState state)
{
var characterCandidates = WordCompanionService.DiscoverManuscriptScanCharacterCandidates(preview);
return new()
{
PreviewID = preview.PreviewID,
@ -358,12 +357,12 @@ public sealed class WordCompanionFollowHub(
CompanionDocumentIdentifier = preview.CompanionDocumentIdentifier,
TotalWordCount = preview.TotalWordCount,
ChapterCount = preview.ChapterCount,
SceneCount = preview.SceneCount,
CharacterCandidateCount = characterCandidates.Count,
SceneCount = 0,
CharacterCandidateCount = 0,
CreatedUtc = preview.CreatedUtc,
Chapters = preview.Chapters,
Scenes = preview.Scenes,
CharacterCandidates = characterCandidates
Scenes = [],
CharacterCandidates = []
};
}
}

View File

@ -36,8 +36,32 @@ public static class ChapterStructureBoundaryNormaliser
SuggestedFix = "Do not return a final scene boundary unless it references an actual supplied paragraph."
});
}
else if (IsTrailingTerminalOffByOne(final, previous, paragraphCount, phantomParagraph))
{
boundaries = boundaries
.Take(finalIndex)
.Select((boundary, index) => CopyBoundary(boundary, index + 1))
.ToList();
var continuity = RepairContinuity(chapterStructure, boundaries, paragraphCount);
issues.Add(new ValidationIssue
{
Severity = "Warning",
Path = $"sceneBoundaries[{finalIndex}]",
Message = $"Discarded trailing terminal off-by-one scene boundary {phantomParagraph}-{paragraphCount} because previous scene already ended at the final structural paragraph {paragraphCount}.",
SuggestedFix = $"Do not return a boundary starting after the final supplied paragraph {paragraphCount}."
});
}
var boundaryModel = ReferenceEquals(boundaries, chapterStructure.SceneBoundaries)
? chapterStructure
: new ChapterStructureModel
{
SchemaVersion = chapterStructure.SchemaVersion,
ChapterSummary = chapterStructure.ChapterSummary,
SceneBoundaries = boundaries.ToList(),
ExtensionData = chapterStructure.ExtensionData
};
var continuity = RepairContinuity(boundaryModel, boundaries, paragraphCount);
issues.AddRange(continuity.Issues);
return new ChapterStructureBoundaryNormalisationResult(continuity.ChapterStructure, issues);
}
@ -152,11 +176,9 @@ public static class ChapterStructureBoundaryNormaliser
{
Severity = "Warning",
Path = $"sceneBoundaries[{index}].endParagraph",
Message = $"Adjusted scene {index + 1:N0} end paragraph from {end:N0} to {start:N0} after boundary continuity repair.",
Message = $"Scene {index + 1:N0} has start paragraph {start:N0} after end paragraph {end:N0}; this cannot be safely normalised.",
SuggestedFix = "Return an endParagraph greater than or equal to startParagraph."
});
end = start;
changed = true;
}
if (end > paragraphCount)
@ -252,6 +274,15 @@ public static class ChapterStructureBoundaryNormaliser
return invalidTrailingRange && HasPlaceholderSignal(final);
}
private static bool IsTrailingTerminalOffByOne(
ChapterSceneBoundary final,
ChapterSceneBoundary previous,
int paragraphCount,
int phantomParagraph)
=> final.StartParagraph == phantomParagraph
&& final.EndParagraph == paragraphCount
&& previous.EndParagraph == paragraphCount;
private static bool HasPlaceholderSignal(ChapterSceneBoundary boundary)
{
if (boundary.Confidence <= 0.05m)

View File

@ -16,6 +16,7 @@ public interface IManuscriptScanPreviewStore
Task<ManuscriptScanPreview?> GetPreviewAsync(int userId, Guid previewId);
Task<ManuscriptScanReviewDecision?> GetReviewAsync(int userId, Guid previewId);
Task<ManuscriptScanReviewDecision?> SaveReviewAsync(int userId, ManuscriptScanReviewDecision review);
Task<ManuscriptScanPreview?> SaveCharacterCandidatesAsync(int userId, Guid previewId, IReadOnlyList<ManuscriptScanCharacterCandidatePreview> candidates);
Task<OnboardingBuildProgress> BuildProgressAsync(int userId, Guid previewId, string message, int percentComplete, OnboardingManuscriptBuildResult? result = null);
Task<OnboardingManuscriptBuildResult?> GetBuildResultAsync(int userId, Guid previewId);
Task<OnboardingManuscriptBuildResult?> SaveBuildResultAsync(int userId, OnboardingManuscriptBuildResult result);
@ -92,8 +93,8 @@ public sealed class ManuscriptScanPreviewStore(
session.Message = "Scan complete";
session.PercentComplete = 100;
session.ChapterCount = stored.ChapterCount;
session.SceneCount = stored.SceneCount;
session.CharacterCandidateCount = stored.CharacterCandidateCount;
session.SceneCount = 0;
session.CharacterCandidateCount = 0;
session.TotalWordCount = stored.TotalWordCount;
session.PreviewID = stored.PreviewID;
session.UpdatedUtc = stored.CreatedUtc;
@ -193,6 +194,45 @@ public sealed class ManuscriptScanPreviewStore(
return Task.FromResult<ManuscriptScanReviewDecision?>(stored);
}
public Task<ManuscriptScanPreview?> SaveCharacterCandidatesAsync(int userId, Guid previewId, IReadOnlyList<ManuscriptScanCharacterCandidatePreview> candidates)
{
if (!previews.TryGetValue(previewId, out var preview) || preview.UserID != userId)
{
return Task.FromResult<ManuscriptScanPreview?>(null);
}
var cleaned = candidates
.Where(candidate => !string.IsNullOrWhiteSpace(candidate.Name))
.ToList();
var updated = new ManuscriptScanPreview
{
PreviewID = preview.PreviewID,
UserID = preview.UserID,
OnboardingID = preview.OnboardingID,
ProjectID = preview.ProjectID,
BookID = preview.BookID,
Source = preview.Source,
DocumentTitle = preview.DocumentTitle,
CompanionDocumentIdentifier = preview.CompanionDocumentIdentifier,
TotalWordCount = preview.TotalWordCount,
ChapterCount = preview.ChapterCount,
SceneCount = preview.SceneCount,
CharacterCandidateCount = cleaned.Count,
CreatedUtc = preview.CreatedUtc,
Chapters = preview.Chapters,
Scenes = preview.Scenes,
CharacterCandidates = cleaned
};
previews[previewId] = updated;
if (sessions.TryGetValue((userId, preview.OnboardingID), out var session))
{
session.CharacterCandidateCount = cleaned.Count;
session.UpdatedUtc = DateTime.UtcNow;
}
return Task.FromResult<ManuscriptScanPreview?>(updated);
}
public Task<OnboardingBuildProgress> BuildProgressAsync(int userId, Guid previewId, string message, int percentComplete, OnboardingManuscriptBuildResult? result = null)
{
if (!previews.TryGetValue(previewId, out var preview) || preview.UserID != userId)
@ -256,7 +296,6 @@ public sealed class ManuscriptScanPreviewStore(
private async Task<ManuscriptScanReviewDecision> BuildDefaultReviewAsync(ManuscriptScanPreview preview)
{
var aiHints = await GetAiSexHintsAsync(preview.CharacterCandidates);
return new ManuscriptScanReviewDecision
{
PreviewID = preview.PreviewID,
@ -271,18 +310,7 @@ public sealed class ManuscriptScanPreviewStore(
ChapterNumber = chapter.ChapterNumber
})
.ToList(),
Scenes = preview.Scenes
.OrderBy(scene => scene.TemporaryChapterKey)
.ThenBy(scene => scene.SceneNumberWithinChapter)
.Select(scene => new ManuscriptScanSceneReviewDecision
{
TemporarySceneKey = scene.TemporarySceneKey,
TemporaryChapterKey = scene.TemporaryChapterKey,
Include = true,
Title = string.IsNullOrWhiteSpace(scene.Title) ? $"Scene {scene.SceneNumberWithinChapter}" : scene.Title,
SceneNumberWithinChapter = scene.SceneNumberWithinChapter
})
.ToList(),
Scenes = [],
Characters = preview.CharacterCandidates
.Select(candidate => new ManuscriptScanCharacterReviewDecision
{
@ -290,9 +318,7 @@ public sealed class ManuscriptScanPreviewStore(
Include = string.Equals(candidate.Category, "ProbableCharacter", StringComparison.OrdinalIgnoreCase),
Name = candidate.Name,
Category = candidate.Category,
SexHint = aiHints.TryGetValue(candidate.TemporaryCharacterKey, out var aiHint)
? aiHint
: CharacterSexHintService.Suggest(candidate.Name)
SexHint = CharacterSexHintService.Suggest(candidate.Name)
})
.ToList()
};
@ -353,11 +379,11 @@ file static class ManuscriptScanPreviewExtensions
CompanionDocumentIdentifier = preview.CompanionDocumentIdentifier,
TotalWordCount = preview.TotalWordCount,
ChapterCount = preview.ChapterCount,
SceneCount = preview.SceneCount,
CharacterCandidateCount = preview.CharacterCandidateCount,
SceneCount = 0,
CharacterCandidateCount = 0,
CreatedUtc = preview.CreatedUtc == default ? DateTime.UtcNow : preview.CreatedUtc,
Chapters = preview.Chapters,
Scenes = preview.Scenes,
CharacterCandidates = preview.CharacterCandidates
Scenes = [],
CharacterCandidates = []
};
}

View File

@ -73,9 +73,17 @@ public sealed class ManuscriptStructureAnalysisService(
if (!validation.IsValid || chapter?.SceneBoundaries is null || chapter.SceneBoundaries.Count == 0)
{
var diagnostic = BuildBoundaryDiagnostic(chapter, request.ParagraphMap, paragraphs.Count);
logger.LogWarning(
"Structural scene-boundary validation failed. ProjectID={ProjectID} BookID={BookID} TemporaryChapterKey={TemporaryChapterKey} StructuralParagraphCount={StructuralParagraphCount} Diagnostic={Diagnostic}",
request.ProjectID,
request.BookID,
request.TemporaryChapterKey,
paragraphs.Count,
diagnostic);
return ManuscriptStructureAnalysisResult.Failed(
request.TemporaryChapterKey,
BuildValidationMessage(validation),
$"{BuildValidationMessage(validation)} {diagnostic}",
persistedJson);
}
@ -215,6 +223,42 @@ public sealed class ManuscriptStructureAnalysisService(
return first;
}
private static string BuildBoundaryDiagnostic(
ChapterStructureModel? chapter,
IReadOnlyList<ManuscriptScanParagraphMap> paragraphMap,
int structuralParagraphCount)
{
if (chapter?.SceneBoundaries is null || chapter.SceneBoundaries.Count == 0)
{
return $"Structural paragraphs: 1..{structuralParagraphCount:N0}; no scene boundaries were returned.";
}
var finalMap = paragraphMap
.Where(item => item.StructuralParagraphOrdinal == structuralParagraphCount)
.Select(item => item.WordParagraphIndex)
.FirstOrDefault();
var ranges = chapter.SceneBoundaries
.OrderBy(boundary => boundary.SceneNumber ?? int.MaxValue)
.Select(boundary =>
{
var number = boundary.SceneNumber?.ToString("N0") ?? "?";
var start = boundary.StartParagraph;
var end = boundary.EndParagraph;
var startWord = start.HasValue
? paragraphMap.FirstOrDefault(item => item.StructuralParagraphOrdinal == start.Value)?.WordParagraphIndex
: null;
var endWord = end.HasValue
? paragraphMap.FirstOrDefault(item => item.StructuralParagraphOrdinal == end.Value)?.WordParagraphIndex
: null;
return $"scene {number}: structural {Display(start)}-{Display(end)}, Word {Display(startWord)}-{Display(endWord)}";
});
return $"Structural paragraphs: 1..{structuralParagraphCount:N0}; final structural paragraph maps to Word paragraph {finalMap:N0}; returned {string.Join("; ", ranges)}.";
}
private static string Display(int? value)
=> value.HasValue ? value.Value.ToString("N0") : "?";
private static int CountWords(string value)
=> string.IsNullOrWhiteSpace(value)
? 0

View File

@ -339,6 +339,9 @@ public sealed class OnboardingService(
await SaveStructuralChapterSourcesAsync(preview, result);
await SaveSceneManuscriptSourcesAsync(preview, structuralScenes, result);
await coreImportCharacterMapping.MapBookAsync(preview.ProjectID, preview.BookID);
var canonicalSceneSources = await builds.ListSceneManuscriptSourcesByBookAsync(preview.BookID);
var characterCandidates = WordCompanionService.DiscoverCoreImportCharacterCandidates(canonicalSceneSources);
await scanPreviews.SaveCharacterCandidatesAsync(userId, preview.PreviewID, characterCandidates);
await scanPreviews.SaveBuildResultAsync(userId, result);
await PublishBuildProgress(userId, preview.PreviewID, "Updating Word document with scene markers. Keep Word open while the document is saved.", 90, progress);
return result;

View File

@ -723,6 +723,48 @@ public sealed class WordCompanionService(
.ToList();
}
public static IReadOnlyList<ManuscriptScanCharacterCandidatePreview> DiscoverCoreImportCharacterCandidates(
IReadOnlyList<SceneManuscriptSourceRecord> sourceScenes)
{
var manuscriptText = string.Join("\n\n", sourceScenes
.OrderBy(scene => scene.ChapterID)
.ThenBy(scene => scene.SourceStartParagraph ?? int.MaxValue)
.ThenBy(scene => scene.SceneID)
.Select(scene => scene.SourceText)
.Where(text => !string.IsNullOrWhiteSpace(text)));
if (string.IsNullOrWhiteSpace(manuscriptText))
{
return [];
}
var sceneIds = sourceScenes
.Where(scene => !string.IsNullOrWhiteSpace(scene.SourceText))
.Select(scene => scene.SceneID)
.Distinct()
.Order()
.ToList();
var sceneEvidence = sceneIds.Count == 0
? "Discovered from canonical scene source text."
: $"Discovered from canonical scene source text. Source SceneIDs: {string.Join(", ", sceneIds)}.";
return DiscoverCharacterCandidates(manuscriptText)
.Where(IsAutoSelectedScanCharacterCandidate)
.Select((candidate, index) => new ManuscriptScanCharacterCandidatePreview
{
TemporaryCharacterKey = $"post-scene-{StableCandidateKey(candidate.Text, index)}",
Name = candidate.Text,
MentionCount = candidate.MentionCount,
QualityScore = candidate.QualityScore,
Category = candidate.Category,
Reason = string.IsNullOrWhiteSpace(candidate.Reason)
? sceneEvidence
: $"{candidate.Reason} {sceneEvidence}",
IsExistingCharacterMatch = candidate.IsExistingCharacterMatch,
SuggestedImportance = candidate.Confidence
})
.ToList();
}
private static WordCompanionCharacterCandidateDto? BuildCandidateDto(CharacterCandidateEvidence evidence)
{
if (string.IsNullOrWhiteSpace(evidence.Text) || evidence.Text.Length > 200)

View File

@ -293,6 +293,10 @@
<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>

View File

@ -55,6 +55,9 @@
&& !includedPrimaryKeys.Contains(candidate.AliasTargetTemporaryCharacterKey)))
.Select(candidate => candidate.TemporaryCharacterKey)
.ToHashSet(StringComparer.Ordinal);
var displayedSceneCount = isCharacterStep ? Model.SelectedSceneCount : 0;
var displayedCharacterCount = isCharacterStep ? Model.SelectedCharacterCount : 0;
var displayedCharacterTotal = isCharacterStep ? visibleCharacterCount : 0;
}
<section class="onboarding-shell" aria-labelledby="scan-review-title">
@ -87,9 +90,13 @@
<span>Chapters</span>
<strong>@Model.SelectedChapterCount / @Model.ChapterCount</strong>
</div>
<div>
<span>Scenes</span>
<strong>@displayedSceneCount.ToString("N0")</strong>
</div>
<div>
<span>Characters</span>
<strong>@Model.SelectedCharacterCount / @visibleCharacterCount</strong>
<strong>@displayedCharacterCount.ToString("N0") / @displayedCharacterTotal.ToString("N0")</strong>
</div>
<div>
<span>Words</span>
@ -108,7 +115,7 @@
<section class="onboarding-review-workflow" aria-label="Scan preview">
<div class="onboarding-review-section" data-scan-review-panel="structure" hidden="@isCharacterStep">
<h2>Chapters and scenes</h2>
<h2>Chapters</h2>
@if (!Model.Chapters.Any())
{
<p>No chapters were detected. Make sure your chapter headings use Heading 1, or add clear chapter headings.</p>

View File

@ -2140,10 +2140,8 @@
const buildOnboardingScanPreview = (paragraphs, command) => {
const chapters = [];
const scenes = [];
const documentText = [];
let chapter = null;
let scene = null;
let totalWordCount = 0;
let detectedHeadingChapters = 0;
let manuscriptStarted = false;
@ -2162,12 +2160,7 @@
].includes(clean);
};
const finishScene = () => {
scene = null;
};
const startChapter = (paragraph, title, isOpening = false) => {
finishScene();
chapter = {
temporaryChapterKey: `chapter-${chapters.length + 1}`,
chapterNumber: chapters.length + 1,
@ -2180,30 +2173,9 @@
existingChapterID: isOpening ? null : paragraphAnchorId(paragraph, "PD-CHAPTER")
};
chapters.push(chapter);
startScene(paragraph, null);
return chapter;
};
const startScene = (paragraph, title = null) => {
if (!chapter) {
return null;
}
finishScene();
scene = {
temporarySceneKey: `scene-${scenes.length + 1}`,
temporaryChapterKey: chapter.temporaryChapterKey,
sceneNumberWithinChapter: scenes.filter((item) => item.temporaryChapterKey === chapter.temporaryChapterKey).length + 1,
title,
wordCount: 0,
openingTextPreview: "",
startPosition: paragraph?.index ?? null,
existingSceneID: paragraph ? paragraphAnchorId(paragraph, "PD-SCENE") : null
};
scenes.push(scene);
return scene;
};
const recordSourceParagraph = (paragraph, text, options = {}) => {
if (!chapter || !paragraph || !Number.isInteger(paragraph.index)) {
return;
@ -2222,7 +2194,7 @@
};
const addWords = (paragraph, text, words) => {
if (!chapter || !scene || words <= 0) {
if (!chapter || words <= 0) {
return;
}
@ -2234,10 +2206,6 @@
wordParagraphIndex: Number.isInteger(paragraph?.index) ? paragraph.index : -1,
text
});
scene.wordCount += words;
if (!scene.openingTextPreview && text && !sceneSeparatorTexts.has(text)) {
scene.openingTextPreview = text.length > 180 ? `${text.slice(0, 177)}...` : text;
}
};
paragraphs.forEach((paragraph, index) => {
@ -2263,15 +2231,6 @@
detectedHeadingChapters += 1;
startChapter(paragraph, text);
recordSourceParagraph(paragraph, text, { isChapterHeading: true, includeInSourceText: false });
totalWordCount += words;
chapter.wordCount += words;
chapter.chapterTextParagraphs.push(text);
chapter.paragraphMap = chapter.paragraphMap || [];
chapter.paragraphMap.push({
structuralParagraphOrdinal: chapter.chapterTextParagraphs.length,
wordParagraphIndex: Number.isInteger(paragraph?.index) ? paragraph.index : -1,
text
});
return;
}
@ -2286,16 +2245,12 @@
documentText.push(text);
if (isBuiltInHeading(paragraph, 2)) {
startScene(paragraph, text);
recordSourceParagraph(paragraph, text, { isSceneHeading: true, includeInSourceText: true });
totalWordCount += words;
addWords(paragraph, text, words);
recordSourceParagraph(paragraph, text, { isSceneHeading: true, includeInSourceText: false });
return;
}
if (sceneSeparatorTexts.has(text)) {
recordSourceParagraph(paragraph, text, { isSceneSeparator: true, includeInSourceText: false });
startScene(paragraph, null);
return;
}
@ -2319,7 +2274,6 @@
delete item.chapterTextParagraphs;
});
const documentTextForDiscovery = documentText.join("\n");
return {
previewID: "00000000-0000-0000-0000-000000000000",
userID: scanCommandValue(command, "userID", "UserID", "userId"),
@ -2331,13 +2285,12 @@
companionDocumentIdentifier: currentDocumentGuid(),
totalWordCount,
chapterCount: chapters.length,
sceneCount: scenes.length,
sceneCount: 0,
characterCandidateCount: 0,
createdUtc: new Date().toISOString(),
chapters,
scenes,
characterCandidates: [],
documentTextForDiscovery
scenes: [],
characterCandidates: []
};
};
@ -2404,25 +2357,7 @@
});
const preview = buildOnboardingScanPreview(paragraphs, command);
await reportOnboardingScanProgress(command, "Detecting scene breaks", 50, preview);
await reportOnboardingScanProgress(command, `Scene ${preview.sceneCount} of ${preview.sceneCount} scanned`, 70, preview);
await reportOnboardingScanProgress(command, "Finding character candidates", 85, preview);
const documentTextForDiscovery = preview.documentTextForDiscovery || "";
delete preview.documentTextForDiscovery;
try {
const discovery = await postJson("/api/word-companion/manuscript/discover-characters", {
projectId: preview.projectID,
documentText: documentTextForDiscovery,
includeExcluded: false
});
preview.characterCandidates = mapOnboardingCharacterCandidates(discovery?.candidates)
.filter((candidate) => String(candidate.category || "").toLowerCase() === "probablecharacter");
preview.characterCandidateCount = preview.characterCandidates.length;
} catch (discoveryError) {
console.warn("Unable to refine onboarding character candidates.", discoveryError);
preview.characterCandidates = [];
preview.characterCandidateCount = 0;
}
await reportOnboardingScanProgress(command, "Preparing chapter review", 85, preview);
await reportOnboardingScanProgress(command, "Preparing preview", 95, preview);
lastOnboardingScanPreview = preview;
await companionPresenceConnection.invoke("CompleteOnboardingScan", preview);

View File

@ -86,8 +86,9 @@
const updateScanButton = () => {
document.querySelectorAll("[data-onboarding-scan-start]").forEach((button) => {
button.disabled = !companionConnected || !companionDocumentOpen || currentScanStatus === "Running";
button.textContent = currentScanStatus === "Complete" ? "Scan again" : "Scan manuscript";
const openingReview = currentScanStatus === "Complete" && scanStartedInThisPage;
button.disabled = openingReview || !companionConnected || !companionDocumentOpen || currentScanStatus === "Running";
button.textContent = openingReview ? "Opening chapter review..." : currentScanStatus === "Complete" ? "Scan again" : "Scan manuscript";
});
};
@ -101,7 +102,11 @@
scanStartedInThisPage = true;
}
currentScanStatus = status;
const message = field(state, "message", "Message") || (status === "Complete" ? "Scan complete. Review what PlotDirector found." : "Ready to scan");
const autoOpeningReview = status === "Complete" && scanStartedInThisPage;
let message = field(state, "message", "Message") || (status === "Complete" ? "Scan complete. Review what PlotDirector found." : "Ready to scan");
if (autoOpeningReview) {
message = "Scan complete. Opening chapter review...";
}
const percent = field(state, "percentComplete", "PercentComplete");
const previewId = field(state, "previewID", "PreviewID") || field(state, "previewId", "PreviewId");
const panel = document.querySelector("[data-onboarding-scan-panel]");