Phase 20AT – Expand Relationship Types and Replace Free-Text Relationship Entry

This commit is contained in:
Nick Beckley 2026-07-10 21:12:36 +00:00
parent e8b42664f1
commit 3d54362c3b
8 changed files with 259 additions and 38 deletions

View File

@ -18,7 +18,8 @@ var tests = new (string Name, Action Test)[]
("Location canonical keys merge trivial variants", LocationCanonicalKeysMergeTrivialVariants),
("Asset filtering rejects generic objects", AssetFilteringRejectsGenericObjects),
("Asset filtering preserves story assets", AssetFilteringPreservesStoryAssets),
("Asset canonical keys merge trivial variants", AssetCanonicalKeysMergeTrivialVariants)
("Asset canonical keys merge trivial variants", AssetCanonicalKeysMergeTrivialVariants),
("Relationship signals map to broad lookup types", RelationshipSignalsMapToBroadTypes)
};
foreach (var test in tests)
@ -225,6 +226,18 @@ static void AssetCanonicalKeysMergeTrivialVariants()
Assert(AssetCanonicalKey("driving license") == AssetCanonicalKey("driving licence"), "License/licence spelling should merge.");
}
static void RelationshipSignalsMapToBroadTypes()
{
Assert(RelationshipTypeFromSignal("Friend") == "Friend", "Friend should map to Friend.");
Assert(RelationshipTypeFromSignal("driving instructor", "learner makes progress") == "Educational", "Instructor/learner should map to Educational.");
Assert(RelationshipTypeFromSignal("aunt", "family visit") == "Family", "Aunt/family should map to Family.");
Assert(RelationshipTypeFromSignal("boss", "employee at work") == "Professional", "Boss/employee should map to Professional.");
Assert(RelationshipTypeFromSignal("police", "suspect witness interview") == "Authority", "Police/suspect/witness should map to Authority.");
Assert(RelationshipTypeFromSignal("neighbour") == "Neighbour", "Neighbour should map to Neighbour.");
Assert(RelationshipTypeFromSignal("adversarial", "mistrust between them") == "Rival", "Adversarial/mistrust should map to Rival.");
Assert(RelationshipTypeFromSignal("uncertain emotional connection") == "Unknown", "Uncertain signals should map to Unknown.");
}
static bool IsAssetNameCandidate(string name)
=> InvokePrivateAssetFilter("IsAssetNameCandidate", name);
@ -246,6 +259,15 @@ static bool InvokePrivateAssetFilter(string methodName, string name)
return method!.Invoke(null, [name]) is true;
}
static string RelationshipTypeFromSignal(string signal, string? evidence = null)
{
var method = typeof(StoryIntelligenceRelationshipImportService).GetMethod(
"RelationshipTypeFromSignal",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
Assert(method is not null, "RelationshipTypeFromSignal was not found.");
return (string)method!.Invoke(null, [signal, evidence])!;
}
static JsonSerializerOptions JsonOptions()
=> new()
{

View File

@ -17,6 +17,10 @@
</ItemGroup>
<ItemGroup>
<Content Include="Docs\AI\**\*">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
</Content>
<Content Include="Help\**\*">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>

View File

@ -850,6 +850,7 @@ public sealed class OnboardingStoryIntelligenceBatch
public List<OnboardingStoryIntelligenceCharacterDecision> CharacterDecisions { get; } = [];
public List<OnboardingStoryIntelligenceLocationDecision> LocationDecisions { get; } = [];
public List<OnboardingStoryIntelligenceAssetDecision> AssetDecisions { get; } = [];
public List<OnboardingStoryIntelligenceRelationshipSelection> RelationshipSelections { get; } = [];
public List<OnboardingStoryIntelligenceRelationshipDecision> RelationshipDecisions { get; } = [];
public StoryIntelligenceCharacterImportBatchResult? LastCharacterImportResult { get; set; }
public StoryIntelligenceLocationImportBatchResult? LastLocationImportResult { get; set; }
@ -906,6 +907,12 @@ public sealed class OnboardingStoryIntelligenceRelationshipDecision
public bool CreatedOrLinked { get; init; }
}
public sealed class OnboardingStoryIntelligenceRelationshipSelection
{
public string Key { get; init; } = string.Empty;
public int RelationshipTypeID { get; init; }
}
public sealed class StoryIntelligenceCharacterImportBatchResult
{
public int CharactersCreated { get; init; }

View File

@ -60,6 +60,7 @@ public sealed class StoryPromptRepository(
ILogger<StoryPromptRepository> logger) : IStoryPromptRepository
{
private const string PromptFolder = "Docs/AI";
private const string RepositoryPromptFolder = "PlotLine/Docs/AI";
public async Task<string> LoadPromptAsync(string fileName, CancellationToken cancellationToken)
{
@ -77,10 +78,10 @@ public sealed class StoryPromptRepository(
return cachedPrompt;
}
var promptPath = Path.Combine(environment.ContentRootPath, PromptFolder, fileName);
if (!File.Exists(promptPath))
var promptPath = ResolvePromptPath(fileName);
if (promptPath is null)
{
throw new FileNotFoundException($"Story Intelligence prompt file '{fileName}' was not found in /Docs/AI/.", promptPath);
throw new FileNotFoundException($"Story Intelligence prompt file '{fileName}' was not found in /{PromptFolder}/ or /{RepositoryPromptFolder}/.", Path.Combine(environment.ContentRootPath, PromptFolder, fileName));
}
var prompt = await File.ReadAllTextAsync(promptPath, cancellationToken);
@ -93,6 +94,23 @@ public sealed class StoryPromptRepository(
logger.LogInformation("Loaded Story Intelligence prompt {PromptFile} from {PromptPath}.", fileName, promptPath);
return prompt;
}
private string? ResolvePromptPath(string fileName)
{
var promptPath = Path.Combine(environment.ContentRootPath, PromptFolder, fileName);
if (File.Exists(promptPath))
{
return promptPath;
}
var repositoryPromptPath = Path.Combine(environment.ContentRootPath, RepositoryPromptFolder, fileName);
if (File.Exists(repositoryPromptPath))
{
return repositoryPromptPath;
}
return null;
}
}
public sealed class StoryPromptBuilder : IStoryPromptBuilder

View File

@ -1,5 +1,6 @@
using System.Text.Json;
using System.Text.RegularExpressions;
using Microsoft.AspNetCore.Mvc.Rendering;
using PlotLine.Data;
using PlotLine.Models;
using PlotLine.ViewModels;
@ -28,6 +29,9 @@ public sealed class StoryIntelligenceRelationshipImportService(
public async Task<StoryIntelligenceRelationshipReviewViewModel> BuildReviewAsync(OnboardingStoryIntelligenceBatch batch)
{
var data = await BuildCandidateDataAsync(batch);
var lookupData = await characters.GetLookupsAsync(batch.ProjectID);
var typeOptions = ToRelationshipTypeOptions(lookupData.RelationshipTypes);
var selectedTypeIds = batch.RelationshipSelections.ToDictionary(selection => selection.Key, selection => selection.RelationshipTypeID, StringComparer.OrdinalIgnoreCase);
var decidedKeys = batch.RelationshipDecisions.Select(decision => decision.Key).ToHashSet(StringComparer.OrdinalIgnoreCase);
var visibleCandidates = data.Candidates.Where(candidate => !decidedKeys.Contains(candidate.Key)).ToList();
return new StoryIntelligenceRelationshipReviewViewModel
@ -36,12 +40,14 @@ public sealed class StoryIntelligenceRelationshipImportService(
CanImport = visibleCandidates.Count > 0,
AlreadyLinkedCount = data.AlreadyLinkedCount,
IsComplete = data.HasCommittedScenes && visibleCandidates.Count == 0,
RelationshipTypeOptions = typeOptions,
Candidates = visibleCandidates.Select(candidate => new StoryIntelligenceRelationshipReviewCandidateViewModel
{
Key = candidate.Key,
CharacterAName = candidate.CharacterAName,
CharacterBName = candidate.CharacterBName,
RelationshipType = candidate.RelationshipType,
RelationshipTypeID = ResolveRelationshipTypeID(lookupData.RelationshipTypes, selectedTypeIds.GetValueOrDefault(candidate.Key), candidate.RelationshipType),
AppearsInScenes = candidate.Appearances.Select(appearance => appearance.SceneID).Distinct().Count(),
Confidence = ConfidenceBand(candidate.Confidence),
FirstAppearance = candidate.FirstAppearanceNote,
@ -65,6 +71,7 @@ public sealed class StoryIntelligenceRelationshipImportService(
var choices = form.Relationships
.Where(choice => !string.IsNullOrWhiteSpace(choice.Key))
.ToDictionary(choice => choice.Key, choice => choice, StringComparer.OrdinalIgnoreCase);
SaveRelationshipSelections(batch, choices.Values);
if (choices.Count == 0)
{
if (data.Candidates.Count == 0)
@ -83,6 +90,7 @@ public sealed class StoryIntelligenceRelationshipImportService(
}
var lookupData = await characters.GetLookupsAsync(batch.ProjectID);
var relationshipTypesById = lookupData.RelationshipTypes.ToDictionary(type => type.RelationshipTypeID);
var created = 0;
var linkedExisting = 0;
var merged = 0;
@ -116,12 +124,17 @@ public sealed class StoryIntelligenceRelationshipImportService(
if (!relationshipId.HasValue)
{
if (!choice.RelationshipTypeID.HasValue || !relationshipTypesById.ContainsKey(choice.RelationshipTypeID.Value))
{
return new StoryIntelligenceImportCommitResult { Success = false, Message = "Choose a valid relationship type for each relationship you want to create." };
}
relationshipId = await characters.SaveRelationshipAsync(new CharacterRelationship
{
ProjectID = batch.ProjectID,
CharacterAID = candidate.CharacterAID,
CharacterBID = candidate.CharacterBID,
RelationshipTypeID = MatchRelationshipType(lookupData.RelationshipTypes, choice.RelationshipType ?? candidate.RelationshipType),
RelationshipTypeID = choice.RelationshipTypeID.Value,
IsPermanent = false,
StartSceneID = candidate.Appearances.OrderBy(appearance => appearance.SceneNumber).FirstOrDefault()?.SceneID,
EndSceneID = null,
@ -358,7 +371,7 @@ public sealed class StoryIntelligenceRelationshipImportService(
characterA.CharacterName,
characterB.CharacterID,
characterB.CharacterName,
RelationshipTypeFromSignal(relationshipSignal),
RelationshipTypeFromSignal(relationshipSignal, evidence),
existingMatch?.CharacterRelationshipID,
existingMatch is null ? null : $"{existingMatch.CharacterAName} / {existingMatch.RelationshipTypeName} / {existingMatch.CharacterBName}");
groups[key] = candidate;
@ -366,7 +379,7 @@ public sealed class StoryIntelligenceRelationshipImportService(
if ((confidence ?? 0m) >= (candidate.Confidence ?? 0m))
{
candidate.RelationshipType = RelationshipTypeFromSignal(relationshipSignal);
candidate.RelationshipType = RelationshipTypeFromSignal(relationshipSignal, evidence);
}
candidate.Confidence = Max(candidate.Confidence, confidence);
@ -459,15 +472,12 @@ public sealed class StoryIntelligenceRelationshipImportService(
&& !string.IsNullOrWhiteSpace(observation.ObjectName);
}
private static int MatchRelationshipType(IReadOnlyList<RelationshipType> types, string? signal)
{
var normalised = Normalise(signal);
var mapped = RelationshipTypeFromSignal(signal);
return types.FirstOrDefault(type => string.Equals(type.TypeName, mapped, StringComparison.OrdinalIgnoreCase))?.RelationshipTypeID
?? types.FirstOrDefault(type => normalised.Contains(Normalise(type.TypeName), StringComparison.OrdinalIgnoreCase))?.RelationshipTypeID
?? types.FirstOrDefault(type => string.Equals(type.TypeName, "Unknown", StringComparison.OrdinalIgnoreCase))?.RelationshipTypeID
?? types.First().RelationshipTypeID;
}
private static int ResolveRelationshipTypeID(IReadOnlyList<RelationshipType> types, int? selectedRelationshipTypeId, string? suggestedType)
=> selectedRelationshipTypeId.HasValue && types.Any(type => type.RelationshipTypeID == selectedRelationshipTypeId.Value)
? selectedRelationshipTypeId.Value
: types.FirstOrDefault(type => string.Equals(type.TypeName, suggestedType, StringComparison.OrdinalIgnoreCase))?.RelationshipTypeID
?? types.FirstOrDefault(type => string.Equals(type.TypeName, "Unknown", StringComparison.OrdinalIgnoreCase))?.RelationshipTypeID
?? types.First().RelationshipTypeID;
private static int MatchRelationshipState(IReadOnlyList<RelationshipState> states, string? signal)
{
@ -486,27 +496,64 @@ public sealed class StoryIntelligenceRelationshipImportService(
?? states.First().RelationshipStateID;
}
private static string RelationshipTypeFromSignal(string? signal)
private static string RelationshipTypeFromSignal(string? signal, string? evidence = null)
{
var value = Normalise(signal);
if (value.Contains("mother") || value.Contains("father") || value.Contains("parent")) return "Parent";
if (value.Contains("daughter") || value.Contains("son") || value.Contains("child")) return "Child";
if (value.Contains("sister") || value.Contains("brother") || value.Contains("sibling")) return "Sibling";
if (value.Contains("spouse") || value.Contains("husband") || value.Contains("wife") || value.Contains("married")) return "Spouse";
if (value.Contains("romantic") || value.Contains("lover") || value.Contains("love")) return "Lover";
if (value.Contains("friend")) return "Friend";
if (value.Contains("enemy") || value.Contains("adversarial") || value.Contains("hostile")) return "Enemy";
if (value.Contains("rival")) return "Rival";
if (value.Contains("suspicious") || value.Contains("suspect")) return "Suspicious Of";
if (value.Contains("neighbour") || value.Contains("neighbor")) return "Neighbour";
if (value.Contains("teacher")) return "Teacher";
if (value.Contains("student")) return "Student";
if (value.Contains("employer") || value.Contains("boss")) return "Employer";
if (value.Contains("employee")) return "Employee";
if (value.Contains("colleague") || value.Contains("professional") || value.Contains("police") || value.Contains("detective") || value.Contains("sergeant")) return "Colleague";
if (value.Contains("ally") || value.Contains("support")) return "Ally";
if (value.Contains("guardian") || value.Contains("protector")) return "Protector";
if (value.Contains("estranged") || value.Contains("separat")) return "Estranged From";
var value = NormaliseForMatching($"{signal} {evidence}");
if (ContainsAny(value, "mother", "father", "sister", "brother", "parent", "daughter", "son", " child ", "aunt", "uncle", "cousin", "family", "sibling", "parent and child"))
{
return "Family";
}
if (ContainsAny(value, "romance", "romantic", "lover", "spouse", "husband", "wife", "girlfriend", "boyfriend", "love interest", "former partner", "partner like", "partner-like"))
{
return "Romantic";
}
if (ContainsAny(value, "teacher", "student", "instructor", "learner", "mentor", "pupil", "tutor", "driving instructor"))
{
return "Educational";
}
if (ContainsAny(value, "police", "detective", "suspect", "witness", "authority", "guard", "prisoner", "investigator", "person of interest"))
{
return "Authority";
}
if (ContainsAny(value, "criminal", "accomplice", "offender", "victim", "gang", "conspirator"))
{
return "Criminal";
}
if (ContainsAny(value, "doctor", "patient", "nurse", "therapist", "medical"))
{
return "Medical";
}
if (ContainsAny(value, "neighbour", "neighbor"))
{
return "Neighbour";
}
if (ContainsAny(value, "colleague", "coworker", "co worker", "boss", "employee", "manager", "client", "professional", "work relationship"))
{
return "Professional";
}
if (ContainsAny(value, "friend", "friendship", "best friend", "close friend", "childhood friend", "trust between friends", "affection between friends"))
{
return "Friend";
}
if (ContainsAny(value, "rival", "enemy", "adversarial", "conflict", "mistrust", "antagonist", "competitor"))
{
return "Rival";
}
if (ContainsAny(value, "ally", "protector", "supporter", "confidant", "dependence", "cooperation"))
{
return "Ally";
}
return "Unknown";
}
@ -541,6 +588,32 @@ public sealed class StoryIntelligenceRelationshipImportService(
private static string RelationshipLabel(RelationshipCandidate candidate)
=> $"{candidate.CharacterAName} / {candidate.RelationshipType} / {candidate.CharacterBName}";
private static IReadOnlyList<SelectListItem> ToRelationshipTypeOptions(IEnumerable<RelationshipType> relationshipTypes)
=> relationshipTypes
.OrderBy(type => type.CategorySortOrder)
.ThenBy(type => type.SortOrder)
.ThenBy(type => type.TypeName)
.Select(type => new SelectListItem(type.TypeName, type.RelationshipTypeID.ToString()))
.ToList();
private static void SaveRelationshipSelections(OnboardingStoryIntelligenceBatch batch, IEnumerable<StoryIntelligenceRelationshipImportChoiceForm> choices)
{
foreach (var choice in choices)
{
if (string.IsNullOrWhiteSpace(choice.Key) || choice.RelationshipTypeID is not { } relationshipTypeId)
{
continue;
}
batch.RelationshipSelections.RemoveAll(selection => string.Equals(selection.Key, choice.Key, StringComparison.OrdinalIgnoreCase));
batch.RelationshipSelections.Add(new OnboardingStoryIntelligenceRelationshipSelection
{
Key = choice.Key,
RelationshipTypeID = relationshipTypeId
});
}
}
private static string? BuildFirstAppearance(Scene scene, SceneIntelligenceScene parsed)
{
var summary = Clean(parsed.Summary?.Short);
@ -592,10 +665,17 @@ public sealed class StoryIntelligenceRelationshipImportService(
private static string Normalise(string? value)
=> Clean(value).ToLowerInvariant();
private static string NormaliseForMatching(string? value)
=> $" {NonWord.Replace(Normalise(value), " ")} ";
private static bool ContainsAny(string value, params string[] needles)
=> needles.Any(needle => value.Contains(NormaliseForMatching(needle), StringComparison.Ordinal));
private static string Clean(string? value)
=> string.IsNullOrWhiteSpace(value) ? string.Empty : Whitespace.Replace(value, " ").Trim();
private static readonly Regex Whitespace = new(@"\s+", RegexOptions.Compiled);
private static readonly Regex NonWord = new(@"[^\p{L}\p{N}]+", RegexOptions.Compiled);
private static readonly string[] RelationshipSignalWords =
[

View File

@ -0,0 +1,73 @@
SET ANSI_NULLS ON;
GO
SET QUOTED_IDENTIFIER ON;
GO
DECLARE @OtherCategoryID int = (
SELECT TOP (1) RelationshipCategoryID
FROM dbo.RelationshipCategories
WHERE CategoryName = N'Other'
ORDER BY RelationshipCategoryID
);
DECLARE @FamilyCategoryID int = (
SELECT TOP (1) RelationshipCategoryID
FROM dbo.RelationshipCategories
WHERE CategoryName = N'Family'
ORDER BY RelationshipCategoryID
);
DECLARE @FriendshipCategoryID int = (
SELECT TOP (1) RelationshipCategoryID
FROM dbo.RelationshipCategories
WHERE CategoryName = N'Friendship'
ORDER BY RelationshipCategoryID
);
DECLARE @RomanticCategoryID int = (
SELECT TOP (1) RelationshipCategoryID
FROM dbo.RelationshipCategories
WHERE CategoryName = N'Romantic / Sexual'
ORDER BY RelationshipCategoryID
);
DECLARE @HostileCategoryID int = (
SELECT TOP (1) RelationshipCategoryID
FROM dbo.RelationshipCategories
WHERE CategoryName = N'Hostile / Conflict'
ORDER BY RelationshipCategoryID
);
DECLARE @ProfessionalCategoryID int = (
SELECT TOP (1) RelationshipCategoryID
FROM dbo.RelationshipCategories
WHERE CategoryName = N'Professional / Social'
ORDER BY RelationshipCategoryID
);
MERGE dbo.RelationshipTypes AS target
USING (VALUES
(N'Unknown', 0, 1, @OtherCategoryID),
(N'Family', 1, 2, @FamilyCategoryID),
(N'Friend', 0, 3, @FriendshipCategoryID),
(N'Romantic', 0, 4, @RomanticCategoryID),
(N'Professional', 0, 5, @ProfessionalCategoryID),
(N'Educational', 0, 6, @ProfessionalCategoryID),
(N'Authority', 0, 7, @ProfessionalCategoryID),
(N'Criminal', 0, 8, @HostileCategoryID),
(N'Medical', 0, 9, @ProfessionalCategoryID),
(N'Neighbour', 0, 10, @ProfessionalCategoryID),
(N'Ally', 0, 11, @FriendshipCategoryID),
(N'Rival', 0, 12, @HostileCategoryID)
) AS source (TypeName, IsPermanentDefault, SortOrder, RelationshipCategoryID)
ON target.TypeName = source.TypeName
WHEN MATCHED THEN
UPDATE SET
target.IsPermanentDefault = source.IsPermanentDefault,
target.SortOrder = source.SortOrder,
target.IsActive = 1,
target.RelationshipCategoryID = COALESCE(source.RelationshipCategoryID, target.RelationshipCategoryID)
WHEN NOT MATCHED THEN
INSERT (TypeName, IsPermanentDefault, SortOrder, IsActive, RelationshipCategoryID)
VALUES (source.TypeName, source.IsPermanentDefault, source.SortOrder, 1, source.RelationshipCategoryID);
GO

View File

@ -1,4 +1,5 @@
using System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.Mvc.Rendering;
using PlotLine.Models;
namespace PlotLine.ViewModels;
@ -333,6 +334,7 @@ public sealed class StoryIntelligenceRelationshipReviewViewModel
public bool HasCommittedScenes { get; init; }
public int AlreadyLinkedCount { get; init; }
public bool IsComplete { get; init; }
public IReadOnlyList<SelectListItem> RelationshipTypeOptions { get; init; } = [];
public IReadOnlyList<StoryIntelligenceRelationshipReviewCandidateViewModel> Candidates { get; init; } = [];
}
@ -342,6 +344,7 @@ public sealed class StoryIntelligenceRelationshipReviewCandidateViewModel
public string CharacterAName { get; init; } = string.Empty;
public string CharacterBName { get; init; } = string.Empty;
public string RelationshipType { get; init; } = "Unknown";
public int RelationshipTypeID { get; init; }
public int AppearsInScenes { get; init; }
public string Confidence { get; init; } = "Unknown";
public string? FirstAppearance { get; init; }
@ -364,7 +367,7 @@ public sealed class StoryIntelligenceRelationshipImportChoiceForm
{
public string Key { get; set; } = string.Empty;
public string Action { get; set; } = StoryIntelligenceRelationshipImportActions.CreateNew;
public string? RelationshipType { get; set; }
public int? RelationshipTypeID { get; set; }
public string? AliasTargetKey { get; set; }
}

View File

@ -127,7 +127,21 @@
<div data-relationship-type-panel>
<label class="form-label" for="relationship-type-@i">Relationship type</label>
<input id="relationship-type-@i" class="form-control" name="Relationships[@i].RelationshipType" value="@candidate.RelationshipType" />
<select id="relationship-type-@i" class="form-select" name="Relationships[@i].RelationshipTypeID">
@foreach (var option in Model.RelationshipReview.RelationshipTypeOptions)
{
<option value="@option.Value" selected="@(option.Value == candidate.RelationshipTypeID.ToString())">@option.Text</option>
}
</select>
<div class="story-review-note mt-2">
<strong>Story Intelligence observation</strong>
<p>@candidate.RelationshipType</p>
@if (!string.IsNullOrWhiteSpace(candidate.ExampleContext))
{
<strong>Evidence</strong>
<p>@candidate.ExampleContext</p>
}
</div>
</div>
<div data-relationship-alias-panel hidden>