844 lines
36 KiB
C#
844 lines
36 KiB
C#
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);
|
||
}
|