Phase 20D.1 – Character Candidate Quality Pass prompt.
This commit is contained in:
parent
a70527f3a4
commit
c8302f2967
@ -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; }
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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) {
|
||||
|
||||
26
PlotLine/wwwroot/js/word-companion-host.min.js
vendored
26
PlotLine/wwwroot/js/word-companion-host.min.js
vendored
File diff suppressed because one or more lines are too long
Loading…
x
Reference in New Issue
Block a user