Phase 20S – Story Intelligence Entity Resolution & Import Preview

This commit is contained in:
Nick Beckley 2026-07-05 19:34:23 +01:00
parent 6038d2144a
commit 1dbf49ca4c
6 changed files with 610 additions and 1 deletions

View File

@ -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<StoryImportEntityResolution> Characters { get; init; } = [];
public IReadOnlyList<StoryImportEntityResolution> Locations { get; init; } = [];
public IReadOnlyList<StoryImportEntityResolution> Assets { get; init; } = [];
public IReadOnlyList<StoryImportRelationshipPreview> Relationships { get; init; } = [];
public IReadOnlyList<StoryImportKnowledgePreview> Knowledge { get; init; } = [];
public IReadOnlyList<StoryImportTimelinePreview> Timeline { get; init; } = [];
public IReadOnlyList<string> 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<StoryImportResolutionCandidate> 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;
}

View File

@ -203,6 +203,7 @@ public class Program
builder.Services.AddScoped<IStoryIntelligenceResultPersistenceService, StoryIntelligenceResultPersistenceService>();
builder.Services.AddScoped<IStoryIntelligenceExistingChapterQueueService, StoryIntelligenceExistingChapterQueueService>();
builder.Services.AddScoped<IStoryIntelligenceFullChapterTestService, StoryIntelligenceFullChapterTestService>();
builder.Services.AddScoped<ISceneImportResolver, SceneImportResolver>();
builder.Services.AddScoped<IPersistedStoryIntelligenceRunner, PersistedStoryIntelligenceRunner>();
builder.Services.AddScoped<IProjectAccessService, ProjectAccessService>();
builder.Services.AddScoped<ISubscriptionService, SubscriptionService>();

View File

@ -0,0 +1,384 @@
using System.Text.Json;
using PlotLine.Data;
using PlotLine.Models;
namespace PlotLine.Services;
public interface ISceneImportResolver
{
Task<StoryIntelligenceImportPreview> ResolveAsync(StoryIntelligenceSavedRun run, IReadOnlyList<StoryIntelligenceSavedSceneResult> sceneResults);
}
public sealed class SceneImportResolver(
ICharacterRepository characters,
ILocationRepository locations,
IAssetRepository assets,
ILogger<SceneImportResolver> logger) : ISceneImportResolver
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true
};
private static readonly HashSet<string> 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<string> IgnoredPovNames = CreateSet("narrator", "unknown");
private static readonly HashSet<string> 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<string> GenericAssets = CreateSet(
"the key", "key", "the letter", "letter", "the car", "car", "phone", "table", "chair", "door", "window");
public async Task<StoryIntelligenceImportPreview> ResolveAsync(StoryIntelligenceSavedRun run, IReadOnlyList<StoryIntelligenceSavedSceneResult> 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<SceneIntelligenceScene>()
.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<StoryImportEntityResolution> ResolveCharacters(IEnumerable<SceneIntelligenceScene> 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<StoryImportEntityResolution> ResolveLocations(IEnumerable<SceneIntelligenceScene> 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<StoryImportEntityResolution> ResolveAssets(IEnumerable<SceneIntelligenceScene> 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<StoryImportRelationshipPreview> ResolveRelationships(IEnumerable<SceneIntelligenceScene> scenes, IReadOnlyList<CharacterRelationship> 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<StoryImportKnowledgePreview> ResolveKnowledge(IEnumerable<SceneIntelligenceScene> 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<StoryImportTimelinePreview> ResolveTimeline(IEnumerable<SceneIntelligenceScene> 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<EntityIndex> BuildCharacterIndexAsync(int projectId)
{
var rows = new List<EntityIndexRow>();
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<EntityIndex> BuildLocationIndexAsync(int projectId)
{
var rows = new List<EntityIndexRow>();
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<EntityIndex> BuildAssetIndexAsync(int projectId)
{
var rows = new List<EntityIndexRow>();
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<string> BuildWarnings(
IReadOnlyList<StoryImportEntityResolution> characterResolutions,
IReadOnlyList<StoryImportEntityResolution> locationResolutions,
IReadOnlyList<StoryImportEntityResolution> 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<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 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<string> 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<EntityIndexRow> rows)
{
public IReadOnlyList<StoryImportResolutionCandidate> 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;
}
}
}

View File

@ -17,6 +17,7 @@ public interface IStoryIntelligenceResultPersistenceService
public sealed class StoryIntelligenceResultPersistenceService(
IStoryIntelligenceResultRepository repository,
IStoryIntelligenceClient client,
ISceneImportResolver importResolver,
ILogger<StoryIntelligenceResultPersistenceService> 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)
};
}

View File

@ -292,6 +292,7 @@ public sealed class StoryIntelligenceSavedRunDetailViewModel
public StoryIntelligenceSavedRun? Run { get; init; }
public StoryIntelligenceSavedChapterResult? ChapterResult { get; init; }
public IReadOnlyList<StoryIntelligenceSavedSceneResult> SceneResults { get; init; } = [];
public StoryIntelligenceImportPreview? ImportPreview { get; init; }
}
public sealed class AdminFeatureRequestFilter

View File

@ -219,6 +219,78 @@ else
}
</section>
<section class="edit-panel">
<h2>Import Preview</h2>
@if (Model.ImportPreview is null)
{
<p class="mb-0">No import preview is available.</p>
}
else
{
var preview = Model.ImportPreview;
<div class="alert alert-info">Dry run only. No PlotDirector entities are created or updated from this preview.</div>
<h3>Characters</h3>
@RenderEntityList(preview.Characters)
<h3>Locations</h3>
@RenderEntityList(preview.Locations)
<h3>Assets</h3>
@RenderEntityList(preview.Assets)
<h3>Relationships</h3>
@if (preview.Relationships.Count == 0)
{
<p>No relationship changes detected.</p>
}
else
{
<ul>
@foreach (var relationship in preview.Relationships)
{
<li>@relationship.Action: @relationship.CharacterA - @relationship.Signal - @relationship.CharacterB (@DisplayConfidence(relationship.ConfidencePercent))</li>
}
</ul>
}
<h3>Knowledge</h3>
@if (preview.Knowledge.Count == 0)
{
<p>No knowledge changes detected.</p>
}
else
{
<ul>
@foreach (var item in preview.Knowledge)
{
<li>@item.Action: @item.Character - @item.KnowledgeItem (@item.OldState -> @item.NewState, @DisplayConfidence(item.ConfidencePercent))</li>
}
</ul>
}
<h3>Timeline</h3>
<p>@preview.Timeline.Count.ToString("N0") scene timeline entr@(preview.Timeline.Count == 1 ? "y" : "ies") would be created.</p>
@if (preview.Timeline.Count > 0)
{
<ul>
@foreach (var item in preview.Timeline.Take(20))
{
<li>Scene @Display(item.SceneNumber): @item.SceneDate (@DisplayConfidence(item.ConfidencePercent))</li>
}
</ul>
}
<h3>Warnings</h3>
<ul class="mb-0">
@foreach (var warning in preview.Warnings)
{
<li>@warning</li>
}
</ul>
}
</section>
@foreach (var scene in Model.SceneResults)
{
<section class="edit-panel">
@ -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<StoryImportEntityResolution> items)
{
if (items.Count == 0)
{
return new Microsoft.AspNetCore.Html.HtmlString("<p>No items detected.</p>");
}
var builder = new System.Text.StringBuilder();
foreach (var group in items.GroupBy(item => item.Status))
{
builder.Append("<h4>").Append(Encode(group.Key)).Append("</h4><ul>");
foreach (var item in group)
{
builder.Append("<li>");
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(" <span class=\"muted\">from ").Append(Encode(item.InputName)).Append("</span>");
}
builder.Append(" <span class=\"muted\">(").Append(Encode(DisplayConfidence(item.ConfidencePercent))).Append(")</span>");
if (item.Candidates.Count > 1)
{
builder.Append("<br /><span class=\"muted\">Candidates: ")
.Append(Encode(string.Join(", ", item.Candidates.Select(candidate => candidate.Name))))
.Append("</span>");
}
if (!string.IsNullOrWhiteSpace(item.Notes))
{
builder.Append("<br /><span class=\"muted\">").Append(Encode(item.Notes)).Append("</span>");
}
builder.Append("</li>");
}
builder.Append("</ul>");
}
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