Keep character review within one wizard step
This commit is contained in:
parent
1ead298fa8
commit
1c7cf9bee1
@ -67,7 +67,9 @@ var tests = new (string Name, Action Test)[]
|
||||
("Character sex hints are conservative and reviewable", CharacterSexHintsAreConservativeAndReviewable),
|
||||
("Character discovery does not truncate scan review candidates", CharacterDiscoveryDoesNotTruncateScanReviewCandidates),
|
||||
("Character discovery rejects sentence-start words", CharacterDiscoveryRejectsSentenceStartWords),
|
||||
("Character discovery rejects fragments and location names", CharacterDiscoveryRejectsFragmentsAndLocationNames),
|
||||
("Manuscript scan preview keeps only auto-selected character candidates", ManuscriptScanPreviewKeepsOnlyAutoSelectedCharacterCandidates),
|
||||
("Onboarding wizard keeps character pages inside one main step", OnboardingWizardKeepsCharacterPagesInsideOneMainStep),
|
||||
("Scan review aliases feed Story Intelligence known character context", ScanReviewAliasesFeedStoryIntelligenceKnownCharacterContext),
|
||||
("Scan review post supports full-book form submissions", ScanReviewPostSupportsFullBookFormSubmissions),
|
||||
("Story Intelligence experience boot does not serialise live model", StoryIntelligenceExperienceBootDoesNotSerialiseLiveModel),
|
||||
@ -1333,6 +1335,38 @@ static void ManuscriptScanPreviewKeepsOnlyAutoSelectedCharacterCandidates()
|
||||
Assert(candidates[0].Name == "Colin", "Scan preview should keep probable character candidates.");
|
||||
}
|
||||
|
||||
static void CharacterDiscoveryRejectsFragmentsAndLocationNames()
|
||||
{
|
||||
var method = typeof(WordCompanionService).GetMethod("DiscoverCharacterCandidates", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
|
||||
Assert(method is not null, "Character discovery method could not be found.");
|
||||
|
||||
var text = """
|
||||
Bark Narrated the line break. Freedom Narrated another section.
|
||||
Eventually Rob arrived and Rob spoke to Maggie.
|
||||
Bristol Road curved away from Barmouth. Bristol Road appeared again.
|
||||
Maggie said hello. Maggie smiled at Rob.
|
||||
""";
|
||||
|
||||
var candidates = (IReadOnlyList<WordCompanionCharacterCandidateDto>)method!.Invoke(null, [text, false])!;
|
||||
Assert(candidates.Any(candidate => candidate.Text == "Maggie"), "Real character names should still survive fragment filtering.");
|
||||
Assert(candidates.Any(candidate => candidate.Text == "Rob"), "Known character names should still survive fragment filtering.");
|
||||
Assert(!candidates.Any(candidate => candidate.Text is "Bark Narrated" or "Freedom Narrated" or "Eventually Rob"), "Narrative fragments must not become character candidates.");
|
||||
Assert(!candidates.Any(candidate => candidate.Text == "Bristol Road"), "Location names must not become character candidates.");
|
||||
}
|
||||
|
||||
static void OnboardingWizardKeepsCharacterPagesInsideOneMainStep()
|
||||
{
|
||||
var header = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Views/Onboarding/_OnboardingJourneyHeader.cshtml"));
|
||||
var review = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Views/Onboarding/ScanReview.cshtml"));
|
||||
var storyService = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Services/OnboardingStoryIntelligenceService.cs"));
|
||||
|
||||
Assert(!header.Contains("BuildDynamicSteps", StringComparison.Ordinal), "Onboarding header should not create one top-level step per character page.");
|
||||
Assert(header.Contains("new(\"Review Characters\", 8)", StringComparison.Ordinal), "Onboarding header should keep character review as one main step.");
|
||||
Assert(review.Contains("var currentStep = isCharacterStep ? 8 : 7;", StringComparison.Ordinal), "Scan review should keep every character page on the same main wizard step.");
|
||||
Assert(review.Contains("onboarding-subprogress", StringComparison.Ordinal), "Character pagination should be shown as section-level progress.");
|
||||
Assert(storyService.Contains("const int totalWizardSteps = 11;", StringComparison.Ordinal), "Story Intelligence summary/context should use the fixed wizard step count.");
|
||||
}
|
||||
|
||||
static void StoryIntelligenceExperienceBootDoesNotSerialiseLiveModel()
|
||||
{
|
||||
var view = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Views/Development/StoryIntelligenceExperience.cshtml"));
|
||||
|
||||
@ -80,7 +80,7 @@ public sealed class OnboardingService(
|
||||
{
|
||||
CurrentStep = step,
|
||||
StepNumber = StepNumber(step),
|
||||
TotalSteps = 10,
|
||||
TotalSteps = 11,
|
||||
WritingJourney = state.WritingJourney,
|
||||
WritingSoftware = state.WritingSoftware,
|
||||
ProjectID = state.ProjectID,
|
||||
|
||||
@ -66,9 +66,9 @@ public sealed class OnboardingStoryIntelligenceService(
|
||||
var included = IncludedChapters(preview, review);
|
||||
var existingBatch = await batchStore.GetLatestForPreviewAsync(userId, preview.PreviewID);
|
||||
var missingTextCount = included.Count(chapter => string.IsNullOrWhiteSpace(chapter.Preview.ChapterText));
|
||||
var primaryCharacterCount = preview.CharacterCandidates.Count(candidate => !string.Equals(candidate.Category, "Excluded", StringComparison.OrdinalIgnoreCase));
|
||||
var characterPageCount = Math.Max(1, (int)Math.Ceiling(primaryCharacterCount / 10d));
|
||||
var totalWizardSteps = 7 + characterPageCount + 3;
|
||||
const int storySummaryStep = 9;
|
||||
const int storyContextStep = 10;
|
||||
const int totalWizardSteps = 11;
|
||||
return new StoryIntelligenceOverviewViewModel
|
||||
{
|
||||
PreviewID = preview.PreviewID,
|
||||
@ -79,8 +79,8 @@ public sealed class OnboardingStoryIntelligenceService(
|
||||
ApprovedChapterCount = included.Count,
|
||||
ApprovedWordCount = included.Sum(chapter => chapter.Preview.WordCount),
|
||||
MissingChapterTextCount = missingTextCount,
|
||||
WizardCurrentStep = totalWizardSteps - 2,
|
||||
WizardContextStep = totalWizardSteps - 1,
|
||||
WizardCurrentStep = storySummaryStep,
|
||||
WizardContextStep = storyContextStep,
|
||||
WizardTotalSteps = totalWizardSteps,
|
||||
ExistingBatchID = existingBatch?.BatchID,
|
||||
CanStart = included.Count > 0 && missingTextCount == 0,
|
||||
|
||||
@ -75,6 +75,10 @@ public sealed class WordCompanionService(
|
||||
"Course", "Thing", "Things", "Still", "Err", "Er", "Erm", "Alright", "Allright", "Alrite", "New",
|
||||
"Somewhere", "Coke", "Like", "Don", "Isn"
|
||||
};
|
||||
private static readonly HashSet<string> CharacterDiscoveryFragmentWords = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"Actually", "Eventually", "Finally", "Immediately", "Later", "Meanwhile", "Narrated", "Suddenly", "Then"
|
||||
};
|
||||
private static readonly HashSet<string> CharacterDiscoveryRelationshipTitles = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"Mum", "Dad", "Mother", "Father", "Gran", "Grandma", "Grandad", "Nan", "Nana", "Granny", "Brother",
|
||||
@ -693,6 +697,7 @@ public sealed class WordCompanionService(
|
||||
var hasTemporalWord = words.Any(word => CharacterDiscoveryTemporalWords.Contains(TrimHonorificPunctuation(word)));
|
||||
var hasLocationIndicator = words.Any(word => CharacterDiscoveryLocationIndicators.Contains(TrimHonorificPunctuation(word)));
|
||||
var hasStopWord = words.Any(word => CharacterDiscoveryStopWords.Contains(TrimHonorificPunctuation(word)));
|
||||
var hasFragmentWord = words.Any(word => CharacterDiscoveryFragmentWords.Contains(TrimHonorificPunctuation(word)));
|
||||
var isRelationshipTitle = words.Length == 1 && CharacterDiscoveryRelationshipTitles.Contains(first);
|
||||
var isSingleWord = words.Length == 1;
|
||||
var hasKnownGivenName = CharacterSexHintService.Suggest(first) != CharacterSexHintService.Unknown;
|
||||
@ -779,7 +784,7 @@ public sealed class WordCompanionService(
|
||||
score -= 3;
|
||||
}
|
||||
|
||||
var category = CandidateCategory(score, hasStopWord, isRelationshipTitle, hasTemporalWord, hasLocationIndicator, evidence, isSingleWord, mostlySentenceStart, hasKnownGivenName || hasNameLikeWord, hasHonorific);
|
||||
var category = CandidateCategory(score, hasStopWord || hasFragmentWord, isRelationshipTitle, hasTemporalWord, hasLocationIndicator, evidence, isSingleWord, mostlySentenceStart, hasKnownGivenName || hasNameLikeWord, hasHonorific);
|
||||
var confidence = score >= 4 ? "High" : score >= 2 ? "Medium" : "Low";
|
||||
if (hasLocationIndicator && evidence.DialogueEvidenceCount == 0 && !hasHonorific)
|
||||
{
|
||||
@ -797,7 +802,7 @@ public sealed class WordCompanionService(
|
||||
Confidence = confidence,
|
||||
QualityScore = score,
|
||||
Category = category,
|
||||
Reason = BuildCandidateReason(evidence, hasStopWord, isRelationshipTitle, hasHonorific, hasMultiWordName, hasTemporalWord, hasLocationIndicator, mostlySentenceStart)
|
||||
Reason = BuildCandidateReason(evidence, hasStopWord || hasFragmentWord, isRelationshipTitle, hasHonorific, hasMultiWordName, hasTemporalWord, hasLocationIndicator, mostlySentenceStart)
|
||||
};
|
||||
}
|
||||
|
||||
@ -842,6 +847,14 @@ public sealed class WordCompanionService(
|
||||
{
|
||||
return "Excluded";
|
||||
}
|
||||
if (hasLocationIndicator
|
||||
&& !hasHonorific
|
||||
&& !hasNameEvidence
|
||||
&& evidence.DialogueEvidenceCount == 0
|
||||
&& evidence.PossessiveEvidenceCount == 0)
|
||||
{
|
||||
return "Excluded";
|
||||
}
|
||||
if (hasLocationIndicator && score < 4)
|
||||
{
|
||||
return "Excluded";
|
||||
|
||||
@ -27,8 +27,8 @@
|
||||
var requestedCharacterPage = ViewData["CharacterReviewPage"] is int requestedPage ? requestedPage : 1;
|
||||
var characterPage = Math.Clamp(requestedCharacterPage, 1, characterReviewPageCount);
|
||||
var currentCharacters = primaryCharacters.Skip((characterPage - 1) * characterPageSize).Take(characterPageSize).ToList();
|
||||
var currentStep = isCharacterStep ? 7 + characterPage : 7;
|
||||
var totalSteps = 7 + characterReviewPageCount + 2;
|
||||
var currentStep = isCharacterStep ? 8 : 7;
|
||||
const int totalSteps = 11;
|
||||
var characterPageTitle = $"Review Characters {characterPage} of {primaryCharacterPageCount}";
|
||||
var duplicateCanonicalNames = primaryCharacters
|
||||
.Where(candidate => candidate.Include && string.IsNullOrWhiteSpace(candidate.AliasTargetTemporaryCharacterKey))
|
||||
@ -62,8 +62,8 @@
|
||||
|
||||
<div class="onboarding-copy">
|
||||
<p class="eyebrow">Step @currentStep of @totalSteps</p>
|
||||
<h1 id="scan-review-title">@(isCharacterStep ? characterPageTitle : "Review Chapters")</h1>
|
||||
<p>@(isCharacterStep ? "Review this page of detected names and aliases, then continue through the wizard." : "Confirm the chapter and scene structure before reviewing detected characters.")</p>
|
||||
<h1 id="scan-review-title">@(isCharacterStep ? "Review Characters" : "Review Chapters")</h1>
|
||||
<p>@(isCharacterStep ? "Review this page of detected names and aliases, then continue through the character section." : "Confirm the chapter and scene structure before reviewing detected characters.")</p>
|
||||
</div>
|
||||
|
||||
@if (TempData["OnboardingReviewMessage"] is string reviewMessage)
|
||||
@ -193,6 +193,15 @@
|
||||
</div>
|
||||
|
||||
<aside class="onboarding-review-section" data-scan-review-panel="characters" hidden="@(!isCharacterStep)">
|
||||
<div class="onboarding-subprogress" aria-label="Character review progress">
|
||||
<div>
|
||||
<span>Character page @characterPage of @primaryCharacterPageCount</span>
|
||||
<strong>@Math.Min(characterPage * characterPageSize, primaryCharacters.Count).ToString("N0") / @primaryCharacters.Count.ToString("N0") reviewed</strong>
|
||||
</div>
|
||||
<div class="onboarding-progress-track">
|
||||
<span style="width:@((characterPage * 100) / primaryCharacterPageCount)%"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="onboarding-review-section-heading">
|
||||
<div>
|
||||
<h2>@characterPageTitle</h2>
|
||||
|
||||
@ -1,16 +1,15 @@
|
||||
@model OnboardingJourneyHeaderViewModel
|
||||
@{
|
||||
var characterReviewStepCount = Math.Max(1, Model.TotalSteps - 10);
|
||||
var steps = Model.TotalSteps <= 10
|
||||
? BuildDefaultSteps()
|
||||
: BuildDynamicSteps(characterReviewStepCount, Model.TotalSteps);
|
||||
var progressWidth = Model.TotalSteps <= 1
|
||||
var steps = BuildDefaultSteps();
|
||||
var totalSteps = Math.Max(Model.TotalSteps, steps.Count);
|
||||
var currentStep = Math.Clamp(Model.CurrentStep, 1, totalSteps);
|
||||
var progressWidth = totalSteps <= 1
|
||||
? 100
|
||||
: ((Math.Clamp(Model.CurrentStep, 1, Model.TotalSteps) - 1) * 100) / (Model.TotalSteps - 1);
|
||||
: ((currentStep - 1) * 100) / (totalSteps - 1);
|
||||
}
|
||||
|
||||
<div class="onboarding-progress" aria-label="@Model.AriaLabel">
|
||||
<span>Step @Model.CurrentStep of @Model.TotalSteps</span>
|
||||
<span>Step @currentStep of @totalSteps</span>
|
||||
<div class="onboarding-progress-track">
|
||||
<span style="width:@progressWidth%"></span>
|
||||
</div>
|
||||
@ -18,7 +17,7 @@
|
||||
<ol class="onboarding-stepper" style="--onboarding-step-count:@steps.Count" aria-label="Manuscript import journey">
|
||||
@foreach (var step in steps)
|
||||
{
|
||||
<li class="@StepClass(step.Order, Model.CurrentStep)">
|
||||
<li class="@StepClass(step.Order, currentStep)">
|
||||
<span>@step.Order</span>
|
||||
<strong>@step.Label</strong>
|
||||
</li>
|
||||
@ -38,34 +37,11 @@
|
||||
new("Connect Word", 6),
|
||||
new("Review Chapters", 7),
|
||||
new("Review Characters", 8),
|
||||
new("Story Context", 9),
|
||||
new("Complete", 10)
|
||||
new("Story Summary", 9),
|
||||
new("Story Context", 10),
|
||||
new("Complete", 11)
|
||||
];
|
||||
|
||||
private static IReadOnlyList<HeaderStep> BuildDynamicSteps(int characterReviewStepCount, int totalSteps)
|
||||
{
|
||||
var steps = new List<HeaderStep>
|
||||
{
|
||||
new("Welcome", 1),
|
||||
new("Writing Stage", 2),
|
||||
new("Writing Software", 3),
|
||||
new("Project", 4),
|
||||
new("Book", 5),
|
||||
new("Connect Word", 6),
|
||||
new("Review Chapters", 7)
|
||||
};
|
||||
|
||||
for (var index = 1; index <= characterReviewStepCount; index++)
|
||||
{
|
||||
steps.Add(new HeaderStep($"Characters {index}", 7 + index));
|
||||
}
|
||||
|
||||
steps.Add(new HeaderStep("Story Summary", totalSteps - 2));
|
||||
steps.Add(new HeaderStep("Story Context", totalSteps - 1));
|
||||
steps.Add(new HeaderStep("Complete", totalSteps));
|
||||
return steps;
|
||||
}
|
||||
|
||||
private static string StepClass(int order, int currentStep)
|
||||
{
|
||||
if (order < currentStep)
|
||||
|
||||
@ -514,6 +514,30 @@
|
||||
color: var(--bs-secondary-color);
|
||||
}
|
||||
|
||||
.onboarding-subprogress {
|
||||
display: grid;
|
||||
gap: .55rem;
|
||||
border: 1px solid rgba(47, 111, 99, .16);
|
||||
border-radius: 8px;
|
||||
padding: .8rem;
|
||||
background: rgba(47, 111, 99, .06);
|
||||
}
|
||||
|
||||
.onboarding-subprogress div:first-child {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
color: var(--bs-secondary-color);
|
||||
font-size: .86rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.onboarding-subprogress strong {
|
||||
color: var(--bs-body-color);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.onboarding-character-filter {
|
||||
display: grid;
|
||||
gap: .35rem;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user