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 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; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
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