Phase 20D.1 – Character Candidate Quality Pass prompt.

This commit is contained in:
Nick Beckley 2026-07-03 22:45:55 +01:00
parent a70527f3a4
commit c8302f2967
8 changed files with 350 additions and 109 deletions

View File

@ -88,6 +88,10 @@ public sealed class ManuscriptScanCharacterCandidatePreview
public string TemporaryCharacterKey { get; init; } = string.Empty;
public string Name { get; init; } = string.Empty;
public int MentionCount { get; init; }
public int QualityScore { get; init; }
public string Category { get; init; } = "PossibleCharacter";
public string? Reason { get; init; }
public bool IsExistingCharacterMatch { get; init; }
public string? SuggestedImportance { get; init; }
}

View File

@ -485,13 +485,17 @@ public sealed class WordCompanionCharacterCandidateDto
public string Text { get; init; } = string.Empty;
public int MentionCount { get; init; }
public string Confidence { get; init; } = "Medium";
public int QualityScore { get; init; }
public string Category { get; init; } = "PossibleCharacter";
public string Reason { get; init; } = string.Empty;
public bool IsExistingCharacterMatch { get; init; }
}
public sealed class WordCompanionDiscoverCharactersRequest
{
public int ProjectId { get; set; }
public string? DocumentText { get; set; }
public bool IncludeExcluded { get; set; }
}
public sealed class WordCompanionDiscoverCharactersResponse

View File

@ -153,7 +153,9 @@ public sealed class OnboardingService(
})
.ToList(),
CharacterCandidates = preview.CharacterCandidates
.OrderByDescending(candidate => candidate.MentionCount)
.OrderByDescending(candidate => CandidateCategorySortValue(candidate.Category))
.ThenByDescending(candidate => candidate.QualityScore)
.ThenByDescending(candidate => candidate.MentionCount)
.ThenBy(candidate => candidate.Name)
.ToList()
};
@ -507,6 +509,12 @@ public sealed class OnboardingService(
PreviewID = state.PreviewID
};
private static int CandidateCategorySortValue(string? category)
=> string.Equals(category, "ProbableCharacter", StringComparison.OrdinalIgnoreCase) ? 4
: string.Equals(category, "PossibleCharacter", StringComparison.OrdinalIgnoreCase) ? 3
: string.Equals(category, "RelationshipTitle", StringComparison.OrdinalIgnoreCase) ? 2
: 1;
private static string? CleanOptional(string? value)
{
var cleaned = Clean(value);

View File

@ -42,26 +42,42 @@ public sealed class WordCompanionService(
IManuscriptDocumentRepository manuscriptDocuments,
ICurrentUserService currentUser) : IWordCompanionService
{
private const int CharacterDiscoveryMinimumMentions = 5;
private const int CharacterDiscoveryMinimumMentions = 2;
private const int CharacterDiscoverySuggestionLimit = 50;
private const int CharacterDiscoveryExcludedLimit = 30;
private static readonly Regex CharacterCandidateRegex = new(
@"(?<!['\p{L}])(?:(?:Mr|Mrs|Ms|Miss|Dr|Rev|Fr|Sir|Lady|Lord|DS|DI|DC|PC|Professor|Aunt|Uncle)\.?\s+)?[A-Z][a-z]+(?:[-'][A-Z]?[a-z]+)?(?:\s+[A-Z][a-z]+(?:[-'][A-Z]?[a-z]+)?){0,2}(?!['\p{L}])",
@"(?<!['\p{L}])(?:(?:Mr|Mrs|Ms|Miss|Dr|Rev|Fr|Sir|Lady|Lord|DS|DI|DC|PC|Professor|Aunt|Uncle)\.?\s+)?[A-Z][a-z]+(?:[-'][A-Z][a-z]+)?(?:\s+[A-Z][a-z]+(?:[-'][A-Z][a-z]+)?){0,2}(?=(?:[']s)?(?!\p{L})|(?!(?:[']))[^\p{L}]|$)",
RegexOptions.Compiled);
private static readonly HashSet<string> CharacterDiscoveryStopWords = new(StringComparer.OrdinalIgnoreCase)
{
"A", "An", "And", "As", "At", "But", "By", "For", "From", "He", "Her", "His", "I", "If", "In", "Into",
"It", "Its", "Of", "On", "Or", "Our", "She", "So", "That", "The", "Their", "Then", "There", "They",
"This", "To", "We", "When", "Where", "Who", "With", "You", "Your",
"A", "An", "As", "At", "By", "He", "Her", "Hers", "Him", "His", "I", "If", "In", "It", "Its", "Me",
"Mine", "My", "Of", "On", "Our", "Ours", "She", "The", "Their", "Theirs", "They", "Them", "To", "Us",
"We", "You", "Your", "Yours",
"What", "When", "Where", "Why", "Who", "How", "Which", "Whose", "Whom",
"Yes", "No", "Yeah", "Yep", "Nope", "Okay", "OK", "Ok", "Well", "Right",
"There", "Here", "This", "That", "These", "Those", "Then", "Now",
"Not", "Just", "Only", "Very", "Really", "Maybe", "Perhaps", "Probably",
"Are", "Is", "Was", "Were", "Be", "Been", "Being", "Am",
"Can", "Could", "Should", "Would", "Will", "Won", "Shall", "May", "Might", "Must",
"For", "From", "With", "Without", "Into", "Onto", "Over", "Under", "Between", "Before", "After",
"During", "Through", "Around", "About",
"And", "But", "Or", "So", "Because", "Though", "Although",
"One", "Two", "Three", "First", "Second", "Third",
"Come", "Go", "Get", "Got", "Let", "Look", "See", "Tell", "Ask", "Asked", "Said", "Say", "Think",
"Thought", "Know", "Knew", "Want", "Wanted", "Need", "Needed",
"Please", "Thank", "Thanks", "Sorry", "Hello", "Hi", "Hey", "Goodbye", "Bye",
"Chapter", "Scene", "Book", "Part", "Page", "Prologue", "Epilogue", "Volume", "Act",
"Christmas", "Easter", "Morning", "Afternoon", "Evening", "Night", "Today", "Tomorrow", "Yesterday",
"Chapter", "Scene", "Part", "Prologue", "Epilogue", "Book", "Volume", "Act",
"Mum", "Dad", "Mother", "Father", "Grandma", "Grandad", "Nan", "Nana", "Granny",
"Yes", "No", "Okay", "Ok", "Hello", "Goodbye",
"What", "Not", "Just", "Yeah", "How", "Well", "Are", "Like", "One", "Can", "Thank", "Why", "Come",
"Let", "Maybe", "Please", "God", "Something", "After", "Did", "Even", "All", "Nothing", "Have",
"Don", "Won", "Could", "Would", "Should", "Do", "Does", "Had", "Has", "Was", "Were", "Is", "Am",
"Right", "Really", "Now", "Look", "Listen", "Sorry", "Thanks", "Fine", "Great", "Sure", "Course",
"Thing", "Things", "Everyone", "Everything", "Anything", "Someone", "Nobody", "People",
"Still", "Err", "Er", "Erm", "Alright", "Allright", "Alrite", "New", "Somewhere", "Coke"
"Tonight", "Tomorrow", "Yesterday", "God", "Something", "Anything", "Nothing", "Everything", "Everyone",
"Somebody", "Someone", "Anybody", "Nobody", "People", "Person", "Man", "Woman", "Boy", "Girl",
"Did", "Do", "Does", "Done", "Had", "Has", "Have", "Having", "Even", "All", "Fine", "Great", "Sure",
"Course", "Thing", "Things", "Still", "Err", "Er", "Erm", "Alright", "Allright", "Alrite", "New",
"Somewhere", "Coke", "Like", "Don", "Isn"
};
private static readonly HashSet<string> CharacterDiscoveryRelationshipTitles = new(StringComparer.OrdinalIgnoreCase)
{
"Mum", "Dad", "Mother", "Father", "Gran", "Grandma", "Grandad", "Nan", "Nana", "Granny", "Brother",
"Sister", "Aunt", "Auntie", "Uncle", "Cousin"
};
private static readonly HashSet<string> CharacterDiscoveryTemporalWords = new(StringComparer.OrdinalIgnoreCase)
{
@ -83,6 +99,12 @@ public sealed class WordCompanionService(
"said", "asked", "replied", "answered", "whispered", "shouted", "called", "muttered", "cried", "snapped",
"sighed", "laughed", "continued", "began", "told", "yelled"
};
private static readonly HashSet<string> CharacterDiscoveryHumanActionVerbs = new(StringComparer.OrdinalIgnoreCase)
{
"walked", "turned", "smiled", "looked", "stared", "nodded", "sat", "stood", "stepped", "ran", "came",
"went", "opened", "closed", "held", "took", "put", "gave", "grabbed", "shrugged", "frowned", "laughed",
"sighed", "watched", "waited", "leaned", "knelt", "waved", "pointed", "followed", "paused", "breathed"
};
private static readonly HashSet<string> CharacterDiscoveryLocationIndicators = new(StringComparer.OrdinalIgnoreCase)
{
"Road", "Lane", "Street", "Close", "Avenue", "Drive", "Way", "Hill", "Hospital", "Inn", "Church", "School",
@ -512,7 +534,7 @@ public sealed class WordCompanionService(
return null;
}
var candidates = DiscoverCharacterCandidates(request.DocumentText ?? string.Empty);
var candidates = DiscoverCharacterCandidates(request.DocumentText ?? string.Empty, request.IncludeExcluded);
return new WordCompanionDiscoverCharactersResponse
{
ProjectId = request.ProjectId,
@ -551,18 +573,19 @@ public sealed class WordCompanionService(
private static bool HasInvalidIds(IEnumerable<int> ids) => ids.Any(id => id <= 0);
private static IReadOnlyList<WordCompanionCharacterCandidateDto> DiscoverCharacterCandidates(string manuscriptText)
private static IReadOnlyList<WordCompanionCharacterCandidateDto> DiscoverCharacterCandidates(string manuscriptText, bool includeExcluded = false)
{
if (string.IsNullOrWhiteSpace(manuscriptText))
{
return [];
}
manuscriptText = NormalizeApostrophes(manuscriptText);
var candidates = new Dictionary<string, CharacterCandidateEvidence>(StringComparer.Ordinal);
foreach (Match match in CharacterCandidateRegex.Matches(manuscriptText))
{
var candidate = NormalizeCandidateName(match.Value);
if (!IsLikelyCharacterCandidate(candidate))
if (string.IsNullOrWhiteSpace(candidate))
{
continue;
}
@ -583,50 +606,71 @@ public sealed class WordCompanionService(
{
evidence.DialogueEvidenceCount += 1;
}
if (HasPossessiveEvidence(manuscriptText, match.Index, match.Length))
{
evidence.PossessiveEvidenceCount += 1;
}
if (HasHumanActionEvidence(manuscriptText, match.Index, match.Length))
{
evidence.HumanActionEvidenceCount += 1;
}
}
return candidates.Values
.Where(item => item.MentionCount >= CharacterDiscoveryMinimumMentions)
var builtCandidates = candidates.Values
.Select(BuildCandidateDto)
.Where(item => item is not null)
.Select(item => item!)
.OrderByDescending(item => ConfidenceSortValue(item.Confidence))
.ToList();
var visible = builtCandidates
.Where(item => !string.Equals(item.Category, "Excluded", StringComparison.OrdinalIgnoreCase))
.OrderByDescending(item => CategorySortValue(item.Category))
.ThenByDescending(item => item.QualityScore)
.ThenByDescending(item => ConfidenceSortValue(item.Confidence))
.ThenByDescending(item => item.MentionCount)
.ThenBy(item => item.Text, StringComparer.OrdinalIgnoreCase)
.Take(CharacterDiscoverySuggestionLimit)
.ToList();
}
private static bool IsLikelyCharacterCandidate(string candidate)
{
if (string.IsNullOrWhiteSpace(candidate) || candidate.Length > 200)
if (includeExcluded)
{
return false;
visible.AddRange(builtCandidates
.Where(item => string.Equals(item.Category, "Excluded", StringComparison.OrdinalIgnoreCase))
.OrderByDescending(item => item.MentionCount)
.ThenBy(item => item.Text, StringComparer.OrdinalIgnoreCase)
.Take(CharacterDiscoveryExcludedLimit));
}
var words = candidate.Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (words.Length == 0 || words.Any(word => CharacterDiscoveryStopWords.Contains(TrimHonorificPunctuation(word))))
{
return false;
}
var first = TrimHonorificPunctuation(words[0]);
return words.Length > 1 || first.Length > 2;
return visible;
}
private static WordCompanionCharacterCandidateDto? BuildCandidateDto(CharacterCandidateEvidence evidence)
{
var score = 0;
if (string.IsNullOrWhiteSpace(evidence.Text) || evidence.Text.Length > 200)
{
return null;
}
var words = evidence.Text.Split(' ', StringSplitOptions.RemoveEmptyEntries);
var first = words.Length > 0 ? TrimHonorificPunctuation(words[0]) : string.Empty;
if (words.Length == 0)
{
return null;
}
var hasHonorific = words.Length > 1 && CharacterDiscoveryHonorifics.Contains(first);
var hasMultiWordName = words.Length > 1;
var hasNameLikeWord = words.Any(word => CharacterDiscoveryNameLikeWords.Contains(TrimHonorificPunctuation(word)));
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 isRelationshipTitle = words.Length == 1 && CharacterDiscoveryRelationshipTitles.Contains(first);
var nonSentenceStartMentions = evidence.MentionCount - evidence.SentenceStartMentions;
var mostlySentenceStart = evidence.MentionCount > 0 && evidence.SentenceStartMentions >= Math.Ceiling(evidence.MentionCount * 0.8m);
var score = 0;
if (evidence.MentionCount >= CharacterDiscoveryMinimumMentions)
{
score += 1;
@ -635,6 +679,10 @@ public sealed class WordCompanionService(
{
score += 1;
}
if (evidence.MentionCount >= 25)
{
score += 1;
}
if (nonSentenceStartMentions > 0)
{
score += 2;
@ -651,6 +699,14 @@ public sealed class WordCompanionService(
{
score += 4;
}
if (evidence.PossessiveEvidenceCount > 0)
{
score += 2;
}
if (evidence.HumanActionEvidenceCount > 0)
{
score += 2;
}
if (hasNameLikeWord)
{
score += 3;
@ -667,43 +723,107 @@ public sealed class WordCompanionService(
{
score -= 2;
}
if (!hasMultiWordName && evidence.DialogueEvidenceCount == 0 && nonSentenceStartMentions == 0)
if (!hasMultiWordName
&& evidence.DialogueEvidenceCount == 0
&& evidence.PossessiveEvidenceCount == 0
&& evidence.HumanActionEvidenceCount == 0
&& nonSentenceStartMentions == 0)
{
score -= 1;
}
if (score < 0 && !hasLocationIndicator)
if (evidence.MentionCount < CharacterDiscoveryMinimumMentions)
{
return null;
score -= 2;
}
if (first.Length <= 2 && !hasHonorific)
{
score -= 3;
}
var category = CandidateCategory(score, hasStopWord, isRelationshipTitle, hasTemporalWord, hasLocationIndicator, evidence);
var confidence = score >= 4 ? "High" : score >= 2 ? "Medium" : "Low";
if (hasLocationIndicator && evidence.DialogueEvidenceCount == 0 && !hasHonorific)
{
confidence = score >= 4 ? "Medium" : "Low";
}
if (string.Equals(category, "Excluded", StringComparison.Ordinal))
{
confidence = "Low";
}
return new WordCompanionCharacterCandidateDto
{
Text = evidence.Text,
MentionCount = evidence.MentionCount,
Confidence = confidence,
Reason = BuildCandidateReason(evidence, hasHonorific, hasMultiWordName, hasTemporalWord, hasLocationIndicator, mostlySentenceStart)
QualityScore = score,
Category = category,
Reason = BuildCandidateReason(evidence, hasStopWord, isRelationshipTitle, hasHonorific, hasMultiWordName, hasTemporalWord, hasLocationIndicator, mostlySentenceStart)
};
}
private static string CandidateCategory(
int score,
bool hasStopWord,
bool isRelationshipTitle,
bool hasTemporalWord,
bool hasLocationIndicator,
CharacterCandidateEvidence evidence)
{
if (hasStopWord)
{
return "Excluded";
}
if (isRelationshipTitle)
{
return "RelationshipTitle";
}
if (hasTemporalWord && evidence.DialogueEvidenceCount == 0 && evidence.PossessiveEvidenceCount == 0)
{
return "Excluded";
}
if (hasLocationIndicator && score < 4)
{
return "Excluded";
}
if (evidence.MentionCount < CharacterDiscoveryMinimumMentions && evidence.DialogueEvidenceCount == 0)
{
return "Excluded";
}
return score >= 4 ? "ProbableCharacter" : "PossibleCharacter";
}
private static string BuildCandidateReason(
CharacterCandidateEvidence evidence,
bool hasStopWord,
bool isRelationshipTitle,
bool hasHonorific,
bool hasMultiWordName,
bool hasTemporalWord,
bool hasLocationIndicator,
bool mostlySentenceStart)
{
if (hasStopWord)
{
return "Excluded word";
}
if (isRelationshipTitle)
{
return "Relationship title";
}
if (evidence.DialogueEvidenceCount > 0)
{
return "Dialogue evidence";
}
if (evidence.PossessiveEvidenceCount > 0)
{
return "Possessive use";
}
if (evidence.HumanActionEvidenceCount > 0)
{
return "Human action nearby";
}
if (hasHonorific)
{
return "Title or honorific";
@ -735,6 +855,14 @@ public sealed class WordCompanionService(
: 1;
}
private static int CategorySortValue(string category)
{
return string.Equals(category, "ProbableCharacter", StringComparison.OrdinalIgnoreCase) ? 4
: string.Equals(category, "PossibleCharacter", StringComparison.OrdinalIgnoreCase) ? 3
: string.Equals(category, "RelationshipTitle", StringComparison.OrdinalIgnoreCase) ? 2
: 1;
}
private static bool AppearsAtSentenceStart(string manuscriptText, int matchIndex)
{
for (var i = matchIndex - 1; i >= 0; i--)
@ -768,6 +896,22 @@ public sealed class WordCompanionService(
return HasTagAfterCandidate(after) || HasTagBeforeCandidate(before);
}
private static bool HasPossessiveEvidence(string manuscriptText, int matchIndex, int matchLength)
{
var afterStart = matchIndex + matchLength;
return afterStart + 1 < manuscriptText.Length
&& manuscriptText[afterStart] == '\''
&& char.ToLowerInvariant(manuscriptText[afterStart + 1]) == 's';
}
private static bool HasHumanActionEvidence(string manuscriptText, int matchIndex, int matchLength)
{
var afterStart = matchIndex + matchLength;
var after = manuscriptText[afterStart..Math.Min(manuscriptText.Length, afterStart + 36)];
var match = Regex.Match(after, @"^\s+(?<verb>[a-z]+)\b", RegexOptions.IgnoreCase);
return match.Success && CharacterDiscoveryHumanActionVerbs.Contains(match.Groups["verb"].Value);
}
private static bool HasTagAfterCandidate(string textAfterCandidate)
{
var match = Regex.Match(textAfterCandidate, @"^\s*,?\s*(?<tag>[a-z]+)\b", RegexOptions.IgnoreCase);
@ -782,7 +926,12 @@ public sealed class WordCompanionService(
private static string NormalizeCandidateName(string candidate)
{
return Regex.Replace(candidate ?? string.Empty, @"\s+", " ").Trim().Trim(',', '.', ';', ':', '!', '?', '"', '\'', '');
return Regex.Replace(NormalizeApostrophes(candidate), @"\s+", " ").Trim().Trim(',', '.', ';', ':', '!', '?', '"', '\'');
}
private static string NormalizeApostrophes(string? value)
{
return (value ?? string.Empty).Replace('', '\'').Replace('', '\'');
}
private static string TrimHonorificPunctuation(string word)
@ -796,6 +945,8 @@ public sealed class WordCompanionService(
public int MentionCount { get; set; }
public int SentenceStartMentions { get; set; }
public int DialogueEvidenceCount { get; set; }
public int PossessiveEvidenceCount { get; set; }
public int HumanActionEvidenceCount { get; set; }
}
private static WordCompanionManuscriptDocumentDto ToDto(ManuscriptDocumentModel document) => new()

View File

@ -1,6 +1,18 @@
@model ManuscriptScanReviewViewModel
@{
ViewData["Title"] = "Review manuscript scan";
var probableCharacters = Model.CharacterCandidates
.Where(candidate => string.Equals(candidate.Category, "ProbableCharacter", StringComparison.OrdinalIgnoreCase))
.ToList();
var possibleCharacters = Model.CharacterCandidates
.Where(candidate => string.Equals(candidate.Category, "PossibleCharacter", StringComparison.OrdinalIgnoreCase))
.ToList();
var relationshipTitles = Model.CharacterCandidates
.Where(candidate => string.Equals(candidate.Category, "RelationshipTitle", StringComparison.OrdinalIgnoreCase))
.ToList();
var excludedCharacters = Model.CharacterCandidates
.Where(candidate => string.Equals(candidate.Category, "Excluded", StringComparison.OrdinalIgnoreCase))
.ToList();
}
<section class="onboarding-shell" aria-labelledby="scan-review-title">
@ -76,21 +88,81 @@
<aside class="onboarding-review-section">
<h2>Character candidates</h2>
@if (!Model.CharacterCandidates.Any())
@if (!probableCharacters.Any() && !possibleCharacters.Any() && !relationshipTitles.Any())
{
<p>No repeated character names were found yet.</p>
}
else
{
<ul class="onboarding-character-candidates">
@foreach (var candidate in Model.CharacterCandidates)
<div class="onboarding-character-groups">
<section>
<h3>Probable characters</h3>
@if (!probableCharacters.Any())
{
<p>No high-confidence character names yet.</p>
}
else
{
<ul class="onboarding-character-candidates">
@foreach (var candidate in probableCharacters)
{
<li>
<strong>@candidate.Name</strong>
<span>@candidate.MentionCount mention@(candidate.MentionCount == 1 ? string.Empty : "s") / score @candidate.QualityScore</span>
</li>
}
</ul>
}
</section>
@if (possibleCharacters.Any())
{
<li>
<strong>@candidate.Name</strong>
<span>@candidate.MentionCount mention@(candidate.MentionCount == 1 ? string.Empty : "s")</span>
</li>
<section>
<h3>Possible characters</h3>
<ul class="onboarding-character-candidates">
@foreach (var candidate in possibleCharacters)
{
<li>
<strong>@candidate.Name</strong>
<span>@candidate.MentionCount mention@(candidate.MentionCount == 1 ? string.Empty : "s") / score @candidate.QualityScore</span>
</li>
}
</ul>
</section>
}
</ul>
@if (relationshipTitles.Any())
{
<section>
<h3>Relationship titles</h3>
<ul class="onboarding-character-candidates">
@foreach (var candidate in relationshipTitles)
{
<li>
<strong>@candidate.Name</strong>
<span>@candidate.MentionCount mention@(candidate.MentionCount == 1 ? string.Empty : "s")</span>
</li>
}
</ul>
</section>
}
@if (excludedCharacters.Any())
{
<details class="onboarding-excluded-candidates">
<summary>Show excluded candidates</summary>
<ul class="onboarding-character-candidates">
@foreach (var candidate in excludedCharacters)
{
<li>
<strong>@candidate.Name</strong>
<span>@candidate.MentionCount mention@(candidate.MentionCount == 1 ? string.Empty : "s") / @candidate.Reason</span>
</li>
}
</ul>
</details>
}
</div>
}
</aside>
</section>

View File

@ -360,7 +360,8 @@
}
.onboarding-review-chapter ol,
.onboarding-character-candidates {
.onboarding-character-candidates,
.onboarding-character-groups {
display: grid;
gap: .65rem;
margin: 0;
@ -393,6 +394,31 @@
gap: .75rem;
}
.onboarding-character-groups section {
display: grid;
gap: .5rem;
}
.onboarding-character-groups h3 {
margin: 0;
font-size: 1rem;
}
.onboarding-excluded-candidates {
border-top: 1px solid rgba(31, 42, 68, .12);
padding-top: .65rem;
}
.onboarding-excluded-candidates summary {
cursor: pointer;
color: var(--bs-secondary-color);
font-weight: 700;
}
.onboarding-excluded-candidates .onboarding-character-candidates {
margin-top: .65rem;
}
.onboarding-summary div {
display: flex;
align-items: center;

View File

@ -1893,38 +1893,20 @@
};
};
const properNameStopWords = new Set([
"Chapter", "Scene", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday",
"January", "February", "March", "April", "May", "June", "July", "August", "September", "October",
"November", "December", "The", "A", "An", "And", "But", "Or", "If", "Then", "When", "Where",
"This", "That", "These", "Those", "He", "She", "They", "We", "I", "You", "It", "His", "Her",
"Their", "Our", "Your", "My", "Word", "PlotDirector"
]);
const onboardingCandidateValue = (candidate, camel, pascal) => candidate?.[camel] ?? candidate?.[pascal] ?? null;
const candidateCharacterNames = (text) => {
const counts = new Map();
const matches = String(text || "").match(/\b[A-Z][a-z]+(?:\s+[A-Z][a-z]+){0,2}\b/g) || [];
for (const match of matches) {
const name = match.trim();
const parts = name.split(/\s+/);
if (parts.some((part) => properNameStopWords.has(part)) || name.length < 3) {
continue;
}
counts.set(name, (counts.get(name) || 0) + 1);
}
return [...counts.entries()]
.filter(([, count]) => count >= 2)
.sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0]))
.slice(0, 40)
.map(([name, count], index) => ({
temporaryCharacterKey: `character-${index + 1}`,
name,
mentionCount: count,
suggestedImportance: null
}));
};
const mapOnboardingCharacterCandidates = (candidates) => (Array.isArray(candidates) ? candidates : [])
.map((candidate, index) => ({
temporaryCharacterKey: `character-${index + 1}`,
name: onboardingCandidateValue(candidate, "text", "Text") || onboardingCandidateValue(candidate, "name", "Name") || "",
mentionCount: Number.parseInt(onboardingCandidateValue(candidate, "mentionCount", "MentionCount") || "0", 10) || 0,
qualityScore: Number.parseInt(onboardingCandidateValue(candidate, "qualityScore", "QualityScore") || "0", 10) || 0,
category: onboardingCandidateValue(candidate, "category", "Category") || "PossibleCharacter",
reason: onboardingCandidateValue(candidate, "reason", "Reason") || "",
isExistingCharacterMatch: !!onboardingCandidateValue(candidate, "isExistingCharacterMatch", "IsExistingCharacterMatch"),
suggestedImportance: null
}))
.filter((candidate) => candidate.name);
const buildOnboardingScanPreview = (paragraphs, command) => {
const chapters = [];
@ -2032,7 +2014,7 @@
throw new Error("No chapters were detected. Make sure your chapter headings use Heading 1, or add clear chapter headings.");
}
const characterCandidates = candidateCharacterNames(documentText.join("\n"));
const documentTextForDiscovery = documentText.join("\n");
return {
previewID: "00000000-0000-0000-0000-000000000000",
userID: command.userID || command.UserID || 0,
@ -2045,11 +2027,12 @@
totalWordCount,
chapterCount: chapters.length,
sceneCount: scenes.length,
characterCandidateCount: characterCandidates.length,
characterCandidateCount: 0,
createdUtc: new Date().toISOString(),
chapters,
scenes,
characterCandidates
characterCandidates: [],
documentTextForDiscovery
};
};
@ -2111,6 +2094,23 @@
await reportOnboardingScanProgress(command, "Detecting scene breaks", 50, preview);
await reportOnboardingScanProgress(command, `Scene ${preview.sceneCount} of ${preview.sceneCount} scanned`, 70, preview);
await reportOnboardingScanProgress(command, "Finding character candidates", 85, preview);
const documentTextForDiscovery = preview.documentTextForDiscovery || "";
delete preview.documentTextForDiscovery;
try {
const discovery = await postJson("/api/word-companion/manuscript/discover-characters", {
projectId: preview.projectID,
documentText: documentTextForDiscovery,
includeExcluded: true
});
preview.characterCandidates = mapOnboardingCharacterCandidates(discovery?.candidates);
preview.characterCandidateCount = preview.characterCandidates
.filter((candidate) => String(candidate.category || "").toLowerCase() !== "excluded")
.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 companionPresenceConnection.invoke("CompleteOnboardingScan", preview);
} catch (error) {

File diff suppressed because one or more lines are too long