diff --git a/PlotLine.Tests/Program.cs b/PlotLine.Tests/Program.cs index d708746..f1a33fc 100644 --- a/PlotLine.Tests/Program.cs +++ b/PlotLine.Tests/Program.cs @@ -64,6 +64,7 @@ var tests = new (string Name, Action Test)[] ("Phase 21U extracts mandatory character appearance fixtures", Phase21UExtractsMandatoryCharacterAppearanceFixtures), ("Phase 21U enforces alias age semantic and UI reset rules", Phase21UEnforcesAliasAgeSemanticAndUiResetRules), ("Story Intelligence visual polish keeps labels fixed and semantics specific", StoryIntelligenceVisualPolishKeepsLabelsFixedAndSemanticsSpecific), + ("Scan review aliases feed Story Intelligence known character context", ScanReviewAliasesFeedStoryIntelligenceKnownCharacterContext), ("Scan review post supports full-book form submissions", ScanReviewPostSupportsFullBookFormSubmissions), ("Story Intelligence experience boot does not serialise live model", StoryIntelligenceExperienceBootDoesNotSerialiseLiveModel), ("Live visualisation strips illustration diagnostics", LiveVisualisationStripsIllustrationDiagnostics), @@ -1248,6 +1249,8 @@ static void StoryIntelligenceVisualPolishKeepsLabelsFixedAndSemanticsSpecific() Assert(StoryIntelligenceIllustrationCompatibility.LocationType("Station Gardens") == "Road", "Gardens should classify as Road."); Assert(StoryIntelligenceIllustrationCompatibility.LocationType("driver seat inside the car") == "DriverSeat", "Driver seat should classify as DriverSeat."); Assert(StoryIntelligenceIllustrationCompatibility.LocationType("Driving Test Centre waiting room") == "DrivingTestCentre", "Driving test centre should have its own location family."); + Assert(StoryIntelligenceIllustrationCompatibility.LocationType("Bristol Road kitchen") == "Kitchen", "Functional place context should beat a nearby road name."); + Assert(StoryIntelligenceIllustrationCompatibility.LocationType("Bristol Road car park") == "CarPark", "Car park context should beat a nearby road name."); Assert(!StoryIntelligenceIllustrationCompatibility.LocationCompatible("VehicleInterior", "HouseExterior"), "Vehicle interiors must not resolve to houses or buildings."); Assert(StoryIntelligenceIllustrationCompatibility.AssetType("passport") == "Passport", "Passport should not be Generic Object."); @@ -1263,6 +1266,42 @@ static void StoryIntelligenceVisualPolishKeepsLabelsFixedAndSemanticsSpecific() Assert(snapshot.Contains("\"asset-traffic-sign\" => $\"{FallbackRoot}/asset-traffic-sign.svg\"", StringComparison.Ordinal), "Traffic signs need a recognisable fallback."); } +static void ScanReviewAliasesFeedStoryIntelligenceKnownCharacterContext() +{ + var preview = new ManuscriptScanPreview + { + CharacterCandidates = + [ + new() { TemporaryCharacterKey = "c-maggie", Name = "Maggie", MentionCount = 8, QualityScore = 94, Category = "ProbableCharacter" }, + new() { TemporaryCharacterKey = "c-margaret", Name = "Margaret", MentionCount = 3, QualityScore = 88, Category = "ProbableCharacter" }, + new() { TemporaryCharacterKey = "c-mags", Name = "Mags", MentionCount = 2, QualityScore = 78, Category = "PossibleCharacter" }, + new() { TemporaryCharacterKey = "c-kevin", Name = "Kevin", MentionCount = 4, QualityScore = 90, Category = "ProbableCharacter" } + ] + }; + var review = new ManuscriptScanReviewDecision + { + Characters = + [ + new() { TemporaryCharacterKey = "c-maggie", Include = true, Name = "Maggie", Category = "ProbableCharacter", ExistingCharacterID = 123 }, + new() { TemporaryCharacterKey = "c-margaret", Include = true, Name = "Margaret", Category = "ProbableCharacter", AliasTargetTemporaryCharacterKey = "c-maggie" }, + new() { TemporaryCharacterKey = "c-mags", Include = true, Name = "Mags", Category = "PossibleCharacter", AliasTargetTemporaryCharacterKey = "c-maggie" }, + new() { TemporaryCharacterKey = "c-kevin", Include = true, Name = "Kevin", Category = "ProbableCharacter" } + ] + }; + + var json = OnboardingStoryIntelligenceService.BuildKnownCharacterContextJson(preview, review); + Assert(!string.IsNullOrWhiteSpace(json), "Known character context JSON should be produced."); + + var context = JsonSerializer.Deserialize>(json!, new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!; + var maggie = context.Single(item => item.CanonicalName == "Maggie"); + Assert(maggie.ExistingCharacterID == 123, "Existing character ID should stay on the canonical scan character."); + Assert(maggie.Aliases.Contains("Margaret"), "Alias target should include Margaret."); + Assert(maggie.Aliases.Contains("Mags"), "Alias target should include Mags."); + Assert(maggie.MentionCount == 13, $"Alias mention counts should accumulate, got {maggie.MentionCount}."); + Assert(context.Any(item => item.CanonicalName == "Kevin"), "Other included canonical candidates should remain available."); + Assert(!context.Any(item => item.CanonicalName == "Margaret"), "Alias candidates should not become separate canonical context records."); +} + static JsonSerializerOptions JsonOptions() => new() { diff --git a/PlotLine/Data/CharacterIntelligenceRepository.cs b/PlotLine/Data/CharacterIntelligenceRepository.cs new file mode 100644 index 0000000..8b1047c --- /dev/null +++ b/PlotLine/Data/CharacterIntelligenceRepository.cs @@ -0,0 +1,73 @@ +using Dapper; +using PlotLine.Models; + +namespace PlotLine.Data; + +public interface ICharacterIntelligenceRepository +{ + Task> ListProfilesAsync(int importSessionId); + Task UpsertProfileAsync(CharacterIntelligenceProfileSave request); + Task AddEvidenceAsync(CharacterIntelligenceEvidenceSave request); +} + +public sealed class CharacterIntelligenceRepository(ISqlConnectionFactory connectionFactory) : ICharacterIntelligenceRepository +{ + public async Task> ListProfilesAsync(int importSessionId) + { + using var connection = connectionFactory.CreateConnection(); + var rows = await connection.QueryAsync( + """ + SELECT * + FROM dbo.CharacterIntelligenceProfiles + WHERE ImportSessionID = @ImportSessionID + ORDER BY IsRecurring DESC, Confidence DESC, CanonicalName; + """, + new { ImportSessionID = importSessionId }); + return rows.ToList(); + } + + public async Task UpsertProfileAsync(CharacterIntelligenceProfileSave request) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleAsync( + """ + MERGE dbo.CharacterIntelligenceProfiles WITH (HOLDLOCK) AS target + USING (SELECT @ImportSessionID AS ImportSessionID, @CanonicalIdentityKey AS CanonicalIdentityKey) AS source + ON target.ImportSessionID = source.ImportSessionID + AND target.CanonicalIdentityKey = source.CanonicalIdentityKey + WHEN MATCHED THEN + UPDATE SET + CanonicalName = @CanonicalName, + ProfileJson = @ProfileJson, + Confidence = @Confidence, + NarrativeImportance = @NarrativeImportance, + IsRecurring = CASE WHEN @IsRecurring = 1 THEN 1 ELSE target.IsRecurring END, + EvidenceVersion = @EvidenceVersion, + LastUpdatedSceneResultID = @SceneResultID, + UpdatedUtc = SYSUTCDATETIME() + WHEN NOT MATCHED THEN + INSERT (ImportSessionID, ProjectID, BookID, CanonicalIdentityKey, CanonicalName, ProfileJson, Confidence, NarrativeImportance, IsRecurring, EvidenceVersion, LastUpdatedSceneResultID) + VALUES (@ImportSessionID, @ProjectID, @BookID, @CanonicalIdentityKey, @CanonicalName, @ProfileJson, @Confidence, @NarrativeImportance, @IsRecurring, @EvidenceVersion, @SceneResultID); + + SELECT * + FROM dbo.CharacterIntelligenceProfiles + WHERE ImportSessionID = @ImportSessionID + AND CanonicalIdentityKey = @CanonicalIdentityKey; + """, + request); + } + + public async Task AddEvidenceAsync(CharacterIntelligenceEvidenceSave request) + { + using var connection = connectionFactory.CreateConnection(); + await connection.ExecuteAsync( + """ + INSERT dbo.CharacterIntelligenceEvidenceEvents + (CharacterIntelligenceProfileID, SceneResultID, FieldName, PreviousValue, NewValue, Evidence, Confidence, IsContradiction, Source) + VALUES + (@CharacterIntelligenceProfileID, @SceneResultID, @FieldName, @PreviousValue, @NewValue, @Evidence, @Confidence, @IsContradiction, @Source); + """, + request); + } +} + diff --git a/PlotLine/Data/StoryIntelligenceResultRepository.cs b/PlotLine/Data/StoryIntelligenceResultRepository.cs index d85670b..c540a86 100644 --- a/PlotLine/Data/StoryIntelligenceResultRepository.cs +++ b/PlotLine/Data/StoryIntelligenceResultRepository.cs @@ -179,7 +179,8 @@ public sealed class StoryIntelligenceResultRepository(ISqlConnectionFactory conn request.SourceParagraphCount, request.SourceChapterCount, request.Model, - request.PromptVersionsSummary + request.PromptVersionsSummary, + request.KnownCharactersJson }, commandType: CommandType.StoredProcedure); } diff --git a/PlotLine/Docs/AI/Character-Profile-Update-Prompt-V1.md b/PlotLine/Docs/AI/Character-Profile-Update-Prompt-V1.md new file mode 100644 index 0000000..978992b --- /dev/null +++ b/PlotLine/Docs/AI/Character-Profile-Update-Prompt-V1.md @@ -0,0 +1,100 @@ +# Character Profile Update Prompt V1 + +You are PlotDirector's Character Intelligence updater. + +You do not rewrite profiles from scratch. +You only report updates that should be applied to the existing canonical profiles. + +Use only: + +- CURRENT_CHARACTER_PROFILES_JSON +- CURRENT_SCENE_INTELLIGENCE_JSON +- CURRENT_STORY_MEMORY_JSON + +Do not use raw manuscript text. +Do not infer appearance independently with pattern matching. +Use the structured scene intelligence and accumulated memory. + +For each recurring or potentially recurring character in the current scene, decide: + +- has anything new been learned? +- has any existing fact become more certain? +- has anything been contradicted? +- has this character possibly been confused with another profile? +- should aliases be added? + +Return JSON only. Do not include markdown. + +The response must match this shape: + +{ + "schemaVersion": "22", + "updates": [ + { + "canonicalName": "Maggie", + "aliases": ["Miss Jones"], + "recurrenceSignal": "Appears again with the same role and appearance evidence.", + "confidence": 0.82, + "fieldUpdates": [ + { + "fieldName": "HairColour", + "value": "Red", + "evidence": "Structured scene intelligence reports long natural red hair.", + "confidence": 0.91, + "isNewInformation": true, + "increasesCertainty": true + } + ], + "contradictions": [ + { + "fieldName": "EstimatedAgeBand", + "existingValue": "Teen", + "newValue": "Adult", + "evidence": "Scene intelligence now identifies the character as an adult.", + "confidence": 0.78 + } + ] + } + ] +} + +Allowed fieldName values: + +- RoleInStory +- NarrativeImportance +- Presentation +- EstimatedAge +- EstimatedAgeBand +- HairColour +- HairLength +- HairStyle +- EyeColour +- SkinTone +- Height +- Build +- DistinctiveFeatures +- TypicalClothing +- Occupation +- Relationships +- CurrentStatus + +Guidelines: + +- Prefer stable accumulated knowledge over one-scene uncertainty. +- Do not downgrade an existing fact unless the current scene clearly contradicts it. +- If the current scene merely repeats known information, return the update only when it increases confidence. +- Preserve ambiguity by using contradictions instead of overwriting uncertain conflicts. +- Keep evidence summaries short and do not quote manuscript prose. + +CURRENT_CHARACTER_PROFILES_JSON: +{{CURRENT_CHARACTER_PROFILES_JSON}} + +CURRENT_SCENE_INTELLIGENCE_JSON: +{{CURRENT_SCENE_INTELLIGENCE_JSON}} + +CURRENT_STORY_MEMORY_JSON: +{{CURRENT_STORY_MEMORY_JSON}} + +SCENE_RESULT_ID: +{{SCENE_RESULT_ID}} + diff --git a/PlotLine/Docs/AI/Scene-Prompt-V2.md b/PlotLine/Docs/AI/Scene-Prompt-V2.md index 962c205..277626d 100644 --- a/PlotLine/Docs/AI/Scene-Prompt-V2.md +++ b/PlotLine/Docs/AI/Scene-Prompt-V2.md @@ -77,6 +77,13 @@ It should not: Use only the scene text and supplied context. +When `knownCharacters` is supplied in the scene context, use it only as identity context for names already detected before analysis: + +- match scene character names against `canonicalName` and `aliases`; +- when the scene uses a supplied alias for the same identity, use the supplied `canonicalName` and include the scene-used name in `aliases`; +- do not create a character solely because it appears in `knownCharacters`; +- do not override explicit scene evidence with scan context. + If something is not stated or strongly implied: - use `null` for scalar values; @@ -212,6 +219,7 @@ Report characters who are: For each character: - use the manuscript's name or label; +- when supplied `knownCharacters` identify that name as an alias, use the supplied canonical name and place the alias used in the scene in `aliases`; - include exactly these properties: `name`, `roleInScene`, `mentionedOnly`, `aliases`, `actions`, `confidence`, `notes`; - set `mentionedOnly` to `false` when active in the scene; - set `mentionedOnly` to `true` when absent and only referred to; @@ -277,6 +285,8 @@ Use the `name` property for both named locations and generic labels. For an unna Use `parentLocationHint` only when the scene supports a containing place. Do not invent location hierarchy. +Prefer the active scene location over nearby route or address wording. If the action happens in a house, shop, office, garage, waiting room, test centre, car park, vehicle interior or other functional place that is described by reference to a road, report the functional place as the present location and put the road/address wording in `parentLocationHint` or `notes` only when the scene supports it. Report a road, lane or street as the present location only when the action space is actually the road, lane or street. + Do not infer a character's current location from another character thinking about them. Example: if Beth thinks `closer, perhaps, to Maggie`, do not create `Maggie at Ashdown Trust`, `Maggie's location nearby` or `Beth leaves Maggie's location`. Instead, use a question or observation such as `Beth speculates that the Ashdown Trust may bring her closer to Maggie.` diff --git a/PlotLine/Models/CharacterIntelligenceModels.cs b/PlotLine/Models/CharacterIntelligenceModels.cs new file mode 100644 index 0000000..df11d71 --- /dev/null +++ b/PlotLine/Models/CharacterIntelligenceModels.cs @@ -0,0 +1,138 @@ +using System.Text.Json.Serialization; + +namespace PlotLine.Models; + +public sealed class CharacterIntelligenceProfile +{ + public int CharacterIntelligenceProfileID { get; init; } + public int ImportSessionID { get; init; } + public int ProjectID { get; init; } + public int BookID { get; init; } + public string CanonicalIdentityKey { get; init; } = string.Empty; + public string CanonicalName { get; init; } = string.Empty; + public string ProfileJson { get; init; } = string.Empty; + public decimal Confidence { get; init; } + public string? NarrativeImportance { get; init; } + public bool IsRecurring { get; init; } + public string EvidenceVersion { get; init; } = CharacterIntelligenceVersions.Current; + public int? LastUpdatedSceneResultID { get; init; } + public DateTime CreatedUtc { get; init; } + public DateTime UpdatedUtc { get; init; } +} + +public static class CharacterIntelligenceVersions +{ + public const string Current = "22"; +} + +public sealed class CharacterIntelligenceProfileDocument +{ + public string CanonicalName { get; set; } = string.Empty; + public List Aliases { get; set; } = []; + public string? RoleInStory { get; set; } + public string? NarrativeImportance { get; set; } + public string? Presentation { get; set; } + public string? EstimatedAge { get; set; } + public string? EstimatedAgeBand { get; set; } + public string? HairColour { get; set; } + public string? HairLength { get; set; } + public string? HairStyle { get; set; } + public string? EyeColour { get; set; } + public string? SkinTone { get; set; } + public string? Height { get; set; } + public string? Build { get; set; } + public List DistinctiveFeatures { get; set; } = []; + public string? TypicalClothing { get; set; } + public string? Occupation { get; set; } + public List Relationships { get; set; } = []; + public string? CurrentStatus { get; set; } + public decimal Confidence { get; set; } + public List EvidenceTimeline { get; set; } = []; + public List Contradictions { get; set; } = []; + public DateTime LastUpdatedUtc { get; set; } +} + +public sealed class CharacterIntelligenceEvidenceItem +{ + public int? SceneResultID { get; set; } + public decimal? ChapterNumber { get; set; } + public int? SceneNumber { get; set; } + public string FieldName { get; set; } = string.Empty; + public string? Value { get; set; } + public string Evidence { get; set; } = string.Empty; + public decimal Confidence { get; set; } + public DateTime RecordedUtc { get; set; } = DateTime.UtcNow; +} + +public sealed class CharacterIntelligenceContradiction +{ + public string FieldName { get; set; } = string.Empty; + public string? ExistingValue { get; set; } + public string? NewValue { get; set; } + public string Evidence { get; set; } = string.Empty; + public decimal Confidence { get; set; } + public int? SceneResultID { get; set; } +} + +public sealed record CharacterIntelligenceProfileSave( + int ImportSessionID, + int ProjectID, + int BookID, + string CanonicalIdentityKey, + string CanonicalName, + string ProfileJson, + decimal Confidence, + string? NarrativeImportance, + bool IsRecurring, + string EvidenceVersion, + int SceneResultID); + +public sealed record CharacterIntelligenceEvidenceSave( + int CharacterIntelligenceProfileID, + int SceneResultID, + string FieldName, + string? PreviousValue, + string? NewValue, + string Evidence, + decimal Confidence, + bool IsContradiction, + string Source); + +public sealed class CharacterIntelligenceProfileUpdateResponse +{ + public string SchemaVersion { get; init; } = CharacterIntelligenceVersions.Current; + public List Updates { get; init; } = []; +} + +public sealed class CharacterIntelligenceProfileUpdate +{ + public string CanonicalName { get; init; } = string.Empty; + public List Aliases { get; init; } = []; + public string? RecurrenceSignal { get; init; } + public decimal Confidence { get; init; } + public List FieldUpdates { get; init; } = []; + public List Contradictions { get; init; } = []; + + [JsonExtensionData] + public Dictionary? ExtensionData { get; init; } +} + +public sealed class CharacterIntelligenceFieldUpdate +{ + public string FieldName { get; init; } = string.Empty; + public string? Value { get; init; } + public string Evidence { get; init; } = string.Empty; + public decimal Confidence { get; init; } + public bool IsNewInformation { get; init; } + public bool IncreasesCertainty { get; init; } +} + +public sealed class CharacterIntelligenceAiContradiction +{ + public string FieldName { get; init; } = string.Empty; + public string? ExistingValue { get; init; } + public string? NewValue { get; init; } + public string Evidence { get; init; } = string.Empty; + public decimal Confidence { get; init; } +} + diff --git a/PlotLine/Models/ManuscriptScanModels.cs b/PlotLine/Models/ManuscriptScanModels.cs index b03116a..6d64645 100644 --- a/PlotLine/Models/ManuscriptScanModels.cs +++ b/PlotLine/Models/ManuscriptScanModels.cs @@ -153,6 +153,7 @@ public sealed class ManuscriptScanCharacterReviewDecision public string Name { get; init; } = string.Empty; public string Category { get; init; } = "PossibleCharacter"; public int? ExistingCharacterID { get; init; } + public string? AliasTargetTemporaryCharacterKey { get; init; } } public sealed class OnboardingManuscriptBuildResult diff --git a/PlotLine/Models/SceneIntelligenceModels.cs b/PlotLine/Models/SceneIntelligenceModels.cs index 825bfe9..8d35b13 100644 --- a/PlotLine/Models/SceneIntelligenceModels.cs +++ b/PlotLine/Models/SceneIntelligenceModels.cs @@ -111,8 +111,10 @@ public sealed class SceneIntelligenceCharacterAppearance public string? HairColour { get; init; } public string? HairLength { get; init; } public string? HairStyle { get; init; } + public string? EyeColour { get; init; } public string? FacialHair { get; init; } public string? Glasses { get; init; } + public string? Height { get; init; } public string? Build { get; init; } public List? DistinctiveFeatures { get; init; } public string? Clothing { get; init; } diff --git a/PlotLine/Models/StoryIntelligencePersistenceModels.cs b/PlotLine/Models/StoryIntelligencePersistenceModels.cs index ed8ed04..fc8115a 100644 --- a/PlotLine/Models/StoryIntelligencePersistenceModels.cs +++ b/PlotLine/Models/StoryIntelligencePersistenceModels.cs @@ -149,6 +149,18 @@ public sealed class StoryIntelligenceRunQueueRequest public int? SourceChapterCount { get; init; } public string Model { get; init; } = string.Empty; public string PromptVersionsSummary { get; init; } = string.Empty; + public string? KnownCharactersJson { get; init; } +} + +public sealed class StoryIntelligenceKnownCharacterContext +{ + public string CanonicalName { get; init; } = string.Empty; + public IReadOnlyList Aliases { get; init; } = []; + public int? ExistingCharacterID { get; init; } + public IReadOnlyList CandidateKeys { get; init; } = []; + public int MentionCount { get; init; } + public decimal Confidence { get; init; } + public string Source { get; init; } = "InitialManuscriptScan"; } public sealed class StoryIntelligenceChapterResultSaveRequest @@ -283,6 +295,7 @@ public sealed class StoryIntelligenceQueuedRun public int? SourceChapterCount { get; init; } public string PromptVersion { get; init; } = string.Empty; public string? PromptVersionsSummary { get; init; } + public string? KnownCharactersJson { get; init; } public string Model { get; init; } = string.Empty; public DateTime StartedUtc { get; init; } public DateTime? CompletedUtc { get; init; } diff --git a/PlotLine/Program.cs b/PlotLine/Program.cs index 3c2fbba..ff0271a 100644 --- a/PlotLine/Program.cs +++ b/PlotLine/Program.cs @@ -150,6 +150,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(); @@ -222,6 +223,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/CharacterIntelligenceService.cs b/PlotLine/Services/CharacterIntelligenceService.cs new file mode 100644 index 0000000..e294ec3 --- /dev/null +++ b/PlotLine/Services/CharacterIntelligenceService.cs @@ -0,0 +1,457 @@ +using System.Text.Json; +using PlotLine.Data; +using PlotLine.Models; + +namespace PlotLine.Services; + +public interface ICharacterIntelligenceService +{ + Task UpdateProfilesForSceneAsync( + StoryIntelligenceBookPipelineState importSession, + int sceneResultId, + SceneIntelligenceScene scene, + CancellationToken cancellationToken = default); +} + +public sealed class CharacterIntelligenceService( + ICharacterIntelligenceRepository profiles, + IStoryMemoryRepository storyMemory, + IStoryPromptRepository prompts, + IStoryPromptVersionService versions, + IStoryIntelligenceClient client, + ILogger logger) : ICharacterIntelligenceService +{ + private const string PromptFile = "Character-Profile-Update-Prompt-V1.md"; + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + WriteIndented = false + }; + + public async Task UpdateProfilesForSceneAsync( + StoryIntelligenceBookPipelineState importSession, + int sceneResultId, + SceneIntelligenceScene scene, + CancellationToken cancellationToken = default) + { + var characters = SceneCharacterNames(scene).ToList(); + if (characters.Count == 0) + { + return; + } + + var existingProfiles = await profiles.ListProfilesAsync(importSession.StoryIntelligenceBookPipelineID); + var response = await TryGetAiUpdatesAsync(importSession, sceneResultId, scene, existingProfiles, cancellationToken) + ?? BuildStructuredFallbackUpdates(scene); + + foreach (var update in response.Updates.Where(item => !string.IsNullOrWhiteSpace(item.CanonicalName))) + { + cancellationToken.ThrowIfCancellationRequested(); + var key = CharacterKey(update.CanonicalName, update.Aliases); + var existing = existingProfiles.FirstOrDefault(item => string.Equals(item.CanonicalIdentityKey, key, StringComparison.OrdinalIgnoreCase)); + var document = ReadProfile(existing) ?? NewProfile(update.CanonicalName, update.Aliases); + var events = ApplyUpdate(document, update, sceneResultId, scene); + var saved = await profiles.UpsertProfileAsync(new CharacterIntelligenceProfileSave( + importSession.StoryIntelligenceBookPipelineID, + importSession.ProjectID, + importSession.BookID, + key, + document.CanonicalName, + JsonSerializer.Serialize(document, JsonOptions), + document.Confidence, + document.NarrativeImportance, + IsRecurring(document, scene), + CharacterIntelligenceVersions.Current, + sceneResultId)); + + foreach (var item in events) + { + await profiles.AddEvidenceAsync(item with { CharacterIntelligenceProfileID = saved.CharacterIntelligenceProfileID }); + } + } + } + + private async Task TryGetAiUpdatesAsync( + StoryIntelligenceBookPipelineState importSession, + int sceneResultId, + SceneIntelligenceScene scene, + IReadOnlyList existingProfiles, + CancellationToken cancellationToken) + { + try + { + var memoryCharacters = await storyMemory.ListCharactersAsync(importSession.StoryIntelligenceBookPipelineID); + var memoryAttributes = await storyMemory.ListCharacterAttributesAsync(importSession.StoryIntelligenceBookPipelineID); + var prompt = await prompts.LoadPromptAsync(PromptFile, cancellationToken); + var completed = prompt + .Replace("{{CURRENT_CHARACTER_PROFILES_JSON}}", BuildCurrentProfilesJson(existingProfiles), StringComparison.Ordinal) + .Replace("{{CURRENT_SCENE_INTELLIGENCE_JSON}}", JsonSerializer.Serialize(scene, JsonOptions), StringComparison.Ordinal) + .Replace("{{CURRENT_STORY_MEMORY_JSON}}", BuildStoryMemoryJson(memoryCharacters, memoryAttributes), StringComparison.Ordinal) + .Replace("{{SCENE_RESULT_ID}}", sceneResultId.ToString("N0"), StringComparison.Ordinal); + var clientResult = await client.ExecutePromptAsync( + completed, + versions.GetPromptVersion(PromptFile), + cancellationToken, + client.GetConfigurationStatus().SceneIntelligenceModel); + var output = ExtractOutputText(clientResult.RawResponseText); + var parse = StoryIntelligenceAiJsonParser.Parse(output, JsonOptions); + if (parse.Success && parse.Parsed is not null) + { + return parse.Parsed; + } + + logger.LogWarning( + "Character Intelligence profile update JSON could not be parsed for import session {ImportSessionId}, scene result {SceneResultId}: {ErrorMessage}", + importSession.StoryIntelligenceBookPipelineID, + sceneResultId, + parse.ErrorMessage); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger.LogWarning( + ex, + "Character Intelligence AI profile update failed for import session {ImportSessionId}, scene result {SceneResultId}. Structured scene output fallback will be used.", + importSession.StoryIntelligenceBookPipelineID, + sceneResultId); + } + + return null; + } + + private static CharacterIntelligenceProfileDocument? ReadProfile(CharacterIntelligenceProfile? profile) + { + if (profile is null || string.IsNullOrWhiteSpace(profile.ProfileJson)) + { + return null; + } + + try + { + return JsonSerializer.Deserialize(profile.ProfileJson, JsonOptions); + } + catch (JsonException) + { + return null; + } + } + + private static CharacterIntelligenceProfileDocument NewProfile(string canonicalName, IEnumerable? aliases) + => new() + { + CanonicalName = Clean(canonicalName), + Aliases = CleanList(aliases), + Confidence = 0.25m, + LastUpdatedUtc = DateTime.UtcNow + }; + + private static IReadOnlyList ApplyUpdate( + CharacterIntelligenceProfileDocument profile, + CharacterIntelligenceProfileUpdate update, + int sceneResultId, + SceneIntelligenceScene scene) + { + var events = new List(); + profile.CanonicalName = FirstConfigured(profile.CanonicalName, update.CanonicalName); + MergeList(profile.Aliases, update.Aliases); + profile.Confidence = Math.Max(profile.Confidence, ClampConfidence(update.Confidence)); + + foreach (var field in update.FieldUpdates.Where(item => !string.IsNullOrWhiteSpace(item.FieldName) && !string.IsNullOrWhiteSpace(item.Value))) + { + var previous = GetField(profile, field.FieldName); + if (!ShouldApply(previous, field)) + { + continue; + } + + SetField(profile, field.FieldName, field.Value); + var confidence = ClampConfidence(field.Confidence); + profile.Confidence = Math.Max(profile.Confidence, confidence); + profile.EvidenceTimeline.Add(new CharacterIntelligenceEvidenceItem + { + SceneResultID = sceneResultId, + ChapterNumber = scene.SceneReference?.ChapterNumber, + SceneNumber = scene.SceneReference?.SceneNumber is decimal number ? Convert.ToInt32(number) : null, + FieldName = field.FieldName, + Value = field.Value, + Evidence = Clean(field.Evidence), + Confidence = confidence + }); + events.Add(NewEvidence(sceneResultId, field.FieldName, previous, field.Value, field.Evidence, confidence, false, "AI profile update")); + } + + foreach (var contradiction in update.Contradictions.Where(item => !string.IsNullOrWhiteSpace(item.FieldName))) + { + profile.Contradictions.Add(new CharacterIntelligenceContradiction + { + FieldName = contradiction.FieldName, + ExistingValue = contradiction.ExistingValue, + NewValue = contradiction.NewValue, + Evidence = Clean(contradiction.Evidence), + Confidence = ClampConfidence(contradiction.Confidence), + SceneResultID = sceneResultId + }); + events.Add(NewEvidence(sceneResultId, contradiction.FieldName, contradiction.ExistingValue, contradiction.NewValue, contradiction.Evidence, ClampConfidence(contradiction.Confidence), true, "AI profile update")); + } + + profile.EvidenceTimeline = profile.EvidenceTimeline.TakeLast(80).ToList(); + profile.Contradictions = profile.Contradictions.TakeLast(40).ToList(); + profile.LastUpdatedUtc = DateTime.UtcNow; + return events; + } + + private static CharacterIntelligenceEvidenceSave NewEvidence( + int sceneResultId, + string fieldName, + string? previous, + string? value, + string evidence, + decimal confidence, + bool contradiction, + string source) + => new(0, sceneResultId, fieldName, previous, value, Clean(evidence), confidence, contradiction, source); + + private static CharacterIntelligenceProfileUpdateResponse BuildStructuredFallbackUpdates(SceneIntelligenceScene scene) + { + var updates = new List(); + foreach (var group in (scene.Characters ?? []) + .Where(item => !string.IsNullOrWhiteSpace(item.Name)) + .GroupBy(item => Clean(item.Name), StringComparer.OrdinalIgnoreCase)) + { + var character = group.OrderByDescending(item => item.Confidence ?? 0).First(); + var appearance = (scene.CharacterAppearance ?? []) + .Where(item => !string.IsNullOrWhiteSpace(item.CanonicalName)) + .FirstOrDefault(item => SameCharacter(item.CanonicalName, character.Name, item.Aliases)); + var fields = new List + { + Field("RoleInStory", character.RoleInScene, character.Notes, character.Confidence), + Field("Presentation", appearance?.Presentation ?? appearance?.PronounEvidence, appearance?.Evidence, appearance?.Confidence), + Field("EstimatedAge", appearance?.ExplicitAge, appearance?.Evidence, appearance?.Confidence), + Field("EstimatedAgeBand", appearance?.InferredAgeBand, appearance?.Evidence, appearance?.Confidence), + Field("HairColour", appearance?.HairColour, appearance?.Evidence, appearance?.Confidence), + Field("HairLength", appearance?.HairLength, appearance?.Evidence, appearance?.Confidence), + Field("HairStyle", appearance?.HairStyle, appearance?.Evidence, appearance?.Confidence), + Field("EyeColour", appearance?.EyeColour, appearance?.Evidence, appearance?.Confidence), + Field("Height", appearance?.Height, appearance?.Evidence, appearance?.Confidence), + Field("Build", appearance?.Build, appearance?.Evidence, appearance?.Confidence), + Field("TypicalClothing", appearance?.Clothing, appearance?.Evidence, appearance?.Confidence), + Field("Occupation", appearance?.RoleOrOccupation, appearance?.Evidence, appearance?.Confidence), + Field("CurrentStatus", character.MentionedOnly == true ? "Mentioned" : "Present in scene", character.Notes, character.Confidence) + }; + updates.Add(new CharacterIntelligenceProfileUpdate + { + CanonicalName = group.Key, + Aliases = CleanList(character.Aliases).Concat(CleanList(appearance?.Aliases)).Distinct(StringComparer.OrdinalIgnoreCase).ToList(), + Confidence = character.Confidence ?? appearance?.Confidence ?? 0.55m, + FieldUpdates = fields.Where(item => !string.IsNullOrWhiteSpace(item.Value)).ToList() + }); + } + + return new CharacterIntelligenceProfileUpdateResponse { Updates = updates }; + } + + private static CharacterIntelligenceFieldUpdate Field(string name, string? value, string? evidence, decimal? confidence) + => new() + { + FieldName = name, + Value = Clean(value), + Evidence = Clean(evidence), + Confidence = confidence ?? 0.55m, + IsNewInformation = true, + IncreasesCertainty = true + }; + + private static bool ShouldApply(string? previous, CharacterIntelligenceFieldUpdate update) + { + if (string.IsNullOrWhiteSpace(update.Value)) + { + return false; + } + + if (string.IsNullOrWhiteSpace(previous) || string.Equals(previous, "Unknown", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + return update.IncreasesCertainty && ClampConfidence(update.Confidence) >= 0.75m && !string.Equals(previous, update.Value, StringComparison.OrdinalIgnoreCase); + } + + private static string? GetField(CharacterIntelligenceProfileDocument profile, string fieldName) + => NormaliseField(fieldName) switch + { + "roleinstory" => profile.RoleInStory, + "narrativeimportance" => profile.NarrativeImportance, + "presentation" => profile.Presentation, + "estimatedage" => profile.EstimatedAge, + "estimatedageband" => profile.EstimatedAgeBand, + "haircolour" => profile.HairColour, + "hairlength" => profile.HairLength, + "hairstyle" => profile.HairStyle, + "eyecolour" => profile.EyeColour, + "skintone" => profile.SkinTone, + "height" => profile.Height, + "build" => profile.Build, + "typicalclothing" => profile.TypicalClothing, + "occupation" => profile.Occupation, + "currentstatus" => profile.CurrentStatus, + _ => null + }; + + private static void SetField(CharacterIntelligenceProfileDocument profile, string fieldName, string? value) + { + var clean = Clean(value); + switch (NormaliseField(fieldName)) + { + case "roleinstory": profile.RoleInStory = clean; break; + case "narrativeimportance": profile.NarrativeImportance = clean; break; + case "presentation": profile.Presentation = clean; break; + case "estimatedage": profile.EstimatedAge = clean; break; + case "estimatedageband": profile.EstimatedAgeBand = clean; break; + case "haircolour": profile.HairColour = clean; break; + case "hairlength": profile.HairLength = clean; break; + case "hairstyle": profile.HairStyle = clean; break; + case "eyecolour": profile.EyeColour = clean; break; + case "skintone": profile.SkinTone = clean; break; + case "height": profile.Height = clean; break; + case "build": profile.Build = clean; break; + case "typicalclothing": profile.TypicalClothing = clean; break; + case "occupation": profile.Occupation = clean; break; + case "currentstatus": profile.CurrentStatus = clean; break; + case "distinctivefeatures": AddUnique(profile.DistinctiveFeatures, clean); break; + case "relationships": AddUnique(profile.Relationships, clean); break; + } + } + + private static bool IsRecurring(CharacterIntelligenceProfileDocument profile, SceneIntelligenceScene scene) + => profile.EvidenceTimeline.Select(item => item.SceneResultID).Distinct().Count() > 1 + || (scene.Relationships ?? []).Any(item => string.Equals(Clean(item.CharacterA), profile.CanonicalName, StringComparison.OrdinalIgnoreCase) + || string.Equals(Clean(item.CharacterB), profile.CanonicalName, StringComparison.OrdinalIgnoreCase)) + || profile.Confidence >= 0.7m; + + private static IEnumerable SceneCharacterNames(SceneIntelligenceScene scene) + => (scene.Characters ?? []).Select(item => item.Name) + .Concat((scene.CharacterAppearance ?? []).Select(item => item.CanonicalName)) + .Where(item => !string.IsNullOrWhiteSpace(item)) + .Select(item => Clean(item)!); + + private static string BuildCurrentProfilesJson(IReadOnlyList existingProfiles) + => JsonSerializer.Serialize(existingProfiles.Select(item => new + { + item.CanonicalIdentityKey, + item.CanonicalName, + item.Confidence, + item.NarrativeImportance, + item.IsRecurring, + profile = ReadProfile(item) + }), JsonOptions); + + private static string BuildStoryMemoryJson( + IReadOnlyList characters, + IReadOnlyList attributes) + => JsonSerializer.Serialize(new + { + characters = characters.Select(item => new + { + item.CanonicalIdentityKey, + item.DisplayName, + item.IsNamed, + item.IsGroupEntity, + item.IsNarrator, + item.IsPrimaryPOV, + item.Significance, + attributes = attributes + .Where(attribute => attribute.StoryMemoryCharacterID == item.StoryMemoryCharacterID) + .Select(attribute => new + { + attribute.AttributeType, + attribute.NormalisedValue, + attribute.Confidence, + attribute.IsExplicit, + attribute.EvidenceSummary + }) + }) + }, JsonOptions); + + private static string ExtractOutputText(string rawResponseText) + { + using var response = JsonDocument.Parse(rawResponseText); + foreach (var output in response.RootElement.GetProperty("output").EnumerateArray()) + { + if (!output.TryGetProperty("content", out var content)) + { + continue; + } + + foreach (var item in content.EnumerateArray()) + { + if (item.TryGetProperty("type", out var type) + && string.Equals(type.GetString(), "output_text", StringComparison.OrdinalIgnoreCase) + && item.TryGetProperty("text", out var text) + && !string.IsNullOrWhiteSpace(text.GetString())) + { + return text.GetString()!.Trim(); + } + } + } + + return string.Empty; + } + + private static bool SameCharacter(string? value, string? name, IEnumerable? aliases) + { + var clean = Clean(value); + return string.Equals(clean, Clean(name), StringComparison.OrdinalIgnoreCase) + || CleanList(aliases).Any(alias => string.Equals(alias, clean, StringComparison.OrdinalIgnoreCase)); + } + + private static string CharacterKey(string name, IEnumerable? aliases) + { + var candidates = CleanList(aliases).Prepend(Clean(name)).Where(item => !string.IsNullOrWhiteSpace(item)).ToList(); + var selected = candidates.OrderBy(item => item.Length).FirstOrDefault() ?? "unknown"; + return $"character-intelligence-{StableKey(selected)}"; + } + + private static string StableKey(string? value) + { + var chars = (value ?? string.Empty).Trim().ToLowerInvariant().Select(ch => char.IsLetterOrDigit(ch) ? ch : ' ').ToArray(); + return string.Join('-', new string(chars).Split(' ', StringSplitOptions.RemoveEmptyEntries)); + } + + private static string NormaliseField(string value) + => new(value.Where(char.IsLetterOrDigit).Select(char.ToLowerInvariant).ToArray()); + + private static void MergeList(List target, IEnumerable? values) + { + foreach (var value in CleanList(values)) + { + AddUnique(target, value); + } + } + + private static void AddUnique(List target, string? value) + { + var clean = Clean(value); + if (!string.IsNullOrWhiteSpace(clean) && !target.Contains(clean, StringComparer.OrdinalIgnoreCase)) + { + target.Add(clean); + } + } + + private static List CleanList(IEnumerable? values) + => (values ?? []) + .Select(Clean) + .Where(item => !string.IsNullOrWhiteSpace(item)) + .Select(item => item!) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + private static string Clean(string? value) + => string.Join(' ', (value ?? string.Empty).Trim().Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)); + + private static string FirstConfigured(params string?[] values) + => values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))?.Trim() ?? string.Empty; + + private static decimal ClampConfidence(decimal confidence) + => Math.Clamp(confidence <= 0 ? 0.55m : confidence, 0m, 1m); +} + diff --git a/PlotLine/Services/OnboardingService.cs b/PlotLine/Services/OnboardingService.cs index 10a37ac..55bdcca 100644 --- a/PlotLine/Services/OnboardingService.cs +++ b/PlotLine/Services/OnboardingService.cs @@ -163,16 +163,24 @@ public sealed class OnboardingService( .Select(item => { var cleanName = Clean(item.Name); - var matchedExistingId = item.ExistingCharacterID.HasValue && existingCharacterIds.Contains(item.ExistingCharacterID.Value) + var aliasTargetKey = CleanOptional(item.AliasTargetTemporaryCharacterKey); + if (!string.IsNullOrWhiteSpace(aliasTargetKey) + && (!candidateLookup.ContainsKey(aliasTargetKey) || string.Equals(aliasTargetKey, item.TemporaryCharacterKey, StringComparison.Ordinal))) + { + aliasTargetKey = null; + } + + var matchedExistingId = string.IsNullOrWhiteSpace(aliasTargetKey) && item.ExistingCharacterID.HasValue && existingCharacterIds.Contains(item.ExistingCharacterID.Value) ? item.ExistingCharacterID - : MatchExistingCharacter(cleanName, existingCharacters)?.CharacterID; + : string.IsNullOrWhiteSpace(aliasTargetKey) ? MatchExistingCharacter(cleanName, existingCharacters)?.CharacterID : null; return new ManuscriptScanCharacterReviewDecision { TemporaryCharacterKey = item.TemporaryCharacterKey, Include = item.Include, Name = cleanName, Category = item.Category, - ExistingCharacterID = matchedExistingId + ExistingCharacterID = matchedExistingId, + AliasTargetTemporaryCharacterKey = aliasTargetKey }; }) .ToList(); @@ -211,6 +219,11 @@ public sealed class OnboardingService( var chapterDecisions = review.Chapters.Where(chapter => chapter.Include).OrderBy(chapter => chapter.ChapterNumber).ToList(); await PublishBuildProgress(userId, preview.PreviewID, "Preparing chapters for analysis...", 35, progress); + var characterDecisions = review.Characters + .Where(IsIncludedCanonicalCharacter) + .OrderBy(character => character.Name) + .ThenBy(character => character.TemporaryCharacterKey) + .ToList(); var request = new OnboardingManuscriptBuildRequest { PreviewID = preview.PreviewID, @@ -225,7 +238,12 @@ public sealed class OnboardingService( WordCount = preview.Chapters.FirstOrDefault(item => item.TemporaryChapterKey == chapter.TemporaryChapterKey)?.WordCount ?? 0 }).ToList(), Scenes = [], - Characters = [] + Characters = characterDecisions.Select(character => new OnboardingBuildCharacterRequest + { + TemporaryCharacterKey = character.TemporaryCharacterKey, + Name = character.Name, + ExistingCharacterID = character.ExistingCharacterID + }).ToList() }; await PublishBuildProgress(userId, preview.PreviewID, "Saving chapters...", 60, progress); @@ -252,6 +270,7 @@ public sealed class OnboardingService( CharacterMappings = result.CharacterMappings, MarkerWarning = result.MarkerWarning }; + await AddScanCharacterAliasesAsync(review, result); await scanPreviews.SaveBuildResultAsync(userId, result); await PublishBuildProgress(userId, preview.PreviewID, "Finalising project...", 90, progress); await PublishBuildProgress(userId, preview.PreviewID, result.Message, 100, progress, result); @@ -385,7 +404,12 @@ public sealed class OnboardingService( Category = candidate.Category, Reason = candidate.Reason, ExistingCharacterID = existing?.CharacterID, - ExistingCharacterName = existing?.CharacterName + ExistingCharacterName = existing?.CharacterName, + AliasTargetTemporaryCharacterKey = decision?.AliasTargetTemporaryCharacterKey, + AliasTargetName = !string.IsNullOrWhiteSpace(decision?.AliasTargetTemporaryCharacterKey) + && characterReview.TryGetValue(decision.AliasTargetTemporaryCharacterKey, out var targetDecision) + ? targetDecision.Name + : null }; }) .ToList(); @@ -406,7 +430,7 @@ public sealed class OnboardingService( CharacterCandidateCount = preview.CharacterCandidateCount, SelectedChapterCount = selectedChapterKeys.Count, SelectedSceneCount = selectedScenes.Count, - SelectedCharacterCount = characters.Count(character => character.Include && !string.Equals(character.Category, "Excluded", StringComparison.OrdinalIgnoreCase)), + SelectedCharacterCount = characters.Count(character => character.Include && !character.IsAlias && !string.Equals(character.Category, "Excluded", StringComparison.OrdinalIgnoreCase)), SelectedWordCount = selectedScenes.Sum(scene => scene.WordCount), Chapters = chapters, CharacterCandidates = characters, @@ -466,7 +490,7 @@ public sealed class OnboardingService( } var duplicateName = review.Characters - .Where(character => character.Include && !string.Equals(character.Category, "Excluded", StringComparison.OrdinalIgnoreCase)) + .Where(IsIncludedCanonicalCharacter) .Select(character => Clean(character.Name)) .Where(name => !string.IsNullOrWhiteSpace(name)) .GroupBy(name => name, StringComparer.OrdinalIgnoreCase) @@ -480,8 +504,56 @@ public sealed class OnboardingService( { throw new InvalidOperationException("Every included character needs a display name."); } + + var characterLookup = review.Characters.ToDictionary(character => character.TemporaryCharacterKey, StringComparer.Ordinal); + foreach (var character in review.Characters.Where(character => character.Include && !string.IsNullOrWhiteSpace(character.AliasTargetTemporaryCharacterKey))) + { + if (string.Equals(character.TemporaryCharacterKey, character.AliasTargetTemporaryCharacterKey, StringComparison.Ordinal)) + { + throw new InvalidOperationException("A character candidate cannot be an alias of itself."); + } + + if (!characterLookup.TryGetValue(character.AliasTargetTemporaryCharacterKey!, out var target) + || !IsIncludedCanonicalCharacter(target)) + { + throw new InvalidOperationException($"Choose an included primary character for the alias \"{character.Name}\"."); + } + } } + private async Task AddScanCharacterAliasesAsync(ManuscriptScanReviewDecision review, OnboardingManuscriptBuildResult result) + { + var characterIds = result.CharacterMappings.ToDictionary(item => item.TemporaryCharacterKey, item => item.CharacterID, StringComparer.Ordinal); + var decisions = review.Characters.ToDictionary(item => item.TemporaryCharacterKey, StringComparer.Ordinal); + foreach (var alias in review.Characters.Where(character => character.Include && !string.IsNullOrWhiteSpace(character.AliasTargetTemporaryCharacterKey))) + { + if (!decisions.TryGetValue(alias.AliasTargetTemporaryCharacterKey!, out var target) + || !characterIds.TryGetValue(target.TemporaryCharacterKey, out var characterId)) + { + continue; + } + + var cleanAlias = Clean(alias.Name); + if (string.IsNullOrWhiteSpace(cleanAlias) || string.Equals(cleanAlias, target.Name, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var existingAliases = await characters.ListAliasesAsync(characterId); + if (existingAliases.Any(item => string.Equals(item.Alias, cleanAlias, StringComparison.OrdinalIgnoreCase))) + { + continue; + } + + await characters.AddAliasAsync(characterId, cleanAlias, null); + } + } + + private static bool IsIncludedCanonicalCharacter(ManuscriptScanCharacterReviewDecision character) + => character.Include + && string.IsNullOrWhiteSpace(character.AliasTargetTemporaryCharacterKey) + && !string.Equals(character.Category, "Excluded", StringComparison.OrdinalIgnoreCase); + private static Character? MatchExistingCharacter(string? name, IReadOnlyList existingCharacters) { var clean = Clean(name); diff --git a/PlotLine/Services/OnboardingStoryIntelligenceService.cs b/PlotLine/Services/OnboardingStoryIntelligenceService.cs index 53b7566..89fa528 100644 --- a/PlotLine/Services/OnboardingStoryIntelligenceService.cs +++ b/PlotLine/Services/OnboardingStoryIntelligenceService.cs @@ -120,6 +120,7 @@ public sealed class OnboardingStoryIntelligenceService( clientStatus.ChapterStructureModel, clientStatus.SceneIntelligenceModel); var batchItems = new List(); + var knownCharactersJson = BuildKnownCharacterContextJson(preview, review); foreach (var chapter in included) { @@ -151,7 +152,8 @@ public sealed class OnboardingStoryIntelligenceService( SourceParagraphCount = paragraphs.Count, SourceChapterCount = 1, Model = modelSummary, - PromptVersionsSummary = PromptVersionsSummary + PromptVersionsSummary = PromptVersionsSummary, + KnownCharactersJson = knownCharactersJson }); batchItems.Add(new OnboardingStoryIntelligenceBatchItem @@ -238,6 +240,82 @@ public sealed class OnboardingStoryIntelligenceService( return string.IsNullOrWhiteSpace(clean) ? string.Empty : clean[..Math.Min(clean.Length, 160)]; } + public static string? BuildKnownCharacterContextJson(ManuscriptScanPreview preview, ManuscriptScanReviewDecision review) + { + var candidates = preview.CharacterCandidates.ToDictionary(candidate => candidate.TemporaryCharacterKey, StringComparer.Ordinal); + var decisions = review.Characters + .Where(character => character.Include && !string.Equals(character.Category, "Excluded", StringComparison.OrdinalIgnoreCase)) + .ToDictionary(character => character.TemporaryCharacterKey, StringComparer.Ordinal); + var roots = decisions.Values + .Where(character => string.IsNullOrWhiteSpace(character.AliasTargetTemporaryCharacterKey)) + .OrderBy(character => character.Name) + .ThenBy(character => character.TemporaryCharacterKey) + .ToList(); + + var context = new List(); + foreach (var root in roots) + { + var aliasDecisions = decisions.Values + .Where(character => string.Equals(character.AliasTargetTemporaryCharacterKey, root.TemporaryCharacterKey, StringComparison.Ordinal)) + .OrderBy(character => character.Name) + .ToList(); + var names = new List(); + AddName(names, root.Name); + if (candidates.TryGetValue(root.TemporaryCharacterKey, out var rootCandidate)) + { + AddName(names, rootCandidate.Name); + } + + foreach (var alias in aliasDecisions) + { + AddName(names, alias.Name); + if (candidates.TryGetValue(alias.TemporaryCharacterKey, out var aliasCandidate)) + { + AddName(names, aliasCandidate.Name); + } + } + + var aliases = names + .Where(name => !string.Equals(name, root.Name, StringComparison.OrdinalIgnoreCase)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(name => name) + .ToList(); + var allKeys = new[] { root.TemporaryCharacterKey } + .Concat(aliasDecisions.Select(alias => alias.TemporaryCharacterKey)) + .ToList(); + var mentionCount = allKeys + .Select(key => candidates.TryGetValue(key, out var candidate) ? candidate.MentionCount : 0) + .Sum(); + var confidence = allKeys + .Select(key => candidates.TryGetValue(key, out var candidate) ? Math.Clamp(candidate.QualityScore / 100m, 0.1m, 1m) : 0.5m) + .DefaultIfEmpty(0.5m) + .Max(); + + context.Add(new StoryIntelligenceKnownCharacterContext + { + CanonicalName = root.Name, + Aliases = aliases, + ExistingCharacterID = root.ExistingCharacterID, + CandidateKeys = allKeys, + MentionCount = mentionCount, + Confidence = confidence + }); + } + + return context.Count == 0 + ? null + : JsonSerializer.Serialize(context, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); + } + + private static void AddName(List names, string? value) + { + var clean = CleanContext(value); + if (!string.IsNullOrWhiteSpace(clean) && !names.Contains(clean, StringComparer.OrdinalIgnoreCase)) + { + names.Add(clean); + } + } + public async Task GetProgressAsync(Guid batchId) { var batch = await batchStore.GetAsync(RequireUserId(), batchId); diff --git a/PlotLine/Services/PersistedStoryIntelligenceRunner.cs b/PlotLine/Services/PersistedStoryIntelligenceRunner.cs index 8e07ee9..9d31434 100644 --- a/PlotLine/Services/PersistedStoryIntelligenceRunner.cs +++ b/PlotLine/Services/PersistedStoryIntelligenceRunner.cs @@ -24,6 +24,7 @@ public sealed class PersistedStoryIntelligenceRunner( IOptions pricingOptions, IStoryIntelligenceProgressNotifier notifier, IStoryMemoryService storyMemory, + ICharacterIntelligenceService characterIntelligence, ILogger logger) : IPersistedStoryIntelligenceRunner { private const string ChapterPromptFile = "Chapter-Structure-Prompt-V2.md"; @@ -354,6 +355,23 @@ public sealed class PersistedStoryIntelligenceRunner( { logger.LogError(ex, "Story Memory processing failed for import session {ImportSessionId}, scene result {SceneResultId}.", importSessionId, sceneResultId); } + + try + { + var importSession = await pipelines.GetByIdForUserAsync(importSessionId.Value, run.UserID); + if (importSession is not null && sceneAttempt.Parsed is not null) + { + await characterIntelligence.UpdateProfilesForSceneAsync( + importSession, + sceneResultId, + sceneAttempt.Parsed, + cancellationToken); + } + } + catch (Exception ex) when (ex is not OperationCanceledException && !IsFatal(ex)) + { + logger.LogError(ex, "Character Intelligence profile update failed for import session {ImportSessionId}, scene result {SceneResultId}.", importSessionId, sceneResultId); + } } completedScenes++; @@ -834,7 +852,8 @@ public sealed class PersistedStoryIntelligenceRunner( bookId = run.BookID, chapterId = run.ChapterID, chapterNumber = ChapterNumber(run), - sourceLabel = SourceLabel(run) + sourceLabel = SourceLabel(run), + knownCharacters = KnownCharacters(run) }, JsonOptions); private static string BuildSceneContextJson(StoryIntelligenceQueuedRun run, ChapterSceneBoundary boundary) @@ -846,9 +865,27 @@ public sealed class PersistedStoryIntelligenceRunner( sceneId = (int?)null, chapterNumber = ChapterNumber(run), sceneNumber = boundary.SceneNumber, - sourceLabel = $"{SourceLabel(run)}, suggested scene {boundary.SceneNumber}" + sourceLabel = $"{SourceLabel(run)}, suggested scene {boundary.SceneNumber}", + knownCharacters = KnownCharacters(run) }, JsonOptions); + private static IReadOnlyList KnownCharacters(StoryIntelligenceQueuedRun run) + { + if (string.IsNullOrWhiteSpace(run.KnownCharactersJson)) + { + return []; + } + + try + { + return JsonSerializer.Deserialize>(run.KnownCharactersJson, JsonOptions) ?? []; + } + catch (JsonException) + { + return []; + } + } + private static string BuildChapterPrompt(string promptTemplate, string chapterContextJson, string chapterText) => promptTemplate .Replace("{{CHAPTER_CONTEXT_JSON}}", chapterContextJson ?? string.Empty, StringComparison.Ordinal) diff --git a/PlotLine/Services/StoryIntelligenceIllustrationCompatibility.cs b/PlotLine/Services/StoryIntelligenceIllustrationCompatibility.cs index dac1bdf..054ecab 100644 --- a/PlotLine/Services/StoryIntelligenceIllustrationCompatibility.cs +++ b/PlotLine/Services/StoryIntelligenceIllustrationCompatibility.cs @@ -274,6 +274,29 @@ public static class StoryIntelligenceIllustrationCompatibility { var text = (value ?? string.Empty).ToLowerInvariant(); var padded = $" {text} "; + if (ContainsAny(text, "driving test centre", "driving test center", "test centre", "test center")) return "DrivingTestCentre"; + if (ContainsAny(text, "police station")) return "PoliceStation"; + if (ContainsAny(text, "derelict hospital", "abandoned hospital")) return "DerelictHospital"; + if (ContainsAny(text, "driver seat", "driver's seat")) return "DriverSeat"; + if (ContainsAny(text, "passenger seat")) return "PassengerSeat"; + if (ContainsAny(text, "rear seat", "back seat")) return "RearSeat"; + if (ContainsAny(text, "car interior", "vehicle interior", "inside the car", "inside car", "boot", "cabin", "dashboard")) return "VehicleInterior"; + if (ContainsAny(text, "bathroom", "toilet", "washroom")) return "Bathroom"; + if (text.Contains("kitchen", StringComparison.OrdinalIgnoreCase)) return "Kitchen"; + if (text.Contains("bedroom", StringComparison.OrdinalIgnoreCase)) return "Bedroom"; + if (ContainsAny(text, "living room", "sitting room", "lounge")) return "LivingRoom"; + if (ContainsAny(text, "hospital")) return "Hospital"; + if (ContainsAny(text, "church", "chapel")) return "Church"; + if (ContainsAny(text, "restaurant", "cafe", "café", "diner")) return "Restaurant"; + if (ContainsAny(text, "pub", "public house")) return "Pub"; + if (ContainsAny(text, "workshop")) return "Workshop"; + if (ContainsAny(text, "garage")) return "Garage"; + if (ContainsAny(text, "front door", "porch", "entrance", "house exterior", "outside the house", "exterior")) return "HouseExterior"; + if (ContainsAny(text, "office", "desk", "study", "library")) return "Office"; + if (text.Contains("school", StringComparison.OrdinalIgnoreCase)) return "School"; + if (text.Contains("waiting room", StringComparison.OrdinalIgnoreCase)) return "WaitingRoom"; + if (ContainsAny(text, "car park", "parking", "forecourt")) return "CarPark"; + if (text.Contains("shop", StringComparison.OrdinalIgnoreCase)) return "Shop"; if (ContainsAny(text, "dual carriageway")) return "DualCarriageway"; if (ContainsAny(text, "motorway", "highway")) return "Motorway"; if (ContainsAny(text, "flyover")) return "Flyover"; diff --git a/PlotLine/Services/StoryIntelligenceVisualisationSnapshotService.cs b/PlotLine/Services/StoryIntelligenceVisualisationSnapshotService.cs index 10b219a..295ec3e 100644 --- a/PlotLine/Services/StoryIntelligenceVisualisationSnapshotService.cs +++ b/PlotLine/Services/StoryIntelligenceVisualisationSnapshotService.cs @@ -603,7 +603,7 @@ public sealed class StoryIntelligenceVisualisationSnapshotService( { var location = (scene.Locations ?? []) .Where(item => !string.IsNullOrWhiteSpace(item.Name) && item.MentionedOnly != true) - .OrderByDescending(item => item.PresentInScene == true) + .OrderByDescending(LocationSelectionScore) .ThenByDescending(item => Specificity(item.Name)) .ThenByDescending(item => item.Confidence ?? 0) .FirstOrDefault(); @@ -624,6 +624,32 @@ public sealed class StoryIntelligenceVisualisationSnapshotService( }; } + private static decimal LocationSelectionScore(SceneIntelligenceLocation location) + { + var type = StoryIntelligenceIllustrationCompatibility.LocationType($"{location.Name} {location.LocationType} {location.GenericRoomType}"); + var score = location.PresentInScene == true ? 1000m : 0m; + if (string.Equals(location.LocationType, "named", StringComparison.OrdinalIgnoreCase)) + { + score += 100m; + } + + if (type is "DrivingTestCentre" or "PoliceStation" or "DerelictHospital" or "Hospital" or "Church" or "Restaurant" or "Pub" + or "Workshop" or "Garage" or "HouseExterior" or "Office" or "School" or "WaitingRoom" or "CarPark" or "Shop" + or "Kitchen" or "Bathroom" or "Bedroom" or "LivingRoom" or "Hallway" or "Stairwell" or "VehicleInterior" + or "DriverSeat" or "PassengerSeat" or "RearSeat" or "FlatInterior") + { + score += 250m; + } + + if (type is "Road" or "Lane" or "Street" or "Avenue" or "Drive" or "Close" or "Motorway" or "DualCarriageway" + or "Bridge" or "Flyover" or "Underpass") + { + score -= 100m; + } + + return score; + } + private static IReadOnlyList BuildAssets(SceneIntelligenceScene scene) => (scene.Assets ?? []) .Where(asset => !string.IsNullOrWhiteSpace(asset.Name) && asset.MentionedOnly != true && !IsLowValueAsset(asset)) diff --git a/PlotLine/Sql/145_Phase22_CharacterIntelligenceProfiles.sql b/PlotLine/Sql/145_Phase22_CharacterIntelligenceProfiles.sql new file mode 100644 index 0000000..c14bfe1 --- /dev/null +++ b/PlotLine/Sql/145_Phase22_CharacterIntelligenceProfiles.sql @@ -0,0 +1,61 @@ +SET ANSI_NULLS ON; +SET QUOTED_IDENTIFIER ON; +GO + +IF OBJECT_ID(N'dbo.CharacterIntelligenceProfiles', N'U') IS NULL +BEGIN + CREATE TABLE dbo.CharacterIntelligenceProfiles + ( + CharacterIntelligenceProfileID int IDENTITY(1,1) NOT NULL CONSTRAINT PK_CharacterIntelligenceProfiles PRIMARY KEY, + ImportSessionID int NOT NULL, + ProjectID int NOT NULL, + BookID int NOT NULL, + CanonicalIdentityKey nvarchar(180) NOT NULL, + CanonicalName nvarchar(220) NOT NULL, + ProfileJson nvarchar(max) NOT NULL, + Confidence decimal(5,2) NOT NULL CONSTRAINT DF_CharacterIntelligenceProfiles_Confidence DEFAULT 0, + NarrativeImportance nvarchar(80) NULL, + IsRecurring bit NOT NULL CONSTRAINT DF_CharacterIntelligenceProfiles_IsRecurring DEFAULT 0, + EvidenceVersion nvarchar(40) NOT NULL CONSTRAINT DF_CharacterIntelligenceProfiles_EvidenceVersion DEFAULT N'22', + LastUpdatedSceneResultID int NULL, + CreatedUtc datetime2(0) NOT NULL CONSTRAINT DF_CharacterIntelligenceProfiles_CreatedUtc DEFAULT SYSUTCDATETIME(), + UpdatedUtc datetime2(0) NOT NULL CONSTRAINT DF_CharacterIntelligenceProfiles_UpdatedUtc DEFAULT SYSUTCDATETIME(), + CONSTRAINT FK_CharacterIntelligenceProfiles_ImportSession FOREIGN KEY (ImportSessionID) REFERENCES dbo.StoryIntelligenceBookPipelines(StoryIntelligenceBookPipelineID), + CONSTRAINT FK_CharacterIntelligenceProfiles_LastSceneResult FOREIGN KEY (LastUpdatedSceneResultID) REFERENCES dbo.StoryIntelligenceSceneResults(SceneResultID) + ); +END; +GO + +IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'UX_CharacterIntelligenceProfiles_ImportCanonical' AND object_id = OBJECT_ID(N'dbo.CharacterIntelligenceProfiles')) + CREATE UNIQUE INDEX UX_CharacterIntelligenceProfiles_ImportCanonical ON dbo.CharacterIntelligenceProfiles(ImportSessionID, CanonicalIdentityKey); +GO + +IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'IX_CharacterIntelligenceProfiles_ImportRecurring' AND object_id = OBJECT_ID(N'dbo.CharacterIntelligenceProfiles')) + CREATE INDEX IX_CharacterIntelligenceProfiles_ImportRecurring ON dbo.CharacterIntelligenceProfiles(ImportSessionID, IsRecurring DESC, Confidence DESC, CanonicalName); +GO + +IF OBJECT_ID(N'dbo.CharacterIntelligenceEvidenceEvents', N'U') IS NULL +BEGIN + CREATE TABLE dbo.CharacterIntelligenceEvidenceEvents + ( + CharacterIntelligenceEvidenceEventID int IDENTITY(1,1) NOT NULL CONSTRAINT PK_CharacterIntelligenceEvidenceEvents PRIMARY KEY, + CharacterIntelligenceProfileID int NOT NULL, + SceneResultID int NOT NULL, + FieldName nvarchar(80) NOT NULL, + PreviousValue nvarchar(500) NULL, + NewValue nvarchar(500) NULL, + Evidence nvarchar(700) NULL, + Confidence decimal(5,2) NOT NULL CONSTRAINT DF_CharacterIntelligenceEvidenceEvents_Confidence DEFAULT 0, + IsContradiction bit NOT NULL CONSTRAINT DF_CharacterIntelligenceEvidenceEvents_IsContradiction DEFAULT 0, + Source nvarchar(80) NOT NULL CONSTRAINT DF_CharacterIntelligenceEvidenceEvents_Source DEFAULT N'AI profile update', + CreatedUtc datetime2(0) NOT NULL CONSTRAINT DF_CharacterIntelligenceEvidenceEvents_CreatedUtc DEFAULT SYSUTCDATETIME(), + CONSTRAINT FK_CharacterIntelligenceEvidenceEvents_Profile FOREIGN KEY (CharacterIntelligenceProfileID) REFERENCES dbo.CharacterIntelligenceProfiles(CharacterIntelligenceProfileID), + CONSTRAINT FK_CharacterIntelligenceEvidenceEvents_SceneResult FOREIGN KEY (SceneResultID) REFERENCES dbo.StoryIntelligenceSceneResults(SceneResultID) + ); +END; +GO + +IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'IX_CharacterIntelligenceEvidenceEvents_ProfileScene' AND object_id = OBJECT_ID(N'dbo.CharacterIntelligenceEvidenceEvents')) + CREATE INDEX IX_CharacterIntelligenceEvidenceEvents_ProfileScene ON dbo.CharacterIntelligenceEvidenceEvents(CharacterIntelligenceProfileID, SceneResultID, CreatedUtc); +GO + diff --git a/PlotLine/Sql/146_Phase22B_StoryIntelligenceKnownCharactersContext.sql b/PlotLine/Sql/146_Phase22B_StoryIntelligenceKnownCharactersContext.sql new file mode 100644 index 0000000..2679da9 --- /dev/null +++ b/PlotLine/Sql/146_Phase22B_StoryIntelligenceKnownCharactersContext.sql @@ -0,0 +1,98 @@ +SET ANSI_NULLS ON; +GO +SET QUOTED_IDENTIFIER ON; +GO + +IF COL_LENGTH(N'dbo.StoryIntelligenceRuns', N'KnownCharactersJson') IS NULL +BEGIN + ALTER TABLE dbo.StoryIntelligenceRuns ADD KnownCharactersJson nvarchar(max) NULL; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceRun_QueueAdminText + @UserID int, + @ProjectID int = NULL, + @BookID int = NULL, + @ChapterID int = NULL, + @ChapterNumber decimal(9, 2) = NULL, + @SourceType nvarchar(50), + @SourceLabel nvarchar(300), + @SourceText nvarchar(max), + @SourceFileName nvarchar(260) = NULL, + @SourceFileSizeBytes bigint = NULL, + @SourceWordCount int = NULL, + @SourceCharacterCount int = NULL, + @SourceParagraphCount int = NULL, + @SourceChapterCount int = NULL, + @Model nvarchar(100), + @PromptVersionsSummary nvarchar(500), + @KnownCharactersJson nvarchar(max) = NULL +AS +BEGIN + SET NOCOUNT ON; + + INSERT dbo.StoryIntelligenceRuns + ( + UserID, ProjectID, BookID, ChapterID, ChapterNumber, Status, SourceType, SourceLabel, SourceText, + SourceFileName, SourceFileSizeBytes, SourceWordCount, SourceCharacterCount, SourceParagraphCount, + SourceChapterCount, Model, PromptVersion, PromptVersionsSummary, KnownCharactersJson, StartedUtc, + CurrentStage, CurrentMessage, CompletedScenes, FailedScenes + ) + VALUES + ( + @UserID, @ProjectID, @BookID, @ChapterID, @ChapterNumber, N'Pending', @SourceType, @SourceLabel, @SourceText, + @SourceFileName, @SourceFileSizeBytes, @SourceWordCount, @SourceCharacterCount, @SourceParagraphCount, + @SourceChapterCount, @Model, @PromptVersionsSummary, @PromptVersionsSummary, @KnownCharactersJson, + SYSUTCDATETIME(), N'Pending', N'Queued for Story Intelligence processing.', 0, 0 + ); + + SELECT CAST(SCOPE_IDENTITY() AS int) AS StoryIntelligenceRunID; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceRun_ClaimNextPending +AS +BEGIN + SET NOCOUNT ON; + SET XACT_ABORT ON; + + DECLARE @StoryIntelligenceRunID int; + + SELECT TOP (1) @StoryIntelligenceRunID = StoryIntelligenceRunID + FROM dbo.StoryIntelligenceRuns WITH (UPDLOCK, READPAST) + WHERE Status = N'Pending' + ORDER BY CreatedUtc; + + IF @StoryIntelligenceRunID IS NULL + BEGIN + SELECT TOP (0) + StoryIntelligenceRunID, UserID, ProjectID, BookID, ChapterID, ChapterNumber, Status, SourceType, SourceLabel, + SourceText, SourceWordCount, SourceCharacterCount, SourceParagraphCount, SourceChapterCount, PromptVersion, + PromptVersionsSummary, KnownCharactersJson, Model, StartedUtc, CompletedUtc, FailureStage, TotalInputTokens, + TotalOutputTokens, TotalTokens, TotalDurationMs, EstimatedCostGBP, EstimatedCostUSD, + ErrorMessage, ErrorDetail, CurrentStage, CurrentMessage, TotalDetectedScenes, + CompletedScenes, FailedScenes, CancellationRequestedUtc, CancelledUtc, CreatedUtc, UpdatedUtc + FROM dbo.StoryIntelligenceRuns; + RETURN; + END; + + UPDATE dbo.StoryIntelligenceRuns + SET Status = N'Running', + StartedUtc = SYSUTCDATETIME(), + CurrentStage = N'ChapterStructure', + CurrentMessage = N'Running Chapter Structure analysis.', + UpdatedUtc = SYSUTCDATETIME() + WHERE StoryIntelligenceRunID = @StoryIntelligenceRunID + AND Status = N'Pending'; + + SELECT + StoryIntelligenceRunID, UserID, ProjectID, BookID, ChapterID, ChapterNumber, Status, SourceType, SourceLabel, + SourceText, SourceWordCount, SourceCharacterCount, SourceParagraphCount, SourceChapterCount, PromptVersion, + PromptVersionsSummary, KnownCharactersJson, Model, StartedUtc, CompletedUtc, FailureStage, TotalInputTokens, + TotalOutputTokens, TotalTokens, TotalDurationMs, EstimatedCostGBP, EstimatedCostUSD, + ErrorMessage, ErrorDetail, CurrentStage, CurrentMessage, TotalDetectedScenes, + CompletedScenes, FailedScenes, CancellationRequestedUtc, CancelledUtc, CreatedUtc, UpdatedUtc + FROM dbo.StoryIntelligenceRuns + WHERE StoryIntelligenceRunID = @StoryIntelligenceRunID; +END; +GO diff --git a/PlotLine/ViewModels/OnboardingViewModels.cs b/PlotLine/ViewModels/OnboardingViewModels.cs index c00a546..189be77 100644 --- a/PlotLine/ViewModels/OnboardingViewModels.cs +++ b/PlotLine/ViewModels/OnboardingViewModels.cs @@ -668,6 +668,9 @@ public sealed class ManuscriptScanReviewCharacterViewModel public int? ExistingCharacterID { get; init; } public string? ExistingCharacterName { get; init; } public bool IsExistingCharacterMatch => ExistingCharacterID.HasValue; + public string? AliasTargetTemporaryCharacterKey { get; init; } + public string? AliasTargetName { get; init; } + public bool IsAlias => !string.IsNullOrWhiteSpace(AliasTargetTemporaryCharacterKey); } public sealed class ManuscriptScanExistingCharacterOptionViewModel @@ -709,4 +712,5 @@ public sealed class ManuscriptScanCharacterReviewFormItem public string Name { get; set; } = string.Empty; public string Category { get; set; } = "PossibleCharacter"; public int? ExistingCharacterID { get; set; } + public string? AliasTargetTemporaryCharacterKey { get; set; } } diff --git a/PlotLine/Views/Onboarding/ScanReview.cshtml b/PlotLine/Views/Onboarding/ScanReview.cshtml index 37f32ef..8a1fbd0 100644 --- a/PlotLine/Views/Onboarding/ScanReview.cshtml +++ b/PlotLine/Views/Onboarding/ScanReview.cshtml @@ -13,6 +13,10 @@ var excludedCharacters = Model.CharacterCandidates .Where(candidate => string.Equals(candidate.Category, "Excluded", StringComparison.OrdinalIgnoreCase)) .ToList(); + var detectedAliasTargets = Model.CharacterCandidates + .Where(candidate => !string.Equals(candidate.Category, "Excluded", StringComparison.OrdinalIgnoreCase)) + .OrderBy(candidate => candidate.ReviewName) + .ToList(); }
@@ -196,7 +200,21 @@ } + @candidate.MentionCount mention@(candidate.MentionCount == 1 ? string.Empty : "s") / score @candidate.QualityScore + @if (!string.IsNullOrWhiteSpace(candidate.AliasTargetName)) + { + Alias of detected character: @candidate.AliasTargetName + } @if (!string.IsNullOrWhiteSpace(candidate.ExistingCharacterName)) { Matches existing character: @candidate.ExistingCharacterName @@ -242,7 +260,21 @@ } + @candidate.MentionCount mention@(candidate.MentionCount == 1 ? string.Empty : "s") / score @candidate.QualityScore + @if (!string.IsNullOrWhiteSpace(candidate.AliasTargetName)) + { + Alias of detected character: @candidate.AliasTargetName + } @if (!string.IsNullOrWhiteSpace(candidate.Reason)) { @candidate.Reason