731 lines
32 KiB
C#
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);
|
|
}
|