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),
|
("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),
|
("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),
|
("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),
|
||||||
@ -67,6 +69,7 @@ var tests = new (string Name, Action Test)[]
|
|||||||
("Structural import persists paragraph mapping and source text", StructuralImportPersistsParagraphMappingAndSourceText),
|
("Structural import persists paragraph mapping and source text", StructuralImportPersistsParagraphMappingAndSourceText),
|
||||||
("Structural import persists canonical scene source text", StructuralImportPersistsCanonicalSceneSourceText),
|
("Structural import persists canonical scene source text", StructuralImportPersistsCanonicalSceneSourceText),
|
||||||
("Structural import adds summaries without extra scene AI calls", StructuralImportAddsSummariesWithoutExtraSceneAiCalls),
|
("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 deterministically maps known characters", CoreImportDeterministicallyMapsKnownCharacters),
|
||||||
("Core import wizard orders scenes before characters", CoreImportWizardOrdersScenesBeforeCharacters),
|
("Core import wizard orders scenes before characters", CoreImportWizardOrdersScenesBeforeCharacters),
|
||||||
("Core import remaps characters after review", CoreImportRemapsCharactersAfterReview),
|
("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.");
|
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()
|
static void SharedParserReportsRawOutput()
|
||||||
{
|
{
|
||||||
const string raw = """{"schemaVersion":"1.0","sceneBoundaries":[""";
|
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.");
|
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()
|
static void CoreImportDeterministicallyMapsKnownCharacters()
|
||||||
{
|
{
|
||||||
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
|
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 invent paragraph numbers.
|
||||||
|
|
||||||
Never return an `endParagraph` greater than the highest paragraph number supplied.
|
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.
|
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)
|
private static ManuscriptScanPreview NormalisePreviewContext(ManuscriptScanPreview preview, int userId, UserOnboardingState state)
|
||||||
{
|
{
|
||||||
var characterCandidates = WordCompanionService.DiscoverManuscriptScanCharacterCandidates(preview);
|
|
||||||
return new()
|
return new()
|
||||||
{
|
{
|
||||||
PreviewID = preview.PreviewID,
|
PreviewID = preview.PreviewID,
|
||||||
@ -358,12 +357,12 @@ public sealed class WordCompanionFollowHub(
|
|||||||
CompanionDocumentIdentifier = preview.CompanionDocumentIdentifier,
|
CompanionDocumentIdentifier = preview.CompanionDocumentIdentifier,
|
||||||
TotalWordCount = preview.TotalWordCount,
|
TotalWordCount = preview.TotalWordCount,
|
||||||
ChapterCount = preview.ChapterCount,
|
ChapterCount = preview.ChapterCount,
|
||||||
SceneCount = preview.SceneCount,
|
SceneCount = 0,
|
||||||
CharacterCandidateCount = characterCandidates.Count,
|
CharacterCandidateCount = 0,
|
||||||
CreatedUtc = preview.CreatedUtc,
|
CreatedUtc = preview.CreatedUtc,
|
||||||
Chapters = preview.Chapters,
|
Chapters = preview.Chapters,
|
||||||
Scenes = preview.Scenes,
|
Scenes = [],
|
||||||
CharacterCandidates = characterCandidates
|
CharacterCandidates = []
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -36,8 +36,32 @@ public static class ChapterStructureBoundaryNormaliser
|
|||||||
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."
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
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);
|
issues.AddRange(continuity.Issues);
|
||||||
return new ChapterStructureBoundaryNormalisationResult(continuity.ChapterStructure, issues);
|
return new ChapterStructureBoundaryNormalisationResult(continuity.ChapterStructure, issues);
|
||||||
}
|
}
|
||||||
@ -152,11 +176,9 @@ public static class ChapterStructureBoundaryNormaliser
|
|||||||
{
|
{
|
||||||
Severity = "Warning",
|
Severity = "Warning",
|
||||||
Path = $"sceneBoundaries[{index}].endParagraph",
|
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."
|
SuggestedFix = "Return an endParagraph greater than or equal to startParagraph."
|
||||||
});
|
});
|
||||||
end = start;
|
|
||||||
changed = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (end > paragraphCount)
|
if (end > paragraphCount)
|
||||||
@ -252,6 +274,15 @@ public static class ChapterStructureBoundaryNormaliser
|
|||||||
return invalidTrailingRange && HasPlaceholderSignal(final);
|
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)
|
private static bool HasPlaceholderSignal(ChapterSceneBoundary boundary)
|
||||||
{
|
{
|
||||||
if (boundary.Confidence <= 0.05m)
|
if (boundary.Confidence <= 0.05m)
|
||||||
|
|||||||
@ -16,6 +16,7 @@ public interface IManuscriptScanPreviewStore
|
|||||||
Task<ManuscriptScanPreview?> GetPreviewAsync(int userId, Guid previewId);
|
Task<ManuscriptScanPreview?> GetPreviewAsync(int userId, Guid previewId);
|
||||||
Task<ManuscriptScanReviewDecision?> GetReviewAsync(int userId, Guid previewId);
|
Task<ManuscriptScanReviewDecision?> GetReviewAsync(int userId, Guid previewId);
|
||||||
Task<ManuscriptScanReviewDecision?> SaveReviewAsync(int userId, ManuscriptScanReviewDecision review);
|
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<OnboardingBuildProgress> BuildProgressAsync(int userId, Guid previewId, string message, int percentComplete, OnboardingManuscriptBuildResult? result = null);
|
||||||
Task<OnboardingManuscriptBuildResult?> GetBuildResultAsync(int userId, Guid previewId);
|
Task<OnboardingManuscriptBuildResult?> GetBuildResultAsync(int userId, Guid previewId);
|
||||||
Task<OnboardingManuscriptBuildResult?> SaveBuildResultAsync(int userId, OnboardingManuscriptBuildResult result);
|
Task<OnboardingManuscriptBuildResult?> SaveBuildResultAsync(int userId, OnboardingManuscriptBuildResult result);
|
||||||
@ -92,8 +93,8 @@ public sealed class ManuscriptScanPreviewStore(
|
|||||||
session.Message = "Scan complete";
|
session.Message = "Scan complete";
|
||||||
session.PercentComplete = 100;
|
session.PercentComplete = 100;
|
||||||
session.ChapterCount = stored.ChapterCount;
|
session.ChapterCount = stored.ChapterCount;
|
||||||
session.SceneCount = stored.SceneCount;
|
session.SceneCount = 0;
|
||||||
session.CharacterCandidateCount = stored.CharacterCandidateCount;
|
session.CharacterCandidateCount = 0;
|
||||||
session.TotalWordCount = stored.TotalWordCount;
|
session.TotalWordCount = stored.TotalWordCount;
|
||||||
session.PreviewID = stored.PreviewID;
|
session.PreviewID = stored.PreviewID;
|
||||||
session.UpdatedUtc = stored.CreatedUtc;
|
session.UpdatedUtc = stored.CreatedUtc;
|
||||||
@ -193,6 +194,45 @@ public sealed class ManuscriptScanPreviewStore(
|
|||||||
return Task.FromResult<ManuscriptScanReviewDecision?>(stored);
|
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)
|
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)
|
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)
|
private async Task<ManuscriptScanReviewDecision> BuildDefaultReviewAsync(ManuscriptScanPreview preview)
|
||||||
{
|
{
|
||||||
var aiHints = await GetAiSexHintsAsync(preview.CharacterCandidates);
|
|
||||||
return new ManuscriptScanReviewDecision
|
return new ManuscriptScanReviewDecision
|
||||||
{
|
{
|
||||||
PreviewID = preview.PreviewID,
|
PreviewID = preview.PreviewID,
|
||||||
@ -271,18 +310,7 @@ public sealed class ManuscriptScanPreviewStore(
|
|||||||
ChapterNumber = chapter.ChapterNumber
|
ChapterNumber = chapter.ChapterNumber
|
||||||
})
|
})
|
||||||
.ToList(),
|
.ToList(),
|
||||||
Scenes = preview.Scenes
|
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(),
|
|
||||||
Characters = preview.CharacterCandidates
|
Characters = preview.CharacterCandidates
|
||||||
.Select(candidate => new ManuscriptScanCharacterReviewDecision
|
.Select(candidate => new ManuscriptScanCharacterReviewDecision
|
||||||
{
|
{
|
||||||
@ -290,9 +318,7 @@ public sealed class ManuscriptScanPreviewStore(
|
|||||||
Include = string.Equals(candidate.Category, "ProbableCharacter", StringComparison.OrdinalIgnoreCase),
|
Include = string.Equals(candidate.Category, "ProbableCharacter", StringComparison.OrdinalIgnoreCase),
|
||||||
Name = candidate.Name,
|
Name = candidate.Name,
|
||||||
Category = candidate.Category,
|
Category = candidate.Category,
|
||||||
SexHint = aiHints.TryGetValue(candidate.TemporaryCharacterKey, out var aiHint)
|
SexHint = CharacterSexHintService.Suggest(candidate.Name)
|
||||||
? aiHint
|
|
||||||
: CharacterSexHintService.Suggest(candidate.Name)
|
|
||||||
})
|
})
|
||||||
.ToList()
|
.ToList()
|
||||||
};
|
};
|
||||||
@ -353,11 +379,11 @@ file static class ManuscriptScanPreviewExtensions
|
|||||||
CompanionDocumentIdentifier = preview.CompanionDocumentIdentifier,
|
CompanionDocumentIdentifier = preview.CompanionDocumentIdentifier,
|
||||||
TotalWordCount = preview.TotalWordCount,
|
TotalWordCount = preview.TotalWordCount,
|
||||||
ChapterCount = preview.ChapterCount,
|
ChapterCount = preview.ChapterCount,
|
||||||
SceneCount = preview.SceneCount,
|
SceneCount = 0,
|
||||||
CharacterCandidateCount = preview.CharacterCandidateCount,
|
CharacterCandidateCount = 0,
|
||||||
CreatedUtc = preview.CreatedUtc == default ? DateTime.UtcNow : preview.CreatedUtc,
|
CreatedUtc = preview.CreatedUtc == default ? DateTime.UtcNow : preview.CreatedUtc,
|
||||||
Chapters = preview.Chapters,
|
Chapters = preview.Chapters,
|
||||||
Scenes = preview.Scenes,
|
Scenes = [],
|
||||||
CharacterCandidates = preview.CharacterCandidates
|
CharacterCandidates = []
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -73,9 +73,17 @@ public sealed class ManuscriptStructureAnalysisService(
|
|||||||
|
|
||||||
if (!validation.IsValid || chapter?.SceneBoundaries is null || chapter.SceneBoundaries.Count == 0)
|
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(
|
return ManuscriptStructureAnalysisResult.Failed(
|
||||||
request.TemporaryChapterKey,
|
request.TemporaryChapterKey,
|
||||||
BuildValidationMessage(validation),
|
$"{BuildValidationMessage(validation)} {diagnostic}",
|
||||||
persistedJson);
|
persistedJson);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -215,6 +223,42 @@ public sealed class ManuscriptStructureAnalysisService(
|
|||||||
return first;
|
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)
|
private static int CountWords(string value)
|
||||||
=> string.IsNullOrWhiteSpace(value)
|
=> string.IsNullOrWhiteSpace(value)
|
||||||
? 0
|
? 0
|
||||||
|
|||||||
@ -339,6 +339,9 @@ public sealed class OnboardingService(
|
|||||||
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);
|
||||||
|
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 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);
|
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;
|
||||||
|
|||||||
@ -723,6 +723,48 @@ public sealed class WordCompanionService(
|
|||||||
.ToList();
|
.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)
|
private static WordCompanionCharacterCandidateDto? BuildCandidateDto(CharacterCandidateEvidence evidence)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(evidence.Text) || evidence.Text.Length > 200)
|
if (string.IsNullOrWhiteSpace(evidence.Text) || evidence.Text.Length > 200)
|
||||||
|
|||||||
@ -293,6 +293,10 @@
|
|||||||
<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>
|
||||||
|
|||||||
@ -55,6 +55,9 @@
|
|||||||
&& !includedPrimaryKeys.Contains(candidate.AliasTargetTemporaryCharacterKey)))
|
&& !includedPrimaryKeys.Contains(candidate.AliasTargetTemporaryCharacterKey)))
|
||||||
.Select(candidate => candidate.TemporaryCharacterKey)
|
.Select(candidate => candidate.TemporaryCharacterKey)
|
||||||
.ToHashSet(StringComparer.Ordinal);
|
.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">
|
<section class="onboarding-shell" aria-labelledby="scan-review-title">
|
||||||
@ -87,9 +90,13 @@
|
|||||||
<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>@displayedSceneCount.ToString("N0")</strong>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span>Characters</span>
|
<span>Characters</span>
|
||||||
<strong>@Model.SelectedCharacterCount / @visibleCharacterCount</strong>
|
<strong>@displayedCharacterCount.ToString("N0") / @displayedCharacterTotal.ToString("N0")</strong>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span>Words</span>
|
<span>Words</span>
|
||||||
@ -108,7 +115,7 @@
|
|||||||
|
|
||||||
<section class="onboarding-review-workflow" aria-label="Scan preview">
|
<section class="onboarding-review-workflow" aria-label="Scan preview">
|
||||||
<div class="onboarding-review-section" data-scan-review-panel="structure" hidden="@isCharacterStep">
|
<div class="onboarding-review-section" data-scan-review-panel="structure" hidden="@isCharacterStep">
|
||||||
<h2>Chapters and scenes</h2>
|
<h2>Chapters</h2>
|
||||||
@if (!Model.Chapters.Any())
|
@if (!Model.Chapters.Any())
|
||||||
{
|
{
|
||||||
<p>No chapters were detected. Make sure your chapter headings use Heading 1, or add clear chapter headings.</p>
|
<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 buildOnboardingScanPreview = (paragraphs, command) => {
|
||||||
const chapters = [];
|
const chapters = [];
|
||||||
const scenes = [];
|
|
||||||
const documentText = [];
|
const documentText = [];
|
||||||
let chapter = null;
|
let chapter = null;
|
||||||
let scene = null;
|
|
||||||
let totalWordCount = 0;
|
let totalWordCount = 0;
|
||||||
let detectedHeadingChapters = 0;
|
let detectedHeadingChapters = 0;
|
||||||
let manuscriptStarted = false;
|
let manuscriptStarted = false;
|
||||||
@ -2162,12 +2160,7 @@
|
|||||||
].includes(clean);
|
].includes(clean);
|
||||||
};
|
};
|
||||||
|
|
||||||
const finishScene = () => {
|
|
||||||
scene = null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const startChapter = (paragraph, title, isOpening = false) => {
|
const startChapter = (paragraph, title, isOpening = false) => {
|
||||||
finishScene();
|
|
||||||
chapter = {
|
chapter = {
|
||||||
temporaryChapterKey: `chapter-${chapters.length + 1}`,
|
temporaryChapterKey: `chapter-${chapters.length + 1}`,
|
||||||
chapterNumber: chapters.length + 1,
|
chapterNumber: chapters.length + 1,
|
||||||
@ -2180,30 +2173,9 @@
|
|||||||
existingChapterID: isOpening ? null : paragraphAnchorId(paragraph, "PD-CHAPTER")
|
existingChapterID: isOpening ? null : paragraphAnchorId(paragraph, "PD-CHAPTER")
|
||||||
};
|
};
|
||||||
chapters.push(chapter);
|
chapters.push(chapter);
|
||||||
startScene(paragraph, null);
|
|
||||||
return chapter;
|
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 = {}) => {
|
const recordSourceParagraph = (paragraph, text, options = {}) => {
|
||||||
if (!chapter || !paragraph || !Number.isInteger(paragraph.index)) {
|
if (!chapter || !paragraph || !Number.isInteger(paragraph.index)) {
|
||||||
return;
|
return;
|
||||||
@ -2222,7 +2194,7 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const addWords = (paragraph, text, words) => {
|
const addWords = (paragraph, text, words) => {
|
||||||
if (!chapter || !scene || words <= 0) {
|
if (!chapter || words <= 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2234,10 +2206,6 @@
|
|||||||
wordParagraphIndex: Number.isInteger(paragraph?.index) ? paragraph.index : -1,
|
wordParagraphIndex: Number.isInteger(paragraph?.index) ? paragraph.index : -1,
|
||||||
text
|
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) => {
|
paragraphs.forEach((paragraph, index) => {
|
||||||
@ -2263,15 +2231,6 @@
|
|||||||
detectedHeadingChapters += 1;
|
detectedHeadingChapters += 1;
|
||||||
startChapter(paragraph, text);
|
startChapter(paragraph, text);
|
||||||
recordSourceParagraph(paragraph, text, { isChapterHeading: true, includeInSourceText: false });
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2286,16 +2245,12 @@
|
|||||||
documentText.push(text);
|
documentText.push(text);
|
||||||
|
|
||||||
if (isBuiltInHeading(paragraph, 2)) {
|
if (isBuiltInHeading(paragraph, 2)) {
|
||||||
startScene(paragraph, text);
|
recordSourceParagraph(paragraph, text, { isSceneHeading: true, includeInSourceText: false });
|
||||||
recordSourceParagraph(paragraph, text, { isSceneHeading: true, includeInSourceText: true });
|
|
||||||
totalWordCount += words;
|
|
||||||
addWords(paragraph, text, words);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sceneSeparatorTexts.has(text)) {
|
if (sceneSeparatorTexts.has(text)) {
|
||||||
recordSourceParagraph(paragraph, text, { isSceneSeparator: true, includeInSourceText: false });
|
recordSourceParagraph(paragraph, text, { isSceneSeparator: true, includeInSourceText: false });
|
||||||
startScene(paragraph, null);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2319,7 +2274,6 @@
|
|||||||
delete item.chapterTextParagraphs;
|
delete item.chapterTextParagraphs;
|
||||||
});
|
});
|
||||||
|
|
||||||
const documentTextForDiscovery = documentText.join("\n");
|
|
||||||
return {
|
return {
|
||||||
previewID: "00000000-0000-0000-0000-000000000000",
|
previewID: "00000000-0000-0000-0000-000000000000",
|
||||||
userID: scanCommandValue(command, "userID", "UserID", "userId"),
|
userID: scanCommandValue(command, "userID", "UserID", "userId"),
|
||||||
@ -2331,13 +2285,12 @@
|
|||||||
companionDocumentIdentifier: currentDocumentGuid(),
|
companionDocumentIdentifier: currentDocumentGuid(),
|
||||||
totalWordCount,
|
totalWordCount,
|
||||||
chapterCount: chapters.length,
|
chapterCount: chapters.length,
|
||||||
sceneCount: scenes.length,
|
sceneCount: 0,
|
||||||
characterCandidateCount: 0,
|
characterCandidateCount: 0,
|
||||||
createdUtc: new Date().toISOString(),
|
createdUtc: new Date().toISOString(),
|
||||||
chapters,
|
chapters,
|
||||||
scenes,
|
scenes: [],
|
||||||
characterCandidates: [],
|
characterCandidates: []
|
||||||
documentTextForDiscovery
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -2404,25 +2357,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
const preview = buildOnboardingScanPreview(paragraphs, command);
|
const preview = buildOnboardingScanPreview(paragraphs, command);
|
||||||
await reportOnboardingScanProgress(command, "Detecting scene breaks", 50, preview);
|
await reportOnboardingScanProgress(command, "Preparing chapter review", 85, 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 preview", 95, preview);
|
await reportOnboardingScanProgress(command, "Preparing preview", 95, preview);
|
||||||
lastOnboardingScanPreview = preview;
|
lastOnboardingScanPreview = preview;
|
||||||
await companionPresenceConnection.invoke("CompleteOnboardingScan", preview);
|
await companionPresenceConnection.invoke("CompleteOnboardingScan", preview);
|
||||||
|
|||||||
@ -86,8 +86,9 @@
|
|||||||
|
|
||||||
const updateScanButton = () => {
|
const updateScanButton = () => {
|
||||||
document.querySelectorAll("[data-onboarding-scan-start]").forEach((button) => {
|
document.querySelectorAll("[data-onboarding-scan-start]").forEach((button) => {
|
||||||
button.disabled = !companionConnected || !companionDocumentOpen || currentScanStatus === "Running";
|
const openingReview = currentScanStatus === "Complete" && scanStartedInThisPage;
|
||||||
button.textContent = currentScanStatus === "Complete" ? "Scan again" : "Scan manuscript";
|
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;
|
scanStartedInThisPage = true;
|
||||||
}
|
}
|
||||||
currentScanStatus = status;
|
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 percent = field(state, "percentComplete", "PercentComplete");
|
||||||
const previewId = field(state, "previewID", "PreviewID") || field(state, "previewId", "PreviewId");
|
const previewId = field(state, "previewID", "PreviewID") || field(state, "previewId", "PreviewId");
|
||||||
const panel = document.querySelector("[data-onboarding-scan-panel]");
|
const panel = document.querySelector("[data-onboarding-scan-panel]");
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user