PlotDirector/PlotLine/Services/StoryIntelligenceAssetImportService.cs

844 lines
36 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.Text.Json;
using System.Text.RegularExpressions;
using PlotLine.Data;
using PlotLine.Models;
using PlotLine.ViewModels;
namespace PlotLine.Services;
public interface IStoryIntelligenceAssetImportService
{
Task<StoryIntelligenceAssetReviewViewModel> BuildReviewAsync(OnboardingStoryIntelligenceBatch batch);
Task<StoryIntelligenceImportCommitResult> ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceAssetImportForm form);
}
public sealed class StoryIntelligenceAssetImportService(
IStoryIntelligenceResultRepository runs,
IAssetRepository assets,
ICharacterRepository characters,
ISceneRepository scenes,
IStoryIntelligencePipelineStateService pipelineState,
ILogger<StoryIntelligenceAssetImportService> logger) : IStoryIntelligenceAssetImportService
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true
};
private static readonly HashSet<string> GenericObjects = CreateSet(
"chair", "chairs", "table", "tables", "wall", "walls", "floor", "floors", "window", "windows",
"door", "doors", "tree", "trees", "grass", "lamp", "lamps", "cup", "cups", "plate", "plates",
"knife and fork", "people", "person", "building", "car park", "street", "road", "room", "desk",
"bed", "sofa", "sink", "towel", "mug", "glass", "bottle", "phone", "telephone", "key", "keys",
"car", "vehicle", "coat", "dress", "shoes", "shirt", "trousers", "weather", "rain", "food", "drink");
private static readonly HashSet<string> AcceptedAssetNames = CreateSet(
"tr6", "passport", "photograph", "photo", "key", "keys", "car keys", "letter", "envelope", "note",
"notebook", "red notebook", "old notebook", "knife", "gun", "suitcase", "rucksack", "wedding ring",
"ring", "necklace", "cassette tape", "camera", "map", "diary", "watch", "driving licence",
"driving license", "licence", "license", "document", "folder", "wallet", "bank book", "scrap of paper");
private static readonly HashSet<string> StorySignals = CreateSet(
"lost", "found", "hidden", "stolen", "missing", "taken", "given", "carried", "used", "opened",
"revealed", "evidence", "clue", "important", "sought", "passport", "licence", "license", "letter",
"note", "photograph", "photo", "map", "diary", "notebook", "keys", "necklace", "ring");
private static readonly HashSet<string> AdjectivesToFold = CreateSet(
"red", "old", "new", "small", "large", "little", "anonymous", "folded", "sealed", "missing");
public async Task<StoryIntelligenceAssetReviewViewModel> BuildReviewAsync(OnboardingStoryIntelligenceBatch batch)
{
var data = await BuildCandidateDataAsync(batch);
var decidedKeys = batch.AssetDecisions.Select(decision => decision.Key).ToHashSet(StringComparer.OrdinalIgnoreCase);
var visibleCandidates = data.Candidates.Where(candidate => !decidedKeys.Contains(candidate.Key)).ToList();
return new StoryIntelligenceAssetReviewViewModel
{
HasCommittedScenes = data.HasCommittedScenes,
CanImport = visibleCandidates.Count > 0,
AlreadyLinkedCount = data.AlreadyLinkedCount,
IsComplete = data.HasCommittedScenes && visibleCandidates.Count == 0,
Candidates = visibleCandidates.Select(candidate => new StoryIntelligenceAssetReviewCandidateViewModel
{
Key = candidate.Key,
AssetName = candidate.DisplayName,
ImportName = candidate.DisplayName,
Category = candidate.Category,
AppearsInScenes = candidate.Appearances.Select(appearance => appearance.SceneID).Distinct().Count(),
Confidence = ConfidenceBand(candidate.Confidence),
PossibleOwner = candidate.OwnerOrHolder,
ExampleFirstAppearance = candidate.FirstAppearanceNote,
ExampleContext = candidate.Description,
PossibleAliases = candidate.Aliases.OrderBy(value => value).ToList(),
ExistingAssetID = candidate.ExistingAssetID,
ExistingAssetName = candidate.ExistingAssetName
}).ToList()
};
}
public async Task<StoryIntelligenceImportCommitResult> ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceAssetImportForm form)
{
var data = await BuildCandidateDataAsync(batch);
if (!data.HasCommittedScenes)
{
return new StoryIntelligenceImportCommitResult { Success = false, Message = "Create scenes before importing assets." };
}
var choices = form.Assets
.Where(choice => !string.IsNullOrWhiteSpace(choice.Key))
.ToDictionary(choice => choice.Key, choice => choice, StringComparer.OrdinalIgnoreCase);
if (choices.Count == 0)
{
if (data.Candidates.Count == 0)
{
batch.AssetStageComplete = true;
batch.LastAssetImportResult = new StoryIntelligenceAssetImportBatchResult();
await pipelineState.RecordAssetImportAsync(batch.ProjectID, batch.BookID);
return new StoryIntelligenceImportCommitResult
{
Success = true,
Message = "Asset review completed. No asset decisions were waiting."
};
}
return new StoryIntelligenceImportCommitResult { Success = false, Message = "Choose at least one asset to create, link or ignore." };
}
var existingIndex = await BuildAssetIndexAsync(batch.ProjectID);
var characterIndex = await BuildCharacterIndexAsync(batch.ProjectID);
var lookupData = await assets.GetLookupsAsync(batch.ProjectID);
var created = 0;
var linkedExisting = 0;
var ignored = 0;
var aliasesAdded = 0;
var sceneEventsCreated = 0;
var ownershipLinksCreated = 0;
var resolvedCandidateIds = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
var candidateByKey = data.Candidates.ToDictionary(candidate => candidate.Key, StringComparer.OrdinalIgnoreCase);
foreach (var candidate in data.Candidates)
{
if (!choices.TryGetValue(candidate.Key, out var choice))
{
continue;
}
if (string.Equals(choice.Action, StoryIntelligenceAssetImportActions.Ignore, StringComparison.OrdinalIgnoreCase))
{
ignored++;
AddDecision(batch, candidate.Key, StoryIntelligenceAssetImportActions.Ignore, candidate.DisplayName, null, false);
continue;
}
if (string.Equals(choice.Action, StoryIntelligenceAssetImportActions.Alias, StringComparison.OrdinalIgnoreCase))
{
continue;
}
var requestedName = Clean(choice.ImportName);
var importName = string.IsNullOrWhiteSpace(requestedName) ? candidate.DisplayName : requestedName;
var forceCreateSeparate = string.Equals(choice.Action, StoryIntelligenceAssetImportActions.CreateSeparate, StringComparison.OrdinalIgnoreCase);
var matchedExistingId = forceCreateSeparate
? null
: candidate.ExistingAssetID
?? existingIndex.Find(importName)?.StoryAssetID
?? existingIndex.Find(candidate.DisplayName)?.StoryAssetID;
var assetId = matchedExistingId;
if (!assetId.HasValue)
{
assetId = await assets.SaveAssetAsync(new StoryAsset
{
ProjectID = batch.ProjectID,
AssetName = importName,
AssetKindID = MatchAssetKind(lookupData.AssetKinds, candidate),
Description = BuildAssetDescription(candidate),
Importance = 5,
CurrentStateID = MatchAssetState(lookupData.AssetStates, "Known To Exist")
});
created++;
}
else
{
linkedExisting++;
}
resolvedCandidateIds[candidate.Key] = assetId.Value;
AddDecision(batch, candidate.Key, choice.Action, importName, assetId.Value, true);
foreach (var alias in candidate.Aliases)
{
if (await TryAddAliasAsync(assetId.Value, alias, importName))
{
aliasesAdded++;
}
}
}
foreach (var candidate in data.Candidates)
{
if (!choices.TryGetValue(candidate.Key, out var choice)
|| !string.Equals(choice.Action, StoryIntelligenceAssetImportActions.Alias, StringComparison.OrdinalIgnoreCase))
{
continue;
}
var targetKey = Clean(choice.AliasTargetKey);
if (string.IsNullOrWhiteSpace(targetKey)
|| string.Equals(targetKey, candidate.Key, StringComparison.OrdinalIgnoreCase)
|| !choices.TryGetValue(targetKey, out var targetChoice)
|| string.Equals(targetChoice.Action, StoryIntelligenceAssetImportActions.Ignore, StringComparison.OrdinalIgnoreCase)
|| string.Equals(targetChoice.Action, StoryIntelligenceAssetImportActions.Alias, StringComparison.OrdinalIgnoreCase)
|| !candidateByKey.TryGetValue(targetKey, out var targetCandidate)
|| !resolvedCandidateIds.TryGetValue(targetKey, out var targetAssetId))
{
logger.LogWarning(
"Story Intelligence asset alias candidate {CandidateKey} could not be imported because target {TargetKey} was not a resolved create/link target.",
candidate.Key,
targetKey);
continue;
}
var targetName = targetCandidate.ExistingAssetName ?? targetChoice.ImportName ?? targetCandidate.DisplayName;
if (await TryAddAliasAsync(targetAssetId, candidate.DisplayName, targetName))
{
aliasesAdded++;
}
foreach (var alias in candidate.Aliases)
{
if (await TryAddAliasAsync(targetAssetId, alias, targetName))
{
aliasesAdded++;
}
}
resolvedCandidateIds[candidate.Key] = targetAssetId;
AddDecision(batch, candidate.Key, StoryIntelligenceAssetImportActions.Alias, candidate.DisplayName, targetAssetId, true);
}
foreach (var candidate in data.Candidates)
{
if (!choices.TryGetValue(candidate.Key, out var choice)
|| string.Equals(choice.Action, StoryIntelligenceAssetImportActions.Ignore, StringComparison.OrdinalIgnoreCase))
{
continue;
}
var assetId = string.Equals(choice.Action, StoryIntelligenceAssetImportActions.Alias, StringComparison.OrdinalIgnoreCase)
? resolvedCandidateIds.GetValueOrDefault(Clean(choice.AliasTargetKey))
: resolvedCandidateIds.GetValueOrDefault(candidate.Key);
if (assetId <= 0)
{
continue;
}
foreach (var appearance in candidate.Appearances
.Where(appearance => !appearance.AlreadyLinked)
.GroupBy(appearance => appearance.SceneID)
.Select(group => group.OrderByDescending(appearance => appearance.Confidence ?? 0m).First()))
{
await assets.SaveEventAsync(new AssetEvent
{
StoryAssetID = assetId,
SceneID = appearance.SceneID,
AssetEventTypeID = MatchAssetEventType(lookupData.AssetEventTypes, appearance.EventType),
ToStateID = MatchAssetState(lookupData.AssetStates, StateFromEventType(appearance.EventType)),
EventTitle = appearance.EventType,
EventDescription = appearance.Notes
});
sceneEventsCreated++;
var owner = Clean(appearance.OwnerOrHolder ?? candidate.OwnerOrHolder);
if (!string.IsNullOrWhiteSpace(owner)
&& characterIndex.Find(owner) is CharacterMatch ownerMatch
&& lookupData.AssetCustodyEventTypes.FirstOrDefault(type => string.Equals(type.TypeName, "Has", StringComparison.OrdinalIgnoreCase)) is { } custodyType
&& lookupData.CustodyRoles.FirstOrDefault(role => string.Equals(role.RoleName, "Has Custody", StringComparison.OrdinalIgnoreCase)) is { } role)
{
await assets.SaveCustodyEventAsync(
new AssetCustodyEvent
{
StoryAssetID = assetId,
SceneID = appearance.SceneID,
AssetCustodyEventTypeID = custodyType.AssetCustodyEventTypeID,
Description = $"Initial owner/holder from Story Intelligence: {owner}"
},
owner,
[ownerMatch.CharacterID],
role.CustodyRoleID);
ownershipLinksCreated++;
}
}
}
batch.AssetStageComplete = true;
batch.LastAssetImportResult = new StoryIntelligenceAssetImportBatchResult
{
AssetsCreated = created,
AssetsLinked = linkedExisting,
AssetAliasesAdded = aliasesAdded,
AssetsIgnored = ignored,
SceneAssetEventsCreated = sceneEventsCreated,
OwnershipLinksCreated = ownershipLinksCreated
};
await pipelineState.RecordAssetImportAsync(batch.ProjectID, batch.BookID);
logger.LogInformation(
"Imported Story Intelligence assets for batch {BatchID}. Created={Created} LinkedExisting={LinkedExisting} Aliases={Aliases} Ignored={Ignored} SceneEvents={SceneEvents} Ownership={Ownership}",
batch.BatchID,
created,
linkedExisting,
aliasesAdded,
ignored,
sceneEventsCreated,
ownershipLinksCreated);
return new StoryIntelligenceImportCommitResult
{
Success = true,
ScenesCreated = sceneEventsCreated,
Message = $"Assets imported. {created:N0} created, {linkedExisting:N0} linked, {aliasesAdded:N0} alias/variant(s) added, {ignored:N0} ignored, {sceneEventsCreated:N0} scene asset event(s) created."
};
}
private async Task<AssetCandidateData> BuildCandidateDataAsync(OnboardingStoryIntelligenceBatch batch)
{
var existingIndex = await BuildAssetIndexAsync(batch.ProjectID);
var groups = new Dictionary<string, AssetCandidate>(StringComparer.OrdinalIgnoreCase);
var alreadyLinked = 0;
var hasCommittedScenes = false;
foreach (var item in batch.Items)
{
var commit = await runs.GetImportCommitAsync(item.RunID);
if (commit is null || !string.Equals(commit.Status, StoryIntelligenceImportCommitStatuses.Completed, StringComparison.OrdinalIgnoreCase))
{
continue;
}
hasCommittedScenes = true;
var importedScenes = (await scenes.ListByChapterAsync(item.ChapterID))
.Where(scene => scene.ImportRunID == item.RunID)
.ToList();
var importedByRange = importedScenes
.Where(scene => scene.SourceStartParagraph.HasValue && scene.SourceEndParagraph.HasValue)
.ToDictionary(scene => $"{scene.SourceStartParagraph}-{scene.SourceEndParagraph}", scene => scene, StringComparer.OrdinalIgnoreCase);
var importedByNumber = importedScenes.ToDictionary(scene => Convert.ToInt32(scene.SceneNumber), scene => scene);
var sceneResults = await runs.ListSceneResultsAsync(item.RunID);
foreach (var sceneResult in sceneResults)
{
var parsed = TryReadScene(sceneResult);
if (parsed is null)
{
continue;
}
var importedScene = ResolveImportedScene(sceneResult, importedByRange, importedByNumber);
if (importedScene is null)
{
continue;
}
var existingEvents = await assets.ListEventsBySceneAsync(importedScene.SceneID);
alreadyLinked += existingEvents.Select(assetEvent => assetEvent.StoryAssetID).Distinct().Count();
AddSceneAssets(groups, existingIndex, importedScene, parsed, existingEvents);
}
}
var candidates = groups.Values
.Where(candidate => candidate.Appearances.Any(appearance => !appearance.AlreadyLinked))
.Where(IsVisibleAssetCandidate)
.OrderByDescending(candidate => candidate.Appearances.Select(appearance => appearance.SceneID).Distinct().Count())
.ThenBy(candidate => candidate.DisplayName)
.ToList();
return new AssetCandidateData(hasCommittedScenes, alreadyLinked, candidates);
}
private static void AddSceneAssets(
Dictionary<string, AssetCandidate> groups,
AssetIndex existingIndex,
Scene importedScene,
SceneIntelligenceScene parsed,
IReadOnlyList<AssetEvent> existingEvents)
{
foreach (var asset in parsed.Assets ?? [])
{
AddAsset(groups, existingIndex, importedScene, parsed, asset, existingEvents);
}
foreach (var observation in parsed.Observations ?? [])
{
if (IsAssetEntity(observation.SubjectEntityType))
{
AddAsset(groups, existingIndex, importedScene, parsed, new SceneIntelligenceAsset
{
Name = observation.SubjectName,
AssetType = observation.ObservationType,
Status = observation.Predicate,
Confidence = observation.Confidence,
Notes = observation.Description
}, existingEvents);
}
if (IsAssetEntity(observation.ObjectEntityType))
{
AddAsset(groups, existingIndex, importedScene, parsed, new SceneIntelligenceAsset
{
Name = observation.ObjectName,
AssetType = observation.ObservationType,
Status = observation.Predicate,
MentionedOnly = true,
Confidence = observation.Confidence,
Notes = observation.Description
}, existingEvents);
}
}
}
private static void AddAsset(
Dictionary<string, AssetCandidate> groups,
AssetIndex existingIndex,
Scene importedScene,
SceneIntelligenceScene parsed,
SceneIntelligenceAsset asset,
IReadOnlyList<AssetEvent> existingEvents)
{
var cleanName = CleanAssetName(asset.Name);
if (!IsAssetNameCandidate(cleanName))
{
return;
}
var key = ResolveCandidateKey(groups, existingIndex, cleanName);
if (!groups.TryGetValue(key, out var candidate))
{
var match = existingIndex.Find(cleanName);
candidate = new AssetCandidate(key, DisplayAssetName(cleanName), match?.StoryAssetID, match?.AssetName)
{
Category = Categorise(cleanName, asset.AssetType)
};
groups[key] = candidate;
}
else if (!string.Equals(candidate.DisplayName, cleanName, StringComparison.OrdinalIgnoreCase))
{
candidate.Aliases.Add(cleanName);
}
candidate.Confidence = Max(candidate.Confidence, asset.Confidence);
candidate.Description ??= Clean(asset.Notes);
candidate.OwnerOrHolder ??= Clean(asset.OwnerOrHolder);
candidate.FirstAppearanceNote ??= BuildFirstAppearance(importedScene, parsed, cleanName);
candidate.Appearances.Add(new AssetAppearanceImport(
importedScene.SceneID,
importedScene.SceneNumber,
importedScene.SceneTitle,
EventTypeFromAsset(asset),
asset.OwnerOrHolder,
asset.Notes,
asset.Confidence,
candidate.ExistingAssetID.HasValue && existingEvents.Any(existing => existing.StoryAssetID == candidate.ExistingAssetID.Value)));
}
private async Task<AssetIndex> BuildAssetIndexAsync(int projectId)
{
var index = new AssetIndex();
foreach (var asset in await assets.ListAssetsAsync(projectId))
{
index.Add(asset.AssetName, asset.StoryAssetID, asset.AssetName);
foreach (var alias in await assets.ListAliasesAsync(asset.StoryAssetID))
{
index.Add(alias.Alias, asset.StoryAssetID, asset.AssetName);
}
}
return index;
}
private async Task<CharacterIndex> BuildCharacterIndexAsync(int projectId)
{
var index = new CharacterIndex();
foreach (var character in await characters.ListCharactersAsync(projectId))
{
index.Add(character.CharacterName, character.CharacterID, character.CharacterName);
index.Add(character.ShortName, character.CharacterID, character.CharacterName);
foreach (var alias in await characters.ListAliasesAsync(character.CharacterID))
{
index.Add(alias.Alias, character.CharacterID, character.CharacterName);
}
}
return index;
}
private async Task<bool> TryAddAliasAsync(int storyAssetId, string alias, string primaryName)
{
var cleanAlias = Clean(alias);
if (string.IsNullOrWhiteSpace(cleanAlias) || string.Equals(cleanAlias, primaryName, StringComparison.OrdinalIgnoreCase))
{
return false;
}
var existingAliases = await assets.ListAliasesAsync(storyAssetId);
if (existingAliases.Any(item => string.Equals(Clean(item.Alias), cleanAlias, StringComparison.OrdinalIgnoreCase)))
{
return false;
}
await assets.AddAliasAsync(storyAssetId, cleanAlias, null);
return true;
}
private static Scene? ResolveImportedScene(
StoryIntelligenceSavedSceneResult sceneResult,
IReadOnlyDictionary<string, Scene> byRange,
IReadOnlyDictionary<int, Scene> byNumber)
{
if (sceneResult.StartParagraph.HasValue
&& sceneResult.EndParagraph.HasValue
&& byRange.TryGetValue($"{sceneResult.StartParagraph}-{sceneResult.EndParagraph}", out var rangeMatch))
{
return rangeMatch;
}
return byNumber.TryGetValue(sceneResult.TemporarySceneNumber, out var numberMatch) ? numberMatch : null;
}
private static SceneIntelligenceScene? TryReadScene(StoryIntelligenceSavedSceneResult result)
{
if (string.IsNullOrWhiteSpace(result.ParsedJson))
{
return null;
}
try
{
var direct = JsonSerializer.Deserialize<SceneIntelligenceScene>(result.ParsedJson, JsonOptions);
if (!string.IsNullOrWhiteSpace(direct?.SchemaVersion))
{
return direct;
}
using var document = JsonDocument.Parse(result.ParsedJson);
if (document.RootElement.TryGetProperty("parsedScene", out var parsedScene))
{
return parsedScene.Deserialize<SceneIntelligenceScene>(JsonOptions);
}
}
catch (JsonException)
{
}
return null;
}
private static int MatchAssetKind(IReadOnlyList<AssetKind> kinds, AssetCandidate candidate)
=> kinds.FirstOrDefault(kind =>
candidate.Category.Contains(kind.KindName, StringComparison.OrdinalIgnoreCase)
|| kind.KindName.Contains(candidate.Category, StringComparison.OrdinalIgnoreCase))?.AssetKindID
?? kinds.FirstOrDefault(kind => string.Equals(kind.KindName, "Physical Object", StringComparison.OrdinalIgnoreCase))?.AssetKindID
?? kinds.First().AssetKindID;
private static int MatchAssetEventType(IReadOnlyList<AssetEventType> eventTypes, string eventType)
=> eventTypes.FirstOrDefault(type => string.Equals(type.TypeName, eventType, StringComparison.OrdinalIgnoreCase))?.AssetEventTypeID
?? eventTypes.FirstOrDefault(type => string.Equals(type.TypeName, "Mentioned", StringComparison.OrdinalIgnoreCase))?.AssetEventTypeID
?? eventTypes.First().AssetEventTypeID;
private static int? MatchAssetState(IReadOnlyList<AssetState> states, string? stateName)
=> string.IsNullOrWhiteSpace(stateName)
? null
: states.FirstOrDefault(state => string.Equals(state.StateName, stateName, StringComparison.OrdinalIgnoreCase))?.AssetStateID;
private static string StateFromEventType(string eventType)
=> eventType switch
{
"First Mentioned" => "Known To Exist",
"Mentioned" => "Mentioned",
"Found" => "Found",
"Given" => "Possessed",
"Taken" => "Possessed",
"Lost" => "Lost",
"Hidden" => "Hidden",
"Used" => "Used",
"Opened" => "Opened",
"Revealed" => "Revealed",
"Investigated" => "Investigated",
_ => "Known To Exist"
};
private static string BuildAssetDescription(AssetCandidate candidate)
{
var lines = new List<string>();
if (!string.IsNullOrWhiteSpace(candidate.OwnerOrHolder))
{
lines.Add($"Possible owner/holder from Story Intelligence: {candidate.OwnerOrHolder}");
}
if (!string.IsNullOrWhiteSpace(candidate.Description))
{
lines.Add(candidate.Description);
}
return string.Join(Environment.NewLine, lines);
}
private static bool IsVisibleAssetCandidate(AssetCandidate candidate)
{
if (IsAcceptedStoryAssetName(candidate.DisplayName))
{
return true;
}
var sceneCount = candidate.Appearances.Select(appearance => appearance.SceneID).Distinct().Count();
var signalText = $"{candidate.DisplayName} {candidate.Category} {candidate.Description} {candidate.OwnerOrHolder} {string.Join(' ', candidate.Appearances.Select(a => a.Notes))}";
return sceneCount >= 2 && ContainsStoryAssetSignal(signalText);
}
private static bool IsAssetNameCandidate(string? name)
{
var clean = CleanAssetName(name);
if (string.IsNullOrWhiteSpace(clean) || clean.Length < 2 || !clean.Any(char.IsLetter))
{
return false;
}
if (IsAcceptedStoryAssetName(clean))
{
return true;
}
var normalised = Normalise(clean);
return !GenericObjects.Contains(normalised) && !GenericObjects.Contains(RemoveLeadingArticle(normalised));
}
private static bool IsAcceptedStoryAssetName(string? name)
{
var normalised = Normalise(CleanAssetName(name));
var withoutArticle = RemoveLeadingArticle(normalised);
return AcceptedAssetNames.Contains(normalised)
|| AcceptedAssetNames.Contains(withoutArticle)
|| normalised.Contains("tr6", StringComparison.OrdinalIgnoreCase)
|| normalised.Contains("'s car", StringComparison.OrdinalIgnoreCase)
|| normalised.Contains("s car", StringComparison.OrdinalIgnoreCase);
}
private static bool ContainsStoryAssetSignal(string value)
=> StorySignals.Any(signal => value.Contains(signal, StringComparison.OrdinalIgnoreCase));
private static string EventTypeFromAsset(SceneIntelligenceAsset asset)
{
var value = $"{asset.Status} {asset.Notes}".ToLowerInvariant();
if (value.Contains("found")) return "Found";
if (value.Contains("lost")) return "Lost";
if (value.Contains("given") || value.Contains("gives")) return "Given";
if (value.Contains("taken") || value.Contains("takes")) return "Taken";
if (value.Contains("stolen")) return "Stolen";
if (value.Contains("hidden") || value.Contains("hides")) return "Hidden";
if (value.Contains("opened")) return "Opened";
if (value.Contains("used") || value.Contains("uses")) return "Used";
if (value.Contains("revealed") || value.Contains("reveals")) return "Revealed";
if (value.Contains("investigated")) return "Investigated";
return asset.MentionedOnly == true ? "Mentioned" : "First Mentioned";
}
private static string Categorise(string name, string? assetType)
{
var value = $"{name} {assetType}".ToLowerInvariant();
if (value.Contains("passport") || value.Contains("licence") || value.Contains("license") || value.Contains("letter") || value.Contains("note") || value.Contains("map") || value.Contains("diary") || value.Contains("notebook") || value.Contains("document"))
{
return "Document";
}
if (value.Contains("photo") || value.Contains("evidence") || value.Contains("clue"))
{
return "Evidence";
}
if (value.Contains("car") || value.Contains("tr6") || value.Contains("vehicle"))
{
return "Vehicle";
}
if (value.Contains("necklace") || value.Contains("ring") || value.Contains("watch"))
{
return "Physical Object";
}
return string.IsNullOrWhiteSpace(assetType) ? "Physical Object" : Clean(assetType);
}
private static string ResolveCandidateKey(
IReadOnlyDictionary<string, AssetCandidate> groups,
AssetIndex existingIndex,
string name)
{
var existingMatch = existingIndex.Find(name);
if (existingMatch is not null)
{
return CanonicalAssetKey(existingMatch.AssetName);
}
var canonical = CanonicalAssetKey(name);
return groups.ContainsKey(canonical) ? canonical : canonical;
}
private static string? BuildFirstAppearance(Scene scene, SceneIntelligenceScene parsed, string assetName)
{
var summary = Clean(parsed.Summary?.Short);
return string.IsNullOrWhiteSpace(summary)
? $"Scene {scene.SceneNumber:g}: {assetName}"
: $"Scene {scene.SceneNumber:g}: {summary}";
}
private static void AddDecision(OnboardingStoryIntelligenceBatch batch, string key, string action, string? assetName, int? storyAssetId, bool createdOrLinked)
{
batch.AssetDecisions.RemoveAll(decision => string.Equals(decision.Key, key, StringComparison.OrdinalIgnoreCase));
batch.AssetDecisions.Add(new OnboardingStoryIntelligenceAssetDecision
{
Key = key,
Action = action,
AssetName = assetName,
StoryAssetID = storyAssetId,
CreatedOrLinked = createdOrLinked
});
}
private static bool IsAssetEntity(string? entityType)
=> string.Equals(Clean(entityType), "Asset", StringComparison.OrdinalIgnoreCase)
|| string.Equals(Clean(entityType), "Object", StringComparison.OrdinalIgnoreCase);
private static decimal? Max(decimal? current, decimal? next)
=> current.HasValue && next.HasValue ? Math.Max(current.Value, next.Value) : current ?? next;
private static string ConfidenceBand(decimal? confidence)
=> confidence switch
{
>= 0.75m => "High",
>= 0.45m => "Medium",
null => "Unknown",
_ => "Low"
};
private static string DisplayAssetName(string name)
{
var clean = CleanAssetName(name);
return string.IsNullOrWhiteSpace(clean) ? clean : char.ToUpperInvariant(clean[0]) + clean[1..];
}
private static string CanonicalAssetKey(string name)
{
var clean = RemoveLeadingArticle(Normalise(CleanAssetName(name)));
var words = clean.Split(' ', StringSplitOptions.RemoveEmptyEntries).ToList();
while (words.Count > 1 && AdjectivesToFold.Contains(words[0]))
{
words.RemoveAt(0);
}
if (words.Count == 2 && words[1].Equals("keys", StringComparison.OrdinalIgnoreCase))
{
return "keys";
}
if (clean is "photo")
{
return "photograph";
}
if (clean is "driving license")
{
return "driving licence";
}
return string.Join(' ', words);
}
private static string CleanAssetName(string? value)
=> Clean(value).Trim(' ', '.', ',', ';', ':', '!', '?', '"', '\'');
private static string RemoveLeadingArticle(string value)
=> value.StartsWith("the ", StringComparison.OrdinalIgnoreCase)
? value[4..]
: value.StartsWith("a ", StringComparison.OrdinalIgnoreCase)
? value[2..]
: value.StartsWith("an ", StringComparison.OrdinalIgnoreCase)
? value[3..]
: value;
private static string Normalise(string? value)
=> Clean(value).ToLowerInvariant();
private static string Clean(string? value)
=> string.Join(' ', (value ?? string.Empty).Trim().Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries));
private static HashSet<string> CreateSet(params string[] values)
=> values.Select(Normalise).ToHashSet(StringComparer.OrdinalIgnoreCase);
private sealed class AssetIndex
{
private readonly Dictionary<string, AssetMatch> byName = new(StringComparer.OrdinalIgnoreCase);
public void Add(string? name, int storyAssetId, string assetName)
{
var key = CanonicalAssetKey(name ?? string.Empty);
if (!string.IsNullOrWhiteSpace(key))
{
byName.TryAdd(key, new AssetMatch(storyAssetId, assetName));
}
}
public AssetMatch? Find(string name)
=> byName.TryGetValue(CanonicalAssetKey(name), out var match) ? match : null;
}
private sealed class CharacterIndex
{
private readonly Dictionary<string, CharacterMatch> byName = new(StringComparer.OrdinalIgnoreCase);
public void Add(string? name, int characterId, string characterName)
{
var key = Normalise(name);
if (!string.IsNullOrWhiteSpace(key))
{
byName.TryAdd(key, new CharacterMatch(characterId, characterName));
}
}
public CharacterMatch? Find(string name)
=> byName.TryGetValue(Normalise(name), out var match) ? match : null;
}
private sealed record AssetMatch(int StoryAssetID, string AssetName);
private sealed record CharacterMatch(int CharacterID, string CharacterName);
private sealed class AssetCandidate(string key, string displayName, int? existingAssetId, string? existingAssetName)
{
public string Key { get; } = key;
public string DisplayName { get; } = displayName;
public int? ExistingAssetID { get; } = existingAssetId;
public string? ExistingAssetName { get; } = existingAssetName;
public string Category { get; set; } = "Physical Object";
public string? OwnerOrHolder { get; set; }
public HashSet<string> Aliases { get; } = new(StringComparer.OrdinalIgnoreCase);
public List<AssetAppearanceImport> Appearances { get; } = [];
public decimal? Confidence { get; set; }
public string? Description { get; set; }
public string? FirstAppearanceNote { get; set; }
}
private sealed record AssetAppearanceImport(
int SceneID,
decimal SceneNumber,
string SceneTitle,
string EventType,
string? OwnerOrHolder,
string? Notes,
decimal? Confidence,
bool AlreadyLinked);
private sealed record AssetCandidateData(
bool HasCommittedScenes,
int AlreadyLinkedCount,
IReadOnlyList<AssetCandidate> Candidates);
}