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 TemporaryCharacterKey { get; init; } = string.Empty;
public string Name { get; init; } = string.Empty; public string Name { get; init; } = string.Empty;
public int MentionCount { get; init; } 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; } public string? SuggestedImportance { get; init; }
} }

View File

@ -485,13 +485,17 @@ public sealed class WordCompanionCharacterCandidateDto
public string Text { get; init; } = string.Empty; public string Text { get; init; } = string.Empty;
public int MentionCount { get; init; } public int MentionCount { get; init; }
public string Confidence { get; init; } = "Medium"; 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 string Reason { get; init; } = string.Empty;
public bool IsExistingCharacterMatch { get; init; }
} }
public sealed class WordCompanionDiscoverCharactersRequest public sealed class WordCompanionDiscoverCharactersRequest
{ {
public int ProjectId { get; set; } public int ProjectId { get; set; }
public string? DocumentText { get; set; } public string? DocumentText { get; set; }
public bool IncludeExcluded { get; set; }
} }
public sealed class WordCompanionDiscoverCharactersResponse public sealed class WordCompanionDiscoverCharactersResponse

View File

@ -153,7 +153,9 @@ public sealed class OnboardingService(
}) })
.ToList(), .ToList(),
CharacterCandidates = preview.CharacterCandidates CharacterCandidates = preview.CharacterCandidates
.OrderByDescending(candidate => candidate.MentionCount) .OrderByDescending(candidate => CandidateCategorySortValue(candidate.Category))
.ThenByDescending(candidate => candidate.QualityScore)
.ThenByDescending(candidate => candidate.MentionCount)
.ThenBy(candidate => candidate.Name) .ThenBy(candidate => candidate.Name)
.ToList() .ToList()
}; };
@ -507,6 +509,12 @@ public sealed class OnboardingService(
PreviewID = state.PreviewID 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) private static string? CleanOptional(string? value)
{ {
var cleaned = Clean(value); var cleaned = Clean(value);

View File

@ -42,26 +42,42 @@ public sealed class WordCompanionService(
IManuscriptDocumentRepository manuscriptDocuments, IManuscriptDocumentRepository manuscriptDocuments,
ICurrentUserService currentUser) : IWordCompanionService ICurrentUserService currentUser) : IWordCompanionService
{ {
private const int CharacterDiscoveryMinimumMentions = 5; private const int CharacterDiscoveryMinimumMentions = 2;
private const int CharacterDiscoverySuggestionLimit = 50; private const int CharacterDiscoverySuggestionLimit = 50;
private const int CharacterDiscoveryExcludedLimit = 30;
private static readonly Regex CharacterCandidateRegex = new( 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); RegexOptions.Compiled);
private static readonly HashSet<string> CharacterDiscoveryStopWords = new(StringComparer.OrdinalIgnoreCase) 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", "A", "An", "As", "At", "By", "He", "Her", "Hers", "Him", "His", "I", "If", "In", "It", "Its", "Me",
"It", "Its", "Of", "On", "Or", "Our", "She", "So", "That", "The", "Their", "Then", "There", "They", "Mine", "My", "Of", "On", "Our", "Ours", "She", "The", "Their", "Theirs", "They", "Them", "To", "Us",
"This", "To", "We", "When", "Where", "Who", "With", "You", "Your", "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", "Christmas", "Easter", "Morning", "Afternoon", "Evening", "Night", "Today", "Tomorrow", "Yesterday",
"Chapter", "Scene", "Part", "Prologue", "Epilogue", "Book", "Volume", "Act", "Tonight", "Tomorrow", "Yesterday", "God", "Something", "Anything", "Nothing", "Everything", "Everyone",
"Mum", "Dad", "Mother", "Father", "Grandma", "Grandad", "Nan", "Nana", "Granny", "Somebody", "Someone", "Anybody", "Nobody", "People", "Person", "Man", "Woman", "Boy", "Girl",
"Yes", "No", "Okay", "Ok", "Hello", "Goodbye", "Did", "Do", "Does", "Done", "Had", "Has", "Have", "Having", "Even", "All", "Fine", "Great", "Sure",
"What", "Not", "Just", "Yeah", "How", "Well", "Are", "Like", "One", "Can", "Thank", "Why", "Come", "Course", "Thing", "Things", "Still", "Err", "Er", "Erm", "Alright", "Allright", "Alrite", "New",
"Let", "Maybe", "Please", "God", "Something", "After", "Did", "Even", "All", "Nothing", "Have", "Somewhere", "Coke", "Like", "Don", "Isn"
"Don", "Won", "Could", "Would", "Should", "Do", "Does", "Had", "Has", "Was", "Were", "Is", "Am", };
"Right", "Really", "Now", "Look", "Listen", "Sorry", "Thanks", "Fine", "Great", "Sure", "Course", private static readonly HashSet<string> CharacterDiscoveryRelationshipTitles = new(StringComparer.OrdinalIgnoreCase)
"Thing", "Things", "Everyone", "Everything", "Anything", "Someone", "Nobody", "People", {
"Still", "Err", "Er", "Erm", "Alright", "Allright", "Alrite", "New", "Somewhere", "Coke" "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) 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", "said", "asked", "replied", "answered", "whispered", "shouted", "called", "muttered", "cried", "snapped",
"sighed", "laughed", "continued", "began", "told", "yelled" "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) private static readonly HashSet<string> CharacterDiscoveryLocationIndicators = new(StringComparer.OrdinalIgnoreCase)
{ {
"Road", "Lane", "Street", "Close", "Avenue", "Drive", "Way", "Hill", "Hospital", "Inn", "Church", "School", "Road", "Lane", "Street", "Close", "Avenue", "Drive", "Way", "Hill", "Hospital", "Inn", "Church", "School",
@ -512,7 +534,7 @@ public sealed class WordCompanionService(
return null; return null;
} }
var candidates = DiscoverCharacterCandidates(request.DocumentText ?? string.Empty); var candidates = DiscoverCharacterCandidates(request.DocumentText ?? string.Empty, request.IncludeExcluded);
return new WordCompanionDiscoverCharactersResponse return new WordCompanionDiscoverCharactersResponse
{ {
ProjectId = request.ProjectId, 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 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)) if (string.IsNullOrWhiteSpace(manuscriptText))
{ {
return []; return [];
} }
manuscriptText = NormalizeApostrophes(manuscriptText);
var candidates = new Dictionary<string, CharacterCandidateEvidence>(StringComparer.Ordinal); var candidates = new Dictionary<string, CharacterCandidateEvidence>(StringComparer.Ordinal);
foreach (Match match in CharacterCandidateRegex.Matches(manuscriptText)) foreach (Match match in CharacterCandidateRegex.Matches(manuscriptText))
{ {
var candidate = NormalizeCandidateName(match.Value); var candidate = NormalizeCandidateName(match.Value);
if (!IsLikelyCharacterCandidate(candidate)) if (string.IsNullOrWhiteSpace(candidate))
{ {
continue; continue;
} }
@ -583,50 +606,71 @@ public sealed class WordCompanionService(
{ {
evidence.DialogueEvidenceCount += 1; 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 var builtCandidates = candidates.Values
.Where(item => item.MentionCount >= CharacterDiscoveryMinimumMentions)
.Select(BuildCandidateDto) .Select(BuildCandidateDto)
.Where(item => item is not null) .Where(item => item is not null)
.Select(item => item!) .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) .ThenByDescending(item => item.MentionCount)
.ThenBy(item => item.Text, StringComparer.OrdinalIgnoreCase) .ThenBy(item => item.Text, StringComparer.OrdinalIgnoreCase)
.Take(CharacterDiscoverySuggestionLimit) .Take(CharacterDiscoverySuggestionLimit)
.ToList(); .ToList();
}
private static bool IsLikelyCharacterCandidate(string candidate) if (includeExcluded)
{
if (string.IsNullOrWhiteSpace(candidate) || candidate.Length > 200)
{ {
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); return visible;
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;
} }
private static WordCompanionCharacterCandidateDto? BuildCandidateDto(CharacterCandidateEvidence evidence) 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 words = evidence.Text.Split(' ', StringSplitOptions.RemoveEmptyEntries);
var first = words.Length > 0 ? TrimHonorificPunctuation(words[0]) : string.Empty; 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 hasHonorific = words.Length > 1 && CharacterDiscoveryHonorifics.Contains(first);
var hasMultiWordName = words.Length > 1; var hasMultiWordName = words.Length > 1;
var hasNameLikeWord = words.Any(word => CharacterDiscoveryNameLikeWords.Contains(TrimHonorificPunctuation(word))); var hasNameLikeWord = words.Any(word => CharacterDiscoveryNameLikeWords.Contains(TrimHonorificPunctuation(word)));
var hasTemporalWord = words.Any(word => CharacterDiscoveryTemporalWords.Contains(TrimHonorificPunctuation(word))); var hasTemporalWord = words.Any(word => CharacterDiscoveryTemporalWords.Contains(TrimHonorificPunctuation(word)));
var hasLocationIndicator = words.Any(word => CharacterDiscoveryLocationIndicators.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 nonSentenceStartMentions = evidence.MentionCount - evidence.SentenceStartMentions;
var mostlySentenceStart = evidence.MentionCount > 0 && evidence.SentenceStartMentions >= Math.Ceiling(evidence.MentionCount * 0.8m); var mostlySentenceStart = evidence.MentionCount > 0 && evidence.SentenceStartMentions >= Math.Ceiling(evidence.MentionCount * 0.8m);
var score = 0;
if (evidence.MentionCount >= CharacterDiscoveryMinimumMentions) if (evidence.MentionCount >= CharacterDiscoveryMinimumMentions)
{ {
score += 1; score += 1;
@ -635,6 +679,10 @@ public sealed class WordCompanionService(
{ {
score += 1; score += 1;
} }
if (evidence.MentionCount >= 25)
{
score += 1;
}
if (nonSentenceStartMentions > 0) if (nonSentenceStartMentions > 0)
{ {
score += 2; score += 2;
@ -651,6 +699,14 @@ public sealed class WordCompanionService(
{ {
score += 4; score += 4;
} }
if (evidence.PossessiveEvidenceCount > 0)
{
score += 2;
}
if (evidence.HumanActionEvidenceCount > 0)
{
score += 2;
}
if (hasNameLikeWord) if (hasNameLikeWord)
{ {
score += 3; score += 3;
@ -667,43 +723,107 @@ public sealed class WordCompanionService(
{ {
score -= 2; score -= 2;
} }
if (!hasMultiWordName && evidence.DialogueEvidenceCount == 0 && nonSentenceStartMentions == 0) if (!hasMultiWordName
&& evidence.DialogueEvidenceCount == 0
&& evidence.PossessiveEvidenceCount == 0
&& evidence.HumanActionEvidenceCount == 0
&& nonSentenceStartMentions == 0)
{ {
score -= 1; score -= 1;
} }
if (evidence.MentionCount < CharacterDiscoveryMinimumMentions)
if (score < 0 && !hasLocationIndicator)
{ {
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"; var confidence = score >= 4 ? "High" : score >= 2 ? "Medium" : "Low";
if (hasLocationIndicator && evidence.DialogueEvidenceCount == 0 && !hasHonorific) if (hasLocationIndicator && evidence.DialogueEvidenceCount == 0 && !hasHonorific)
{ {
confidence = score >= 4 ? "Medium" : "Low"; confidence = score >= 4 ? "Medium" : "Low";
} }
if (string.Equals(category, "Excluded", StringComparison.Ordinal))
{
confidence = "Low";
}
return new WordCompanionCharacterCandidateDto return new WordCompanionCharacterCandidateDto
{ {
Text = evidence.Text, Text = evidence.Text,
MentionCount = evidence.MentionCount, MentionCount = evidence.MentionCount,
Confidence = confidence, 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( private static string BuildCandidateReason(
CharacterCandidateEvidence evidence, CharacterCandidateEvidence evidence,
bool hasStopWord,
bool isRelationshipTitle,
bool hasHonorific, bool hasHonorific,
bool hasMultiWordName, bool hasMultiWordName,
bool hasTemporalWord, bool hasTemporalWord,
bool hasLocationIndicator, bool hasLocationIndicator,
bool mostlySentenceStart) bool mostlySentenceStart)
{ {
if (hasStopWord)
{
return "Excluded word";
}
if (isRelationshipTitle)
{
return "Relationship title";
}
if (evidence.DialogueEvidenceCount > 0) if (evidence.DialogueEvidenceCount > 0)
{ {
return "Dialogue evidence"; return "Dialogue evidence";
} }
if (evidence.PossessiveEvidenceCount > 0)
{
return "Possessive use";
}
if (evidence.HumanActionEvidenceCount > 0)
{
return "Human action nearby";
}
if (hasHonorific) if (hasHonorific)
{ {
return "Title or honorific"; return "Title or honorific";
@ -735,6 +855,14 @@ public sealed class WordCompanionService(
: 1; : 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) private static bool AppearsAtSentenceStart(string manuscriptText, int matchIndex)
{ {
for (var i = matchIndex - 1; i >= 0; i--) for (var i = matchIndex - 1; i >= 0; i--)
@ -768,6 +896,22 @@ public sealed class WordCompanionService(
return HasTagAfterCandidate(after) || HasTagBeforeCandidate(before); 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) private static bool HasTagAfterCandidate(string textAfterCandidate)
{ {
var match = Regex.Match(textAfterCandidate, @"^\s*,?\s*(?<tag>[a-z]+)\b", RegexOptions.IgnoreCase); 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) 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) private static string TrimHonorificPunctuation(string word)
@ -796,6 +945,8 @@ public sealed class WordCompanionService(
public int MentionCount { get; set; } public int MentionCount { get; set; }
public int SentenceStartMentions { get; set; } public int SentenceStartMentions { get; set; }
public int DialogueEvidenceCount { get; set; } public int DialogueEvidenceCount { get; set; }
public int PossessiveEvidenceCount { get; set; }
public int HumanActionEvidenceCount { get; set; }
} }
private static WordCompanionManuscriptDocumentDto ToDto(ManuscriptDocumentModel document) => new() private static WordCompanionManuscriptDocumentDto ToDto(ManuscriptDocumentModel document) => new()

View File

@ -1,6 +1,18 @@
@model ManuscriptScanReviewViewModel @model ManuscriptScanReviewViewModel
@{ @{
ViewData["Title"] = "Review manuscript scan"; 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"> <section class="onboarding-shell" aria-labelledby="scan-review-title">
@ -76,21 +88,81 @@
<aside class="onboarding-review-section"> <aside class="onboarding-review-section">
<h2>Character candidates</h2> <h2>Character candidates</h2>
@if (!Model.CharacterCandidates.Any()) @if (!probableCharacters.Any() && !possibleCharacters.Any() && !relationshipTitles.Any())
{ {
<p>No repeated character names were found yet.</p> <p>No repeated character names were found yet.</p>
} }
else else
{ {
<ul class="onboarding-character-candidates"> <div class="onboarding-character-groups">
@foreach (var candidate in Model.CharacterCandidates) <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> <section>
<strong>@candidate.Name</strong> <h3>Possible characters</h3>
<span>@candidate.MentionCount mention@(candidate.MentionCount == 1 ? string.Empty : "s")</span> <ul class="onboarding-character-candidates">
</li> @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> </aside>
</section> </section>

View File

@ -360,7 +360,8 @@
} }
.onboarding-review-chapter ol, .onboarding-review-chapter ol,
.onboarding-character-candidates { .onboarding-character-candidates,
.onboarding-character-groups {
display: grid; display: grid;
gap: .65rem; gap: .65rem;
margin: 0; margin: 0;
@ -393,6 +394,31 @@
gap: .75rem; 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 { .onboarding-summary div {
display: flex; display: flex;
align-items: center; align-items: center;

View File

@ -1893,38 +1893,20 @@
}; };
}; };
const properNameStopWords = new Set([ const onboardingCandidateValue = (candidate, camel, pascal) => candidate?.[camel] ?? candidate?.[pascal] ?? null;
"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 candidateCharacterNames = (text) => { const mapOnboardingCharacterCandidates = (candidates) => (Array.isArray(candidates) ? candidates : [])
const counts = new Map(); .map((candidate, index) => ({
const matches = String(text || "").match(/\b[A-Z][a-z]+(?:\s+[A-Z][a-z]+){0,2}\b/g) || []; temporaryCharacterKey: `character-${index + 1}`,
for (const match of matches) { name: onboardingCandidateValue(candidate, "text", "Text") || onboardingCandidateValue(candidate, "name", "Name") || "",
const name = match.trim(); mentionCount: Number.parseInt(onboardingCandidateValue(candidate, "mentionCount", "MentionCount") || "0", 10) || 0,
const parts = name.split(/\s+/); qualityScore: Number.parseInt(onboardingCandidateValue(candidate, "qualityScore", "QualityScore") || "0", 10) || 0,
if (parts.some((part) => properNameStopWords.has(part)) || name.length < 3) { category: onboardingCandidateValue(candidate, "category", "Category") || "PossibleCharacter",
continue; reason: onboardingCandidateValue(candidate, "reason", "Reason") || "",
} isExistingCharacterMatch: !!onboardingCandidateValue(candidate, "isExistingCharacterMatch", "IsExistingCharacterMatch"),
suggestedImportance: null
counts.set(name, (counts.get(name) || 0) + 1); }))
} .filter((candidate) => candidate.name);
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 buildOnboardingScanPreview = (paragraphs, command) => { const buildOnboardingScanPreview = (paragraphs, command) => {
const chapters = []; 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."); 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 { return {
previewID: "00000000-0000-0000-0000-000000000000", previewID: "00000000-0000-0000-0000-000000000000",
userID: command.userID || command.UserID || 0, userID: command.userID || command.UserID || 0,
@ -2045,11 +2027,12 @@
totalWordCount, totalWordCount,
chapterCount: chapters.length, chapterCount: chapters.length,
sceneCount: scenes.length, sceneCount: scenes.length,
characterCandidateCount: characterCandidates.length, characterCandidateCount: 0,
createdUtc: new Date().toISOString(), createdUtc: new Date().toISOString(),
chapters, chapters,
scenes, scenes,
characterCandidates characterCandidates: [],
documentTextForDiscovery
}; };
}; };
@ -2111,6 +2094,23 @@
await reportOnboardingScanProgress(command, "Detecting scene breaks", 50, preview); await reportOnboardingScanProgress(command, "Detecting scene breaks", 50, preview);
await reportOnboardingScanProgress(command, `Scene ${preview.sceneCount} of ${preview.sceneCount} scanned`, 70, preview); await reportOnboardingScanProgress(command, `Scene ${preview.sceneCount} of ${preview.sceneCount} scanned`, 70, preview);
await reportOnboardingScanProgress(command, "Finding character candidates", 85, 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 reportOnboardingScanProgress(command, "Preparing preview", 95, preview);
await companionPresenceConnection.invoke("CompleteOnboardingScan", preview); await companionPresenceConnection.invoke("CompleteOnboardingScan", preview);
} catch (error) { } catch (error) {

File diff suppressed because one or more lines are too long