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