From 1dbf49ca4c43eff6dccb29249dda3d274d56bf7a Mon Sep 17 00:00:00 2001 From: Nick Beckley Date: Sun, 5 Jul 2026 19:34:23 +0100 Subject: [PATCH] =?UTF-8?q?Phase=2020S=20=E2=80=93=20Story=20Intelligence?= =?UTF-8?q?=20Entity=20Resolution=20&=20Import=20Preview?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../StoryIntelligenceImportPreviewModels.cs | 84 ++++ PlotLine/Program.cs | 1 + PlotLine/Services/SceneImportResolver.cs | 384 ++++++++++++++++++ ...oryIntelligenceResultPersistenceService.cs | 5 +- .../ViewModels/FeatureRequestViewModels.cs | 1 + .../Admin/StoryIntelligenceRunDetails.cshtml | 136 +++++++ 6 files changed, 610 insertions(+), 1 deletion(-) create mode 100644 PlotLine/Models/StoryIntelligenceImportPreviewModels.cs create mode 100644 PlotLine/Services/SceneImportResolver.cs diff --git a/PlotLine/Models/StoryIntelligenceImportPreviewModels.cs b/PlotLine/Models/StoryIntelligenceImportPreviewModels.cs new file mode 100644 index 0000000..3773f7c --- /dev/null +++ b/PlotLine/Models/StoryIntelligenceImportPreviewModels.cs @@ -0,0 +1,84 @@ +namespace PlotLine.Models; + +public static class StoryImportResolutionStatuses +{ + public const string Matched = "Matched"; + public const string NewCharacter = "New Character"; + public const string NewLocation = "New Location"; + public const string NewAsset = "New Asset"; + public const string Ambiguous = "Ambiguous"; + public const string UnresolvedPersonReference = "Unresolved Person Reference"; + public const string GenericReference = "Generic Reference"; + public const string Ignored = "Ignored"; +} + +public sealed class StoryIntelligenceImportPreview +{ + public IReadOnlyList Characters { get; init; } = []; + public IReadOnlyList Locations { get; init; } = []; + public IReadOnlyList Assets { get; init; } = []; + public IReadOnlyList Relationships { get; init; } = []; + public IReadOnlyList Knowledge { get; init; } = []; + public IReadOnlyList Timeline { get; init; } = []; + public IReadOnlyList Warnings { get; init; } = []; +} + +public sealed class StoryImportEntityResolution +{ + public string InputName { get; init; } = string.Empty; + public string Status { get; init; } = string.Empty; + public string EntityType { get; init; } = string.Empty; + public string? MatchedName { get; init; } + public int? MatchedID { get; init; } + public decimal? Confidence { get; init; } + public IReadOnlyList Candidates { get; init; } = []; + public string? Notes { get; init; } + public string ConfidenceBand => ConfidenceToBand(Confidence); + public int? ConfidencePercent => Confidence.HasValue ? (int)Math.Round(Confidence.Value * 100m) : null; + + private static string ConfidenceToBand(decimal? confidence) + => confidence switch + { + >= 0.90m => "High", + >= 0.70m => "Medium", + null => "Unknown", + _ => "Low" + }; +} + +public sealed class StoryImportResolutionCandidate +{ + public int EntityID { get; init; } + public string Name { get; init; } = string.Empty; + public string MatchReason { get; init; } = string.Empty; +} + +public sealed class StoryImportRelationshipPreview +{ + public string Action { get; init; } = string.Empty; + public string CharacterA { get; init; } = string.Empty; + public string CharacterB { get; init; } = string.Empty; + public string Signal { get; init; } = string.Empty; + public decimal? Confidence { get; init; } + public int? ConfidencePercent => Confidence.HasValue ? (int)Math.Round(Confidence.Value * 100m) : null; +} + +public sealed class StoryImportKnowledgePreview +{ + public string Action { get; init; } = "Would Update Knowledge"; + public string Character { get; init; } = string.Empty; + public string KnowledgeItem { get; init; } = string.Empty; + public string OldState { get; init; } = "Unknown"; + public string NewState { get; init; } = string.Empty; + public decimal? Confidence { get; init; } + public int? ConfidencePercent => Confidence.HasValue ? (int)Math.Round(Confidence.Value * 100m) : null; +} + +public sealed class StoryImportTimelinePreview +{ + public string Action { get; init; } = "Would Create Scene Timeline Entry"; + public int? SceneNumber { get; init; } + public string SceneDate { get; init; } = string.Empty; + public decimal? Confidence { get; init; } + public int? ConfidencePercent => Confidence.HasValue ? (int)Math.Round(Confidence.Value * 100m) : null; +} diff --git a/PlotLine/Program.cs b/PlotLine/Program.cs index 7525af0..531a132 100644 --- a/PlotLine/Program.cs +++ b/PlotLine/Program.cs @@ -203,6 +203,7 @@ public class Program builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/PlotLine/Services/SceneImportResolver.cs b/PlotLine/Services/SceneImportResolver.cs new file mode 100644 index 0000000..02573ac --- /dev/null +++ b/PlotLine/Services/SceneImportResolver.cs @@ -0,0 +1,384 @@ +using System.Text.Json; +using PlotLine.Data; +using PlotLine.Models; + +namespace PlotLine.Services; + +public interface ISceneImportResolver +{ + Task ResolveAsync(StoryIntelligenceSavedRun run, IReadOnlyList sceneResults); +} + +public sealed class SceneImportResolver( + ICharacterRepository characters, + ILocationRepository locations, + IAssetRepository assets, + ILogger logger) : ISceneImportResolver +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true + }; + + private static readonly HashSet GenericPeople = CreateSet( + "the receptionist", "receptionist", "old man", "young woman", "police officer", "doctor", "nurse", "driver", + "man", "woman", "boy", "girl", "child", "person", "guard", "waiter", "waitress", "clerk", "shopkeeper"); + + private static readonly HashSet IgnoredPovNames = CreateSet("narrator", "unknown"); + + private static readonly HashSet GenericLocations = CreateSet( + "car park", "road", "house", "kitchen", "stairwell", "stair", "doorway", "corridor", "street", "room", + "bedroom", "bathroom", "office", "car", "church", "hall", "table", "chair"); + + private static readonly HashSet GenericAssets = CreateSet( + "the key", "key", "the letter", "letter", "the car", "car", "phone", "table", "chair", "door", "window"); + + public async Task ResolveAsync(StoryIntelligenceSavedRun run, IReadOnlyList sceneResults) + { + if (run.ProjectID is not int projectId) + { + return new StoryIntelligenceImportPreview + { + Warnings = ["Import Preview requires a project id to resolve existing PlotDirector entities."] + }; + } + + var scenes = sceneResults + .Where(scene => scene.ValidationErrorsCount == 0) + .Select(TryReadScene) + .Where(scene => scene is not null) + .Cast() + .ToList(); + + var characterIndex = await BuildCharacterIndexAsync(projectId); + var locationIndex = await BuildLocationIndexAsync(projectId); + var assetIndex = await BuildAssetIndexAsync(projectId); + var existingRelationships = await characters.ListRelationshipsByProjectAsync(projectId); + + var characterResolutions = ResolveCharacters(scenes, characterIndex).ToList(); + var locationResolutions = ResolveLocations(scenes, locationIndex).ToList(); + var assetResolutions = ResolveAssets(scenes, assetIndex).ToList(); + var relationshipPreview = ResolveRelationships(scenes, existingRelationships).ToList(); + var knowledgePreview = ResolveKnowledge(scenes).ToList(); + var timelinePreview = ResolveTimeline(scenes).ToList(); + var warnings = BuildWarnings(characterResolutions, locationResolutions, assetResolutions).ToList(); + + return new StoryIntelligenceImportPreview + { + Characters = characterResolutions, + Locations = locationResolutions, + Assets = assetResolutions, + Relationships = relationshipPreview, + Knowledge = knowledgePreview, + Timeline = timelinePreview, + Warnings = warnings + }; + } + + private IEnumerable ResolveCharacters(IEnumerable scenes, EntityIndex index) + { + foreach (var item in scenes + .SelectMany(scene => (scene.Characters ?? []).Select(character => new CharacterMention(character.Name, character.Confidence, character.Notes))) + .Concat(scenes.Select(scene => new CharacterMention(scene.PointOfView?.CharacterName, scene.PointOfView?.Confidence, "Point of view character."))) + .Where(item => !string.IsNullOrWhiteSpace(item.Name)) + .GroupBy(item => Clean(item.Name), StringComparer.OrdinalIgnoreCase) + .Select(group => group.First())) + { + var name = Clean(item.Name); + if (IgnoredPovNames.Contains(name)) + { + yield return LogResolution(new StoryImportEntityResolution + { + InputName = name, + EntityType = "Character", + Status = StoryImportResolutionStatuses.Ignored, + Confidence = item.Confidence, + Notes = "Narrator identity is intentionally left for later chapter metadata resolution." + }); + continue; + } + + if (GenericPeople.Contains(name.ToLowerInvariant())) + { + yield return LogResolution(new StoryImportEntityResolution + { + InputName = name, + EntityType = "Character", + Status = StoryImportResolutionStatuses.UnresolvedPersonReference, + Confidence = item.Confidence, + Notes = "Generic person reference; would not create a Character record." + }); + continue; + } + + yield return LogResolution(MatchEntity("Character", name, item.Confidence, index, StoryImportResolutionStatuses.NewCharacter)); + } + } + + private IEnumerable ResolveLocations(IEnumerable scenes, EntityIndex index) + { + foreach (var item in scenes + .SelectMany(scene => scene.Locations ?? []) + .Where(location => !string.IsNullOrWhiteSpace(location.Name)) + .GroupBy(location => Clean(location.Name), StringComparer.OrdinalIgnoreCase) + .Select(group => group.First())) + { + var name = Clean(item.Name); + if (IsGenericLocation(item)) + { + yield return LogResolution(new StoryImportEntityResolution + { + InputName = name, + EntityType = "Location", + Status = StoryImportResolutionStatuses.GenericReference, + Confidence = item.Confidence, + Notes = "Generic location reference; would not create a Location record." + }); + continue; + } + + yield return LogResolution(MatchEntity("Location", name, item.Confidence, index, StoryImportResolutionStatuses.NewLocation)); + } + } + + private IEnumerable ResolveAssets(IEnumerable scenes, EntityIndex index) + { + foreach (var item in scenes + .SelectMany(scene => scene.Assets ?? []) + .Where(asset => !string.IsNullOrWhiteSpace(asset.Name)) + .GroupBy(asset => Clean(asset.Name), StringComparer.OrdinalIgnoreCase) + .Select(group => group.First())) + { + var name = Clean(item.Name); + if (GenericAssets.Contains(name.ToLowerInvariant())) + { + yield return LogResolution(new StoryImportEntityResolution + { + InputName = name, + EntityType = "Asset", + Status = StoryImportResolutionStatuses.GenericReference, + Confidence = item.Confidence, + Notes = "Generic object reference; would not create an Asset record unless later confirmed as meaningful." + }); + continue; + } + + yield return LogResolution(MatchEntity("Asset", name, item.Confidence, index, StoryImportResolutionStatuses.NewAsset)); + } + } + + private static IEnumerable ResolveRelationships(IEnumerable scenes, IReadOnlyList existingRelationships) + { + foreach (var relationship in scenes.SelectMany(scene => scene.Relationships ?? [])) + { + if (string.IsNullOrWhiteSpace(relationship.CharacterA) || string.IsNullOrWhiteSpace(relationship.CharacterB)) + { + continue; + } + + var exists = existingRelationships.Any(existing => + NamesMatch(existing.CharacterAName, relationship.CharacterA) && NamesMatch(existing.CharacterBName, relationship.CharacterB) + || NamesMatch(existing.CharacterAName, relationship.CharacterB) && NamesMatch(existing.CharacterBName, relationship.CharacterA)); + + yield return new StoryImportRelationshipPreview + { + Action = exists ? "Would Update Relationship" : "Would Create Relationship", + CharacterA = relationship.CharacterA, + CharacterB = relationship.CharacterB, + Signal = relationship.RelationshipSignal ?? "relationship signal", + Confidence = relationship.Confidence + }; + } + } + + private static IEnumerable ResolveKnowledge(IEnumerable scenes) + => scenes.SelectMany(scene => scene.KnowledgeChanges ?? []) + .Where(change => !string.IsNullOrWhiteSpace(change.RecipientCharacter) && !string.IsNullOrWhiteSpace(change.KnowledgeItem)) + .Select(change => new StoryImportKnowledgePreview + { + Character = change.RecipientCharacter!, + KnowledgeItem = change.KnowledgeItem!, + OldState = "Unknown", + NewState = string.IsNullOrWhiteSpace(change.ChangeType) ? "Would update" : change.ChangeType!, + Confidence = change.Confidence + }); + + private static IEnumerable ResolveTimeline(IEnumerable scenes) + => scenes.SelectMany(scene => (scene.TimelineClues ?? []).Select(clue => new StoryImportTimelinePreview + { + SceneNumber = scene.SceneReference?.SceneNumber.HasValue == true ? Convert.ToInt32(scene.SceneReference.SceneNumber.Value) : null, + SceneDate = !string.IsNullOrWhiteSpace(clue.AbsoluteDate) ? clue.AbsoluteDate! : clue.Clue ?? "Timeline clue", + Confidence = clue.Confidence + })); + + private StoryImportEntityResolution MatchEntity(string entityType, string inputName, decimal? confidence, EntityIndex index, string newStatus) + { + var candidates = index.Find(inputName); + return candidates.Count switch + { + 0 => new StoryImportEntityResolution { InputName = inputName, EntityType = entityType, Status = newStatus, Confidence = confidence }, + 1 => new StoryImportEntityResolution + { + InputName = inputName, + EntityType = entityType, + Status = StoryImportResolutionStatuses.Matched, + MatchedID = candidates[0].EntityID, + MatchedName = candidates[0].Name, + Confidence = confidence, + Candidates = candidates + }, + _ => new StoryImportEntityResolution + { + InputName = inputName, + EntityType = entityType, + Status = StoryImportResolutionStatuses.Ambiguous, + Confidence = confidence, + Candidates = candidates + } + }; + } + + private async Task BuildCharacterIndexAsync(int projectId) + { + var rows = new List(); + foreach (var character in await characters.ListCharactersAsync(projectId)) + { + rows.Add(new EntityIndexRow(character.CharacterID, character.CharacterName, character.CharacterName, "Name")); + if (!string.IsNullOrWhiteSpace(character.ShortName)) + { + rows.Add(new EntityIndexRow(character.CharacterID, character.CharacterName, character.ShortName!, "Display name")); + } + + foreach (var alias in await characters.ListAliasesAsync(character.CharacterID)) + { + rows.Add(new EntityIndexRow(character.CharacterID, character.CharacterName, alias.Alias, "Alias")); + } + } + + return new EntityIndex(rows); + } + + private async Task BuildLocationIndexAsync(int projectId) + { + var rows = new List(); + foreach (var location in await locations.ListByProjectAsync(projectId)) + { + rows.Add(new EntityIndexRow(location.LocationID, location.LocationName, location.LocationName, "Name")); + foreach (var alias in await locations.ListAliasesAsync(location.LocationID)) + { + rows.Add(new EntityIndexRow(location.LocationID, location.LocationName, alias.Alias, "Alias")); + } + } + + return new EntityIndex(rows); + } + + private async Task BuildAssetIndexAsync(int projectId) + { + var rows = new List(); + foreach (var asset in await assets.ListAssetsAsync(projectId)) + { + rows.Add(new EntityIndexRow(asset.StoryAssetID, asset.AssetName, asset.AssetName, "Name")); + foreach (var alias in await assets.ListAliasesAsync(asset.StoryAssetID)) + { + rows.Add(new EntityIndexRow(asset.StoryAssetID, asset.AssetName, alias.Alias, "Alias")); + } + } + + return new EntityIndex(rows); + } + + private StoryImportEntityResolution LogResolution(StoryImportEntityResolution resolution) + { + logger.LogInformation( + "Story Intelligence Import Preview resolved {EntityType}. Input={InputName} Status={Status} Resolved={ResolvedName} Confidence={ConfidencePercent} Candidates={Candidates}", + resolution.EntityType, + resolution.InputName, + resolution.Status, + resolution.MatchedName, + resolution.ConfidencePercent, + string.Join(", ", resolution.Candidates.Select(candidate => candidate.Name))); + return resolution; + } + + private static IEnumerable BuildWarnings( + IReadOnlyList characterResolutions, + IReadOnlyList locationResolutions, + IReadOnlyList assetResolutions) + { + var unresolved = characterResolutions.Concat(locationResolutions).Concat(assetResolutions) + .Count(item => item.Status is StoryImportResolutionStatuses.Ambiguous or StoryImportResolutionStatuses.UnresolvedPersonReference); + yield return unresolved == 0 ? "No unresolved entities." : $"{unresolved:N0} unresolved or ambiguous reference(s)."; + } + + private static SceneIntelligenceScene? TryReadScene(StoryIntelligenceSavedSceneResult result) + { + if (string.IsNullOrWhiteSpace(result.ParsedJson)) + { + return null; + } + + try + { + var direct = JsonSerializer.Deserialize(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(JsonOptions); + } + } + catch (JsonException) + { + } + + return null; + } + + private static bool IsGenericLocation(SceneIntelligenceLocation location) + => string.Equals(location.LocationType, "generic", StringComparison.OrdinalIgnoreCase) + || GenericLocations.Contains(Clean(location.Name).ToLowerInvariant()); + + private static string Clean(string? value) + => string.Join(' ', (value ?? string.Empty).Trim().Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)); + + private static bool NamesMatch(string? first, string? second) + => string.Equals(Clean(first), Clean(second), StringComparison.OrdinalIgnoreCase); + + private static HashSet CreateSet(params string[] values) + => new(values, StringComparer.OrdinalIgnoreCase); + + private sealed record EntityIndexRow(int EntityID, string EntityName, string MatchValue, string MatchReason); + + private sealed record CharacterMention(string? Name, decimal? Confidence, string? Notes); + + private sealed class EntityIndex(IReadOnlyList rows) + { + public IReadOnlyList Find(string value) + { + var clean = Clean(value); + var matches = rows + .Where(row => string.Equals(Clean(row.MatchValue), clean, StringComparison.Ordinal) + || string.Equals(Clean(row.MatchValue), clean, StringComparison.OrdinalIgnoreCase)) + .GroupBy(row => row.EntityID) + .Select(group => + { + var first = group.First(); + return new StoryImportResolutionCandidate + { + EntityID = first.EntityID, + Name = first.EntityName, + MatchReason = first.MatchReason + }; + }) + .ToList(); + + return matches; + } + } +} diff --git a/PlotLine/Services/StoryIntelligenceResultPersistenceService.cs b/PlotLine/Services/StoryIntelligenceResultPersistenceService.cs index 1112c12..3890d7c 100644 --- a/PlotLine/Services/StoryIntelligenceResultPersistenceService.cs +++ b/PlotLine/Services/StoryIntelligenceResultPersistenceService.cs @@ -17,6 +17,7 @@ public interface IStoryIntelligenceResultPersistenceService public sealed class StoryIntelligenceResultPersistenceService( IStoryIntelligenceResultRepository repository, IStoryIntelligenceClient client, + ISceneImportResolver importResolver, ILogger logger) : IStoryIntelligenceResultPersistenceService { private static readonly JsonSerializerOptions JsonOptions = new() @@ -104,11 +105,13 @@ public sealed class StoryIntelligenceResultPersistenceService( return null; } + var sceneResults = await repository.ListSceneResultsAsync(runId); return new StoryIntelligenceSavedRunDetailViewModel { Run = run, ChapterResult = await repository.GetChapterResultAsync(runId), - SceneResults = await repository.ListSceneResultsAsync(runId) + SceneResults = sceneResults, + ImportPreview = await importResolver.ResolveAsync(run, sceneResults) }; } diff --git a/PlotLine/ViewModels/FeatureRequestViewModels.cs b/PlotLine/ViewModels/FeatureRequestViewModels.cs index 02b10ee..c4c618a 100644 --- a/PlotLine/ViewModels/FeatureRequestViewModels.cs +++ b/PlotLine/ViewModels/FeatureRequestViewModels.cs @@ -292,6 +292,7 @@ public sealed class StoryIntelligenceSavedRunDetailViewModel public StoryIntelligenceSavedRun? Run { get; init; } public StoryIntelligenceSavedChapterResult? ChapterResult { get; init; } public IReadOnlyList SceneResults { get; init; } = []; + public StoryIntelligenceImportPreview? ImportPreview { get; init; } } public sealed class AdminFeatureRequestFilter diff --git a/PlotLine/Views/Admin/StoryIntelligenceRunDetails.cshtml b/PlotLine/Views/Admin/StoryIntelligenceRunDetails.cshtml index ab9a90c..737efe4 100644 --- a/PlotLine/Views/Admin/StoryIntelligenceRunDetails.cshtml +++ b/PlotLine/Views/Admin/StoryIntelligenceRunDetails.cshtml @@ -219,6 +219,78 @@ else } +
+

Import Preview

+ @if (Model.ImportPreview is null) + { +

No import preview is available.

+ } + else + { + var preview = Model.ImportPreview; +
Dry run only. No PlotDirector entities are created or updated from this preview.
+ +

Characters

+ @RenderEntityList(preview.Characters) + +

Locations

+ @RenderEntityList(preview.Locations) + +

Assets

+ @RenderEntityList(preview.Assets) + +

Relationships

+ @if (preview.Relationships.Count == 0) + { +

No relationship changes detected.

+ } + else + { +
    + @foreach (var relationship in preview.Relationships) + { +
  • @relationship.Action: @relationship.CharacterA - @relationship.Signal - @relationship.CharacterB (@DisplayConfidence(relationship.ConfidencePercent))
  • + } +
+ } + +

Knowledge

+ @if (preview.Knowledge.Count == 0) + { +

No knowledge changes detected.

+ } + else + { +
    + @foreach (var item in preview.Knowledge) + { +
  • @item.Action: @item.Character - @item.KnowledgeItem (@item.OldState -> @item.NewState, @DisplayConfidence(item.ConfidencePercent))
  • + } +
+ } + +

Timeline

+

@preview.Timeline.Count.ToString("N0") scene timeline entr@(preview.Timeline.Count == 1 ? "y" : "ies") would be created.

+ @if (preview.Timeline.Count > 0) + { +
    + @foreach (var item in preview.Timeline.Take(20)) + { +
  • Scene @Display(item.SceneNumber): @item.SceneDate (@DisplayConfidence(item.ConfidencePercent))
  • + } +
+ } + +

Warnings

+
    + @foreach (var warning in preview.Warnings) + { +
  • @warning
  • + } +
+ } +
+ @foreach (var scene in Model.SceneResults) {
@@ -284,6 +356,70 @@ else private static string DisplayCost(decimal? value, string currency) => value.HasValue ? $"{value.Value:0.000000} {currency}" : "None"; + private static string DisplayConfidence(int? percent) + => percent.HasValue + ? percent.Value >= 90 + ? $"{percent.Value}% High" + : percent.Value >= 70 + ? $"{percent.Value}% Medium" + : $"{percent.Value}% Low" + : "Unknown"; + + private static Microsoft.AspNetCore.Html.IHtmlContent RenderEntityList(IReadOnlyList items) + { + if (items.Count == 0) + { + return new Microsoft.AspNetCore.Html.HtmlString("

No items detected.

"); + } + + var builder = new System.Text.StringBuilder(); + foreach (var group in items.GroupBy(item => item.Status)) + { + builder.Append("

").Append(Encode(group.Key)).Append("

    "); + foreach (var item in group) + { + builder.Append("
  • "); + builder.Append(IconForStatus(item.Status)).Append(' '); + builder.Append(Encode(item.MatchedName ?? item.InputName)); + if (!string.IsNullOrWhiteSpace(item.MatchedName) && !string.Equals(item.MatchedName, item.InputName, StringComparison.OrdinalIgnoreCase)) + { + builder.Append(" from ").Append(Encode(item.InputName)).Append(""); + } + + builder.Append(" (").Append(Encode(DisplayConfidence(item.ConfidencePercent))).Append(")"); + if (item.Candidates.Count > 1) + { + builder.Append("
    Candidates: ") + .Append(Encode(string.Join(", ", item.Candidates.Select(candidate => candidate.Name)))) + .Append(""); + } + + if (!string.IsNullOrWhiteSpace(item.Notes)) + { + builder.Append("
    ").Append(Encode(item.Notes)).Append(""); + } + + builder.Append("
  • "); + } + + builder.Append("
"); + } + + return new Microsoft.AspNetCore.Html.HtmlString(builder.ToString()); + } + + private static string IconForStatus(string status) + => status switch + { + StoryImportResolutionStatuses.Matched => "✓", + StoryImportResolutionStatuses.Ambiguous => "⚠", + StoryImportResolutionStatuses.NewCharacter or StoryImportResolutionStatuses.NewLocation or StoryImportResolutionStatuses.NewAsset => "+", + _ => "•" + }; + + private static string Encode(string? value) + => System.Net.WebUtility.HtmlEncode(value ?? string.Empty); + private static string ChapterStructureModelUsed(StoryIntelligenceSavedRunDetailViewModel model) => !string.IsNullOrWhiteSpace(model.ChapterResult?.Model) ? model.ChapterResult.Model