Tighten manuscript character detection
This commit is contained in:
parent
43a57df070
commit
95f5c2925d
@ -66,6 +66,7 @@ var tests = new (string Name, Action Test)[]
|
|||||||
("Story Intelligence visual polish keeps labels fixed and semantics specific", StoryIntelligenceVisualPolishKeepsLabelsFixedAndSemanticsSpecific),
|
("Story Intelligence visual polish keeps labels fixed and semantics specific", StoryIntelligenceVisualPolishKeepsLabelsFixedAndSemanticsSpecific),
|
||||||
("Character sex hints are conservative and reviewable", CharacterSexHintsAreConservativeAndReviewable),
|
("Character sex hints are conservative and reviewable", CharacterSexHintsAreConservativeAndReviewable),
|
||||||
("Character discovery does not truncate scan review candidates", CharacterDiscoveryDoesNotTruncateScanReviewCandidates),
|
("Character discovery does not truncate scan review candidates", CharacterDiscoveryDoesNotTruncateScanReviewCandidates),
|
||||||
|
("Character discovery rejects sentence-start words", CharacterDiscoveryRejectsSentenceStartWords),
|
||||||
("Scan review aliases feed Story Intelligence known character context", ScanReviewAliasesFeedStoryIntelligenceKnownCharacterContext),
|
("Scan review aliases feed Story Intelligence known character context", ScanReviewAliasesFeedStoryIntelligenceKnownCharacterContext),
|
||||||
("Scan review post supports full-book form submissions", ScanReviewPostSupportsFullBookFormSubmissions),
|
("Scan review post supports full-book form submissions", ScanReviewPostSupportsFullBookFormSubmissions),
|
||||||
("Story Intelligence experience boot does not serialise live model", StoryIntelligenceExperienceBootDoesNotSerialiseLiveModel),
|
("Story Intelligence experience boot does not serialise live model", StoryIntelligenceExperienceBootDoesNotSerialiseLiveModel),
|
||||||
@ -1276,7 +1277,7 @@ static void CharacterDiscoveryDoesNotTruncateScanReviewCandidates()
|
|||||||
var builder = new StringBuilder();
|
var builder = new StringBuilder();
|
||||||
for (var index = 0; index < 60; index++)
|
for (var index = 0; index < 60; index++)
|
||||||
{
|
{
|
||||||
var name = $"Person{(char)('a' + index / 26)}{(char)('a' + index % 26)}";
|
var name = $"Mr Person{(char)('a' + index / 26)}{(char)('a' + index % 26)}";
|
||||||
builder.Append(name).Append(" said hello. ").Append(name).Append(" smiled. ");
|
builder.Append(name).Append(" said hello. ").Append(name).Append(" smiled. ");
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1284,6 +1285,25 @@ static void CharacterDiscoveryDoesNotTruncateScanReviewCandidates()
|
|||||||
Assert(candidates.Count >= 60, $"Character discovery should not truncate the scan review to 50 candidates; got {candidates.Count}.");
|
Assert(candidates.Count >= 60, $"Character discovery should not truncate the scan review to 50 candidates; got {candidates.Count}.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static void CharacterDiscoveryRejectsSentenceStartWords()
|
||||||
|
{
|
||||||
|
var method = typeof(WordCompanionService).GetMethod("DiscoverCharacterCandidates", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
|
||||||
|
Assert(method is not null, "Character discovery method could not be found.");
|
||||||
|
|
||||||
|
var text = """
|
||||||
|
Silence filled the room. Silence made everything worse.
|
||||||
|
Morning came too quickly. Morning dragged at the windows.
|
||||||
|
Eyes looked toward the door. Eyes followed the sound.
|
||||||
|
Colin said nothing. Debbie smiled at Colin. Mr Wilkinson opened the file.
|
||||||
|
""";
|
||||||
|
|
||||||
|
var candidates = (IReadOnlyList<WordCompanionCharacterCandidateDto>)method!.Invoke(null, [text, false])!;
|
||||||
|
Assert(candidates.Any(candidate => candidate.Text == "Colin"), "Real common character names should still be detected.");
|
||||||
|
Assert(candidates.Any(candidate => candidate.Text == "Debbie"), "Real common character names should still be detected.");
|
||||||
|
Assert(candidates.Any(candidate => candidate.Text == "Mr Wilkinson"), "Titled character names should still be detected.");
|
||||||
|
Assert(!candidates.Any(candidate => candidate.Text is "Silence" or "Morning" or "Eyes"), "Repeated sentence-start words must not become character candidates.");
|
||||||
|
}
|
||||||
|
|
||||||
static void StoryIntelligenceExperienceBootDoesNotSerialiseLiveModel()
|
static void StoryIntelligenceExperienceBootDoesNotSerialiseLiveModel()
|
||||||
{
|
{
|
||||||
var view = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Views/Development/StoryIntelligenceExperience.cshtml"));
|
var view = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Views/Development/StoryIntelligenceExperience.cshtml"));
|
||||||
|
|||||||
@ -260,7 +260,9 @@ public sealed class WordCompanionFollowHub(
|
|||||||
|| state.UsesWordCompanion == true;
|
|| state.UsesWordCompanion == true;
|
||||||
|
|
||||||
private static ManuscriptScanPreview NormalisePreviewContext(ManuscriptScanPreview preview, int userId, UserOnboardingState state)
|
private static ManuscriptScanPreview NormalisePreviewContext(ManuscriptScanPreview preview, int userId, UserOnboardingState state)
|
||||||
=> new()
|
{
|
||||||
|
var characterCandidates = WordCompanionService.DiscoverManuscriptScanCharacterCandidates(preview);
|
||||||
|
return new()
|
||||||
{
|
{
|
||||||
PreviewID = preview.PreviewID,
|
PreviewID = preview.PreviewID,
|
||||||
UserID = preview.UserID == 0 ? userId : preview.UserID,
|
UserID = preview.UserID == 0 ? userId : preview.UserID,
|
||||||
@ -273,10 +275,11 @@ public sealed class WordCompanionFollowHub(
|
|||||||
TotalWordCount = preview.TotalWordCount,
|
TotalWordCount = preview.TotalWordCount,
|
||||||
ChapterCount = preview.ChapterCount,
|
ChapterCount = preview.ChapterCount,
|
||||||
SceneCount = preview.SceneCount,
|
SceneCount = preview.SceneCount,
|
||||||
CharacterCandidateCount = preview.CharacterCandidateCount,
|
CharacterCandidateCount = characterCandidates.Count,
|
||||||
CreatedUtc = preview.CreatedUtc,
|
CreatedUtc = preview.CreatedUtc,
|
||||||
Chapters = preview.Chapters,
|
Chapters = preview.Chapters,
|
||||||
Scenes = preview.Scenes,
|
Scenes = preview.Scenes,
|
||||||
CharacterCandidates = preview.CharacterCandidates
|
CharacterCandidates = characterCandidates
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -646,6 +646,32 @@ public sealed class WordCompanionService(
|
|||||||
return visible;
|
return visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static IReadOnlyList<ManuscriptScanCharacterCandidatePreview> DiscoverManuscriptScanCharacterCandidates(ManuscriptScanPreview preview)
|
||||||
|
{
|
||||||
|
var manuscriptText = string.Join("\n\n", preview.Chapters
|
||||||
|
.OrderBy(chapter => chapter.ChapterNumber)
|
||||||
|
.Select(chapter => chapter.ChapterText)
|
||||||
|
.Where(text => !string.IsNullOrWhiteSpace(text)));
|
||||||
|
if (string.IsNullOrWhiteSpace(manuscriptText))
|
||||||
|
{
|
||||||
|
return FilterIncomingScanCharacterCandidates(preview.CharacterCandidates);
|
||||||
|
}
|
||||||
|
|
||||||
|
return DiscoverCharacterCandidates(manuscriptText)
|
||||||
|
.Select((candidate, index) => new ManuscriptScanCharacterCandidatePreview
|
||||||
|
{
|
||||||
|
TemporaryCharacterKey = $"c-{StableCandidateKey(candidate.Text, index)}",
|
||||||
|
Name = candidate.Text,
|
||||||
|
MentionCount = candidate.MentionCount,
|
||||||
|
QualityScore = candidate.QualityScore,
|
||||||
|
Category = candidate.Category,
|
||||||
|
Reason = candidate.Reason,
|
||||||
|
IsExistingCharacterMatch = candidate.IsExistingCharacterMatch,
|
||||||
|
SuggestedImportance = candidate.Confidence
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
private static WordCompanionCharacterCandidateDto? BuildCandidateDto(CharacterCandidateEvidence evidence)
|
private static WordCompanionCharacterCandidateDto? BuildCandidateDto(CharacterCandidateEvidence evidence)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(evidence.Text) || evidence.Text.Length > 200)
|
if (string.IsNullOrWhiteSpace(evidence.Text) || evidence.Text.Length > 200)
|
||||||
@ -667,6 +693,8 @@ public sealed class WordCompanionService(
|
|||||||
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 hasStopWord = words.Any(word => CharacterDiscoveryStopWords.Contains(TrimHonorificPunctuation(word)));
|
||||||
var isRelationshipTitle = words.Length == 1 && CharacterDiscoveryRelationshipTitles.Contains(first);
|
var isRelationshipTitle = words.Length == 1 && CharacterDiscoveryRelationshipTitles.Contains(first);
|
||||||
|
var isSingleWord = words.Length == 1;
|
||||||
|
var hasKnownGivenName = CharacterSexHintService.Suggest(first) != CharacterSexHintService.Unknown;
|
||||||
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);
|
||||||
|
|
||||||
@ -707,10 +735,19 @@ public sealed class WordCompanionService(
|
|||||||
{
|
{
|
||||||
score += 2;
|
score += 2;
|
||||||
}
|
}
|
||||||
if (hasNameLikeWord)
|
if (hasNameLikeWord || hasKnownGivenName)
|
||||||
{
|
{
|
||||||
score += 3;
|
score += 3;
|
||||||
}
|
}
|
||||||
|
if (isSingleWord
|
||||||
|
&& mostlySentenceStart
|
||||||
|
&& !hasKnownGivenName
|
||||||
|
&& !hasNameLikeWord
|
||||||
|
&& evidence.PossessiveEvidenceCount == 0
|
||||||
|
&& nonSentenceStartMentions == 0)
|
||||||
|
{
|
||||||
|
score -= 5;
|
||||||
|
}
|
||||||
if (hasTemporalWord)
|
if (hasTemporalWord)
|
||||||
{
|
{
|
||||||
score -= 1;
|
score -= 1;
|
||||||
@ -740,7 +777,7 @@ public sealed class WordCompanionService(
|
|||||||
score -= 3;
|
score -= 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
var category = CandidateCategory(score, hasStopWord, isRelationshipTitle, hasTemporalWord, hasLocationIndicator, evidence);
|
var category = CandidateCategory(score, hasStopWord, isRelationshipTitle, hasTemporalWord, hasLocationIndicator, evidence, isSingleWord, mostlySentenceStart, hasKnownGivenName || hasNameLikeWord, hasHonorific);
|
||||||
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)
|
||||||
{
|
{
|
||||||
@ -768,12 +805,24 @@ public sealed class WordCompanionService(
|
|||||||
bool isRelationshipTitle,
|
bool isRelationshipTitle,
|
||||||
bool hasTemporalWord,
|
bool hasTemporalWord,
|
||||||
bool hasLocationIndicator,
|
bool hasLocationIndicator,
|
||||||
CharacterCandidateEvidence evidence)
|
CharacterCandidateEvidence evidence,
|
||||||
|
bool isSingleWord,
|
||||||
|
bool mostlySentenceStart,
|
||||||
|
bool hasNameEvidence,
|
||||||
|
bool hasHonorific)
|
||||||
{
|
{
|
||||||
if (hasStopWord)
|
if (hasStopWord)
|
||||||
{
|
{
|
||||||
return "Excluded";
|
return "Excluded";
|
||||||
}
|
}
|
||||||
|
if (isSingleWord
|
||||||
|
&& mostlySentenceStart
|
||||||
|
&& !hasNameEvidence
|
||||||
|
&& evidence.PossessiveEvidenceCount == 0
|
||||||
|
&& evidence.MentionCount - evidence.SentenceStartMentions == 0)
|
||||||
|
{
|
||||||
|
return "Excluded";
|
||||||
|
}
|
||||||
if (isRelationshipTitle)
|
if (isRelationshipTitle)
|
||||||
{
|
{
|
||||||
return "RelationshipTitle";
|
return "RelationshipTitle";
|
||||||
@ -786,7 +835,10 @@ public sealed class WordCompanionService(
|
|||||||
{
|
{
|
||||||
return "Excluded";
|
return "Excluded";
|
||||||
}
|
}
|
||||||
if (evidence.MentionCount < CharacterDiscoveryMinimumMentions && evidence.DialogueEvidenceCount == 0)
|
if (evidence.MentionCount < CharacterDiscoveryMinimumMentions
|
||||||
|
&& evidence.DialogueEvidenceCount == 0
|
||||||
|
&& !hasHonorific
|
||||||
|
&& !(hasNameEvidence && (evidence.HumanActionEvidenceCount > 0 || evidence.PossessiveEvidenceCount > 0)))
|
||||||
{
|
{
|
||||||
return "Excluded";
|
return "Excluded";
|
||||||
}
|
}
|
||||||
@ -929,6 +981,29 @@ public sealed class WordCompanionService(
|
|||||||
return Regex.Replace(NormalizeApostrophes(candidate), @"\s+", " ").Trim().Trim(',', '.', ';', ':', '!', '?', '"', '\'');
|
return Regex.Replace(NormalizeApostrophes(candidate), @"\s+", " ").Trim().Trim(',', '.', ';', ':', '!', '?', '"', '\'');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<ManuscriptScanCharacterCandidatePreview> FilterIncomingScanCharacterCandidates(
|
||||||
|
IReadOnlyList<ManuscriptScanCharacterCandidatePreview> candidates)
|
||||||
|
=> candidates
|
||||||
|
.Where(candidate => !string.IsNullOrWhiteSpace(candidate.Name))
|
||||||
|
.Where(candidate => !string.Equals(candidate.Category, "Excluded", StringComparison.OrdinalIgnoreCase))
|
||||||
|
.Where(candidate =>
|
||||||
|
candidate.Name.Split(' ', StringSplitOptions.RemoveEmptyEntries).Length > 1
|
||||||
|
|| candidate.QualityScore >= 4
|
||||||
|
|| CharacterSexHintService.Suggest(candidate.Name) != CharacterSexHintService.Unknown)
|
||||||
|
.GroupBy(candidate => NormalizeCandidateName(candidate.Name), StringComparer.OrdinalIgnoreCase)
|
||||||
|
.Select(group => group.OrderByDescending(candidate => candidate.QualityScore).ThenByDescending(candidate => candidate.MentionCount).First())
|
||||||
|
.OrderByDescending(candidate => CategorySortValue(candidate.Category))
|
||||||
|
.ThenByDescending(candidate => candidate.QualityScore)
|
||||||
|
.ThenByDescending(candidate => candidate.MentionCount)
|
||||||
|
.ThenBy(candidate => candidate.Name, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
private static string StableCandidateKey(string name, int index)
|
||||||
|
{
|
||||||
|
var slug = Regex.Replace(name.ToLowerInvariant(), @"[^a-z0-9]+", "-").Trim('-');
|
||||||
|
return string.IsNullOrWhiteSpace(slug) ? $"candidate-{index + 1}" : slug;
|
||||||
|
}
|
||||||
|
|
||||||
private static string NormalizeApostrophes(string? value)
|
private static string NormalizeApostrophes(string? value)
|
||||||
{
|
{
|
||||||
return (value ?? string.Empty).Replace('’', '\'').Replace('‘', '\'');
|
return (value ?? string.Empty).Replace('’', '\'').Replace('‘', '\'');
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user