Phase 20S – Story Intelligence Entity Resolution & Import Preview
This commit is contained in:
parent
6038d2144a
commit
1dbf49ca4c
84
PlotLine/Models/StoryIntelligenceImportPreviewModels.cs
Normal file
84
PlotLine/Models/StoryIntelligenceImportPreviewModels.cs
Normal 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;
|
||||||
|
}
|
||||||
@ -203,6 +203,7 @@ public class Program
|
|||||||
builder.Services.AddScoped<IStoryIntelligenceResultPersistenceService, StoryIntelligenceResultPersistenceService>();
|
builder.Services.AddScoped<IStoryIntelligenceResultPersistenceService, StoryIntelligenceResultPersistenceService>();
|
||||||
builder.Services.AddScoped<IStoryIntelligenceExistingChapterQueueService, StoryIntelligenceExistingChapterQueueService>();
|
builder.Services.AddScoped<IStoryIntelligenceExistingChapterQueueService, StoryIntelligenceExistingChapterQueueService>();
|
||||||
builder.Services.AddScoped<IStoryIntelligenceFullChapterTestService, StoryIntelligenceFullChapterTestService>();
|
builder.Services.AddScoped<IStoryIntelligenceFullChapterTestService, StoryIntelligenceFullChapterTestService>();
|
||||||
|
builder.Services.AddScoped<ISceneImportResolver, SceneImportResolver>();
|
||||||
builder.Services.AddScoped<IPersistedStoryIntelligenceRunner, PersistedStoryIntelligenceRunner>();
|
builder.Services.AddScoped<IPersistedStoryIntelligenceRunner, PersistedStoryIntelligenceRunner>();
|
||||||
builder.Services.AddScoped<IProjectAccessService, ProjectAccessService>();
|
builder.Services.AddScoped<IProjectAccessService, ProjectAccessService>();
|
||||||
builder.Services.AddScoped<ISubscriptionService, SubscriptionService>();
|
builder.Services.AddScoped<ISubscriptionService, SubscriptionService>();
|
||||||
|
|||||||
384
PlotLine/Services/SceneImportResolver.cs
Normal file
384
PlotLine/Services/SceneImportResolver.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -17,6 +17,7 @@ public interface IStoryIntelligenceResultPersistenceService
|
|||||||
public sealed class StoryIntelligenceResultPersistenceService(
|
public sealed class StoryIntelligenceResultPersistenceService(
|
||||||
IStoryIntelligenceResultRepository repository,
|
IStoryIntelligenceResultRepository repository,
|
||||||
IStoryIntelligenceClient client,
|
IStoryIntelligenceClient client,
|
||||||
|
ISceneImportResolver importResolver,
|
||||||
ILogger<StoryIntelligenceResultPersistenceService> logger) : IStoryIntelligenceResultPersistenceService
|
ILogger<StoryIntelligenceResultPersistenceService> logger) : IStoryIntelligenceResultPersistenceService
|
||||||
{
|
{
|
||||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
@ -104,11 +105,13 @@ public sealed class StoryIntelligenceResultPersistenceService(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var sceneResults = await repository.ListSceneResultsAsync(runId);
|
||||||
return new StoryIntelligenceSavedRunDetailViewModel
|
return new StoryIntelligenceSavedRunDetailViewModel
|
||||||
{
|
{
|
||||||
Run = run,
|
Run = run,
|
||||||
ChapterResult = await repository.GetChapterResultAsync(runId),
|
ChapterResult = await repository.GetChapterResultAsync(runId),
|
||||||
SceneResults = await repository.ListSceneResultsAsync(runId)
|
SceneResults = sceneResults,
|
||||||
|
ImportPreview = await importResolver.ResolveAsync(run, sceneResults)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -292,6 +292,7 @@ public sealed class StoryIntelligenceSavedRunDetailViewModel
|
|||||||
public StoryIntelligenceSavedRun? Run { get; init; }
|
public StoryIntelligenceSavedRun? Run { get; init; }
|
||||||
public StoryIntelligenceSavedChapterResult? ChapterResult { get; init; }
|
public StoryIntelligenceSavedChapterResult? ChapterResult { get; init; }
|
||||||
public IReadOnlyList<StoryIntelligenceSavedSceneResult> SceneResults { get; init; } = [];
|
public IReadOnlyList<StoryIntelligenceSavedSceneResult> SceneResults { get; init; } = [];
|
||||||
|
public StoryIntelligenceImportPreview? ImportPreview { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class AdminFeatureRequestFilter
|
public sealed class AdminFeatureRequestFilter
|
||||||
|
|||||||
@ -219,6 +219,78 @@ else
|
|||||||
}
|
}
|
||||||
</section>
|
</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)
|
@foreach (var scene in Model.SceneResults)
|
||||||
{
|
{
|
||||||
<section class="edit-panel">
|
<section class="edit-panel">
|
||||||
@ -284,6 +356,70 @@ else
|
|||||||
private static string DisplayCost(decimal? value, string currency)
|
private static string DisplayCost(decimal? value, string currency)
|
||||||
=> value.HasValue ? $"{value.Value:0.000000} {currency}" : "None";
|
=> 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)
|
private static string ChapterStructureModelUsed(StoryIntelligenceSavedRunDetailViewModel model)
|
||||||
=> !string.IsNullOrWhiteSpace(model.ChapterResult?.Model)
|
=> !string.IsNullOrWhiteSpace(model.ChapterResult?.Model)
|
||||||
? model.ChapterResult.Model
|
? model.ChapterResult.Model
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user