Phase 20U – Final Entity Resolution Refinement Before Import
This commit is contained in:
parent
6325fbfec5
commit
7403cdc1f3
@ -6,7 +6,9 @@ public static class StoryImportResolutionStatuses
|
|||||||
public const string NewCharacter = "New Character";
|
public const string NewCharacter = "New Character";
|
||||||
public const string NewLocation = "New Location";
|
public const string NewLocation = "New Location";
|
||||||
public const string NewAsset = "New Asset";
|
public const string NewAsset = "New Asset";
|
||||||
|
public const string PossibleMatch = "Possible Match";
|
||||||
public const string PossibleDuplicate = "Possible Duplicate";
|
public const string PossibleDuplicate = "Possible Duplicate";
|
||||||
|
public const string PossibleDuplicateAliasCandidate = "Possible Duplicate / Alias Candidate";
|
||||||
public const string Ambiguous = "Ambiguous";
|
public const string Ambiguous = "Ambiguous";
|
||||||
public const string UnresolvedPersonReference = "Unresolved Person Reference";
|
public const string UnresolvedPersonReference = "Unresolved Person Reference";
|
||||||
public const string GenericReference = "Generic Reference";
|
public const string GenericReference = "Generic Reference";
|
||||||
@ -20,6 +22,7 @@ public sealed class StoryIntelligenceImportPreview
|
|||||||
public IReadOnlyList<StoryImportEntityResolution> Assets { get; init; } = [];
|
public IReadOnlyList<StoryImportEntityResolution> Assets { get; init; } = [];
|
||||||
public IReadOnlyList<StoryImportRelationshipPreview> Relationships { get; init; } = [];
|
public IReadOnlyList<StoryImportRelationshipPreview> Relationships { get; init; } = [];
|
||||||
public IReadOnlyList<StoryImportKnowledgePreview> Knowledge { get; init; } = [];
|
public IReadOnlyList<StoryImportKnowledgePreview> Knowledge { get; init; } = [];
|
||||||
|
public IReadOnlyList<StoryImportKnowledgePreview> UnresolvedKnowledge { get; init; } = [];
|
||||||
public IReadOnlyList<StoryImportTimelinePreview> Timeline { get; init; } = [];
|
public IReadOnlyList<StoryImportTimelinePreview> Timeline { get; init; } = [];
|
||||||
public IReadOnlyList<string> Warnings { get; init; } = [];
|
public IReadOnlyList<string> Warnings { get; init; } = [];
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
using PlotLine.Data;
|
using PlotLine.Data;
|
||||||
using PlotLine.Models;
|
using PlotLine.Models;
|
||||||
|
|
||||||
@ -25,10 +26,13 @@ public sealed class SceneImportResolver(
|
|||||||
"the receptionist", "receptionist", "old man", "young woman", "police officer", "doctor", "nurse", "driver",
|
"the receptionist", "receptionist", "old man", "young woman", "police officer", "doctor", "nurse", "driver",
|
||||||
"man", "woman", "women", "boy", "girl", "child", "children", "person", "people", "guard", "security guard",
|
"man", "woman", "women", "boy", "girl", "child", "children", "person", "people", "guard", "security guard",
|
||||||
"cashier", "elderly attendant", "attendant", "driver of car behind", "shop assistant", "crowd", "family",
|
"cashier", "elderly attendant", "attendant", "driver of car behind", "shop assistant", "crowd", "family",
|
||||||
"group of women", "small group", "women (small group)", "waiter", "waitress", "clerk", "shopkeeper");
|
"group of women", "small group", "women (small group)", "waiter", "waitress", "clerk", "shopkeeper", "elderly cashier");
|
||||||
|
|
||||||
private static readonly HashSet<string> IgnoredPovNames = CreateSet("narrator", "unknown");
|
private static readonly HashSet<string> IgnoredPovNames = CreateSet("narrator", "unknown");
|
||||||
|
|
||||||
|
private static readonly HashSet<string> ParentReferences = CreateSet(
|
||||||
|
"mum", "mother", "mam", "mom", "mummy", "mommy", "ma");
|
||||||
|
|
||||||
private static readonly HashSet<string> GenericLocations = CreateSet(
|
private static readonly HashSet<string> GenericLocations = CreateSet(
|
||||||
"car park", "house", "kitchen", "stairwell", "stair", "doorway", "corridor", "room",
|
"car park", "house", "kitchen", "stairwell", "stair", "doorway", "corridor", "room",
|
||||||
"bedroom", "bathroom", "office", "reception", "sixth floor", "floor", "car", "hall", "table", "chair");
|
"bedroom", "bathroom", "office", "reception", "sixth floor", "floor", "car", "hall", "table", "chair");
|
||||||
@ -36,12 +40,17 @@ public sealed class SceneImportResolver(
|
|||||||
private static readonly HashSet<string> EnvironmentalAssets = CreateSet(
|
private static readonly HashSet<string> EnvironmentalAssets = CreateSet(
|
||||||
"pink towel", "mug of tea", "magazine", "fire door", "old oak door", "iron handle", "reception desk",
|
"pink towel", "mug of tea", "magazine", "fire door", "old oak door", "iron handle", "reception desk",
|
||||||
"stained-glass windows", "stained glass windows", "chair", "desk", "window", "windows", "cup", "floor",
|
"stained-glass windows", "stained glass windows", "chair", "desk", "window", "windows", "cup", "floor",
|
||||||
"wall", "door", "table", "towel", "mug", "handle");
|
"wall", "door", "table", "towel", "mug", "handle", "telephone", "phone", "keys", "key");
|
||||||
|
|
||||||
private static readonly HashSet<string> StoryAssetNames = CreateSet(
|
private static readonly HashSet<string> StoryAssetNames = CreateSet(
|
||||||
"car", "vehicle", "passport", "letter", "key", "photograph", "photo", "bank book", "bank account",
|
"passport", "letter", "photograph", "photo", "bank book", "bank account",
|
||||||
"necklace", "weapon", "folder", "document", "diary", "telephone number", "phone number", "scrap of paper", "wallet");
|
"necklace", "weapon", "folder", "document", "diary", "telephone number", "phone number", "scrap of paper", "wallet");
|
||||||
|
|
||||||
|
private static readonly string[] ParentheticalLocationSuffixes =
|
||||||
|
[
|
||||||
|
"reception", "sixth floor", "sixth floor reception", "sixth-floor reception", "office", "lobby", "corridor", "stairwell"
|
||||||
|
];
|
||||||
|
|
||||||
public async Task<StoryIntelligenceImportPreview> ResolveAsync(StoryIntelligenceSavedRun run, IReadOnlyList<StoryIntelligenceSavedSceneResult> sceneResults)
|
public async Task<StoryIntelligenceImportPreview> ResolveAsync(StoryIntelligenceSavedRun run, IReadOnlyList<StoryIntelligenceSavedSceneResult> sceneResults)
|
||||||
{
|
{
|
||||||
if (run.ProjectID is not int projectId)
|
if (run.ProjectID is not int projectId)
|
||||||
@ -68,7 +77,8 @@ public sealed class SceneImportResolver(
|
|||||||
var locationResolutions = ResolveLocations(scenes, locationIndex).ToList();
|
var locationResolutions = ResolveLocations(scenes, locationIndex).ToList();
|
||||||
var assetResolutions = ResolveAssets(scenes, assetIndex).ToList();
|
var assetResolutions = ResolveAssets(scenes, assetIndex).ToList();
|
||||||
var relationshipPreview = ResolveRelationships(scenes, existingRelationships, characterResolutions).ToList();
|
var relationshipPreview = ResolveRelationships(scenes, existingRelationships, characterResolutions).ToList();
|
||||||
var knowledgePreview = ResolveKnowledge(scenes).ToList();
|
var knowledgePreview = ResolveKnowledge(scenes, characterResolutions, includeResolved: true).ToList();
|
||||||
|
var unresolvedKnowledgePreview = ResolveKnowledge(scenes, characterResolutions, includeResolved: false).ToList();
|
||||||
var timelinePreview = ResolveTimeline(scenes).ToList();
|
var timelinePreview = ResolveTimeline(scenes).ToList();
|
||||||
var warnings = BuildWarnings(characterResolutions, locationResolutions, assetResolutions).ToList();
|
var warnings = BuildWarnings(characterResolutions, locationResolutions, assetResolutions).ToList();
|
||||||
|
|
||||||
@ -79,6 +89,7 @@ public sealed class SceneImportResolver(
|
|||||||
Assets = assetResolutions,
|
Assets = assetResolutions,
|
||||||
Relationships = relationshipPreview,
|
Relationships = relationshipPreview,
|
||||||
Knowledge = knowledgePreview,
|
Knowledge = knowledgePreview,
|
||||||
|
UnresolvedKnowledge = unresolvedKnowledgePreview,
|
||||||
Timeline = timelinePreview,
|
Timeline = timelinePreview,
|
||||||
Warnings = warnings
|
Warnings = warnings
|
||||||
};
|
};
|
||||||
@ -86,48 +97,90 @@ public sealed class SceneImportResolver(
|
|||||||
|
|
||||||
private IEnumerable<StoryImportEntityResolution> ResolveCharacters(IEnumerable<SceneIntelligenceScene> scenes, EntityIndex index)
|
private IEnumerable<StoryImportEntityResolution> ResolveCharacters(IEnumerable<SceneIntelligenceScene> scenes, EntityIndex index)
|
||||||
{
|
{
|
||||||
foreach (var item in scenes
|
var mentions = scenes
|
||||||
.SelectMany(scene => (scene.Characters ?? []).Select(character => new CharacterMention(character.Name, character.Confidence, character.Notes)))
|
.SelectMany(scene => (scene.Characters ?? []).Select(character => new CharacterMention(
|
||||||
.Concat(scenes.Select(scene => new CharacterMention(scene.PointOfView?.CharacterName, scene.PointOfView?.Confidence, "Point of view character.")))
|
character.Name,
|
||||||
|
character.Confidence,
|
||||||
|
character.Notes,
|
||||||
|
SceneNumber(scene),
|
||||||
|
string.Join(" ", (character.Actions ?? []).Where(action => !string.IsNullOrWhiteSpace(action))))))
|
||||||
|
.Concat(scenes.Select(scene => new CharacterMention(
|
||||||
|
scene.PointOfView?.CharacterName,
|
||||||
|
scene.PointOfView?.Confidence,
|
||||||
|
"Point of view character.",
|
||||||
|
SceneNumber(scene),
|
||||||
|
string.Empty)))
|
||||||
.Where(item => !string.IsNullOrWhiteSpace(item.Name))
|
.Where(item => !string.IsNullOrWhiteSpace(item.Name))
|
||||||
.GroupBy(item => Clean(item.Name), StringComparer.OrdinalIgnoreCase)
|
.GroupBy(item => Clean(item.Name), StringComparer.OrdinalIgnoreCase)
|
||||||
.Select(group => group.First()))
|
.Select(group => new CharacterMentionGroup(
|
||||||
|
group.Key,
|
||||||
|
group.Max(item => item.Confidence),
|
||||||
|
string.Join(" ", group.Select(item => item.Notes).Where(note => !string.IsNullOrWhiteSpace(note))),
|
||||||
|
group.Select(item => item.SceneNumber).Where(number => number.HasValue).Select(number => number!.Value).Distinct().Order().ToList(),
|
||||||
|
string.Join(" ", group.Select(item => item.Context).Where(context => !string.IsNullOrWhiteSpace(context)))))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var resolutions = new List<StoryImportEntityResolution>();
|
||||||
|
foreach (var item in mentions)
|
||||||
{
|
{
|
||||||
var name = Clean(item.Name);
|
var name = Clean(item.Name);
|
||||||
if (IgnoredPovNames.Contains(name))
|
if (IgnoredPovNames.Contains(name))
|
||||||
{
|
{
|
||||||
yield return LogResolution(new StoryImportEntityResolution
|
resolutions.Add(LogResolution(new StoryImportEntityResolution
|
||||||
{
|
{
|
||||||
InputName = name,
|
InputName = name,
|
||||||
EntityType = "Character",
|
EntityType = "Character",
|
||||||
Status = StoryImportResolutionStatuses.Ignored,
|
Status = StoryImportResolutionStatuses.Ignored,
|
||||||
Confidence = item.Confidence,
|
Confidence = item.Confidence,
|
||||||
Notes = "Narrator identity is intentionally left for later chapter metadata resolution."
|
Notes = "Narrator identity is intentionally left for later chapter metadata resolution."
|
||||||
});
|
}));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (IsGenericPerson(name))
|
if (IsGenericPerson(name))
|
||||||
{
|
{
|
||||||
yield return LogResolution(new StoryImportEntityResolution
|
resolutions.Add(LogResolution(new StoryImportEntityResolution
|
||||||
{
|
{
|
||||||
InputName = name,
|
InputName = name,
|
||||||
EntityType = "Character",
|
EntityType = "Character",
|
||||||
Status = StoryImportResolutionStatuses.UnresolvedPersonReference,
|
Status = StoryImportResolutionStatuses.UnresolvedPersonReference,
|
||||||
Confidence = item.Confidence,
|
Confidence = item.Confidence,
|
||||||
Notes = "Rejected: generic unnamed role; would not create a Character record."
|
Notes = IsCompoundGenericPerson(name)
|
||||||
});
|
? "Rejected compound generic person: all phrase parts are generic person roles."
|
||||||
|
: "Rejected: generic unnamed role; would not create a Character record."
|
||||||
|
}));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
yield return LogResolution(MatchEntity("Character", name, item.Confidence, index, StoryImportResolutionStatuses.NewCharacter));
|
var resolution = MatchEntity("Character", name, item.Confidence, index, StoryImportResolutionStatuses.NewCharacter);
|
||||||
|
if (resolution.Status == StoryImportResolutionStatuses.NewCharacter && IsParentReference(name))
|
||||||
|
{
|
||||||
|
var candidates = index.FindParentCandidates();
|
||||||
|
if (candidates.Count == 1)
|
||||||
|
{
|
||||||
|
resolution = WithStatusAndNotes(resolution, StoryImportResolutionStatuses.PossibleMatch, "Family-role reference may match an existing parent/mother figure; not resolved automatically.");
|
||||||
|
resolution = WithCandidates(resolution, candidates);
|
||||||
|
}
|
||||||
|
else if (candidates.Count > 1)
|
||||||
|
{
|
||||||
|
resolution = WithStatusAndNotes(resolution, StoryImportResolutionStatuses.Ambiguous, "Family-role reference may match more than one existing parent/mother figure; not resolved automatically.");
|
||||||
|
resolution = WithCandidates(resolution, candidates);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resolutions.Add(LogResolution(resolution));
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var resolution in AddAliasCandidates(resolutions, mentions))
|
||||||
|
{
|
||||||
|
yield return resolution;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private IEnumerable<StoryImportEntityResolution> ResolveLocations(IEnumerable<SceneIntelligenceScene> scenes, EntityIndex index)
|
private IEnumerable<StoryImportEntityResolution> ResolveLocations(IEnumerable<SceneIntelligenceScene> scenes, EntityIndex index)
|
||||||
{
|
{
|
||||||
foreach (var group in scenes
|
foreach (var group in scenes
|
||||||
.SelectMany(scene => scene.Locations ?? [])
|
.SelectMany(scene => (scene.Locations ?? []).SelectMany(ExpandLocationMention))
|
||||||
.Where(location => !string.IsNullOrWhiteSpace(location.Name))
|
.Where(location => !string.IsNullOrWhiteSpace(location.Name))
|
||||||
.GroupBy(location => NormaliseLocationName(location.Name), StringComparer.OrdinalIgnoreCase))
|
.GroupBy(location => NormaliseLocationName(location.Name), StringComparer.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
@ -148,12 +201,34 @@ public sealed class SceneImportResolver(
|
|||||||
|
|
||||||
var resolution = MatchEntity("Location", group.Key, item.Confidence, index, StoryImportResolutionStatuses.NewLocation);
|
var resolution = MatchEntity("Location", group.Key, item.Confidence, index, StoryImportResolutionStatuses.NewLocation);
|
||||||
var variants = group.Select(location => Clean(location.Name)).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
|
var variants = group.Select(location => Clean(location.Name)).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
|
||||||
if (!string.Equals(group.Key, name, StringComparison.OrdinalIgnoreCase) || variants.Count > 1)
|
var internalAreas = group.Select(location => InternalLocationQualifier(location.Name)).Where(value => !string.IsNullOrWhiteSpace(value)).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
|
||||||
|
var routeSources = group.Select(location => location.Notes).Where(note => !string.IsNullOrWhiteSpace(note) && note.Contains("Grouped road reference", StringComparison.OrdinalIgnoreCase)).Distinct().ToList();
|
||||||
|
if (!string.Equals(group.Key, name, StringComparison.OrdinalIgnoreCase) || variants.Count > 1 || internalAreas.Count > 0 || routeSources.Count > 0)
|
||||||
{
|
{
|
||||||
resolution = resolution.Status == StoryImportResolutionStatuses.NewLocation && variants.Count > 1
|
var notes = new List<string>();
|
||||||
? WithStatusAndNotes(resolution, StoryImportResolutionStatuses.PossibleDuplicate, $"Possible duplicate location variant(s): {string.Join(", ", variants)}.")
|
if (variants.Count > 1 || !string.Equals(group.Key, name, StringComparison.OrdinalIgnoreCase))
|
||||||
: WithNotes(resolution, $"Merged location variant(s): {string.Join(", ", variants)}.");
|
{
|
||||||
logger.LogInformation("Merged Location {InputName} -> {NormalisedName}. Reason={Reason}", name, group.Key, "Location normalisation.");
|
notes.Add($"Folded location variant(s): {string.Join(", ", variants)}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (internalAreas.Count > 0)
|
||||||
|
{
|
||||||
|
notes.Add($"Internal area note(s): {string.Join(", ", internalAreas)}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (routeSources.Count > 0)
|
||||||
|
{
|
||||||
|
notes.Add(string.Join(" ", routeSources));
|
||||||
|
}
|
||||||
|
|
||||||
|
resolution = WithNotes(resolution, string.Join(" ", notes));
|
||||||
|
logger.LogInformation("Folded location variant: {InputName} -> {NormalisedName}. Reason={Reason}", name, group.Key, "Parenthetical/internal qualifier or grouped route reference.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resolution.Status == StoryImportResolutionStatuses.NewLocation && IsNamedRoad(group.Key))
|
||||||
|
{
|
||||||
|
resolution = WithNotes(resolution, AppendNote(resolution.Notes, "Accepted named road: proper road/street name."));
|
||||||
|
logger.LogInformation("Accepted named road: {InputName}. Reason={Reason}", group.Key, "Proper road/street name.");
|
||||||
}
|
}
|
||||||
|
|
||||||
yield return LogResolution(resolution);
|
yield return LogResolution(resolution);
|
||||||
@ -165,12 +240,13 @@ public sealed class SceneImportResolver(
|
|||||||
foreach (var item in scenes
|
foreach (var item in scenes
|
||||||
.SelectMany(scene => scene.Assets ?? [])
|
.SelectMany(scene => scene.Assets ?? [])
|
||||||
.Where(asset => !string.IsNullOrWhiteSpace(asset.Name))
|
.Where(asset => !string.IsNullOrWhiteSpace(asset.Name))
|
||||||
.GroupBy(asset => Clean(asset.Name), StringComparer.OrdinalIgnoreCase)
|
.GroupBy(asset => NormaliseAssetName(asset.Name), StringComparer.OrdinalIgnoreCase)
|
||||||
.Select(group => group.First()))
|
.Select(group => group.First()))
|
||||||
{
|
{
|
||||||
var name = Clean(item.Name);
|
var name = NormaliseAssetName(item.Name);
|
||||||
if (IsRejectedAsset(item))
|
if (IsRejectedAsset(item))
|
||||||
{
|
{
|
||||||
|
logger.LogInformation("Rejected generic asset: {InputName}. Reason={Reason}", name, "Generic object without owner/name/significance.");
|
||||||
yield return LogResolution(new StoryImportEntityResolution
|
yield return LogResolution(new StoryImportEntityResolution
|
||||||
{
|
{
|
||||||
InputName = name,
|
InputName = name,
|
||||||
@ -182,6 +258,7 @@ public sealed class SceneImportResolver(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.LogInformation("Accepted story asset: {InputName}. Reason={Reason}", name, "Specific owner/name/type/significance.");
|
||||||
yield return LogResolution(MatchEntity("Asset", name, item.Confidence, index, StoryImportResolutionStatuses.NewAsset));
|
yield return LogResolution(MatchEntity("Asset", name, item.Confidence, index, StoryImportResolutionStatuses.NewAsset));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -191,10 +268,7 @@ public sealed class SceneImportResolver(
|
|||||||
IReadOnlyList<CharacterRelationship> existingRelationships,
|
IReadOnlyList<CharacterRelationship> existingRelationships,
|
||||||
IReadOnlyList<StoryImportEntityResolution> characterResolutions)
|
IReadOnlyList<StoryImportEntityResolution> characterResolutions)
|
||||||
{
|
{
|
||||||
var resolvedCharacters = characterResolutions
|
var resolvedCharacters = BuildResolvedCharacterNameMap(characterResolutions, IsRelationshipEligibleCharacter);
|
||||||
.Where(item => item.Status == StoryImportResolutionStatuses.Matched)
|
|
||||||
.GroupBy(item => Clean(item.InputName), StringComparer.OrdinalIgnoreCase)
|
|
||||||
.ToDictionary(group => group.Key, group => group.First().MatchedName ?? group.First().InputName, StringComparer.OrdinalIgnoreCase);
|
|
||||||
|
|
||||||
var grouped = scenes
|
var grouped = scenes
|
||||||
.SelectMany(scene => (scene.Relationships ?? []).Select(relationship => new
|
.SelectMany(scene => (scene.Relationships ?? []).Select(relationship => new
|
||||||
@ -229,17 +303,41 @@ public sealed class SceneImportResolver(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static IEnumerable<StoryImportKnowledgePreview> ResolveKnowledge(IEnumerable<SceneIntelligenceScene> scenes)
|
private static IEnumerable<StoryImportKnowledgePreview> ResolveKnowledge(
|
||||||
=> scenes.SelectMany(scene => scene.KnowledgeChanges ?? [])
|
IEnumerable<SceneIntelligenceScene> scenes,
|
||||||
.Where(change => !string.IsNullOrWhiteSpace(change.RecipientCharacter) && !string.IsNullOrWhiteSpace(change.KnowledgeItem))
|
IReadOnlyList<StoryImportEntityResolution> characterResolutions,
|
||||||
.Select(change => new StoryImportKnowledgePreview
|
bool includeResolved)
|
||||||
{
|
{
|
||||||
Character = change.RecipientCharacter!,
|
var characterStatus = BuildResolvedCharacterStatusMap(characterResolutions);
|
||||||
|
|
||||||
|
foreach (var change in scenes.SelectMany(scene => scene.KnowledgeChanges ?? [])
|
||||||
|
.Where(change => !string.IsNullOrWhiteSpace(change.RecipientCharacter) && !string.IsNullOrWhiteSpace(change.KnowledgeItem)))
|
||||||
|
{
|
||||||
|
var recipient = Clean(change.RecipientCharacter);
|
||||||
|
var isResolved = characterStatus.TryGetValue(recipient, out var resolution)
|
||||||
|
&& IsKnowledgeEligibleCharacter(resolution);
|
||||||
|
|
||||||
|
if (IgnoredPovNames.Contains(recipient))
|
||||||
|
{
|
||||||
|
isResolved = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isResolved != includeResolved)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
yield return new StoryImportKnowledgePreview
|
||||||
|
{
|
||||||
|
Action = includeResolved ? "Would Update Knowledge" : "Unresolved Knowledge Reference",
|
||||||
|
Character = isResolved && resolution is not null ? RelationshipDisplayName(resolution) : recipient,
|
||||||
KnowledgeItem = change.KnowledgeItem!,
|
KnowledgeItem = change.KnowledgeItem!,
|
||||||
OldState = "Unknown",
|
OldState = "Unknown",
|
||||||
NewState = string.IsNullOrWhiteSpace(change.ChangeType) ? "Would update" : change.ChangeType!,
|
NewState = string.IsNullOrWhiteSpace(change.ChangeType) ? "Would update" : change.ChangeType!,
|
||||||
Confidence = change.Confidence
|
Confidence = change.Confidence
|
||||||
});
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static IEnumerable<StoryImportTimelinePreview> ResolveTimeline(IEnumerable<SceneIntelligenceScene> scenes)
|
private static IEnumerable<StoryImportTimelinePreview> ResolveTimeline(IEnumerable<SceneIntelligenceScene> scenes)
|
||||||
=> scenes.SelectMany(scene => (scene.TimelineClues ?? []).Select(clue => new StoryImportTimelinePreview
|
=> scenes.SelectMany(scene => (scene.TimelineClues ?? []).Select(clue => new StoryImportTimelinePreview
|
||||||
@ -292,6 +390,83 @@ public sealed class SceneImportResolver(
|
|||||||
Notes = notes
|
Notes = notes
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private static StoryImportEntityResolution WithCandidates(StoryImportEntityResolution resolution, IReadOnlyList<StoryImportResolutionCandidate> candidates)
|
||||||
|
=> new()
|
||||||
|
{
|
||||||
|
InputName = resolution.InputName,
|
||||||
|
Status = resolution.Status,
|
||||||
|
EntityType = resolution.EntityType,
|
||||||
|
MatchedName = resolution.MatchedName,
|
||||||
|
MatchedID = resolution.MatchedID,
|
||||||
|
Confidence = resolution.Confidence,
|
||||||
|
Candidates = candidates,
|
||||||
|
Notes = resolution.Notes
|
||||||
|
};
|
||||||
|
|
||||||
|
private IEnumerable<StoryImportEntityResolution> AddAliasCandidates(
|
||||||
|
IReadOnlyList<StoryImportEntityResolution> resolutions,
|
||||||
|
IReadOnlyList<CharacterMentionGroup> mentions)
|
||||||
|
{
|
||||||
|
var hiddenAliasNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
var aliasCandidates = new List<StoryImportEntityResolution>();
|
||||||
|
var byName = mentions.ToDictionary(item => Clean(item.Name), StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
foreach (var pair in BuildAliasPairs(resolutions, byName))
|
||||||
|
{
|
||||||
|
hiddenAliasNames.Add(pair.First.InputName);
|
||||||
|
hiddenAliasNames.Add(pair.Second.InputName);
|
||||||
|
var inputName = $"{pair.First.InputName} / {pair.Second.InputName}";
|
||||||
|
var confidence = new[] { pair.First.Confidence, pair.Second.Confidence }.Where(value => value.HasValue).DefaultIfEmpty().Max();
|
||||||
|
aliasCandidates.Add(LogResolution(new StoryImportEntityResolution
|
||||||
|
{
|
||||||
|
InputName = inputName,
|
||||||
|
EntityType = "Character",
|
||||||
|
Status = StoryImportResolutionStatuses.PossibleDuplicateAliasCandidate,
|
||||||
|
Confidence = confidence,
|
||||||
|
Notes = "Possible alias candidate: both names appear in the same conversation and source notes suggest alternate naming."
|
||||||
|
}));
|
||||||
|
logger.LogInformation("Possible alias candidate: {InputName}. Reason={Reason}", inputName, "Both names appear in same conversation and source notes suggest alternate naming.");
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var resolution in resolutions.Where(resolution => !hiddenAliasNames.Contains(resolution.InputName)))
|
||||||
|
{
|
||||||
|
yield return resolution;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var candidate in aliasCandidates)
|
||||||
|
{
|
||||||
|
yield return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IEnumerable<(StoryImportEntityResolution First, StoryImportEntityResolution Second)> BuildAliasPairs(
|
||||||
|
IReadOnlyList<StoryImportEntityResolution> resolutions,
|
||||||
|
IReadOnlyDictionary<string, CharacterMentionGroup> mentionsByName)
|
||||||
|
{
|
||||||
|
var newCharacters = resolutions
|
||||||
|
.Where(item => item.Status == StoryImportResolutionStatuses.NewCharacter)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
for (var i = 0; i < newCharacters.Count; i++)
|
||||||
|
{
|
||||||
|
for (var j = i + 1; j < newCharacters.Count; j++)
|
||||||
|
{
|
||||||
|
var first = newCharacters[i];
|
||||||
|
var second = newCharacters[j];
|
||||||
|
if (!mentionsByName.TryGetValue(Clean(first.InputName), out var firstMention)
|
||||||
|
|| !mentionsByName.TryGetValue(Clean(second.InputName), out var secondMention))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (IsAliasCandidate(first.InputName, second.InputName, firstMention, secondMention))
|
||||||
|
{
|
||||||
|
yield return (first, second);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<EntityIndex> BuildCharacterIndexAsync(int projectId)
|
private async Task<EntityIndex> BuildCharacterIndexAsync(int projectId)
|
||||||
{
|
{
|
||||||
var rows = new List<EntityIndexRow>();
|
var rows = new List<EntityIndexRow>();
|
||||||
@ -406,27 +581,68 @@ public sealed class SceneImportResolver(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static bool IsGenericLocation(SceneIntelligenceLocation location)
|
private static bool IsGenericLocation(SceneIntelligenceLocation location)
|
||||||
=> string.Equals(location.LocationType, "generic", StringComparison.OrdinalIgnoreCase)
|
{
|
||||||
|| GenericLocations.Contains(Clean(location.Name).ToLowerInvariant());
|
var name = Clean(location.Name);
|
||||||
|
var normalised = NormaliseLocationName(name);
|
||||||
|
return !IsNamedRoad(name)
|
||||||
|
&& GenericLocations.Contains(name.ToLowerInvariant())
|
||||||
|
&& string.Equals(normalised, name, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
private static bool IsGenericPerson(string name)
|
private static bool IsGenericPerson(string name)
|
||||||
{
|
{
|
||||||
var clean = Clean(name).ToLowerInvariant();
|
var clean = Clean(name).ToLowerInvariant();
|
||||||
return GenericPeople.Contains(clean)
|
return GenericPeople.Contains(clean)
|
||||||
|| clean.StartsWith("the ", StringComparison.OrdinalIgnoreCase) && GenericPeople.Contains(clean[4..])
|
|| clean.StartsWith("the ", StringComparison.OrdinalIgnoreCase) && GenericPeople.Contains(clean[4..])
|
||||||
|
|| IsCompoundGenericPerson(clean)
|
||||||
|| clean.Contains(" group", StringComparison.OrdinalIgnoreCase)
|
|| clean.Contains(" group", StringComparison.OrdinalIgnoreCase)
|
||||||
|| clean.Contains("crowd", StringComparison.OrdinalIgnoreCase);
|
|| clean.Contains("crowd", StringComparison.OrdinalIgnoreCase);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool IsCompoundGenericPerson(string name)
|
||||||
|
{
|
||||||
|
var clean = StripParenthetical(Clean(name)).ToLowerInvariant();
|
||||||
|
var parts = Regex.Split(clean, @"\s*(/|,|\band\b|\bor\b)\s*")
|
||||||
|
.Where(part => !string.IsNullOrWhiteSpace(part) && part is not "/" and not "," and not "and" and not "or")
|
||||||
|
.Select(part => RemoveLeadingArticle(part.Trim()))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return parts.Count > 1 && parts.All(part => GenericPeople.Contains(part));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsParentReference(string name)
|
||||||
|
=> ParentReferences.Contains(RemoveLeadingArticle(Clean(name)).ToLowerInvariant());
|
||||||
|
|
||||||
|
private static bool IsAliasCandidate(string firstName, string secondName, CharacterMentionGroup first, CharacterMentionGroup second)
|
||||||
|
{
|
||||||
|
var sameOrAdjacentScene = first.SceneNumbers.Any(firstScene => second.SceneNumbers.Any(secondScene => Math.Abs(firstScene - secondScene) <= 1));
|
||||||
|
var combined = $"{firstName} {secondName} {first.Notes} {first.Context} {second.Notes} {second.Context}";
|
||||||
|
var hasAliasLanguage = combined.Contains("I use Susie", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| combined.Contains("called herself", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| combined.Contains("real name", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| combined.Contains("also known as", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| combined.Contains("alias", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| combined.Contains("used the name", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| combined.Contains("Debbie", StringComparison.OrdinalIgnoreCase) && combined.Contains("Susie", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
return sameOrAdjacentScene && hasAliasLanguage;
|
||||||
|
}
|
||||||
|
|
||||||
private static bool IsRejectedAsset(SceneIntelligenceAsset asset)
|
private static bool IsRejectedAsset(SceneIntelligenceAsset asset)
|
||||||
{
|
{
|
||||||
var name = Clean(asset.Name).ToLowerInvariant();
|
var name = NormaliseAssetName(asset.Name);
|
||||||
if (EnvironmentalAssets.Contains(name))
|
var lower = name.ToLowerInvariant();
|
||||||
|
if (IsAcceptedStoryAsset(asset))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (EnvironmentalAssets.Contains(lower) || EnvironmentalAssets.Contains(RemoveLeadingArticle(lower)))
|
||||||
{
|
{
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (StoryAssetNames.Contains(RemoveLeadingArticle(name)))
|
if (StoryAssetNames.Contains(RemoveLeadingArticle(lower)))
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@ -435,6 +651,54 @@ public sealed class SceneImportResolver(
|
|||||||
return !ContainsStoryAssetSignal(signalText);
|
return !ContainsStoryAssetSignal(signalText);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool IsAcceptedStoryAsset(SceneIntelligenceAsset asset)
|
||||||
|
{
|
||||||
|
var name = NormaliseAssetName(asset.Name);
|
||||||
|
var lower = name.ToLowerInvariant();
|
||||||
|
var signalText = $"{name} {asset.Status} {asset.OwnerOrHolder} {asset.Notes} {asset.AssetType}".ToLowerInvariant();
|
||||||
|
|
||||||
|
if ((lower.Contains("'s car", StringComparison.OrdinalIgnoreCase) || lower.Contains("porsche", StringComparison.OrdinalIgnoreCase))
|
||||||
|
&& !string.Equals(RemoveLeadingArticle(lower), "car", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lower.Contains("bank account", StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& (HasParentheticalQualifier(name) || !string.IsNullOrWhiteSpace(asset.OwnerOrHolder)))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lower.Contains("shop keys", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| lower.Contains("telephone number", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| lower.Contains("phone number", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| lower.Contains("contact detail", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| lower.Contains("scrap of paper", StringComparison.OrdinalIgnoreCase) && lower.Contains("number", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lower.Contains("record", StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& (signalText.Contains("evidence", StringComparison.OrdinalIgnoreCase) || signalText.Contains("official", StringComparison.OrdinalIgnoreCase)))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lower.Contains("parking ticket", StringComparison.OrdinalIgnoreCase) && ContainsStoryAssetSignal(signalText))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lower.Contains("key", StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& !string.Equals(RemoveLeadingArticle(lower), "key", StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& !string.Equals(RemoveLeadingArticle(lower), "keys", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
private static bool ContainsStoryAssetSignal(string value)
|
private static bool ContainsStoryAssetSignal(string value)
|
||||||
=> value.Contains("sought", StringComparison.OrdinalIgnoreCase)
|
=> value.Contains("sought", StringComparison.OrdinalIgnoreCase)
|
||||||
|| value.Contains("lost", StringComparison.OrdinalIgnoreCase)
|
|| value.Contains("lost", StringComparison.OrdinalIgnoreCase)
|
||||||
@ -449,6 +713,12 @@ public sealed class SceneImportResolver(
|
|||||||
private static string NormaliseLocationName(string? value)
|
private static string NormaliseLocationName(string? value)
|
||||||
{
|
{
|
||||||
var clean = RemoveLeadingArticle(Clean(value));
|
var clean = RemoveLeadingArticle(Clean(value));
|
||||||
|
var parenthetical = InternalLocationQualifier(clean);
|
||||||
|
if (!string.IsNullOrWhiteSpace(parenthetical))
|
||||||
|
{
|
||||||
|
clean = StripParenthetical(clean);
|
||||||
|
}
|
||||||
|
|
||||||
var lower = clean.ToLowerInvariant();
|
var lower = clean.ToLowerInvariant();
|
||||||
var suffixes = new[]
|
var suffixes = new[]
|
||||||
{
|
{
|
||||||
@ -466,6 +736,95 @@ public sealed class SceneImportResolver(
|
|||||||
return clean;
|
return clean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static IEnumerable<SceneIntelligenceLocation> ExpandLocationMention(SceneIntelligenceLocation location)
|
||||||
|
{
|
||||||
|
var name = Clean(location.Name);
|
||||||
|
if (!name.Contains('/'))
|
||||||
|
{
|
||||||
|
yield return location;
|
||||||
|
yield break;
|
||||||
|
}
|
||||||
|
|
||||||
|
var parenthetical = InternalLocationQualifier(name);
|
||||||
|
var baseName = StripParenthetical(name);
|
||||||
|
var parts = baseName.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||||
|
.Select(Clean)
|
||||||
|
.Where(part => !string.IsNullOrWhiteSpace(part))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (parts.Count > 1 && parts.All(IsNamedRoad))
|
||||||
|
{
|
||||||
|
foreach (var part in parts)
|
||||||
|
{
|
||||||
|
yield return new SceneIntelligenceLocation
|
||||||
|
{
|
||||||
|
Name = part,
|
||||||
|
LocationType = location.LocationType,
|
||||||
|
GenericRoomType = location.GenericRoomType,
|
||||||
|
ParentLocationHint = location.ParentLocationHint,
|
||||||
|
PresentInScene = location.PresentInScene,
|
||||||
|
MentionedOnly = location.MentionedOnly,
|
||||||
|
Confidence = location.Confidence,
|
||||||
|
Notes = $"Grouped road reference from {name}{(string.IsNullOrWhiteSpace(parenthetical) ? string.Empty : $" ({parenthetical})")}."
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
yield break;
|
||||||
|
}
|
||||||
|
|
||||||
|
yield return location;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsNamedRoad(string? value)
|
||||||
|
{
|
||||||
|
var clean = StripParenthetical(RemoveLeadingArticle(Clean(value)));
|
||||||
|
if (string.IsNullOrWhiteSpace(clean) || clean.Contains('/'))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Regex.IsMatch(clean, @"\b(Street|Road|Lane|Avenue|Drive|Place|Square|Way|Close|Court|Terrace|Crescent|Queensway|High Street|New Street)\b$", RegexOptions.IgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string InternalLocationQualifier(string? value)
|
||||||
|
{
|
||||||
|
var clean = Clean(value);
|
||||||
|
var open = clean.IndexOf('(');
|
||||||
|
var close = clean.LastIndexOf(')');
|
||||||
|
if (open >= 0 && close > open)
|
||||||
|
{
|
||||||
|
var qualifier = Clean(clean[(open + 1)..close]);
|
||||||
|
return qualifier;
|
||||||
|
}
|
||||||
|
|
||||||
|
var lower = clean.ToLowerInvariant();
|
||||||
|
foreach (var suffix in ParentheticalLocationSuffixes)
|
||||||
|
{
|
||||||
|
if (lower.EndsWith(" " + suffix, StringComparison.OrdinalIgnoreCase) && clean.Length > suffix.Length + 1)
|
||||||
|
{
|
||||||
|
return suffix;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string StripParenthetical(string? value)
|
||||||
|
{
|
||||||
|
var clean = Clean(value);
|
||||||
|
var open = clean.IndexOf('(');
|
||||||
|
return open >= 0 ? Clean(clean[..open]) : clean;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NormaliseAssetName(string? value)
|
||||||
|
=> Clean(value).Replace('’', '\'').Replace('`', '\'');
|
||||||
|
|
||||||
|
private static bool HasParentheticalQualifier(string value)
|
||||||
|
=> value.Contains('(') && value.Contains(')');
|
||||||
|
|
||||||
|
private static string AppendNote(string? existing, string note)
|
||||||
|
=> string.IsNullOrWhiteSpace(existing) ? note : $"{existing} {note}";
|
||||||
|
|
||||||
private static string Clean(string? value)
|
private static string Clean(string? value)
|
||||||
=> string.Join(' ', (value ?? string.Empty).Trim().Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries));
|
=> string.Join(' ', (value ?? string.Empty).Trim().Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries));
|
||||||
|
|
||||||
@ -475,6 +834,69 @@ public sealed class SceneImportResolver(
|
|||||||
private static bool NamesMatch(string? first, string? second)
|
private static bool NamesMatch(string? first, string? second)
|
||||||
=> string.Equals(Clean(first), Clean(second), StringComparison.OrdinalIgnoreCase);
|
=> string.Equals(Clean(first), Clean(second), StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
private static int? SceneNumber(SceneIntelligenceScene scene)
|
||||||
|
=> scene.SceneReference?.SceneNumber.HasValue == true ? Convert.ToInt32(scene.SceneReference.SceneNumber.Value) : null;
|
||||||
|
|
||||||
|
private static bool IsRelationshipEligibleCharacter(StoryImportEntityResolution resolution)
|
||||||
|
=> resolution.Status is StoryImportResolutionStatuses.Matched
|
||||||
|
or StoryImportResolutionStatuses.NewCharacter
|
||||||
|
or StoryImportResolutionStatuses.PossibleDuplicateAliasCandidate
|
||||||
|
or StoryImportResolutionStatuses.PossibleMatch;
|
||||||
|
|
||||||
|
private static bool IsKnowledgeEligibleCharacter(StoryImportEntityResolution resolution)
|
||||||
|
=> resolution.Status is StoryImportResolutionStatuses.Matched
|
||||||
|
or StoryImportResolutionStatuses.NewCharacter
|
||||||
|
or StoryImportResolutionStatuses.PossibleDuplicateAliasCandidate
|
||||||
|
or StoryImportResolutionStatuses.PossibleMatch;
|
||||||
|
|
||||||
|
private static string RelationshipDisplayName(StoryImportEntityResolution resolution)
|
||||||
|
=> resolution.Status == StoryImportResolutionStatuses.Matched && !string.IsNullOrWhiteSpace(resolution.MatchedName)
|
||||||
|
? resolution.MatchedName!
|
||||||
|
: resolution.InputName;
|
||||||
|
|
||||||
|
private static Dictionary<string, string> BuildResolvedCharacterNameMap(
|
||||||
|
IReadOnlyList<StoryImportEntityResolution> characterResolutions,
|
||||||
|
Func<StoryImportEntityResolution, bool> eligible)
|
||||||
|
{
|
||||||
|
var map = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
foreach (var resolution in characterResolutions.Where(eligible))
|
||||||
|
{
|
||||||
|
AddCharacterMapEntry(map, resolution, RelationshipDisplayName(resolution));
|
||||||
|
}
|
||||||
|
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Dictionary<string, StoryImportEntityResolution> BuildResolvedCharacterStatusMap(IReadOnlyList<StoryImportEntityResolution> characterResolutions)
|
||||||
|
{
|
||||||
|
var map = new Dictionary<string, StoryImportEntityResolution>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
foreach (var resolution in characterResolutions)
|
||||||
|
{
|
||||||
|
AddCharacterMapEntry(map, resolution, resolution);
|
||||||
|
}
|
||||||
|
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddCharacterMapEntry<T>(Dictionary<string, T> map, StoryImportEntityResolution resolution, T value)
|
||||||
|
{
|
||||||
|
var inputName = Clean(resolution.InputName);
|
||||||
|
if (!string.IsNullOrWhiteSpace(inputName))
|
||||||
|
{
|
||||||
|
map.TryAdd(inputName, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resolution.Status != StoryImportResolutionStatuses.PossibleDuplicateAliasCandidate || !inputName.Contains('/'))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var part in inputName.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||||
|
{
|
||||||
|
map.TryAdd(Clean(part), value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static string RelationshipKey(string characterA, string characterB, string? signal)
|
private static string RelationshipKey(string characterA, string characterB, string? signal)
|
||||||
{
|
{
|
||||||
var names = new[] { Clean(characterA), Clean(characterB) }.Order(StringComparer.OrdinalIgnoreCase).ToArray();
|
var names = new[] { Clean(characterA), Clean(characterB) }.Order(StringComparer.OrdinalIgnoreCase).ToArray();
|
||||||
@ -486,7 +908,9 @@ public sealed class SceneImportResolver(
|
|||||||
|
|
||||||
private sealed record EntityIndexRow(int EntityID, string EntityName, string MatchValue, string MatchReason);
|
private sealed record EntityIndexRow(int EntityID, string EntityName, string MatchValue, string MatchReason);
|
||||||
|
|
||||||
private sealed record CharacterMention(string? Name, decimal? Confidence, string? Notes);
|
private sealed record CharacterMention(string? Name, decimal? Confidence, string? Notes, int? SceneNumber, string? Context);
|
||||||
|
|
||||||
|
private sealed record CharacterMentionGroup(string Name, decimal? Confidence, string Notes, IReadOnlyList<int> SceneNumbers, string Context);
|
||||||
|
|
||||||
private sealed class EntityIndex(IReadOnlyList<EntityIndexRow> rows)
|
private sealed class EntityIndex(IReadOnlyList<EntityIndexRow> rows)
|
||||||
{
|
{
|
||||||
@ -513,5 +937,36 @@ public sealed class SceneImportResolver(
|
|||||||
|
|
||||||
return matches;
|
return matches;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<StoryImportResolutionCandidate> FindParentCandidates()
|
||||||
|
{
|
||||||
|
var parentMatches = rows
|
||||||
|
.Where(row =>
|
||||||
|
{
|
||||||
|
var value = RemoveLeadingArticle(Clean(row.MatchValue)).ToLowerInvariant();
|
||||||
|
var name = Clean(row.EntityName).ToLowerInvariant();
|
||||||
|
return ParentReferences.Contains(value)
|
||||||
|
|| value.Contains("mother", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| value.Contains("mum", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| value.Contains("mom", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| name.Contains("mother", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| name.Contains("mum", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| name.Contains("mom", StringComparison.OrdinalIgnoreCase);
|
||||||
|
})
|
||||||
|
.GroupBy(row => row.EntityID)
|
||||||
|
.Select(group =>
|
||||||
|
{
|
||||||
|
var first = group.First();
|
||||||
|
return new StoryImportResolutionCandidate
|
||||||
|
{
|
||||||
|
EntityID = first.EntityID,
|
||||||
|
Name = first.EntityName,
|
||||||
|
MatchReason = first.MatchReason
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return parentMatches;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -269,6 +269,17 @@ else
|
|||||||
</ul>
|
</ul>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@if (preview.UnresolvedKnowledge.Count > 0)
|
||||||
|
{
|
||||||
|
<h4>Knowledge involving unresolved references</h4>
|
||||||
|
<ul>
|
||||||
|
@foreach (var item in preview.UnresolvedKnowledge)
|
||||||
|
{
|
||||||
|
<li>@item.Action: @item.Character - @item.KnowledgeItem (@item.OldState -> @item.NewState, @DisplayConfidence(item.ConfidencePercent))</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
}
|
||||||
|
|
||||||
<h3>Timeline Clues</h3>
|
<h3>Timeline Clues</h3>
|
||||||
<p>@preview.Timeline.Count.ToString("N0") timeline clue@(preview.Timeline.Count == 1 ? string.Empty : "s") detected.</p>
|
<p>@preview.Timeline.Count.ToString("N0") timeline clue@(preview.Timeline.Count == 1 ? string.Empty : "s") detected.</p>
|
||||||
@if (preview.Timeline.Count > 0)
|
@if (preview.Timeline.Count > 0)
|
||||||
@ -416,6 +427,8 @@ else
|
|||||||
StoryImportResolutionStatuses.Matched => "✓",
|
StoryImportResolutionStatuses.Matched => "✓",
|
||||||
StoryImportResolutionStatuses.Ambiguous => "⚠",
|
StoryImportResolutionStatuses.Ambiguous => "⚠",
|
||||||
StoryImportResolutionStatuses.PossibleDuplicate => "⚠",
|
StoryImportResolutionStatuses.PossibleDuplicate => "⚠",
|
||||||
|
StoryImportResolutionStatuses.PossibleDuplicateAliasCandidate => "⚠",
|
||||||
|
StoryImportResolutionStatuses.PossibleMatch => "?",
|
||||||
StoryImportResolutionStatuses.NewCharacter or StoryImportResolutionStatuses.NewLocation or StoryImportResolutionStatuses.NewAsset => "+",
|
StoryImportResolutionStatuses.NewCharacter or StoryImportResolutionStatuses.NewLocation or StoryImportResolutionStatuses.NewAsset => "+",
|
||||||
_ => "•"
|
_ => "•"
|
||||||
};
|
};
|
||||||
@ -424,7 +437,8 @@ else
|
|||||||
=> status switch
|
=> status switch
|
||||||
{
|
{
|
||||||
StoryImportResolutionStatuses.NewCharacter or StoryImportResolutionStatuses.NewLocation or StoryImportResolutionStatuses.NewAsset => "New",
|
StoryImportResolutionStatuses.NewCharacter or StoryImportResolutionStatuses.NewLocation or StoryImportResolutionStatuses.NewAsset => "New",
|
||||||
StoryImportResolutionStatuses.GenericReference => "Generic",
|
StoryImportResolutionStatuses.PossibleDuplicate or StoryImportResolutionStatuses.PossibleDuplicateAliasCandidate => "Possible Duplicate / Alias Candidate",
|
||||||
|
StoryImportResolutionStatuses.GenericReference or StoryImportResolutionStatuses.Ignored => "Generic / Ignored",
|
||||||
StoryImportResolutionStatuses.UnresolvedPersonReference => "Unresolved",
|
StoryImportResolutionStatuses.UnresolvedPersonReference => "Unresolved",
|
||||||
_ => status
|
_ => status
|
||||||
};
|
};
|
||||||
@ -434,10 +448,10 @@ else
|
|||||||
{
|
{
|
||||||
"Matched" => 0,
|
"Matched" => 0,
|
||||||
"New" => 1,
|
"New" => 1,
|
||||||
"Possible Duplicate" => 2,
|
"Possible Match" => 2,
|
||||||
"Ambiguous" => 3,
|
"Possible Duplicate / Alias Candidate" => 3,
|
||||||
"Ignored" => 4,
|
"Ambiguous" => 4,
|
||||||
"Generic" => 5,
|
"Generic / Ignored" => 5,
|
||||||
"Unresolved" => 6,
|
"Unresolved" => 6,
|
||||||
_ => 99
|
_ => 99
|
||||||
};
|
};
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user