Compare commits
No commits in common. "72d243831432d69171ea107389003f35c989ecee" and "c9a82347453bb264a56c68ae5aaba1943fd73f51" have entirely different histories.
72d2438314
...
c9a8234745
@ -64,7 +64,6 @@ 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),
|
||||||
@ -1149,10 +1148,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-select\" name=\"appearancePreference\"", StringComparison.Ordinal), "Appearance guidance must be a dropdown, not a radio list.");
|
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=\"primaryAgeGroup\"", StringComparison.Ordinal), "Primary age group must be a dropdown.");
|
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=\"storyEra\"", StringComparison.Ordinal), "Story era 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=\"storyLocation\"", StringComparison.Ordinal), "Story location must be a dropdown.");
|
Assert(onboarding.Contains("<select class=\"form-control\" 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.");
|
||||||
@ -1249,8 +1248,6 @@ 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.");
|
||||||
@ -1266,42 +1263,6 @@ 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()
|
||||||
{
|
{
|
||||||
|
|||||||
@ -1,73 +0,0 @@
|
|||||||
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,8 +179,7 @@ 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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,100 +0,0 @@
|
|||||||
# 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,13 +77,6 @@ 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;
|
||||||
@ -219,7 +212,6 @@ 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;
|
||||||
@ -285,8 +277,6 @@ 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.`
|
||||||
|
|||||||
@ -1,138 +0,0 @@
|
|||||||
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,7 +153,6 @@ 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,10 +111,8 @@ 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,18 +149,6 @@ 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
|
||||||
@ -295,7 +283,6 @@ 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,7 +150,6 @@ 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>();
|
||||||
@ -223,7 +222,6 @@ 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>();
|
||||||
|
|||||||
@ -1,457 +0,0 @@
|
|||||||
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,24 +163,16 @@ public sealed class OnboardingService(
|
|||||||
.Select(item =>
|
.Select(item =>
|
||||||
{
|
{
|
||||||
var cleanName = Clean(item.Name);
|
var cleanName = Clean(item.Name);
|
||||||
var aliasTargetKey = CleanOptional(item.AliasTargetTemporaryCharacterKey);
|
var matchedExistingId = item.ExistingCharacterID.HasValue && existingCharacterIds.Contains(item.ExistingCharacterID.Value)
|
||||||
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
|
||||||
: string.IsNullOrWhiteSpace(aliasTargetKey) ? MatchExistingCharacter(cleanName, existingCharacters)?.CharacterID : null;
|
: MatchExistingCharacter(cleanName, existingCharacters)?.CharacterID;
|
||||||
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();
|
||||||
@ -219,11 +211,6 @@ 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,
|
||||||
@ -238,12 +225,7 @@ 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 = characterDecisions.Select(character => new OnboardingBuildCharacterRequest
|
Characters = []
|
||||||
{
|
|
||||||
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);
|
||||||
@ -270,7 +252,6 @@ 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);
|
||||||
@ -404,12 +385,7 @@ 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();
|
||||||
@ -430,7 +406,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 && !character.IsAlias && !string.Equals(character.Category, "Excluded", StringComparison.OrdinalIgnoreCase)),
|
SelectedCharacterCount = characters.Count(character => character.Include && !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,
|
||||||
@ -490,7 +466,7 @@ public sealed class OnboardingService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
var duplicateName = review.Characters
|
var duplicateName = review.Characters
|
||||||
.Where(IsIncludedCanonicalCharacter)
|
.Where(character => character.Include && !string.Equals(character.Category, "Excluded", StringComparison.OrdinalIgnoreCase))
|
||||||
.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)
|
||||||
@ -504,56 +480,8 @@ 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,7 +120,6 @@ 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)
|
||||||
{
|
{
|
||||||
@ -152,8 +151,7 @@ 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
|
||||||
@ -240,82 +238,6 @@ 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,7 +24,6 @@ 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";
|
||||||
@ -355,23 +354,6 @@ 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++;
|
||||||
@ -852,8 +834,7 @@ 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)
|
||||||
@ -865,27 +846,9 @@ 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,29 +274,6 @@ 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(LocationSelectionScore)
|
.OrderByDescending(item => item.PresentInScene == true)
|
||||||
.ThenByDescending(item => Specificity(item.Name))
|
.ThenByDescending(item => Specificity(item.Name))
|
||||||
.ThenByDescending(item => item.Confidence ?? 0)
|
.ThenByDescending(item => item.Confidence ?? 0)
|
||||||
.FirstOrDefault();
|
.FirstOrDefault();
|
||||||
@ -624,32 +624,6 @@ 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))
|
||||||
|
|||||||
@ -1,61 +0,0 @@
|
|||||||
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
|
|
||||||
|
|
||||||
@ -1,98 +0,0 @@
|
|||||||
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,9 +668,6 @@ 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
|
||||||
@ -712,5 +709,4 @@ 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,11 +13,6 @@
|
|||||||
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">
|
||||||
@ -28,8 +23,9 @@
|
|||||||
|
|
||||||
<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 Manuscript Scan</h1>
|
<h1 id="scan-review-title">Review Chapters</h1>
|
||||||
<p>Confirm the chapter structure first, then review the detected character names and aliases before Story Intelligence begins.</p>
|
<p>Review the chapters PlotDirector found before Story Intelligence begins.</p>
|
||||||
|
<p>Scene and character scan details are shown for context only in this phase.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (TempData["OnboardingReviewMessage"] is string reviewMessage)
|
@if (TempData["OnboardingReviewMessage"] is string reviewMessage)
|
||||||
@ -65,13 +61,8 @@
|
|||||||
<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-workflow" aria-label="Scan preview" data-scan-review-tabs>
|
<section class="onboarding-review-grid" aria-label="Scan preview">
|
||||||
<div class="onboarding-review-tabs" role="tablist" aria-label="Scan review sections">
|
<div class="onboarding-review-section">
|
||||||
<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())
|
||||||
{
|
{
|
||||||
@ -149,19 +140,10 @@
|
|||||||
}
|
}
|
||||||
</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" data-scan-review-panel="characters" hidden>
|
<aside class="onboarding-review-section">
|
||||||
<div class="onboarding-review-section-heading">
|
<h2>Character candidates</h2>
|
||||||
<div>
|
|
||||||
<h2>Character candidates</h2>
|
|
||||||
<p>@visibleCharacterCount.ToString("N0") detected candidate@(visibleCharacterCount == 1 ? string.Empty : "s") before excluded names.</p>
|
|
||||||
</div>
|
|
||||||
<button class="btn btn-outline-secondary btn-sm" type="button" data-scan-review-next="structure">Back to chapters</button>
|
|
||||||
</div>
|
|
||||||
@if (!probableCharacters.Any() && !possibleCharacters.Any() && !relationshipTitles.Any())
|
@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>
|
||||||
@ -173,11 +155,6 @@
|
|||||||
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[]
|
||||||
@ -197,7 +174,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" data-character-page-item>
|
<li data-character-name="@candidate.ReviewName" data-character-reason="@candidate.Reason">
|
||||||
<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">
|
||||||
@ -219,21 +196,7 @@
|
|||||||
}
|
}
|
||||||
</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>
|
||||||
@ -257,7 +220,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" data-character-page-item>
|
<li data-character-name="@candidate.ReviewName" data-character-reason="@candidate.Reason">
|
||||||
<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">
|
||||||
@ -279,21 +242,7 @@
|
|||||||
}
|
}
|
||||||
</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>
|
||||||
@ -318,33 +267,6 @@
|
|||||||
</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]");
|
||||||
@ -360,79 +282,26 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
const characterFilter = document.querySelector("[data-character-filter]");
|
const characterFilter = document.querySelector("[data-character-filter]");
|
||||||
const characterPager = document.querySelector("[data-character-pager]");
|
characterFilter?.addEventListener("input", () => {
|
||||||
const characterPageLabel = document.querySelector("[data-character-page-label]");
|
const query = characterFilter.value.trim().toLowerCase();
|
||||||
const characterPagePrev = document.querySelector("[data-character-page-prev]");
|
let visibleCount = 0;
|
||||||
const characterPageNext = document.querySelector("[data-character-page-next]");
|
document.querySelectorAll("[data-character-name]").forEach((item) => {
|
||||||
const characterPageSize = 20;
|
|
||||||
let characterPage = 1;
|
|
||||||
|
|
||||||
const characterItems = () => [...document.querySelectorAll("[data-character-page-item]")];
|
|
||||||
const matchingCharacterItems = () => {
|
|
||||||
const query = characterFilter?.value.trim().toLowerCase() || "";
|
|
||||||
return characterItems().filter((item) => {
|
|
||||||
const text = `${item.dataset.characterName || ""} ${item.dataset.characterReason || ""}`.toLowerCase();
|
const text = `${item.dataset.characterName || ""} ${item.dataset.characterReason || ""}`.toLowerCase();
|
||||||
return query.length === 0 || text.includes(query);
|
const isVisible = query.length === 0 || text.includes(query);
|
||||||
|
item.hidden = !isVisible;
|
||||||
|
if (isVisible) {
|
||||||
|
visibleCount++;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
};
|
|
||||||
|
|
||||||
const refreshCharacterSections = () => {
|
|
||||||
document.querySelectorAll(".onboarding-character-groups section").forEach((section) => {
|
document.querySelectorAll(".onboarding-character-groups section").forEach((section) => {
|
||||||
const hasVisibleItems = [...section.querySelectorAll("[data-character-page-item]")].some((item) => !item.hidden);
|
const hasVisibleItems = [...section.querySelectorAll("[data-character-name]")].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 = matches.length > 0;
|
emptyState.hidden = visibleCount > 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 onboarding-analysis-panel">
|
<div class="onboarding-panel">
|
||||||
<partial name="_OnboardingJourneyHeader" model="@(new OnboardingJourneyHeaderViewModel { CurrentStep = 8 })" />
|
<partial name="_StoryIntelligencePipelineHeader" model="@(new StoryIntelligencePipelineHeaderViewModel { CurrentStage = "Analyse Manuscript" })" />
|
||||||
<div class="onboarding-copy">
|
<div class="onboarding-copy">
|
||||||
<p class="eyebrow">Step 8 of 9</p>
|
<p class="eyebrow">Analyse manuscript</p>
|
||||||
<h1 id="story-intelligence-title">Analyse Manuscript</h1>
|
<h1 id="story-intelligence-title">Analyse Manuscript</h1>
|
||||||
<p>Set the analysis context, then PlotDirector will read the approved chapters and prepare scenes, characters, locations, assets, relationships and knowledge for review.</p>
|
<p>PlotDirector will analyse approved chapters in the background and prepare scene suggestions for review.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (TempData["OnboardingStoryIntelligenceError"] is string error)
|
@if (TempData["OnboardingStoryIntelligenceError"] is string error)
|
||||||
@ -59,9 +59,14 @@
|
|||||||
</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 onboarding-form-section--quiet">
|
<section class="onboarding-form-section">
|
||||||
<p>@Model.Message</p>
|
<p>@Model.Message</p>
|
||||||
@if (Model.MissingChapterTextCount > 0)
|
@if (Model.MissingChapterTextCount > 0)
|
||||||
{
|
{
|
||||||
@ -70,88 +75,74 @@
|
|||||||
</section>
|
</section>
|
||||||
}
|
}
|
||||||
|
|
||||||
<form asp-action="StartStoryIntelligence" method="post" class="onboarding-analysis-setup">
|
<div class="onboarding-actions">
|
||||||
<div class="onboarding-analysis-setup__header">
|
<a class="btn btn-outline-secondary" asp-action="ScanReview" asp-route-previewId="@Model.PreviewID">Back to chapter review</a>
|
||||||
<div>
|
|
||||||
<p class="eyebrow">Analysis setup</p>
|
|
||||||
<h2>Story and portrait context</h2>
|
|
||||||
<p>Explicit manuscript evidence always wins. These choices guide unresolved generated portraits and broad story context.</p>
|
|
||||||
</div>
|
|
||||||
<strong>Required before analysis</strong>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="onboarding-analysis-controls">
|
|
||||||
<label>
|
|
||||||
Appearance baseline
|
|
||||||
<select class="form-select" name="appearancePreference">
|
|
||||||
<option value="@StoryMemoryAppearancePreferences.Balanced">Balanced diverse cast</option>
|
|
||||||
<option value="@StoryMemoryAppearancePreferences.ExplicitEvidenceOnly">Explicit evidence only</option>
|
|
||||||
<option value="@StoryMemoryAppearancePreferences.PredominantlyLight">Predominantly light-skinned cast</option>
|
|
||||||
<option value="@StoryMemoryAppearancePreferences.PredominantlyMedium">Predominantly medium-skinned cast</option>
|
|
||||||
<option value="@StoryMemoryAppearancePreferences.PredominantlyDark">Predominantly dark-skinned cast</option>
|
|
||||||
<option value="@StoryMemoryAppearancePreferences.CustomMix">Custom mix</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Primary character age group
|
|
||||||
<select class="form-select" name="primaryAgeGroup">
|
|
||||||
<option value="">No baseline</option>
|
|
||||||
<option value="Late teen / early 20s">Late teen / early 20s</option>
|
|
||||||
<option value="Young teen">Young teen</option>
|
|
||||||
<option value="Older teen">Older teen</option>
|
|
||||||
<option value="Young adult">Young adult</option>
|
|
||||||
<option value="Adult">Adult</option>
|
|
||||||
<option value="Middle-aged adult">Middle-aged adult</option>
|
|
||||||
<option value="Senior">Senior</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Era
|
|
||||||
<select class="form-select" name="storyEra">
|
|
||||||
<option value="">No baseline</option>
|
|
||||||
<option value="1980s">1980s</option>
|
|
||||||
<option value="1970s">1970s</option>
|
|
||||||
<option value="1990s">1990s</option>
|
|
||||||
<option value="2000s">2000s</option>
|
|
||||||
<option value="Contemporary">Contemporary</option>
|
|
||||||
<option value="Victorian">Victorian</option>
|
|
||||||
<option value="Medieval">Medieval</option>
|
|
||||||
<option value="Near future">Near future</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Location
|
|
||||||
<select class="form-select" name="storyLocation">
|
|
||||||
<option value="">No baseline</option>
|
|
||||||
<option value="UK">UK</option>
|
|
||||||
<option value="United States">United States</option>
|
|
||||||
<option value="Europe">Europe</option>
|
|
||||||
<option value="Urban">Urban</option>
|
|
||||||
<option value="Suburban">Suburban</option>
|
|
||||||
<option value="Rural">Rural</option>
|
|
||||||
<option value="Fantasy world">Fantasy world</option>
|
|
||||||
<option value="Science fiction setting">Science fiction setting</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="onboarding-analysis-flow">
|
|
||||||
<div><span>1</span><strong>Analyse chapters</strong></div>
|
|
||||||
<div><span>2</span><strong>Review scenes</strong></div>
|
|
||||||
<div><span>3</span><strong>Review story entities</strong></div>
|
|
||||||
<div><span>4</span><strong>Build database</strong></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="onboarding-actions">
|
|
||||||
<a class="btn btn-outline-secondary" asp-action="ScanReview" asp-route-previewId="@Model.PreviewID">Back to scan review</a>
|
|
||||||
<button class="btn btn-primary" type="submit" disabled="@(!Model.CanStart)">Analyse manuscript</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div class="onboarding-actions onboarding-actions--secondary">
|
|
||||||
<form asp-action="SkipStoryIntelligence" method="post">
|
<form asp-action="SkipStoryIntelligence" method="post">
|
||||||
<button class="btn btn-outline-secondary" type="submit">Skip for now</button>
|
<button class="btn btn-outline-secondary" type="submit">Skip for now</button>
|
||||||
</form>
|
</form>
|
||||||
|
<form asp-action="StartStoryIntelligence" method="post" class="onboarding-form-section">
|
||||||
|
<fieldset>
|
||||||
|
<legend>Unresolved character appearance</legend>
|
||||||
|
<p>Explicit manuscript evidence always wins. This only guides characters whose appearance is not described.</p>
|
||||||
|
<label>
|
||||||
|
Appearance baseline
|
||||||
|
<select class="form-control" name="appearancePreference">
|
||||||
|
<option value="@StoryMemoryAppearancePreferences.Balanced">Balanced diverse cast</option>
|
||||||
|
<option value="@StoryMemoryAppearancePreferences.ExplicitEvidenceOnly">Explicit evidence only</option>
|
||||||
|
<option value="@StoryMemoryAppearancePreferences.PredominantlyLight">Predominantly light-skinned cast</option>
|
||||||
|
<option value="@StoryMemoryAppearancePreferences.PredominantlyMedium">Predominantly medium-skinned cast</option>
|
||||||
|
<option value="@StoryMemoryAppearancePreferences.PredominantlyDark">Predominantly dark-skinned cast</option>
|
||||||
|
<option value="@StoryMemoryAppearancePreferences.CustomMix">Custom mix</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</fieldset>
|
||||||
|
<fieldset>
|
||||||
|
<legend>Story context for generated portraits</legend>
|
||||||
|
<p>These guide generated images when the manuscript is silent. Explicit manuscript evidence still wins.</p>
|
||||||
|
<label>
|
||||||
|
Primary character age group
|
||||||
|
<select class="form-control" name="primaryAgeGroup">
|
||||||
|
<option value="">No baseline</option>
|
||||||
|
<option value="Late teen / early 20s">Late teen / early 20s</option>
|
||||||
|
<option value="Young teen">Young teen</option>
|
||||||
|
<option value="Older teen">Older teen</option>
|
||||||
|
<option value="Young adult">Young adult</option>
|
||||||
|
<option value="Adult">Adult</option>
|
||||||
|
<option value="Middle-aged adult">Middle-aged adult</option>
|
||||||
|
<option value="Senior">Senior</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Era
|
||||||
|
<select class="form-control" name="storyEra">
|
||||||
|
<option value="">No baseline</option>
|
||||||
|
<option value="1980s">1980s</option>
|
||||||
|
<option value="1970s">1970s</option>
|
||||||
|
<option value="1990s">1990s</option>
|
||||||
|
<option value="2000s">2000s</option>
|
||||||
|
<option value="Contemporary">Contemporary</option>
|
||||||
|
<option value="Victorian">Victorian</option>
|
||||||
|
<option value="Medieval">Medieval</option>
|
||||||
|
<option value="Near future">Near future</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Location
|
||||||
|
<select class="form-control" name="storyLocation">
|
||||||
|
<option value="">No baseline</option>
|
||||||
|
<option value="UK">UK</option>
|
||||||
|
<option value="United States">United States</option>
|
||||||
|
<option value="Europe">Europe</option>
|
||||||
|
<option value="Urban">Urban</option>
|
||||||
|
<option value="Suburban">Suburban</option>
|
||||||
|
<option value="Rural">Rural</option>
|
||||||
|
<option value="Fantasy world">Fantasy world</option>
|
||||||
|
<option value="Science fiction setting">Science fiction setting</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</fieldset>
|
||||||
|
<button class="btn btn-primary" type="submit" disabled="@(!Model.CanStart)">Analyse manuscript</button>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -222,15 +222,6 @@
|
|||||||
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;
|
||||||
@ -241,10 +232,6 @@
|
|||||||
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;
|
||||||
@ -477,46 +464,10 @@
|
|||||||
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 {
|
||||||
@ -524,18 +475,6 @@
|
|||||||
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;
|
||||||
@ -1346,23 +1285,6 @@ 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;
|
||||||
@ -1497,105 +1419,6 @@ 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;
|
||||||
@ -1762,8 +1585,6 @@ 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;
|
||||||
@ -1771,15 +1592,6 @@ 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