Compare commits
2 Commits
c9a8234745
...
72d2438314
| Author | SHA1 | Date | |
|---|---|---|---|
| 72d2438314 | |||
| c3043b994a |
@ -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()
|
||||
{
|
||||
|
||||
73
PlotLine/Data/CharacterIntelligenceRepository.cs
Normal file
73
PlotLine/Data/CharacterIntelligenceRepository.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
100
PlotLine/Docs/AI/Character-Profile-Update-Prompt-V1.md
Normal file
100
PlotLine/Docs/AI/Character-Profile-Update-Prompt-V1.md
Normal 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}}
|
||||
|
||||
@ -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.`
|
||||
|
||||
138
PlotLine/Models/CharacterIntelligenceModels.cs
Normal file
138
PlotLine/Models/CharacterIntelligenceModels.cs
Normal 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; }
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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; }
|
||||
|
||||
@ -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; }
|
||||
|
||||
@ -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>();
|
||||
|
||||
457
PlotLine/Services/CharacterIntelligenceService.cs
Normal file
457
PlotLine/Services/CharacterIntelligenceService.cs
Normal 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);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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))
|
||||
|
||||
61
PlotLine/Sql/145_Phase22_CharacterIntelligenceProfiles.sql
Normal file
61
PlotLine/Sql/145_Phase22_CharacterIntelligenceProfiles.sql
Normal 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
|
||||
|
||||
@ -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
|
||||
@ -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; }
|
||||
}
|
||||
|
||||
@ -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">
|
||||
<h2>Character candidates</h2>
|
||||
<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>
|
||||
|
||||
@ -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,74 +70,88 @@
|
||||
</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="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-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>
|
||||
<option value="@StoryMemoryAppearancePreferences.PredominantlyMedium">Predominantly medium-skinned cast</option>
|
||||
<option value="@StoryMemoryAppearancePreferences.PredominantlyDark">Predominantly dark-skinned cast</option>
|
||||
<option value="@StoryMemoryAppearancePreferences.CustomMix">Custom mix</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Primary character age group
|
||||
<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>
|
||||
<option value="Older teen">Older teen</option>
|
||||
<option value="Young adult">Young adult</option>
|
||||
<option value="Adult">Adult</option>
|
||||
<option value="Middle-aged adult">Middle-aged adult</option>
|
||||
<option value="Senior">Senior</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Era
|
||||
<select class="form-select" name="storyEra">
|
||||
<option value="">No baseline</option>
|
||||
<option value="1980s">1980s</option>
|
||||
<option value="1970s">1970s</option>
|
||||
<option value="1990s">1990s</option>
|
||||
<option value="2000s">2000s</option>
|
||||
<option value="Contemporary">Contemporary</option>
|
||||
<option value="Victorian">Victorian</option>
|
||||
<option value="Medieval">Medieval</option>
|
||||
<option value="Near future">Near future</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Location
|
||||
<select class="form-select" name="storyLocation">
|
||||
<option value="">No baseline</option>
|
||||
<option value="UK">UK</option>
|
||||
<option value="United States">United States</option>
|
||||
<option value="Europe">Europe</option>
|
||||
<option value="Urban">Urban</option>
|
||||
<option value="Suburban">Suburban</option>
|
||||
<option value="Rural">Rural</option>
|
||||
<option value="Fantasy world">Fantasy world</option>
|
||||
<option value="Science fiction setting">Science fiction setting</option>
|
||||
</select>
|
||||
</label>
|
||||
</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>
|
||||
<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>
|
||||
<label>
|
||||
Appearance baseline
|
||||
<select class="form-control" 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>
|
||||
<option value="@StoryMemoryAppearancePreferences.PredominantlyMedium">Predominantly medium-skinned cast</option>
|
||||
<option value="@StoryMemoryAppearancePreferences.PredominantlyDark">Predominantly dark-skinned cast</option>
|
||||
<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">
|
||||
<option value="">No baseline</option>
|
||||
<option value="Late teen / early 20s">Late teen / early 20s</option>
|
||||
<option value="Young teen">Young teen</option>
|
||||
<option value="Older teen">Older teen</option>
|
||||
<option value="Young adult">Young adult</option>
|
||||
<option value="Adult">Adult</option>
|
||||
<option value="Middle-aged adult">Middle-aged adult</option>
|
||||
<option value="Senior">Senior</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Era
|
||||
<select class="form-control" name="storyEra">
|
||||
<option value="">No baseline</option>
|
||||
<option value="1980s">1980s</option>
|
||||
<option value="1970s">1970s</option>
|
||||
<option value="1990s">1990s</option>
|
||||
<option value="2000s">2000s</option>
|
||||
<option value="Contemporary">Contemporary</option>
|
||||
<option value="Victorian">Victorian</option>
|
||||
<option value="Medieval">Medieval</option>
|
||||
<option value="Near future">Near future</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Location
|
||||
<select class="form-control" name="storyLocation">
|
||||
<option value="">No baseline</option>
|
||||
<option value="UK">UK</option>
|
||||
<option value="United States">United States</option>
|
||||
<option value="Europe">Europe</option>
|
||||
<option value="Urban">Urban</option>
|
||||
<option value="Suburban">Suburban</option>
|
||||
<option value="Rural">Rural</option>
|
||||
<option value="Fantasy world">Fantasy world</option>
|
||||
<option value="Science fiction setting">Science fiction setting</option>
|
||||
</select>
|
||||
</label>
|
||||
</fieldset>
|
||||
<button class="btn btn-primary" type="submit" disabled="@(!Model.CanStart)">Analyse manuscript</button>
|
||||
</form>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user