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), ("Location canonical keys merge trivial variants", LocationCanonicalKeysMergeTrivialVariants),
("Asset filtering rejects generic objects", AssetFilteringRejectsGenericObjects), ("Asset filtering rejects generic objects", AssetFilteringRejectsGenericObjects),
("Asset filtering preserves story assets", AssetFilteringPreservesStoryAssets), ("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) foreach (var test in tests)
@ -225,6 +226,18 @@ static void AssetCanonicalKeysMergeTrivialVariants()
Assert(AssetCanonicalKey("driving license") == AssetCanonicalKey("driving licence"), "License/licence spelling should merge."); 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) static bool IsAssetNameCandidate(string name)
=> InvokePrivateAssetFilter("IsAssetNameCandidate", name); => InvokePrivateAssetFilter("IsAssetNameCandidate", name);
@ -246,6 +259,15 @@ static bool InvokePrivateAssetFilter(string methodName, string name)
return method!.Invoke(null, [name]) is true; 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() static JsonSerializerOptions JsonOptions()
=> new() => new()
{ {

View File

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

View File

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

View File

@ -60,6 +60,7 @@ public sealed class StoryPromptRepository(
ILogger<StoryPromptRepository> logger) : IStoryPromptRepository ILogger<StoryPromptRepository> logger) : IStoryPromptRepository
{ {
private const string PromptFolder = "Docs/AI"; private const string PromptFolder = "Docs/AI";
private const string RepositoryPromptFolder = "PlotLine/Docs/AI";
public async Task<string> LoadPromptAsync(string fileName, CancellationToken cancellationToken) public async Task<string> LoadPromptAsync(string fileName, CancellationToken cancellationToken)
{ {
@ -77,10 +78,10 @@ public sealed class StoryPromptRepository(
return cachedPrompt; return cachedPrompt;
} }
var promptPath = Path.Combine(environment.ContentRootPath, PromptFolder, fileName); var promptPath = ResolvePromptPath(fileName);
if (!File.Exists(promptPath)) 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); 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); logger.LogInformation("Loaded Story Intelligence prompt {PromptFile} from {PromptPath}.", fileName, promptPath);
return prompt; 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 public sealed class StoryPromptBuilder : IStoryPromptBuilder

View File

@ -1,5 +1,6 @@
using System.Text.Json; using System.Text.Json;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using Microsoft.AspNetCore.Mvc.Rendering;
using PlotLine.Data; using PlotLine.Data;
using PlotLine.Models; using PlotLine.Models;
using PlotLine.ViewModels; using PlotLine.ViewModels;
@ -28,6 +29,9 @@ public sealed class StoryIntelligenceRelationshipImportService(
public async Task<StoryIntelligenceRelationshipReviewViewModel> BuildReviewAsync(OnboardingStoryIntelligenceBatch batch) public async Task<StoryIntelligenceRelationshipReviewViewModel> BuildReviewAsync(OnboardingStoryIntelligenceBatch batch)
{ {
var data = await BuildCandidateDataAsync(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 decidedKeys = batch.RelationshipDecisions.Select(decision => decision.Key).ToHashSet(StringComparer.OrdinalIgnoreCase);
var visibleCandidates = data.Candidates.Where(candidate => !decidedKeys.Contains(candidate.Key)).ToList(); var visibleCandidates = data.Candidates.Where(candidate => !decidedKeys.Contains(candidate.Key)).ToList();
return new StoryIntelligenceRelationshipReviewViewModel return new StoryIntelligenceRelationshipReviewViewModel
@ -36,12 +40,14 @@ public sealed class StoryIntelligenceRelationshipImportService(
CanImport = visibleCandidates.Count > 0, CanImport = visibleCandidates.Count > 0,
AlreadyLinkedCount = data.AlreadyLinkedCount, AlreadyLinkedCount = data.AlreadyLinkedCount,
IsComplete = data.HasCommittedScenes && visibleCandidates.Count == 0, IsComplete = data.HasCommittedScenes && visibleCandidates.Count == 0,
RelationshipTypeOptions = typeOptions,
Candidates = visibleCandidates.Select(candidate => new StoryIntelligenceRelationshipReviewCandidateViewModel Candidates = visibleCandidates.Select(candidate => new StoryIntelligenceRelationshipReviewCandidateViewModel
{ {
Key = candidate.Key, Key = candidate.Key,
CharacterAName = candidate.CharacterAName, CharacterAName = candidate.CharacterAName,
CharacterBName = candidate.CharacterBName, CharacterBName = candidate.CharacterBName,
RelationshipType = candidate.RelationshipType, RelationshipType = candidate.RelationshipType,
RelationshipTypeID = ResolveRelationshipTypeID(lookupData.RelationshipTypes, selectedTypeIds.GetValueOrDefault(candidate.Key), candidate.RelationshipType),
AppearsInScenes = candidate.Appearances.Select(appearance => appearance.SceneID).Distinct().Count(), AppearsInScenes = candidate.Appearances.Select(appearance => appearance.SceneID).Distinct().Count(),
Confidence = ConfidenceBand(candidate.Confidence), Confidence = ConfidenceBand(candidate.Confidence),
FirstAppearance = candidate.FirstAppearanceNote, FirstAppearance = candidate.FirstAppearanceNote,
@ -65,6 +71,7 @@ public sealed class StoryIntelligenceRelationshipImportService(
var choices = form.Relationships var choices = form.Relationships
.Where(choice => !string.IsNullOrWhiteSpace(choice.Key)) .Where(choice => !string.IsNullOrWhiteSpace(choice.Key))
.ToDictionary(choice => choice.Key, choice => choice, StringComparer.OrdinalIgnoreCase); .ToDictionary(choice => choice.Key, choice => choice, StringComparer.OrdinalIgnoreCase);
SaveRelationshipSelections(batch, choices.Values);
if (choices.Count == 0) if (choices.Count == 0)
{ {
if (data.Candidates.Count == 0) if (data.Candidates.Count == 0)
@ -83,6 +90,7 @@ public sealed class StoryIntelligenceRelationshipImportService(
} }
var lookupData = await characters.GetLookupsAsync(batch.ProjectID); var lookupData = await characters.GetLookupsAsync(batch.ProjectID);
var relationshipTypesById = lookupData.RelationshipTypes.ToDictionary(type => type.RelationshipTypeID);
var created = 0; var created = 0;
var linkedExisting = 0; var linkedExisting = 0;
var merged = 0; var merged = 0;
@ -116,12 +124,17 @@ public sealed class StoryIntelligenceRelationshipImportService(
if (!relationshipId.HasValue) 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 relationshipId = await characters.SaveRelationshipAsync(new CharacterRelationship
{ {
ProjectID = batch.ProjectID, ProjectID = batch.ProjectID,
CharacterAID = candidate.CharacterAID, CharacterAID = candidate.CharacterAID,
CharacterBID = candidate.CharacterBID, CharacterBID = candidate.CharacterBID,
RelationshipTypeID = MatchRelationshipType(lookupData.RelationshipTypes, choice.RelationshipType ?? candidate.RelationshipType), RelationshipTypeID = choice.RelationshipTypeID.Value,
IsPermanent = false, IsPermanent = false,
StartSceneID = candidate.Appearances.OrderBy(appearance => appearance.SceneNumber).FirstOrDefault()?.SceneID, StartSceneID = candidate.Appearances.OrderBy(appearance => appearance.SceneNumber).FirstOrDefault()?.SceneID,
EndSceneID = null, EndSceneID = null,
@ -358,7 +371,7 @@ public sealed class StoryIntelligenceRelationshipImportService(
characterA.CharacterName, characterA.CharacterName,
characterB.CharacterID, characterB.CharacterID,
characterB.CharacterName, characterB.CharacterName,
RelationshipTypeFromSignal(relationshipSignal), RelationshipTypeFromSignal(relationshipSignal, evidence),
existingMatch?.CharacterRelationshipID, existingMatch?.CharacterRelationshipID,
existingMatch is null ? null : $"{existingMatch.CharacterAName} / {existingMatch.RelationshipTypeName} / {existingMatch.CharacterBName}"); existingMatch is null ? null : $"{existingMatch.CharacterAName} / {existingMatch.RelationshipTypeName} / {existingMatch.CharacterBName}");
groups[key] = candidate; groups[key] = candidate;
@ -366,7 +379,7 @@ public sealed class StoryIntelligenceRelationshipImportService(
if ((confidence ?? 0m) >= (candidate.Confidence ?? 0m)) if ((confidence ?? 0m) >= (candidate.Confidence ?? 0m))
{ {
candidate.RelationshipType = RelationshipTypeFromSignal(relationshipSignal); candidate.RelationshipType = RelationshipTypeFromSignal(relationshipSignal, evidence);
} }
candidate.Confidence = Max(candidate.Confidence, confidence); candidate.Confidence = Max(candidate.Confidence, confidence);
@ -459,15 +472,12 @@ public sealed class StoryIntelligenceRelationshipImportService(
&& !string.IsNullOrWhiteSpace(observation.ObjectName); && !string.IsNullOrWhiteSpace(observation.ObjectName);
} }
private static int MatchRelationshipType(IReadOnlyList<RelationshipType> types, string? signal) private static int ResolveRelationshipTypeID(IReadOnlyList<RelationshipType> types, int? selectedRelationshipTypeId, string? suggestedType)
{ => selectedRelationshipTypeId.HasValue && types.Any(type => type.RelationshipTypeID == selectedRelationshipTypeId.Value)
var normalised = Normalise(signal); ? selectedRelationshipTypeId.Value
var mapped = RelationshipTypeFromSignal(signal); : types.FirstOrDefault(type => string.Equals(type.TypeName, suggestedType, StringComparison.OrdinalIgnoreCase))?.RelationshipTypeID
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.FirstOrDefault(type => string.Equals(type.TypeName, "Unknown", StringComparison.OrdinalIgnoreCase))?.RelationshipTypeID
?? types.First().RelationshipTypeID; ?? types.First().RelationshipTypeID;
}
private static int MatchRelationshipState(IReadOnlyList<RelationshipState> states, string? signal) private static int MatchRelationshipState(IReadOnlyList<RelationshipState> states, string? signal)
{ {
@ -486,27 +496,64 @@ public sealed class StoryIntelligenceRelationshipImportService(
?? states.First().RelationshipStateID; ?? states.First().RelationshipStateID;
} }
private static string RelationshipTypeFromSignal(string? signal) private static string RelationshipTypeFromSignal(string? signal, string? evidence = null)
{ {
var value = Normalise(signal); var value = NormaliseForMatching($"{signal} {evidence}");
if (value.Contains("mother") || value.Contains("father") || value.Contains("parent")) return "Parent"; if (ContainsAny(value, "mother", "father", "sister", "brother", "parent", "daughter", "son", " child ", "aunt", "uncle", "cousin", "family", "sibling", "parent and child"))
if (value.Contains("daughter") || value.Contains("son") || value.Contains("child")) return "Child"; {
if (value.Contains("sister") || value.Contains("brother") || value.Contains("sibling")) return "Sibling"; return "Family";
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 (ContainsAny(value, "romance", "romantic", "lover", "spouse", "husband", "wife", "girlfriend", "boyfriend", "love interest", "former partner", "partner like", "partner-like"))
if (value.Contains("enemy") || value.Contains("adversarial") || value.Contains("hostile")) return "Enemy"; {
if (value.Contains("rival")) return "Rival"; return "Romantic";
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 (ContainsAny(value, "teacher", "student", "instructor", "learner", "mentor", "pupil", "tutor", "driving instructor"))
if (value.Contains("student")) return "Student"; {
if (value.Contains("employer") || value.Contains("boss")) return "Employer"; return "Educational";
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 (ContainsAny(value, "police", "detective", "suspect", "witness", "authority", "guard", "prisoner", "investigator", "person of interest"))
if (value.Contains("guardian") || value.Contains("protector")) return "Protector"; {
if (value.Contains("estranged") || value.Contains("separat")) return "Estranged From"; 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"; return "Unknown";
} }
@ -541,6 +588,32 @@ public sealed class StoryIntelligenceRelationshipImportService(
private static string RelationshipLabel(RelationshipCandidate candidate) private static string RelationshipLabel(RelationshipCandidate candidate)
=> $"{candidate.CharacterAName} / {candidate.RelationshipType} / {candidate.CharacterBName}"; => $"{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) private static string? BuildFirstAppearance(Scene scene, SceneIntelligenceScene parsed)
{ {
var summary = Clean(parsed.Summary?.Short); var summary = Clean(parsed.Summary?.Short);
@ -592,10 +665,17 @@ public sealed class StoryIntelligenceRelationshipImportService(
private static string Normalise(string? value) private static string Normalise(string? value)
=> Clean(value).ToLowerInvariant(); => 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) private static string Clean(string? value)
=> string.IsNullOrWhiteSpace(value) ? string.Empty : Whitespace.Replace(value, " ").Trim(); => string.IsNullOrWhiteSpace(value) ? string.Empty : Whitespace.Replace(value, " ").Trim();
private static readonly Regex Whitespace = new(@"\s+", RegexOptions.Compiled); 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 = 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 System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.Mvc.Rendering;
using PlotLine.Models; using PlotLine.Models;
namespace PlotLine.ViewModels; namespace PlotLine.ViewModels;
@ -333,6 +334,7 @@ public sealed class StoryIntelligenceRelationshipReviewViewModel
public bool HasCommittedScenes { get; init; } public bool HasCommittedScenes { get; init; }
public int AlreadyLinkedCount { get; init; } public int AlreadyLinkedCount { get; init; }
public bool IsComplete { get; init; } public bool IsComplete { get; init; }
public IReadOnlyList<SelectListItem> RelationshipTypeOptions { get; init; } = [];
public IReadOnlyList<StoryIntelligenceRelationshipReviewCandidateViewModel> Candidates { 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 CharacterAName { get; init; } = string.Empty;
public string CharacterBName { get; init; } = string.Empty; public string CharacterBName { get; init; } = string.Empty;
public string RelationshipType { get; init; } = "Unknown"; public string RelationshipType { get; init; } = "Unknown";
public int RelationshipTypeID { get; init; }
public int AppearsInScenes { get; init; } public int AppearsInScenes { get; init; }
public string Confidence { get; init; } = "Unknown"; public string Confidence { get; init; } = "Unknown";
public string? FirstAppearance { get; init; } public string? FirstAppearance { get; init; }
@ -364,7 +367,7 @@ public sealed class StoryIntelligenceRelationshipImportChoiceForm
{ {
public string Key { get; set; } = string.Empty; public string Key { get; set; } = string.Empty;
public string Action { get; set; } = StoryIntelligenceRelationshipImportActions.CreateNew; public string Action { get; set; } = StoryIntelligenceRelationshipImportActions.CreateNew;
public string? RelationshipType { get; set; } public int? RelationshipTypeID { get; set; }
public string? AliasTargetKey { get; set; } public string? AliasTargetKey { get; set; }
} }

View File

@ -127,7 +127,21 @@
<div data-relationship-type-panel> <div data-relationship-type-panel>
<label class="form-label" for="relationship-type-@i">Relationship type</label> <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>
<div data-relationship-alias-panel hidden> <div data-relationship-alias-panel hidden>