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 extracts mandatory character appearance fixtures", Phase21UExtractsMandatoryCharacterAppearanceFixtures),
|
||||||
("Phase 21U enforces alias age semantic and UI reset rules", Phase21UEnforcesAliasAgeSemanticAndUiResetRules),
|
("Phase 21U enforces alias age semantic and UI reset rules", Phase21UEnforcesAliasAgeSemanticAndUiResetRules),
|
||||||
("Story Intelligence visual polish keeps labels fixed and semantics specific", StoryIntelligenceVisualPolishKeepsLabelsFixedAndSemanticsSpecific),
|
("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),
|
("Scan review post supports full-book form submissions", ScanReviewPostSupportsFullBookFormSubmissions),
|
||||||
("Story Intelligence experience boot does not serialise live model", StoryIntelligenceExperienceBootDoesNotSerialiseLiveModel),
|
("Story Intelligence experience boot does not serialise live model", StoryIntelligenceExperienceBootDoesNotSerialiseLiveModel),
|
||||||
("Live visualisation strips illustration diagnostics", LiveVisualisationStripsIllustrationDiagnostics),
|
("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=\"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=\"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("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-select\" 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-select\" 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-select\" 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=\"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("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("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.");
|
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("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("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("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.LocationCompatible("VehicleInterior", "HouseExterior"), "Vehicle interiors must not resolve to houses or buildings.");
|
||||||
|
|
||||||
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("passport") == "Passport", "Passport should not be Generic Object.");
|
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.");
|
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()
|
static JsonSerializerOptions JsonOptions()
|
||||||
=> new()
|
=> 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.SourceParagraphCount,
|
||||||
request.SourceChapterCount,
|
request.SourceChapterCount,
|
||||||
request.Model,
|
request.Model,
|
||||||
request.PromptVersionsSummary
|
request.PromptVersionsSummary,
|
||||||
|
request.KnownCharactersJson
|
||||||
},
|
},
|
||||||
commandType: CommandType.StoredProcedure);
|
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.
|
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:
|
If something is not stated or strongly implied:
|
||||||
|
|
||||||
- use `null` for scalar values;
|
- use `null` for scalar values;
|
||||||
@ -212,6 +219,7 @@ Report characters who are:
|
|||||||
For each character:
|
For each character:
|
||||||
|
|
||||||
- use the manuscript's name or label;
|
- 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`;
|
- include exactly these properties: `name`, `roleInScene`, `mentionedOnly`, `aliases`, `actions`, `confidence`, `notes`;
|
||||||
- set `mentionedOnly` to `false` when active in the scene;
|
- set `mentionedOnly` to `false` when active in the scene;
|
||||||
- set `mentionedOnly` to `true` when absent and only referred to;
|
- 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.
|
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.
|
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.`
|
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 Name { get; init; } = string.Empty;
|
||||||
public string Category { get; init; } = "PossibleCharacter";
|
public string Category { get; init; } = "PossibleCharacter";
|
||||||
public int? ExistingCharacterID { get; init; }
|
public int? ExistingCharacterID { get; init; }
|
||||||
|
public string? AliasTargetTemporaryCharacterKey { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class OnboardingManuscriptBuildResult
|
public sealed class OnboardingManuscriptBuildResult
|
||||||
|
|||||||
@ -111,8 +111,10 @@ public sealed class SceneIntelligenceCharacterAppearance
|
|||||||
public string? HairColour { get; init; }
|
public string? HairColour { get; init; }
|
||||||
public string? HairLength { get; init; }
|
public string? HairLength { get; init; }
|
||||||
public string? HairStyle { get; init; }
|
public string? HairStyle { get; init; }
|
||||||
|
public string? EyeColour { get; init; }
|
||||||
public string? FacialHair { get; init; }
|
public string? FacialHair { get; init; }
|
||||||
public string? Glasses { get; init; }
|
public string? Glasses { get; init; }
|
||||||
|
public string? Height { get; init; }
|
||||||
public string? Build { get; init; }
|
public string? Build { get; init; }
|
||||||
public List<string>? DistinctiveFeatures { get; init; }
|
public List<string>? DistinctiveFeatures { get; init; }
|
||||||
public string? Clothing { get; init; }
|
public string? Clothing { get; init; }
|
||||||
|
|||||||
@ -149,6 +149,18 @@ public sealed class StoryIntelligenceRunQueueRequest
|
|||||||
public int? SourceChapterCount { get; init; }
|
public int? SourceChapterCount { get; init; }
|
||||||
public string Model { get; init; } = string.Empty;
|
public string Model { get; init; } = string.Empty;
|
||||||
public string PromptVersionsSummary { 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
|
public sealed class StoryIntelligenceChapterResultSaveRequest
|
||||||
@ -283,6 +295,7 @@ public sealed class StoryIntelligenceQueuedRun
|
|||||||
public int? SourceChapterCount { get; init; }
|
public int? SourceChapterCount { get; init; }
|
||||||
public string PromptVersion { get; init; } = string.Empty;
|
public string PromptVersion { get; init; } = string.Empty;
|
||||||
public string? PromptVersionsSummary { get; init; }
|
public string? PromptVersionsSummary { get; init; }
|
||||||
|
public string? KnownCharactersJson { get; init; }
|
||||||
public string Model { get; init; } = string.Empty;
|
public string Model { get; init; } = string.Empty;
|
||||||
public DateTime StartedUtc { get; init; }
|
public DateTime StartedUtc { get; init; }
|
||||||
public DateTime? CompletedUtc { get; init; }
|
public DateTime? CompletedUtc { get; init; }
|
||||||
|
|||||||
@ -150,6 +150,7 @@ public class Program
|
|||||||
builder.Services.AddScoped<IStoryIntelligencePipelineRepository, StoryIntelligencePipelineRepository>();
|
builder.Services.AddScoped<IStoryIntelligencePipelineRepository, StoryIntelligencePipelineRepository>();
|
||||||
builder.Services.AddScoped<IStoryIntelligenceIllustrationMatchingRepository, StoryIntelligenceIllustrationMatchingRepository>();
|
builder.Services.AddScoped<IStoryIntelligenceIllustrationMatchingRepository, StoryIntelligenceIllustrationMatchingRepository>();
|
||||||
builder.Services.AddScoped<IStoryMemoryRepository, StoryMemoryRepository>();
|
builder.Services.AddScoped<IStoryMemoryRepository, StoryMemoryRepository>();
|
||||||
|
builder.Services.AddScoped<ICharacterIntelligenceRepository, CharacterIntelligenceRepository>();
|
||||||
builder.Services.AddScoped<IIllustrationLibraryRepository, IllustrationLibraryRepository>();
|
builder.Services.AddScoped<IIllustrationLibraryRepository, IllustrationLibraryRepository>();
|
||||||
builder.Services.AddScoped<IProjectAccessRepository, ProjectAccessRepository>();
|
builder.Services.AddScoped<IProjectAccessRepository, ProjectAccessRepository>();
|
||||||
builder.Services.AddScoped<IProjectCollaborationRepository, ProjectCollaborationRepository>();
|
builder.Services.AddScoped<IProjectCollaborationRepository, ProjectCollaborationRepository>();
|
||||||
@ -222,6 +223,7 @@ public class Program
|
|||||||
builder.Services.AddScoped<IStoryIntelligenceKnowledgeImportService, StoryIntelligenceKnowledgeImportService>();
|
builder.Services.AddScoped<IStoryIntelligenceKnowledgeImportService, StoryIntelligenceKnowledgeImportService>();
|
||||||
builder.Services.AddScoped<IStoryIntelligenceVisualisationSnapshotService, StoryIntelligenceVisualisationSnapshotService>();
|
builder.Services.AddScoped<IStoryIntelligenceVisualisationSnapshotService, StoryIntelligenceVisualisationSnapshotService>();
|
||||||
builder.Services.AddScoped<IStoryMemoryService, StoryMemoryService>();
|
builder.Services.AddScoped<IStoryMemoryService, StoryMemoryService>();
|
||||||
|
builder.Services.AddScoped<ICharacterIntelligenceService, CharacterIntelligenceService>();
|
||||||
builder.Services.AddScoped<IStoryIntelligencePipelineStateService, StoryIntelligencePipelineStateService>();
|
builder.Services.AddScoped<IStoryIntelligencePipelineStateService, StoryIntelligencePipelineStateService>();
|
||||||
builder.Services.AddScoped<IStoryIntelligenceProgressNotifier, StoryIntelligenceProgressNotifier>();
|
builder.Services.AddScoped<IStoryIntelligenceProgressNotifier, StoryIntelligenceProgressNotifier>();
|
||||||
builder.Services.AddScoped<ISceneImportResolver, SceneImportResolver>();
|
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 =>
|
.Select(item =>
|
||||||
{
|
{
|
||||||
var cleanName = Clean(item.Name);
|
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
|
? item.ExistingCharacterID
|
||||||
: MatchExistingCharacter(cleanName, existingCharacters)?.CharacterID;
|
: string.IsNullOrWhiteSpace(aliasTargetKey) ? MatchExistingCharacter(cleanName, existingCharacters)?.CharacterID : null;
|
||||||
return new ManuscriptScanCharacterReviewDecision
|
return new ManuscriptScanCharacterReviewDecision
|
||||||
{
|
{
|
||||||
TemporaryCharacterKey = item.TemporaryCharacterKey,
|
TemporaryCharacterKey = item.TemporaryCharacterKey,
|
||||||
Include = item.Include,
|
Include = item.Include,
|
||||||
Name = cleanName,
|
Name = cleanName,
|
||||||
Category = item.Category,
|
Category = item.Category,
|
||||||
ExistingCharacterID = matchedExistingId
|
ExistingCharacterID = matchedExistingId,
|
||||||
|
AliasTargetTemporaryCharacterKey = aliasTargetKey
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.ToList();
|
.ToList();
|
||||||
@ -211,6 +219,11 @@ public sealed class OnboardingService(
|
|||||||
var chapterDecisions = review.Chapters.Where(chapter => chapter.Include).OrderBy(chapter => chapter.ChapterNumber).ToList();
|
var chapterDecisions = review.Chapters.Where(chapter => chapter.Include).OrderBy(chapter => chapter.ChapterNumber).ToList();
|
||||||
|
|
||||||
await PublishBuildProgress(userId, preview.PreviewID, "Preparing chapters for analysis...", 35, progress);
|
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
|
var request = new OnboardingManuscriptBuildRequest
|
||||||
{
|
{
|
||||||
PreviewID = preview.PreviewID,
|
PreviewID = preview.PreviewID,
|
||||||
@ -225,7 +238,12 @@ public sealed class OnboardingService(
|
|||||||
WordCount = preview.Chapters.FirstOrDefault(item => item.TemporaryChapterKey == chapter.TemporaryChapterKey)?.WordCount ?? 0
|
WordCount = preview.Chapters.FirstOrDefault(item => item.TemporaryChapterKey == chapter.TemporaryChapterKey)?.WordCount ?? 0
|
||||||
}).ToList(),
|
}).ToList(),
|
||||||
Scenes = [],
|
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);
|
await PublishBuildProgress(userId, preview.PreviewID, "Saving chapters...", 60, progress);
|
||||||
@ -252,6 +270,7 @@ public sealed class OnboardingService(
|
|||||||
CharacterMappings = result.CharacterMappings,
|
CharacterMappings = result.CharacterMappings,
|
||||||
MarkerWarning = result.MarkerWarning
|
MarkerWarning = result.MarkerWarning
|
||||||
};
|
};
|
||||||
|
await AddScanCharacterAliasesAsync(review, result);
|
||||||
await scanPreviews.SaveBuildResultAsync(userId, result);
|
await scanPreviews.SaveBuildResultAsync(userId, result);
|
||||||
await PublishBuildProgress(userId, preview.PreviewID, "Finalising project...", 90, progress);
|
await PublishBuildProgress(userId, preview.PreviewID, "Finalising project...", 90, progress);
|
||||||
await PublishBuildProgress(userId, preview.PreviewID, result.Message, 100, progress, result);
|
await PublishBuildProgress(userId, preview.PreviewID, result.Message, 100, progress, result);
|
||||||
@ -385,7 +404,12 @@ public sealed class OnboardingService(
|
|||||||
Category = candidate.Category,
|
Category = candidate.Category,
|
||||||
Reason = candidate.Reason,
|
Reason = candidate.Reason,
|
||||||
ExistingCharacterID = existing?.CharacterID,
|
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();
|
.ToList();
|
||||||
@ -406,7 +430,7 @@ public sealed class OnboardingService(
|
|||||||
CharacterCandidateCount = preview.CharacterCandidateCount,
|
CharacterCandidateCount = preview.CharacterCandidateCount,
|
||||||
SelectedChapterCount = selectedChapterKeys.Count,
|
SelectedChapterCount = selectedChapterKeys.Count,
|
||||||
SelectedSceneCount = selectedScenes.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),
|
SelectedWordCount = selectedScenes.Sum(scene => scene.WordCount),
|
||||||
Chapters = chapters,
|
Chapters = chapters,
|
||||||
CharacterCandidates = characters,
|
CharacterCandidates = characters,
|
||||||
@ -466,7 +490,7 @@ public sealed class OnboardingService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
var duplicateName = review.Characters
|
var duplicateName = review.Characters
|
||||||
.Where(character => character.Include && !string.Equals(character.Category, "Excluded", StringComparison.OrdinalIgnoreCase))
|
.Where(IsIncludedCanonicalCharacter)
|
||||||
.Select(character => Clean(character.Name))
|
.Select(character => Clean(character.Name))
|
||||||
.Where(name => !string.IsNullOrWhiteSpace(name))
|
.Where(name => !string.IsNullOrWhiteSpace(name))
|
||||||
.GroupBy(name => name, StringComparer.OrdinalIgnoreCase)
|
.GroupBy(name => name, StringComparer.OrdinalIgnoreCase)
|
||||||
@ -480,8 +504,56 @@ public sealed class OnboardingService(
|
|||||||
{
|
{
|
||||||
throw new InvalidOperationException("Every included character needs a display name.");
|
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)
|
private static Character? MatchExistingCharacter(string? name, IReadOnlyList<Character> existingCharacters)
|
||||||
{
|
{
|
||||||
var clean = Clean(name);
|
var clean = Clean(name);
|
||||||
|
|||||||
@ -120,6 +120,7 @@ public sealed class OnboardingStoryIntelligenceService(
|
|||||||
clientStatus.ChapterStructureModel,
|
clientStatus.ChapterStructureModel,
|
||||||
clientStatus.SceneIntelligenceModel);
|
clientStatus.SceneIntelligenceModel);
|
||||||
var batchItems = new List<OnboardingStoryIntelligenceBatchItem>();
|
var batchItems = new List<OnboardingStoryIntelligenceBatchItem>();
|
||||||
|
var knownCharactersJson = BuildKnownCharacterContextJson(preview, review);
|
||||||
|
|
||||||
foreach (var chapter in included)
|
foreach (var chapter in included)
|
||||||
{
|
{
|
||||||
@ -151,7 +152,8 @@ public sealed class OnboardingStoryIntelligenceService(
|
|||||||
SourceParagraphCount = paragraphs.Count,
|
SourceParagraphCount = paragraphs.Count,
|
||||||
SourceChapterCount = 1,
|
SourceChapterCount = 1,
|
||||||
Model = modelSummary,
|
Model = modelSummary,
|
||||||
PromptVersionsSummary = PromptVersionsSummary
|
PromptVersionsSummary = PromptVersionsSummary,
|
||||||
|
KnownCharactersJson = knownCharactersJson
|
||||||
});
|
});
|
||||||
|
|
||||||
batchItems.Add(new OnboardingStoryIntelligenceBatchItem
|
batchItems.Add(new OnboardingStoryIntelligenceBatchItem
|
||||||
@ -238,6 +240,82 @@ public sealed class OnboardingStoryIntelligenceService(
|
|||||||
return string.IsNullOrWhiteSpace(clean) ? string.Empty : clean[..Math.Min(clean.Length, 160)];
|
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)
|
public async Task<StoryIntelligenceProgressViewModel?> GetProgressAsync(Guid batchId)
|
||||||
{
|
{
|
||||||
var batch = await batchStore.GetAsync(RequireUserId(), batchId);
|
var batch = await batchStore.GetAsync(RequireUserId(), batchId);
|
||||||
|
|||||||
@ -24,6 +24,7 @@ public sealed class PersistedStoryIntelligenceRunner(
|
|||||||
IOptions<StoryIntelligencePricingOptions> pricingOptions,
|
IOptions<StoryIntelligencePricingOptions> pricingOptions,
|
||||||
IStoryIntelligenceProgressNotifier notifier,
|
IStoryIntelligenceProgressNotifier notifier,
|
||||||
IStoryMemoryService storyMemory,
|
IStoryMemoryService storyMemory,
|
||||||
|
ICharacterIntelligenceService characterIntelligence,
|
||||||
ILogger<PersistedStoryIntelligenceRunner> logger) : IPersistedStoryIntelligenceRunner
|
ILogger<PersistedStoryIntelligenceRunner> logger) : IPersistedStoryIntelligenceRunner
|
||||||
{
|
{
|
||||||
private const string ChapterPromptFile = "Chapter-Structure-Prompt-V2.md";
|
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);
|
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++;
|
completedScenes++;
|
||||||
@ -834,7 +852,8 @@ public sealed class PersistedStoryIntelligenceRunner(
|
|||||||
bookId = run.BookID,
|
bookId = run.BookID,
|
||||||
chapterId = run.ChapterID,
|
chapterId = run.ChapterID,
|
||||||
chapterNumber = ChapterNumber(run),
|
chapterNumber = ChapterNumber(run),
|
||||||
sourceLabel = SourceLabel(run)
|
sourceLabel = SourceLabel(run),
|
||||||
|
knownCharacters = KnownCharacters(run)
|
||||||
}, JsonOptions);
|
}, JsonOptions);
|
||||||
|
|
||||||
private static string BuildSceneContextJson(StoryIntelligenceQueuedRun run, ChapterSceneBoundary boundary)
|
private static string BuildSceneContextJson(StoryIntelligenceQueuedRun run, ChapterSceneBoundary boundary)
|
||||||
@ -846,9 +865,27 @@ public sealed class PersistedStoryIntelligenceRunner(
|
|||||||
sceneId = (int?)null,
|
sceneId = (int?)null,
|
||||||
chapterNumber = ChapterNumber(run),
|
chapterNumber = ChapterNumber(run),
|
||||||
sceneNumber = boundary.SceneNumber,
|
sceneNumber = boundary.SceneNumber,
|
||||||
sourceLabel = $"{SourceLabel(run)}, suggested scene {boundary.SceneNumber}"
|
sourceLabel = $"{SourceLabel(run)}, suggested scene {boundary.SceneNumber}",
|
||||||
|
knownCharacters = KnownCharacters(run)
|
||||||
}, JsonOptions);
|
}, 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)
|
private static string BuildChapterPrompt(string promptTemplate, string chapterContextJson, string chapterText)
|
||||||
=> promptTemplate
|
=> promptTemplate
|
||||||
.Replace("{{CHAPTER_CONTEXT_JSON}}", chapterContextJson ?? string.Empty, StringComparison.Ordinal)
|
.Replace("{{CHAPTER_CONTEXT_JSON}}", chapterContextJson ?? string.Empty, StringComparison.Ordinal)
|
||||||
|
|||||||
@ -274,6 +274,29 @@ public static class StoryIntelligenceIllustrationCompatibility
|
|||||||
{
|
{
|
||||||
var text = (value ?? string.Empty).ToLowerInvariant();
|
var text = (value ?? string.Empty).ToLowerInvariant();
|
||||||
var padded = $" {text} ";
|
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, "dual carriageway")) return "DualCarriageway";
|
||||||
if (ContainsAny(text, "motorway", "highway")) return "Motorway";
|
if (ContainsAny(text, "motorway", "highway")) return "Motorway";
|
||||||
if (ContainsAny(text, "flyover")) return "Flyover";
|
if (ContainsAny(text, "flyover")) return "Flyover";
|
||||||
|
|||||||
@ -603,7 +603,7 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
|
|||||||
{
|
{
|
||||||
var location = (scene.Locations ?? [])
|
var location = (scene.Locations ?? [])
|
||||||
.Where(item => !string.IsNullOrWhiteSpace(item.Name) && item.MentionedOnly != true)
|
.Where(item => !string.IsNullOrWhiteSpace(item.Name) && item.MentionedOnly != true)
|
||||||
.OrderByDescending(item => item.PresentInScene == true)
|
.OrderByDescending(LocationSelectionScore)
|
||||||
.ThenByDescending(item => Specificity(item.Name))
|
.ThenByDescending(item => Specificity(item.Name))
|
||||||
.ThenByDescending(item => item.Confidence ?? 0)
|
.ThenByDescending(item => item.Confidence ?? 0)
|
||||||
.FirstOrDefault();
|
.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)
|
private static IReadOnlyList<StoryIntelligenceExperienceAssetState> BuildAssets(SceneIntelligenceScene scene)
|
||||||
=> (scene.Assets ?? [])
|
=> (scene.Assets ?? [])
|
||||||
.Where(asset => !string.IsNullOrWhiteSpace(asset.Name) && asset.MentionedOnly != true && !IsLowValueAsset(asset))
|
.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 int? ExistingCharacterID { get; init; }
|
||||||
public string? ExistingCharacterName { get; init; }
|
public string? ExistingCharacterName { get; init; }
|
||||||
public bool IsExistingCharacterMatch => ExistingCharacterID.HasValue;
|
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
|
public sealed class ManuscriptScanExistingCharacterOptionViewModel
|
||||||
@ -709,4 +712,5 @@ public sealed class ManuscriptScanCharacterReviewFormItem
|
|||||||
public string Name { get; set; } = string.Empty;
|
public string Name { get; set; } = string.Empty;
|
||||||
public string Category { get; set; } = "PossibleCharacter";
|
public string Category { get; set; } = "PossibleCharacter";
|
||||||
public int? ExistingCharacterID { get; set; }
|
public int? ExistingCharacterID { get; set; }
|
||||||
|
public string? AliasTargetTemporaryCharacterKey { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,6 +13,11 @@
|
|||||||
var excludedCharacters = Model.CharacterCandidates
|
var excludedCharacters = Model.CharacterCandidates
|
||||||
.Where(candidate => string.Equals(candidate.Category, "Excluded", StringComparison.OrdinalIgnoreCase))
|
.Where(candidate => string.Equals(candidate.Category, "Excluded", StringComparison.OrdinalIgnoreCase))
|
||||||
.ToList();
|
.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">
|
<section class="onboarding-shell" aria-labelledby="scan-review-title">
|
||||||
@ -23,9 +28,8 @@
|
|||||||
|
|
||||||
<div class="onboarding-copy">
|
<div class="onboarding-copy">
|
||||||
<p class="eyebrow">Step 7 of 9</p>
|
<p class="eyebrow">Step 7 of 9</p>
|
||||||
<h1 id="scan-review-title">Review Chapters</h1>
|
<h1 id="scan-review-title">Review Manuscript Scan</h1>
|
||||||
<p>Review the chapters PlotDirector found before Story Intelligence begins.</p>
|
<p>Confirm the chapter structure first, then review the detected character names and aliases before Story Intelligence begins.</p>
|
||||||
<p>Scene and character scan details are shown for context only in this phase.</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (TempData["OnboardingReviewMessage"] is string reviewMessage)
|
@if (TempData["OnboardingReviewMessage"] is string reviewMessage)
|
||||||
@ -61,8 +65,13 @@
|
|||||||
<button class="btn btn-sm btn-outline-secondary" type="reset">Restore scan defaults</button>
|
<button class="btn btn-sm btn-outline-secondary" type="reset">Restore scan defaults</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section class="onboarding-review-grid" aria-label="Scan preview">
|
<section class="onboarding-review-workflow" aria-label="Scan preview" data-scan-review-tabs>
|
||||||
<div class="onboarding-review-section">
|
<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>
|
<h2>Chapters and scenes</h2>
|
||||||
@if (!Model.Chapters.Any())
|
@if (!Model.Chapters.Any())
|
||||||
{
|
{
|
||||||
@ -140,10 +149,19 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<aside class="onboarding-review-section">
|
<aside class="onboarding-review-section" data-scan-review-panel="characters" hidden>
|
||||||
|
<div class="onboarding-review-section-heading">
|
||||||
|
<div>
|
||||||
<h2>Character candidates</h2>
|
<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())
|
@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>
|
<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
|
Find a candidate
|
||||||
<input class="form-control" type="search" placeholder="Search names or notes" data-character-filter />
|
<input class="form-control" type="search" placeholder="Search names or notes" data-character-filter />
|
||||||
</label>
|
</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>
|
<p class="onboarding-empty-state" data-character-empty hidden>No matching candidates.</p>
|
||||||
<div class="onboarding-character-groups">
|
<div class="onboarding-character-groups">
|
||||||
@foreach (var group in new[]
|
@foreach (var group in new[]
|
||||||
@ -174,7 +197,7 @@
|
|||||||
<ul class="onboarding-character-candidates">
|
<ul class="onboarding-character-candidates">
|
||||||
@foreach (var candidate in group.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].TemporaryCharacterKey" value="@candidate.TemporaryCharacterKey" />
|
||||||
<input type="hidden" name="Characters[@characterIndex].Category" value="@candidate.Category" />
|
<input type="hidden" name="Characters[@characterIndex].Category" value="@candidate.Category" />
|
||||||
<label class="onboarding-review-toggle">
|
<label class="onboarding-review-toggle">
|
||||||
@ -196,7 +219,21 @@
|
|||||||
}
|
}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</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>
|
<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))
|
@if (!string.IsNullOrWhiteSpace(candidate.ExistingCharacterName))
|
||||||
{
|
{
|
||||||
<small>Matches existing character: @candidate.ExistingCharacterName</small>
|
<small>Matches existing character: @candidate.ExistingCharacterName</small>
|
||||||
@ -220,7 +257,7 @@
|
|||||||
<ul class="onboarding-character-candidates">
|
<ul class="onboarding-character-candidates">
|
||||||
@foreach (var candidate in excludedCharacters)
|
@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].TemporaryCharacterKey" value="@candidate.TemporaryCharacterKey" />
|
||||||
<input type="hidden" name="Characters[@characterIndex].Category" value="@candidate.Category" />
|
<input type="hidden" name="Characters[@characterIndex].Category" value="@candidate.Category" />
|
||||||
<label class="onboarding-review-toggle">
|
<label class="onboarding-review-toggle">
|
||||||
@ -242,7 +279,21 @@
|
|||||||
}
|
}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</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>
|
<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))
|
@if (!string.IsNullOrWhiteSpace(candidate.Reason))
|
||||||
{
|
{
|
||||||
<small>@candidate.Reason</small>
|
<small>@candidate.Reason</small>
|
||||||
@ -267,6 +318,33 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<script>
|
<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]");
|
const reviewTools = document.querySelector("[data-review-tools]");
|
||||||
reviewTools?.addEventListener("click", (event) => {
|
reviewTools?.addEventListener("click", (event) => {
|
||||||
const button = event.target.closest("[data-bulk-target]");
|
const button = event.target.closest("[data-bulk-target]");
|
||||||
@ -282,26 +360,79 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
const characterFilter = document.querySelector("[data-character-filter]");
|
const characterFilter = document.querySelector("[data-character-filter]");
|
||||||
characterFilter?.addEventListener("input", () => {
|
const characterPager = document.querySelector("[data-character-pager]");
|
||||||
const query = characterFilter.value.trim().toLowerCase();
|
const characterPageLabel = document.querySelector("[data-character-page-label]");
|
||||||
let visibleCount = 0;
|
const characterPagePrev = document.querySelector("[data-character-page-prev]");
|
||||||
document.querySelectorAll("[data-character-name]").forEach((item) => {
|
const characterPageNext = document.querySelector("[data-character-page-next]");
|
||||||
const text = `${item.dataset.characterName || ""} ${item.dataset.characterReason || ""}`.toLowerCase();
|
const characterPageSize = 20;
|
||||||
const isVisible = query.length === 0 || text.includes(query);
|
let characterPage = 1;
|
||||||
item.hidden = !isVisible;
|
|
||||||
if (isVisible) {
|
|
||||||
visibleCount++;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
|
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) => {
|
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;
|
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]");
|
const emptyState = document.querySelector("[data-character-empty]");
|
||||||
if (emptyState) {
|
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>
|
</script>
|
||||||
|
|||||||
@ -4,12 +4,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
<section class="onboarding-shell" aria-labelledby="story-intelligence-title">
|
<section class="onboarding-shell" aria-labelledby="story-intelligence-title">
|
||||||
<div class="onboarding-panel">
|
<div class="onboarding-panel onboarding-analysis-panel">
|
||||||
<partial name="_StoryIntelligencePipelineHeader" model="@(new StoryIntelligencePipelineHeaderViewModel { CurrentStage = "Analyse Manuscript" })" />
|
<partial name="_OnboardingJourneyHeader" model="@(new OnboardingJourneyHeaderViewModel { CurrentStep = 8 })" />
|
||||||
<div class="onboarding-copy">
|
<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>
|
<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>
|
</div>
|
||||||
|
|
||||||
@if (TempData["OnboardingStoryIntelligenceError"] is string error)
|
@if (TempData["OnboardingStoryIntelligenceError"] is string error)
|
||||||
@ -59,14 +59,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</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))
|
@if (!string.IsNullOrWhiteSpace(Model.Message))
|
||||||
{
|
{
|
||||||
<section class="onboarding-form-section">
|
<section class="onboarding-form-section onboarding-form-section--quiet">
|
||||||
<p>@Model.Message</p>
|
<p>@Model.Message</p>
|
||||||
@if (Model.MissingChapterTextCount > 0)
|
@if (Model.MissingChapterTextCount > 0)
|
||||||
{
|
{
|
||||||
@ -75,18 +70,20 @@
|
|||||||
</section>
|
</section>
|
||||||
}
|
}
|
||||||
|
|
||||||
<div class="onboarding-actions">
|
<form asp-action="StartStoryIntelligence" method="post" class="onboarding-analysis-setup">
|
||||||
<a class="btn btn-outline-secondary" asp-action="ScanReview" asp-route-previewId="@Model.PreviewID">Back to chapter review</a>
|
<div class="onboarding-analysis-setup__header">
|
||||||
<form asp-action="SkipStoryIntelligence" method="post">
|
<div>
|
||||||
<button class="btn btn-outline-secondary" type="submit">Skip for now</button>
|
<p class="eyebrow">Analysis setup</p>
|
||||||
</form>
|
<h2>Story and portrait context</h2>
|
||||||
<form asp-action="StartStoryIntelligence" method="post" class="onboarding-form-section">
|
<p>Explicit manuscript evidence always wins. These choices guide unresolved generated portraits and broad story context.</p>
|
||||||
<fieldset>
|
</div>
|
||||||
<legend>Unresolved character appearance</legend>
|
<strong>Required before analysis</strong>
|
||||||
<p>Explicit manuscript evidence always wins. This only guides characters whose appearance is not described.</p>
|
</div>
|
||||||
|
|
||||||
|
<div class="onboarding-analysis-controls">
|
||||||
<label>
|
<label>
|
||||||
Appearance baseline
|
Appearance baseline
|
||||||
<select class="form-control" name="appearancePreference">
|
<select class="form-select" name="appearancePreference">
|
||||||
<option value="@StoryMemoryAppearancePreferences.Balanced">Balanced diverse cast</option>
|
<option value="@StoryMemoryAppearancePreferences.Balanced">Balanced diverse cast</option>
|
||||||
<option value="@StoryMemoryAppearancePreferences.ExplicitEvidenceOnly">Explicit evidence only</option>
|
<option value="@StoryMemoryAppearancePreferences.ExplicitEvidenceOnly">Explicit evidence only</option>
|
||||||
<option value="@StoryMemoryAppearancePreferences.PredominantlyLight">Predominantly light-skinned cast</option>
|
<option value="@StoryMemoryAppearancePreferences.PredominantlyLight">Predominantly light-skinned cast</option>
|
||||||
@ -95,13 +92,9 @@
|
|||||||
<option value="@StoryMemoryAppearancePreferences.CustomMix">Custom mix</option>
|
<option value="@StoryMemoryAppearancePreferences.CustomMix">Custom mix</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</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>
|
<label>
|
||||||
Primary character age group
|
Primary character age group
|
||||||
<select class="form-control" name="primaryAgeGroup">
|
<select class="form-select" name="primaryAgeGroup">
|
||||||
<option value="">No baseline</option>
|
<option value="">No baseline</option>
|
||||||
<option value="Late teen / early 20s">Late teen / early 20s</option>
|
<option value="Late teen / early 20s">Late teen / early 20s</option>
|
||||||
<option value="Young teen">Young teen</option>
|
<option value="Young teen">Young teen</option>
|
||||||
@ -114,7 +107,7 @@
|
|||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
Era
|
Era
|
||||||
<select class="form-control" name="storyEra">
|
<select class="form-select" name="storyEra">
|
||||||
<option value="">No baseline</option>
|
<option value="">No baseline</option>
|
||||||
<option value="1980s">1980s</option>
|
<option value="1980s">1980s</option>
|
||||||
<option value="1970s">1970s</option>
|
<option value="1970s">1970s</option>
|
||||||
@ -128,7 +121,7 @@
|
|||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
Location
|
Location
|
||||||
<select class="form-control" name="storyLocation">
|
<select class="form-select" name="storyLocation">
|
||||||
<option value="">No baseline</option>
|
<option value="">No baseline</option>
|
||||||
<option value="UK">UK</option>
|
<option value="UK">UK</option>
|
||||||
<option value="United States">United States</option>
|
<option value="United States">United States</option>
|
||||||
@ -140,8 +133,24 @@
|
|||||||
<option value="Science fiction setting">Science fiction setting</option>
|
<option value="Science fiction setting">Science fiction setting</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
</fieldset>
|
</div>
|
||||||
|
|
||||||
|
<div class="onboarding-analysis-flow">
|
||||||
|
<div><span>1</span><strong>Analyse chapters</strong></div>
|
||||||
|
<div><span>2</span><strong>Review scenes</strong></div>
|
||||||
|
<div><span>3</span><strong>Review story entities</strong></div>
|
||||||
|
<div><span>4</span><strong>Build database</strong></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="onboarding-actions">
|
||||||
|
<a class="btn btn-outline-secondary" asp-action="ScanReview" asp-route-previewId="@Model.PreviewID">Back to scan review</a>
|
||||||
<button class="btn btn-primary" type="submit" disabled="@(!Model.CanStart)">Analyse manuscript</button>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@ -222,6 +222,15 @@
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.onboarding-actions--inline {
|
||||||
|
margin-top: .75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.onboarding-actions--secondary {
|
||||||
|
justify-content: flex-start;
|
||||||
|
margin-top: .9rem;
|
||||||
|
}
|
||||||
|
|
||||||
.onboarding-form-section {
|
.onboarding-form-section {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: .75rem;
|
gap: .75rem;
|
||||||
@ -232,6 +241,10 @@
|
|||||||
background: rgba(255, 255, 255, .45);
|
background: rgba(255, 255, 255, .45);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.onboarding-form-section--quiet {
|
||||||
|
background: rgba(255, 255, 255, .58);
|
||||||
|
}
|
||||||
|
|
||||||
.onboarding-form-section h2 {
|
.onboarding-form-section h2 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1.05rem;
|
font-size: 1.05rem;
|
||||||
@ -464,10 +477,46 @@
|
|||||||
gap: 1rem;
|
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 {
|
.onboarding-review-section {
|
||||||
display: grid;
|
display: grid;
|
||||||
align-content: start;
|
align-content: start;
|
||||||
gap: .8rem;
|
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 {
|
.onboarding-review-section h2 {
|
||||||
@ -475,6 +524,18 @@
|
|||||||
font-size: 1.2rem;
|
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 {
|
.onboarding-character-filter {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: .35rem;
|
gap: .35rem;
|
||||||
@ -1285,6 +1346,23 @@ summary.story-review-chapter-heading {
|
|||||||
font-size: 1rem;
|
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 {
|
.onboarding-excluded-candidates {
|
||||||
border-top: 1px solid rgba(31, 42, 68, .12);
|
border-top: 1px solid rgba(31, 42, 68, .12);
|
||||||
padding-top: .65rem;
|
padding-top: .65rem;
|
||||||
@ -1419,6 +1497,105 @@ summary.story-review-chapter-heading {
|
|||||||
background: rgba(47, 111, 99, .07);
|
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 {
|
.story-intelligence-dashboard-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -1585,6 +1762,8 @@ summary.story-review-chapter-heading {
|
|||||||
.onboarding-dashboard-nudge,
|
.onboarding-dashboard-nudge,
|
||||||
.onboarding-companion-card,
|
.onboarding-companion-card,
|
||||||
.onboarding-review-grid,
|
.onboarding-review-grid,
|
||||||
|
.onboarding-analysis-controls,
|
||||||
|
.onboarding-analysis-flow,
|
||||||
.onboarding-summary div,
|
.onboarding-summary div,
|
||||||
.story-intelligence-dashboard-card {
|
.story-intelligence-dashboard-card {
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
@ -1592,6 +1771,15 @@ summary.story-review-chapter-heading {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.onboarding-analysis-setup__header,
|
||||||
|
.onboarding-review-section-heading {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.onboarding-review-tabs {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
.onboarding-story-facts {
|
.onboarding-story-facts {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user