PlotDirector/PlotLine/Services/StoryIntelligenceKnowledgeImportService.cs

731 lines
32 KiB
C#

using System.Text.Json;
using System.Text.RegularExpressions;
using Microsoft.AspNetCore.Mvc.Rendering;
using PlotLine.Data;
using PlotLine.Models;
using PlotLine.ViewModels;
namespace PlotLine.Services;
public interface IStoryIntelligenceKnowledgeImportService
{
Task<StoryIntelligenceKnowledgeReviewViewModel> BuildReviewAsync(OnboardingStoryIntelligenceBatch batch);
Task<StoryIntelligenceImportCommitResult> ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceKnowledgeImportForm form);
}
public sealed class StoryIntelligenceKnowledgeImportService(
IStoryIntelligenceResultRepository runs,
ICharacterRepository characters,
ISceneRepository scenes,
IAssetRepository assets,
IStoryIntelligencePipelineStateService pipelineState,
ILogger<StoryIntelligenceKnowledgeImportService> logger) : IStoryIntelligenceKnowledgeImportService
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true
};
public async Task<StoryIntelligenceKnowledgeReviewViewModel> BuildReviewAsync(OnboardingStoryIntelligenceBatch batch)
{
var lookupData = await characters.GetLookupsAsync(batch.ProjectID);
var data = await BuildCandidateDataAsync(batch, lookupData.KnowledgeStates);
var decidedKeys = batch.KnowledgeDecisions.Select(decision => decision.Key).ToHashSet(StringComparer.OrdinalIgnoreCase);
var selectedStateIds = batch.KnowledgeSelections.ToDictionary(selection => selection.Key, selection => selection.KnowledgeStateID, StringComparer.OrdinalIgnoreCase);
var visibleCandidates = data.Candidates.Where(candidate => !decidedKeys.Contains(candidate.Key)).ToList();
return new StoryIntelligenceKnowledgeReviewViewModel
{
HasCommittedScenes = data.HasCommittedScenes,
CanImport = visibleCandidates.Count > 0,
AlreadyLinkedCount = data.AlreadyLinkedCount,
IsComplete = data.HasCommittedScenes && visibleCandidates.Count == 0,
KnowledgeStateOptions = lookupData.KnowledgeStates
.OrderBy(state => state.SortOrder)
.ThenBy(state => state.StateName)
.Select(state => new SelectListItem(state.StateName, state.KnowledgeStateID.ToString()))
.ToList(),
Candidates = visibleCandidates.Select(candidate => new StoryIntelligenceKnowledgeReviewCandidateViewModel
{
Key = candidate.Key,
CharacterName = candidate.CharacterName,
KnowledgeStatement = candidate.KnowledgeStatement,
KnowledgeStateID = ResolveKnowledgeStateID(lookupData.KnowledgeStates, selectedStateIds.GetValueOrDefault(candidate.Key), candidate.KnowledgeStateName),
KnowledgeStateName = candidate.KnowledgeStateName,
AppearsInScenes = candidate.Appearances.Select(appearance => appearance.SceneID).Distinct().Count(),
Confidence = ConfidenceBand(candidate.Confidence),
FirstScene = candidate.FirstScene,
ExampleScene = candidate.ExampleScene,
Evidence = candidate.Evidence,
ExistingKnowledgeID = candidate.ExistingKnowledgeID,
ExistingKnowledgeLabel = candidate.ExistingKnowledgeLabel
}).ToList()
};
}
public async Task<StoryIntelligenceImportCommitResult> ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceKnowledgeImportForm form)
{
var lookupData = await characters.GetLookupsAsync(batch.ProjectID);
var data = await BuildCandidateDataAsync(batch, lookupData.KnowledgeStates);
if (!data.HasCommittedScenes)
{
return new StoryIntelligenceImportCommitResult { Success = false, Message = "Create scenes before importing knowledge." };
}
var choices = form.Knowledge
.Where(choice => !string.IsNullOrWhiteSpace(choice.Key))
.ToDictionary(choice => choice.Key, choice => choice, StringComparer.OrdinalIgnoreCase);
SaveKnowledgeSelections(batch, choices.Values);
if (choices.Count == 0)
{
if (data.Candidates.Count == 0)
{
batch.KnowledgeStageComplete = true;
batch.LastKnowledgeImportResult = new StoryIntelligenceKnowledgeImportBatchResult();
await pipelineState.RecordKnowledgeImportAsync(batch.ProjectID, batch.BookID);
return new StoryIntelligenceImportCommitResult
{
Success = true,
Message = "Knowledge review completed. No knowledge decisions were waiting."
};
}
return new StoryIntelligenceImportCommitResult { Success = false, Message = "Choose at least one knowledge item to create, link, merge or ignore." };
}
var stateIds = lookupData.KnowledgeStates.Select(state => state.KnowledgeStateID).ToHashSet();
var created = 0;
var linkedExisting = 0;
var merged = 0;
var ignored = 0;
var resolvedKnowledge = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
var candidatesByKey = 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, StoryIntelligenceKnowledgeImportActions.Ignore, StringComparison.OrdinalIgnoreCase))
{
ignored++;
AddDecision(batch, candidate.Key, StoryIntelligenceKnowledgeImportActions.Ignore, null, KnowledgeLabel(candidate), false);
continue;
}
if (string.Equals(choice.Action, StoryIntelligenceKnowledgeImportActions.Alias, StringComparison.OrdinalIgnoreCase))
{
continue;
}
var knowledgeId = string.Equals(choice.Action, StoryIntelligenceKnowledgeImportActions.LinkExisting, StringComparison.OrdinalIgnoreCase)
? candidate.ExistingKnowledgeID
: null;
if (!knowledgeId.HasValue)
{
if (!choice.KnowledgeStateID.HasValue || !stateIds.Contains(choice.KnowledgeStateID.Value))
{
return new StoryIntelligenceImportCommitResult { Success = false, Message = "Choose a valid knowledge state for each knowledge item you want to create." };
}
var firstAppearance = candidate.Appearances.OrderBy(appearance => appearance.SceneNumber).First();
knowledgeId = await characters.SaveKnowledgeAsync(new CharacterKnowledgeItem
{
CharacterID = candidate.CharacterID,
StoryAssetID = candidate.StoryAssetID,
PlotThreadID = null,
SceneID = firstAppearance.SceneID,
KnowledgeStateID = choice.KnowledgeStateID.Value,
SourceEventID = null,
Description = BuildKnowledgeDescription(candidate, firstAppearance)
});
created++;
}
else
{
linkedExisting++;
}
resolvedKnowledge[candidate.Key] = knowledgeId.Value;
AddDecision(batch, candidate.Key, choice.Action, knowledgeId.Value, KnowledgeLabel(candidate), true);
}
foreach (var candidate in data.Candidates)
{
if (!choices.TryGetValue(candidate.Key, out var choice)
|| !string.Equals(choice.Action, StoryIntelligenceKnowledgeImportActions.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, StoryIntelligenceKnowledgeImportActions.Ignore, StringComparison.OrdinalIgnoreCase)
|| string.Equals(targetChoice.Action, StoryIntelligenceKnowledgeImportActions.Alias, StringComparison.OrdinalIgnoreCase)
|| !candidatesByKey.ContainsKey(targetKey)
|| !resolvedKnowledge.TryGetValue(targetKey, out var targetKnowledgeId))
{
logger.LogWarning(
"Story Intelligence knowledge merge candidate {CandidateKey} could not be imported because target {TargetKey} was not a resolved create/link target.",
candidate.Key,
targetKey);
continue;
}
resolvedKnowledge[candidate.Key] = targetKnowledgeId;
merged++;
AddDecision(batch, candidate.Key, StoryIntelligenceKnowledgeImportActions.Alias, targetKnowledgeId, KnowledgeLabel(candidate), true);
}
batch.KnowledgeStageComplete = true;
batch.LastKnowledgeImportResult = new StoryIntelligenceKnowledgeImportBatchResult
{
KnowledgeCreated = created,
KnowledgeLinked = linkedExisting,
KnowledgeMerged = merged,
KnowledgeIgnored = ignored
};
await pipelineState.RecordKnowledgeImportAsync(batch.ProjectID, batch.BookID);
logger.LogInformation(
"Imported Story Intelligence knowledge for batch {BatchID}. Created={Created} LinkedExisting={LinkedExisting} Merged={Merged} Ignored={Ignored}",
batch.BatchID,
created,
linkedExisting,
merged,
ignored);
return new StoryIntelligenceImportCommitResult
{
Success = true,
Message = $"Knowledge imported. {created:N0} created, {linkedExisting:N0} linked, {merged:N0} merged, {ignored:N0} ignored."
};
}
private async Task<KnowledgeCandidateData> BuildCandidateDataAsync(OnboardingStoryIntelligenceBatch batch, IReadOnlyList<KnowledgeState> knowledgeStates)
{
var characterIndex = await BuildCharacterIndexAsync(batch.ProjectID);
var assetIndex = await BuildAssetIndexAsync(batch.ProjectID);
var existingKnowledge = await ListExistingKnowledgeAsync(batch.ProjectID);
var groups = new Dictionary<string, KnowledgeCandidate>(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 existingSceneKnowledge = await characters.ListKnowledgeBySceneAsync(importedScene.SceneID);
alreadyLinked += existingSceneKnowledge.Count;
AddSceneKnowledge(groups, characterIndex, assetIndex, existingKnowledge, importedScene, parsed, knowledgeStates);
}
}
var candidates = groups.Values
.OrderByDescending(candidate => candidate.Appearances.Select(appearance => appearance.SceneID).Distinct().Count())
.ThenBy(candidate => candidate.CharacterName)
.ThenBy(candidate => candidate.KnowledgeStatement)
.ToList();
return new KnowledgeCandidateData(hasCommittedScenes, alreadyLinked, candidates);
}
private static void AddSceneKnowledge(
Dictionary<string, KnowledgeCandidate> groups,
CharacterIndex characterIndex,
AssetIndex assetIndex,
IReadOnlyDictionary<string, CharacterKnowledgeItem> existingKnowledge,
Scene importedScene,
SceneIntelligenceScene parsed,
IReadOnlyList<KnowledgeState> knowledgeStates)
{
foreach (var change in parsed.KnowledgeChanges ?? [])
{
AddKnowledge(groups, characterIndex, assetIndex, existingKnowledge, importedScene, parsed, change.RecipientCharacter, change.KnowledgeItem, change.ChangeType, change.Evidence, change.Confidence, knowledgeStates);
}
foreach (var observation in parsed.Observations ?? [])
{
if (!IsKnowledgeObservation(observation))
{
continue;
}
AddKnowledge(
groups,
characterIndex,
assetIndex,
existingKnowledge,
importedScene,
parsed,
observation.SubjectName,
FirstConfigured(observation.Description, JoinConfigured(observation.Predicate, observation.ObjectName)),
FirstConfigured(observation.Predicate, observation.ObservationType),
observation.Evidence,
observation.Confidence,
knowledgeStates);
}
}
private static void AddKnowledge(
Dictionary<string, KnowledgeCandidate> groups,
CharacterIndex characterIndex,
AssetIndex assetIndex,
IReadOnlyDictionary<string, CharacterKnowledgeItem> existingKnowledge,
Scene importedScene,
SceneIntelligenceScene parsed,
string? characterName,
string? knowledgeItem,
string? changeType,
string? evidence,
decimal? confidence,
IReadOnlyList<KnowledgeState> knowledgeStates)
{
var character = characterIndex.Find(characterName);
var statement = CleanKnowledgeStatement(knowledgeItem);
if (character is null || string.IsNullOrWhiteSpace(statement))
{
return;
}
var key = KnowledgeKey(character.CharacterID, statement);
if (!groups.TryGetValue(key, out var candidate))
{
var existingKey = ExistingKnowledgeKey(character.CharacterID, statement);
existingKnowledge.TryGetValue(existingKey, out var existingMatch);
var stateName = KnowledgeStateFromSignal(changeType, statement, knowledgeStates);
var assetMatch = assetIndex.Find(statement);
candidate = new KnowledgeCandidate(
key,
character.CharacterID,
character.CharacterName,
statement,
stateName,
assetMatch?.StoryAssetID,
existingMatch?.CharacterKnowledgeID,
existingMatch is null ? null : ExistingKnowledgeLabel(existingMatch));
groups[key] = candidate;
}
if ((confidence ?? 0m) >= (candidate.Confidence ?? 0m))
{
candidate.KnowledgeStateName = KnowledgeStateFromSignal(changeType, statement, knowledgeStates);
}
candidate.Confidence = Max(candidate.Confidence, confidence);
candidate.FirstScene ??= BuildSceneReference(importedScene);
candidate.ExampleScene ??= BuildFirstAppearance(importedScene, parsed);
candidate.Evidence ??= Clean(evidence);
candidate.Appearances.Add(new KnowledgeAppearanceImport(
importedScene.SceneID,
importedScene.SceneNumber,
importedScene.SceneTitle,
changeType,
evidence,
confidence));
}
private async Task<IReadOnlyDictionary<string, CharacterKnowledgeItem>> ListExistingKnowledgeAsync(int projectId)
{
var rows = new Dictionary<string, CharacterKnowledgeItem>(StringComparer.OrdinalIgnoreCase);
foreach (var character in await characters.ListCharactersAsync(projectId))
{
foreach (var item in await characters.ListKnowledgeByCharacterAsync(character.CharacterID))
{
var key = ExistingKnowledgeKey(item.CharacterID, KnowledgeTitle(item));
if (!rows.ContainsKey(key))
{
rows[key] = item;
}
}
}
return rows;
}
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<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 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 bool IsKnowledgeObservation(SceneIntelligenceObservation observation)
{
var type = Clean(observation.ObservationType);
if (!type.Contains("knowledge", StringComparison.OrdinalIgnoreCase)
&& !type.Contains("discover", StringComparison.OrdinalIgnoreCase)
&& !type.Contains("belief", StringComparison.OrdinalIgnoreCase)
&& !type.Contains("suspect", StringComparison.OrdinalIgnoreCase))
{
return false;
}
return IsCharacterEntity(observation.SubjectEntityType)
&& !string.IsNullOrWhiteSpace(observation.SubjectName)
&& !string.IsNullOrWhiteSpace(FirstConfigured(observation.Description, observation.Predicate, observation.ObjectName));
}
private static string KnowledgeStateFromSignal(string? changeType, string? statement, IReadOnlyList<KnowledgeState> states)
{
var value = NormaliseForMatching($"{changeType} {statement}");
var preferred =
ContainsAny(value, "unaware", "does not know", "doesn't know", "not know", "forgets", "forgot", "stops believing") ? "Unaware" :
ContainsAny(value, "suspects", "suspect", "suspicion") ? "Suspects" :
ContainsAny(value, "misunderstands", "misunderstand", "mistaken", "false belief", "wrongly believes") ? "Misunderstands" :
ContainsAny(value, "believes", "belief", "partially", "may know") ? "Partially Knows" :
ContainsAny(value, "learns", "discovers", "confirms", "realises", "realizes", "is told", "knows", "reveals", "revealed") ? "Knows" :
"Knows";
return states.FirstOrDefault(state => string.Equals(state.StateName, preferred, StringComparison.OrdinalIgnoreCase))?.StateName
?? states.FirstOrDefault(state => string.Equals(state.StateName, "Knows", StringComparison.OrdinalIgnoreCase))?.StateName
?? states.FirstOrDefault()?.StateName
?? "Knows";
}
private static int ResolveKnowledgeStateID(IReadOnlyList<KnowledgeState> states, int? selectedKnowledgeStateId, string? suggestedState)
=> selectedKnowledgeStateId.HasValue && states.Any(state => state.KnowledgeStateID == selectedKnowledgeStateId.Value)
? selectedKnowledgeStateId.Value
: states.FirstOrDefault(state => string.Equals(state.StateName, suggestedState, StringComparison.OrdinalIgnoreCase))?.KnowledgeStateID
?? states.FirstOrDefault(state => string.Equals(state.StateName, "Knows", StringComparison.OrdinalIgnoreCase))?.KnowledgeStateID
?? states.First().KnowledgeStateID;
private static void SaveKnowledgeSelections(OnboardingStoryIntelligenceBatch batch, IEnumerable<StoryIntelligenceKnowledgeImportChoiceForm> choices)
{
foreach (var choice in choices)
{
if (string.IsNullOrWhiteSpace(choice.Key) || choice.KnowledgeStateID is not { } knowledgeStateId)
{
continue;
}
batch.KnowledgeSelections.RemoveAll(selection => string.Equals(selection.Key, choice.Key, StringComparison.OrdinalIgnoreCase));
batch.KnowledgeSelections.Add(new OnboardingStoryIntelligenceKnowledgeSelection
{
Key = choice.Key,
KnowledgeStateID = knowledgeStateId
});
}
}
private static void AddDecision(OnboardingStoryIntelligenceBatch batch, string key, string action, int? knowledgeId, string? label, bool createdOrLinked)
{
batch.KnowledgeDecisions.RemoveAll(decision => string.Equals(decision.Key, key, StringComparison.OrdinalIgnoreCase));
batch.KnowledgeDecisions.Add(new OnboardingStoryIntelligenceKnowledgeDecision
{
Key = key,
Action = action,
CharacterKnowledgeID = knowledgeId,
KnowledgeLabel = label,
CreatedOrLinked = createdOrLinked
});
}
private static string BuildKnowledgeDescription(KnowledgeCandidate candidate, KnowledgeAppearanceImport appearance)
{
var lines = new List<string> { candidate.KnowledgeStatement };
if (!string.IsNullOrWhiteSpace(appearance.ChangeType))
{
lines.Add($"Change: {Clean(appearance.ChangeType)}.");
}
if (!string.IsNullOrWhiteSpace(appearance.Evidence))
{
lines.Add($"Evidence: {Clean(appearance.Evidence)}");
}
lines.Add("Imported from Story Intelligence knowledge review.");
return string.Join(Environment.NewLine, lines);
}
private static string KnowledgeLabel(KnowledgeCandidate candidate)
=> $"{candidate.CharacterName}: {candidate.KnowledgeStatement}";
private static string ExistingKnowledgeLabel(CharacterKnowledgeItem item)
=> $"{item.CharacterName}: {item.KnowledgeStateName} - {KnowledgeTitle(item)}";
private static string KnowledgeTitle(CharacterKnowledgeItem item)
=> CleanKnowledgeStatement(item.Description);
private static string CleanKnowledgeStatement(string? value)
{
var cleaned = Clean(value);
if (string.IsNullOrWhiteSpace(cleaned))
{
return string.Empty;
}
foreach (var prefix in new[] { "Knowledge:", "Evidence:", "Change:" })
{
if (cleaned.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
{
cleaned = cleaned[prefix.Length..].Trim();
}
}
var firstLine = cleaned.Split('\n', '\r').Select(line => line.Trim()).FirstOrDefault(line => !string.IsNullOrWhiteSpace(line));
return firstLine ?? cleaned;
}
private static string KnowledgeKey(int characterId, string statement)
=> $"{characterId}:{CanonicalKnowledgeKey(statement)}";
private static string ExistingKnowledgeKey(int characterId, string statement)
=> KnowledgeKey(characterId, statement);
private static string CanonicalKnowledgeKey(string? value)
{
var normalised = NormaliseForMatching(value);
var possessiveFamily = Regex.Match(
normalised,
@"\b(?<subject>\w+)\s+(?:is|was)\s+(?<holder>\w+)\s+s\s+(?:biological\s+)?(?<relation>father|mother|parent|sister|brother|sibling|aunt|uncle|cousin|child|son|daughter)\b",
RegexOptions.IgnoreCase);
if (possessiveFamily.Success)
{
return $"{possessiveFamily.Groups["relation"].Value} {possessiveFamily.Groups["subject"].Value} {possessiveFamily.Groups["holder"].Value}";
}
var pronounFamily = Regex.Match(
normalised,
@"\b(?<holder>\w+)\s+.*?\b(?<subject>\w+)\s+(?:is|was)\s+(?:her|his|their)\s+(?:biological\s+)?(?<relation>father|mother|parent|sister|brother|sibling|aunt|uncle|cousin|child|son|daughter)\b",
RegexOptions.IgnoreCase);
if (pronounFamily.Success)
{
return $"{pronounFamily.Groups["relation"].Value} {pronounFamily.Groups["subject"].Value} {pronounFamily.Groups["holder"].Value}";
}
normalised = Regex.Replace(normalised, @"\b(discovers?|learns?|realises?|realizes?|knows?|believes?|suspects?|confirms?|is told|that|the|a|an|his|her|their|its|about)\b", " ", RegexOptions.IgnoreCase);
return Whitespace.Replace(normalised, " ").Trim();
}
private static string? BuildFirstAppearance(Scene scene, SceneIntelligenceScene parsed)
{
var summary = Clean(parsed.Summary?.Short);
return string.IsNullOrWhiteSpace(summary)
? BuildSceneReference(scene)
: $"{BuildSceneReference(scene)}: {summary}";
}
private static string BuildSceneReference(Scene scene)
=> $"Scene {scene.SceneNumber:g}";
private static bool IsCharacterEntity(string? entityType)
=> string.Equals(Clean(entityType), "Character", StringComparison.OrdinalIgnoreCase);
private static string ConfidenceBand(decimal? confidence)
=> confidence switch
{
>= 0.75m => "High",
>= 0.45m => "Medium",
null => "Unknown",
_ => "Low"
};
private static decimal? Max(decimal? current, decimal? next)
=> current.HasValue && next.HasValue ? Math.Max(current.Value, next.Value) : current ?? next;
private static string? JoinConfigured(params string?[] values)
{
var configured = values.Select(Clean).Where(value => !string.IsNullOrWhiteSpace(value)).ToList();
return configured.Count == 0 ? null : string.Join(" ", configured);
}
private static string? FirstConfigured(params string?[] values)
=> values.Select(Clean).FirstOrDefault(value => !string.IsNullOrWhiteSpace(value));
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 sealed record KnowledgeCandidateData(bool HasCommittedScenes, int AlreadyLinkedCount, IReadOnlyList<KnowledgeCandidate> Candidates);
private sealed class KnowledgeCandidate(
string key,
int characterId,
string characterName,
string knowledgeStatement,
string knowledgeStateName,
int? storyAssetId,
int? existingKnowledgeId,
string? existingKnowledgeLabel)
{
public string Key { get; } = key;
public int CharacterID { get; } = characterId;
public string CharacterName { get; } = characterName;
public string KnowledgeStatement { get; } = knowledgeStatement;
public string KnowledgeStateName { get; set; } = knowledgeStateName;
public int? StoryAssetID { get; } = storyAssetId;
public int? ExistingKnowledgeID { get; } = existingKnowledgeId;
public string? ExistingKnowledgeLabel { get; } = existingKnowledgeLabel;
public decimal? Confidence { get; set; }
public string? FirstScene { get; set; }
public string? ExampleScene { get; set; }
public string? Evidence { get; set; }
public List<KnowledgeAppearanceImport> Appearances { get; } = [];
}
private sealed record KnowledgeAppearanceImport(
int SceneID,
decimal SceneNumber,
string SceneTitle,
string? ChangeType,
string? Evidence,
decimal? Confidence);
private sealed class CharacterIndex
{
private readonly Dictionary<string, CharacterMatch> byName = new(StringComparer.OrdinalIgnoreCase);
public void Add(string? name, int characterId, string characterName)
{
var key = Clean(name);
if (string.IsNullOrWhiteSpace(key) || byName.ContainsKey(key))
{
return;
}
byName[key] = new CharacterMatch(characterId, characterName);
}
public CharacterMatch? Find(string? name)
=> byName.TryGetValue(Clean(name), out var match) ? match : null;
}
private sealed class AssetIndex
{
private readonly Dictionary<string, AssetMatch> byName = new(StringComparer.OrdinalIgnoreCase);
public void Add(string? name, int storyAssetId, string assetName)
{
var key = Clean(name);
if (string.IsNullOrWhiteSpace(key) || byName.ContainsKey(key))
{
return;
}
byName[key] = new AssetMatch(storyAssetId, assetName);
}
public AssetMatch? Find(string? statement)
{
var value = NormaliseForMatching(statement);
return byName.Values.FirstOrDefault(asset => value.Contains(NormaliseForMatching(asset.AssetName), StringComparison.Ordinal));
}
}
private sealed record CharacterMatch(int CharacterID, string CharacterName);
private sealed record AssetMatch(int StoryAssetID, string AssetName);
}