Reset story intelligence memory extraction
This commit is contained in:
parent
311fbcbbb0
commit
8b2448ecd0
@ -60,6 +60,8 @@ var tests = new (string Name, Action Test)[]
|
|||||||
("Phase 21R scene browser and panels are replay friendly", Phase21RSceneBrowserAndPanelsAreReplayFriendly),
|
("Phase 21R scene browser and panels are replay friendly", Phase21RSceneBrowserAndPanelsAreReplayFriendly),
|
||||||
("Phase 21S uses durable Story Memory assignments", Phase21SUsesDurableStoryMemoryAssignments),
|
("Phase 21S uses durable Story Memory assignments", Phase21SUsesDurableStoryMemoryAssignments),
|
||||||
("Phase 21T repairs durable character understanding", Phase21TRepairsDurableCharacterUnderstanding),
|
("Phase 21T repairs durable character understanding", Phase21TRepairsDurableCharacterUnderstanding),
|
||||||
|
("Phase 21U extracts mandatory character appearance fixtures", Phase21UExtractsMandatoryCharacterAppearanceFixtures),
|
||||||
|
("Phase 21U enforces alias age semantic and UI reset rules", Phase21UEnforcesAliasAgeSemanticAndUiResetRules),
|
||||||
("Story Intelligence visual polish keeps labels fixed and semantics specific", StoryIntelligenceVisualPolishKeepsLabelsFixedAndSemanticsSpecific),
|
("Story Intelligence visual polish keeps labels fixed and semantics specific", StoryIntelligenceVisualPolishKeepsLabelsFixedAndSemanticsSpecific),
|
||||||
("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),
|
||||||
@ -557,7 +559,7 @@ static void StoryIntelligenceEvidenceExtractionPreventsObservedAttributeLeakage(
|
|||||||
Assert(toddler.AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.Child, $"Toddler should be Child, got {toddler.AgeBand}.");
|
Assert(toddler.AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.Child, $"Toddler should be Child, got {toddler.AgeBand}.");
|
||||||
Assert(toddler.Presentation == StoryIntelligenceIllustrationCompatibility.Presentations.Masculine, $"Boy toddler should be Masculine, got {toddler.Presentation}.");
|
Assert(toddler.Presentation == StoryIntelligenceIllustrationCompatibility.Presentations.Masculine, $"Boy toddler should be Masculine, got {toddler.Presentation}.");
|
||||||
|
|
||||||
Assert(StoryIntelligenceIllustrationCompatibility.LocationType("Cock Hill Lane") == "Road", "Cock Hill Lane must classify as Road, not interior.");
|
Assert(StoryIntelligenceIllustrationCompatibility.LocationType("Cock Hill Lane") == "Lane", "Cock Hill Lane must classify as Lane, not interior.");
|
||||||
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("money") == "Money", "Money must not classify as Car.");
|
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("money") == "Money", "Money must not classify as Car.");
|
||||||
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("bottle") == "Bottle", "Bottle must not classify as LockedBox.");
|
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("bottle") == "Bottle", "Bottle must not classify as LockedBox.");
|
||||||
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("scarves") == "Clothing", "Scarves must not classify as Car.");
|
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("scarves") == "Clothing", "Scarves must not classify as Car.");
|
||||||
@ -634,7 +636,7 @@ static void Phase21OPreservesAdultDefaultsAndRealModeContinuity()
|
|||||||
Assert(examiner.Presentation == StoryIntelligenceIllustrationCompatibility.Presentations.Feminine, $"Female examiner should infer Feminine, got {examiner.Presentation}.");
|
Assert(examiner.Presentation == StoryIntelligenceIllustrationCompatibility.Presentations.Feminine, $"Female examiner should infer Feminine, got {examiner.Presentation}.");
|
||||||
Assert(examiner.AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.Adult, $"Driving examiner should infer Adult, got {examiner.AgeBand}.");
|
Assert(examiner.AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.Adult, $"Driving examiner should infer Adult, got {examiner.AgeBand}.");
|
||||||
Assert(StoryIntelligenceIllustrationCompatibility.LocationType("Bristol Road") == "Road", "Road names should classify as Road.");
|
Assert(StoryIntelligenceIllustrationCompatibility.LocationType("Bristol Road") == "Road", "Road names should classify as Road.");
|
||||||
Assert(StoryIntelligenceIllustrationCompatibility.LocationType("Cock Hill Lane") == "Road", "Lane names should classify as Road.");
|
Assert(StoryIntelligenceIllustrationCompatibility.LocationType("Cock Hill Lane") == "Lane", "Lane names should classify as Lane.");
|
||||||
Assert(StoryIntelligenceIllustrationCompatibility.AssetCompatible("Hatchback", "Car"), "Hatchbacks may fall back to neutral generic car artwork.");
|
Assert(StoryIntelligenceIllustrationCompatibility.AssetCompatible("Hatchback", "Car"), "Hatchbacks may fall back to neutral generic car artwork.");
|
||||||
Assert(!StoryIntelligenceIllustrationCompatibility.AssetCompatible("SportsCar", "Hatchback"), "Vehicle subtypes should remain strict.");
|
Assert(!StoryIntelligenceIllustrationCompatibility.AssetCompatible("SportsCar", "Hatchback"), "Vehicle subtypes should remain strict.");
|
||||||
|
|
||||||
@ -898,8 +900,8 @@ static void Phase21QRepairsProductionVisualisationRegressions()
|
|||||||
Assert(maggie.HairColour == StoryIntelligenceIllustrationCompatibility.HairColours.Red, $"Maggie red-hair evidence should be explicit, got {maggie.HairColour}.");
|
Assert(maggie.HairColour == StoryIntelligenceIllustrationCompatibility.HairColours.Red, $"Maggie red-hair evidence should be explicit, got {maggie.HairColour}.");
|
||||||
Assert(Rejected(maggie, darkHairCandidate).Any(reason => reason.Contains("hair colour hard mismatch", StringComparison.OrdinalIgnoreCase)), "Red-hair evidence must invalidate dark-haired assignments.");
|
Assert(Rejected(maggie, darkHairCandidate).Any(reason => reason.Contains("hair colour hard mismatch", StringComparison.OrdinalIgnoreCase)), "Red-hair evidence must invalidate dark-haired assignments.");
|
||||||
|
|
||||||
Assert(StoryIntelligenceIllustrationCompatibility.LocationType("Cock Hill Lane") == "Road", "Cock Hill Lane should classify as Road/Lane.");
|
Assert(StoryIntelligenceIllustrationCompatibility.LocationType("Cock Hill Lane") == "Lane", "Cock Hill Lane should classify as Lane.");
|
||||||
Assert(StoryIntelligenceIllustrationCompatibility.LocationType("derelict hospital waiting room") == "Hospital", "Derelict hospital should remain Hospital rather than generic interior.");
|
Assert(StoryIntelligenceIllustrationCompatibility.LocationType("derelict hospital waiting room") == "DerelictHospital", "Derelict hospital should remain typed rather than generic interior.");
|
||||||
Assert(StoryIntelligenceIllustrationCompatibility.LocationType("bench beside the road") == "Road", "Bench context beside road should not become indoor room artwork.");
|
Assert(StoryIntelligenceIllustrationCompatibility.LocationType("bench beside the road") == "Road", "Bench context beside road should not become indoor room artwork.");
|
||||||
Assert(!StoryIntelligenceIllustrationCompatibility.LocationCompatible("Road", "CarPark"), "Lane/Road must not use car park artwork.");
|
Assert(!StoryIntelligenceIllustrationCompatibility.LocationCompatible("Road", "CarPark"), "Lane/Road must not use car park artwork.");
|
||||||
Assert(!StoryIntelligenceIllustrationCompatibility.LocationCompatible("WaitingRoom", "FlatInterior"), "Waiting room must not use flat interior artwork.");
|
Assert(!StoryIntelligenceIllustrationCompatibility.LocationCompatible("WaitingRoom", "FlatInterior"), "Waiting room must not use flat interior artwork.");
|
||||||
@ -930,8 +932,8 @@ static void Phase21QViewKeepsPanelsBoundedAndRealControlsHidden()
|
|||||||
Assert(!view.Contains("data-scene-title", StringComparison.Ordinal), "Redundant scene heading should be removed from the real visualisation.");
|
Assert(!view.Contains("data-scene-title", StringComparison.Ordinal), "Redundant scene heading should be removed from the real visualisation.");
|
||||||
Assert(view.IndexOf("data-knowledge", StringComparison.Ordinal) < view.IndexOf("data-relationships", StringComparison.Ordinal), "Knowledge threads should be prioritised above relationship changes.");
|
Assert(view.IndexOf("data-knowledge", StringComparison.Ordinal) < view.IndexOf("data-relationships", StringComparison.Ordinal), "Knowledge threads should be prioritised above relationship changes.");
|
||||||
Assert(view.Contains("Model.Mode == \"simulation\" || Model.Mode == \"replay\"", StringComparison.Ordinal), "Prototype controls must remain hidden in live mode while supporting replay.");
|
Assert(view.Contains("Model.Mode == \"simulation\" || Model.Mode == \"replay\"", StringComparison.Ordinal), "Prototype controls must remain hidden in live mode while supporting replay.");
|
||||||
Assert(script.Contains("relationships.slice(0, 2)", StringComparison.Ordinal), "Relationship changes should be capped to the two most relevant visible cards.");
|
Assert(script.Contains("relationships.slice(0, 1)", StringComparison.Ordinal), "Relationship changes should be capped to the most relevant visible card.");
|
||||||
Assert(script.Contains("renderOverflowSummary", StringComparison.Ordinal), "Relationship overflow should be indicated compactly.");
|
Assert(!script.Contains("renderOverflowSummary", StringComparison.Ordinal), "Side panels must not hide readable content behind '+N more' overflow controls.");
|
||||||
Assert(css.Contains("max-height: calc(100vh - 126px)", StringComparison.Ordinal), "Side columns should be bounded to the viewport.");
|
Assert(css.Contains("max-height: calc(100vh - 126px)", StringComparison.Ordinal), "Side columns should be bounded to the viewport.");
|
||||||
Assert(css.Contains("overflow-y: auto", StringComparison.Ordinal), "Side columns should keep a single hidden-track vertical scroll.");
|
Assert(css.Contains("overflow-y: auto", StringComparison.Ordinal), "Side columns should keep a single hidden-track vertical scroll.");
|
||||||
Assert(css.Contains("z-index: 180", StringComparison.Ordinal), "Character labels should render in a high label layer above nodes and paths.");
|
Assert(css.Contains("z-index: 180", StringComparison.Ordinal), "Character labels should render in a high label layer above nodes and paths.");
|
||||||
@ -1019,6 +1021,80 @@ static void Phase21TRepairsDurableCharacterUnderstanding()
|
|||||||
Assert(snapshot.Contains("ApplyPersistedStoryMemoryAssignmentsAsync(importSessionId, model)", StringComparison.Ordinal), "Live and replay must apply durable Story Memory assignments.");
|
Assert(snapshot.Contains("ApplyPersistedStoryMemoryAssignmentsAsync(importSessionId, model)", StringComparison.Ordinal), "Live and replay must apply durable Story Memory assignments.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static void Phase21UExtractsMandatoryCharacterAppearanceFixtures()
|
||||||
|
{
|
||||||
|
const string source = """
|
||||||
|
Maggie, a late teenage girl, pushed back her long natural red hair while Beth, her late teenage sister, checked the mirror.
|
||||||
|
Beth was initially blonde, and Rosie was a blonde young woman.
|
||||||
|
Miss Davies smiled. "Miss Davies, but you can call me Zoe," she said.
|
||||||
|
Mum waited outside with Mrs Patterson while Colin, David, Graham, Rick and Rob spoke to the driving examiner.
|
||||||
|
""";
|
||||||
|
var facts = CharacterAppearanceExtractionService.Extract(source, ["Maggie", "Beth", "Rosie", "Miss Davies", "Zoe", "Mum", "Mrs Patterson", "Colin", "David", "Graham", "Rick", "Rob"]);
|
||||||
|
|
||||||
|
Assert(facts.Any(fact => fact.CanonicalName == "Maggie" && fact.AttributeType == "HairColour" && fact.NormalisedValue == "Red"), "Maggie's red-hair evidence was not extracted.");
|
||||||
|
Assert(facts.Any(fact => fact.CanonicalName == "Maggie" && fact.AttributeType == "HairLength" && fact.NormalisedValue == "Long"), "Maggie's long-hair evidence was not extracted.");
|
||||||
|
Assert(facts.Any(fact => fact.CanonicalName == "Beth" && fact.AttributeType == "HairColour" && fact.NormalisedValue == "Blonde"), "Beth's blonde evidence was not extracted.");
|
||||||
|
Assert(facts.Any(fact => fact.CanonicalName == "Rosie" && fact.AttributeType == "HairColour" && fact.NormalisedValue == "Blonde"), "Rosie's blonde evidence was not extracted.");
|
||||||
|
Assert(facts.Any(fact => fact.CanonicalName == "Zoe Davies" && fact.AttributeType == "Alias" && fact.NormalisedValue == "miss davies"), "Miss Davies should resolve as an alias of Zoe Davies.");
|
||||||
|
Assert(facts.Any(fact => fact.CanonicalName == "Zoe Davies" && fact.AttributeType == "Alias" && fact.NormalisedValue == "zoe"), "Zoe should resolve as an alias of Zoe Davies.");
|
||||||
|
|
||||||
|
var maggie = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Maggie", ["Maggie is a late teenage girl.", "Maggie has long natural red hair."], []);
|
||||||
|
var beth = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Beth", ["Beth is a late teenage girl.", "Beth was initially blonde."], []);
|
||||||
|
var rosie = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Rosie", ["Rosie is a blonde young woman."], []);
|
||||||
|
|
||||||
|
Assert(maggie.AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.OlderTeen, $"Maggie should be OlderTeen, got {maggie.AgeBand}.");
|
||||||
|
Assert(maggie.HairColour == StoryIntelligenceIllustrationCompatibility.HairColours.Red, $"Maggie should be Red, got {maggie.HairColour}.");
|
||||||
|
Assert(beth.AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.OlderTeen, $"Beth should be OlderTeen, got {beth.AgeBand}.");
|
||||||
|
Assert(beth.HairColour == StoryIntelligenceIllustrationCompatibility.HairColours.Blonde, $"Beth should be Blonde, got {beth.HairColour}.");
|
||||||
|
Assert(rosie.AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.YoungAdult, $"Rosie should be YoungAdult, got {rosie.AgeBand}.");
|
||||||
|
Assert(rosie.HairColour == StoryIntelligenceIllustrationCompatibility.HairColours.Blonde, $"Rosie should be Blonde, got {rosie.HairColour}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
static void Phase21UEnforcesAliasAgeSemanticAndUiResetRules()
|
||||||
|
{
|
||||||
|
var zoeAliases = CharacterAppearanceExtractionService.AliasMap("""Miss Davies, but you can call me Zoe.""", ["Miss Davies", "Zoe"]);
|
||||||
|
Assert(zoeAliases.TryGetValue("miss davies", out var missCanonical) && missCanonical == "Zoe Davies", "Miss Davies should canonicalise to Zoe Davies.");
|
||||||
|
Assert(zoeAliases.TryGetValue("zoe", out var zoeCanonical) && zoeCanonical == "Zoe Davies", "Zoe should canonicalise to Zoe Davies.");
|
||||||
|
|
||||||
|
var mum = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Mum", ["Mum is mother of teenage daughters."], []);
|
||||||
|
var examiner = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("driving examiner", ["the driving examiner"], []);
|
||||||
|
var childCandidate = Metadata("char-child", "Child", "Feminine");
|
||||||
|
Assert(Rejected(mum, childCandidate).Any(reason => reason.Contains("age hard mismatch", StringComparison.OrdinalIgnoreCase)), "Parent role should reject child portraits.");
|
||||||
|
Assert(examiner.AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.Adult, $"Driving examiner should infer Adult, got {examiner.AgeBand}.");
|
||||||
|
Assert(StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("girl", ["girl"], []).AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.Unknown, "Girl alone must not mean Child.");
|
||||||
|
|
||||||
|
Assert(StoryIntelligenceIllustrationCompatibility.LocationType("Cock Hill Lane") == "Lane", "Cock Hill Lane should be Lane.");
|
||||||
|
Assert(StoryIntelligenceIllustrationCompatibility.LocationType("Mount Pleasant Road") == "Road", "Mount Pleasant Road should be Road.");
|
||||||
|
Assert(StoryIntelligenceIllustrationCompatibility.LocationType("car interior") == "VehicleInterior", "Car interior should be VehicleInterior.");
|
||||||
|
Assert(StoryIntelligenceIllustrationCompatibility.LocationType("bathroom") == "Bathroom", "Bathroom should be Bathroom.");
|
||||||
|
Assert(StoryIntelligenceIllustrationCompatibility.LocationType("waiting room") == "WaitingRoom", "Waiting room should be WaitingRoom.");
|
||||||
|
Assert(!StoryIntelligenceIllustrationCompatibility.LocationCompatible("VehicleInterior", "HouseExterior"), "VehicleInterior must not use building art.");
|
||||||
|
|
||||||
|
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("blue VW Golf") == "Hatchback", "Blue VW Golf should be Hatchback.");
|
||||||
|
Assert(StoryIntelligenceIllustrationCompatibility.AssetColour("blue VW Golf") == "Blue", "Blue VW Golf should be Blue.");
|
||||||
|
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("red TR6") == "SportsCar", "TR6 should be SportsCar.");
|
||||||
|
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("lipstick") == "Lipstick", "Lipstick should be Lipstick.");
|
||||||
|
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("skirt") == "Skirt", "Skirt should be Skirt.");
|
||||||
|
Assert(!StoryIntelligenceIllustrationCompatibility.AssetCompatible("Hatchback", "SportsCar"), "Hatchback must not use sports-car art when demand generation is available.");
|
||||||
|
|
||||||
|
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
|
||||||
|
var onboarding = File.ReadAllText(Path.Combine(root, "Views/Onboarding/StoryIntelligence.cshtml"));
|
||||||
|
var storyMemory = File.ReadAllText(Path.Combine(root, "Services/StoryMemoryServices.cs"));
|
||||||
|
var script = File.ReadAllText(Path.Combine(root, "wwwroot/js/story-intelligence-experience-prototype.js"));
|
||||||
|
var css = File.ReadAllText(Path.Combine(root, "wwwroot/css/story-intelligence-experience-prototype.css"));
|
||||||
|
var resetSql = File.ReadAllText(Path.Combine(root, "Sql/143_Phase21U_DevelopmentStoryIntelligenceReset.sql"));
|
||||||
|
var scenePrompt = File.ReadAllText(Path.Combine(root, "Docs/AI/Scene-Prompt-V2.md"));
|
||||||
|
|
||||||
|
Assert(onboarding.Contains("name=\"appearancePreference\"", StringComparison.Ordinal), "Import setup must ask for appearance preference.");
|
||||||
|
Assert(storyMemory.Contains("CharacterAppearanceExtractionService.Extract", StringComparison.Ordinal), "Durable Story Memory must consume dedicated appearance extraction.");
|
||||||
|
Assert(storyMemory.Contains("Maximum one primary POV", StringComparison.Ordinal) || storyMemory.Contains("ResolveScenePov", StringComparison.Ordinal), "Story Memory must use a single POV resolver.");
|
||||||
|
Assert(!script.Contains("renderOverflowSummary", StringComparison.Ordinal), "Side panels must not use hidden '+N more' overflow controls.");
|
||||||
|
Assert(css.Contains("-webkit-line-clamp: 3", StringComparison.Ordinal), "Insight body text should be clamped inside its card.");
|
||||||
|
Assert(resetSql.Contains("StoryIntelligence_DevelopmentAccountReset", StringComparison.Ordinal), "Phase 21U reset procedure is missing.");
|
||||||
|
Assert(resetSql.Contains("ProjectHardDelete_ForOwner", StringComparison.Ordinal), "Reset must reuse owner-scoped project hard delete.");
|
||||||
|
Assert(scenePrompt.Contains("characterAppearance", StringComparison.OrdinalIgnoreCase), "Scene prompt must request structured character appearance.");
|
||||||
|
}
|
||||||
|
|
||||||
static void Phase21RSceneBrowserAndPanelsAreReplayFriendly()
|
static void Phase21RSceneBrowserAndPanelsAreReplayFriendly()
|
||||||
{
|
{
|
||||||
var view = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Views/Development/StoryIntelligenceExperience.cshtml"));
|
var view = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Views/Development/StoryIntelligenceExperience.cshtml"));
|
||||||
@ -1053,11 +1129,11 @@ static void StoryIntelligenceVisualPolishKeepsLabelsFixedAndSemanticsSpecific()
|
|||||||
Assert(css.Contains("background: rgba(4, 10, 20, 0.91)", StringComparison.Ordinal), "Character caption opacity should be increased for readability.");
|
Assert(css.Contains("background: rgba(4, 10, 20, 0.91)", StringComparison.Ordinal), "Character caption opacity should be increased for readability.");
|
||||||
Assert(css.Contains(".story-exp-insight span {\n display: none;", StringComparison.Ordinal), "Redundant insight tone headings should be hidden.");
|
Assert(css.Contains(".story-exp-insight span {\n display: none;", StringComparison.Ordinal), "Redundant insight tone headings should be hidden.");
|
||||||
|
|
||||||
Assert(StoryIntelligenceIllustrationCompatibility.LocationType("Cock Hill Lane") == "Road", "Cock Hill Lane should classify as Road.");
|
Assert(StoryIntelligenceIllustrationCompatibility.LocationType("Cock Hill Lane") == "Lane", "Cock Hill Lane should classify as Lane.");
|
||||||
Assert(StoryIntelligenceIllustrationCompatibility.LocationType("Mount Pleasant Road") == "Road", "Mount Pleasant Road should classify as Road.");
|
Assert(StoryIntelligenceIllustrationCompatibility.LocationType("Mount Pleasant Road") == "Road", "Mount Pleasant Road should classify as Road.");
|
||||||
Assert(StoryIntelligenceIllustrationCompatibility.LocationType("Queens Court") == "Road", "Court should classify as Road.");
|
Assert(StoryIntelligenceIllustrationCompatibility.LocationType("Queens Court") == "Road", "Court should classify as Road.");
|
||||||
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") == "VehicleInterior", "Driver seat should classify as VehicleInterior.");
|
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.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.");
|
||||||
|
|
||||||
|
|||||||
@ -86,9 +86,9 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
|
|||||||
|
|
||||||
[HttpPost("story-intelligence/start")]
|
[HttpPost("story-intelligence/start")]
|
||||||
[ValidateAntiForgeryToken]
|
[ValidateAntiForgeryToken]
|
||||||
public async Task<IActionResult> StartStoryIntelligence()
|
public async Task<IActionResult> StartStoryIntelligence([FromForm] string? appearancePreference)
|
||||||
{
|
{
|
||||||
var job = await storyIntelligence.StartAsync();
|
var job = await storyIntelligence.StartAsync(appearancePreference);
|
||||||
if (job is null)
|
if (job is null)
|
||||||
{
|
{
|
||||||
TempData["OnboardingStoryIntelligenceError"] = "Review the manuscript scan before starting Story Intelligence.";
|
TempData["OnboardingStoryIntelligenceError"] = "Review the manuscript scan before starting Story Intelligence.";
|
||||||
|
|||||||
@ -50,7 +50,8 @@ It should not:
|
|||||||
7. Treat assets as meaningful story objects, not ordinary props.
|
7. Treat assets as meaningful story objects, not ordinary props.
|
||||||
8. Treat relationships as scene-local signals, not final records.
|
8. Treat relationships as scene-local signals, not final records.
|
||||||
9. Treat metrics as descriptive intensity scores, not quality scores.
|
9. Treat metrics as descriptive intensity scores, not quality scores.
|
||||||
10. Return JSON only.
|
10. Extract explicit visual character appearance separately from story understanding.
|
||||||
|
11. Return JSON only.
|
||||||
|
|
||||||
## Strict Behavioural Rules
|
## Strict Behavioural Rules
|
||||||
|
|
||||||
@ -229,6 +230,26 @@ For each character:
|
|||||||
|
|
||||||
Mentioned-only characters matter because later stages need to know who is referenced without assuming they were present.
|
Mentioned-only characters matter because later stages need to know who is referenced without assuming they were present.
|
||||||
|
|
||||||
|
## Character Appearance Rules
|
||||||
|
|
||||||
|
Scene understanding is not enough for illustration matching. Report explicit or strongly supported visual facts in `characterAppearance`.
|
||||||
|
|
||||||
|
For every named or recurring character with supported appearance evidence, include exactly these properties: `canonicalName`, `aliases`, `title`, `presentation`, `explicitAge`, `inferredAgeBand`, `hairColour`, `hairLength`, `hairStyle`, `facialHair`, `glasses`, `build`, `distinctiveFeatures`, `clothing`, `roleOrOccupation`, `relationshipAgeEvidence`, `pronounEvidence`, `evidence`, `confidence`, `evidenceKind`.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- Use the manuscript as the authority.
|
||||||
|
- Keep unknown visual fields as `null`.
|
||||||
|
- Use `evidenceKind: "explicit"` for directly stated facts and `evidenceKind: "inferred"` for title, role, relationship or pronoun evidence.
|
||||||
|
- Keep `evidence` to a short exact fragment from the scene.
|
||||||
|
- Do not infer hair colour from nearby characters.
|
||||||
|
- Do not inherit appearance from the point of view character.
|
||||||
|
- Do not copy appearance evidence between identities.
|
||||||
|
- If the scene says `Miss Davies, but you can call me Zoe`, use canonicalName `Zoe Davies` and aliases `["Miss Davies", "Zoe", "Miss Davies (Zoe)"]`.
|
||||||
|
- Unknown age must not be marked as child.
|
||||||
|
- Adult titles and occupations such as Mr, Mrs, Miss, Ms, teacher, driving examiner, driving instructor, police officer, detective, solicitor, landlord, manager, nurse, receptionist, mother, father, aunt and uncle are adult evidence.
|
||||||
|
- Words like `girl`, `lad` or `young` alone are presentation/voice clues, not child-age evidence.
|
||||||
|
|
||||||
## Location Reporting Rules
|
## Location Reporting Rules
|
||||||
|
|
||||||
Named locations:
|
Named locations:
|
||||||
@ -543,6 +564,30 @@ Return exactly one JSON object matching this shape:
|
|||||||
"notes": null
|
"notes": null
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"characterAppearance": [
|
||||||
|
{
|
||||||
|
"canonicalName": "",
|
||||||
|
"aliases": [],
|
||||||
|
"title": null,
|
||||||
|
"presentation": null,
|
||||||
|
"explicitAge": null,
|
||||||
|
"inferredAgeBand": null,
|
||||||
|
"hairColour": null,
|
||||||
|
"hairLength": null,
|
||||||
|
"hairStyle": null,
|
||||||
|
"facialHair": null,
|
||||||
|
"glasses": null,
|
||||||
|
"build": null,
|
||||||
|
"distinctiveFeatures": [],
|
||||||
|
"clothing": null,
|
||||||
|
"roleOrOccupation": null,
|
||||||
|
"relationshipAgeEvidence": null,
|
||||||
|
"pronounEvidence": null,
|
||||||
|
"evidence": "",
|
||||||
|
"confidence": 0.0,
|
||||||
|
"evidenceKind": "explicit"
|
||||||
|
}
|
||||||
|
],
|
||||||
"locations": [
|
"locations": [
|
||||||
{
|
{
|
||||||
"name": "",
|
"name": "",
|
||||||
|
|||||||
@ -12,6 +12,7 @@ public sealed class SceneIntelligenceScene
|
|||||||
public SceneIntelligencePointOfView? PointOfView { get; init; }
|
public SceneIntelligencePointOfView? PointOfView { get; init; }
|
||||||
public SceneIntelligenceSetting? Setting { get; init; }
|
public SceneIntelligenceSetting? Setting { get; init; }
|
||||||
public List<SceneIntelligenceCharacter>? Characters { get; init; }
|
public List<SceneIntelligenceCharacter>? Characters { get; init; }
|
||||||
|
public List<SceneIntelligenceCharacterAppearance>? CharacterAppearance { get; init; }
|
||||||
public List<SceneIntelligenceLocation>? Locations { get; init; }
|
public List<SceneIntelligenceLocation>? Locations { get; init; }
|
||||||
public List<SceneIntelligenceAsset>? Assets { get; init; }
|
public List<SceneIntelligenceAsset>? Assets { get; init; }
|
||||||
public List<SceneIntelligenceRelationship>? Relationships { get; init; }
|
public List<SceneIntelligenceRelationship>? Relationships { get; init; }
|
||||||
@ -99,6 +100,33 @@ public sealed class SceneIntelligenceCharacter
|
|||||||
public Dictionary<string, JsonElement>? ExtensionData { get; init; }
|
public Dictionary<string, JsonElement>? ExtensionData { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed class SceneIntelligenceCharacterAppearance
|
||||||
|
{
|
||||||
|
public string? CanonicalName { get; init; }
|
||||||
|
public List<string>? Aliases { get; init; }
|
||||||
|
public string? Title { get; init; }
|
||||||
|
public string? Presentation { get; init; }
|
||||||
|
public string? ExplicitAge { get; init; }
|
||||||
|
public string? InferredAgeBand { get; init; }
|
||||||
|
public string? HairColour { get; init; }
|
||||||
|
public string? HairLength { get; init; }
|
||||||
|
public string? HairStyle { get; init; }
|
||||||
|
public string? FacialHair { get; init; }
|
||||||
|
public string? Glasses { get; init; }
|
||||||
|
public string? Build { get; init; }
|
||||||
|
public List<string>? DistinctiveFeatures { get; init; }
|
||||||
|
public string? Clothing { get; init; }
|
||||||
|
public string? RoleOrOccupation { get; init; }
|
||||||
|
public string? RelationshipAgeEvidence { get; init; }
|
||||||
|
public string? PronounEvidence { get; init; }
|
||||||
|
public string? Evidence { get; init; }
|
||||||
|
public decimal? Confidence { get; init; }
|
||||||
|
public string? EvidenceKind { get; init; }
|
||||||
|
|
||||||
|
[JsonExtensionData]
|
||||||
|
public Dictionary<string, JsonElement>? ExtensionData { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
public sealed class SceneIntelligenceLocation
|
public sealed class SceneIntelligenceLocation
|
||||||
{
|
{
|
||||||
public string? Name { get; init; }
|
public string? Name { get; init; }
|
||||||
|
|||||||
169
PlotLine/Services/CharacterAppearanceExtractionService.cs
Normal file
169
PlotLine/Services/CharacterAppearanceExtractionService.cs
Normal file
@ -0,0 +1,169 @@
|
|||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using PlotLine.Models;
|
||||||
|
|
||||||
|
namespace PlotLine.Services;
|
||||||
|
|
||||||
|
public sealed record CharacterAppearanceFact(
|
||||||
|
string CanonicalName,
|
||||||
|
IReadOnlyList<string> Aliases,
|
||||||
|
string AttributeType,
|
||||||
|
string NormalisedValue,
|
||||||
|
string EvidenceFragment,
|
||||||
|
decimal Confidence,
|
||||||
|
bool IsExplicit,
|
||||||
|
string SourceScene);
|
||||||
|
|
||||||
|
public static class CharacterAppearanceExtractionService
|
||||||
|
{
|
||||||
|
private static readonly Regex MissCallMeRegex = new(@"\bMiss\s+(?<surname>[A-Z][A-Za-z'’-]+)\b[^.!?]{0,80}?\b(?:call me|called)\s+(?<first>[A-Z][A-Za-z'’-]+)\b", RegexOptions.Compiled);
|
||||||
|
private static readonly Regex HairRegex = new(@"\b(?<name>Mum|Mother|Mrs\s+[A-Z][A-Za-z'’-]+|Miss\s+[A-Z][A-Za-z'’-]+|[A-Z][A-Za-z'’-]+)\b[^.!?]{0,90}?\b(?<length>(?i:long|short|shoulder-length|shoulder length|medium-length|medium length))?\s*(?<qualifier>(?i:natural)\s+)?(?<colour>(?i:red|auburn|ginger|blonde|fair|black|brown|brunette|grey|gray|white))\s+(?i:hair)\b", RegexOptions.Compiled);
|
||||||
|
private static readonly Regex HairAdjectiveRegex = new(@"\b(?<name>Mum|Mother|Mrs\s+[A-Z][A-Za-z'’-]+|Miss\s+[A-Z][A-Za-z'’-]+|[A-Z][A-Za-z'’-]+)\b[^.!?]{0,60}?\b(?<colour>(?i:blonde|fair-haired|red-haired|auburn-haired|ginger-haired|black-haired|brown-haired|brunette|grey-haired|gray-haired|white-haired))\b", RegexOptions.Compiled);
|
||||||
|
private static readonly Regex HairLeadingRegex = new(@"\b(?<length>(?i:long|short|shoulder-length|shoulder length|medium-length|medium length))?\s*(?<qualifier>(?i:natural)\s+)?(?<colour>(?i:red|auburn|ginger|blonde|fair|black|brown|brunette|grey|gray|white))\s+(?i:hair)(?:ed)?\s+(?<name>(?i:girl|woman|man|boy))\b", RegexOptions.Compiled);
|
||||||
|
private static readonly Regex HairAdjectiveLeadingRegex = new(@"\b(?<colour>(?i:blonde|fair-haired|red-haired|auburn-haired|ginger-haired|black-haired|brown-haired|brunette|grey-haired|gray-haired|white-haired))\s+(?<name>(?i:girl|woman|man|boy|young woman|young man))\b", RegexOptions.Compiled);
|
||||||
|
private static readonly Regex AgeRegex = new(@"\b(?<name>Mum|Mother|Mrs\s+[A-Z][A-Za-z'’-]+|Miss\s+[A-Z][A-Za-z'’-]+|[A-Z][A-Za-z'’-]+)\b[^.!?]{0,90}?\b(?<age>(?i:late teenage|teenage|fifteen|sixteen|seventeen|eighteen|nineteen|young woman|young man|adult woman|adult man|middle-aged|mature|mother|mum|driving examiner|driving instructor|teacher))\b", RegexOptions.Compiled);
|
||||||
|
|
||||||
|
public static IReadOnlyList<CharacterAppearanceFact> Extract(string sourceText, IEnumerable<string>? knownCharacterNames = null, string sourceScene = "Fixture")
|
||||||
|
{
|
||||||
|
var facts = new List<CharacterAppearanceFact>();
|
||||||
|
if (string.IsNullOrWhiteSpace(sourceText))
|
||||||
|
{
|
||||||
|
return facts;
|
||||||
|
}
|
||||||
|
|
||||||
|
var aliases = AliasMap(sourceText, knownCharacterNames);
|
||||||
|
foreach (Match match in HairRegex.Matches(sourceText))
|
||||||
|
{
|
||||||
|
var rawName = match.Groups["name"].Value.Trim();
|
||||||
|
AddHairFacts(facts, CanonicalName(rawName, aliases), AliasesFor(rawName, aliases), match, sourceScene);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (Match match in HairAdjectiveRegex.Matches(sourceText))
|
||||||
|
{
|
||||||
|
var rawName = match.Groups["name"].Value.Trim();
|
||||||
|
AddHairFacts(facts, CanonicalName(rawName, aliases), AliasesFor(rawName, aliases), match, sourceScene);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (Match match in HairLeadingRegex.Matches(sourceText))
|
||||||
|
{
|
||||||
|
var descriptor = match.Groups["name"].Value.Trim();
|
||||||
|
var candidate = (knownCharacterNames ?? []).FirstOrDefault(name => sourceText.Contains(name, StringComparison.OrdinalIgnoreCase));
|
||||||
|
if (!string.IsNullOrWhiteSpace(candidate))
|
||||||
|
{
|
||||||
|
AddHairFacts(facts, CanonicalName(candidate, aliases), AliasesFor(candidate, aliases), match, sourceScene);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
AddHairFacts(facts, descriptor, [descriptor], match, sourceScene);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (Match match in HairAdjectiveLeadingRegex.Matches(sourceText))
|
||||||
|
{
|
||||||
|
var descriptor = match.Groups["name"].Value.Trim();
|
||||||
|
var candidate = (knownCharacterNames ?? []).FirstOrDefault(name => sourceText.Contains(name, StringComparison.OrdinalIgnoreCase));
|
||||||
|
AddHairFacts(facts, string.IsNullOrWhiteSpace(candidate) ? descriptor : CanonicalName(candidate, aliases), string.IsNullOrWhiteSpace(candidate) ? [descriptor] : AliasesFor(candidate, aliases), match, sourceScene);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (Match match in AgeRegex.Matches(sourceText))
|
||||||
|
{
|
||||||
|
var rawName = match.Groups["name"].Value.Trim();
|
||||||
|
var age = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence(rawName, [match.Value], []).AgeBand;
|
||||||
|
if (age != StoryIntelligenceIllustrationCompatibility.AgeBands.Unknown)
|
||||||
|
{
|
||||||
|
facts.Add(new(CanonicalName(rawName, aliases), AliasesFor(rawName, aliases), "AgeBand", age, ShortFragment(match.Value), 0.9m, true, sourceScene));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var (alias, canonical) in aliases)
|
||||||
|
{
|
||||||
|
facts.Add(new(canonical, AliasesFor(alias, aliases), "Alias", alias, AliasEvidence(sourceText, alias, canonical), 0.96m, true, sourceScene));
|
||||||
|
}
|
||||||
|
|
||||||
|
return facts
|
||||||
|
.GroupBy(fact => $"{fact.CanonicalName}|{fact.AttributeType}|{fact.NormalisedValue}|{fact.EvidenceFragment}", StringComparer.OrdinalIgnoreCase)
|
||||||
|
.Select(group => group.First())
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string CanonicalName(string name, IReadOnlyDictionary<string, string>? aliases = null)
|
||||||
|
{
|
||||||
|
var clean = (name ?? string.Empty).Trim();
|
||||||
|
if (aliases is not null && aliases.TryGetValue(Normalise(clean), out var canonical))
|
||||||
|
{
|
||||||
|
return canonical;
|
||||||
|
}
|
||||||
|
|
||||||
|
return clean;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IReadOnlyDictionary<string, string> AliasMap(string sourceText, IEnumerable<string>? knownCharacterNames = null)
|
||||||
|
{
|
||||||
|
var aliases = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
foreach (Match match in MissCallMeRegex.Matches(sourceText ?? string.Empty))
|
||||||
|
{
|
||||||
|
var first = match.Groups["first"].Value.Trim();
|
||||||
|
var surname = match.Groups["surname"].Value.Trim();
|
||||||
|
var canonical = $"{first} {surname}";
|
||||||
|
aliases[Normalise(first)] = canonical;
|
||||||
|
aliases[Normalise($"Miss {surname}")] = canonical;
|
||||||
|
aliases[Normalise($"Miss {surname} ({first})")] = canonical;
|
||||||
|
}
|
||||||
|
|
||||||
|
return aliases;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddHairFacts(List<CharacterAppearanceFact> facts, string canonical, IReadOnlyList<string> aliases, Match match, string sourceScene)
|
||||||
|
{
|
||||||
|
var colour = StoryIntelligenceIllustrationCompatibility.NormalizeHairColour(match.Groups["colour"].Value);
|
||||||
|
if (colour != StoryIntelligenceIllustrationCompatibility.HairColours.Unknown)
|
||||||
|
{
|
||||||
|
facts.Add(new(canonical, aliases, "HairColour", colour, ShortFragment(match.Value), 0.94m, true, sourceScene));
|
||||||
|
}
|
||||||
|
|
||||||
|
var length = StoryIntelligenceIllustrationCompatibility.NormalizeHairLength(match.Groups["length"].Value);
|
||||||
|
if (length != "Unknown")
|
||||||
|
{
|
||||||
|
facts.Add(new(canonical, aliases, "HairLength", length, ShortFragment(match.Value), 0.88m, true, sourceScene));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<string> AliasesFor(string name, IReadOnlyDictionary<string, string> aliases)
|
||||||
|
{
|
||||||
|
var canonical = CanonicalName(name, aliases);
|
||||||
|
var list = aliases
|
||||||
|
.Where(item => string.Equals(item.Value, canonical, StringComparison.OrdinalIgnoreCase))
|
||||||
|
.Select(item => item.Key)
|
||||||
|
.Append(Normalise(name))
|
||||||
|
.Where(item => !string.IsNullOrWhiteSpace(item))
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToList();
|
||||||
|
return list.Count == 0 ? [name] : list;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string AliasEvidence(string sourceText, string alias, string canonical)
|
||||||
|
{
|
||||||
|
var index = sourceText.IndexOf(alias, StringComparison.OrdinalIgnoreCase);
|
||||||
|
if (index < 0)
|
||||||
|
{
|
||||||
|
index = sourceText.IndexOf(canonical.Split(' ')[0], StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (index < 0)
|
||||||
|
{
|
||||||
|
return canonical;
|
||||||
|
}
|
||||||
|
|
||||||
|
var start = Math.Max(0, index - 60);
|
||||||
|
var length = Math.Min(sourceText.Length - start, 140);
|
||||||
|
return ShortFragment(sourceText.Substring(start, length));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ShortFragment(string value)
|
||||||
|
=> Regex.Replace(value.Trim(), @"\s+", " ") is var clean && clean.Length > 160 ? clean[..160].TrimEnd() : clean;
|
||||||
|
|
||||||
|
private static string Normalise(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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -9,7 +9,7 @@ namespace PlotLine.Services;
|
|||||||
public interface IOnboardingStoryIntelligenceService
|
public interface IOnboardingStoryIntelligenceService
|
||||||
{
|
{
|
||||||
Task<StoryIntelligenceOverviewViewModel> GetOverviewAsync();
|
Task<StoryIntelligenceOverviewViewModel> GetOverviewAsync();
|
||||||
Task<OnboardingStoryIntelligenceBatch?> StartAsync();
|
Task<OnboardingStoryIntelligenceBatch?> StartAsync(string? appearancePreference = null);
|
||||||
Task<StoryIntelligenceProgressViewModel?> GetProgressAsync(Guid batchId);
|
Task<StoryIntelligenceProgressViewModel?> GetProgressAsync(Guid batchId);
|
||||||
Task<StoryIntelligenceProgressViewModel?> CancelAsync(Guid batchId);
|
Task<StoryIntelligenceProgressViewModel?> CancelAsync(Guid batchId);
|
||||||
Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> CommitAsync(Guid batchId, int runId);
|
Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> CommitAsync(Guid batchId, int runId);
|
||||||
@ -83,7 +83,7 @@ public sealed class OnboardingStoryIntelligenceService(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<OnboardingStoryIntelligenceBatch?> StartAsync()
|
public async Task<OnboardingStoryIntelligenceBatch?> StartAsync(string? appearancePreference = null)
|
||||||
{
|
{
|
||||||
var userId = RequireUserId();
|
var userId = RequireUserId();
|
||||||
var context = await GetContextAsync(userId);
|
var context = await GetContextAsync(userId);
|
||||||
@ -189,6 +189,7 @@ public sealed class OnboardingStoryIntelligenceService(
|
|||||||
BookID = preview.BookID,
|
BookID = preview.BookID,
|
||||||
ProjectName = wizard.SelectedProjectName,
|
ProjectName = wizard.SelectedProjectName,
|
||||||
BookTitle = wizard.SelectedBookTitle,
|
BookTitle = wizard.SelectedBookTitle,
|
||||||
|
AppearancePreference = NormaliseAppearancePreference(appearancePreference),
|
||||||
Items = batchItems
|
Items = batchItems
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -204,6 +205,18 @@ public sealed class OnboardingStoryIntelligenceService(
|
|||||||
return batch;
|
return batch;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string NormaliseAppearancePreference(string? value)
|
||||||
|
=> value?.Trim() switch
|
||||||
|
{
|
||||||
|
StoryMemoryAppearancePreferences.ExplicitEvidenceOnly => StoryMemoryAppearancePreferences.ExplicitEvidenceOnly,
|
||||||
|
StoryMemoryAppearancePreferences.Balanced => StoryMemoryAppearancePreferences.Balanced,
|
||||||
|
StoryMemoryAppearancePreferences.PredominantlyLight => StoryMemoryAppearancePreferences.PredominantlyLight,
|
||||||
|
StoryMemoryAppearancePreferences.PredominantlyMedium => StoryMemoryAppearancePreferences.PredominantlyMedium,
|
||||||
|
StoryMemoryAppearancePreferences.PredominantlyDark => StoryMemoryAppearancePreferences.PredominantlyDark,
|
||||||
|
StoryMemoryAppearancePreferences.CustomMix => StoryMemoryAppearancePreferences.CustomMix,
|
||||||
|
_ => StoryMemoryAppearancePreferences.Balanced
|
||||||
|
};
|
||||||
|
|
||||||
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);
|
||||||
@ -919,6 +932,7 @@ public sealed class OnboardingStoryIntelligenceBatch
|
|||||||
public int BookID { get; init; }
|
public int BookID { get; init; }
|
||||||
public string? ProjectName { get; init; }
|
public string? ProjectName { get; init; }
|
||||||
public string? BookTitle { get; init; }
|
public string? BookTitle { get; init; }
|
||||||
|
public string AppearancePreference { get; init; } = StoryMemoryAppearancePreferences.Balanced;
|
||||||
public DateTime CreatedUtc { get; init; } = DateTime.UtcNow;
|
public DateTime CreatedUtc { get; init; } = DateTime.UtcNow;
|
||||||
public IReadOnlyList<OnboardingStoryIntelligenceBatchItem> Items { get; init; } = [];
|
public IReadOnlyList<OnboardingStoryIntelligenceBatchItem> Items { get; init; } = [];
|
||||||
public List<OnboardingStoryIntelligenceCharacterDecision> CharacterDecisions { get; } = [];
|
public List<OnboardingStoryIntelligenceCharacterDecision> CharacterDecisions { get; } = [];
|
||||||
|
|||||||
@ -259,30 +259,46 @@ public static class StoryIntelligenceIllustrationCompatibility
|
|||||||
var padded = $" {text} ";
|
var padded = $" {text} ";
|
||||||
if (ContainsAny(text, "driving test centre", "driving test center", "test centre", "test center")) return "DrivingTestCentre";
|
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, "police station")) return "PoliceStation";
|
||||||
if (ContainsAny(text, "car interior", "vehicle interior", "inside the car", "inside car", "driver seat", "driver's seat", "passenger seat", "rear seat", "back seat", "boot", "cabin", "dashboard")) return "VehicleInterior";
|
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 (ContainsAny(text, "bathroom", "toilet", "washroom")) return "Bathroom";
|
||||||
if (text.Contains("kitchen", StringComparison.OrdinalIgnoreCase)) return "Kitchen";
|
if (text.Contains("kitchen", StringComparison.OrdinalIgnoreCase)) return "Kitchen";
|
||||||
if (text.Contains("bedroom", StringComparison.OrdinalIgnoreCase)) return "Bedroom";
|
if (text.Contains("bedroom", StringComparison.OrdinalIgnoreCase)) return "Bedroom";
|
||||||
if (ContainsAny(text, "living room", "sitting room", "lounge")) return "LivingRoom";
|
if (ContainsAny(text, "living room", "sitting room", "lounge")) return "LivingRoom";
|
||||||
if (ContainsAny(text, "hall", "hallway", "corridor")) return "Hallway";
|
if (ContainsAny(text, "hall", "hallway", "corridor")) return "Hallway";
|
||||||
if (text.Contains("stair", StringComparison.OrdinalIgnoreCase)) return "Stairwell";
|
if (text.Contains("stair", StringComparison.OrdinalIgnoreCase)) return "Stairwell";
|
||||||
if (ContainsAny(text, "hospital", "derelict hospital")) return "Hospital";
|
if (ContainsAny(text, "derelict hospital", "abandoned hospital")) return "DerelictHospital";
|
||||||
|
if (ContainsAny(text, "hospital")) return "Hospital";
|
||||||
if (ContainsAny(text, "church", "chapel")) return "Church";
|
if (ContainsAny(text, "church", "chapel")) return "Church";
|
||||||
if (ContainsAny(text, "restaurant", "cafe", "café", "diner")) return "Restaurant";
|
if (ContainsAny(text, "restaurant", "cafe", "café", "diner")) return "Restaurant";
|
||||||
if (ContainsAny(text, "pub", "public house")) return "Pub";
|
if (ContainsAny(text, "pub", "public house")) return "Pub";
|
||||||
if (ContainsAny(text, "beach", "seafront")) return "Beach";
|
if (ContainsAny(text, "beach", "seafront")) return "Beach";
|
||||||
if (ContainsAny(text, "railway station", "train station", "station platform")) return "RailwayStation";
|
if (ContainsAny(text, "railway station", "train station", "station platform")) return "RailwayStation";
|
||||||
if (ContainsAny(text, "canal", "towpath")) return "Canal";
|
if (ContainsAny(text, "canal", "towpath")) return "Canal";
|
||||||
if (ContainsAny(text, "garage", "workshop")) return "Garage";
|
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, "front door", "porch", "entrance", "house exterior", "outside the house", "exterior")) return "HouseExterior";
|
||||||
if (ContainsAny(text, "office", "desk", "study", "library")) return "Office";
|
if (ContainsAny(text, "office", "desk", "study", "library")) return "Office";
|
||||||
if (text.Contains("school", StringComparison.OrdinalIgnoreCase)) return "School";
|
if (text.Contains("school", StringComparison.OrdinalIgnoreCase)) return "School";
|
||||||
if (text.Contains("waiting room", StringComparison.OrdinalIgnoreCase)) return "WaitingRoom";
|
if (text.Contains("waiting room", StringComparison.OrdinalIgnoreCase)) return "WaitingRoom";
|
||||||
if (ContainsAny(text, "car park", "parking", "forecourt")) return "CarPark";
|
if (ContainsAny(text, "car park", "parking", "forecourt")) return "CarPark";
|
||||||
|
if (ContainsAny(text, "dual carriageway")) return "DualCarriageway";
|
||||||
|
if (ContainsAny(text, "motorway", "highway")) return "Motorway";
|
||||||
|
if (ContainsAny(text, "bridge")) return "Bridge";
|
||||||
|
if (ContainsAny(text, "tunnel")) return "Tunnel";
|
||||||
|
if (ContainsAny(text, "flyover")) return "Flyover";
|
||||||
|
if (ContainsAny(text, "underpass")) return "Underpass";
|
||||||
|
if (ContainsAny(text, " lane ") || text.EndsWith(" lane", StringComparison.OrdinalIgnoreCase)) return "Lane";
|
||||||
|
if (ContainsAny(text, " street ") || text.EndsWith(" street", StringComparison.OrdinalIgnoreCase)) return "Street";
|
||||||
|
if (ContainsAny(text, " avenue ") || text.EndsWith(" avenue", StringComparison.OrdinalIgnoreCase)) return "Avenue";
|
||||||
|
if (ContainsAny(text, " drive ") || text.EndsWith(" drive", StringComparison.OrdinalIgnoreCase)) return "Drive";
|
||||||
|
if (ContainsAny(text, " close ") || text.EndsWith(" close", StringComparison.OrdinalIgnoreCase)) return "Close";
|
||||||
if (ContainsAny(text,
|
if (ContainsAny(text,
|
||||||
"road", "street", "lane", "drive", " close ", "court", "way", "avenue", "terrace", "gardens",
|
"road", "court", "way", "terrace", "gardens",
|
||||||
"highway", "motorway", "bridge", "roundabout", "junction", "crossroads", "dual carriageway",
|
"roundabout", "junction", "crossroads",
|
||||||
"track", "footpath", " path ", "cycleway", "tunnel", "flyover", "underpass", "boulevard")
|
"track", "footpath", " path ", "cycleway", "boulevard")
|
||||||
|| RoadSuffixRegex.IsMatch(padded))
|
|| RoadSuffixRegex.IsMatch(padded))
|
||||||
{
|
{
|
||||||
return "Road";
|
return "Road";
|
||||||
@ -323,24 +339,32 @@ public static class StoryIntelligenceIllustrationCompatibility
|
|||||||
if (ContainsAny(text, "money", "cash", "coins", "banknote")) return "Money";
|
if (ContainsAny(text, "money", "cash", "coins", "banknote")) return "Money";
|
||||||
if (ContainsAny(text, "scarf", "scarves")) return "Clothing";
|
if (ContainsAny(text, "scarf", "scarves")) return "Clothing";
|
||||||
if (ContainsAny(text, "paperwork")) return "Document";
|
if (ContainsAny(text, "paperwork")) return "Document";
|
||||||
|
if (ContainsAny(text, "lipstick")) return "Lipstick";
|
||||||
if (ContainsAny(text, "ring", "necklace", "jewellery", "jewelry")) return "Jewellery";
|
if (ContainsAny(text, "ring", "necklace", "jewellery", "jewelry")) return "Jewellery";
|
||||||
|
if (ContainsAny(text, "shoes", "boots", "trainers")) return "Shoes";
|
||||||
|
if (ContainsAny(text, "dress")) return "Dress";
|
||||||
|
if (ContainsAny(text, "skirt")) return "Skirt";
|
||||||
|
if (ContainsAny(text, "stockings")) return "Stockings";
|
||||||
if (ContainsAny(text, "gun", "pistol", "rifle", "weapon")) return "Weapon";
|
if (ContainsAny(text, "gun", "pistol", "rifle", "weapon")) return "Weapon";
|
||||||
if (ContainsAny(padded, " hatchback ", " vw golf ", " volkswagen golf ", " golf gti ", " ford fiesta ", " fiesta ")) return "Hatchback";
|
if (ContainsAny(padded, " hatchback ", " vw golf ", " volkswagen golf ", " golf gti ", " ford fiesta ", " fiesta ")) return "Hatchback";
|
||||||
if (ContainsAny(padded, " family car ", " estate car ", " saloon ")) return "FamilyCar";
|
if (ContainsAny(padded, " estate car ", " estate ")) return "Estate";
|
||||||
|
if (ContainsAny(padded, " saloon ")) return "Saloon";
|
||||||
|
if (ContainsAny(padded, " family car ")) return "Car";
|
||||||
if (ContainsAny(padded, " sports car ", " tr6 ", " roadster ")) return "SportsCar";
|
if (ContainsAny(padded, " sports car ", " tr6 ", " roadster ")) return "SportsCar";
|
||||||
if (ContainsAny(padded, " van ", " transit ")) return "Van";
|
if (ContainsAny(padded, " van ", " transit ")) return "Van";
|
||||||
if (ContainsAny(padded, " motor car ", " car ", " vehicle ")) return "Car";
|
if (ContainsAny(padded, " motor car ", " car ", " vehicle ")) return "Car";
|
||||||
if (ContainsAny(text, "supercharger", "engine part")) return "CarPart";
|
if (ContainsAny(text, "supercharger", "engine part")) return "CarPart";
|
||||||
if (ContainsAny(text, "biscuit tin", "tin")) return "Tin";
|
if (ContainsAny(text, "biscuit tin", "tin")) return "Tin";
|
||||||
if (ContainsAny(text, "parcel", "package")) return "Parcel";
|
if (ContainsAny(text, "parcel", "package")) return "Parcel";
|
||||||
if (ContainsAny(text, "blouse", "skirt", "jumper", "jumpers", "sunglasses", "stockings")) return "Clothing";
|
if (ContainsAny(text, "blouse", "jumper", "jumpers", "sunglasses", "clothing", "clothes")) return "Clothing";
|
||||||
if (text.Contains("rucksack", StringComparison.OrdinalIgnoreCase)) return "Rucksack";
|
if (text.Contains("rucksack", StringComparison.OrdinalIgnoreCase)) return "Rucksack";
|
||||||
if (text.Contains("suitcase", StringComparison.OrdinalIgnoreCase)) return "Suitcase";
|
if (text.Contains("suitcase", StringComparison.OrdinalIgnoreCase)) return "Suitcase";
|
||||||
if (text.Contains("note", StringComparison.OrdinalIgnoreCase)) return "Note";
|
if (text.Contains("note", StringComparison.OrdinalIgnoreCase)) return "Note";
|
||||||
if (text.Contains("letter", StringComparison.OrdinalIgnoreCase)) return "Letter";
|
if (text.Contains("letter", StringComparison.OrdinalIgnoreCase)) return "Letter";
|
||||||
if (text.Contains("notebook", StringComparison.OrdinalIgnoreCase)) return "Notebook";
|
if (text.Contains("notebook", StringComparison.OrdinalIgnoreCase)) return "Notebook";
|
||||||
if (text.Contains("book", StringComparison.OrdinalIgnoreCase)) return "Book";
|
if (text.Contains("book", StringComparison.OrdinalIgnoreCase)) return "Book";
|
||||||
if (ContainsAny(text, "telephone", "phone", "recorder")) return "Telephone";
|
if (ContainsAny(text, "telephone", "phone", "mobile")) return "Phone";
|
||||||
|
if (ContainsAny(text, "recorder")) return "Recorder";
|
||||||
if (text.Contains("key", StringComparison.OrdinalIgnoreCase)) return "Keys";
|
if (text.Contains("key", StringComparison.OrdinalIgnoreCase)) return "Keys";
|
||||||
if (ContainsAny(text, "tr6")) return "Car";
|
if (ContainsAny(text, "tr6")) return "Car";
|
||||||
if (ContainsAny(text, "photo", "photograph")) return "Photograph";
|
if (ContainsAny(text, "photo", "photograph")) return "Photograph";
|
||||||
@ -364,6 +388,8 @@ public static class StoryIntelligenceIllustrationCompatibility
|
|||||||
|
|
||||||
public static bool LocationCompatible(string requestedType, string candidateType)
|
public static bool LocationCompatible(string requestedType, string candidateType)
|
||||||
=> requestedType == candidateType
|
=> requestedType == candidateType
|
||||||
|
|| (requestedType is "Lane" or "Street" or "Avenue" or "Drive" or "Close" && candidateType == "Road")
|
||||||
|
|| (requestedType is "DriverSeat" or "PassengerSeat" or "RearSeat" && candidateType == "VehicleInterior")
|
||||||
|| (requestedType is "FrontDoor" or "Porch" && candidateType is "HouseExterior")
|
|| (requestedType is "FrontDoor" or "Porch" && candidateType is "HouseExterior")
|
||||||
|| (requestedType is "Kitchen" or "LivingRoom" or "Bedroom" && candidateType is "FlatInterior")
|
|| (requestedType is "Kitchen" or "LivingRoom" or "Bedroom" && candidateType is "FlatInterior")
|
||||||
|| (requestedType == "Bathroom" && candidateType == "Bathroom");
|
|| (requestedType == "Bathroom" && candidateType == "Bathroom");
|
||||||
@ -371,7 +397,7 @@ public static class StoryIntelligenceIllustrationCompatibility
|
|||||||
public static bool AssetCompatible(string requestedType, string candidateType)
|
public static bool AssetCompatible(string requestedType, string candidateType)
|
||||||
=> requestedType == candidateType
|
=> requestedType == candidateType
|
||||||
|| (requestedType == "Hatchback" && candidateType == "Car")
|
|| (requestedType == "Hatchback" && candidateType == "Car")
|
||||||
|| (requestedType == "FamilyCar" && candidateType == "Car")
|
|| (requestedType is "Saloon" or "Estate" && candidateType == "Car")
|
||||||
|| (requestedType == "Note" && candidateType is "Letter" or "Document")
|
|| (requestedType == "Note" && candidateType is "Letter" or "Document")
|
||||||
|| (requestedType == "Rucksack" && candidateType == "Rucksack");
|
|| (requestedType == "Rucksack" && candidateType == "Rucksack");
|
||||||
|
|
||||||
@ -673,6 +699,18 @@ public static class StoryIntelligenceIllustrationCompatibility
|
|||||||
{
|
{
|
||||||
lower.IndexOf("hair", StringComparison.Ordinal),
|
lower.IndexOf("hair", StringComparison.Ordinal),
|
||||||
lower.IndexOf("skin", StringComparison.Ordinal),
|
lower.IndexOf("skin", StringComparison.Ordinal),
|
||||||
|
lower.IndexOf("blonde", StringComparison.Ordinal),
|
||||||
|
lower.IndexOf("brunette", StringComparison.Ordinal),
|
||||||
|
lower.IndexOf("red haired", StringComparison.Ordinal),
|
||||||
|
lower.IndexOf("red-haired", StringComparison.Ordinal),
|
||||||
|
lower.IndexOf("fair haired", StringComparison.Ordinal),
|
||||||
|
lower.IndexOf("fair-haired", StringComparison.Ordinal),
|
||||||
|
lower.IndexOf("black haired", StringComparison.Ordinal),
|
||||||
|
lower.IndexOf("black-haired", StringComparison.Ordinal),
|
||||||
|
lower.IndexOf("brown haired", StringComparison.Ordinal),
|
||||||
|
lower.IndexOf("brown-haired", StringComparison.Ordinal),
|
||||||
|
lower.IndexOf("grey haired", StringComparison.Ordinal),
|
||||||
|
lower.IndexOf("grey-haired", StringComparison.Ordinal),
|
||||||
lower.IndexOf("glasses", StringComparison.Ordinal),
|
lower.IndexOf("glasses", StringComparison.Ordinal),
|
||||||
lower.IndexOf("spectacles", StringComparison.Ordinal),
|
lower.IndexOf("spectacles", StringComparison.Ordinal),
|
||||||
lower.IndexOf("beard", StringComparison.Ordinal),
|
lower.IndexOf("beard", StringComparison.Ordinal),
|
||||||
|
|||||||
@ -269,6 +269,7 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
|
|||||||
scenes.Add(BuildEmptyScene(activeSnapshot.Run));
|
scenes.Add(BuildEmptyScene(activeSnapshot.Run));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var appearancePreference = await storyMemoryRepository.GetAppearancePreferenceAsync(importSessionId);
|
||||||
var model = new StoryIntelligenceExperiencePrototypeViewModel
|
var model = new StoryIntelligenceExperiencePrototypeViewModel
|
||||||
{
|
{
|
||||||
Mode = replayMode ? "replay" : "real",
|
Mode = replayMode ? "replay" : "real",
|
||||||
@ -297,7 +298,8 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
|
|||||||
completedChapterRuns,
|
completedChapterRuns,
|
||||||
displayedProgressReason,
|
displayedProgressReason,
|
||||||
storyMemory,
|
storyMemory,
|
||||||
replayMode),
|
replayMode,
|
||||||
|
appearancePreference),
|
||||||
ProjectName = DisplayTitle(session.ProjectName, "Untitled project"),
|
ProjectName = DisplayTitle(session.ProjectName, "Untitled project"),
|
||||||
BookTitle = DisplayTitle(session.BookDisplayTitle, "Untitled book"),
|
BookTitle = DisplayTitle(session.BookDisplayTitle, "Untitled book"),
|
||||||
Scenes = scenes
|
Scenes = scenes
|
||||||
@ -1449,7 +1451,8 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
|
|||||||
int completedChapters,
|
int completedChapters,
|
||||||
string displayedProgressReason,
|
string displayedProgressReason,
|
||||||
StoryMemory storyMemory,
|
StoryMemory storyMemory,
|
||||||
bool replayMode = false)
|
bool replayMode = false,
|
||||||
|
string appearancePreference = "")
|
||||||
{
|
{
|
||||||
var failed = snapshots.Sum(snapshot => snapshot.Parsed.Count(item => item.Scene is null));
|
var failed = snapshots.Sum(snapshot => snapshot.Parsed.Count(item => item.Scene is null));
|
||||||
var warning = failed == 0 ? null : $"{failed:N0} scene result(s) skipped because they could not be parsed.";
|
var warning = failed == 0 ? null : $"{failed:N0} scene result(s) skipped because they could not be parsed.";
|
||||||
@ -1513,6 +1516,7 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
|
|||||||
ReplayState = replayMode ? "Paused at first available scene" : string.Empty,
|
ReplayState = replayMode ? "Paused at first available scene" : string.Empty,
|
||||||
SnapshotCacheStatus = replayMode ? "Reconstructed from saved analysis" : string.Empty,
|
SnapshotCacheStatus = replayMode ? "Reconstructed from saved analysis" : string.Empty,
|
||||||
RebuildVersion = replayMode ? "21R-replay-v1" : string.Empty,
|
RebuildVersion = replayMode ? "21R-replay-v1" : string.Empty,
|
||||||
|
AppearancePreference = appearancePreference,
|
||||||
FailedChaptersSkipped = failedChapterSnapshots.Count,
|
FailedChaptersSkipped = failedChapterSnapshots.Count,
|
||||||
FailedChapterSummary = failedChapterSummary,
|
FailedChapterSummary = failedChapterSummary,
|
||||||
PanelVisibleCounts = replayMode ? "relationships:2; knowledge:3; observations:2; discoveries:4; activity:4" : string.Empty,
|
PanelVisibleCounts = replayMode ? "relationships:2; knowledge:3; observations:2; discoveries:4; activity:4" : string.Empty,
|
||||||
|
|||||||
@ -249,11 +249,17 @@ public sealed class StoryMemoryService(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
var resolvedName = ResolveIdentityAlias(sceneCharacter.Name, identityAliases);
|
var sceneAppearanceText = CharacterText(sceneCharacter);
|
||||||
|
var knownNames = KnownNames(sceneCharacter.Name, sceneCharacter.Aliases);
|
||||||
|
var appearanceAliases = CharacterAppearanceExtractionService.AliasMap(sceneAppearanceText, knownNames);
|
||||||
|
var resolvedName = CharacterAppearanceExtractionService.CanonicalName(
|
||||||
|
ResolveIdentityAlias(sceneCharacter.Name, identityAliases),
|
||||||
|
appearanceAliases);
|
||||||
var resolvedAliases = string.Equals(resolvedName, sceneCharacter.Name, StringComparison.OrdinalIgnoreCase)
|
var resolvedAliases = string.Equals(resolvedName, sceneCharacter.Name, StringComparison.OrdinalIgnoreCase)
|
||||||
? sceneCharacter.Aliases
|
? sceneCharacter.Aliases
|
||||||
: (sceneCharacter.Aliases ?? []).Concat([sceneCharacter.Name!]).ToList();
|
: (sceneCharacter.Aliases ?? []).Concat([sceneCharacter.Name!]).ToList();
|
||||||
var key = CharacterKey(resolvedName, resolvedAliases);
|
var key = CharacterKey(resolvedName, resolvedAliases);
|
||||||
|
var displayName = CanonicalCharacterDisplayName(resolvedName, resolvedAliases);
|
||||||
var isGroup = StoryIntelligenceIllustrationCompatibility.IsGroupEntity(sceneCharacter.Name);
|
var isGroup = StoryIntelligenceIllustrationCompatibility.IsGroupEntity(sceneCharacter.Name);
|
||||||
var resolvedPovName = ResolveScenePov(scene);
|
var resolvedPovName = ResolveScenePov(scene);
|
||||||
var isPrimaryPov = string.Equals(resolvedPovName, sceneCharacter.Name, StringComparison.OrdinalIgnoreCase)
|
var isPrimaryPov = string.Equals(resolvedPovName, sceneCharacter.Name, StringComparison.OrdinalIgnoreCase)
|
||||||
@ -264,7 +270,7 @@ public sealed class StoryMemoryService(
|
|||||||
session.ProjectID,
|
session.ProjectID,
|
||||||
session.BookID,
|
session.BookID,
|
||||||
key,
|
key,
|
||||||
resolvedName.Trim(),
|
displayName,
|
||||||
context,
|
context,
|
||||||
sourceSectionType,
|
sourceSectionType,
|
||||||
IsNamedCharacter(sceneCharacter.Name),
|
IsNamedCharacter(sceneCharacter.Name),
|
||||||
@ -280,7 +286,7 @@ public sealed class StoryMemoryService(
|
|||||||
await memory.AddCharacterAliasAsync(character.StoryMemoryCharacterID, alias, Normalise(alias), "Scene character alias", result.SceneResultID, sceneCharacter.Confidence ?? 0.7m);
|
await memory.AddCharacterAliasAsync(character.StoryMemoryCharacterID, alias, Normalise(alias), "Scene character alias", result.SceneResultID, sceneCharacter.Confidence ?? 0.7m);
|
||||||
}
|
}
|
||||||
|
|
||||||
var identityEvidence = new[] { sceneCharacter.Name, resolvedName, sceneCharacter.RoleInScene, sceneCharacter.Notes }
|
var identityEvidence = new[] { sceneCharacter.Name, resolvedName, displayName, sceneCharacter.RoleInScene, sceneCharacter.Notes }
|
||||||
.Concat(sceneCharacter.Actions ?? [])
|
.Concat(sceneCharacter.Actions ?? [])
|
||||||
.Concat(sceneCharacter.Aliases ?? [])
|
.Concat(sceneCharacter.Aliases ?? [])
|
||||||
.Concat(ExtensionEvidence(sceneCharacter.ExtensionData))
|
.Concat(ExtensionEvidence(sceneCharacter.ExtensionData))
|
||||||
@ -298,6 +304,59 @@ public sealed class StoryMemoryService(
|
|||||||
await AddKnownAttributeAsync(character.StoryMemoryCharacterID, "HairColour", inferred.HairColour, 0.82m, inferred.HairColour != StoryIntelligenceIllustrationCompatibility.HairColours.Unknown, "Deterministic evidence inference", result.SceneResultID);
|
await AddKnownAttributeAsync(character.StoryMemoryCharacterID, "HairColour", inferred.HairColour, 0.82m, inferred.HairColour != StoryIntelligenceIllustrationCompatibility.HairColours.Unknown, "Deterministic evidence inference", result.SceneResultID);
|
||||||
await AddKnownAttributeAsync(character.StoryMemoryCharacterID, "SkinTone", inferred.SkinTone, 0.8m, inferred.SkinTone != StoryIntelligenceIllustrationCompatibility.SkinTones.Unknown, "Deterministic evidence inference", result.SceneResultID);
|
await AddKnownAttributeAsync(character.StoryMemoryCharacterID, "SkinTone", inferred.SkinTone, 0.8m, inferred.SkinTone != StoryIntelligenceIllustrationCompatibility.SkinTones.Unknown, "Deterministic evidence inference", result.SceneResultID);
|
||||||
await AddKnownAttributeAsync(character.StoryMemoryCharacterID, "HairLength", inferred.HairLength, 0.72m, inferred.HairLength != "Unknown", "Deterministic evidence inference", result.SceneResultID);
|
await AddKnownAttributeAsync(character.StoryMemoryCharacterID, "HairLength", inferred.HairLength, 0.72m, inferred.HairLength != "Unknown", "Deterministic evidence inference", result.SceneResultID);
|
||||||
|
foreach (var fact in CharacterAppearanceExtractionService.Extract(sceneAppearanceText, [displayName, .. knownNames], $"SceneResultID {result.SceneResultID}"))
|
||||||
|
{
|
||||||
|
if (fact.AttributeType == "Alias")
|
||||||
|
{
|
||||||
|
await memory.AddCharacterAliasAsync(character.StoryMemoryCharacterID, fact.NormalisedValue, Normalise(fact.NormalisedValue), fact.EvidenceFragment, result.SceneResultID, fact.Confidence);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await AddKnownAttributeAsync(character.StoryMemoryCharacterID, fact.AttributeType, fact.NormalisedValue, fact.Confidence, fact.IsExplicit, fact.EvidenceFragment, result.SceneResultID);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var appearance in scene.CharacterAppearance ?? [])
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(appearance.CanonicalName))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var aliases = appearance.Aliases ?? [];
|
||||||
|
var canonical = CharacterAppearanceExtractionService.CanonicalName(
|
||||||
|
appearance.CanonicalName,
|
||||||
|
CharacterAppearanceExtractionService.AliasMap(appearance.Evidence ?? string.Empty, [appearance.CanonicalName, .. aliases]));
|
||||||
|
var displayName = CanonicalCharacterDisplayName(canonical, aliases);
|
||||||
|
var character = await memory.UpsertCharacterAsync(new StoryMemoryCharacterSave(
|
||||||
|
session.StoryIntelligenceBookPipelineID,
|
||||||
|
session.ProjectID,
|
||||||
|
session.BookID,
|
||||||
|
CharacterKey(displayName, aliases),
|
||||||
|
displayName,
|
||||||
|
context,
|
||||||
|
sourceSectionType,
|
||||||
|
IsNamedCharacter(displayName),
|
||||||
|
StoryIntelligenceIllustrationCompatibility.IsGroupEntity(displayName),
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
55,
|
||||||
|
result.SceneResultID));
|
||||||
|
|
||||||
|
await memory.AddCharacterAliasAsync(character.StoryMemoryCharacterID, displayName, Normalise(displayName), "Structured character appearance canonical identity", result.SceneResultID, appearance.Confidence ?? 0.85m);
|
||||||
|
foreach (var alias in aliases.Where(alias => !string.IsNullOrWhiteSpace(alias)))
|
||||||
|
{
|
||||||
|
await memory.AddCharacterAliasAsync(character.StoryMemoryCharacterID, alias.Trim(), Normalise(alias), "Structured character appearance alias", result.SceneResultID, appearance.Confidence ?? 0.8m);
|
||||||
|
}
|
||||||
|
|
||||||
|
var evidence = string.IsNullOrWhiteSpace(appearance.Evidence) ? "Structured character appearance extraction" : appearance.Evidence.Trim();
|
||||||
|
var explicitFact = string.Equals(appearance.EvidenceKind, "explicit", StringComparison.OrdinalIgnoreCase);
|
||||||
|
await AddKnownAttributeAsync(character.StoryMemoryCharacterID, "AgeBand", StoryIntelligenceIllustrationCompatibility.NormalizeAgeBand(appearance.InferredAgeBand ?? appearance.ExplicitAge ?? appearance.RoleOrOccupation), appearance.Confidence ?? 0.82m, explicitFact || !string.IsNullOrWhiteSpace(appearance.ExplicitAge), evidence, result.SceneResultID);
|
||||||
|
await AddKnownAttributeAsync(character.StoryMemoryCharacterID, "Presentation", StoryIntelligenceIllustrationCompatibility.NormalizePresentation(appearance.Presentation ?? appearance.PronounEvidence ?? appearance.Title), appearance.Confidence ?? 0.82m, explicitFact, evidence, result.SceneResultID);
|
||||||
|
await AddKnownAttributeAsync(character.StoryMemoryCharacterID, "HairColour", StoryIntelligenceIllustrationCompatibility.NormalizeHairColour(appearance.HairColour), appearance.Confidence ?? 0.9m, explicitFact, evidence, result.SceneResultID);
|
||||||
|
await AddKnownAttributeAsync(character.StoryMemoryCharacterID, "HairLength", StoryIntelligenceIllustrationCompatibility.NormalizeHairLength(appearance.HairLength), appearance.Confidence ?? 0.86m, explicitFact, evidence, result.SceneResultID);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(scene.Setting?.LocationName))
|
if (!string.IsNullOrWhiteSpace(scene.Setting?.LocationName))
|
||||||
@ -343,8 +402,8 @@ public sealed class StoryMemoryService(
|
|||||||
|
|
||||||
var sourceName = ResolveIdentityAlias(relationship.CharacterA, identityAliases);
|
var sourceName = ResolveIdentityAlias(relationship.CharacterA, identityAliases);
|
||||||
var targetName = ResolveIdentityAlias(relationship.CharacterB, identityAliases);
|
var targetName = ResolveIdentityAlias(relationship.CharacterB, identityAliases);
|
||||||
var source = await memory.UpsertCharacterAsync(new StoryMemoryCharacterSave(session.StoryIntelligenceBookPipelineID, session.ProjectID, session.BookID, CharacterKey(sourceName), sourceName.Trim(), context, sourceSectionType, IsNamedCharacter(sourceName), StoryIntelligenceIllustrationCompatibility.IsGroupEntity(sourceName), true, false, false, 40, result.SceneResultID));
|
var source = await memory.UpsertCharacterAsync(new StoryMemoryCharacterSave(session.StoryIntelligenceBookPipelineID, session.ProjectID, session.BookID, CharacterKey(sourceName), CanonicalCharacterDisplayName(sourceName, null), context, sourceSectionType, IsNamedCharacter(sourceName), StoryIntelligenceIllustrationCompatibility.IsGroupEntity(sourceName), true, false, false, 40, result.SceneResultID));
|
||||||
var target = await memory.UpsertCharacterAsync(new StoryMemoryCharacterSave(session.StoryIntelligenceBookPipelineID, session.ProjectID, session.BookID, CharacterKey(targetName), targetName.Trim(), context, sourceSectionType, IsNamedCharacter(targetName), StoryIntelligenceIllustrationCompatibility.IsGroupEntity(targetName), true, false, false, 40, result.SceneResultID));
|
var target = await memory.UpsertCharacterAsync(new StoryMemoryCharacterSave(session.StoryIntelligenceBookPipelineID, session.ProjectID, session.BookID, CharacterKey(targetName), CanonicalCharacterDisplayName(targetName, null), context, sourceSectionType, IsNamedCharacter(targetName), StoryIntelligenceIllustrationCompatibility.IsGroupEntity(targetName), true, false, false, 40, result.SceneResultID));
|
||||||
await memory.UpsertRelationshipAsync(session.StoryIntelligenceBookPipelineID, StoryMemoryEntityTypes.Character, source.StoryMemoryCharacterID, StoryMemoryEntityTypes.Character, target.StoryMemoryCharacterID, relationship.RelationshipSignal ?? "related", relationship.Confidence ?? 0.6m, !string.IsNullOrWhiteSpace(relationship.Evidence), result.SceneResultID);
|
await memory.UpsertRelationshipAsync(session.StoryIntelligenceBookPipelineID, StoryMemoryEntityTypes.Character, source.StoryMemoryCharacterID, StoryMemoryEntityTypes.Character, target.StoryMemoryCharacterID, relationship.RelationshipSignal ?? "related", relationship.Confidence ?? 0.6m, !string.IsNullOrWhiteSpace(relationship.Evidence), result.SceneResultID);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -576,6 +635,16 @@ public sealed class StoryMemoryService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
foreach (var character in ordered.SelectMany(item => item.Scene?.Characters ?? []))
|
||||||
|
{
|
||||||
|
var evidence = CharacterText(character);
|
||||||
|
var localAliases = CharacterAppearanceExtractionService.AliasMap(evidence, KnownNames(character.Name, character.Aliases));
|
||||||
|
foreach (var alias in localAliases)
|
||||||
|
{
|
||||||
|
aliases[alias.Key] = alias.Value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return aliases;
|
return aliases;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -616,12 +685,30 @@ public sealed class StoryMemoryService(
|
|||||||
return normalised.Split(' ', StringSplitOptions.RemoveEmptyEntries).Length <= 2;
|
return normalised.Split(' ', StringSplitOptions.RemoveEmptyEntries).Length <= 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string CanonicalCharacterDisplayName(string? name, IEnumerable<string>? aliases)
|
||||||
|
{
|
||||||
|
var clean = (name ?? string.Empty).Trim();
|
||||||
|
var known = KnownNames(clean, aliases);
|
||||||
|
var aliasMap = CharacterAppearanceExtractionService.AliasMap(string.Empty, known);
|
||||||
|
var canonical = CharacterAppearanceExtractionService.CanonicalName(clean, aliasMap);
|
||||||
|
return string.IsNullOrWhiteSpace(canonical) ? clean : canonical;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<string> KnownNames(string? name, IEnumerable<string>? aliases)
|
||||||
|
=> (aliases ?? [])
|
||||||
|
.Prepend(name ?? string.Empty)
|
||||||
|
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||||
|
.Select(value => value.Trim())
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
private static string CharacterText(SceneIntelligenceCharacter character)
|
private static string CharacterText(SceneIntelligenceCharacter character)
|
||||||
=> string.Join(' ', new[]
|
=> string.Join(' ', new[]
|
||||||
{
|
{
|
||||||
character.Name,
|
character.Name,
|
||||||
character.RoleInScene,
|
character.RoleInScene,
|
||||||
character.Notes,
|
character.Notes,
|
||||||
|
string.Join(' ', character.Aliases ?? []),
|
||||||
string.Join(' ', character.Actions ?? [])
|
string.Join(' ', character.Actions ?? [])
|
||||||
}
|
}
|
||||||
.Where(value => !string.IsNullOrWhiteSpace(value)));
|
.Where(value => !string.IsNullOrWhiteSpace(value)));
|
||||||
|
|||||||
141
PlotLine/Sql/143_Phase21U_DevelopmentStoryIntelligenceReset.sql
Normal file
141
PlotLine/Sql/143_Phase21U_DevelopmentStoryIntelligenceReset.sql
Normal file
@ -0,0 +1,141 @@
|
|||||||
|
SET ANSI_NULLS ON;
|
||||||
|
SET QUOTED_IDENTIFIER ON;
|
||||||
|
GO
|
||||||
|
|
||||||
|
/*
|
||||||
|
Phase 21U development reset.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
DECLARE @DevelopmentEmail nvarchar(256) = N'you@example.com';
|
||||||
|
EXEC dbo.StoryIntelligence_DevelopmentAccountReset @DevelopmentEmail = @DevelopmentEmail;
|
||||||
|
|
||||||
|
This intentionally deletes project/book/import data only for projects owned by
|
||||||
|
the supplied account. It also clears the Story Intelligence illustration
|
||||||
|
library tables because that library is global development data.
|
||||||
|
|
||||||
|
Physical files must be deleted separately from:
|
||||||
|
wwwroot/uploads/story-intelligence-library
|
||||||
|
*/
|
||||||
|
|
||||||
|
CREATE OR ALTER PROCEDURE dbo.StoryIntelligence_DevelopmentAccountReset
|
||||||
|
@DevelopmentEmail nvarchar(256)
|
||||||
|
AS
|
||||||
|
BEGIN
|
||||||
|
SET NOCOUNT ON;
|
||||||
|
SET XACT_ABORT ON;
|
||||||
|
|
||||||
|
DECLARE @UserID int;
|
||||||
|
SELECT @UserID = UserID
|
||||||
|
FROM dbo.AppUser
|
||||||
|
WHERE Email = @DevelopmentEmail;
|
||||||
|
|
||||||
|
IF @UserID IS NULL
|
||||||
|
THROW 52101, 'Development account email was not found.', 1;
|
||||||
|
|
||||||
|
DECLARE @ProjectIDs TABLE (ProjectID int NOT NULL PRIMARY KEY);
|
||||||
|
INSERT @ProjectIDs (ProjectID)
|
||||||
|
SELECT DISTINCT p.ProjectID
|
||||||
|
FROM dbo.Projects p
|
||||||
|
INNER JOIN dbo.ProjectUserAccess pua ON pua.ProjectID = p.ProjectID
|
||||||
|
WHERE pua.UserID = @UserID
|
||||||
|
AND pua.AccessRole = N'Owner'
|
||||||
|
AND pua.IsActive = 1;
|
||||||
|
|
||||||
|
DECLARE @DeletedProjects table (ProjectID int NOT NULL PRIMARY KEY, ProjectName nvarchar(200) NULL);
|
||||||
|
INSERT @DeletedProjects(ProjectID, ProjectName)
|
||||||
|
SELECT p.ProjectID, p.ProjectName
|
||||||
|
FROM dbo.Projects p
|
||||||
|
INNER JOIN @ProjectIDs ids ON ids.ProjectID = p.ProjectID;
|
||||||
|
|
||||||
|
BEGIN TRY
|
||||||
|
BEGIN TRANSACTION;
|
||||||
|
|
||||||
|
UPDATE dbo.StoryIntelligenceRuns
|
||||||
|
SET Status = CASE WHEN Status IN (N'Pending', N'Running') THEN N'Cancelled' ELSE Status END,
|
||||||
|
CurrentStage = N'Cancelled by Phase 21U reset',
|
||||||
|
CurrentMessage = N'Development reset invalidated this run.',
|
||||||
|
CompletedUtc = COALESCE(CompletedUtc, SYSUTCDATETIME()),
|
||||||
|
UpdatedUtc = SYSUTCDATETIME()
|
||||||
|
WHERE ProjectID IN (SELECT ProjectID FROM @ProjectIDs)
|
||||||
|
AND Status IN (N'Pending', N'Running');
|
||||||
|
|
||||||
|
UPDATE dbo.IllustrationLibraryItems
|
||||||
|
SET Status = CASE WHEN Status IN (N'Queued', N'Generating') THEN N'Failed' ELSE Status END,
|
||||||
|
LastError = N'Invalidated by Phase 21U development reset',
|
||||||
|
UpdatedUtc = SYSUTCDATETIME()
|
||||||
|
WHERE Category IN (N'Character', N'Location', N'Asset')
|
||||||
|
AND Status IN (N'Queued', N'Generating');
|
||||||
|
|
||||||
|
DELETE FROM dbo.StoryMemoryIllustrationAssignments;
|
||||||
|
DELETE FROM dbo.StoryMemoryIllustrationDemands;
|
||||||
|
DELETE FROM dbo.StoryMemoryRelationships;
|
||||||
|
DELETE FROM dbo.StoryMemoryCharacterAttributes;
|
||||||
|
DELETE FROM dbo.StoryMemoryCharacterAliases;
|
||||||
|
DELETE FROM dbo.StoryMemoryAssets;
|
||||||
|
DELETE FROM dbo.StoryMemoryLocations;
|
||||||
|
DELETE FROM dbo.StoryMemoryCharacters;
|
||||||
|
DELETE FROM dbo.StoryMemoryProcessedSceneResults;
|
||||||
|
DELETE FROM dbo.StoryMemoryImportPreferences;
|
||||||
|
DELETE FROM dbo.StoryIntelligenceCharacterIllustrationAssignments;
|
||||||
|
DELETE FROM dbo.StoryIntelligenceIllustrationDemands;
|
||||||
|
|
||||||
|
IF OBJECT_ID(N'dbo.IllustrationGenerationAttempts', N'U') IS NOT NULL
|
||||||
|
DELETE FROM dbo.IllustrationGenerationAttempts;
|
||||||
|
|
||||||
|
IF OBJECT_ID(N'dbo.IllustrationLibraryApprovalHistory', N'U') IS NOT NULL
|
||||||
|
DELETE FROM dbo.IllustrationLibraryApprovalHistory;
|
||||||
|
|
||||||
|
IF OBJECT_ID(N'dbo.IllustrationLibraryLineage', N'U') IS NOT NULL
|
||||||
|
DELETE FROM dbo.IllustrationLibraryLineage;
|
||||||
|
|
||||||
|
UPDATE dbo.IllustrationLibraryItems
|
||||||
|
SET ParentItemID = NULL
|
||||||
|
WHERE Category IN (N'Character', N'Location', N'Asset');
|
||||||
|
|
||||||
|
DELETE FROM dbo.IllustrationLibraryItems
|
||||||
|
WHERE Category IN (N'Character', N'Location', N'Asset');
|
||||||
|
|
||||||
|
COMMIT TRANSACTION;
|
||||||
|
END TRY
|
||||||
|
BEGIN CATCH
|
||||||
|
IF XACT_STATE() <> 0 ROLLBACK TRANSACTION;
|
||||||
|
THROW;
|
||||||
|
END CATCH;
|
||||||
|
|
||||||
|
DECLARE @ProjectID int;
|
||||||
|
DECLARE project_cursor CURSOR LOCAL FAST_FORWARD FOR
|
||||||
|
SELECT ProjectID FROM @ProjectIDs ORDER BY ProjectID;
|
||||||
|
|
||||||
|
OPEN project_cursor;
|
||||||
|
FETCH NEXT FROM project_cursor INTO @ProjectID;
|
||||||
|
WHILE @@FETCH_STATUS = 0
|
||||||
|
BEGIN
|
||||||
|
UPDATE dbo.Projects
|
||||||
|
SET IsArchived = 1,
|
||||||
|
ArchivedDate = COALESCE(ArchivedDate, SYSUTCDATETIME()),
|
||||||
|
ArchivedReason = N'Phase 21U development reset'
|
||||||
|
WHERE ProjectID = @ProjectID;
|
||||||
|
|
||||||
|
EXEC dbo.ProjectHardDelete_ForOwner @ProjectID = @ProjectID, @UserID = @UserID;
|
||||||
|
FETCH NEXT FROM project_cursor INTO @ProjectID;
|
||||||
|
END
|
||||||
|
CLOSE project_cursor;
|
||||||
|
DEALLOCATE project_cursor;
|
||||||
|
|
||||||
|
SELECT N'ProjectsDeleted' AS Metric, COUNT(*) AS Value FROM @DeletedProjects
|
||||||
|
UNION ALL SELECT N'RemainingOwnedProjects', COUNT(*)
|
||||||
|
FROM dbo.Projects p
|
||||||
|
INNER JOIN dbo.ProjectUserAccess pua ON pua.ProjectID = p.ProjectID
|
||||||
|
WHERE pua.UserID = @UserID AND pua.IsActive = 1
|
||||||
|
UNION ALL SELECT N'RemainingStoryIntelligenceRuns', COUNT(*)
|
||||||
|
FROM dbo.StoryIntelligenceRuns
|
||||||
|
WHERE UserID = @UserID
|
||||||
|
UNION ALL SELECT N'RemainingIllustrationLibraryItems', COUNT(*)
|
||||||
|
FROM dbo.IllustrationLibraryItems
|
||||||
|
WHERE Category IN (N'Character', N'Location', N'Asset');
|
||||||
|
|
||||||
|
SELECT ProjectID, ProjectName
|
||||||
|
FROM @DeletedProjects
|
||||||
|
ORDER BY ProjectID;
|
||||||
|
END;
|
||||||
|
GO
|
||||||
@ -131,6 +131,7 @@ public sealed class StoryIntelligenceOverviewViewModel
|
|||||||
public Guid? ExistingBatchID { get; init; }
|
public Guid? ExistingBatchID { get; init; }
|
||||||
public StoryIntelligenceJobProgress? ExistingJob { get; init; }
|
public StoryIntelligenceJobProgress? ExistingJob { get; init; }
|
||||||
public bool CanStart { get; init; }
|
public bool CanStart { get; init; }
|
||||||
|
public string AppearancePreference { get; init; } = StoryMemoryAppearancePreferences.Balanced;
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class StoryIntelligenceProgressViewModel
|
public sealed class StoryIntelligenceProgressViewModel
|
||||||
|
|||||||
@ -72,6 +72,7 @@ public sealed class StoryIntelligenceExperienceSnapshotDiagnostics
|
|||||||
public string ReplayState { get; init; } = string.Empty;
|
public string ReplayState { get; init; } = string.Empty;
|
||||||
public string SnapshotCacheStatus { get; init; } = string.Empty;
|
public string SnapshotCacheStatus { get; init; } = string.Empty;
|
||||||
public string RebuildVersion { get; init; } = string.Empty;
|
public string RebuildVersion { get; init; } = string.Empty;
|
||||||
|
public string AppearancePreference { get; init; } = string.Empty;
|
||||||
public int FailedChaptersSkipped { get; init; }
|
public int FailedChaptersSkipped { get; init; }
|
||||||
public string FailedChapterSummary { get; init; } = string.Empty;
|
public string FailedChapterSummary { get; init; } = string.Empty;
|
||||||
public string PanelVisibleCounts { get; init; } = string.Empty;
|
public string PanelVisibleCounts { get; init; } = string.Empty;
|
||||||
|
|||||||
@ -117,6 +117,17 @@
|
|||||||
<option value="all derived visualisation state">All derived visualisation state</option>
|
<option value="all derived visualisation state">All derived visualisation state</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
<label>
|
||||||
|
Appearance Guidance
|
||||||
|
<select name="appearancePreference">
|
||||||
|
<option value="@StoryMemoryAppearancePreferences.ExplicitEvidenceOnly">Explicit evidence only</option>
|
||||||
|
<option value="@StoryMemoryAppearancePreferences.Balanced" selected="@(Model.Diagnostics.AppearancePreference == StoryMemoryAppearancePreferences.Balanced)">Balanced diverse cast</option>
|
||||||
|
<option value="@StoryMemoryAppearancePreferences.PredominantlyLight" selected="@(Model.Diagnostics.AppearancePreference == StoryMemoryAppearancePreferences.PredominantlyLight)">Predominantly light-skinned</option>
|
||||||
|
<option value="@StoryMemoryAppearancePreferences.PredominantlyMedium" selected="@(Model.Diagnostics.AppearancePreference == StoryMemoryAppearancePreferences.PredominantlyMedium)">Predominantly medium-skinned</option>
|
||||||
|
<option value="@StoryMemoryAppearancePreferences.PredominantlyDark" selected="@(Model.Diagnostics.AppearancePreference == StoryMemoryAppearancePreferences.PredominantlyDark)">Predominantly dark-skinned</option>
|
||||||
|
<option value="@StoryMemoryAppearancePreferences.CustomMix" selected="@(Model.Diagnostics.AppearancePreference == StoryMemoryAppearancePreferences.CustomMix)">Custom mix</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
<button type="submit">Rebuild</button>
|
<button type="submit">Rebuild</button>
|
||||||
</form>
|
</form>
|
||||||
}
|
}
|
||||||
@ -350,6 +361,10 @@
|
|||||||
<dt>Unresolved characters</dt>
|
<dt>Unresolved characters</dt>
|
||||||
<dd data-diagnostic-key="unresolvedCharacterCount">@Model.Diagnostics.UnresolvedCharacterCount</dd>
|
<dd data-diagnostic-key="unresolvedCharacterCount">@Model.Diagnostics.UnresolvedCharacterCount</dd>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Appearance guidance</dt>
|
||||||
|
<dd data-diagnostic-key="appearancePreference">@Model.Diagnostics.AppearancePreference</dd>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt>Narrator confidence</dt>
|
<dt>Narrator confidence</dt>
|
||||||
<dd data-diagnostic-key="narratorConfidence">@Model.Diagnostics.NarratorConfidence</dd>
|
<dd data-diagnostic-key="narratorConfidence">@Model.Diagnostics.NarratorConfidence</dd>
|
||||||
|
|||||||
@ -80,7 +80,17 @@
|
|||||||
<form asp-action="SkipStoryIntelligence" method="post">
|
<form asp-action="SkipStoryIntelligence" method="post">
|
||||||
<button class="btn btn-outline-secondary" type="submit">Skip for now</button>
|
<button class="btn btn-outline-secondary" type="submit">Skip for now</button>
|
||||||
</form>
|
</form>
|
||||||
<form asp-action="StartStoryIntelligence" method="post">
|
<form asp-action="StartStoryIntelligence" method="post" class="onboarding-form-section">
|
||||||
|
<fieldset>
|
||||||
|
<legend>Unresolved character appearance</legend>
|
||||||
|
<p>Explicit manuscript evidence always wins. This only guides characters whose appearance is not described.</p>
|
||||||
|
<label><input type="radio" name="appearancePreference" value="@StoryMemoryAppearancePreferences.ExplicitEvidenceOnly" /> Use explicit manuscript evidence only</label>
|
||||||
|
<label><input type="radio" name="appearancePreference" value="@StoryMemoryAppearancePreferences.Balanced" checked /> Balanced diverse cast</label>
|
||||||
|
<label><input type="radio" name="appearancePreference" value="@StoryMemoryAppearancePreferences.PredominantlyLight" /> Predominantly light-skinned cast</label>
|
||||||
|
<label><input type="radio" name="appearancePreference" value="@StoryMemoryAppearancePreferences.PredominantlyMedium" /> Predominantly medium-skinned cast</label>
|
||||||
|
<label><input type="radio" name="appearancePreference" value="@StoryMemoryAppearancePreferences.PredominantlyDark" /> Predominantly dark-skinned cast</label>
|
||||||
|
<label><input type="radio" name="appearancePreference" value="@StoryMemoryAppearancePreferences.CustomMix" /> Custom mix</label>
|
||||||
|
</fieldset>
|
||||||
<button class="btn btn-primary" type="submit" disabled="@(!Model.CanStart)">Analyse manuscript</button>
|
<button class="btn btn-primary" type="submit" disabled="@(!Model.CanStart)">Analyse manuscript</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -340,7 +340,7 @@ input:focus-visible {
|
|||||||
.story-exp-right {
|
.story-exp-right {
|
||||||
display: grid;
|
display: grid;
|
||||||
align-content: start;
|
align-content: start;
|
||||||
gap: 14px;
|
gap: 9px;
|
||||||
max-height: calc(100vh - 126px);
|
max-height: calc(100vh - 126px);
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
@ -355,11 +355,11 @@ input:focus-visible {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.story-exp-left {
|
.story-exp-left {
|
||||||
grid-template-rows: auto auto minmax(150px, 1fr) minmax(120px, 0.72fr);
|
grid-template-rows: auto auto minmax(170px, 1fr) minmax(96px, 0.56fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
.story-exp-right {
|
.story-exp-right {
|
||||||
grid-template-rows: minmax(190px, 1.2fr) minmax(150px, 0.95fr) auto;
|
grid-template-rows: minmax(218px, 1.35fr) minmax(185px, 1.08fr) auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.story-exp-left,
|
.story-exp-left,
|
||||||
@ -381,7 +381,7 @@ input:focus-visible {
|
|||||||
|
|
||||||
.story-exp-progress,
|
.story-exp-progress,
|
||||||
.story-exp-panel {
|
.story-exp-panel {
|
||||||
padding: 11px;
|
padding: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.story-exp-panel {
|
.story-exp-panel {
|
||||||
@ -504,8 +504,8 @@ input:focus-visible {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.story-exp-panel h2 {
|
.story-exp-panel h2 {
|
||||||
margin: 0 0 7px;
|
margin: 0 0 5px;
|
||||||
font-size: 0.88rem;
|
font-size: 0.82rem;
|
||||||
letter-spacing: 0.01em;
|
letter-spacing: 0.01em;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -524,8 +524,8 @@ input:focus-visible {
|
|||||||
.story-exp-feed li,
|
.story-exp-feed li,
|
||||||
.story-exp-insight {
|
.story-exp-insight {
|
||||||
border: 1px solid rgba(255, 255, 255, 0.065);
|
border: 1px solid rgba(255, 255, 255, 0.065);
|
||||||
border-radius: 9px;
|
border-radius: 7px;
|
||||||
padding: 7px 8px;
|
padding: 6px 7px;
|
||||||
background: rgba(255, 255, 255, 0.035);
|
background: rgba(255, 255, 255, 0.035);
|
||||||
color: var(--story-soft);
|
color: var(--story-soft);
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@ -1186,7 +1186,7 @@ input:focus-visible {
|
|||||||
|
|
||||||
.story-exp-insight {
|
.story-exp-insight {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 2px;
|
gap: 1px;
|
||||||
border-color: rgba(255, 255, 255, 0.055);
|
border-color: rgba(255, 255, 255, 0.055);
|
||||||
background:
|
background:
|
||||||
linear-gradient(180deg, rgba(255, 255, 255, 0.038), rgba(255, 255, 255, 0.018));
|
linear-gradient(180deg, rgba(255, 255, 255, 0.038), rgba(255, 255, 255, 0.018));
|
||||||
@ -1200,25 +1200,18 @@ input:focus-visible {
|
|||||||
padding: 6px 7px;
|
padding: 6px 7px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.story-exp-panel--compact .story-exp-insight p {
|
.story-exp-insight p {
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
line-height: 1.16;
|
line-height: 1.16;
|
||||||
-webkit-box-orient: vertical;
|
-webkit-box-orient: vertical;
|
||||||
-webkit-line-clamp: 2;
|
-webkit-line-clamp: 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.story-exp-more {
|
.story-exp-panel--compact .story-exp-insight p {
|
||||||
border: 1px solid rgba(151, 185, 218, 0.12);
|
-webkit-line-clamp: 2;
|
||||||
border-radius: 8px;
|
|
||||||
padding: 5px 7px;
|
|
||||||
background: rgba(255, 255, 255, 0.035);
|
|
||||||
color: var(--story-soft);
|
|
||||||
font-size: 0.72rem;
|
|
||||||
font-weight: 800;
|
|
||||||
text-align: left;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.story-exp-list li,
|
.story-exp-list li,
|
||||||
@ -1228,7 +1221,7 @@ input:focus-visible {
|
|||||||
|
|
||||||
.story-exp-insight strong {
|
.story-exp-insight strong {
|
||||||
color: var(--story-text);
|
color: var(--story-text);
|
||||||
font-size: 0.82rem;
|
font-size: 0.76rem;
|
||||||
line-height: 1.12;
|
line-height: 1.12;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -414,15 +414,12 @@
|
|||||||
detail: relationship.state,
|
detail: relationship.state,
|
||||||
tone: relationship.weight >= 90 ? "confirmed" : relationship.weight >= 70 ? "strengthening" : "emerging"
|
tone: relationship.weight >= 90 ? "confirmed" : relationship.weight >= 70 ? "strengthening" : "emerging"
|
||||||
}));
|
}));
|
||||||
renderInsights(dom.relationships, relationships.slice(0, 2));
|
renderInsights(dom.relationships, relationships.slice(0, 1));
|
||||||
renderOverflowSummary(dom.relationships, relationships.length - 2, "relationship");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateKnowledgeThreads(scene) {
|
function updateKnowledgeThreads(scene) {
|
||||||
renderInsights(dom.knowledge, (scene.knowledgeThreads || []).slice(0, 3));
|
renderInsights(dom.knowledge, (scene.knowledgeThreads || []).slice(0, 5));
|
||||||
renderOverflowSummary(dom.knowledge, (scene.knowledgeThreads || []).length - 3, "thread");
|
renderInsights(dom.observations, (scene.observations || []).slice(0, 4));
|
||||||
renderInsights(dom.observations, (scene.observations || []).slice(0, 2));
|
|
||||||
renderOverflowSummary(dom.observations, (scene.observations || []).length - 2, "observation");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateSceneRibbon(scene) {
|
function updateSceneRibbon(scene) {
|
||||||
@ -544,26 +541,6 @@
|
|||||||
if (dom.detailPanel) dom.detailPanel.hidden = true;
|
if (dom.detailPanel) dom.detailPanel.hidden = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderOverflowSummary(container, hiddenCount, noun) {
|
|
||||||
if (!container) return;
|
|
||||||
let node = container.querySelector("[data-insight-overflow]");
|
|
||||||
if (hiddenCount <= 0) {
|
|
||||||
node?.remove();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!node) {
|
|
||||||
node = document.createElement("button");
|
|
||||||
node.type = "button";
|
|
||||||
node.className = "story-exp-more";
|
|
||||||
node.dataset.insightOverflow = "true";
|
|
||||||
container.append(node);
|
|
||||||
}
|
|
||||||
|
|
||||||
node.textContent = `+${hiddenCount} more ${noun}${hiddenCount === 1 ? "" : "s"}`;
|
|
||||||
node.title = `${hiddenCount} additional ${noun}${hiddenCount === 1 ? "" : "s"} in this scene`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderList(container, items) {
|
function renderList(container, items) {
|
||||||
const activeIds = new Set(items.map((item, index) => feedId(item, index)));
|
const activeIds = new Set(items.map((item, index) => feedId(item, index)));
|
||||||
items.forEach((item, index) => {
|
items.forEach((item, index) => {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user