Compare commits

..

2 Commits

22 changed files with 1676 additions and 113 deletions

View File

@ -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),
@ -1148,10 +1149,10 @@ static void Phase21UEnforcesAliasAgeSemanticAndUiResetRules()
Assert(onboarding.Contains("name=\"primaryAgeGroup\"", StringComparison.Ordinal), "Import setup must ask for primary age group.");
Assert(onboarding.Contains("name=\"storyEra\"", StringComparison.Ordinal), "Import setup must ask for era.");
Assert(onboarding.Contains("name=\"storyLocation\"", StringComparison.Ordinal), "Import setup must ask for location.");
Assert(onboarding.Contains("<select class=\"form-control\" name=\"appearancePreference\"", StringComparison.Ordinal), "Appearance guidance must be a dropdown, not a radio list.");
Assert(onboarding.Contains("<select class=\"form-control\" name=\"primaryAgeGroup\"", StringComparison.Ordinal), "Primary age group must be a dropdown.");
Assert(onboarding.Contains("<select class=\"form-control\" name=\"storyEra\"", StringComparison.Ordinal), "Story era must be a dropdown.");
Assert(onboarding.Contains("<select class=\"form-control\" name=\"storyLocation\"", StringComparison.Ordinal), "Story location must be a dropdown.");
Assert(onboarding.Contains("<select class=\"form-select\" name=\"appearancePreference\"", StringComparison.Ordinal), "Appearance guidance must be a dropdown, not a radio list.");
Assert(onboarding.Contains("<select class=\"form-select\" name=\"primaryAgeGroup\"", StringComparison.Ordinal), "Primary age group must be a dropdown.");
Assert(onboarding.Contains("<select class=\"form-select\" name=\"storyEra\"", StringComparison.Ordinal), "Story era must be a dropdown.");
Assert(onboarding.Contains("<select class=\"form-select\" name=\"storyLocation\"", StringComparison.Ordinal), "Story location must be a dropdown.");
Assert(storyMemory.Contains("CharacterAppearanceExtractionService.Extract", StringComparison.Ordinal), "Durable Story Memory must consume dedicated appearance extraction.");
Assert(storyMemory.Contains("SkinToneInstruction(skinTone)", StringComparison.Ordinal), "Demand generation must use hard skin-tone wording.");
Assert(storyMemory.Contains("HairColours.Red", StringComparison.Ordinal) && storyMemory.Contains("SkinTones.Light", StringComparison.Ordinal), "Red-hair demand evidence must force light skin when skin is otherwise unknown.");
@ -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<List<StoryIntelligenceKnownCharacterContext>>(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()
{

View File

@ -0,0 +1,73 @@
using Dapper;
using PlotLine.Models;
namespace PlotLine.Data;
public interface ICharacterIntelligenceRepository
{
Task<IReadOnlyList<CharacterIntelligenceProfile>> ListProfilesAsync(int importSessionId);
Task<CharacterIntelligenceProfile> UpsertProfileAsync(CharacterIntelligenceProfileSave request);
Task AddEvidenceAsync(CharacterIntelligenceEvidenceSave request);
}
public sealed class CharacterIntelligenceRepository(ISqlConnectionFactory connectionFactory) : ICharacterIntelligenceRepository
{
public async Task<IReadOnlyList<CharacterIntelligenceProfile>> ListProfilesAsync(int importSessionId)
{
using var connection = connectionFactory.CreateConnection();
var rows = await connection.QueryAsync<CharacterIntelligenceProfile>(
"""
SELECT *
FROM dbo.CharacterIntelligenceProfiles
WHERE ImportSessionID = @ImportSessionID
ORDER BY IsRecurring DESC, Confidence DESC, CanonicalName;
""",
new { ImportSessionID = importSessionId });
return rows.ToList();
}
public async Task<CharacterIntelligenceProfile> UpsertProfileAsync(CharacterIntelligenceProfileSave request)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleAsync<CharacterIntelligenceProfile>(
"""
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);
}
}

View File

@ -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);
}

View File

@ -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}}

View File

@ -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.`

View File

@ -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<string> 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<string> DistinctiveFeatures { get; set; } = [];
public string? TypicalClothing { get; set; }
public string? Occupation { get; set; }
public List<string> Relationships { get; set; } = [];
public string? CurrentStatus { get; set; }
public decimal Confidence { get; set; }
public List<CharacterIntelligenceEvidenceItem> EvidenceTimeline { get; set; } = [];
public List<CharacterIntelligenceContradiction> 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<CharacterIntelligenceProfileUpdate> Updates { get; init; } = [];
}
public sealed class CharacterIntelligenceProfileUpdate
{
public string CanonicalName { get; init; } = string.Empty;
public List<string> Aliases { get; init; } = [];
public string? RecurrenceSignal { get; init; }
public decimal Confidence { get; init; }
public List<CharacterIntelligenceFieldUpdate> FieldUpdates { get; init; } = [];
public List<CharacterIntelligenceAiContradiction> Contradictions { get; init; } = [];
[JsonExtensionData]
public Dictionary<string, System.Text.Json.JsonElement>? 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; }
}

View File

@ -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

View File

@ -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<string>? DistinctiveFeatures { get; init; }
public string? Clothing { get; init; }

View File

@ -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<string> Aliases { get; init; } = [];
public int? ExistingCharacterID { get; init; }
public IReadOnlyList<string> 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; }

View File

@ -150,6 +150,7 @@ public class Program
builder.Services.AddScoped<IStoryIntelligencePipelineRepository, StoryIntelligencePipelineRepository>();
builder.Services.AddScoped<IStoryIntelligenceIllustrationMatchingRepository, StoryIntelligenceIllustrationMatchingRepository>();
builder.Services.AddScoped<IStoryMemoryRepository, StoryMemoryRepository>();
builder.Services.AddScoped<ICharacterIntelligenceRepository, CharacterIntelligenceRepository>();
builder.Services.AddScoped<IIllustrationLibraryRepository, IllustrationLibraryRepository>();
builder.Services.AddScoped<IProjectAccessRepository, ProjectAccessRepository>();
builder.Services.AddScoped<IProjectCollaborationRepository, ProjectCollaborationRepository>();
@ -222,6 +223,7 @@ public class Program
builder.Services.AddScoped<IStoryIntelligenceKnowledgeImportService, StoryIntelligenceKnowledgeImportService>();
builder.Services.AddScoped<IStoryIntelligenceVisualisationSnapshotService, StoryIntelligenceVisualisationSnapshotService>();
builder.Services.AddScoped<IStoryMemoryService, StoryMemoryService>();
builder.Services.AddScoped<ICharacterIntelligenceService, CharacterIntelligenceService>();
builder.Services.AddScoped<IStoryIntelligencePipelineStateService, StoryIntelligencePipelineStateService>();
builder.Services.AddScoped<IStoryIntelligenceProgressNotifier, StoryIntelligenceProgressNotifier>();
builder.Services.AddScoped<ISceneImportResolver, SceneImportResolver>();

View File

@ -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<CharacterIntelligenceService> 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<CharacterIntelligenceProfileUpdateResponse?> TryGetAiUpdatesAsync(
StoryIntelligenceBookPipelineState importSession,
int sceneResultId,
SceneIntelligenceScene scene,
IReadOnlyList<CharacterIntelligenceProfile> 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<CharacterIntelligenceProfileUpdateResponse>(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<CharacterIntelligenceProfileDocument>(profile.ProfileJson, JsonOptions);
}
catch (JsonException)
{
return null;
}
}
private static CharacterIntelligenceProfileDocument NewProfile(string canonicalName, IEnumerable<string>? aliases)
=> new()
{
CanonicalName = Clean(canonicalName),
Aliases = CleanList(aliases),
Confidence = 0.25m,
LastUpdatedUtc = DateTime.UtcNow
};
private static IReadOnlyList<CharacterIntelligenceEvidenceSave> ApplyUpdate(
CharacterIntelligenceProfileDocument profile,
CharacterIntelligenceProfileUpdate update,
int sceneResultId,
SceneIntelligenceScene scene)
{
var events = new List<CharacterIntelligenceEvidenceSave>();
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<CharacterIntelligenceProfileUpdate>();
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<CharacterIntelligenceFieldUpdate>
{
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<string> 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<CharacterIntelligenceProfile> 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<StoryMemoryCharacter> characters,
IReadOnlyList<StoryMemoryCharacterAttribute> 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<string>? 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<string>? 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<string> target, IEnumerable<string>? values)
{
foreach (var value in CleanList(values))
{
AddUnique(target, value);
}
}
private static void AddUnique(List<string> target, string? value)
{
var clean = Clean(value);
if (!string.IsNullOrWhiteSpace(clean) && !target.Contains(clean, StringComparer.OrdinalIgnoreCase))
{
target.Add(clean);
}
}
private static List<string> CleanList(IEnumerable<string>? 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);
}

View File

@ -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<Character> existingCharacters)
{
var clean = Clean(name);

View File

@ -120,6 +120,7 @@ public sealed class OnboardingStoryIntelligenceService(
clientStatus.ChapterStructureModel,
clientStatus.SceneIntelligenceModel);
var batchItems = new List<OnboardingStoryIntelligenceBatchItem>();
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<StoryIntelligenceKnownCharacterContext>();
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<string>();
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<string> names, string? value)
{
var clean = CleanContext(value);
if (!string.IsNullOrWhiteSpace(clean) && !names.Contains(clean, StringComparer.OrdinalIgnoreCase))
{
names.Add(clean);
}
}
public async Task<StoryIntelligenceProgressViewModel?> GetProgressAsync(Guid batchId)
{
var batch = await batchStore.GetAsync(RequireUserId(), batchId);

View File

@ -24,6 +24,7 @@ public sealed class PersistedStoryIntelligenceRunner(
IOptions<StoryIntelligencePricingOptions> pricingOptions,
IStoryIntelligenceProgressNotifier notifier,
IStoryMemoryService storyMemory,
ICharacterIntelligenceService characterIntelligence,
ILogger<PersistedStoryIntelligenceRunner> 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<StoryIntelligenceKnownCharacterContext> KnownCharacters(StoryIntelligenceQueuedRun run)
{
if (string.IsNullOrWhiteSpace(run.KnownCharactersJson))
{
return [];
}
try
{
return JsonSerializer.Deserialize<IReadOnlyList<StoryIntelligenceKnownCharacterContext>>(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)

View File

@ -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";

View File

@ -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<StoryIntelligenceExperienceAssetState> BuildAssets(SceneIntelligenceScene scene)
=> (scene.Assets ?? [])
.Where(asset => !string.IsNullOrWhiteSpace(asset.Name) && asset.MentionedOnly != true && !IsLowValueAsset(asset))

View File

@ -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

View File

@ -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

View File

@ -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; }
}

View File

@ -13,6 +13,11 @@
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();
var visibleCharacterCount = probableCharacters.Count + possibleCharacters.Count + relationshipTitles.Count;
}
<section class="onboarding-shell" aria-labelledby="scan-review-title">
@ -23,9 +28,8 @@
<div class="onboarding-copy">
<p class="eyebrow">Step 7 of 9</p>
<h1 id="scan-review-title">Review Chapters</h1>
<p>Review the chapters PlotDirector found before Story Intelligence begins.</p>
<p>Scene and character scan details are shown for context only in this phase.</p>
<h1 id="scan-review-title">Review Manuscript Scan</h1>
<p>Confirm the chapter structure first, then review the detected character names and aliases before Story Intelligence begins.</p>
</div>
@if (TempData["OnboardingReviewMessage"] is string reviewMessage)
@ -61,8 +65,13 @@
<button class="btn btn-sm btn-outline-secondary" type="reset">Restore scan defaults</button>
</div>
<section class="onboarding-review-grid" aria-label="Scan preview">
<div class="onboarding-review-section">
<section class="onboarding-review-workflow" aria-label="Scan preview" data-scan-review-tabs>
<div class="onboarding-review-tabs" role="tablist" aria-label="Scan review sections">
<button class="is-active" type="button" role="tab" aria-selected="true" data-scan-review-tab="structure">1. Chapters and scenes</button>
<button type="button" role="tab" aria-selected="false" data-scan-review-tab="characters">2. Characters</button>
</div>
<div class="onboarding-review-section" data-scan-review-panel="structure">
<h2>Chapters and scenes</h2>
@if (!Model.Chapters.Any())
{
@ -140,10 +149,19 @@
}
</div>
}
<div class="onboarding-actions onboarding-actions--inline">
<button class="btn btn-primary" type="button" data-scan-review-next="characters">Continue to characters</button>
</div>
</div>
<aside class="onboarding-review-section">
<aside class="onboarding-review-section" data-scan-review-panel="characters" hidden>
<div class="onboarding-review-section-heading">
<div>
<h2>Character candidates</h2>
<p>@visibleCharacterCount.ToString("N0") detected candidate@(visibleCharacterCount == 1 ? string.Empty : "s") before excluded names.</p>
</div>
<button class="btn btn-outline-secondary btn-sm" type="button" data-scan-review-next="structure">Back to chapters</button>
</div>
@if (!probableCharacters.Any() && !possibleCharacters.Any() && !relationshipTitles.Any())
{
<p>No repeated character names were found yet. You can still prepare chapters and add characters later.</p>
@ -155,6 +173,11 @@
Find a candidate
<input class="form-control" type="search" placeholder="Search names or notes" data-character-filter />
</label>
<div class="onboarding-character-pager" data-character-pager hidden>
<button class="btn btn-outline-secondary btn-sm" type="button" data-character-page-prev>Previous</button>
<span data-character-page-label></span>
<button class="btn btn-outline-secondary btn-sm" type="button" data-character-page-next>Next</button>
</div>
<p class="onboarding-empty-state" data-character-empty hidden>No matching candidates.</p>
<div class="onboarding-character-groups">
@foreach (var group in new[]
@ -174,7 +197,7 @@
<ul class="onboarding-character-candidates">
@foreach (var candidate in group.Candidates)
{
<li data-character-name="@candidate.ReviewName" data-character-reason="@candidate.Reason">
<li data-character-name="@candidate.ReviewName" data-character-reason="@candidate.Reason" data-character-page-item>
<input type="hidden" name="Characters[@characterIndex].TemporaryCharacterKey" value="@candidate.TemporaryCharacterKey" />
<input type="hidden" name="Characters[@characterIndex].Category" value="@candidate.Category" />
<label class="onboarding-review-toggle">
@ -196,7 +219,21 @@
}
</select>
</label>
<label class="onboarding-review-field">
Alias of detected character
<select class="form-select" name="Characters[@characterIndex].AliasTargetTemporaryCharacterKey">
<option value="">Not an alias</option>
@foreach (var target in detectedAliasTargets.Where(target => !string.Equals(target.TemporaryCharacterKey, candidate.TemporaryCharacterKey, StringComparison.Ordinal)))
{
<option value="@target.TemporaryCharacterKey" selected="@(string.Equals(candidate.AliasTargetTemporaryCharacterKey, target.TemporaryCharacterKey, StringComparison.Ordinal))">@target.ReviewName</option>
}
</select>
</label>
<span>@candidate.MentionCount mention@(candidate.MentionCount == 1 ? string.Empty : "s") / score @candidate.QualityScore</span>
@if (!string.IsNullOrWhiteSpace(candidate.AliasTargetName))
{
<small>Alias of detected character: @candidate.AliasTargetName</small>
}
@if (!string.IsNullOrWhiteSpace(candidate.ExistingCharacterName))
{
<small>Matches existing character: @candidate.ExistingCharacterName</small>
@ -220,7 +257,7 @@
<ul class="onboarding-character-candidates">
@foreach (var candidate in excludedCharacters)
{
<li data-character-name="@candidate.ReviewName" data-character-reason="@candidate.Reason">
<li data-character-name="@candidate.ReviewName" data-character-reason="@candidate.Reason" data-character-page-item>
<input type="hidden" name="Characters[@characterIndex].TemporaryCharacterKey" value="@candidate.TemporaryCharacterKey" />
<input type="hidden" name="Characters[@characterIndex].Category" value="@candidate.Category" />
<label class="onboarding-review-toggle">
@ -242,7 +279,21 @@
}
</select>
</label>
<label class="onboarding-review-field">
Alias of detected character
<select class="form-select" name="Characters[@characterIndex].AliasTargetTemporaryCharacterKey">
<option value="">Not an alias</option>
@foreach (var target in detectedAliasTargets.Where(target => !string.Equals(target.TemporaryCharacterKey, candidate.TemporaryCharacterKey, StringComparison.Ordinal)))
{
<option value="@target.TemporaryCharacterKey" selected="@(string.Equals(candidate.AliasTargetTemporaryCharacterKey, target.TemporaryCharacterKey, StringComparison.Ordinal))">@target.ReviewName</option>
}
</select>
</label>
<span>@candidate.MentionCount mention@(candidate.MentionCount == 1 ? string.Empty : "s") / score @candidate.QualityScore</span>
@if (!string.IsNullOrWhiteSpace(candidate.AliasTargetName))
{
<small>Alias of detected character: @candidate.AliasTargetName</small>
}
@if (!string.IsNullOrWhiteSpace(candidate.Reason))
{
<small>@candidate.Reason</small>
@ -267,6 +318,33 @@
</section>
<script>
const scanReviewRoot = document.querySelector("[data-scan-review-tabs]");
const showScanPanel = (panelName) => {
if (!scanReviewRoot) {
return;
}
scanReviewRoot.querySelectorAll("[data-scan-review-tab]").forEach((tab) => {
const isActive = tab.dataset.scanReviewTab === panelName;
tab.classList.toggle("is-active", isActive);
tab.setAttribute("aria-selected", String(isActive));
});
scanReviewRoot.querySelectorAll("[data-scan-review-panel]").forEach((panel) => {
panel.hidden = panel.dataset.scanReviewPanel !== panelName;
});
};
scanReviewRoot?.addEventListener("click", (event) => {
const tab = event.target.closest("[data-scan-review-tab]");
if (tab) {
showScanPanel(tab.dataset.scanReviewTab);
}
const next = event.target.closest("[data-scan-review-next]");
if (next) {
showScanPanel(next.dataset.scanReviewNext);
}
});
const reviewTools = document.querySelector("[data-review-tools]");
reviewTools?.addEventListener("click", (event) => {
const button = event.target.closest("[data-bulk-target]");
@ -282,26 +360,79 @@
});
const characterFilter = document.querySelector("[data-character-filter]");
characterFilter?.addEventListener("input", () => {
const query = characterFilter.value.trim().toLowerCase();
let visibleCount = 0;
document.querySelectorAll("[data-character-name]").forEach((item) => {
const text = `${item.dataset.characterName || ""} ${item.dataset.characterReason || ""}`.toLowerCase();
const isVisible = query.length === 0 || text.includes(query);
item.hidden = !isVisible;
if (isVisible) {
visibleCount++;
}
});
const characterPager = document.querySelector("[data-character-pager]");
const characterPageLabel = document.querySelector("[data-character-page-label]");
const characterPagePrev = document.querySelector("[data-character-page-prev]");
const characterPageNext = document.querySelector("[data-character-page-next]");
const characterPageSize = 20;
let characterPage = 1;
const characterItems = () => [...document.querySelectorAll("[data-character-page-item]")];
const matchingCharacterItems = () => {
const query = characterFilter?.value.trim().toLowerCase() || "";
return characterItems().filter((item) => {
const text = `${item.dataset.characterName || ""} ${item.dataset.characterReason || ""}`.toLowerCase();
return query.length === 0 || text.includes(query);
});
};
const refreshCharacterSections = () => {
document.querySelectorAll(".onboarding-character-groups section").forEach((section) => {
const hasVisibleItems = [...section.querySelectorAll("[data-character-name]")].some((item) => !item.hidden);
const hasVisibleItems = [...section.querySelectorAll("[data-character-page-item]")].some((item) => !item.hidden);
section.hidden = !hasVisibleItems;
});
};
const renderCharacterPage = () => {
const matches = matchingCharacterItems();
const pageCount = Math.max(1, Math.ceil(matches.length / characterPageSize));
characterPage = Math.min(Math.max(characterPage, 1), pageCount);
const start = (characterPage - 1) * characterPageSize;
const visible = new Set(matches.slice(start, start + characterPageSize));
characterItems().forEach((item) => {
item.hidden = !visible.has(item);
});
if (characterPager) {
characterPager.hidden = matches.length <= characterPageSize;
}
if (characterPageLabel) {
characterPageLabel.textContent = matches.length === 0
? "No candidates"
: `Page ${characterPage} of ${pageCount} / ${matches.length.toLocaleString()} candidate${matches.length === 1 ? "" : "s"}`;
}
if (characterPagePrev) {
characterPagePrev.disabled = characterPage <= 1;
}
if (characterPageNext) {
characterPageNext.disabled = characterPage >= pageCount;
}
refreshCharacterSections();
const emptyState = document.querySelector("[data-character-empty]");
if (emptyState) {
emptyState.hidden = visibleCount > 0;
emptyState.hidden = matches.length > 0;
}
};
characterFilter?.addEventListener("input", () => {
characterPage = 1;
renderCharacterPage();
});
characterPagePrev?.addEventListener("click", () => {
characterPage -= 1;
renderCharacterPage();
});
characterPageNext?.addEventListener("click", () => {
characterPage += 1;
renderCharacterPage();
});
renderCharacterPage();
</script>

View File

@ -4,12 +4,12 @@
}
<section class="onboarding-shell" aria-labelledby="story-intelligence-title">
<div class="onboarding-panel">
<partial name="_StoryIntelligencePipelineHeader" model="@(new StoryIntelligencePipelineHeaderViewModel { CurrentStage = "Analyse Manuscript" })" />
<div class="onboarding-panel onboarding-analysis-panel">
<partial name="_OnboardingJourneyHeader" model="@(new OnboardingJourneyHeaderViewModel { CurrentStep = 8 })" />
<div class="onboarding-copy">
<p class="eyebrow">Analyse manuscript</p>
<p class="eyebrow">Step 8 of 9</p>
<h1 id="story-intelligence-title">Analyse Manuscript</h1>
<p>PlotDirector will analyse approved chapters in the background and prepare scene suggestions for review.</p>
<p>Set the analysis context, then PlotDirector will read the approved chapters and prepare scenes, characters, locations, assets, relationships and knowledge for review.</p>
</div>
@if (TempData["OnboardingStoryIntelligenceError"] is string error)
@ -59,14 +59,9 @@
</div>
</div>
<section class="onboarding-form-section">
<p>Nothing will be created yet. PlotDirector will prepare scene suggestions for review.</p>
<p>Scenes are reviewed first. Character review follows after scenes are created, with locations, assets, relationships and knowledge shown as upcoming Story Intelligence stages.</p>
</section>
@if (!string.IsNullOrWhiteSpace(Model.Message))
{
<section class="onboarding-form-section">
<section class="onboarding-form-section onboarding-form-section--quiet">
<p>@Model.Message</p>
@if (Model.MissingChapterTextCount > 0)
{
@ -75,18 +70,20 @@
</section>
}
<div class="onboarding-actions">
<a class="btn btn-outline-secondary" asp-action="ScanReview" asp-route-previewId="@Model.PreviewID">Back to chapter review</a>
<form asp-action="SkipStoryIntelligence" method="post">
<button class="btn btn-outline-secondary" type="submit">Skip for now</button>
</form>
<form asp-action="StartStoryIntelligence" method="post" class="onboarding-form-section">
<fieldset>
<legend>Unresolved character appearance</legend>
<p>Explicit manuscript evidence always wins. This only guides characters whose appearance is not described.</p>
<form asp-action="StartStoryIntelligence" method="post" class="onboarding-analysis-setup">
<div class="onboarding-analysis-setup__header">
<div>
<p class="eyebrow">Analysis setup</p>
<h2>Story and portrait context</h2>
<p>Explicit manuscript evidence always wins. These choices guide unresolved generated portraits and broad story context.</p>
</div>
<strong>Required before analysis</strong>
</div>
<div class="onboarding-analysis-controls">
<label>
Appearance baseline
<select class="form-control" name="appearancePreference">
<select class="form-select" name="appearancePreference">
<option value="@StoryMemoryAppearancePreferences.Balanced">Balanced diverse cast</option>
<option value="@StoryMemoryAppearancePreferences.ExplicitEvidenceOnly">Explicit evidence only</option>
<option value="@StoryMemoryAppearancePreferences.PredominantlyLight">Predominantly light-skinned cast</option>
@ -95,13 +92,9 @@
<option value="@StoryMemoryAppearancePreferences.CustomMix">Custom mix</option>
</select>
</label>
</fieldset>
<fieldset>
<legend>Story context for generated portraits</legend>
<p>These guide generated images when the manuscript is silent. Explicit manuscript evidence still wins.</p>
<label>
Primary character age group
<select class="form-control" name="primaryAgeGroup">
<select class="form-select" name="primaryAgeGroup">
<option value="">No baseline</option>
<option value="Late teen / early 20s">Late teen / early 20s</option>
<option value="Young teen">Young teen</option>
@ -114,7 +107,7 @@
</label>
<label>
Era
<select class="form-control" name="storyEra">
<select class="form-select" name="storyEra">
<option value="">No baseline</option>
<option value="1980s">1980s</option>
<option value="1970s">1970s</option>
@ -128,7 +121,7 @@
</label>
<label>
Location
<select class="form-control" name="storyLocation">
<select class="form-select" name="storyLocation">
<option value="">No baseline</option>
<option value="UK">UK</option>
<option value="United States">United States</option>
@ -140,8 +133,24 @@
<option value="Science fiction setting">Science fiction setting</option>
</select>
</label>
</fieldset>
</div>
<div class="onboarding-analysis-flow">
<div><span>1</span><strong>Analyse chapters</strong></div>
<div><span>2</span><strong>Review scenes</strong></div>
<div><span>3</span><strong>Review story entities</strong></div>
<div><span>4</span><strong>Build database</strong></div>
</div>
<div class="onboarding-actions">
<a class="btn btn-outline-secondary" asp-action="ScanReview" asp-route-previewId="@Model.PreviewID">Back to scan review</a>
<button class="btn btn-primary" type="submit" disabled="@(!Model.CanStart)">Analyse manuscript</button>
</div>
</form>
<div class="onboarding-actions onboarding-actions--secondary">
<form asp-action="SkipStoryIntelligence" method="post">
<button class="btn btn-outline-secondary" type="submit">Skip for now</button>
</form>
</div>
}

View File

@ -222,6 +222,15 @@
flex-wrap: wrap;
}
.onboarding-actions--inline {
margin-top: .75rem;
}
.onboarding-actions--secondary {
justify-content: flex-start;
margin-top: .9rem;
}
.onboarding-form-section {
display: grid;
gap: .75rem;
@ -232,6 +241,10 @@
background: rgba(255, 255, 255, .45);
}
.onboarding-form-section--quiet {
background: rgba(255, 255, 255, .58);
}
.onboarding-form-section h2 {
margin: 0;
font-size: 1.05rem;
@ -464,10 +477,46 @@
gap: 1rem;
}
.onboarding-review-workflow {
display: grid;
gap: 1rem;
}
.onboarding-review-tabs {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: .35rem;
border: 1px solid rgba(31, 42, 68, .14);
border-radius: 8px;
padding: .35rem;
background: rgba(255, 255, 255, .58);
}
.onboarding-review-tabs button {
min-height: 2.7rem;
border: 1px solid transparent;
border-radius: 6px;
background: transparent;
color: var(--bs-secondary-color);
font: inherit;
font-weight: 800;
}
.onboarding-review-tabs button.is-active {
border-color: rgba(47, 111, 99, .34);
background: rgba(47, 111, 99, .11);
color: var(--bs-body-color);
box-shadow: inset 0 0 0 1px rgba(47, 111, 99, .08);
}
.onboarding-review-section {
display: grid;
align-content: start;
gap: .8rem;
border: 1px solid rgba(31, 42, 68, .1);
border-radius: 8px;
padding: 1rem;
background: rgba(255, 255, 255, .42);
}
.onboarding-review-section h2 {
@ -475,6 +524,18 @@
font-size: 1.2rem;
}
.onboarding-review-section-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}
.onboarding-review-section-heading p {
margin: .25rem 0 0;
color: var(--bs-secondary-color);
}
.onboarding-character-filter {
display: grid;
gap: .35rem;
@ -1285,6 +1346,23 @@ summary.story-review-chapter-heading {
font-size: 1rem;
}
.onboarding-character-pager {
display: flex;
align-items: center;
justify-content: space-between;
gap: .75rem;
border: 1px solid rgba(31, 42, 68, .1);
border-radius: 8px;
padding: .6rem;
background: rgba(255, 255, 255, .62);
}
.onboarding-character-pager span {
color: var(--bs-secondary-color);
font-weight: 800;
text-align: center;
}
.onboarding-excluded-candidates {
border-top: 1px solid rgba(31, 42, 68, .12);
padding-top: .65rem;
@ -1419,6 +1497,105 @@ summary.story-review-chapter-heading {
background: rgba(47, 111, 99, .07);
}
.onboarding-analysis-panel {
width: min(1040px, 100%);
}
.onboarding-analysis-setup {
display: grid;
gap: 1rem;
margin-top: 1rem;
border: 1px solid rgba(47, 111, 99, .28);
border-radius: 8px;
padding: clamp(1rem, 2vw, 1.35rem);
background: linear-gradient(135deg, rgba(47, 111, 99, .1), rgba(255, 255, 255, .72) 52%, rgba(182, 138, 70, .12));
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .54);
}
.onboarding-analysis-setup__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}
.onboarding-analysis-setup__header h2 {
margin: 0 0 .35rem;
font-size: 1.35rem;
}
.onboarding-analysis-setup__header p:not(.eyebrow) {
margin: 0;
color: var(--bs-secondary-color);
}
.onboarding-analysis-setup__header > strong {
flex: 0 0 auto;
border: 1px solid rgba(47, 111, 99, .22);
border-radius: 999px;
padding: .35rem .7rem;
background: rgba(255, 255, 255, .72);
color: #2f6f63;
font-size: .82rem;
white-space: nowrap;
}
.onboarding-analysis-controls {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: .8rem;
}
.onboarding-analysis-controls label {
display: grid;
gap: .35rem;
min-width: 0;
color: var(--bs-body-color);
font-weight: 800;
}
.onboarding-analysis-controls select {
min-height: 3rem;
border-color: rgba(31, 42, 68, .22);
background-color: #fff;
font-weight: 700;
}
.onboarding-analysis-flow {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: .65rem;
}
.onboarding-analysis-flow div {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
gap: .55rem;
min-width: 0;
border: 1px solid rgba(31, 42, 68, .1);
border-radius: 8px;
padding: .7rem;
background: rgba(255, 255, 255, .64);
}
.onboarding-analysis-flow span {
display: grid;
place-items: center;
width: 1.45rem;
aspect-ratio: 1;
border-radius: 50%;
background: #2f6f63;
color: #fff;
font-size: .8rem;
font-weight: 900;
}
.onboarding-analysis-flow strong {
min-width: 0;
font-size: .9rem;
}
.story-intelligence-dashboard-card {
display: flex;
align-items: center;
@ -1585,6 +1762,8 @@ summary.story-review-chapter-heading {
.onboarding-dashboard-nudge,
.onboarding-companion-card,
.onboarding-review-grid,
.onboarding-analysis-controls,
.onboarding-analysis-flow,
.onboarding-summary div,
.story-intelligence-dashboard-card {
align-items: stretch;
@ -1592,6 +1771,15 @@ summary.story-review-chapter-heading {
flex-direction: column;
}
.onboarding-analysis-setup__header,
.onboarding-review-section-heading {
flex-direction: column;
}
.onboarding-review-tabs {
grid-template-columns: 1fr;
}
.onboarding-story-facts {
grid-template-columns: 1fr;
}