From 3bf8f88f16d7df6534ce8e94f97167bae0c39a93 Mon Sep 17 00:00:00 2001 From: Nick Beckley Date: Fri, 28 Aug 2026 14:03:10 +0000 Subject: [PATCH] Tighten structural asset phrase extraction --- PlotLine.Tests/Program.cs | 15 ++++++++ .../StoryIntelligenceAssetImportService.cs | 34 +++++++++++++++++-- 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/PlotLine.Tests/Program.cs b/PlotLine.Tests/Program.cs index 1b3f967..63f274c 100644 --- a/PlotLine.Tests/Program.cs +++ b/PlotLine.Tests/Program.cs @@ -42,6 +42,7 @@ var tests = new (string Name, Action Test)[] ("Asset canonical keys merge trivial variants", AssetCanonicalKeysMergeTrivialVariants), ("Asset canonical keys merge contextual vehicle variants", AssetCanonicalKeysMergeContextualVehicleVariants), ("Asset contextual names resolve POV ownership", AssetContextualNamesResolvePovOwnership), + ("Asset structural phrases trim neighbouring nouns", AssetStructuralPhrasesTrimNeighbouringNouns), ("Relationship signals map to broad lookup types", RelationshipSignalsMapToBroadTypes), ("Relationship signals infer friendship without romance", RelationshipSignalsInferFriendshipWithoutRomance), ("Relationship events preserve chronological story order", RelationshipEventsPreserveChronologicalStoryOrder), @@ -1326,6 +1327,11 @@ static void AssetContextualNamesResolvePovOwnership() Assert(IsAssetNameCandidate("Beth's Memory Tin"), "Owned Memory Tin-style significant objects should be retained."); } +static void AssetStructuralPhrasesTrimNeighbouringNouns() +{ + Assert(CleanStructuralAssetPhrase("memory tin and photographs") == "memory tin", "Structural asset extraction should not bundle neighbouring nouns after and."); +} + static void RelationshipSignalsMapToBroadTypes() { Assert(RelationshipTypeFromSignal("Friend") == "Friend", "Friend should map to Friend."); @@ -1423,6 +1429,15 @@ static string ResolveContextualAssetName(string name, string? ownerOrHolder, Sce return (string)method!.Invoke(null, [name, ownerOrHolder, scene])!; } +static string CleanStructuralAssetPhrase(string name) +{ + var method = typeof(StoryIntelligenceAssetImportService).GetMethod( + "CleanStructuralAssetPhrase", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static); + Assert(method is not null, "CleanStructuralAssetPhrase was not found."); + return (string)method!.Invoke(null, [name])!; +} + static IComparable StoryOrderKey((int SceneID, decimal ChapterNumber, decimal SceneNumber, int SortOrder) shim) { var nested = typeof(StoryIntelligenceRelationshipImportService).GetNestedType( diff --git a/PlotLine/Services/StoryIntelligenceAssetImportService.cs b/PlotLine/Services/StoryIntelligenceAssetImportService.cs index 3ef0015..2b37bbb 100644 --- a/PlotLine/Services/StoryIntelligenceAssetImportService.cs +++ b/PlotLine/Services/StoryIntelligenceAssetImportService.cs @@ -462,7 +462,7 @@ public sealed class StoryIntelligenceAssetImportService( foreach (Match match in StructuralPossessiveAssetPattern.Matches(source)) { var owner = match.Groups["owner"].Value; - var objectName = match.Groups["asset"].Value; + var objectName = CleanStructuralAssetPhrase(match.Groups["asset"].Value); if (string.IsNullOrWhiteSpace(owner) || string.IsNullOrWhiteSpace(objectName)) { continue; @@ -482,7 +482,7 @@ public sealed class StoryIntelligenceAssetImportService( foreach (Match match in StructuralSignificantAssetPattern.Matches(source)) { - var objectName = match.Groups["asset"].Value; + var objectName = CleanStructuralAssetPhrase(match.Groups["asset"].Value); if (string.IsNullOrWhiteSpace(objectName) || !IsOwnedAssetName(objectName)) { continue; @@ -912,6 +912,26 @@ public sealed class StoryIntelligenceAssetImportService( private static string CleanAssetName(string? value) => StoryIntelligenceEntityTextNormaliser.CleanName(value); + private static string CleanStructuralAssetPhrase(string? value) + { + var clean = CleanAssetName(value); + if (string.IsNullOrWhiteSpace(clean)) + { + return string.Empty; + } + + foreach (var separator in StructuralAssetTailSeparators) + { + var index = clean.IndexOf(separator, StringComparison.OrdinalIgnoreCase); + if (index > 0) + { + clean = clean[..index].Trim(); + } + } + + return clean; + } + private static string RemoveLeadingArticle(string value) => value.StartsWith("the ", StringComparison.OrdinalIgnoreCase) ? value[4..] @@ -930,6 +950,16 @@ public sealed class StoryIntelligenceAssetImportService( private static HashSet CreateSet(params string[] values) => values.Select(Normalise).ToHashSet(StringComparer.OrdinalIgnoreCase); + private static readonly string[] StructuralAssetTailSeparators = + [ + " and ", + " or ", + " while ", + " that ", + " which ", + "," + ]; + private static readonly Regex StructuralPossessiveAssetPattern = new( @"\b(?[A-Z][\p{L}'-]{1,40})'s\s+(?[A-Z][\p{L}\p{N}'-]*(?:\s+[A-Z][\p{L}\p{N}'-]*){0,3})\b", RegexOptions.Compiled);