Enforce staged core import scan state
This commit is contained in:
parent
4214779883
commit
fbb973574c
@ -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");
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -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 = []
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 = []
|
||||
};
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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]");
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user