Phase 20AT – Expand Relationship Types and Replace Free-Text Relationship Entry
This commit is contained in:
parent
e8b42664f1
commit
3d54362c3b
@ -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()
|
||||
{
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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; }
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
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 =
|
||||
[
|
||||
|
||||
73
PlotLine/Sql/132_Phase20AT_RelationshipTypeTaxonomy.sql
Normal file
73
PlotLine/Sql/132_Phase20AT_RelationshipTypeTaxonomy.sql
Normal 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
|
||||
@ -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; }
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user