Compare commits

...

17 Commits

Author SHA1 Message Date
9bb738f07e Clarify story stage presence and portraits 2026-07-26 18:33:14 +00:00
35c56d2c2c Give story characters more stage space 2026-07-26 16:09:39 +00:00
d103953eb5 Recognise Sophie presentation evidence 2026-07-26 15:42:07 +00:00
b2b7fbc735 Strengthen pronoun presentation evidence 2026-07-26 15:38:31 +00:00
17144c64d3 Rebalance story stage after removing assets 2026-07-26 15:27:49 +00:00
588a223b44 Focus story visualisation on insights 2026-07-26 15:21:06 +00:00
049628b295 Improve road locations and age evidence 2026-07-26 15:10:13 +00:00
9db13a7fd6 Cap only active image demand jobs 2026-07-26 14:30:36 +00:00
72148565e2 Tighten story intelligence evidence visuals 2026-07-26 12:16:34 +00:00
d7fe153816 Add progressive evidence cards to Story Intelligence 2026-07-26 11:44:03 +00:00
f02569b7d4 Make Story Intelligence side panels fluid 2026-07-26 11:32:49 +00:00
24bafca765 Avoid retrying exhausted OpenAI quota 2026-07-25 21:01:25 +00:00
0f83f739c5 Add Story Intelligence worker pause switch 2026-07-25 20:56:52 +00:00
02bc4fd2de Strengthen story intelligence portrait context 2026-07-25 20:40:07 +00:00
61b4d9d288 Queue story memory illustration demands 2026-07-25 19:57:52 +00:00
8b2448ecd0 Reset story intelligence memory extraction 2026-07-25 19:29:36 +00:00
311fbcbbb0 Phase 21T - Forensic Character Understanding Audit 2026-07-24 20:12:50 +00:00
24 changed files with 2196 additions and 380 deletions

View File

@ -39,6 +39,7 @@ var tests = new (string Name, Action Test)[]
("Illustration starter generation plan skips successful items", IllustrationStarterGenerationPlanSkipsSuccessfulItems),
("Story Intelligence prototype uses starter illustration codes", StoryIntelligencePrototypeUsesStarterIllustrationCodes),
("Story Intelligence simulation mode remains available", StoryIntelligenceSimulationModeRemainsAvailable),
("Story Intelligence does not retry exhausted billing quota", StoryIntelligenceDoesNotRetryExhaustedBillingQuota),
("Story Intelligence visualisation contract omits raw manuscript text", StoryIntelligenceVisualisationContractOmitsRawManuscriptText),
("Illustration bulk retry result reports counts", IllustrationBulkRetryResultReportsCounts),
("Illustration bulk retry skips blocking duplicate stable codes", IllustrationBulkRetrySkipsBlockingDuplicateStableCodes),
@ -59,6 +60,9 @@ var tests = new (string Name, Action Test)[]
("Phase 21R replay uses persisted data only", Phase21RReplayUsesPersistedDataOnly),
("Phase 21R scene browser and panels are replay friendly", Phase21RSceneBrowserAndPanelsAreReplayFriendly),
("Phase 21S uses durable Story Memory assignments", Phase21SUsesDurableStoryMemoryAssignments),
("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),
("Scan review post supports full-book form submissions", ScanReviewPostSupportsFullBookFormSubmissions),
("Story Intelligence experience boot does not serialise live model", StoryIntelligenceExperienceBootDoesNotSerialiseLiveModel),
@ -556,7 +560,7 @@ static void StoryIntelligenceEvidenceExtractionPreventsObservedAttributeLeakage(
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(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("bottle") == "Bottle", "Bottle must not classify as LockedBox.");
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("scarves") == "Clothing", "Scarves must not classify as Car.");
@ -633,7 +637,7 @@ static void Phase21OPreservesAdultDefaultsAndRealModeContinuity()
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(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("SportsCar", "Hatchback"), "Vehicle subtypes should remain strict.");
@ -659,12 +663,12 @@ static void Phase21PUsesOneAuthoritativeCurrentVisualisationScene()
Assert(view.Contains("Scenes = Model.Scenes", StringComparison.Ordinal), "Initial real page payload must include the same scene collection as polled snapshots.");
}
static IllustrationCharacterMetadata Metadata(string code, string ageBand, string presentation, bool isUnknownFigure = false, string hairColour = "Unknown")
static IllustrationCharacterMetadata Metadata(string code, string ageBand, string presentation, bool isUnknownFigure = false, string hairColour = "Unknown", string skinTone = "Unknown")
=> new(
ageBand,
presentation,
hairColour,
"Unknown",
skinTone,
"Unknown",
"Unknown",
"Unknown",
@ -674,6 +678,16 @@ static IllustrationCharacterMetadata Metadata(string code, string ageBand, strin
IllustrationPromptBuilder.CurrentTemplateVersion,
"Test");
static StoryMemoryCharacterAttribute Attribute(string type, string value, decimal confidence, bool isExplicit, int sceneResultId)
=> new()
{
AttributeType = type,
NormalisedValue = value,
Confidence = confidence,
IsExplicit = isExplicit,
SourceSceneResultID = sceneResultId
};
static IReadOnlyList<string> Rejected(CharacterEvidenceProfile evidence, IllustrationCharacterMetadata metadata)
=> StoryIntelligenceIllustrationCompatibility.CharacterHardRejections(evidence, metadata, isNamedCharacter: true, alreadyAssignedToAnotherSignificantCharacter: false);
@ -685,6 +699,14 @@ static void StoryIntelligenceSimulationModeRemainsAvailable()
Assert(prototype.Scenes.Any(scene => scene.PovCharacterId == "rosie"), "Simulation should still exercise POV transitions.");
}
static void StoryIntelligenceDoesNotRetryExhaustedBillingQuota()
{
var client = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Services/StoryIntelligenceOpenAIInfrastructure.cs"));
Assert(client.Contains("IsInsufficientQuota(responseText)", StringComparison.Ordinal), "OpenAI insufficient_quota responses must be detected before retrying.");
Assert(client.Contains("request was not retried", StringComparison.Ordinal), "OpenAI insufficient_quota responses should fail clearly without retry churn.");
}
static void StoryIntelligenceVisualisationContractOmitsRawManuscriptText()
{
var model = new StoryIntelligenceExperiencePrototypeViewModel
@ -887,8 +909,8 @@ static void Phase21QRepairsProductionVisualisationRegressions()
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(StoryIntelligenceIllustrationCompatibility.LocationType("Cock Hill Lane") == "Road", "Cock Hill Lane should classify as Road/Lane.");
Assert(StoryIntelligenceIllustrationCompatibility.LocationType("derelict hospital waiting room") == "Hospital", "Derelict hospital should remain Hospital rather than generic interior.");
Assert(StoryIntelligenceIllustrationCompatibility.LocationType("Cock Hill Lane") == "Lane", "Cock Hill Lane should classify as Lane.");
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.LocationCompatible("Road", "CarPark"), "Lane/Road must not use car park artwork.");
Assert(!StoryIntelligenceIllustrationCompatibility.LocationCompatible("WaitingRoom", "FlatInterior"), "Waiting room must not use flat interior artwork.");
@ -919,8 +941,8 @@ static void Phase21QViewKeepsPanelsBoundedAndRealControlsHidden()
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.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("renderOverflowSummary", StringComparison.Ordinal), "Relationship overflow should be indicated compactly.");
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), "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("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.");
@ -970,6 +992,180 @@ static void Phase21SUsesDurableStoryMemoryAssignments()
Assert(development.Contains("RebuildImportSessionAsync", StringComparison.Ordinal), "Development rebuild action must call the durable memory rebuild.");
}
static void Phase21TRepairsDurableCharacterUnderstanding()
{
var maggie = StoryMemoryService.ResolveCharacterEvidenceFromAttributes(
"Maggie",
[
Attribute("AgeBand", "Adult", 0.88m, false, 10),
Attribute("AgeBand", "OlderTeen", 0.98m, true, 20),
Attribute("Presentation", "Feminine", 0.98m, true, 1),
Attribute("HairColour", "Red", 0.82m, true, 20),
Attribute("HairLength", "Long", 0.72m, true, 20)
]);
Assert(maggie.AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.OlderTeen, $"Explicit later teen evidence should win, got {maggie.AgeBand}.");
Assert(maggie.HairColour == StoryIntelligenceIllustrationCompatibility.HairColours.Red, $"Durable red-hair evidence should reach resolver, got {maggie.HairColour}.");
Assert(maggie.HairLength == "Long", $"Durable hair length should reach resolver, got {maggie.HairLength}.");
var unknown = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Sarah", ["Sarah watches."], []);
var childCandidate = Metadata("char-demand-child-feminine", "Child", "Feminine");
Assert(Rejected(unknown, childCandidate).Any(reason => reason.Contains("unknown age", StringComparison.OrdinalIgnoreCase)), "Unknown named characters must reject child portraits.");
var adult = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Mrs Patterson", ["Mrs Patterson is a teacher."], []);
var teenCandidate = Metadata("char-demand-olderteen-feminine", "OlderTeen", "Feminine");
Assert(Rejected(adult, teenCandidate).Any(reason => reason.Contains("age hard mismatch", StringComparison.OrdinalIgnoreCase)), "Adult title/occupation evidence must reject teen portraits.");
var olderTeen = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Colin", ["Colin is a learner driver at the driving test centre."], []);
var childMasculineCandidate = Metadata("char-demand-child-masculine", "Child", "Masculine");
Assert(Rejected(olderTeen, childMasculineCandidate).Any(reason => reason.Contains("age hard mismatch", StringComparison.OrdinalIgnoreCase)), "Older teen evidence must reject child portraits.");
Assert(StoryIntelligenceIllustrationCompatibility.IsGroupEntity("Group of girls"), "Group of girls should not reserve a unique character portrait.");
Assert(StoryIntelligenceIllustrationCompatibility.IsGroupEntity("pair of girls"), "Pair of girls should not reserve a unique character portrait.");
var service = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Services/StoryMemoryServices.cs"));
var snapshot = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Services/StoryIntelligenceVisualisationSnapshotService.cs"));
Assert(service.Contains("BuildCrossSceneIdentityAliases", StringComparison.Ordinal), "Rebuild should repair cross-scene descriptive-to-named character identities from saved analysis.");
Assert(service.Contains("ExtensionEvidence(sceneCharacter.ExtensionData)", StringComparison.Ordinal), "Saved scene appearance extension data must be included in durable evidence.");
Assert(snapshot.Contains("NonPovRole(memoryCharacter.Role)", StringComparison.Ordinal), "Carried memory characters must not keep POV role outside the resolved current POV.");
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(!CharacterAppearanceExtractionService.Extract("Beth went next door and mumbled, 'Mum.'", ["Beth", "Mum"]).Any(fact => fact.CanonicalName == "Beth" && fact.AttributeType == "AgeBand" && fact.NormalisedValue == StoryIntelligenceIllustrationCompatibility.AgeBands.MatureAdult), "Nearby Mum text must not age Beth into a mature adult.");
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}.");
var maggieMemory = StoryMemoryService.ResolveCharacterEvidenceFromAttributes("Maggie", [
new StoryMemoryCharacterAttribute { AttributeType = "HairColour", NormalisedValue = StoryIntelligenceIllustrationCompatibility.HairColours.Red, Confidence = 0.95m, IsExplicit = true }
]);
Assert(maggieMemory.SkinTone == StoryIntelligenceIllustrationCompatibility.SkinTones.Light, $"Natural red hair should default unknown skin tone to Light, got {maggieMemory.SkinTone}.");
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 redHairedMaggie = StoryMemoryService.ResolveCharacterEvidenceFromAttributes("Maggie", [
new StoryMemoryCharacterAttribute { AttributeType = "HairColour", NormalisedValue = StoryIntelligenceIllustrationCompatibility.HairColours.Red, Confidence = 0.95m, IsExplicit = true }
]);
var bethFromLeakyEvidence = StoryMemoryService.ResolveCharacterEvidenceFromAttributes("Beth", [
new StoryMemoryCharacterAttribute { AttributeType = "AgeBand", NormalisedValue = StoryIntelligenceIllustrationCompatibility.AgeBands.MatureAdult, Confidence = 0.9m, IsExplicit = true, EvidenceSummary = "Beth goes next door and mumbles 'Mum'" },
new StoryMemoryCharacterAttribute { AttributeType = "AgeBand", NormalisedValue = StoryIntelligenceIllustrationCompatibility.AgeBands.YoungAdult, Confidence = 0.9m, IsExplicit = true, EvidenceSummary = "No fifteen-year-old girl with a future." },
new StoryMemoryCharacterAttribute { AttributeType = "Presentation", NormalisedValue = StoryIntelligenceIllustrationCompatibility.Presentations.Masculine, Confidence = 0.95m, IsExplicit = true, EvidenceSummary = "I checked my look in the mirror." }
]);
var mrsWithTeenContext = StoryMemoryService.ApplyImportContextToEvidence(
StoryMemoryService.ResolveCharacterEvidenceFromAttributes("Mrs Patterson", [
new StoryMemoryCharacterAttribute { AttributeType = "AgeBand", NormalisedValue = StoryIntelligenceIllustrationCompatibility.AgeBands.Adult, Confidence = 0.88m, IsExplicit = true }
]),
"Mrs Patterson",
new StoryMemoryImportContext(StoryMemoryAppearancePreferences.PredominantlyLight, "OlderTeen", "1980s", "UK"));
var rosieBirthday = StoryMemoryService.ResolveCharacterEvidenceFromAttributes("Rosie", [
new StoryMemoryCharacterAttribute { AttributeType = "AgeBand", NormalisedValue = StoryIntelligenceIllustrationCompatibility.AgeBands.Child, Confidence = 0.98m, IsExplicit = true, EvidenceSummary = "Deterministic evidence inference" },
new StoryMemoryCharacterAttribute { AttributeType = "AgeBand", NormalisedValue = StoryIntelligenceIllustrationCompatibility.AgeBands.YoungAdult, Confidence = 0.90m, IsExplicit = true, EvidenceSummary = "She may be eighteen now, but she is still the baby of the group." }
]);
var alisonPronoun = StoryIntelligenceIllustrationCompatibility.NormalizePresentation("She was a nurse at Selly Oak Hospital.");
var alisonName = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Alison", ["Alison"], []);
var sophie = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Sophie", ["Sophie glared at me. She called the pub hers."], ["Sophie is Rebecca's sister."]);
var gregFromRosieAction = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Greg", ["Greg offers to take Rosie to hospital."], []);
var childCandidate = Metadata("char-child", "Child", "Feminine");
var darkRedCandidate = Metadata("char-red-dark", "OlderTeen", "Feminine", hairColour: "Red", skinTone: "Dark");
Assert(bethFromLeakyEvidence.AgeBand != StoryIntelligenceIllustrationCompatibility.AgeBands.MatureAdult, $"Leaked family-role evidence should not age Beth up, got {bethFromLeakyEvidence.AgeBand}.");
Assert(bethFromLeakyEvidence.Presentation == StoryIntelligenceIllustrationCompatibility.Presentations.Feminine, $"Name/title guard should repair leaked Beth presentation, got {bethFromLeakyEvidence.Presentation}.");
Assert(mrsWithTeenContext.AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.Adult, $"Adult-role titles must not be overridden by primary age context, got {mrsWithTeenContext.AgeBand}.");
Assert(rosieBirthday.AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.YoungAdult, $"Rosie's explicit eighteen-year-old evidence must beat weak child leakage, got {rosieBirthday.AgeBand}.");
Assert(alisonPronoun == StoryIntelligenceIllustrationCompatibility.Presentations.Feminine, $"Direct she/her pronoun evidence should infer Feminine, got {alisonPronoun}.");
Assert(alisonName.Presentation == StoryIntelligenceIllustrationCompatibility.Presentations.Feminine, $"Common feminine name Alison should infer Feminine, got {alisonName.Presentation}.");
Assert(sophie.Presentation == StoryIntelligenceIllustrationCompatibility.Presentations.Feminine, $"Sophie/she/sister evidence should infer Feminine, got {sophie.Presentation}.");
Assert(gregFromRosieAction.Presentation != StoryIntelligenceIllustrationCompatibility.Presentations.Feminine, "Greg must not inherit feminine presentation from action text mentioning Rosie.");
Assert(Rejected(mum, childCandidate).Any(reason => reason.Contains("age hard mismatch", StringComparison.OrdinalIgnoreCase)), "Parent role should reject child portraits.");
Assert(Rejected(redHairedMaggie, darkRedCandidate).Any(reason => reason.Contains("skin tone hard mismatch", StringComparison.OrdinalIgnoreCase)), "Red-haired light-skin evidence must reject dark-skin generated 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.LocationCompatible("Street", "Road"), "Side streets should be able to reuse road artwork while demand-specific street art is pending.");
Assert(StoryIntelligenceIllustrationCompatibility.LocationCompatible("Road", "Street"), "Named roads should be able to reuse street artwork when it is the closest generated road-family image.");
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 persistedRunner = File.ReadAllText(Path.Combine(root, "Services/PersistedStoryIntelligenceRunner.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(onboarding.Contains("name=\"primaryAgeGroup\"", StringComparison.Ordinal), "Import setup must ask for primary age group.");
Assert(onboarding.Contains("name=\"storyEra\"", StringComparison.Ordinal), "Import setup must ask for era.");
Assert(onboarding.Contains("name=\"storyLocation\"", StringComparison.Ordinal), "Import setup must ask for location.");
Assert(onboarding.Contains("<select class=\"form-control\" name=\"appearancePreference\"", StringComparison.Ordinal), "Appearance guidance must be a dropdown, not a radio list.");
Assert(onboarding.Contains("<select class=\"form-control\" name=\"primaryAgeGroup\"", StringComparison.Ordinal), "Primary age group must be a dropdown.");
Assert(onboarding.Contains("<select class=\"form-control\" name=\"storyEra\"", StringComparison.Ordinal), "Story era must be a dropdown.");
Assert(onboarding.Contains("<select class=\"form-control\" name=\"storyLocation\"", StringComparison.Ordinal), "Story location must be a dropdown.");
Assert(storyMemory.Contains("CharacterAppearanceExtractionService.Extract", StringComparison.Ordinal), "Durable Story Memory must consume dedicated appearance extraction.");
Assert(storyMemory.Contains("SkinToneInstruction(skinTone)", StringComparison.Ordinal), "Demand generation must use hard skin-tone wording.");
Assert(storyMemory.Contains("HairColours.Red", StringComparison.Ordinal) && storyMemory.Contains("SkinTones.Light", StringComparison.Ordinal), "Red-hair demand evidence must force light skin when skin is otherwise unknown.");
Assert(storyMemory.Contains("ApplyPrimaryAgeContext", StringComparison.Ordinal), "Primary age-group context must guide generic adult/unknown character ages.");
Assert(persistedRunner.Contains("QueueDemand = true", StringComparison.Ordinal), "Live imports must queue missing illustration demand, not only record it.");
Assert(persistedRunner.Contains("MaxDemandToQueue = 24", StringComparison.Ordinal), "Live imports must cap automatic demand generation.");
Assert(storyMemory.Contains("UpsertPlannedAsync(specification", StringComparison.Ordinal), "Durable Story Memory demand must create reusable illustration library items.");
Assert(storyMemory.Contains("QueueAsync(planned.IllustrationLibraryItemID", StringComparison.Ordinal), "Durable Story Memory demand must queue generated library items for the worker.");
Assert(storyMemory.Contains("demand.IllustrationLibraryItemId", StringComparison.Ordinal), "Demand fallback assignments must retain queued item IDs so generated images can attach later.");
Assert(storyMemory.Contains("var categoryCap = maxDemandToQueue;", StringComparison.Ordinal), "Automatic demand generation should cap in-flight work per category, not starve locations and assets behind a divided cap.");
Assert(storyMemory.Contains("or IllustrationLibraryStatuses.Generating);", StringComparison.Ordinal), "Demand generation cap must count only in-flight jobs, not already generated preserved images.");
Assert(!storyMemory.Contains("or IllustrationLibraryStatuses.Generated\r\n or IllustrationLibraryStatuses.Approved", StringComparison.Ordinal), "Generated or approved preserved images must not permanently fill the demand queue cap.");
Assert(storyMemory.Contains("\"Flyover\"", StringComparison.Ordinal) && storyMemory.Contains("concrete overpass", StringComparison.Ordinal), "Location prompts must distinguish road-under-flyover scenes from generic roads.");
Assert(storyMemory.Contains("semanticType is \"Road\" or \"Lane\" or \"Street\"", StringComparison.Ordinal), "Road-family locations must be treated as exterior scenes.");
Assert(storyMemory.Contains("evidence.HairColour", StringComparison.Ordinal), "Character demand keys/specs must include durable hair colour evidence.");
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.");
Assert(File.Exists(Path.Combine(root, "Sql/144_Phase21V_StoryMemoryImportContext.sql")), "Import context SQL migration is missing.");
}
static void Phase21RSceneBrowserAndPanelsAreReplayFriendly()
{
var view = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Views/Development/StoryIntelligenceExperience.cshtml"));
@ -987,6 +1183,21 @@ static void Phase21RSceneBrowserAndPanelsAreReplayFriendly()
Assert(script.Contains("if (!realMode || !snapshotUrl || state.terminal) return;", StringComparison.Ordinal), "Replay must not poll live snapshot endpoints.");
Assert(css.Contains(".story-exp-replay-browser", StringComparison.Ordinal), "Replay browser styling was not found.");
Assert(css.Contains("scrollbar-width: none", StringComparison.Ordinal), "Side-panel native scrollbar tracks should be suppressed.");
Assert(css.Contains(".story-exp-rebuild {\n position: fixed;", StringComparison.Ordinal), "Replay rebuild controls should be fixed so they do not resize the whole screen.");
Assert(css.Contains(".story-exp-panel:has(.story-exp-insight-list:empty)", StringComparison.Ordinal), "Empty side panels should collapse instead of reserving large blank space.");
Assert(css.Contains("grid-template-columns: minmax(180px, 0.42fr) minmax(660px, 1.55fr) minmax(380px, 0.84fr);", StringComparison.Ordinal), "Right insight rail should be widened now that assets are not stage nodes.");
Assert(css.Contains("--story-side-body-font-size: 0.72rem;", StringComparison.Ordinal), "Left and right side panels should share body text sizing.");
Assert(!view.Contains("data-assets", StringComparison.Ordinal), "Asset visualisation lane should not be rendered on the main stage.");
Assert(script.Contains("Assets are recorded in insights/diagnostics but not rendered as stage nodes.", StringComparison.Ordinal), "Asset data should remain available without drawing asset stage nodes.");
Assert(script.Contains("width * 0.84 - size / 2", StringComparison.Ordinal), "Location should be a compact right-side anchor once asset nodes are removed.");
Assert(script.Contains("{ x: 0.60, y: 0.66, role: \"context\" }", StringComparison.Ordinal), "Character layout should use more of the stage width.");
Assert(script.Contains("presence-cluster", StringComparison.Ordinal), "Presence should be shown as a single location callout instead of many confusing location lines.");
Assert(css.Contains(".story-exp-zone--location .story-exp-zone-label {\n left: 80%;", StringComparison.Ordinal), "Location lane label should follow the compact right-side anchor.");
Assert(!css.Contains("grid-template-rows: minmax(218px, 1.35fr) minmax(185px, 1.08fr) auto", StringComparison.Ordinal), "Right rail must not use fixed-height panel tracks.");
Assert(!css.Contains("grid-template-rows: auto auto minmax(170px, 1fr) minmax(96px, 0.56fr)", StringComparison.Ordinal), "Left rail must not use fixed-height panel tracks.");
Assert(script.Contains("shouldUseCharacterPortrait", StringComparison.Ordinal), "Characters should use evidence cards until portrait evidence is strong enough.");
Assert(script.Contains("shouldUseAssetImage", StringComparison.Ordinal), "Assets should use text cards until an object-specific image is confident enough.");
Assert(script.Contains("appearance pending", StringComparison.Ordinal), "Character evidence cards should expose unresolved appearance state.");
Assert(!css.Contains(".story-exp-insight-list {\n display: grid;\n gap: 9px;\n min-height: 0;\n overflow: auto", StringComparison.Ordinal), "Nested insight lists should not create native scrollbars.");
Assert(progressViewModel.Contains("public int? ImportSessionID", StringComparison.Ordinal), "Import progress page should carry the import session for replay links.");
}
@ -1004,11 +1215,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(".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("Queens Court") == "Road", "Court 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.LocationCompatible("VehicleInterior", "HouseExterior"), "Vehicle interiors must not resolve to houses or buildings.");
@ -1019,7 +1230,7 @@ static void StoryIntelligenceVisualPolishKeepsLabelsFixedAndSemanticsSpecific()
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("wall clock") == "Clock", "Clock should classify as Clock.");
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("park bench") == "Bench", "Bench should classify as Bench.");
Assert(snapshot.Contains("\"loc-generic-road\" => $\"{FallbackRoot}/location-road.svg\"", StringComparison.Ordinal), "Roads need a generic road fallback illustration.");
Assert(snapshot.Contains("type is \"Road\" or \"Lane\" or \"Street\"", StringComparison.Ordinal), "Road-family scene locations need a generic road fallback illustration.");
Assert(snapshot.Contains("\"loc-vehicle-interior\" => $\"{FallbackRoot}/location-vehicle-interior.svg\"", StringComparison.Ordinal), "Vehicle interiors need a non-building fallback illustration.");
Assert(snapshot.Contains("\"asset-document\" => $\"{FallbackRoot}/asset-document.svg\"", StringComparison.Ordinal), "Documents need a recognisable document fallback.");
Assert(snapshot.Contains("\"asset-traffic-sign\" => $\"{FallbackRoot}/asset-traffic-sign.svg\"", StringComparison.Ordinal), "Traffic signs need a recognisable fallback.");

View File

@ -86,9 +86,13 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
[HttpPost("story-intelligence/start")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> StartStoryIntelligence()
public async Task<IActionResult> StartStoryIntelligence(
[FromForm] string? appearancePreference,
[FromForm] string? primaryAgeGroup,
[FromForm] string? storyEra,
[FromForm] string? storyLocation)
{
var job = await storyIntelligence.StartAsync();
var job = await storyIntelligence.StartAsync(appearancePreference, primaryAgeGroup, storyEra, storyLocation);
if (job is null)
{
TempData["OnboardingStoryIntelligenceError"] = "Review the manuscript scan before starting Story Intelligence.";

View File

@ -24,7 +24,9 @@ public interface IStoryMemoryRepository
Task InvalidateAssignmentAsync(int assignmentId, string reason);
Task<StoryMemoryIllustrationDemand> UpsertDemandAsync(StoryMemoryIllustrationDemandSave request);
Task<string> GetAppearancePreferenceAsync(int importSessionId);
Task<StoryMemoryImportContext> GetImportContextAsync(int importSessionId);
Task SetAppearancePreferenceAsync(int importSessionId, string preference);
Task SetImportContextAsync(int importSessionId, string appearancePreference, string? primaryAgeGroup, string? storyEra, string? storyLocation);
Task<StoryMemoryDiagnostics> GetDiagnosticsAsync(int importSessionId);
}
@ -99,8 +101,8 @@ public sealed class StoryMemoryRepository(ISqlConnectionFactory connectionFactor
WHEN MATCHED THEN
UPDATE SET
DisplayName = CASE WHEN LEN(@DisplayName) > LEN(target.DisplayName) THEN @DisplayName ELSE target.DisplayName END,
EntityContext = @EntityContext,
SourceSectionType = @SourceSectionType,
EntityContext = CASE WHEN target.EntityContext = N'Story' OR @EntityContext = N'Story' THEN N'Story' ELSE @EntityContext END,
SourceSectionType = CASE WHEN target.SourceSectionType = N'Story' OR @SourceSectionType = N'Story' THEN N'Story' ELSE @SourceSectionType END,
IsNamed = CASE WHEN @IsNamed = 1 THEN 1 ELSE target.IsNamed END,
IsGroupEntity = CASE WHEN @IsGroupEntity = 1 THEN 1 ELSE target.IsGroupEntity END,
IsResolved = CASE WHEN @IsResolved = 1 THEN 1 ELSE target.IsResolved END,
@ -362,29 +364,75 @@ public sealed class StoryMemoryRepository(ISqlConnectionFactory connectionFactor
}
public async Task<string> GetAppearancePreferenceAsync(int importSessionId)
=> (await GetImportContextAsync(importSessionId)).AppearancePreference;
public async Task<StoryMemoryImportContext> GetImportContextAsync(int importSessionId)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleOrDefaultAsync<string>(
"SELECT AppearancePreference FROM dbo.StoryMemoryImportPreferences WHERE ImportSessionID = @ImportSessionID;",
await EnsureImportPreferenceContextColumnsAsync(connection);
return await connection.QuerySingleOrDefaultAsync<StoryMemoryImportContext>(
"""
SELECT
AppearancePreference,
COALESCE(PrimaryAgeGroup, N'') AS PrimaryAgeGroup,
COALESCE(StoryEra, N'') AS StoryEra,
COALESCE(StoryLocation, N'') AS StoryLocation
FROM dbo.StoryMemoryImportPreferences
WHERE ImportSessionID = @ImportSessionID;
""",
new { ImportSessionID = importSessionId })
?? StoryMemoryAppearancePreferences.Balanced;
?? new StoryMemoryImportContext(StoryMemoryAppearancePreferences.Balanced, string.Empty, string.Empty, string.Empty);
}
public async Task SetAppearancePreferenceAsync(int importSessionId, string preference)
=> await SetImportContextAsync(importSessionId, preference, null, null, null);
public async Task SetImportContextAsync(int importSessionId, string appearancePreference, string? primaryAgeGroup, string? storyEra, string? storyLocation)
{
using var connection = connectionFactory.CreateConnection();
await EnsureImportPreferenceContextColumnsAsync(connection);
await connection.ExecuteAsync(
"""
MERGE dbo.StoryMemoryImportPreferences WITH (HOLDLOCK) AS target
USING (SELECT @ImportSessionID AS ImportSessionID) AS source
ON target.ImportSessionID = source.ImportSessionID
WHEN MATCHED THEN
UPDATE SET AppearancePreference = @AppearancePreference, UpdatedUtc = SYSUTCDATETIME()
UPDATE SET AppearancePreference = @AppearancePreference,
PrimaryAgeGroup = COALESCE(@PrimaryAgeGroup, PrimaryAgeGroup),
StoryEra = COALESCE(@StoryEra, StoryEra),
StoryLocation = COALESCE(@StoryLocation, StoryLocation),
UpdatedUtc = SYSUTCDATETIME()
WHEN NOT MATCHED THEN
INSERT (ImportSessionID, AppearancePreference)
VALUES (@ImportSessionID, @AppearancePreference);
INSERT (ImportSessionID, AppearancePreference, PrimaryAgeGroup, StoryEra, StoryLocation)
VALUES (@ImportSessionID, @AppearancePreference, COALESCE(@PrimaryAgeGroup, N''), COALESCE(@StoryEra, N''), COALESCE(@StoryLocation, N''));
""",
new { ImportSessionID = importSessionId, AppearancePreference = preference });
new
{
ImportSessionID = importSessionId,
AppearancePreference = appearancePreference,
PrimaryAgeGroup = TrimContext(primaryAgeGroup, 120),
StoryEra = TrimContext(storyEra, 120),
StoryLocation = TrimContext(storyLocation, 160)
});
}
private static async Task EnsureImportPreferenceContextColumnsAsync(System.Data.IDbConnection connection)
{
await connection.ExecuteAsync(
"""
IF COL_LENGTH(N'dbo.StoryMemoryImportPreferences', N'PrimaryAgeGroup') IS NULL
ALTER TABLE dbo.StoryMemoryImportPreferences ADD PrimaryAgeGroup nvarchar(120) NULL;
IF COL_LENGTH(N'dbo.StoryMemoryImportPreferences', N'StoryEra') IS NULL
ALTER TABLE dbo.StoryMemoryImportPreferences ADD StoryEra nvarchar(120) NULL;
IF COL_LENGTH(N'dbo.StoryMemoryImportPreferences', N'StoryLocation') IS NULL
ALTER TABLE dbo.StoryMemoryImportPreferences ADD StoryLocation nvarchar(160) NULL;
""");
}
private static string? TrimContext(string? value, int maxLength)
{
var clean = value?.Trim();
return string.IsNullOrWhiteSpace(clean) ? null : clean[..Math.Min(clean.Length, maxLength)];
}
public async Task<StoryMemoryDiagnostics> GetDiagnosticsAsync(int importSessionId)

View File

@ -50,7 +50,8 @@ It should not:
7. Treat assets as meaningful story objects, not ordinary props.
8. Treat relationships as scene-local signals, not final records.
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
@ -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.
## 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
Named locations:
@ -543,6 +564,30 @@ Return exactly one JSON object matching this shape:
"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": [
{
"name": "",

View File

@ -12,6 +12,7 @@ public sealed class SceneIntelligenceScene
public SceneIntelligencePointOfView? PointOfView { get; init; }
public SceneIntelligenceSetting? Setting { get; init; }
public List<SceneIntelligenceCharacter>? Characters { get; init; }
public List<SceneIntelligenceCharacterAppearance>? CharacterAppearance { get; init; }
public List<SceneIntelligenceLocation>? Locations { get; init; }
public List<SceneIntelligenceAsset>? Assets { get; init; }
public List<SceneIntelligenceRelationship>? Relationships { get; init; }
@ -99,6 +100,33 @@ public sealed class SceneIntelligenceCharacter
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 string? Name { get; init; }

View File

@ -214,18 +214,30 @@ public sealed class StoryMemoryRebuildResult
public IReadOnlyList<string> Warnings { get; init; } = [];
}
public sealed class StoryMemoryRebuildOptions
public sealed record StoryMemoryRebuildOptions
{
public bool DryRun { get; init; }
public bool RebuildAll { get; init; }
public bool QueueDemand { get; init; }
public int? MaxDemandToQueue { get; init; }
public string AppearancePreference { get; init; } = StoryMemoryAppearancePreferences.Balanced;
public string PrimaryAgeGroup { get; init; } = string.Empty;
public string StoryEra { get; init; } = string.Empty;
public string StoryLocation { get; init; } = string.Empty;
}
public sealed class StoryMemoryResolveOptions
public sealed record StoryMemoryResolveOptions
{
public bool QueueDemand { get; init; }
public int? MaxDemandToQueue { get; init; }
public string AppearancePreference { get; init; } = StoryMemoryAppearancePreferences.Balanced;
public string PrimaryAgeGroup { get; init; } = string.Empty;
public string StoryEra { get; init; } = string.Empty;
public string StoryLocation { get; init; } = string.Empty;
}
public sealed record StoryMemoryImportContext(
string AppearancePreference,
string PrimaryAgeGroup,
string StoryEra,
string StoryLocation);

View File

@ -245,9 +245,15 @@ public class Program
builder.Services.AddScoped<ICurrentUserService, CurrentUserService>();
builder.Services.AddScoped<IFeatureRequestService, FeatureRequestService>();
builder.Services.AddHostedService<EmailQueueWorker>();
builder.Services.AddHostedService<StoryIntelligenceWorker>();
builder.Services.AddHostedService<PersistedStoryIntelligenceWorker>();
builder.Services.AddHostedService<IllustrationGenerationWorker>();
if (builder.Configuration.GetValue("StoryIntelligence:WorkersEnabled", true))
{
builder.Services.AddHostedService<StoryIntelligenceWorker>();
builder.Services.AddHostedService<PersistedStoryIntelligenceWorker>();
}
if (builder.Configuration.GetValue("Illustrations:GenerationWorkerEnabled", true))
{
builder.Services.AddHostedService<IllustrationGenerationWorker>();
}
var app = builder.Build();

View File

@ -0,0 +1,191 @@
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();
if (LooksLikeLeakedRelationshipAge(rawName, match.Groups["age"].Value))
{
continue;
}
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 bool LooksLikeLeakedRelationshipAge(string rawName, string rawAge)
{
var name = Normalise(rawName);
var age = Normalise(rawAge);
var relationshipAge = age is "mother" or "mum" or "father" or "dad" or "aunt" or "uncle";
if (!relationshipAge)
{
return false;
}
return name is not ("mum" or "mother" or "dad" or "father" or "aunt" or "uncle")
&& !name.StartsWith("mrs ", StringComparison.OrdinalIgnoreCase)
&& !name.StartsWith("miss ", StringComparison.OrdinalIgnoreCase)
&& !name.StartsWith("ms ", StringComparison.OrdinalIgnoreCase)
&& !name.StartsWith("mr ", StringComparison.OrdinalIgnoreCase);
}
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));
}
}

View File

@ -81,6 +81,8 @@ public sealed class IllustrationPromptBuilder : IIllustrationPromptBuilder
- subtle depth of field, clean modern rendering, and enough exposure to remain clear inside PlotDirector's dark navy interface
- no visible text, captions, logos, watermarks, UI, signatures, or typography
- no anime, cartoon, Pixar-like, caricature, celebrity likeness, or photoreal identity claim
- for character images, the specified hair colour, hair length, apparent age, era, location context and skin tone are hard requirements; make hair colour plainly visible at thumbnail size and do not reinterpret or diversify away from it
- for character images, vary face shape, nose, eyes, mouth, jaw, expression, hairstyle silhouette, and posture so reusable portraits do not look like siblings unless the specification explicitly asks for family resemblance
- no bright flat studio backdrop, no plain colour-block background, no stock-photo lighting, and no cartoon simplicity
- avoid private manuscript details, real names, excerpts, or recognisable copyrighted characters
- avoid muddy olive and brown grading, heavy sepia tones, underexposure, excessive darkness, low-contrast lighting, flat colour, antique oil-painting texture, distressed canvas texture, rough brushwork, overly sombre default expressions, backgrounds that merge into the subject, and monochrome or near-monochrome results
@ -115,7 +117,7 @@ public sealed class IllustrationPromptBuilder : IIllustrationPromptBuilder
$"apparent age band {specification.ApparentAgeBand}",
$"presentation {specification.Presentation}",
$"hair {specification.HairLength} {specification.HairColour}",
$"skin tone range {specification.SkinTone}",
$"required skin tone {specification.SkinTone}",
$"features {string.Join(", ", specification.Features)}",
$"clothing {specification.ClothingEra} {specification.ClothingStyle}"
}.Where(item => !item.EndsWith(" ", StringComparison.Ordinal))),

View File

@ -9,7 +9,7 @@ namespace PlotLine.Services;
public interface IOnboardingStoryIntelligenceService
{
Task<StoryIntelligenceOverviewViewModel> GetOverviewAsync();
Task<OnboardingStoryIntelligenceBatch?> StartAsync();
Task<OnboardingStoryIntelligenceBatch?> StartAsync(string? appearancePreference = null, string? primaryAgeGroup = null, string? storyEra = null, string? storyLocation = null);
Task<StoryIntelligenceProgressViewModel?> GetProgressAsync(Guid batchId);
Task<StoryIntelligenceProgressViewModel?> CancelAsync(Guid batchId);
Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> CommitAsync(Guid batchId, int runId);
@ -40,6 +40,7 @@ public sealed class OnboardingStoryIntelligenceService(
IStoryIntelligenceRelationshipImportService relationshipImport,
IStoryIntelligenceKnowledgeImportService knowledgeImport,
IStoryIntelligencePipelineStateService pipelineState,
IStoryMemoryRepository storyMemory,
IStoryIntelligenceClient client,
IOnboardingStoryIntelligenceBatchStore batchStore,
IStoryIntelligenceProgressNotifier notifier,
@ -83,7 +84,7 @@ public sealed class OnboardingStoryIntelligenceService(
};
}
public async Task<OnboardingStoryIntelligenceBatch?> StartAsync()
public async Task<OnboardingStoryIntelligenceBatch?> StartAsync(string? appearancePreference = null, string? primaryAgeGroup = null, string? storyEra = null, string? storyLocation = null)
{
var userId = RequireUserId();
var context = await GetContextAsync(userId);
@ -161,6 +162,7 @@ public sealed class OnboardingStoryIntelligenceService(
ChapterID = chapterId,
RunID = runId
});
await pipelineState.RecordChapterAnalysisQueuedAsync(preview.ProjectID, preview.BookID, runId);
await notifier.PublishAsync(new StoryIntelligenceRunProgressEvent
{
@ -189,9 +191,23 @@ public sealed class OnboardingStoryIntelligenceService(
BookID = preview.BookID,
ProjectName = wizard.SelectedProjectName,
BookTitle = wizard.SelectedBookTitle,
AppearancePreference = NormaliseAppearancePreference(appearancePreference),
PrimaryAgeGroup = CleanContext(primaryAgeGroup),
StoryEra = CleanContext(storyEra),
StoryLocation = CleanContext(storyLocation),
Items = batchItems
};
if (await pipelineState.GetForBookAsync(preview.BookID, userId) is { } pipeline)
{
await storyMemory.SetImportContextAsync(
pipeline.StoryIntelligenceBookPipelineID,
batch.AppearancePreference,
batch.PrimaryAgeGroup,
batch.StoryEra,
batch.StoryLocation);
}
await batchStore.SaveAsync(batch);
logger.LogInformation(
"Queued onboarding Story Intelligence batch {BatchID}. UserID={UserID} ProjectID={ProjectID} BookID={BookID} Chapters={ChapterCount}",
@ -204,6 +220,24 @@ public sealed class OnboardingStoryIntelligenceService(
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
};
private static string CleanContext(string? value)
{
var clean = value?.Trim();
return string.IsNullOrWhiteSpace(clean) ? string.Empty : clean[..Math.Min(clean.Length, 160)];
}
public async Task<StoryIntelligenceProgressViewModel?> GetProgressAsync(Guid batchId)
{
var batch = await batchStore.GetAsync(RequireUserId(), batchId);
@ -919,6 +953,10 @@ public sealed class OnboardingStoryIntelligenceBatch
public int BookID { get; init; }
public string? ProjectName { get; init; }
public string? BookTitle { get; init; }
public string AppearancePreference { get; init; } = StoryMemoryAppearancePreferences.Balanced;
public string PrimaryAgeGroup { get; init; } = string.Empty;
public string StoryEra { get; init; } = string.Empty;
public string StoryLocation { get; init; } = string.Empty;
public DateTime CreatedUtc { get; init; } = DateTime.UtcNow;
public IReadOnlyList<OnboardingStoryIntelligenceBatchItem> Items { get; init; } = [];
public List<OnboardingStoryIntelligenceCharacterDecision> CharacterDecisions { get; } = [];

View File

@ -347,7 +347,7 @@ public sealed class PersistedStoryIntelligenceRunner(
importSessionId.Value,
run.UserID,
sceneResultId,
new StoryMemoryResolveOptions { QueueDemand = false },
new StoryMemoryResolveOptions { QueueDemand = true, MaxDemandToQueue = 24 },
cancellationToken);
}
catch (Exception ex) when (ex is not OperationCanceledException && !IsFatal(ex))

View File

@ -61,7 +61,7 @@ public static class StoryIntelligenceIllustrationCompatibility
var relationshipPresentation = RelationshipPresentation(relationshipSafeText);
var entityPresentation = RelationshipPresentation(scopedText);
var pronounPresentation = PronounPresentation(scopedText);
var namePresentation = NamePresentation(entityText);
var namePresentation = NamePresentation(nameText);
var presentation = FirstKnown(
titlePresentation.Value,
entityPresentation.Value,
@ -172,12 +172,25 @@ public static class StoryIntelligenceIllustrationCompatibility
rejected.Add($"age hard mismatch: wanted {evidence.AgeBand}, candidate {candidate.AgeBand}");
}
if (string.Equals(evidence.AgeBand, AgeBands.Unknown, StringComparison.OrdinalIgnoreCase)
&& candidate.AgeBand is AgeBands.Child or AgeBands.YoungTeen or AgeBands.OlderTeen)
{
rejected.Add("unknown age requires adult-compatible artwork unless explicit child or teen evidence exists");
}
if (evidence.HasStrongPresentationEvidence
&& PresentationIncompatible(evidence.Presentation, candidate.Presentation))
{
rejected.Add($"presentation hard mismatch: wanted {evidence.Presentation}, candidate {candidate.Presentation}");
}
if (!string.Equals(evidence.HairColour, HairColours.Unknown, StringComparison.OrdinalIgnoreCase)
&& string.Equals(candidate.HairColour, HairColours.Unknown, StringComparison.OrdinalIgnoreCase)
&& isNamedCharacter)
{
rejected.Add($"named character with {evidence.HairColour} hair needs hair-specific artwork");
}
if (!string.Equals(evidence.HairColour, HairColours.Unknown, StringComparison.OrdinalIgnoreCase)
&& !string.Equals(candidate.HairColour, HairColours.Unknown, StringComparison.OrdinalIgnoreCase)
&& KnownMismatch(evidence.HairColour, candidate.HairColour))
@ -185,6 +198,13 @@ public static class StoryIntelligenceIllustrationCompatibility
rejected.Add($"hair colour hard mismatch: wanted {evidence.HairColour}, candidate {candidate.HairColour}");
}
if (!string.Equals(evidence.SkinTone, SkinTones.Unknown, StringComparison.OrdinalIgnoreCase)
&& !string.Equals(candidate.SkinTone, SkinTones.Unknown, StringComparison.OrdinalIgnoreCase)
&& KnownMismatch(evidence.SkinTone, candidate.SkinTone))
{
rejected.Add($"skin tone hard mismatch: wanted {evidence.SkinTone}, candidate {candidate.SkinTone}");
}
if (alreadyAssignedToAnotherSignificantCharacter && isNamedCharacter)
{
rejected.Add("already assigned to another significant named character");
@ -209,6 +229,9 @@ public static class StoryIntelligenceIllustrationCompatibility
public static string NormalizePresentation(string? value)
{
var text = (value ?? string.Empty).ToLowerInvariant();
var padded = $" {text} ";
if (ContainsAny(padded, " she ", " her ", " hers ", " herself ")) return Presentations.Feminine;
if (ContainsAny(padded, " he ", " him ", " his ", " himself ")) return Presentations.Masculine;
if (ContainsAny(text, "feminine", "female", "woman", "girl", "mother", "mum", "mrs", "miss", "ms", "aunt", "sister", "daughter", "wife", "grandmother")) return Presentations.Feminine;
if (ContainsAny(text, "masculine", "male", "man", "boy", "father", "dad", "mr", "uncle", "brother", "son", "husband", "grandfather")) return Presentations.Masculine;
if (text.Contains("androgynous", StringComparison.OrdinalIgnoreCase)) return Presentations.Androgynous;
@ -253,30 +276,46 @@ public static class StoryIntelligenceIllustrationCompatibility
var padded = $" {text} ";
if (ContainsAny(text, "driving test centre", "driving test center", "test centre", "test center")) return "DrivingTestCentre";
if (ContainsAny(text, "police station")) return "PoliceStation";
if (ContainsAny(text, "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 (text.Contains("kitchen", StringComparison.OrdinalIgnoreCase)) return "Kitchen";
if (text.Contains("bedroom", StringComparison.OrdinalIgnoreCase)) return "Bedroom";
if (ContainsAny(text, "living room", "sitting room", "lounge")) return "LivingRoom";
if (ContainsAny(text, "hall", "hallway", "corridor")) return "Hallway";
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, "restaurant", "cafe", "café", "diner")) return "Restaurant";
if (ContainsAny(text, "pub", "public house")) return "Pub";
if (ContainsAny(text, "beach", "seafront")) return "Beach";
if (ContainsAny(text, "railway station", "train station", "station platform")) return "RailwayStation";
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, "office", "desk", "study", "library")) return "Office";
if (text.Contains("school", StringComparison.OrdinalIgnoreCase)) return "School";
if (text.Contains("waiting room", StringComparison.OrdinalIgnoreCase)) return "WaitingRoom";
if (ContainsAny(text, "car park", "parking", "forecourt")) return "CarPark";
if (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,
"road", "street", "lane", "drive", " close ", "court", "way", "avenue", "terrace", "gardens",
"highway", "motorway", "bridge", "roundabout", "junction", "crossroads", "dual carriageway",
"track", "footpath", " path ", "cycleway", "tunnel", "flyover", "underpass", "boulevard")
"road", "court", "way", "terrace", "gardens",
"roundabout", "junction", "crossroads",
"track", "footpath", " path ", "cycleway", "boulevard")
|| RoadSuffixRegex.IsMatch(padded))
{
return "Road";
@ -317,24 +356,32 @@ public static class StoryIntelligenceIllustrationCompatibility
if (ContainsAny(text, "money", "cash", "coins", "banknote")) return "Money";
if (ContainsAny(text, "scarf", "scarves")) return "Clothing";
if (ContainsAny(text, "paperwork")) return "Document";
if (ContainsAny(text, "lipstick")) return "Lipstick";
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(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, " van ", " transit ")) return "Van";
if (ContainsAny(padded, " motor car ", " car ", " vehicle ")) return "Car";
if (ContainsAny(text, "supercharger", "engine part")) return "CarPart";
if (ContainsAny(text, "biscuit tin", "tin")) return "Tin";
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("suitcase", StringComparison.OrdinalIgnoreCase)) return "Suitcase";
if (text.Contains("note", StringComparison.OrdinalIgnoreCase)) return "Note";
if (text.Contains("letter", StringComparison.OrdinalIgnoreCase)) return "Letter";
if (text.Contains("notebook", StringComparison.OrdinalIgnoreCase)) return "Notebook";
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 (ContainsAny(text, "tr6")) return "Car";
if (ContainsAny(text, "photo", "photograph")) return "Photograph";
@ -358,6 +405,8 @@ public static class StoryIntelligenceIllustrationCompatibility
public static bool LocationCompatible(string requestedType, string candidateType)
=> requestedType == candidateType
|| (requestedType is "Road" or "Lane" or "Street" or "Avenue" or "Drive" or "Close" && candidateType is "Road" or "Lane" or "Street")
|| (requestedType is "DriverSeat" or "PassengerSeat" or "RearSeat" && candidateType == "VehicleInterior")
|| (requestedType is "FrontDoor" or "Porch" && candidateType is "HouseExterior")
|| (requestedType is "Kitchen" or "LivingRoom" or "Bedroom" && candidateType is "FlatInterior")
|| (requestedType == "Bathroom" && candidateType == "Bathroom");
@ -365,14 +414,19 @@ public static class StoryIntelligenceIllustrationCompatibility
public static bool AssetCompatible(string requestedType, string candidateType)
=> requestedType == candidateType
|| (requestedType == "Hatchback" && candidateType == "Car")
|| (requestedType == "FamilyCar" && candidateType == "Car")
|| (requestedType is "Saloon" or "Estate" && candidateType == "Car")
|| (requestedType == "Note" && candidateType is "Letter" or "Document")
|| (requestedType == "Rucksack" && candidateType == "Rucksack");
public static bool IsGroupEntity(string? value)
{
var clean = (value ?? string.Empty).Trim().ToLowerInvariant();
return clean.EndsWith('s')
return clean.StartsWith("group of ", StringComparison.Ordinal)
|| clean.StartsWith("pair of ", StringComparison.Ordinal)
|| clean.StartsWith("two ", StringComparison.Ordinal)
|| clean.Contains(" group of ", StringComparison.Ordinal)
|| clean.Contains(" / group of ", StringComparison.Ordinal)
|| clean.EndsWith('s')
&& (clean.Contains("social worker", StringComparison.Ordinal)
|| clean.Contains("neighbour", StringComparison.Ordinal)
|| clean.Contains("neighbor", StringComparison.Ordinal)
@ -380,6 +434,7 @@ public static class StoryIntelligenceIllustrationCompatibility
|| clean.Contains("student", StringComparison.Ordinal)
|| clean.Contains("examiner", StringComparison.Ordinal)
|| clean.Contains("uniformed", StringComparison.Ordinal)
|| clean.Contains("girl", StringComparison.Ordinal)
|| clean.Contains("responder", StringComparison.Ordinal))
|| clean is "crowd" or "uniformed people" or "police" or "social services";
}
@ -397,6 +452,11 @@ public static class StoryIntelligenceIllustrationCompatibility
if (ContainsAny(text, "teenager", "teenage")) return new(AgeBands.OlderTeen, 0.86m);
if (ContainsAny(text, "young adult", "young woman", "young man")) return new(AgeBands.YoungAdult, 0.82m);
if (ContainsAny(text, "middle-aged", "middle aged", "mature adult")) return new(AgeBands.MatureAdult, 0.86m);
if (ContainsAny(text, "driving test centre", "driving test center", "learner driver", "driving test")
&& !ContainsAny(text, " is a driving examiner", " as a driving examiner", " female driving examiner", " male driving examiner", " driving instructor", " is an instructor", " as an instructor"))
{
return new(AgeBands.OlderTeen, 0.9m);
}
if (ContainsAny(text, " mr ", " mr. ", " mrs ", " mrs. ", " miss ", " miss. ", " ms ", " ms. ", " doctor", " dr ", " dr. ", " professor", " driving examiner", " teacher", " detective", " police officer", " nurse", " receptionist", " solicitor", " landlord", " manager", "social worker")) return new(AgeBands.Adult, 0.88m);
if (ContainsAny(text, "elderly", "senior", "old woman", "old man")) return new(AgeBands.Senior, 0.9m);
return EvidenceSignal.Unknown;
@ -462,7 +522,7 @@ public static class StoryIntelligenceIllustrationCompatibility
private static EvidenceSignal NamePresentation(string text)
{
if (ContainsAny(text, " beth ", " maggie ", " rosie ", " grace ", " zoe ", " rebecca ", " annie ", " mary ", " sharon ", " catherine ", " susan ", " helen ", " elen ")) return new(Presentations.Feminine, 0.82m);
if (ContainsAny(text, " alison ", " sophie ", " beth ", " maggie ", " rosie ", " grace ", " zoe ", " rebecca ", " annie ", " mary ", " sharon ", " catherine ", " susan ", " helen ", " elen ")) return new(Presentations.Feminine, 0.82m);
if (ContainsAny(text, " adam ", " colin ", " david ", " kevin ", " simon ", " graham ", " gareth ", " john ", " michael ", " peter ", " richard ", " rick ", " george ")) return new(Presentations.Masculine, 0.82m);
return EvidenceSignal.Unknown;
}
@ -505,7 +565,7 @@ public static class StoryIntelligenceIllustrationCompatibility
}
if (StartsWithAny(clean, "mrs", "miss", "ms", "mother", "mum", "mom", "aunt", "sister", "daughter", "girl", "woman", "female")
|| clean is "beth" or "maggie" or "grace" or "rosie" or "rebecca" or "annie" or "mary" or "susan" or "helen" or "elen" or "zoe" or "sharon" or "catherine")
|| clean is "alison" or "sophie" or "beth" or "maggie" or "grace" or "rosie" or "rebecca" or "annie" or "mary" or "susan" or "helen" or "elen" or "zoe" or "sharon" or "catherine")
{
return new(Presentations.Feminine, 0.98m);
}
@ -542,8 +602,8 @@ public static class StoryIntelligenceIllustrationCompatibility
=> (evidence, candidate) switch
{
(AgeBands.Child, not AgeBands.Child) => true,
(AgeBands.YoungTeen, AgeBands.YoungAdult or AgeBands.Adult or AgeBands.MatureAdult or AgeBands.Senior) => true,
(AgeBands.OlderTeen, AgeBands.MatureAdult or AgeBands.Senior) => true,
(AgeBands.YoungTeen, AgeBands.Child or AgeBands.YoungAdult or AgeBands.Adult or AgeBands.MatureAdult or AgeBands.Senior) => true,
(AgeBands.OlderTeen, AgeBands.Child or AgeBands.YoungTeen or AgeBands.MatureAdult or AgeBands.Senior) => true,
(AgeBands.Adult, AgeBands.Child or AgeBands.YoungTeen or AgeBands.OlderTeen) => true,
(AgeBands.MatureAdult, AgeBands.Child or AgeBands.YoungTeen or AgeBands.OlderTeen) => true,
(AgeBands.Senior, AgeBands.Child or AgeBands.YoungTeen or AgeBands.OlderTeen or AgeBands.YoungAdult) => true,
@ -656,6 +716,18 @@ public static class StoryIntelligenceIllustrationCompatibility
{
lower.IndexOf("hair", 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("spectacles", StringComparison.Ordinal),
lower.IndexOf("beard", StringComparison.Ordinal),

View File

@ -591,7 +591,7 @@ public sealed class StoryIntelligenceIllustrationMatchingService(
Code = $"char-demand-{StableHash(demand.DemandKey)}",
Title = $"{age} {presentation.ToLowerInvariant()} character",
Description = "Demand-created reusable character illustration for Story Intelligence matching.",
VisualSubject = $"{age} {presentation.ToLowerInvariant()} character with {hairLength.ToLowerInvariant()} {hairColour.ToLowerInvariant()} hair and {skinTone.ToLowerInvariant()} skin tone range.",
VisualSubject = $"{age} {presentation.ToLowerInvariant()} character with {hairLength.ToLowerInvariant()} {hairColour.ToLowerInvariant()} hair. Skin tone requirement: {SkinToneInstruction(skinTone)}.",
Composition = "clear head-and-shoulders cinematic portrait with readable face, subtle atmospheric story background, clean separation, suitable for circular cropping",
Mood = "neutral, story-ready, emotionally readable, subdued and cinematic",
ApparentAgeBand = age,
@ -688,6 +688,15 @@ public sealed class StoryIntelligenceIllustrationMatchingService(
};
}
private static string SkinToneInstruction(string skinTone)
=> skinTone switch
{
StoryIntelligenceIllustrationCompatibility.SkinTones.Light => "must have light/fair skin; do not depict dark or black skin unless explicit manuscript evidence says so",
StoryIntelligenceIllustrationCompatibility.SkinTones.Medium => "must have medium skin tone; do not drift to dark or very pale skin",
StoryIntelligenceIllustrationCompatibility.SkinTones.Dark => "must have dark skin tone",
_ => "not specified"
};
private static Dictionary<string, CharacterObservation> BuildCharacterObservations(StoryIntelligenceExperiencePrototypeViewModel prototype)
{
var observations = new Dictionary<string, CharacterObservation>(StringComparer.OrdinalIgnoreCase);

View File

@ -279,6 +279,11 @@ public sealed class StoryIntelligenceClient(
return (responseText, retryCount);
}
if (IsInsufficientQuota(responseText))
{
throw new InvalidOperationException($"OpenAI billing quota exhausted; request was not retried: {TrimForLog(responseText)}");
}
if (!ShouldRetry(response.StatusCode) || attempt == maxAttempts)
{
throw new InvalidOperationException($"OpenAI request failed with {(int)response.StatusCode} {response.StatusCode}: {TrimForLog(responseText)}");
@ -318,6 +323,10 @@ public sealed class StoryIntelligenceClient(
or HttpStatusCode.ServiceUnavailable
or HttpStatusCode.GatewayTimeout;
internal static bool IsInsufficientQuota(string responseText)
=> responseText.Contains("\"insufficient_quota\"", StringComparison.OrdinalIgnoreCase)
|| responseText.Contains("exceeded your current quota", StringComparison.OrdinalIgnoreCase);
private static Task DelayForRetryAsync(int attempt, CancellationToken cancellationToken)
=> Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, attempt - 1)), cancellationToken);

View File

@ -269,6 +269,7 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
scenes.Add(BuildEmptyScene(activeSnapshot.Run));
}
var appearancePreference = await storyMemoryRepository.GetAppearancePreferenceAsync(importSessionId);
var model = new StoryIntelligenceExperiencePrototypeViewModel
{
Mode = replayMode ? "replay" : "real",
@ -297,7 +298,8 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
completedChapterRuns,
displayedProgressReason,
storyMemory,
replayMode),
replayMode,
appearancePreference),
ProjectName = DisplayTitle(session.ProjectName, "Untitled project"),
BookTitle = DisplayTitle(session.BookDisplayTitle, "Untitled book"),
Scenes = scenes
@ -532,7 +534,9 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
Id = CharacterId(memoryCharacter.Name),
LibraryCode = libraryCode,
Name = memoryCharacter.Name,
Role = memoryCharacter.Role,
Role = string.Equals(CharacterId(memoryCharacter.Name), CharacterId(povName), StringComparison.OrdinalIgnoreCase)
? "POV character"
: NonPovRole(memoryCharacter.Role),
Relevance = memoryCharacter.Relevance,
Weight = Math.Max(38, memoryCharacter.Weight - 18),
ImagePath = CharacterFallback(memoryCharacter.Name)
@ -550,10 +554,16 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
private static string NonPovRole(SceneIntelligenceCharacter character)
{
var role = FirstConfigured(character.RoleInScene, character.MentionedOnly == true ? "Referenced" : "Scene character");
return NonPovRole(role, character.MentionedOnly == true);
}
private static string NonPovRole(string? role, bool referenced = false)
{
role = FirstConfigured(role, referenced ? "Referenced" : "Scene character");
return role.Contains("pov", StringComparison.OrdinalIgnoreCase)
|| role.Contains("point of view", StringComparison.OrdinalIgnoreCase)
|| role.Contains("narrator", StringComparison.OrdinalIgnoreCase)
? character.MentionedOnly == true ? "Referenced" : "Scene character"
? referenced ? "Referenced" : "Scene character"
: role;
}
@ -1441,7 +1451,8 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
int completedChapters,
string displayedProgressReason,
StoryMemory storyMemory,
bool replayMode = false)
bool replayMode = false,
string appearancePreference = "")
{
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.";
@ -1505,6 +1516,7 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
ReplayState = replayMode ? "Paused at first available scene" : string.Empty,
SnapshotCacheStatus = replayMode ? "Reconstructed from saved analysis" : string.Empty,
RebuildVersion = replayMode ? "21R-replay-v1" : string.Empty,
AppearancePreference = appearancePreference,
FailedChaptersSkipped = failedChapterSnapshots.Count,
FailedChapterSummary = failedChapterSummary,
PanelVisibleCounts = replayMode ? "relationships:2; knowledge:3; observations:2; discoveries:4; activity:4" : string.Empty,
@ -1605,13 +1617,25 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
var characters = await storyMemoryRepository.ListCharactersAsync(importSessionId);
var locations = await storyMemoryRepository.ListLocationsAsync(importSessionId);
var assets = await storyMemoryRepository.ListAssetsAsync(importSessionId);
var attributes = await storyMemoryRepository.ListCharacterAttributesAsync(importSessionId);
var importContext = await storyMemoryRepository.GetImportContextAsync(importSessionId);
var characterIds = characters.ToDictionary(item => item.StoryMemoryCharacterID, item => item.CanonicalIdentityKey);
var locationIds = locations.ToDictionary(item => item.StoryMemoryLocationID, item => item.CanonicalIdentityKey);
var assetIds = assets.ToDictionary(item => item.StoryMemoryAssetID, item => item.CanonicalIdentityKey);
var characterEvidence = characters.ToDictionary(
item => item.CanonicalIdentityKey,
item =>
{
var evidence = StoryMemoryService.ResolveCharacterEvidenceFromAttributes(
item.DisplayName,
attributes.Where(attribute => attribute.StoryMemoryCharacterID == item.StoryMemoryCharacterID));
return StoryMemoryService.ApplyImportContextToEvidence(evidence, item.DisplayName, importContext);
},
StringComparer.OrdinalIgnoreCase);
var catalogue = (await illustrationRepository.ListAsync(new IllustrationLibraryFilter()))
.ToDictionary(item => item.IllustrationLibraryItemID);
var characterAssignments = BuildAssignmentLookup(assignments, StoryMemoryEntityTypes.Character, characterIds, catalogue);
var characterAssignments = BuildAssignmentLookup(assignments, StoryMemoryEntityTypes.Character, characterIds, catalogue, characterEvidence);
var locationAssignments = BuildAssignmentLookup(assignments, StoryMemoryEntityTypes.Location, locationIds, catalogue);
var assetAssignments = BuildAssignmentLookup(assignments, StoryMemoryEntityTypes.Asset, assetIds, catalogue);
@ -1634,7 +1658,8 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
IReadOnlyList<StoryMemoryIllustrationAssignment> assignments,
string entityType,
IReadOnlyDictionary<int, string> entityKeys,
IReadOnlyDictionary<int, IllustrationLibraryItem> catalogue)
IReadOnlyDictionary<int, IllustrationLibraryItem> catalogue,
IReadOnlyDictionary<string, CharacterEvidenceProfile>? characterEvidence = null)
{
var lookup = new Dictionary<string, AssignmentImage>(StringComparer.OrdinalIgnoreCase);
foreach (var assignment in assignments.Where(item => item.EntityType == entityType && item.IllustrationLibraryItemID.HasValue))
@ -1651,7 +1676,35 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
continue;
}
lookup[entityKey] = new AssignmentImage(item.IllustrationLibraryItemID, item.StableCode, item.Status, publicUrl, assignment.Confidence, assignment.MatchScore, assignment.AssignmentReason);
CharacterEvidenceProfile? evidence = null;
characterEvidence?.TryGetValue(entityKey, out evidence);
if (entityType == StoryMemoryEntityTypes.Character && evidence is not null)
{
var metadata = StoryIntelligenceIllustrationCompatibility.CharacterMetadataFrom(item);
var rejections = StoryIntelligenceIllustrationCompatibility.CharacterHardRejections(evidence, metadata, true, false);
if (rejections.Count > 0)
{
continue;
}
}
var currentEvidence = evidence is null
? null
: $"age={evidence.AgeBand}; presentation={evidence.Presentation}; hair={evidence.HairColour}; hairLength={evidence.HairLength}; skin={evidence.SkinTone}";
lookup[entityKey] = new AssignmentImage(
item.IllustrationLibraryItemID,
item.StableCode,
item.Status,
publicUrl,
assignment.Confidence,
assignment.MatchScore,
assignment.AssignmentReason,
evidence?.AgeBand,
evidence?.Presentation,
currentEvidence,
assignment.EvidenceVersion,
assignment.AssignmentStatus,
assignment.IsFallback);
}
return lookup;
@ -1709,8 +1762,13 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
AssignmentConfidence = assignment.Confidence,
MatchingScore = assignment.MatchScore,
ReasonChosen = assignment.Reason,
PreviousIllustration = assignment.StableCode,
CurrentEvidence = assignment.CurrentEvidence,
PreviousEvidence = $"assignment evidence version {assignment.EvidenceVersion}",
ResolvedAgeBand = assignment.ResolvedAgeBand,
ResolvedPresentation = assignment.ResolvedPresentation,
DemandStatus = "Read from durable Story Memory",
IllustrationAllocationStatus = status
IllustrationAllocationStatus = assignment.IsFallback ? assignment.AssignmentStatus : status
};
private string? ResolveExistingPublicUploadUrl(string? storedPath)
@ -2126,6 +2184,17 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
return "unknown";
}
var words = clean.Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (words.Length == 2
&& !IsTitleWord(words[0])
&& name is not null
&& name.Trim().Length > 0
&& char.IsUpper(name.Trim()[0])
&& name.Trim().All(ch => char.IsLetter(ch) || char.IsWhiteSpace(ch) || ch == '\'' || ch == '\u2019'))
{
return words[0];
}
if (clean is "mother" or "mum" or "mummy" or "her mother" or "the mother" or "my mother" or "my mum"
|| clean.Contains("beth's mum", StringComparison.Ordinal)
|| clean.Contains("beth's mother", StringComparison.Ordinal))
@ -2146,6 +2215,9 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
return clean;
}
private static bool IsTitleWord(string value)
=> value is "mr" or "mrs" or "miss" or "ms" or "dr" or "doctor" or "sir" or "lady";
private static int CharacterWeight(SceneIntelligenceCharacter character)
=> character.MentionedOnly == true ? 46 : ConfidenceWeight(character.Confidence, 72);
@ -2192,7 +2264,7 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
if (type == "WaitingRoom") return "loc-generic-waiting-room";
if (type == "DrivingTestCentre") return "loc-driving-test-centre";
if (type == "VehicleInterior") return "loc-vehicle-interior";
if (type == "Road") return "loc-generic-road";
if (type is "Road" or "Lane" or "Street" or "Avenue" or "Drive" or "Close" or "Motorway" or "Bridge" or "Flyover" or "Underpass") return "loc-generic-road";
if (type == "Garage") return "loc-garage-workshop";
if (type is "Hospital" or "PoliceStation" or "School" or "Church" or "Restaurant" or "Pub") return $"loc-generic-{StableKey(type)}";
if (type is "Kitchen" or "Bedroom" or "LivingRoom" or "FlatInterior" or "Hallway") return "loc-small-flat-interior";
@ -2353,7 +2425,13 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
string PublicUrl,
decimal Confidence,
int MatchScore,
string? Reason);
string? Reason,
string? ResolvedAgeBand,
string? ResolvedPresentation,
string? CurrentEvidence,
string EvidenceVersion,
string AssignmentStatus,
bool IsFallback);
private sealed record CharacterCodeProfile(
string Code,

File diff suppressed because it is too large Load Diff

View 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

View File

@ -0,0 +1,11 @@
IF COL_LENGTH(N'dbo.StoryMemoryImportPreferences', N'PrimaryAgeGroup') IS NULL
ALTER TABLE dbo.StoryMemoryImportPreferences ADD PrimaryAgeGroup nvarchar(120) NULL;
GO
IF COL_LENGTH(N'dbo.StoryMemoryImportPreferences', N'StoryEra') IS NULL
ALTER TABLE dbo.StoryMemoryImportPreferences ADD StoryEra nvarchar(120) NULL;
GO
IF COL_LENGTH(N'dbo.StoryMemoryImportPreferences', N'StoryLocation') IS NULL
ALTER TABLE dbo.StoryMemoryImportPreferences ADD StoryLocation nvarchar(160) NULL;
GO

View File

@ -131,6 +131,7 @@ public sealed class StoryIntelligenceOverviewViewModel
public Guid? ExistingBatchID { get; init; }
public StoryIntelligenceJobProgress? ExistingJob { get; init; }
public bool CanStart { get; init; }
public string AppearancePreference { get; init; } = StoryMemoryAppearancePreferences.Balanced;
}
public sealed class StoryIntelligenceProgressViewModel

View File

@ -72,6 +72,7 @@ public sealed class StoryIntelligenceExperienceSnapshotDiagnostics
public string ReplayState { get; init; } = string.Empty;
public string SnapshotCacheStatus { get; init; } = string.Empty;
public string RebuildVersion { get; init; } = string.Empty;
public string AppearancePreference { get; init; } = string.Empty;
public int FailedChaptersSkipped { get; init; }
public string FailedChapterSummary { get; init; } = string.Empty;
public string PanelVisibleCounts { get; init; } = string.Empty;

View File

@ -117,6 +117,17 @@
<option value="all derived visualisation state">All derived visualisation state</option>
</select>
</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>
</form>
}
@ -186,10 +197,6 @@
<p class="story-exp-zone-label">Location</p>
<div class="story-exp-location" data-location></div>
</div>
<div class="story-exp-zone story-exp-zone--assets" aria-label="Significant assets">
<p class="story-exp-zone-label">Assets</p>
<div class="story-exp-assets" data-assets></div>
</div>
</div>
<section class="story-exp-ribbon-wrap" aria-label="Recent scenes and timeline">
@ -350,6 +357,10 @@
<dt>Unresolved characters</dt>
<dd data-diagnostic-key="unresolvedCharacterCount">@Model.Diagnostics.UnresolvedCharacterCount</dd>
</div>
<div>
<dt>Appearance guidance</dt>
<dd data-diagnostic-key="appearancePreference">@Model.Diagnostics.AppearancePreference</dd>
</div>
<div>
<dt>Narrator confidence</dt>
<dd data-diagnostic-key="narratorConfidence">@Model.Diagnostics.NarratorConfidence</dd>

View File

@ -80,7 +80,67 @@
<form asp-action="SkipStoryIntelligence" method="post">
<button class="btn btn-outline-secondary" type="submit">Skip for now</button>
</form>
<form asp-action="StartStoryIntelligence" method="post">
<form asp-action="StartStoryIntelligence" method="post" class="onboarding-form-section">
<fieldset>
<legend>Unresolved character appearance</legend>
<p>Explicit manuscript evidence always wins. This only guides characters whose appearance is not described.</p>
<label>
Appearance baseline
<select class="form-control" name="appearancePreference">
<option value="@StoryMemoryAppearancePreferences.Balanced">Balanced diverse cast</option>
<option value="@StoryMemoryAppearancePreferences.ExplicitEvidenceOnly">Explicit evidence only</option>
<option value="@StoryMemoryAppearancePreferences.PredominantlyLight">Predominantly light-skinned cast</option>
<option value="@StoryMemoryAppearancePreferences.PredominantlyMedium">Predominantly medium-skinned cast</option>
<option value="@StoryMemoryAppearancePreferences.PredominantlyDark">Predominantly dark-skinned cast</option>
<option value="@StoryMemoryAppearancePreferences.CustomMix">Custom mix</option>
</select>
</label>
</fieldset>
<fieldset>
<legend>Story context for generated portraits</legend>
<p>These guide generated images when the manuscript is silent. Explicit manuscript evidence still wins.</p>
<label>
Primary character age group
<select class="form-control" name="primaryAgeGroup">
<option value="">No baseline</option>
<option value="Late teen / early 20s">Late teen / early 20s</option>
<option value="Young teen">Young teen</option>
<option value="Older teen">Older teen</option>
<option value="Young adult">Young adult</option>
<option value="Adult">Adult</option>
<option value="Middle-aged adult">Middle-aged adult</option>
<option value="Senior">Senior</option>
</select>
</label>
<label>
Era
<select class="form-control" name="storyEra">
<option value="">No baseline</option>
<option value="1980s">1980s</option>
<option value="1970s">1970s</option>
<option value="1990s">1990s</option>
<option value="2000s">2000s</option>
<option value="Contemporary">Contemporary</option>
<option value="Victorian">Victorian</option>
<option value="Medieval">Medieval</option>
<option value="Near future">Near future</option>
</select>
</label>
<label>
Location
<select class="form-control" name="storyLocation">
<option value="">No baseline</option>
<option value="UK">UK</option>
<option value="United States">United States</option>
<option value="Europe">Europe</option>
<option value="Urban">Urban</option>
<option value="Suburban">Suburban</option>
<option value="Rural">Rural</option>
<option value="Fantasy world">Fantasy world</option>
<option value="Science fiction setting">Science fiction setting</option>
</select>
</label>
</fieldset>
<button class="btn btn-primary" type="submit" disabled="@(!Model.CanStart)">Analyse manuscript</button>
</form>
</div>

View File

@ -16,6 +16,8 @@
--story-rose: #e38ca8;
--story-radius: 18px;
--story-shadow: 0 28px 84px rgba(0, 0, 0, 0.32);
--story-side-body-font-size: 0.72rem;
--story-side-title-font-size: 0.76rem;
--progress-value: 0deg;
}
@ -287,16 +289,26 @@ input:focus-visible {
}
.story-exp-rebuild {
position: fixed;
left: 50%;
top: 66px;
z-index: 18;
display: flex;
align-items: center;
justify-content: center;
flex-wrap: wrap;
gap: 9px;
margin: 0 18px 10px;
width: clamp(320px, calc(100vw - 520px), 720px);
min-height: 42px;
max-height: 74px;
margin: 0;
border: 1px solid rgba(151, 185, 218, 0.12);
border-radius: 999px;
padding: 8px 10px;
background: rgba(4, 12, 24, 0.74);
box-shadow: 0 16px 44px rgba(0, 0, 0, 0.22);
transform: translateX(-50%);
overflow: hidden;
}
.story-exp-rebuild label {
@ -307,6 +319,7 @@ input:focus-visible {
font-size: 0.76rem;
font-weight: 800;
text-transform: uppercase;
white-space: nowrap;
}
.story-exp-rebuild select,
@ -319,7 +332,7 @@ input:focus-visible {
}
.story-exp-rebuild select {
max-width: min(280px, 40vw);
max-width: min(240px, 24vw);
padding: 6px 22px 6px 9px;
}
@ -330,7 +343,7 @@ input:focus-visible {
.story-exp-shell {
display: grid;
grid-template-columns: minmax(180px, 0.46fr) minmax(820px, 2.72fr) minmax(188px, 0.48fr);
grid-template-columns: minmax(180px, 0.42fr) minmax(660px, 1.55fr) minmax(380px, 0.84fr);
gap: 12px;
min-height: 0;
padding: 12px 18px 58px;
@ -339,8 +352,7 @@ input:focus-visible {
.story-exp-left,
.story-exp-right {
display: grid;
align-content: start;
gap: 14px;
gap: 9px;
max-height: calc(100vh - 126px);
min-height: 0;
overflow-x: hidden;
@ -355,11 +367,11 @@ input:focus-visible {
}
.story-exp-left {
grid-template-rows: auto auto minmax(150px, 1fr) minmax(120px, 0.72fr);
grid-template-rows: auto auto minmax(0, auto) minmax(0, auto);
}
.story-exp-right {
grid-template-rows: minmax(190px, 1.2fr) minmax(150px, 0.95fr) auto;
grid-template-rows: minmax(0, auto) minmax(0, auto) minmax(0, auto);
}
.story-exp-left,
@ -381,16 +393,39 @@ input:focus-visible {
.story-exp-progress,
.story-exp-panel {
padding: 11px;
padding: 8px;
}
.story-exp-panel {
display: grid;
grid-template-rows: auto auto;
align-content: start;
grid-template-rows: auto minmax(0, auto);
min-height: 0;
overflow: hidden;
}
.story-exp-panel:has(.story-exp-list:empty),
.story-exp-panel:has(.story-exp-feed:empty),
.story-exp-panel:has(.story-exp-insight-list:empty) {
padding-bottom: 10px;
}
.story-exp-right .story-exp-panel:has(.story-exp-insight-list:not(:empty)) {
grid-template-rows: auto minmax(0, 1fr);
}
.story-exp-right .story-exp-panel:has(.story-exp-insight-list:not(:empty)):first-child {
min-height: clamp(104px, 17vh, 192px);
}
.story-exp-right .story-exp-panel:has(.story-exp-insight-list:not(:empty)):nth-child(2) {
min-height: clamp(170px, 28vh, 330px);
}
.story-exp-right .story-exp-panel:has(.story-exp-insight-list:not(:empty)):nth-child(3) {
min-height: clamp(78px, 13vh, 150px);
}
.story-exp-panel--compact {
padding: 9px;
}
@ -504,8 +539,8 @@ input:focus-visible {
}
.story-exp-panel h2 {
margin: 0 0 7px;
font-size: 0.88rem;
margin: 0 0 5px;
font-size: var(--story-side-title-font-size);
letter-spacing: 0.01em;
}
@ -524,10 +559,11 @@ input:focus-visible {
.story-exp-feed li,
.story-exp-insight {
border: 1px solid rgba(255, 255, 255, 0.065);
border-radius: 9px;
padding: 7px 8px;
border-radius: 7px;
padding: 6px 7px;
background: rgba(255, 255, 255, 0.035);
color: var(--story-soft);
font-size: var(--story-side-body-font-size);
min-width: 0;
overflow-wrap: anywhere;
}
@ -662,15 +698,15 @@ input:focus-visible {
animation: drawLine 760ms cubic-bezier(.16, .84, .28, 1) forwards;
}
.story-exp-connection-group--presence .story-exp-connection,
.story-exp-connection-group--presence-soft .story-exp-connection {
stroke: rgba(125, 193, 255, 0.52);
.story-exp-connection-group--presence-cluster .story-exp-connection {
stroke: rgba(125, 193, 255, 0.62);
stroke-width: 1.5;
}
.story-exp-connection-group--presence-soft .story-exp-connection,
.story-exp-connection-group--presence-cluster .story-exp-connection,
.story-exp-connection-group--knowledge .story-exp-connection,
.story-exp-connection-group--asset-link .story-exp-connection {
stroke-dasharray: 0.16 0.12;
stroke-dasharray: 0.08 0.13;
}
.story-exp-connection-group--support .story-exp-connection {
@ -678,15 +714,19 @@ input:focus-visible {
}
.story-exp-connection-group--conflict .story-exp-connection {
stroke: rgba(227, 140, 168, 0.7);
stroke: rgba(227, 140, 168, 0.42);
stroke-dasharray: 0.2 0.12;
}
.story-exp-connection-group--uncertain .story-exp-connection {
stroke: rgba(182, 162, 255, 0.48);
stroke: rgba(182, 162, 255, 0.32);
stroke-dasharray: 0.12 0.11;
}
.story-exp-connection-group--strong .story-exp-connection {
stroke: rgba(215, 168, 93, 0.34);
}
.story-exp-connection-group--knowledge .story-exp-connection {
stroke: rgba(182, 162, 255, 0.44);
}
@ -741,12 +781,7 @@ input:focus-visible {
}
.story-exp-zone--location .story-exp-zone-label {
left: 55%;
top: 22px;
}
.story-exp-zone--assets .story-exp-zone-label {
right: 8%;
left: 80%;
top: 22px;
}
@ -778,8 +813,7 @@ input:focus-visible {
}
.story-character__image,
.story-location__image,
.story-asset__image {
.story-location__image {
position: relative;
z-index: 1;
display: block;
@ -796,8 +830,7 @@ input:focus-visible {
}
.story-character__image img,
.story-location__image img,
.story-asset__image img {
.story-location__image img {
width: 100%;
height: 100%;
object-fit: cover;
@ -864,6 +897,71 @@ input:focus-visible {
opacity: 0.9;
}
.story-character.is-evidence-card {
width: var(--node-size, 150px);
height: var(--node-size, 150px);
}
.story-character.is-evidence-card .story-character__image {
position: absolute;
inset: 0;
opacity: 0;
pointer-events: none;
}
.story-character.is-evidence-card .story-character__text {
position: absolute;
inset: 0;
top: 0;
left: 0;
width: 100%;
min-height: 100%;
align-content: center;
justify-items: start;
gap: 5px;
border-color: rgba(169, 205, 240, 0.22);
border-radius: 12px;
padding: clamp(8px, calc(var(--node-size, 150px) * 0.07), 12px);
background:
linear-gradient(180deg, rgba(16, 34, 58, 0.94), rgba(5, 13, 25, 0.9));
text-align: left;
transform: none;
}
.story-character.is-evidence-card.is-pov .story-character__text {
border-color: rgba(215, 168, 93, 0.68);
box-shadow: 0 0 0 5px rgba(215, 168, 93, 0.08), 0 24px 56px rgba(0, 0, 0, 0.3);
}
.story-character.is-evidence-card .story-character__text strong,
.story-character.is-evidence-card .story-character__text span {
text-align: left;
}
.story-entity-facts {
display: flex;
flex-wrap: wrap;
gap: 4px;
max-width: 100%;
}
.story-entity-facts span {
display: inline-flex;
max-width: 100%;
border: 1px solid rgba(151, 185, 218, 0.12);
border-radius: 999px;
padding: 2px 6px;
background: rgba(255, 255, 255, 0.045);
color: var(--story-soft);
font-size: 0.61rem;
font-weight: 800;
line-height: 1.2;
overflow: hidden;
text-overflow: ellipsis;
text-transform: none;
white-space: nowrap;
}
.story-location {
position: absolute;
display: grid;
@ -929,68 +1027,6 @@ input:focus-visible {
white-space: nowrap;
}
.story-exp-assets {
position: relative;
width: 100%;
height: 100%;
}
.story-asset {
position: absolute;
display: grid;
justify-items: center;
gap: 6px;
width: max(var(--node-size, 78px), 104px);
opacity: var(--opacity, 1);
transform: translate(var(--x, 0), var(--y, 0)) scale(var(--scale, 1));
z-index: calc(var(--z, 3) + 30);
pointer-events: auto;
transition: transform 860ms cubic-bezier(.16, .84, .28, 1), opacity 620ms ease;
}
.story-asset__image {
width: var(--node-size, 78px);
height: var(--node-size, 78px);
border-radius: 50%;
border-color: rgba(215, 168, 93, 0.68);
box-shadow: 0 0 0 4px rgba(215, 168, 93, 0.08), 0 18px 38px rgba(0, 0, 0, 0.28), 0 0 24px rgba(215, 168, 93, 0.13);
}
.story-asset__text {
position: relative;
z-index: 80;
display: grid;
justify-items: center;
gap: 2px;
width: min(140px, calc(var(--node-size, 78px) + 52px));
border: 1px solid rgba(215, 168, 93, 0.11);
border-radius: 10px;
padding: 4px 7px 5px;
background: rgba(4, 10, 20, 0.6);
box-shadow: 0 12px 26px rgba(0, 0, 0, 0.22);
backdrop-filter: blur(8px);
text-align: center;
}
.story-asset strong,
.story-asset span {
display: block;
overflow: hidden;
max-width: 100%;
line-height: 1.08;
text-overflow: ellipsis;
white-space: nowrap;
}
.story-asset strong {
font-size: 0.78rem;
}
.story-asset span {
color: var(--story-muted);
font-size: 0.64rem;
}
.story-exp-ribbon-wrap {
display: grid;
grid-template-rows: auto minmax(104px, auto) 56px;
@ -1186,7 +1222,7 @@ input:focus-visible {
.story-exp-insight {
display: grid;
gap: 2px;
gap: 1px;
border-color: rgba(255, 255, 255, 0.055);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.038), rgba(255, 255, 255, 0.018));
@ -1200,25 +1236,18 @@ input:focus-visible {
padding: 6px 7px;
}
.story-exp-panel--compact .story-exp-insight p {
.story-exp-insight p {
display: -webkit-box;
overflow: hidden;
margin: 0;
font-size: 0.72rem;
font-size: var(--story-side-body-font-size);
line-height: 1.16;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
-webkit-line-clamp: 3;
}
.story-exp-more {
border: 1px solid rgba(151, 185, 218, 0.12);
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-panel--compact .story-exp-insight p {
-webkit-line-clamp: 2;
}
.story-exp-list li,
@ -1228,7 +1257,7 @@ input:focus-visible {
.story-exp-insight strong {
color: var(--story-text);
font-size: 0.82rem;
font-size: var(--story-side-title-font-size);
line-height: 1.12;
}
@ -1387,8 +1416,7 @@ input:focus-visible {
transform: translateY(12px) scale(0.96);
}
.story-character.is-entering,
.story-asset.is-entering {
.story-character.is-entering {
opacity: 0;
transform: translate(var(--x, 0), var(--y, 0)) scale(0.82);
}

View File

@ -110,7 +110,7 @@
const layout = sceneLayout(current);
reconcileCharacters(current, previous, layout);
reconcileLocation(current, previous);
reconcileAssets(current, previous, layout);
clearAssetStage();
reconcileRelationships(current);
updateKnowledgeThreads(current);
updateSceneRibbon(current);
@ -231,6 +231,13 @@
function setEntityImage(image, url, resolution, altText) {
if (!image) return;
image.alt = altText;
if (!url) {
image.removeAttribute("src");
image.dataset.fallbackUrl = "";
image.dataset.entityId = resolution?.entityId || "";
image.dataset.libraryCode = resolution?.stableCode || "";
return;
}
const fallbackUrl = resolution?.fallbackImageUrl || url;
const nextUrl = failedImageUrls.has(url) ? fallbackUrl : url;
image.dataset.fallbackUrl = fallbackUrl || "";
@ -249,6 +256,115 @@
}
}
function shouldUseCharacterPortrait(character) {
const resolution = character?.imageResolution || {};
if (resolution.fallbackUsed || !resolution.finalImageUrl) return false;
const facts = characterVisualFacts(character);
if (facts.length < 2) return false;
const confidence = Number(resolution.assignmentConfidence || 0);
const score = Number(resolution.matchingScore || 0);
return confidence >= 0.78 || score >= 82;
}
function shouldUseAssetImage(asset) {
const resolution = asset?.imageResolution || {};
if (resolution.fallbackUsed || !resolution.finalImageUrl) return false;
const confidence = Number(resolution.assignmentConfidence || 0);
const score = Number(resolution.matchingScore || 0);
const code = String(resolution.stableCode || asset?.libraryCode || "").toLowerCase();
if (!code || code.includes("generic") || code.includes("unknown")) return false;
return confidence >= 0.86 || score >= 90;
}
function renderFactChips(container, facts) {
if (!container) return;
container.replaceChildren(...facts.slice(0, 4).map((fact) => {
const chip = document.createElement("span");
chip.textContent = fact;
return chip;
}));
}
function characterFacts(character, isPov) {
const facts = [];
if (isPov) facts.push("POV");
facts.push(...characterVisualFacts(character));
const role = conciseCharacterLabel(character, isPov);
if (role && !facts.some((fact) => sameFact(fact, role))) facts.push(role);
if (!facts.length) facts.push(character.weight >= 70 ? "significant" : "identified");
if (!shouldUseCharacterPortrait(character)) facts.push("appearance pending");
return uniqueFacts(facts);
}
function characterVisualFacts(character) {
const resolution = character?.imageResolution || {};
const evidence = parseEvidence(resolution.currentEvidence);
const facts = [
displayEvidenceValue(evidence.age || resolution.resolvedAgeBand),
displayEvidenceValue(evidence.presentation || resolution.resolvedPresentation),
displayHairFact(evidence.hair, evidence.hairLength),
displaySkinFact(evidence.skin)
];
return uniqueFacts(facts.filter(Boolean));
}
function assetFacts(asset) {
const facts = [];
const label = conciseAssetLabel(asset);
if (label) facts.push(label);
if (!shouldUseAssetImage(asset)) facts.push("text card");
return uniqueFacts(facts);
}
function parseEvidence(value) {
const result = {};
String(value || "").split(";").forEach((part) => {
const [key, raw] = part.split("=");
if (!key || raw === undefined) return;
result[key.trim().toLowerCase()] = raw.trim();
});
return result;
}
function displayEvidenceValue(value) {
const clean = String(value || "").trim();
if (!clean || clean.toLowerCase() === "unknown") return "";
return clean
.replace(/([a-z])([A-Z])/g, "$1 $2")
.replace(/\bOlder Teen\b/i, "late teen")
.replace(/\bYoung Adult\b/i, "young adult")
.toLowerCase();
}
function displayHairFact(colour, length) {
const hairColour = displayEvidenceValue(colour);
if (!hairColour) return "";
const hairLength = displayEvidenceValue(length);
return `${hairLength && hairLength !== "medium" ? `${hairLength} ` : ""}${hairColour} hair`;
}
function displaySkinFact(value) {
const skin = displayEvidenceValue(value);
return skin ? `${skin} skin` : "";
}
function uniqueFacts(facts) {
const seen = new Set();
return facts
.map((fact) => String(fact || "").trim())
.filter((fact) => {
if (!fact) return false;
const key = fact.toLowerCase();
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
function sameFact(a, b) {
return String(a || "").trim().toLowerCase() === String(b || "").trim().toLowerCase();
}
function preloadSceneImages(scene) {
if (!scene) return;
collectSceneImages(scene).forEach((url) => {
@ -265,7 +381,7 @@
return [
scene.location?.imagePath,
...(scene.characters || []).map((character) => character.imagePath),
...(scene.assets || []).map((asset) => asset.imagePath)
// Assets are recorded in insights/diagnostics but not rendered as stage nodes.
].filter(Boolean);
}
@ -319,10 +435,13 @@
node.style.setProperty("--opacity", String(position.opacity));
node.style.setProperty("--z", String(position.z));
node.dataset.layoutRole = position.layoutRole;
setEntityImage(node.querySelector("img"), character.imagePath, character.imageResolution, `${character.name} prototype portrait`);
const portraitReady = shouldUseCharacterPortrait(character);
node.classList.toggle("is-evidence-card", !portraitReady);
setEntityImage(node.querySelector("img"), portraitReady ? character.imagePath : "", character.imageResolution, `${character.name} prototype portrait`);
text(node.querySelector("[data-character-name]"), character.name);
const label = conciseCharacterLabel(character, character.id === scene.povCharacterId);
text(node.querySelector("[data-character-role]"), label);
renderFactChips(node.querySelector("[data-character-facts]"), characterFacts(character, character.id === scene.povCharacterId));
node.title = `${character.name}: ${character.role}${character.relevance ? ` - ${character.relevance}` : ""}`;
});
@ -371,40 +490,8 @@
node.title = `${scene.location.name}: ${scene.location.label}`;
}
function reconcileAssets(scene, previous, layout) {
const activeIds = new Set(scene.assets.map((asset) => asset.id));
scene.assets.forEach((asset, index) => {
let node = dom.assets.querySelector(`[data-asset-id="${cssEscape(asset.id)}"]`);
if (!node) {
node = createAssetNode(asset);
node.classList.add("is-entering");
dom.assets.append(node);
settleEntering(node);
}
const position = layout.assets.get(asset.id) || assetPosition(asset, index, scene.assets.length);
node.style.setProperty("--node-size", `${position.size}px`);
node.style.setProperty("--x", `${position.x}px`);
node.style.setProperty("--y", `${position.y}px`);
node.style.setProperty("--scale", String(position.scale));
node.style.setProperty("--z", String(position.z));
node.style.setProperty("--opacity", "1");
setEntityImage(node.querySelector("img"), asset.imagePath, asset.imageResolution, `${asset.name} prototype asset`);
text(node.querySelector("[data-asset-name]"), asset.name);
text(node.querySelector("[data-asset-label]"), conciseAssetLabel(asset));
node.title = `${asset.name}: ${asset.label}`;
});
dom.assets.querySelectorAll("[data-asset-id]").forEach((node) => {
if (!activeIds.has(node.dataset.assetId)) {
node.style.setProperty("--opacity", "0");
node.style.setProperty("--scale", "0.8");
window.setTimeout(() => {
const currentIds = new Set(loadSceneState(state.index).assets.map((asset) => asset.id));
if (!currentIds.has(node.dataset.assetId)) node.remove();
}, reducedMotion ? 0 : 520);
}
});
function clearAssetStage() {
dom.assets?.replaceChildren();
}
function reconcileRelationships(scene) {
@ -414,15 +501,12 @@
detail: relationship.state,
tone: relationship.weight >= 90 ? "confirmed" : relationship.weight >= 70 ? "strengthening" : "emerging"
}));
renderInsights(dom.relationships, relationships.slice(0, 2));
renderOverflowSummary(dom.relationships, relationships.length - 2, "relationship");
renderInsights(dom.relationships, relationships.slice(0, 1));
}
function updateKnowledgeThreads(scene) {
renderInsights(dom.knowledge, (scene.knowledgeThreads || []).slice(0, 3));
renderOverflowSummary(dom.knowledge, (scene.knowledgeThreads || []).length - 3, "thread");
renderInsights(dom.observations, (scene.observations || []).slice(0, 2));
renderOverflowSummary(dom.observations, (scene.observations || []).length - 2, "observation");
renderInsights(dom.knowledge, (scene.knowledgeThreads || []).slice(0, 5));
renderInsights(dom.observations, (scene.observations || []).slice(0, 4));
}
function updateSceneRibbon(scene) {
@ -544,26 +628,6 @@
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) {
const activeIds = new Set(items.map((item, index) => feedId(item, index)));
items.forEach((item, index) => {
@ -618,7 +682,10 @@
const activeIds = new Set();
const occupiedRects = layoutRects(stageRect);
scene.relationships.forEach((relationship, index) => {
scene.relationships
.filter((relationship) => relationship.weight >= 72)
.slice(0, 2)
.forEach((relationship, index) => {
const source = dom.visualStage.querySelector(`[data-character-id="${cssEscape(relationship.sourceId)}"]`);
const target = dom.visualStage.querySelector(`[data-character-id="${cssEscape(relationship.targetId)}"]`);
if (!source || !target) return;
@ -628,37 +695,22 @@
const obstacles = connectionObstacles(stageRect, source, target);
const id = relationship.id || `relationship-${relationship.sourceId}-${relationship.targetId}`;
activeIds.add(id);
drawn.push(drawConnection(id, start, end, relationshipTone(relationship), relationship.weight, index < 2 ? inlineRelationshipLabel(relationship) : "", index, obstacles, occupiedRects));
drawn.push(drawConnection(id, start, end, relationshipTone(relationship), relationship.weight, "", index, obstacles, occupiedRects));
});
const pov = dom.visualStage.querySelector(`[data-character-id="${cssEscape(scene.povCharacterId)}"]`);
const location = dom.visualStage.querySelector("[data-location-id]");
if (pov && location) {
const id = `presence-${scene.povCharacterId}-${location.dataset.locationId}`;
const presentCharacters = scene.characters
.filter((character) => !character.status || !character.status.toLowerCase().includes("absent"))
.map((character) => dom.visualStage.querySelector(`[data-character-id="${cssEscape(character.id)}"]`))
.filter(Boolean);
const cluster = characterClusterPoint(presentCharacters, stageRect);
if (cluster && location) {
const id = `presence-cluster-${location.dataset.locationId}`;
activeIds.add(id);
drawn.push(drawConnection(id, connectionPoint(pov, stageRect, location), connectionPoint(location, stageRect, pov), "presence", 68, "", drawn.length, connectionObstacles(stageRect, pov, location), occupiedRects));
const locationPoint = connectionPoint(location, stageRect, { getBoundingClientRect: () => ({ left: stageRect.left + cluster.x, top: stageRect.top + cluster.y, width: 0, height: 0 }) });
drawn.push(drawConnection(id, cluster, locationPoint, "presence-cluster", 54, `${presentCharacters.length} here`, drawn.length, [], occupiedRects));
}
scene.characters
.filter((character) => character.id !== scene.povCharacterId && character.weight >= 65)
.slice(0, 1)
.forEach((character) => {
const node = dom.visualStage.querySelector(`[data-character-id="${cssEscape(character.id)}"]`);
if (node && location) {
const id = `presence-soft-${character.id}-${location.dataset.locationId}`;
activeIds.add(id);
drawn.push(drawConnection(id, connectionPoint(node, stageRect, location), connectionPoint(location, stageRect, node), "presence-soft", character.weight, "", drawn.length, connectionObstacles(stageRect, node, location), occupiedRects));
}
});
scene.assets.forEach((asset, index) => {
const assetNode = dom.visualStage.querySelector(`[data-asset-id="${cssEscape(asset.id)}"]`);
if (!assetNode || !location) return;
const id = `asset-${location.dataset.locationId}-${asset.id}`;
activeIds.add(id);
drawn.push(drawConnection(id, connectionPoint(location, stageRect, assetNode), connectionPoint(assetNode, stageRect, location), "evidence", asset.weight, index === 0 ? assetConnectionLabel(asset) : "", drawn.length, connectionObstacles(stageRect, location, assetNode), occupiedRects));
});
removeInactiveSvgConnections(activeIds);
}
@ -681,7 +733,7 @@
path.setAttribute("class", "story-exp-connection");
group.append(path);
}
const route = routeConnection(start, end, index, obstacles);
const route = routeConnection(start, end, index, obstacles, tone);
const d = `M ${start.x} ${start.y} C ${route.startControl.x} ${route.startControl.y}, ${route.endControl.x} ${route.endControl.y}, ${end.x} ${end.y}`;
path.setAttribute("d", d);
path.style.strokeWidth = String(0.9 + Math.min(weight, 100) / 62);
@ -731,19 +783,7 @@
<div class="story-character__text">
<strong data-character-name></strong>
<span data-character-role></span>
</div>`;
return node;
}
function createAssetNode(asset) {
const node = document.createElement("article");
node.className = "story-asset";
node.dataset.assetId = asset.id;
node.innerHTML = `
<div class="story-asset__image"><img loading="eager" /></div>
<div class="story-asset__text">
<strong data-asset-name></strong>
<span data-asset-label></span>
<div class="story-entity-facts" data-character-facts></div>
</div>`;
return node;
}
@ -753,8 +793,7 @@
const width = Math.max(760, bounds?.width || 1040);
const height = Math.max(500, bounds?.height || 560);
return {
characters: characterLayout(scene, width, height),
assets: assetLayout(scene, width, height)
characters: characterLayout(scene, width, height)
};
}
@ -765,10 +804,10 @@
const others = characters
.filter((character) => character.id !== pov?.id)
.sort((a, b) => b.weight - a.weight);
const povSize = Math.round(Math.min(198, Math.max(154, width * 0.155)));
const povSize = Math.round(Math.min(188, Math.max(150, width * 0.142)));
if (pov) {
positions.set(pov.id, {
x: clamp(Math.round(width * 0.24 - povSize / 2), 28, width * 0.44 - povSize),
x: clamp(Math.round(width * 0.28 - povSize / 2), 28, width * 0.58 - povSize),
y: clamp(Math.round(height * 0.28 - povSize / 2), 54, height * 0.54 - povSize),
size: povSize,
scale: 1,
@ -780,11 +819,14 @@
}
const slots = [
{ x: 0.41, y: 0.48, role: "partner" },
{ x: 0.12, y: 0.55, role: "support" },
{ x: 0.11, y: 0.19, role: "support" },
{ x: 0.33, y: 0.70, role: "context" },
{ x: 0.05, y: 0.36, role: "context" }
{ x: 0.55, y: 0.46, role: "partner" },
{ x: 0.14, y: 0.58, role: "support" },
{ x: 0.12, y: 0.18, role: "support" },
{ x: 0.42, y: 0.72, role: "context" },
{ x: 0.34, y: 0.46, role: "context" },
{ x: 0.60, y: 0.66, role: "context" },
{ x: 0.06, y: 0.38, role: "context" },
{ x: 0.24, y: 0.76, role: "context" }
];
const placed = [...positions.values()];
others.forEach((character, index) => {
@ -862,64 +904,14 @@
const bounds = dom.visualStage?.getBoundingClientRect();
const width = Math.max(820, bounds?.width || 1040);
const height = Math.max(500, bounds?.height || 560);
const size = Math.min(220, Math.max(176, width * 0.18));
const size = Math.min(188, Math.max(154, width * 0.14));
return {
x: Math.round(width * 0.62 - size / 2),
x: Math.round(width * 0.84 - size / 2),
y: Math.round(height * 0.46 - size / 2),
size
};
}
function assetPosition(asset, index, total) {
const bounds = dom.visualStage?.getBoundingClientRect();
const width = Math.max(820, bounds?.width || 1040);
const height = Math.max(500, bounds?.height || 560);
const anchors = [
{ x: 0.83, y: 0.26 },
{ x: 0.84, y: 0.51 },
{ x: 0.82, y: 0.74 },
{ x: 0.72, y: 0.19 }
];
const anchor = anchors[index % anchors.length];
const size = Math.min(104, Math.max(68, (64 + Math.min(asset.weight, 100) * 0.42) * 0.9));
return {
x: Math.round(width * anchor.x - size / 2),
y: Math.round(height * anchor.y - size / 2),
size: Math.round(size),
scale: 1
};
}
function assetLayout(scene, width, height) {
const positions = new Map();
const total = scene.assets.length;
const anchorsByTotal = {
1: [{ x: 0.83, y: 0.48 }],
2: [{ x: 0.83, y: 0.34 }, { x: 0.83, y: 0.62 }],
3: [{ x: 0.82, y: 0.25 }, { x: 0.85, y: 0.50 }, { x: 0.81, y: 0.75 }],
4: [{ x: 0.78, y: 0.16 }, { x: 0.90, y: 0.37 }, { x: 0.86, y: 0.62 }, { x: 0.73, y: 0.82 }],
5: [{ x: 0.76, y: 0.13 }, { x: 0.91, y: 0.30 }, { x: 0.80, y: 0.48 }, { x: 0.92, y: 0.66 }, { x: 0.72, y: 0.84 }]
};
const anchors = anchorsByTotal[Math.min(5, Math.max(1, total))] || anchorsByTotal[5];
const placed = [];
scene.assets.forEach((asset, index) => {
const anchor = anchors[index] || anchors[anchors.length - 1];
const important = index === 0 || asset.weight >= 90;
const size = Math.round(important
? Math.min(104, Math.max(83, (70 + Math.min(asset.weight, 100) * 0.42) * 0.9))
: Math.min(88, Math.max(68, (58 + Math.min(asset.weight, 100) * 0.32) * 0.9)));
const desired = {
x: width * anchor.x - size / 2,
y: height * anchor.y - size / 2
};
const resolved = avoidNodeCollisions(desired, size, placed, width, height);
const position = { x: resolved.x, y: resolved.y, size, scale: 1, z: important ? 4 : 3 };
placed.push(position);
positions.set(asset.id, position);
});
return positions;
}
function avoidNodeCollisions(desired, size, placed, width, height) {
const padding = Math.max(16, size * 0.12);
const candidates = [
@ -965,11 +957,11 @@
}
function connectionPoint(node, parentRect, targetNode) {
const image = node.querySelector(".story-character__image, .story-location__image, .story-asset__image") || node;
const image = node.querySelector(".story-character__image, .story-location__image") || node;
const centre = centrePoint(image, parentRect);
const rect = image.getBoundingClientRect();
const target = targetNode ? centrePoint(targetNode, parentRect) : centre;
const targetImage = targetNode?.querySelector?.(".story-character__image, .story-location__image, .story-asset__image");
const targetImage = targetNode?.querySelector?.(".story-character__image, .story-location__image");
const targetCentre = targetImage ? centrePoint(targetImage, parentRect) : target;
const radius = Math.min(rect.width, rect.height) * 0.5;
const angle = Math.atan2(targetCentre.y - centre.y, targetCentre.x - centre.x);
@ -979,14 +971,28 @@
};
}
function routeConnection(start, end, index, obstacles) {
function characterClusterPoint(nodes, parentRect) {
if (!nodes.length) return null;
const rects = nodes
.map((node) => node.querySelector(".story-character__image") || node)
.map((node) => node.getBoundingClientRect());
const right = Math.max(...rects.map((rect) => rect.right)) - parentRect.left;
const top = Math.min(...rects.map((rect) => rect.top)) - parentRect.top;
const bottom = Math.max(...rects.map((rect) => rect.bottom)) - parentRect.top;
return {
x: right + 18,
y: (top + bottom) / 2
};
}
function routeConnection(start, end, index, obstacles, tone) {
const mid = { x: (start.x + end.x) / 2, y: (start.y + end.y) / 2 };
const dx = end.x - start.x;
const dy = end.y - start.y;
const length = Math.max(1, Math.hypot(dx, dy));
const normal = { x: -dy / length, y: dx / length };
const tangent = { x: dx / length, y: dy / length };
const base = Math.max(34, Math.min(122, length * 0.2));
const base = tone === "presence-cluster" ? Math.max(18, Math.min(46, length * 0.08)) : Math.max(22, Math.min(68, length * 0.12));
const direction = index % 2 === 0 ? 1 : -1;
const offsets = [direction * base, -direction * base, direction * base * 1.45, -direction * base * 1.45, 0];
let best = offsets[0];
@ -1020,14 +1026,14 @@
}
function layoutRects(parentRect) {
return [...dom.visualStage.querySelectorAll(".story-character__image, .story-location__image, .story-asset__image, .story-character__text, .story-location__text, .story-asset__text")]
return [...dom.visualStage.querySelectorAll(".story-character__image, .story-location__image, .story-character__text, .story-location__text")]
.map((node) => rectRelativeTo(node, parentRect, node.className.includes("__text") ? 8 : 10));
}
function connectionObstacles(parentRect, source, target) {
const sourceImage = source.querySelector(".story-character__image, .story-location__image, .story-asset__image") || source;
const targetImage = target.querySelector(".story-character__image, .story-location__image, .story-asset__image") || target;
return [...dom.visualStage.querySelectorAll(".story-character__image, .story-location__image, .story-asset__image, .story-character__text, .story-location__text, .story-asset__text")]
const sourceImage = source.querySelector(".story-character__image, .story-location__image") || source;
const targetImage = target.querySelector(".story-character__image, .story-location__image") || target;
return [...dom.visualStage.querySelectorAll(".story-character__image, .story-location__image, .story-character__text, .story-location__text")]
.filter((node) => node !== sourceImage && node !== targetImage)
.map((node) => rectRelativeTo(node, parentRect, node.className.includes("__text") ? 8 : 12));
}
@ -1558,7 +1564,7 @@
const layout = sceneLayout(current);
reconcileCharacters(current, current, layout);
reconcileLocation(current, current);
reconcileAssets(current, current, layout);
clearAssetStage();
scheduleConnectionDraw();
});
@ -1568,7 +1574,7 @@
const current = loadSceneState(state.index);
const layout = sceneLayout(current);
reconcileCharacters(current, current, layout);
reconcileAssets(current, current, layout);
clearAssetStage();
scheduleConnectionDraw();
});
observer.observe(dom.visualStage);