Fix core import scene follow and character evidence
This commit is contained in:
parent
f76e5bc388
commit
3740658f3e
@ -4,6 +4,7 @@ using System.Text;
|
|||||||
using Microsoft.AspNetCore.Http.Metadata;
|
using Microsoft.AspNetCore.Http.Metadata;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using PlotLine.Controllers;
|
using PlotLine.Controllers;
|
||||||
|
using PlotLine.Data;
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using PlotLine.Models;
|
using PlotLine.Models;
|
||||||
@ -76,6 +77,7 @@ var tests = new (string Name, Action Test)[]
|
|||||||
("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 scan stage defers scenes and characters", CoreImportScanStageDefersScenesAndCharacters),
|
||||||
|
("Core import character evidence is candidate specific", CoreImportCharacterEvidenceIsCandidateSpecific),
|
||||||
("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),
|
||||||
@ -134,6 +136,7 @@ var tests = new (string Name, Action Test)[]
|
|||||||
("Word Companion exposes writing workspace launch actions", WordCompanionExposesWritingWorkspaceLaunchActions),
|
("Word Companion exposes writing workspace launch actions", WordCompanionExposesWritingWorkspaceLaunchActions),
|
||||||
("Word Companion writing screens receive resolved context identifiers", WordCompanionWritingScreensReceiveResolvedContextIdentifiers),
|
("Word Companion writing screens receive resolved context identifiers", WordCompanionWritingScreensReceiveResolvedContextIdentifiers),
|
||||||
("Word Companion canonical markers drive runtime follow", WordCompanionCanonicalMarkersDriveRuntimeFollow),
|
("Word Companion canonical markers drive runtime follow", WordCompanionCanonicalMarkersDriveRuntimeFollow),
|
||||||
|
("Word Companion runtime follows multiple imported scene anchors", WordCompanionRuntimeFollowsMultipleImportedSceneAnchors),
|
||||||
("Word Companion existing project book linking remains available", WordCompanionExistingProjectBookLinkingRemainsAvailable),
|
("Word Companion existing project book linking remains available", WordCompanionExistingProjectBookLinkingRemainsAvailable),
|
||||||
("Word Companion document identity survives reconnect", WordCompanionDocumentIdentitySurvivesReconnect),
|
("Word Companion document identity survives reconnect", WordCompanionDocumentIdentitySurvivesReconnect),
|
||||||
("Word Companion multiscreen launch and follow remain reachable", WordCompanionMultiscreenLaunchAndFollowRemainReachable),
|
("Word Companion multiscreen launch and follow remain reachable", WordCompanionMultiscreenLaunchAndFollowRemainReachable),
|
||||||
@ -558,6 +561,22 @@ static void WordCompanionCanonicalMarkersDriveRuntimeFollow()
|
|||||||
Assert(repository.Contains("\"dbo.WordCompanion_Runtime_CurrentScene_Validate\"", StringComparison.Ordinal), "Runtime current scene validation must remain stored-procedure based.");
|
Assert(repository.Contains("\"dbo.WordCompanion_Runtime_CurrentScene_Validate\"", StringComparison.Ordinal), "Runtime current scene validation must remain stored-procedure based.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static void WordCompanionRuntimeFollowsMultipleImportedSceneAnchors()
|
||||||
|
{
|
||||||
|
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
|
||||||
|
var script = File.ReadAllText(Path.Combine(root, "wwwroot/js/word-companion-host.js"));
|
||||||
|
|
||||||
|
Assert(script.Contains("paragraph.contentControls.load(\"items/tag,title\")", StringComparison.Ordinal), "Runtime structure reads must load paragraph content controls, not only text/style.");
|
||||||
|
Assert(script.Contains("chapterAnchorId: paragraphAnchorId(paragraph, \"PD-CHAPTER\")", StringComparison.Ordinal), "Runtime paragraph snapshots should carry canonical chapter anchors.");
|
||||||
|
Assert(script.Contains("sceneAnchorId: paragraphAnchorId(paragraph, \"PD-SCENE\")", StringComparison.Ordinal), "Runtime paragraph snapshots should carry canonical scene anchors.");
|
||||||
|
Assert(script.Contains("const hasCanonicalSceneAnchors = paragraphSnapshots.some", StringComparison.Ordinal), "Runtime structure should detect imported scene-anchor mode.");
|
||||||
|
Assert(script.Contains("if (paragraph.sceneAnchorId)", StringComparison.Ordinal), "Runtime structure should start scenes from PD-SCENE anchors on ordinary paragraphs.");
|
||||||
|
Assert(script.Contains("return chapter.scenes\r\n .filter((item) => item.startParagraphIndex <= paragraphIndex)", StringComparison.Ordinal)
|
||||||
|
|| script.Contains("return chapter.scenes\n .filter((item) => item.startParagraphIndex <= paragraphIndex)", StringComparison.Ordinal), "Cursor resolution should select the latest preceding scene boundary.");
|
||||||
|
Assert(script.Contains("findExactAnchorControl(paragraph, tag)", StringComparison.Ordinal), "Marker insertion should reuse only the exact canonical SceneID tag.");
|
||||||
|
Assert(!script.Contains("const existing = findAnchorControl(paragraph, prefix);", StringComparison.Ordinal), "Marker insertion must not collapse distinct PD-SCENE tags by prefix.");
|
||||||
|
}
|
||||||
|
|
||||||
static void WordCompanionExistingProjectBookLinkingRemainsAvailable()
|
static void WordCompanionExistingProjectBookLinkingRemainsAvailable()
|
||||||
{
|
{
|
||||||
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
|
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
|
||||||
@ -1827,7 +1846,44 @@ static void CoreImportScanStageDefersScenesAndCharacters()
|
|||||||
Assert(presence.Contains("Opening chapter review...", StringComparison.Ordinal), "Successful scan should show the immediate auto-navigation state.");
|
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(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("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.");
|
Assert(wordService.Contains("var evidenceScenes = CandidateSceneEvidence", StringComparison.Ordinal), "Post-scene character candidates should calculate candidate-specific canonical scene evidence.");
|
||||||
|
Assert(wordService.Contains("EvidenceScenes = evidenceScenes", StringComparison.Ordinal), "Post-scene character candidates should carry candidate-specific canonical scene evidence.");
|
||||||
|
Assert(!wordService.Contains("Source SceneIDs", StringComparison.Ordinal), "Post-scene character candidates should not dump every canonical SceneID into every candidate reason.");
|
||||||
|
}
|
||||||
|
|
||||||
|
static void CoreImportCharacterEvidenceIsCandidateSpecific()
|
||||||
|
{
|
||||||
|
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
|
||||||
|
var scanReview = File.ReadAllText(Path.Combine(root, "Views/Onboarding/ScanReview.cshtml"));
|
||||||
|
var models = File.ReadAllText(Path.Combine(root, "Models/ManuscriptScanModels.cs"));
|
||||||
|
var viewModels = File.ReadAllText(Path.Combine(root, "ViewModels/OnboardingViewModels.cs"));
|
||||||
|
var onboarding = File.ReadAllText(Path.Combine(root, "Services/OnboardingService.cs"));
|
||||||
|
|
||||||
|
var sources = new List<SceneManuscriptSourceRecord>
|
||||||
|
{
|
||||||
|
new() { SceneID = 101, ChapterID = 1, SourceText = "Rosie saw Beth. Beth smiled at Rosie.", StructuralSummary = "Beth and Rosie talk outside." },
|
||||||
|
new() { SceneID = 102, ChapterID = 1, SourceText = "Rosie waited. Rosie called Colin.", StructuralSummary = "Rosie waits for Colin." },
|
||||||
|
new() { SceneID = 201, ChapterID = 2, SourceText = "Colin answered Rosie. Colin mentioned Beth.", StructuralSummary = "Colin answers the call." }
|
||||||
|
};
|
||||||
|
|
||||||
|
var candidates = WordCompanionService.DiscoverCoreImportCharacterCandidates(sources);
|
||||||
|
var rosie = candidates.Single(candidate => candidate.Name == "Rosie");
|
||||||
|
var beth = candidates.Single(candidate => candidate.Name == "Beth");
|
||||||
|
var colin = candidates.Single(candidate => candidate.Name == "Colin");
|
||||||
|
|
||||||
|
Assert(rosie.MentionCount == rosie.EvidenceScenes.Sum(scene => scene.MentionCount), "Mention count should equal summed candidate-specific scene evidence.");
|
||||||
|
Assert(rosie.EvidenceScenes.Select(scene => scene.SceneID).Order().SequenceEqual(new[] { 101, 102, 201 }), "Rosie evidence should include only scenes where Rosie is mentioned.");
|
||||||
|
Assert(beth.EvidenceScenes.Select(scene => scene.SceneID).Order().SequenceEqual(new[] { 101, 201 }), "Beth evidence should include only scenes where Beth is mentioned.");
|
||||||
|
Assert(colin.EvidenceScenes.Select(scene => scene.SceneID).Order().SequenceEqual(new[] { 102, 201 }), "Colin evidence should include only scenes where Colin is mentioned.");
|
||||||
|
Assert(!beth.EvidenceScenes.Select(scene => scene.SceneID).SequenceEqual(colin.EvidenceScenes.Select(scene => scene.SceneID)), "Different candidates should be able to have different evidence scene sets.");
|
||||||
|
Assert(models.Contains("ManuscriptScanCharacterEvidenceScene", StringComparison.Ordinal), "Scan candidate model should carry structured evidence scenes.");
|
||||||
|
Assert(viewModels.Contains("EvidenceSceneCount => EvidenceScenes.Count", StringComparison.Ordinal), "Character review view model should expose distinct evidence scene count.");
|
||||||
|
Assert(onboarding.Contains("EvidenceScenes = candidate.EvidenceScenes", StringComparison.Ordinal), "Character review model should preserve candidate evidence scenes.");
|
||||||
|
Assert(scanReview.Contains("mention@(candidate.MentionCount == 1 ? string.Empty : \"s\") across", StringComparison.Ordinal), "Character review should explain mentions across scenes.");
|
||||||
|
Assert(scanReview.Contains("View evidence", StringComparison.Ordinal), "Character review should collapse evidence behind a disclosure.");
|
||||||
|
Assert(scanReview.Contains("data-scene-id=\"@evidence.SceneID\"", StringComparison.Ordinal), "Raw SceneIDs should be diagnostics/data attributes, not visible labels.");
|
||||||
|
Assert(!scanReview.Contains("score @candidate.QualityScore", StringComparison.Ordinal), "Character review should not expose an unexplained score label.");
|
||||||
|
Assert(scanReview.Contains("!candidate.Reason.Contains(\"Source SceneIDs:\"", StringComparison.Ordinal), "Character review should suppress legacy raw source SceneID strings.");
|
||||||
}
|
}
|
||||||
|
|
||||||
static void CoreImportDeterministicallyMapsKnownCharacters()
|
static void CoreImportDeterministicallyMapsKnownCharacters()
|
||||||
@ -1962,6 +2018,7 @@ static void WordSyncRetryResumesExistingStructure()
|
|||||||
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
|
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
|
||||||
var onboarding = File.ReadAllText(Path.Combine(root, "Services/OnboardingService.cs"));
|
var onboarding = File.ReadAllText(Path.Combine(root, "Services/OnboardingService.cs"));
|
||||||
var repository = File.ReadAllText(Path.Combine(root, "Data/OnboardingBuildRepository.cs"));
|
var repository = File.ReadAllText(Path.Combine(root, "Data/OnboardingBuildRepository.cs"));
|
||||||
|
var complete = File.ReadAllText(Path.Combine(root, "Views/Onboarding/BuildComplete.cshtml"));
|
||||||
var migration = File.ReadAllText(Path.Combine(root, "Sql/158_Phase25C_OnboardingMarkerSaveStates.sql"));
|
var migration = File.ReadAllText(Path.Combine(root, "Sql/158_Phase25C_OnboardingMarkerSaveStates.sql"));
|
||||||
|
|
||||||
var resumeCheck = onboarding.IndexOf("await builds.GetByPreviewAsync(userId, preview.PreviewID)", StringComparison.Ordinal);
|
var resumeCheck = onboarding.IndexOf("await builds.GetByPreviewAsync(userId, preview.PreviewID)", StringComparison.Ordinal);
|
||||||
@ -1969,6 +2026,8 @@ static void WordSyncRetryResumesExistingStructure()
|
|||||||
Assert(resumeCheck >= 0 && sceneDetection > resumeCheck, "Existing structural builds should be checked before rerunning scene boundary AI.");
|
Assert(resumeCheck >= 0 && sceneDetection > resumeCheck, "Existing structural builds should be checked before rerunning scene boundary AI.");
|
||||||
Assert(onboarding.Contains("BuildExistingStructureResultAsync", StringComparison.Ordinal), "Retry should rebuild marker instructions from persisted structure.");
|
Assert(onboarding.Contains("BuildExistingStructureResultAsync", StringComparison.Ordinal), "Retry should rebuild marker instructions from persisted structure.");
|
||||||
Assert(onboarding.Contains("BuildSceneMarkerInstructionsFromSources", StringComparison.Ordinal), "Retry should use stored SceneID paragraph positions for marker sync.");
|
Assert(onboarding.Contains("BuildSceneMarkerInstructionsFromSources", StringComparison.Ordinal), "Retry should use stored SceneID paragraph positions for marker sync.");
|
||||||
|
Assert(complete.Contains("Resync Word markers", StringComparison.Ordinal), "Scene detection complete page should expose a marker repair action for existing imported documents.");
|
||||||
|
Assert(complete.Contains("Scene detection is not rerun.", StringComparison.Ordinal), "Marker repair UI should explain it reuses existing structure.");
|
||||||
Assert(repository.Contains("Onboarding_ManuscriptBuild_GetByPreview", StringComparison.Ordinal), "Existing build lookup should use a stored procedure.");
|
Assert(repository.Contains("Onboarding_ManuscriptBuild_GetByPreview", StringComparison.Ordinal), "Existing build lookup should use a stored procedure.");
|
||||||
Assert(migration.Contains("CREATE OR ALTER PROCEDURE dbo.Onboarding_ManuscriptBuild_GetByPreview", StringComparison.Ordinal), "Forward migration should provide the existing build lookup procedure.");
|
Assert(migration.Contains("CREATE OR ALTER PROCEDURE dbo.Onboarding_ManuscriptBuild_GetByPreview", StringComparison.Ordinal), "Forward migration should provide the existing build lookup procedure.");
|
||||||
}
|
}
|
||||||
|
|||||||
@ -123,6 +123,17 @@ public sealed class ManuscriptScanCharacterCandidatePreview
|
|||||||
public string? Reason { get; init; }
|
public string? Reason { get; init; }
|
||||||
public bool IsExistingCharacterMatch { get; init; }
|
public bool IsExistingCharacterMatch { get; init; }
|
||||||
public string? SuggestedImportance { get; init; }
|
public string? SuggestedImportance { get; init; }
|
||||||
|
public IReadOnlyList<ManuscriptScanCharacterEvidenceScene> EvidenceScenes { get; init; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ManuscriptScanCharacterEvidenceScene
|
||||||
|
{
|
||||||
|
public int SceneID { get; init; }
|
||||||
|
public int ChapterID { get; init; }
|
||||||
|
public int ChapterNumber { get; init; }
|
||||||
|
public int SceneNumber { get; init; }
|
||||||
|
public int MentionCount { get; init; }
|
||||||
|
public string? StructuralSummary { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class ManuscriptScanState
|
public sealed class ManuscriptScanState
|
||||||
|
|||||||
@ -128,7 +128,7 @@ public sealed class OnboardingService(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
var (userId, preview, _, project, book, existingCharacters) = context.Value;
|
var (userId, preview, _, project, book, existingCharacters, _) = context.Value;
|
||||||
var chapterLookup = preview.Chapters.ToDictionary(chapter => chapter.TemporaryChapterKey, StringComparer.Ordinal);
|
var chapterLookup = preview.Chapters.ToDictionary(chapter => chapter.TemporaryChapterKey, StringComparer.Ordinal);
|
||||||
var sceneLookup = preview.Scenes.ToDictionary(scene => scene.TemporarySceneKey, StringComparer.Ordinal);
|
var sceneLookup = preview.Scenes.ToDictionary(scene => scene.TemporarySceneKey, StringComparer.Ordinal);
|
||||||
var candidateLookup = preview.CharacterCandidates.ToDictionary(candidate => candidate.TemporaryCharacterKey, StringComparer.Ordinal);
|
var candidateLookup = preview.CharacterCandidates.ToDictionary(candidate => candidate.TemporaryCharacterKey, StringComparer.Ordinal);
|
||||||
@ -218,7 +218,9 @@ public sealed class OnboardingService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
var saved = await scanPreviews.SaveReviewAsync(userId, review);
|
var saved = await scanPreviews.SaveReviewAsync(userId, review);
|
||||||
return saved is null ? null : BuildScanReviewViewModel((userId, preview, saved, project, book, existingCharacters));
|
var build = await scanPreviews.GetBuildResultAsync(userId, preview.PreviewID)
|
||||||
|
?? await builds.GetByPreviewAsync(userId, preview.PreviewID);
|
||||||
|
return saved is null ? null : BuildScanReviewViewModel((userId, preview, saved, project, book, existingCharacters, build));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<OnboardingManuscriptBuildResult?> BuildApprovedStructureAsync(Guid previewId, Func<OnboardingBuildProgress, Task>? progress = null)
|
public async Task<OnboardingManuscriptBuildResult?> BuildApprovedStructureAsync(Guid previewId, Func<OnboardingBuildProgress, Task>? progress = null)
|
||||||
@ -229,7 +231,7 @@ public sealed class OnboardingService(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
var (userId, preview, review, _, _, _) = context.Value;
|
var (userId, preview, review, _, _, _, _) = context.Value;
|
||||||
if (!string.Equals(review.Status, ManuscriptScanReviewStatuses.ReadyToImport, StringComparison.Ordinal))
|
if (!string.Equals(review.Status, ManuscriptScanReviewStatuses.ReadyToImport, StringComparison.Ordinal))
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException("Review the scan and choose Save and continue before preparing chapters.");
|
throw new InvalidOperationException("Review the scan and choose Save and continue before preparing chapters.");
|
||||||
@ -409,8 +411,10 @@ public sealed class OnboardingService(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
var (userId, preview, review, _, _, existingCharacters) = context.Value;
|
var (userId, preview, review, _, _, existingCharacters, contextBuild) = context.Value;
|
||||||
var build = await scanPreviews.GetBuildResultAsync(userId, preview.PreviewID);
|
var build = contextBuild
|
||||||
|
?? await scanPreviews.GetBuildResultAsync(userId, preview.PreviewID)
|
||||||
|
?? await builds.GetByPreviewAsync(userId, preview.PreviewID);
|
||||||
if (build is null || build.SceneMappings.Count == 0)
|
if (build is null || build.SceneMappings.Count == 0)
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException("Import the approved chapters and scenes before reviewing characters.");
|
throw new InvalidOperationException("Import the approved chapters and scenes before reviewing characters.");
|
||||||
@ -733,7 +737,7 @@ public sealed class OnboardingService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<(int UserId, ManuscriptScanPreview Preview, ManuscriptScanReviewDecision Review, Project Project, Book Book, IReadOnlyList<Character> ExistingCharacters)?> GetScanReviewContextAsync(Guid? previewId)
|
private async Task<(int UserId, ManuscriptScanPreview Preview, ManuscriptScanReviewDecision Review, Project Project, Book Book, IReadOnlyList<Character> ExistingCharacters, OnboardingManuscriptBuildResult? Build)?> GetScanReviewContextAsync(Guid? previewId)
|
||||||
{
|
{
|
||||||
var userId = RequireUserId();
|
var userId = RequireUserId();
|
||||||
var state = await onboarding.GetAsync(userId);
|
var state = await onboarding.GetAsync(userId);
|
||||||
@ -767,13 +771,15 @@ public sealed class OnboardingService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
var existingCharacters = await characters.ListCharactersAsync(project.ProjectID);
|
var existingCharacters = await characters.ListCharactersAsync(project.ProjectID);
|
||||||
return (userId, preview, review, project, book, existingCharacters.Where(character => !character.IsArchived).ToList());
|
var build = await scanPreviews.GetBuildResultAsync(userId, preview.PreviewID)
|
||||||
|
?? await builds.GetByPreviewAsync(userId, preview.PreviewID);
|
||||||
|
return (userId, preview, review, project, book, existingCharacters.Where(character => !character.IsArchived).ToList(), build);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static ManuscriptScanReviewViewModel BuildScanReviewViewModel(
|
private static ManuscriptScanReviewViewModel BuildScanReviewViewModel(
|
||||||
(int UserId, ManuscriptScanPreview Preview, ManuscriptScanReviewDecision Review, Project Project, Book Book, IReadOnlyList<Character> ExistingCharacters) context)
|
(int UserId, ManuscriptScanPreview Preview, ManuscriptScanReviewDecision Review, Project Project, Book Book, IReadOnlyList<Character> ExistingCharacters, OnboardingManuscriptBuildResult? Build) context)
|
||||||
{
|
{
|
||||||
var (userId, preview, review, project, book, existingCharacters) = context;
|
var (userId, preview, review, project, book, existingCharacters, build) = context;
|
||||||
_ = userId;
|
_ = userId;
|
||||||
var chapterReview = review.Chapters.ToDictionary(item => item.TemporaryChapterKey, StringComparer.Ordinal);
|
var chapterReview = review.Chapters.ToDictionary(item => item.TemporaryChapterKey, StringComparer.Ordinal);
|
||||||
var sceneReview = review.Scenes.ToDictionary(item => item.TemporarySceneKey, StringComparer.Ordinal);
|
var sceneReview = review.Scenes.ToDictionary(item => item.TemporarySceneKey, StringComparer.Ordinal);
|
||||||
@ -854,13 +860,30 @@ public sealed class OnboardingService(
|
|||||||
&& characterReview.TryGetValue(decision.AliasTargetTemporaryCharacterKey, out var targetDecision)
|
&& characterReview.TryGetValue(decision.AliasTargetTemporaryCharacterKey, out var targetDecision)
|
||||||
? targetDecision.Name
|
? targetDecision.Name
|
||||||
: null,
|
: null,
|
||||||
SexHint = CharacterSexHintService.Normalise(decision?.SexHint, reviewName)
|
SexHint = CharacterSexHintService.Normalise(decision?.SexHint, reviewName),
|
||||||
|
EvidenceScenes = candidate.EvidenceScenes
|
||||||
|
.OrderBy(evidence => evidence.ChapterNumber)
|
||||||
|
.ThenBy(evidence => evidence.SceneNumber)
|
||||||
|
.ThenBy(evidence => evidence.SceneID)
|
||||||
|
.Select(evidence => new ManuscriptScanCharacterEvidenceSceneViewModel
|
||||||
|
{
|
||||||
|
SceneID = evidence.SceneID,
|
||||||
|
ChapterID = evidence.ChapterID,
|
||||||
|
ChapterNumber = evidence.ChapterNumber,
|
||||||
|
SceneNumber = evidence.SceneNumber,
|
||||||
|
MentionCount = evidence.MentionCount,
|
||||||
|
StructuralSummary = evidence.StructuralSummary
|
||||||
|
})
|
||||||
|
.ToList()
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
var selectedChapterKeys = chapters.Where(chapter => chapter.Include).Select(chapter => chapter.TemporaryChapterKey).ToHashSet(StringComparer.Ordinal);
|
var selectedChapterKeys = chapters.Where(chapter => chapter.Include).Select(chapter => chapter.TemporaryChapterKey).ToHashSet(StringComparer.Ordinal);
|
||||||
var selectedScenes = chapters.SelectMany(chapter => chapter.Scenes).Where(scene => scene.Include && selectedChapterKeys.Contains(scene.TemporaryChapterKey)).ToList();
|
var selectedScenes = chapters.SelectMany(chapter => chapter.Scenes).Where(scene => scene.Include && selectedChapterKeys.Contains(scene.TemporaryChapterKey)).ToList();
|
||||||
|
var canonicalSceneCount = build?.ScenesCreated > 0
|
||||||
|
? build.ScenesCreated
|
||||||
|
: build?.SceneMappings.Count > 0 ? build.SceneMappings.Count : preview.SceneCount;
|
||||||
var selectedChapterWordCount = chapters
|
var selectedChapterWordCount = chapters
|
||||||
.Where(chapter => chapter.Include)
|
.Where(chapter => chapter.Include)
|
||||||
.Sum(chapter => chapter.WordCount);
|
.Sum(chapter => chapter.WordCount);
|
||||||
@ -874,10 +897,10 @@ public sealed class OnboardingService(
|
|||||||
SelectedBookTitle = BookOptionTitle(book),
|
SelectedBookTitle = BookOptionTitle(book),
|
||||||
TotalWordCount = preview.TotalWordCount,
|
TotalWordCount = preview.TotalWordCount,
|
||||||
ChapterCount = preview.ChapterCount,
|
ChapterCount = preview.ChapterCount,
|
||||||
SceneCount = preview.SceneCount,
|
SceneCount = canonicalSceneCount,
|
||||||
CharacterCandidateCount = preview.CharacterCandidateCount,
|
CharacterCandidateCount = preview.CharacterCandidateCount,
|
||||||
SelectedChapterCount = selectedChapterKeys.Count,
|
SelectedChapterCount = selectedChapterKeys.Count,
|
||||||
SelectedSceneCount = selectedScenes.Count,
|
SelectedSceneCount = canonicalSceneCount > 0 ? canonicalSceneCount : selectedScenes.Count,
|
||||||
SelectedCharacterCount = characters.Count(character => character.Include && !character.IsAlias && !string.Equals(character.Category, "Excluded", StringComparison.OrdinalIgnoreCase)),
|
SelectedCharacterCount = characters.Count(character => character.Include && !character.IsAlias && !string.Equals(character.Category, "Excluded", StringComparison.OrdinalIgnoreCase)),
|
||||||
SelectedWordCount = selectedChapterWordCount,
|
SelectedWordCount = selectedChapterWordCount,
|
||||||
Chapters = chapters,
|
Chapters = chapters,
|
||||||
|
|||||||
@ -726,10 +726,12 @@ public sealed class WordCompanionService(
|
|||||||
public static IReadOnlyList<ManuscriptScanCharacterCandidatePreview> DiscoverCoreImportCharacterCandidates(
|
public static IReadOnlyList<ManuscriptScanCharacterCandidatePreview> DiscoverCoreImportCharacterCandidates(
|
||||||
IReadOnlyList<SceneManuscriptSourceRecord> sourceScenes)
|
IReadOnlyList<SceneManuscriptSourceRecord> sourceScenes)
|
||||||
{
|
{
|
||||||
var manuscriptText = string.Join("\n\n", sourceScenes
|
var orderedScenes = sourceScenes
|
||||||
.OrderBy(scene => scene.ChapterID)
|
.OrderBy(scene => scene.ChapterID)
|
||||||
.ThenBy(scene => scene.SourceStartParagraph ?? int.MaxValue)
|
.ThenBy(scene => scene.SourceStartParagraph ?? int.MaxValue)
|
||||||
.ThenBy(scene => scene.SceneID)
|
.ThenBy(scene => scene.SceneID)
|
||||||
|
.ToList();
|
||||||
|
var manuscriptText = string.Join("\n\n", orderedScenes
|
||||||
.Select(scene => scene.SourceText)
|
.Select(scene => scene.SourceText)
|
||||||
.Where(text => !string.IsNullOrWhiteSpace(text)));
|
.Where(text => !string.IsNullOrWhiteSpace(text)));
|
||||||
if (string.IsNullOrWhiteSpace(manuscriptText))
|
if (string.IsNullOrWhiteSpace(manuscriptText))
|
||||||
@ -737,30 +739,68 @@ public sealed class WordCompanionService(
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
var sceneIds = sourceScenes
|
var chapterNumbers = orderedScenes
|
||||||
.Where(scene => !string.IsNullOrWhiteSpace(scene.SourceText))
|
.GroupBy(scene => scene.ChapterID)
|
||||||
.Select(scene => scene.SceneID)
|
.Select((group, index) => new { group.Key, ChapterNumber = index + 1 })
|
||||||
.Distinct()
|
.ToDictionary(item => item.Key, item => item.ChapterNumber);
|
||||||
.Order()
|
var sceneNumbers = orderedScenes
|
||||||
.ToList();
|
.GroupBy(scene => scene.ChapterID)
|
||||||
var sceneEvidence = sceneIds.Count == 0
|
.SelectMany(group => group.Select((scene, index) => new { scene.SceneID, SceneNumber = index + 1 }))
|
||||||
? "Discovered from canonical scene source text."
|
.ToDictionary(item => item.SceneID, item => item.SceneNumber);
|
||||||
: $"Discovered from canonical scene source text. Source SceneIDs: {string.Join(", ", sceneIds)}.";
|
|
||||||
|
|
||||||
return DiscoverCharacterCandidates(manuscriptText)
|
return DiscoverCharacterCandidates(manuscriptText)
|
||||||
.Where(IsAutoSelectedScanCharacterCandidate)
|
.Where(IsAutoSelectedScanCharacterCandidate)
|
||||||
.Select((candidate, index) => new ManuscriptScanCharacterCandidatePreview
|
.Select((candidate, index) =>
|
||||||
|
{
|
||||||
|
var evidenceScenes = CandidateSceneEvidence(candidate.Text, orderedScenes, chapterNumbers, sceneNumbers);
|
||||||
|
return new ManuscriptScanCharacterCandidatePreview
|
||||||
{
|
{
|
||||||
TemporaryCharacterKey = $"post-scene-{StableCandidateKey(candidate.Text, index)}",
|
TemporaryCharacterKey = $"post-scene-{StableCandidateKey(candidate.Text, index)}",
|
||||||
Name = candidate.Text,
|
Name = candidate.Text,
|
||||||
MentionCount = candidate.MentionCount,
|
MentionCount = evidenceScenes.Sum(evidence => evidence.MentionCount),
|
||||||
QualityScore = candidate.QualityScore,
|
QualityScore = candidate.QualityScore,
|
||||||
Category = candidate.Category,
|
Category = candidate.Category,
|
||||||
Reason = string.IsNullOrWhiteSpace(candidate.Reason)
|
Reason = string.IsNullOrWhiteSpace(candidate.Reason)
|
||||||
? sceneEvidence
|
? "Discovered from canonical scene source text."
|
||||||
: $"{candidate.Reason} {sceneEvidence}",
|
: candidate.Reason,
|
||||||
IsExistingCharacterMatch = candidate.IsExistingCharacterMatch,
|
IsExistingCharacterMatch = candidate.IsExistingCharacterMatch,
|
||||||
SuggestedImportance = candidate.Confidence
|
SuggestedImportance = candidate.Confidence,
|
||||||
|
EvidenceScenes = evidenceScenes
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<ManuscriptScanCharacterEvidenceScene> CandidateSceneEvidence(
|
||||||
|
string candidateText,
|
||||||
|
IReadOnlyList<SceneManuscriptSourceRecord> sourceScenes,
|
||||||
|
IReadOnlyDictionary<int, int> chapterNumbers,
|
||||||
|
IReadOnlyDictionary<int, int> sceneNumbers)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(candidateText))
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
var pattern = $@"(?<![\p{{L}}\p{{N}}]){Regex.Escape(candidateText.Trim())}(?=(?:['’]s)?(?![\p{{L}}\p{{N}}])|$)";
|
||||||
|
var regex = new Regex(pattern, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
|
||||||
|
return sourceScenes
|
||||||
|
.Select(scene => new
|
||||||
|
{
|
||||||
|
Source = scene,
|
||||||
|
MentionCount = string.IsNullOrWhiteSpace(scene.SourceText)
|
||||||
|
? 0
|
||||||
|
: regex.Matches(scene.SourceText).Count
|
||||||
|
})
|
||||||
|
.Where(item => item.MentionCount > 0)
|
||||||
|
.Select(item => new ManuscriptScanCharacterEvidenceScene
|
||||||
|
{
|
||||||
|
SceneID = item.Source.SceneID,
|
||||||
|
ChapterID = item.Source.ChapterID,
|
||||||
|
ChapterNumber = chapterNumbers.GetValueOrDefault(item.Source.ChapterID),
|
||||||
|
SceneNumber = sceneNumbers.GetValueOrDefault(item.Source.SceneID),
|
||||||
|
MentionCount = item.MentionCount,
|
||||||
|
StructuralSummary = item.Source.StructuralSummary
|
||||||
})
|
})
|
||||||
.ToList();
|
.ToList();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -752,9 +752,22 @@ public sealed class ManuscriptScanReviewCharacterViewModel
|
|||||||
public string? AliasTargetTemporaryCharacterKey { get; init; }
|
public string? AliasTargetTemporaryCharacterKey { get; init; }
|
||||||
public string? AliasTargetName { get; init; }
|
public string? AliasTargetName { get; init; }
|
||||||
public string SexHint { get; init; } = "Unknown";
|
public string SexHint { get; init; } = "Unknown";
|
||||||
|
public IReadOnlyList<ManuscriptScanCharacterEvidenceSceneViewModel> EvidenceScenes { get; init; } = [];
|
||||||
|
public int EvidenceSceneCount => EvidenceScenes.Count;
|
||||||
public bool IsAlias => !string.IsNullOrWhiteSpace(AliasTargetTemporaryCharacterKey);
|
public bool IsAlias => !string.IsNullOrWhiteSpace(AliasTargetTemporaryCharacterKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed class ManuscriptScanCharacterEvidenceSceneViewModel
|
||||||
|
{
|
||||||
|
public int SceneID { get; init; }
|
||||||
|
public int ChapterID { get; init; }
|
||||||
|
public int ChapterNumber { get; init; }
|
||||||
|
public int SceneNumber { get; init; }
|
||||||
|
public int MentionCount { get; init; }
|
||||||
|
public string? StructuralSummary { get; init; }
|
||||||
|
public string Label => $"Chapter {ChapterNumber:N0} - Scene {SceneNumber:N0}";
|
||||||
|
}
|
||||||
|
|
||||||
public sealed class ManuscriptScanExistingCharacterOptionViewModel
|
public sealed class ManuscriptScanExistingCharacterOptionViewModel
|
||||||
{
|
{
|
||||||
public int CharacterID { get; init; }
|
public int CharacterID { get; init; }
|
||||||
|
|||||||
@ -16,7 +16,7 @@
|
|||||||
<p>
|
<p>
|
||||||
@if (coreComplete)
|
@if (coreComplete)
|
||||||
{
|
{
|
||||||
@:PlotDirector created the approved chapters and scenes, saved the Word markers, and applied your character review. Core import is complete.
|
@:PlotDirector created the approved chapters and scenes, confirmed the Word markers were saved, and applied your character review. Core import is complete.
|
||||||
}
|
}
|
||||||
else if (Model.AlreadyBuilt)
|
else if (Model.AlreadyBuilt)
|
||||||
{
|
{
|
||||||
@ -24,7 +24,7 @@
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@:PlotDirector created the approved chapters and detected scenes, persisted their manuscript text, updated the Word markers, and saved the manuscript. Word may now be closed if you wish; keep this browser workflow open to review characters.
|
@:PlotDirector created the approved chapters and detected scenes, persisted their manuscript text, and confirmed the Word markers were saved. Word may now be closed if you wish; keep this browser workflow open to review characters.
|
||||||
}
|
}
|
||||||
</p>
|
</p>
|
||||||
@if (!string.IsNullOrWhiteSpace(Model.MarkerWarning))
|
@if (!string.IsNullOrWhiteSpace(Model.MarkerWarning))
|
||||||
@ -100,6 +100,22 @@
|
|||||||
<h2>Next: review characters</h2>
|
<h2>Next: review characters</h2>
|
||||||
<p>Character detection remains part of core import. Review detected names, aliases and existing matches to finish importing the manuscript.</p>
|
<p>Character detection remains part of core import. Review detected names, aliases and existing matches to finish importing the manuscript.</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="onboarding-build-panel onboarding-marker-resync-panel" data-onboarding-build-panel>
|
||||||
|
<div>
|
||||||
|
<p class="onboarding-scan-next">Need to repair the Word document?</p>
|
||||||
|
<p class="onboarding-build-message" data-onboarding-build-message>Resync Word markers uses the existing imported chapters and scenes. Scene detection is not rerun.</p>
|
||||||
|
</div>
|
||||||
|
<strong data-onboarding-build-percent></strong>
|
||||||
|
<div class="onboarding-scan-progress onboarding-build-progress" aria-hidden="true">
|
||||||
|
<span data-onboarding-build-progress></span>
|
||||||
|
</div>
|
||||||
|
<div class="onboarding-manual-save-actions" data-onboarding-manual-save-actions hidden>
|
||||||
|
<button class="btn btn-primary btn-sm" type="button" data-onboarding-build-save-confirm disabled>
|
||||||
|
I've saved the Word document
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
}
|
}
|
||||||
|
|
||||||
<div class="onboarding-actions">
|
<div class="onboarding-actions">
|
||||||
@ -111,6 +127,13 @@
|
|||||||
else
|
else
|
||||||
{
|
{
|
||||||
<a class="btn btn-primary" asp-controller="Onboarding" asp-action="ScanCharacters" asp-route-previewId="@Model.PreviewID">Review characters</a>
|
<a class="btn btn-primary" asp-controller="Onboarding" asp-action="ScanCharacters" asp-route-previewId="@Model.PreviewID">Review characters</a>
|
||||||
|
<button class="btn btn-outline-primary"
|
||||||
|
type="button"
|
||||||
|
data-onboarding-build-start
|
||||||
|
data-onboarding-build-action
|
||||||
|
data-preview-id="@Model.PreviewID">
|
||||||
|
Resync Word markers
|
||||||
|
</button>
|
||||||
}
|
}
|
||||||
<a class="btn btn-outline-primary" asp-controller="Projects" asp-action="Details" asp-route-id="@Model.ProjectID">Open Project Overview</a>
|
<a class="btn btn-outline-primary" asp-controller="Projects" asp-action="Details" asp-route-id="@Model.ProjectID">Open Project Overview</a>
|
||||||
<a class="btn btn-outline-secondary" asp-controller="Writer" asp-action="Index" asp-route-projectId="@Model.ProjectID">Open Writer Workspace</a>
|
<a class="btn btn-outline-secondary" asp-controller="Writer" asp-action="Index" asp-route-projectId="@Model.ProjectID">Open Writer Workspace</a>
|
||||||
|
|||||||
@ -214,22 +214,30 @@
|
|||||||
@foreach (var candidate in group.Candidates)
|
@foreach (var candidate in group.Candidates)
|
||||||
{
|
{
|
||||||
var currentCharacterIndex = allCharacters.FindIndex(item => string.Equals(item.TemporaryCharacterKey, candidate.TemporaryCharacterKey, StringComparison.Ordinal));
|
var currentCharacterIndex = allCharacters.FindIndex(item => string.Equals(item.TemporaryCharacterKey, candidate.TemporaryCharacterKey, StringComparison.Ordinal));
|
||||||
<li class="@(invalidCharacterKeys.Contains(candidate.TemporaryCharacterKey) ? "is-invalid" : string.Empty)">
|
<li class="onboarding-character-card @(invalidCharacterKeys.Contains(candidate.TemporaryCharacterKey) ? "is-invalid" : string.Empty)">
|
||||||
<input type="hidden" name="Characters[@currentCharacterIndex].TemporaryCharacterKey" value="@candidate.TemporaryCharacterKey" />
|
<input type="hidden" name="Characters[@currentCharacterIndex].TemporaryCharacterKey" value="@candidate.TemporaryCharacterKey" />
|
||||||
<input type="hidden" name="Characters[@currentCharacterIndex].Category" value="@candidate.Category" />
|
<input type="hidden" name="Characters[@currentCharacterIndex].Category" value="@candidate.Category" />
|
||||||
|
<div class="onboarding-character-card-header">
|
||||||
|
<div>
|
||||||
|
<strong>@candidate.ReviewName</strong>
|
||||||
|
<span>@candidate.MentionCount.ToString("N0") mention@(candidate.MentionCount == 1 ? string.Empty : "s") across @candidate.EvidenceSceneCount.ToString("N0") scene@(candidate.EvidenceSceneCount == 1 ? string.Empty : "s")</span>
|
||||||
|
</div>
|
||||||
|
<small>@(candidate.IsExistingCharacterMatch ? "Likely existing character" : candidate.Category)</small>
|
||||||
|
</div>
|
||||||
|
<div class="onboarding-character-controls">
|
||||||
<label class="onboarding-review-toggle">
|
<label class="onboarding-review-toggle">
|
||||||
<input type="checkbox" name="Characters[@currentCharacterIndex].Include" value="true" checked="@candidate.Include" />
|
<input type="checkbox" name="Characters[@currentCharacterIndex].Include" value="true" checked="@candidate.Include" />
|
||||||
<input type="hidden" name="Characters[@currentCharacterIndex].Include" value="false" />
|
<input type="hidden" name="Characters[@currentCharacterIndex].Include" value="false" />
|
||||||
Include
|
Create or link
|
||||||
</label>
|
</label>
|
||||||
<label class="onboarding-review-field">
|
<label class="onboarding-review-field">
|
||||||
Name
|
Character name
|
||||||
<input class="form-control" name="Characters[@currentCharacterIndex].Name" value="@candidate.ReviewName" />
|
<input class="form-control" name="Characters[@currentCharacterIndex].Name" value="@candidate.ReviewName" />
|
||||||
</label>
|
</label>
|
||||||
<label class="onboarding-review-field">
|
<label class="onboarding-review-field">
|
||||||
Existing character
|
Link existing
|
||||||
<select class="form-select" name="Characters[@currentCharacterIndex].ExistingCharacterID">
|
<select class="form-select" name="Characters[@currentCharacterIndex].ExistingCharacterID">
|
||||||
<option value="">Create later if imported</option>
|
<option value="">Create new character</option>
|
||||||
@foreach (var option in Model.ExistingCharacterOptions)
|
@foreach (var option in Model.ExistingCharacterOptions)
|
||||||
{
|
{
|
||||||
<option value="@option.CharacterID" selected="@(candidate.ExistingCharacterID == option.CharacterID)">@option.CharacterName</option>
|
<option value="@option.CharacterID" selected="@(candidate.ExistingCharacterID == option.CharacterID)">@option.CharacterName</option>
|
||||||
@ -237,7 +245,7 @@
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label class="onboarding-review-field">
|
<label class="onboarding-review-field">
|
||||||
Alias of detected character
|
Alias of
|
||||||
<select class="form-select" name="Characters[@currentCharacterIndex].AliasTargetTemporaryCharacterKey">
|
<select class="form-select" name="Characters[@currentCharacterIndex].AliasTargetTemporaryCharacterKey">
|
||||||
<option value="">Not an alias</option>
|
<option value="">Not an alias</option>
|
||||||
@foreach (var target in detectedAliasTargets.Where(target => !string.Equals(target.TemporaryCharacterKey, candidate.TemporaryCharacterKey, StringComparison.Ordinal)))
|
@foreach (var target in detectedAliasTargets.Where(target => !string.Equals(target.TemporaryCharacterKey, candidate.TemporaryCharacterKey, StringComparison.Ordinal)))
|
||||||
@ -255,7 +263,8 @@
|
|||||||
<option value="NonBinaryOrOther" selected="@(string.Equals(candidate.SexHint, CharacterSexHintService.NonBinaryOrOther, StringComparison.Ordinal))">Non-binary / other</option>
|
<option value="NonBinaryOrOther" selected="@(string.Equals(candidate.SexHint, CharacterSexHintService.NonBinaryOrOther, StringComparison.Ordinal))">Non-binary / other</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<span>@candidate.MentionCount mention@(candidate.MentionCount == 1 ? string.Empty : "s") / score @candidate.QualityScore</span>
|
</div>
|
||||||
|
<div class="onboarding-character-card-notes">
|
||||||
@if (!string.IsNullOrWhiteSpace(candidate.AliasTargetName))
|
@if (!string.IsNullOrWhiteSpace(candidate.AliasTargetName))
|
||||||
{
|
{
|
||||||
<small>Alias of detected character: @candidate.AliasTargetName</small>
|
<small>Alias of detected character: @candidate.AliasTargetName</small>
|
||||||
@ -264,10 +273,34 @@
|
|||||||
{
|
{
|
||||||
<small>Matches existing character: @candidate.ExistingCharacterName</small>
|
<small>Matches existing character: @candidate.ExistingCharacterName</small>
|
||||||
}
|
}
|
||||||
@if (!string.IsNullOrWhiteSpace(candidate.Reason))
|
@if (!string.IsNullOrWhiteSpace(candidate.Reason) && !candidate.Reason.Contains("Source SceneIDs:", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
<small>@candidate.Reason</small>
|
<small>@candidate.Reason</small>
|
||||||
}
|
}
|
||||||
|
</div>
|
||||||
|
@if (candidate.EvidenceScenes.Any())
|
||||||
|
{
|
||||||
|
<details class="onboarding-character-evidence">
|
||||||
|
<summary>View evidence</summary>
|
||||||
|
<ol>
|
||||||
|
@foreach (var evidence in candidate.EvidenceScenes.Take(8))
|
||||||
|
{
|
||||||
|
<li data-scene-id="@evidence.SceneID">
|
||||||
|
<strong>@evidence.Label</strong>
|
||||||
|
<span>@evidence.MentionCount mention@(evidence.MentionCount == 1 ? string.Empty : "s")</span>
|
||||||
|
@if (!string.IsNullOrWhiteSpace(evidence.StructuralSummary))
|
||||||
|
{
|
||||||
|
<p>@evidence.StructuralSummary</p>
|
||||||
|
}
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ol>
|
||||||
|
@if (candidate.EvidenceScenes.Count > 8)
|
||||||
|
{
|
||||||
|
<small>@(candidate.EvidenceScenes.Count - 8) more scene@(candidate.EvidenceScenes.Count - 8 == 1 ? string.Empty : "s") not shown.</small>
|
||||||
|
}
|
||||||
|
</details>
|
||||||
|
}
|
||||||
</li>
|
</li>
|
||||||
}
|
}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@ -459,6 +459,10 @@
|
|||||||
background: rgba(182, 138, 70, .12);
|
background: rgba(182, 138, 70, .12);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.onboarding-marker-resync-panel {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
.onboarding-manual-save-actions {
|
.onboarding-manual-save-actions {
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -1349,6 +1353,72 @@ summary.story-review-chapter-heading {
|
|||||||
gap: .5rem;
|
gap: .5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.onboarding-character-card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: .85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.onboarding-character-card-header div {
|
||||||
|
display: grid;
|
||||||
|
gap: .15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.onboarding-character-card-header strong {
|
||||||
|
font-size: 1.05rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.onboarding-character-card-header span,
|
||||||
|
.onboarding-character-card-header small,
|
||||||
|
.onboarding-character-card-notes small,
|
||||||
|
.onboarding-character-evidence summary,
|
||||||
|
.onboarding-character-evidence li span {
|
||||||
|
color: var(--bs-secondary-color);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.onboarding-character-controls {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(120px, .75fr) repeat(4, minmax(150px, 1fr));
|
||||||
|
gap: .6rem;
|
||||||
|
align-items: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.onboarding-character-card-notes {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: .35rem .75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.onboarding-character-evidence {
|
||||||
|
border-top: 1px solid rgba(31, 42, 68, .08);
|
||||||
|
padding-top: .45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.onboarding-character-evidence summary {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.onboarding-character-evidence ol {
|
||||||
|
display: grid;
|
||||||
|
gap: .45rem;
|
||||||
|
margin: .55rem 0 0;
|
||||||
|
padding-left: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.onboarding-character-evidence li {
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.onboarding-character-evidence li p {
|
||||||
|
margin: .15rem 0 0;
|
||||||
|
color: var(--bs-secondary-color);
|
||||||
|
}
|
||||||
|
|
||||||
.onboarding-review-toggle {
|
.onboarding-review-toggle {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@ -2000,6 +2000,7 @@
|
|||||||
|
|
||||||
const buildDocumentStructure = (paragraphs, selectionParagraphs) => {
|
const buildDocumentStructure = (paragraphs, selectionParagraphs) => {
|
||||||
const chapters = [];
|
const chapters = [];
|
||||||
|
const hasCanonicalSceneAnchors = paragraphs.some((paragraph) => paragraphAnchorId(paragraph, "PD-SCENE"));
|
||||||
let heading1Count = 0;
|
let heading1Count = 0;
|
||||||
let heading2Count = 0;
|
let heading2Count = 0;
|
||||||
let chapter = null;
|
let chapter = null;
|
||||||
@ -2007,7 +2008,9 @@
|
|||||||
|
|
||||||
paragraphs.forEach((paragraph, index) => {
|
paragraphs.forEach((paragraph, index) => {
|
||||||
const title = String(paragraph.text || "").trim();
|
const title = String(paragraph.text || "").trim();
|
||||||
if (!title) {
|
const chapterAnchorId = paragraphAnchorId(paragraph, "PD-CHAPTER");
|
||||||
|
const sceneAnchorId = paragraphAnchorId(paragraph, "PD-SCENE");
|
||||||
|
if (!title && !chapterAnchorId && !sceneAnchorId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2017,15 +2020,26 @@
|
|||||||
index: chapters.length + 1,
|
index: chapters.length + 1,
|
||||||
paragraphIndex: index,
|
paragraphIndex: index,
|
||||||
title,
|
title,
|
||||||
anchorId: paragraphAnchorId(paragraph, "PD-CHAPTER"),
|
anchorId: chapterAnchorId,
|
||||||
scenes: []
|
scenes: []
|
||||||
};
|
};
|
||||||
chapters.push(chapter);
|
chapters.push(chapter);
|
||||||
scene = null;
|
scene = null;
|
||||||
|
if (sceneAnchorId) {
|
||||||
|
heading2Count += 1;
|
||||||
|
scene = {
|
||||||
|
index: chapter.scenes.length + 1,
|
||||||
|
paragraphIndex: index,
|
||||||
|
title: `Scene ${chapter.scenes.length + 1}`,
|
||||||
|
anchorId: sceneAnchorId,
|
||||||
|
wordCount: 0
|
||||||
|
};
|
||||||
|
chapter.scenes.push(scene);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isBuiltInHeading(paragraph, 2)) {
|
if (isBuiltInHeading(paragraph, 2) || sceneAnchorId) {
|
||||||
heading2Count += 1;
|
heading2Count += 1;
|
||||||
if (!chapter) {
|
if (!chapter) {
|
||||||
return;
|
return;
|
||||||
@ -2034,15 +2048,17 @@
|
|||||||
scene = {
|
scene = {
|
||||||
index: chapter.scenes.length + 1,
|
index: chapter.scenes.length + 1,
|
||||||
paragraphIndex: index,
|
paragraphIndex: index,
|
||||||
title,
|
title: isBuiltInHeading(paragraph, 2) ? title : `Scene ${chapter.scenes.length + 1}`,
|
||||||
anchorId: paragraphAnchorId(paragraph, "PD-SCENE"),
|
anchorId: sceneAnchorId,
|
||||||
wordCount: 0
|
wordCount: 0
|
||||||
};
|
};
|
||||||
chapter.scenes.push(scene);
|
chapter.scenes.push(scene);
|
||||||
|
if (isBuiltInHeading(paragraph, 2)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (scene) {
|
if (scene && title && (!hasCanonicalSceneAnchors || scene.anchorId)) {
|
||||||
scene.wordCount += countWords(title);
|
scene.wordCount += countWords(title);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -2144,8 +2160,8 @@
|
|||||||
styleBuiltIn: paragraph?.styleBuiltIn || "",
|
styleBuiltIn: paragraph?.styleBuiltIn || "",
|
||||||
style: paragraph?.style || "",
|
style: paragraph?.style || "",
|
||||||
wordCount: countWords(text),
|
wordCount: countWords(text),
|
||||||
chapterAnchorId: null,
|
chapterAnchorId: paragraphAnchorId(paragraph, "PD-CHAPTER"),
|
||||||
sceneAnchorId: null
|
sceneAnchorId: paragraphAnchorId(paragraph, "PD-SCENE")
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -2164,6 +2180,7 @@
|
|||||||
|
|
||||||
const buildRuntimeDocumentStructure = (paragraphs, usesExplicitScenes) => {
|
const buildRuntimeDocumentStructure = (paragraphs, usesExplicitScenes) => {
|
||||||
const paragraphSnapshots = paragraphs.map(snapshotParagraph);
|
const paragraphSnapshots = paragraphs.map(snapshotParagraph);
|
||||||
|
const hasCanonicalSceneAnchors = paragraphSnapshots.some((paragraph) => paragraph.sceneAnchorId);
|
||||||
const chapters = [];
|
const chapters = [];
|
||||||
let heading1Count = 0;
|
let heading1Count = 0;
|
||||||
let sceneBoundaryCount = 0;
|
let sceneBoundaryCount = 0;
|
||||||
@ -2210,7 +2227,12 @@
|
|||||||
};
|
};
|
||||||
chapters.push(chapter);
|
chapters.push(chapter);
|
||||||
scene = null;
|
scene = null;
|
||||||
startScene(paragraph, usesExplicitScenes ? "Scene 1" : "Scene 1", paragraph.sceneAnchorId);
|
if (paragraph.sceneAnchorId) {
|
||||||
|
sceneBoundaryCount += 1;
|
||||||
|
startScene(paragraph, "Scene 1", paragraph.sceneAnchorId);
|
||||||
|
} else if (!hasCanonicalSceneAnchors) {
|
||||||
|
startScene(paragraph, "Scene 1", null);
|
||||||
|
}
|
||||||
documentWordCount += paragraph.wordCount;
|
documentWordCount += paragraph.wordCount;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@ -2219,6 +2241,13 @@
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (paragraph.sceneAnchorId) {
|
||||||
|
sceneBoundaryCount += 1;
|
||||||
|
startScene(paragraph, `Scene ${chapter.scenes.length + 1}`, paragraph.sceneAnchorId);
|
||||||
|
} else if (!scene) {
|
||||||
|
startScene(paragraph, "Scene 1", null);
|
||||||
|
}
|
||||||
|
|
||||||
if (usesExplicitScenes && sceneSeparatorTexts.has(paragraph.text)) {
|
if (usesExplicitScenes && sceneSeparatorTexts.has(paragraph.text)) {
|
||||||
sceneBoundaryCount += 1;
|
sceneBoundaryCount += 1;
|
||||||
startScene(paragraph);
|
startScene(paragraph);
|
||||||
@ -3109,13 +3138,11 @@
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
for (const paragraph of bodyParagraphs.items) {
|
for (const paragraph of bodyParagraphs.items) {
|
||||||
if (isBuiltInHeading(paragraph, 1) || isBuiltInHeading(paragraph, 2)) {
|
|
||||||
paragraph.contentControls.load("items/tag,title");
|
paragraph.contentControls.load("items/tag,title");
|
||||||
}
|
}
|
||||||
}
|
|
||||||
await context.sync();
|
await context.sync();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn("Unable to load Word heading content controls. Continuing without PlotDirector anchors.", error);
|
console.warn("Unable to load Word content controls. Continuing without PlotDirector anchors.", error);
|
||||||
}
|
}
|
||||||
|
|
||||||
const structure = buildDocumentStructure(bodyParagraphs.items, selectionParagraphs.items);
|
const structure = buildDocumentStructure(bodyParagraphs.items, selectionParagraphs.items);
|
||||||
@ -3131,6 +3158,15 @@
|
|||||||
selectionParagraphs.load("text,styleBuiltIn,style");
|
selectionParagraphs.load("text,styleBuiltIn,style");
|
||||||
await context.sync();
|
await context.sync();
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const paragraph of bodyParagraphs.items) {
|
||||||
|
paragraph.contentControls.load("items/tag,title");
|
||||||
|
}
|
||||||
|
await context.sync();
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("Unable to load Word runtime content controls. Continuing without PlotDirector anchors.", error);
|
||||||
|
}
|
||||||
|
|
||||||
const cache = buildRuntimeDocumentStructure(bodyParagraphs.items, usesExplicitScenes);
|
const cache = buildRuntimeDocumentStructure(bodyParagraphs.items, usesExplicitScenes);
|
||||||
const selectedIndex = findSelectedParagraphIndex(cache, selectionParagraphs.items);
|
const selectedIndex = findSelectedParagraphIndex(cache, selectionParagraphs.items);
|
||||||
documentStructureCache = cache;
|
documentStructureCache = cache;
|
||||||
@ -4491,8 +4527,13 @@
|
|||||||
return controls.find((control) => anchorIdFromTag(control.tag, prefix)) || null;
|
return controls.find((control) => anchorIdFromTag(control.tag, prefix)) || null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const findExactAnchorControl = (paragraph, tag) => {
|
||||||
|
const controls = safeContentControlItems(paragraph);
|
||||||
|
return controls.find((control) => String(control.tag || "") === tag) || null;
|
||||||
|
};
|
||||||
|
|
||||||
const ensureAnchorControl = (paragraph, title, tag, prefix) => {
|
const ensureAnchorControl = (paragraph, title, tag, prefix) => {
|
||||||
const existing = findAnchorControl(paragraph, prefix);
|
const existing = findExactAnchorControl(paragraph, tag);
|
||||||
if (existing && existing.tag === tag && existing.title === title) {
|
if (existing && existing.tag === tag && existing.title === title) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -165,6 +165,9 @@
|
|||||||
|
|
||||||
const setBuildActionState = (text, disabled, mode = "") => {
|
const setBuildActionState = (text, disabled, mode = "") => {
|
||||||
document.querySelectorAll("[data-onboarding-build-action]").forEach((button) => {
|
document.querySelectorAll("[data-onboarding-build-action]").forEach((button) => {
|
||||||
|
if (!button.dataset.defaultActionText) {
|
||||||
|
button.dataset.defaultActionText = button.textContent.trim() || "Detect Scenes";
|
||||||
|
}
|
||||||
button.textContent = text;
|
button.textContent = text;
|
||||||
button.disabled = disabled;
|
button.disabled = disabled;
|
||||||
if (mode) {
|
if (mode) {
|
||||||
@ -226,7 +229,12 @@
|
|||||||
button.disabled = true;
|
button.disabled = true;
|
||||||
button.textContent = "I've saved the Word document";
|
button.textContent = "I've saved the Word document";
|
||||||
});
|
});
|
||||||
setBuildActionState("Detect Scenes", false);
|
document.querySelectorAll("[data-onboarding-build-action]").forEach((button) => {
|
||||||
|
const defaultText = button.dataset.defaultActionText || button.textContent.trim() || "Detect Scenes";
|
||||||
|
button.textContent = defaultText;
|
||||||
|
button.disabled = false;
|
||||||
|
delete button.dataset.buildMode;
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const confirmManualSaveFromWeb = async () => {
|
const confirmManualSaveFromWeb = async () => {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user