Compare commits
17 Commits
d40c607d9b
...
9bb738f07e
| Author | SHA1 | Date | |
|---|---|---|---|
| 9bb738f07e | |||
| 35c56d2c2c | |||
| d103953eb5 | |||
| b2b7fbc735 | |||
| 17144c64d3 | |||
| 588a223b44 | |||
| 049628b295 | |||
| 9db13a7fd6 | |||
| 72148565e2 | |||
| d7fe153816 | |||
| f02569b7d4 | |||
| 24bafca765 | |||
| 0f83f739c5 | |||
| 02bc4fd2de | |||
| 61b4d9d288 | |||
| 8b2448ecd0 | |||
| 311fbcbbb0 |
@ -39,6 +39,7 @@ var tests = new (string Name, Action Test)[]
|
|||||||
("Illustration starter generation plan skips successful items", IllustrationStarterGenerationPlanSkipsSuccessfulItems),
|
("Illustration starter generation plan skips successful items", IllustrationStarterGenerationPlanSkipsSuccessfulItems),
|
||||||
("Story Intelligence prototype uses starter illustration codes", StoryIntelligencePrototypeUsesStarterIllustrationCodes),
|
("Story Intelligence prototype uses starter illustration codes", StoryIntelligencePrototypeUsesStarterIllustrationCodes),
|
||||||
("Story Intelligence simulation mode remains available", StoryIntelligenceSimulationModeRemainsAvailable),
|
("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),
|
("Story Intelligence visualisation contract omits raw manuscript text", StoryIntelligenceVisualisationContractOmitsRawManuscriptText),
|
||||||
("Illustration bulk retry result reports counts", IllustrationBulkRetryResultReportsCounts),
|
("Illustration bulk retry result reports counts", IllustrationBulkRetryResultReportsCounts),
|
||||||
("Illustration bulk retry skips blocking duplicate stable codes", IllustrationBulkRetrySkipsBlockingDuplicateStableCodes),
|
("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 replay uses persisted data only", Phase21RReplayUsesPersistedDataOnly),
|
||||||
("Phase 21R scene browser and panels are replay friendly", Phase21RSceneBrowserAndPanelsAreReplayFriendly),
|
("Phase 21R scene browser and panels are replay friendly", Phase21RSceneBrowserAndPanelsAreReplayFriendly),
|
||||||
("Phase 21S uses durable Story Memory assignments", Phase21SUsesDurableStoryMemoryAssignments),
|
("Phase 21S uses durable Story Memory assignments", Phase21SUsesDurableStoryMemoryAssignments),
|
||||||
|
("Phase 21T repairs durable character understanding", Phase21TRepairsDurableCharacterUnderstanding),
|
||||||
|
("Phase 21U extracts mandatory character appearance fixtures", Phase21UExtractsMandatoryCharacterAppearanceFixtures),
|
||||||
|
("Phase 21U enforces alias age semantic and UI reset rules", Phase21UEnforcesAliasAgeSemanticAndUiResetRules),
|
||||||
("Story Intelligence visual polish keeps labels fixed and semantics specific", StoryIntelligenceVisualPolishKeepsLabelsFixedAndSemanticsSpecific),
|
("Story Intelligence visual polish keeps labels fixed and semantics specific", StoryIntelligenceVisualPolishKeepsLabelsFixedAndSemanticsSpecific),
|
||||||
("Scan review post supports full-book form submissions", ScanReviewPostSupportsFullBookFormSubmissions),
|
("Scan review post supports full-book form submissions", ScanReviewPostSupportsFullBookFormSubmissions),
|
||||||
("Story Intelligence experience boot does not serialise live model", StoryIntelligenceExperienceBootDoesNotSerialiseLiveModel),
|
("Story Intelligence experience boot does not serialise live model", StoryIntelligenceExperienceBootDoesNotSerialiseLiveModel),
|
||||||
@ -556,7 +560,7 @@ static void StoryIntelligenceEvidenceExtractionPreventsObservedAttributeLeakage(
|
|||||||
Assert(toddler.AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.Child, $"Toddler should be Child, got {toddler.AgeBand}.");
|
Assert(toddler.AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.Child, $"Toddler should be Child, got {toddler.AgeBand}.");
|
||||||
Assert(toddler.Presentation == StoryIntelligenceIllustrationCompatibility.Presentations.Masculine, $"Boy toddler should be Masculine, got {toddler.Presentation}.");
|
Assert(toddler.Presentation == StoryIntelligenceIllustrationCompatibility.Presentations.Masculine, $"Boy toddler should be Masculine, got {toddler.Presentation}.");
|
||||||
|
|
||||||
Assert(StoryIntelligenceIllustrationCompatibility.LocationType("Cock Hill Lane") == "Road", "Cock Hill Lane must classify as Road, not interior.");
|
Assert(StoryIntelligenceIllustrationCompatibility.LocationType("Cock Hill Lane") == "Lane", "Cock Hill Lane must classify as Lane, not interior.");
|
||||||
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("money") == "Money", "Money must not classify as Car.");
|
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("money") == "Money", "Money must not classify as Car.");
|
||||||
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("bottle") == "Bottle", "Bottle must not classify as LockedBox.");
|
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("bottle") == "Bottle", "Bottle must not classify as LockedBox.");
|
||||||
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("scarves") == "Clothing", "Scarves must not classify as Car.");
|
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("scarves") == "Clothing", "Scarves must not classify as Car.");
|
||||||
@ -633,7 +637,7 @@ static void Phase21OPreservesAdultDefaultsAndRealModeContinuity()
|
|||||||
Assert(examiner.Presentation == StoryIntelligenceIllustrationCompatibility.Presentations.Feminine, $"Female examiner should infer Feminine, got {examiner.Presentation}.");
|
Assert(examiner.Presentation == StoryIntelligenceIllustrationCompatibility.Presentations.Feminine, $"Female examiner should infer Feminine, got {examiner.Presentation}.");
|
||||||
Assert(examiner.AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.Adult, $"Driving examiner should infer Adult, got {examiner.AgeBand}.");
|
Assert(examiner.AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.Adult, $"Driving examiner should infer Adult, got {examiner.AgeBand}.");
|
||||||
Assert(StoryIntelligenceIllustrationCompatibility.LocationType("Bristol Road") == "Road", "Road names should classify as Road.");
|
Assert(StoryIntelligenceIllustrationCompatibility.LocationType("Bristol Road") == "Road", "Road names should classify as Road.");
|
||||||
Assert(StoryIntelligenceIllustrationCompatibility.LocationType("Cock Hill Lane") == "Road", "Lane names should classify as Road.");
|
Assert(StoryIntelligenceIllustrationCompatibility.LocationType("Cock Hill Lane") == "Lane", "Lane names should classify as Lane.");
|
||||||
Assert(StoryIntelligenceIllustrationCompatibility.AssetCompatible("Hatchback", "Car"), "Hatchbacks may fall back to neutral generic car artwork.");
|
Assert(StoryIntelligenceIllustrationCompatibility.AssetCompatible("Hatchback", "Car"), "Hatchbacks may fall back to neutral generic car artwork.");
|
||||||
Assert(!StoryIntelligenceIllustrationCompatibility.AssetCompatible("SportsCar", "Hatchback"), "Vehicle subtypes should remain strict.");
|
Assert(!StoryIntelligenceIllustrationCompatibility.AssetCompatible("SportsCar", "Hatchback"), "Vehicle subtypes should remain strict.");
|
||||||
|
|
||||||
@ -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.");
|
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(
|
=> new(
|
||||||
ageBand,
|
ageBand,
|
||||||
presentation,
|
presentation,
|
||||||
hairColour,
|
hairColour,
|
||||||
"Unknown",
|
skinTone,
|
||||||
"Unknown",
|
"Unknown",
|
||||||
"Unknown",
|
"Unknown",
|
||||||
"Unknown",
|
"Unknown",
|
||||||
@ -674,6 +678,16 @@ static IllustrationCharacterMetadata Metadata(string code, string ageBand, strin
|
|||||||
IllustrationPromptBuilder.CurrentTemplateVersion,
|
IllustrationPromptBuilder.CurrentTemplateVersion,
|
||||||
"Test");
|
"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)
|
static IReadOnlyList<string> Rejected(CharacterEvidenceProfile evidence, IllustrationCharacterMetadata metadata)
|
||||||
=> StoryIntelligenceIllustrationCompatibility.CharacterHardRejections(evidence, metadata, isNamedCharacter: true, alreadyAssignedToAnotherSignificantCharacter: false);
|
=> 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.");
|
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()
|
static void StoryIntelligenceVisualisationContractOmitsRawManuscriptText()
|
||||||
{
|
{
|
||||||
var model = new StoryIntelligenceExperiencePrototypeViewModel
|
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(maggie.HairColour == StoryIntelligenceIllustrationCompatibility.HairColours.Red, $"Maggie red-hair evidence should be explicit, got {maggie.HairColour}.");
|
||||||
Assert(Rejected(maggie, darkHairCandidate).Any(reason => reason.Contains("hair colour hard mismatch", StringComparison.OrdinalIgnoreCase)), "Red-hair evidence must invalidate dark-haired assignments.");
|
Assert(Rejected(maggie, darkHairCandidate).Any(reason => reason.Contains("hair colour hard mismatch", StringComparison.OrdinalIgnoreCase)), "Red-hair evidence must invalidate dark-haired assignments.");
|
||||||
|
|
||||||
Assert(StoryIntelligenceIllustrationCompatibility.LocationType("Cock Hill Lane") == "Road", "Cock Hill Lane should classify as Road/Lane.");
|
Assert(StoryIntelligenceIllustrationCompatibility.LocationType("Cock Hill Lane") == "Lane", "Cock Hill Lane should classify as Lane.");
|
||||||
Assert(StoryIntelligenceIllustrationCompatibility.LocationType("derelict hospital waiting room") == "Hospital", "Derelict hospital should remain Hospital rather than generic interior.");
|
Assert(StoryIntelligenceIllustrationCompatibility.LocationType("derelict hospital waiting room") == "DerelictHospital", "Derelict hospital should remain typed rather than generic interior.");
|
||||||
Assert(StoryIntelligenceIllustrationCompatibility.LocationType("bench beside the road") == "Road", "Bench context beside road should not become indoor room artwork.");
|
Assert(StoryIntelligenceIllustrationCompatibility.LocationType("bench beside the road") == "Road", "Bench context beside road should not become indoor room artwork.");
|
||||||
Assert(!StoryIntelligenceIllustrationCompatibility.LocationCompatible("Road", "CarPark"), "Lane/Road must not use car park artwork.");
|
Assert(!StoryIntelligenceIllustrationCompatibility.LocationCompatible("Road", "CarPark"), "Lane/Road must not use car park artwork.");
|
||||||
Assert(!StoryIntelligenceIllustrationCompatibility.LocationCompatible("WaitingRoom", "FlatInterior"), "Waiting room must not use flat interior artwork.");
|
Assert(!StoryIntelligenceIllustrationCompatibility.LocationCompatible("WaitingRoom", "FlatInterior"), "Waiting room must not use flat interior artwork.");
|
||||||
@ -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.Contains("data-scene-title", StringComparison.Ordinal), "Redundant scene heading should be removed from the real visualisation.");
|
||||||
Assert(view.IndexOf("data-knowledge", StringComparison.Ordinal) < view.IndexOf("data-relationships", StringComparison.Ordinal), "Knowledge threads should be prioritised above relationship changes.");
|
Assert(view.IndexOf("data-knowledge", StringComparison.Ordinal) < view.IndexOf("data-relationships", StringComparison.Ordinal), "Knowledge threads should be prioritised above relationship changes.");
|
||||||
Assert(view.Contains("Model.Mode == \"simulation\" || Model.Mode == \"replay\"", StringComparison.Ordinal), "Prototype controls must remain hidden in live mode while supporting replay.");
|
Assert(view.Contains("Model.Mode == \"simulation\" || Model.Mode == \"replay\"", StringComparison.Ordinal), "Prototype controls must remain hidden in live mode while supporting replay.");
|
||||||
Assert(script.Contains("relationships.slice(0, 2)", StringComparison.Ordinal), "Relationship changes should be capped to the two most relevant visible cards.");
|
Assert(script.Contains("relationships.slice(0, 1)", StringComparison.Ordinal), "Relationship changes should be capped to the most relevant visible card.");
|
||||||
Assert(script.Contains("renderOverflowSummary", StringComparison.Ordinal), "Relationship overflow should be indicated compactly.");
|
Assert(!script.Contains("renderOverflowSummary", StringComparison.Ordinal), "Side panels must not hide readable content behind '+N more' overflow controls.");
|
||||||
Assert(css.Contains("max-height: calc(100vh - 126px)", StringComparison.Ordinal), "Side columns should be bounded to the viewport.");
|
Assert(css.Contains("max-height: calc(100vh - 126px)", StringComparison.Ordinal), "Side columns should be bounded to the viewport.");
|
||||||
Assert(css.Contains("overflow-y: auto", StringComparison.Ordinal), "Side columns should keep a single hidden-track vertical scroll.");
|
Assert(css.Contains("overflow-y: auto", StringComparison.Ordinal), "Side columns should keep a single hidden-track vertical scroll.");
|
||||||
Assert(css.Contains("z-index: 180", StringComparison.Ordinal), "Character labels should render in a high label layer above nodes and paths.");
|
Assert(css.Contains("z-index: 180", StringComparison.Ordinal), "Character labels should render in a high label layer above nodes and paths.");
|
||||||
@ -970,6 +992,180 @@ static void Phase21SUsesDurableStoryMemoryAssignments()
|
|||||||
Assert(development.Contains("RebuildImportSessionAsync", StringComparison.Ordinal), "Development rebuild action must call the durable memory rebuild.");
|
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()
|
static void Phase21RSceneBrowserAndPanelsAreReplayFriendly()
|
||||||
{
|
{
|
||||||
var view = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Views/Development/StoryIntelligenceExperience.cshtml"));
|
var view = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Views/Development/StoryIntelligenceExperience.cshtml"));
|
||||||
@ -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(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(".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("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(!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.");
|
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("background: rgba(4, 10, 20, 0.91)", StringComparison.Ordinal), "Character caption opacity should be increased for readability.");
|
||||||
Assert(css.Contains(".story-exp-insight span {\n display: none;", StringComparison.Ordinal), "Redundant insight tone headings should be hidden.");
|
Assert(css.Contains(".story-exp-insight span {\n display: none;", StringComparison.Ordinal), "Redundant insight tone headings should be hidden.");
|
||||||
|
|
||||||
Assert(StoryIntelligenceIllustrationCompatibility.LocationType("Cock Hill Lane") == "Road", "Cock Hill Lane should classify as Road.");
|
Assert(StoryIntelligenceIllustrationCompatibility.LocationType("Cock Hill Lane") == "Lane", "Cock Hill Lane should classify as Lane.");
|
||||||
Assert(StoryIntelligenceIllustrationCompatibility.LocationType("Mount Pleasant Road") == "Road", "Mount Pleasant Road should classify as Road.");
|
Assert(StoryIntelligenceIllustrationCompatibility.LocationType("Mount Pleasant Road") == "Road", "Mount Pleasant Road should classify as Road.");
|
||||||
Assert(StoryIntelligenceIllustrationCompatibility.LocationType("Queens Court") == "Road", "Court should classify as Road.");
|
Assert(StoryIntelligenceIllustrationCompatibility.LocationType("Queens Court") == "Road", "Court should classify as Road.");
|
||||||
Assert(StoryIntelligenceIllustrationCompatibility.LocationType("Station Gardens") == "Road", "Gardens should classify as Road.");
|
Assert(StoryIntelligenceIllustrationCompatibility.LocationType("Station Gardens") == "Road", "Gardens should classify as Road.");
|
||||||
Assert(StoryIntelligenceIllustrationCompatibility.LocationType("driver seat inside the car") == "VehicleInterior", "Driver seat should classify as VehicleInterior.");
|
Assert(StoryIntelligenceIllustrationCompatibility.LocationType("driver seat inside the car") == "DriverSeat", "Driver seat should classify as DriverSeat.");
|
||||||
Assert(StoryIntelligenceIllustrationCompatibility.LocationType("Driving Test Centre waiting room") == "DrivingTestCentre", "Driving test centre should have its own location family.");
|
Assert(StoryIntelligenceIllustrationCompatibility.LocationType("Driving Test Centre waiting room") == "DrivingTestCentre", "Driving test centre should have its own location family.");
|
||||||
Assert(!StoryIntelligenceIllustrationCompatibility.LocationCompatible("VehicleInterior", "HouseExterior"), "Vehicle interiors must not resolve to houses or buildings.");
|
Assert(!StoryIntelligenceIllustrationCompatibility.LocationCompatible("VehicleInterior", "HouseExterior"), "Vehicle interiors must not resolve to houses or buildings.");
|
||||||
|
|
||||||
@ -1019,7 +1230,7 @@ static void StoryIntelligenceVisualPolishKeepsLabelsFixedAndSemanticsSpecific()
|
|||||||
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("wall clock") == "Clock", "Clock should classify as Clock.");
|
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("wall clock") == "Clock", "Clock should classify as Clock.");
|
||||||
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("park bench") == "Bench", "Bench should classify as Bench.");
|
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("\"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-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.");
|
Assert(snapshot.Contains("\"asset-traffic-sign\" => $\"{FallbackRoot}/asset-traffic-sign.svg\"", StringComparison.Ordinal), "Traffic signs need a recognisable fallback.");
|
||||||
|
|||||||
@ -86,9 +86,13 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
|
|||||||
|
|
||||||
[HttpPost("story-intelligence/start")]
|
[HttpPost("story-intelligence/start")]
|
||||||
[ValidateAntiForgeryToken]
|
[ValidateAntiForgeryToken]
|
||||||
public async Task<IActionResult> StartStoryIntelligence()
|
public async Task<IActionResult> StartStoryIntelligence(
|
||||||
|
[FromForm] string? appearancePreference,
|
||||||
|
[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)
|
if (job is null)
|
||||||
{
|
{
|
||||||
TempData["OnboardingStoryIntelligenceError"] = "Review the manuscript scan before starting Story Intelligence.";
|
TempData["OnboardingStoryIntelligenceError"] = "Review the manuscript scan before starting Story Intelligence.";
|
||||||
|
|||||||
@ -24,7 +24,9 @@ public interface IStoryMemoryRepository
|
|||||||
Task InvalidateAssignmentAsync(int assignmentId, string reason);
|
Task InvalidateAssignmentAsync(int assignmentId, string reason);
|
||||||
Task<StoryMemoryIllustrationDemand> UpsertDemandAsync(StoryMemoryIllustrationDemandSave request);
|
Task<StoryMemoryIllustrationDemand> UpsertDemandAsync(StoryMemoryIllustrationDemandSave request);
|
||||||
Task<string> GetAppearancePreferenceAsync(int importSessionId);
|
Task<string> GetAppearancePreferenceAsync(int importSessionId);
|
||||||
|
Task<StoryMemoryImportContext> GetImportContextAsync(int importSessionId);
|
||||||
Task SetAppearancePreferenceAsync(int importSessionId, string preference);
|
Task SetAppearancePreferenceAsync(int importSessionId, string preference);
|
||||||
|
Task SetImportContextAsync(int importSessionId, string appearancePreference, string? primaryAgeGroup, string? storyEra, string? storyLocation);
|
||||||
Task<StoryMemoryDiagnostics> GetDiagnosticsAsync(int importSessionId);
|
Task<StoryMemoryDiagnostics> GetDiagnosticsAsync(int importSessionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -99,8 +101,8 @@ public sealed class StoryMemoryRepository(ISqlConnectionFactory connectionFactor
|
|||||||
WHEN MATCHED THEN
|
WHEN MATCHED THEN
|
||||||
UPDATE SET
|
UPDATE SET
|
||||||
DisplayName = CASE WHEN LEN(@DisplayName) > LEN(target.DisplayName) THEN @DisplayName ELSE target.DisplayName END,
|
DisplayName = CASE WHEN LEN(@DisplayName) > LEN(target.DisplayName) THEN @DisplayName ELSE target.DisplayName END,
|
||||||
EntityContext = @EntityContext,
|
EntityContext = CASE WHEN target.EntityContext = N'Story' OR @EntityContext = N'Story' THEN N'Story' ELSE @EntityContext END,
|
||||||
SourceSectionType = @SourceSectionType,
|
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,
|
IsNamed = CASE WHEN @IsNamed = 1 THEN 1 ELSE target.IsNamed END,
|
||||||
IsGroupEntity = CASE WHEN @IsGroupEntity = 1 THEN 1 ELSE target.IsGroupEntity END,
|
IsGroupEntity = CASE WHEN @IsGroupEntity = 1 THEN 1 ELSE target.IsGroupEntity END,
|
||||||
IsResolved = CASE WHEN @IsResolved = 1 THEN 1 ELSE target.IsResolved 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)
|
public async Task<string> GetAppearancePreferenceAsync(int importSessionId)
|
||||||
|
=> (await GetImportContextAsync(importSessionId)).AppearancePreference;
|
||||||
|
|
||||||
|
public async Task<StoryMemoryImportContext> GetImportContextAsync(int importSessionId)
|
||||||
{
|
{
|
||||||
using var connection = connectionFactory.CreateConnection();
|
using var connection = connectionFactory.CreateConnection();
|
||||||
return await connection.QuerySingleOrDefaultAsync<string>(
|
await EnsureImportPreferenceContextColumnsAsync(connection);
|
||||||
"SELECT AppearancePreference FROM dbo.StoryMemoryImportPreferences WHERE ImportSessionID = @ImportSessionID;",
|
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 })
|
new { ImportSessionID = importSessionId })
|
||||||
?? StoryMemoryAppearancePreferences.Balanced;
|
?? new StoryMemoryImportContext(StoryMemoryAppearancePreferences.Balanced, string.Empty, string.Empty, string.Empty);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task SetAppearancePreferenceAsync(int importSessionId, string preference)
|
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();
|
using var connection = connectionFactory.CreateConnection();
|
||||||
|
await EnsureImportPreferenceContextColumnsAsync(connection);
|
||||||
await connection.ExecuteAsync(
|
await connection.ExecuteAsync(
|
||||||
"""
|
"""
|
||||||
MERGE dbo.StoryMemoryImportPreferences WITH (HOLDLOCK) AS target
|
MERGE dbo.StoryMemoryImportPreferences WITH (HOLDLOCK) AS target
|
||||||
USING (SELECT @ImportSessionID AS ImportSessionID) AS source
|
USING (SELECT @ImportSessionID AS ImportSessionID) AS source
|
||||||
ON target.ImportSessionID = source.ImportSessionID
|
ON target.ImportSessionID = source.ImportSessionID
|
||||||
WHEN MATCHED THEN
|
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
|
WHEN NOT MATCHED THEN
|
||||||
INSERT (ImportSessionID, AppearancePreference)
|
INSERT (ImportSessionID, AppearancePreference, PrimaryAgeGroup, StoryEra, StoryLocation)
|
||||||
VALUES (@ImportSessionID, @AppearancePreference);
|
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)
|
public async Task<StoryMemoryDiagnostics> GetDiagnosticsAsync(int importSessionId)
|
||||||
|
|||||||
@ -50,7 +50,8 @@ It should not:
|
|||||||
7. Treat assets as meaningful story objects, not ordinary props.
|
7. Treat assets as meaningful story objects, not ordinary props.
|
||||||
8. Treat relationships as scene-local signals, not final records.
|
8. Treat relationships as scene-local signals, not final records.
|
||||||
9. Treat metrics as descriptive intensity scores, not quality scores.
|
9. Treat metrics as descriptive intensity scores, not quality scores.
|
||||||
10. Return JSON only.
|
10. Extract explicit visual character appearance separately from story understanding.
|
||||||
|
11. Return JSON only.
|
||||||
|
|
||||||
## Strict Behavioural Rules
|
## Strict Behavioural Rules
|
||||||
|
|
||||||
@ -229,6 +230,26 @@ For each character:
|
|||||||
|
|
||||||
Mentioned-only characters matter because later stages need to know who is referenced without assuming they were present.
|
Mentioned-only characters matter because later stages need to know who is referenced without assuming they were present.
|
||||||
|
|
||||||
|
## Character Appearance Rules
|
||||||
|
|
||||||
|
Scene understanding is not enough for illustration matching. Report explicit or strongly supported visual facts in `characterAppearance`.
|
||||||
|
|
||||||
|
For every named or recurring character with supported appearance evidence, include exactly these properties: `canonicalName`, `aliases`, `title`, `presentation`, `explicitAge`, `inferredAgeBand`, `hairColour`, `hairLength`, `hairStyle`, `facialHair`, `glasses`, `build`, `distinctiveFeatures`, `clothing`, `roleOrOccupation`, `relationshipAgeEvidence`, `pronounEvidence`, `evidence`, `confidence`, `evidenceKind`.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- Use the manuscript as the authority.
|
||||||
|
- Keep unknown visual fields as `null`.
|
||||||
|
- Use `evidenceKind: "explicit"` for directly stated facts and `evidenceKind: "inferred"` for title, role, relationship or pronoun evidence.
|
||||||
|
- Keep `evidence` to a short exact fragment from the scene.
|
||||||
|
- Do not infer hair colour from nearby characters.
|
||||||
|
- Do not inherit appearance from the point of view character.
|
||||||
|
- Do not copy appearance evidence between identities.
|
||||||
|
- If the scene says `Miss Davies, but you can call me Zoe`, use canonicalName `Zoe Davies` and aliases `["Miss Davies", "Zoe", "Miss Davies (Zoe)"]`.
|
||||||
|
- Unknown age must not be marked as child.
|
||||||
|
- Adult titles and occupations such as Mr, Mrs, Miss, Ms, teacher, driving examiner, driving instructor, police officer, detective, solicitor, landlord, manager, nurse, receptionist, mother, father, aunt and uncle are adult evidence.
|
||||||
|
- Words like `girl`, `lad` or `young` alone are presentation/voice clues, not child-age evidence.
|
||||||
|
|
||||||
## Location Reporting Rules
|
## Location Reporting Rules
|
||||||
|
|
||||||
Named locations:
|
Named locations:
|
||||||
@ -543,6 +564,30 @@ Return exactly one JSON object matching this shape:
|
|||||||
"notes": null
|
"notes": null
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"characterAppearance": [
|
||||||
|
{
|
||||||
|
"canonicalName": "",
|
||||||
|
"aliases": [],
|
||||||
|
"title": null,
|
||||||
|
"presentation": null,
|
||||||
|
"explicitAge": null,
|
||||||
|
"inferredAgeBand": null,
|
||||||
|
"hairColour": null,
|
||||||
|
"hairLength": null,
|
||||||
|
"hairStyle": null,
|
||||||
|
"facialHair": null,
|
||||||
|
"glasses": null,
|
||||||
|
"build": null,
|
||||||
|
"distinctiveFeatures": [],
|
||||||
|
"clothing": null,
|
||||||
|
"roleOrOccupation": null,
|
||||||
|
"relationshipAgeEvidence": null,
|
||||||
|
"pronounEvidence": null,
|
||||||
|
"evidence": "",
|
||||||
|
"confidence": 0.0,
|
||||||
|
"evidenceKind": "explicit"
|
||||||
|
}
|
||||||
|
],
|
||||||
"locations": [
|
"locations": [
|
||||||
{
|
{
|
||||||
"name": "",
|
"name": "",
|
||||||
|
|||||||
@ -12,6 +12,7 @@ public sealed class SceneIntelligenceScene
|
|||||||
public SceneIntelligencePointOfView? PointOfView { get; init; }
|
public SceneIntelligencePointOfView? PointOfView { get; init; }
|
||||||
public SceneIntelligenceSetting? Setting { get; init; }
|
public SceneIntelligenceSetting? Setting { get; init; }
|
||||||
public List<SceneIntelligenceCharacter>? Characters { get; init; }
|
public List<SceneIntelligenceCharacter>? Characters { get; init; }
|
||||||
|
public List<SceneIntelligenceCharacterAppearance>? CharacterAppearance { get; init; }
|
||||||
public List<SceneIntelligenceLocation>? Locations { get; init; }
|
public List<SceneIntelligenceLocation>? Locations { get; init; }
|
||||||
public List<SceneIntelligenceAsset>? Assets { get; init; }
|
public List<SceneIntelligenceAsset>? Assets { get; init; }
|
||||||
public List<SceneIntelligenceRelationship>? Relationships { get; init; }
|
public List<SceneIntelligenceRelationship>? Relationships { get; init; }
|
||||||
@ -99,6 +100,33 @@ public sealed class SceneIntelligenceCharacter
|
|||||||
public Dictionary<string, JsonElement>? ExtensionData { get; init; }
|
public Dictionary<string, JsonElement>? ExtensionData { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed class SceneIntelligenceCharacterAppearance
|
||||||
|
{
|
||||||
|
public string? CanonicalName { get; init; }
|
||||||
|
public List<string>? Aliases { get; init; }
|
||||||
|
public string? Title { get; init; }
|
||||||
|
public string? Presentation { get; init; }
|
||||||
|
public string? ExplicitAge { get; init; }
|
||||||
|
public string? InferredAgeBand { get; init; }
|
||||||
|
public string? HairColour { get; init; }
|
||||||
|
public string? HairLength { get; init; }
|
||||||
|
public string? HairStyle { get; init; }
|
||||||
|
public string? FacialHair { get; init; }
|
||||||
|
public string? Glasses { get; init; }
|
||||||
|
public string? Build { get; init; }
|
||||||
|
public List<string>? DistinctiveFeatures { get; init; }
|
||||||
|
public string? Clothing { get; init; }
|
||||||
|
public string? RoleOrOccupation { get; init; }
|
||||||
|
public string? RelationshipAgeEvidence { get; init; }
|
||||||
|
public string? PronounEvidence { get; init; }
|
||||||
|
public string? Evidence { get; init; }
|
||||||
|
public decimal? Confidence { get; init; }
|
||||||
|
public string? EvidenceKind { get; init; }
|
||||||
|
|
||||||
|
[JsonExtensionData]
|
||||||
|
public Dictionary<string, JsonElement>? ExtensionData { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
public sealed class SceneIntelligenceLocation
|
public sealed class SceneIntelligenceLocation
|
||||||
{
|
{
|
||||||
public string? Name { get; init; }
|
public string? Name { get; init; }
|
||||||
|
|||||||
@ -214,18 +214,30 @@ public sealed class StoryMemoryRebuildResult
|
|||||||
public IReadOnlyList<string> Warnings { get; init; } = [];
|
public IReadOnlyList<string> Warnings { get; init; } = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class StoryMemoryRebuildOptions
|
public sealed record StoryMemoryRebuildOptions
|
||||||
{
|
{
|
||||||
public bool DryRun { get; init; }
|
public bool DryRun { get; init; }
|
||||||
public bool RebuildAll { get; init; }
|
public bool RebuildAll { get; init; }
|
||||||
public bool QueueDemand { get; init; }
|
public bool QueueDemand { get; init; }
|
||||||
public int? MaxDemandToQueue { get; init; }
|
public int? MaxDemandToQueue { get; init; }
|
||||||
public string AppearancePreference { get; init; } = StoryMemoryAppearancePreferences.Balanced;
|
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 bool QueueDemand { get; init; }
|
||||||
public int? MaxDemandToQueue { get; init; }
|
public int? MaxDemandToQueue { get; init; }
|
||||||
public string AppearancePreference { get; init; } = StoryMemoryAppearancePreferences.Balanced;
|
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);
|
||||||
|
|||||||
@ -245,9 +245,15 @@ public class Program
|
|||||||
builder.Services.AddScoped<ICurrentUserService, CurrentUserService>();
|
builder.Services.AddScoped<ICurrentUserService, CurrentUserService>();
|
||||||
builder.Services.AddScoped<IFeatureRequestService, FeatureRequestService>();
|
builder.Services.AddScoped<IFeatureRequestService, FeatureRequestService>();
|
||||||
builder.Services.AddHostedService<EmailQueueWorker>();
|
builder.Services.AddHostedService<EmailQueueWorker>();
|
||||||
builder.Services.AddHostedService<StoryIntelligenceWorker>();
|
if (builder.Configuration.GetValue("StoryIntelligence:WorkersEnabled", true))
|
||||||
builder.Services.AddHostedService<PersistedStoryIntelligenceWorker>();
|
{
|
||||||
builder.Services.AddHostedService<IllustrationGenerationWorker>();
|
builder.Services.AddHostedService<StoryIntelligenceWorker>();
|
||||||
|
builder.Services.AddHostedService<PersistedStoryIntelligenceWorker>();
|
||||||
|
}
|
||||||
|
if (builder.Configuration.GetValue("Illustrations:GenerationWorkerEnabled", true))
|
||||||
|
{
|
||||||
|
builder.Services.AddHostedService<IllustrationGenerationWorker>();
|
||||||
|
}
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
|
|||||||
191
PlotLine/Services/CharacterAppearanceExtractionService.cs
Normal file
191
PlotLine/Services/CharacterAppearanceExtractionService.cs
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
- 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 visible text, captions, logos, watermarks, UI, signatures, or typography
|
||||||
- no anime, cartoon, Pixar-like, caricature, celebrity likeness, or photoreal identity claim
|
- 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
|
- 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 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
|
- 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}",
|
$"apparent age band {specification.ApparentAgeBand}",
|
||||||
$"presentation {specification.Presentation}",
|
$"presentation {specification.Presentation}",
|
||||||
$"hair {specification.HairLength} {specification.HairColour}",
|
$"hair {specification.HairLength} {specification.HairColour}",
|
||||||
$"skin tone range {specification.SkinTone}",
|
$"required skin tone {specification.SkinTone}",
|
||||||
$"features {string.Join(", ", specification.Features)}",
|
$"features {string.Join(", ", specification.Features)}",
|
||||||
$"clothing {specification.ClothingEra} {specification.ClothingStyle}"
|
$"clothing {specification.ClothingEra} {specification.ClothingStyle}"
|
||||||
}.Where(item => !item.EndsWith(" ", StringComparison.Ordinal))),
|
}.Where(item => !item.EndsWith(" ", StringComparison.Ordinal))),
|
||||||
|
|||||||
@ -9,7 +9,7 @@ namespace PlotLine.Services;
|
|||||||
public interface IOnboardingStoryIntelligenceService
|
public interface IOnboardingStoryIntelligenceService
|
||||||
{
|
{
|
||||||
Task<StoryIntelligenceOverviewViewModel> GetOverviewAsync();
|
Task<StoryIntelligenceOverviewViewModel> GetOverviewAsync();
|
||||||
Task<OnboardingStoryIntelligenceBatch?> StartAsync();
|
Task<OnboardingStoryIntelligenceBatch?> StartAsync(string? appearancePreference = null, string? primaryAgeGroup = null, string? storyEra = null, string? storyLocation = null);
|
||||||
Task<StoryIntelligenceProgressViewModel?> GetProgressAsync(Guid batchId);
|
Task<StoryIntelligenceProgressViewModel?> GetProgressAsync(Guid batchId);
|
||||||
Task<StoryIntelligenceProgressViewModel?> CancelAsync(Guid batchId);
|
Task<StoryIntelligenceProgressViewModel?> CancelAsync(Guid batchId);
|
||||||
Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> CommitAsync(Guid batchId, int runId);
|
Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> CommitAsync(Guid batchId, int runId);
|
||||||
@ -40,6 +40,7 @@ public sealed class OnboardingStoryIntelligenceService(
|
|||||||
IStoryIntelligenceRelationshipImportService relationshipImport,
|
IStoryIntelligenceRelationshipImportService relationshipImport,
|
||||||
IStoryIntelligenceKnowledgeImportService knowledgeImport,
|
IStoryIntelligenceKnowledgeImportService knowledgeImport,
|
||||||
IStoryIntelligencePipelineStateService pipelineState,
|
IStoryIntelligencePipelineStateService pipelineState,
|
||||||
|
IStoryMemoryRepository storyMemory,
|
||||||
IStoryIntelligenceClient client,
|
IStoryIntelligenceClient client,
|
||||||
IOnboardingStoryIntelligenceBatchStore batchStore,
|
IOnboardingStoryIntelligenceBatchStore batchStore,
|
||||||
IStoryIntelligenceProgressNotifier notifier,
|
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 userId = RequireUserId();
|
||||||
var context = await GetContextAsync(userId);
|
var context = await GetContextAsync(userId);
|
||||||
@ -161,6 +162,7 @@ public sealed class OnboardingStoryIntelligenceService(
|
|||||||
ChapterID = chapterId,
|
ChapterID = chapterId,
|
||||||
RunID = runId
|
RunID = runId
|
||||||
});
|
});
|
||||||
|
await pipelineState.RecordChapterAnalysisQueuedAsync(preview.ProjectID, preview.BookID, runId);
|
||||||
|
|
||||||
await notifier.PublishAsync(new StoryIntelligenceRunProgressEvent
|
await notifier.PublishAsync(new StoryIntelligenceRunProgressEvent
|
||||||
{
|
{
|
||||||
@ -189,9 +191,23 @@ public sealed class OnboardingStoryIntelligenceService(
|
|||||||
BookID = preview.BookID,
|
BookID = preview.BookID,
|
||||||
ProjectName = wizard.SelectedProjectName,
|
ProjectName = wizard.SelectedProjectName,
|
||||||
BookTitle = wizard.SelectedBookTitle,
|
BookTitle = wizard.SelectedBookTitle,
|
||||||
|
AppearancePreference = NormaliseAppearancePreference(appearancePreference),
|
||||||
|
PrimaryAgeGroup = CleanContext(primaryAgeGroup),
|
||||||
|
StoryEra = CleanContext(storyEra),
|
||||||
|
StoryLocation = CleanContext(storyLocation),
|
||||||
Items = batchItems
|
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);
|
await batchStore.SaveAsync(batch);
|
||||||
logger.LogInformation(
|
logger.LogInformation(
|
||||||
"Queued onboarding Story Intelligence batch {BatchID}. UserID={UserID} ProjectID={ProjectID} BookID={BookID} Chapters={ChapterCount}",
|
"Queued onboarding Story Intelligence batch {BatchID}. UserID={UserID} ProjectID={ProjectID} BookID={BookID} Chapters={ChapterCount}",
|
||||||
@ -204,6 +220,24 @@ public sealed class OnboardingStoryIntelligenceService(
|
|||||||
return batch;
|
return batch;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string NormaliseAppearancePreference(string? value)
|
||||||
|
=> value?.Trim() switch
|
||||||
|
{
|
||||||
|
StoryMemoryAppearancePreferences.ExplicitEvidenceOnly => StoryMemoryAppearancePreferences.ExplicitEvidenceOnly,
|
||||||
|
StoryMemoryAppearancePreferences.Balanced => StoryMemoryAppearancePreferences.Balanced,
|
||||||
|
StoryMemoryAppearancePreferences.PredominantlyLight => StoryMemoryAppearancePreferences.PredominantlyLight,
|
||||||
|
StoryMemoryAppearancePreferences.PredominantlyMedium => StoryMemoryAppearancePreferences.PredominantlyMedium,
|
||||||
|
StoryMemoryAppearancePreferences.PredominantlyDark => StoryMemoryAppearancePreferences.PredominantlyDark,
|
||||||
|
StoryMemoryAppearancePreferences.CustomMix => StoryMemoryAppearancePreferences.CustomMix,
|
||||||
|
_ => StoryMemoryAppearancePreferences.Balanced
|
||||||
|
};
|
||||||
|
|
||||||
|
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)
|
public async Task<StoryIntelligenceProgressViewModel?> GetProgressAsync(Guid batchId)
|
||||||
{
|
{
|
||||||
var batch = await batchStore.GetAsync(RequireUserId(), batchId);
|
var batch = await batchStore.GetAsync(RequireUserId(), batchId);
|
||||||
@ -919,6 +953,10 @@ public sealed class OnboardingStoryIntelligenceBatch
|
|||||||
public int BookID { get; init; }
|
public int BookID { get; init; }
|
||||||
public string? ProjectName { get; init; }
|
public string? ProjectName { get; init; }
|
||||||
public string? BookTitle { get; init; }
|
public string? BookTitle { get; init; }
|
||||||
|
public string AppearancePreference { get; init; } = StoryMemoryAppearancePreferences.Balanced;
|
||||||
|
public 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 DateTime CreatedUtc { get; init; } = DateTime.UtcNow;
|
||||||
public IReadOnlyList<OnboardingStoryIntelligenceBatchItem> Items { get; init; } = [];
|
public IReadOnlyList<OnboardingStoryIntelligenceBatchItem> Items { get; init; } = [];
|
||||||
public List<OnboardingStoryIntelligenceCharacterDecision> CharacterDecisions { get; } = [];
|
public List<OnboardingStoryIntelligenceCharacterDecision> CharacterDecisions { get; } = [];
|
||||||
|
|||||||
@ -347,7 +347,7 @@ public sealed class PersistedStoryIntelligenceRunner(
|
|||||||
importSessionId.Value,
|
importSessionId.Value,
|
||||||
run.UserID,
|
run.UserID,
|
||||||
sceneResultId,
|
sceneResultId,
|
||||||
new StoryMemoryResolveOptions { QueueDemand = false },
|
new StoryMemoryResolveOptions { QueueDemand = true, MaxDemandToQueue = 24 },
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
}
|
}
|
||||||
catch (Exception ex) when (ex is not OperationCanceledException && !IsFatal(ex))
|
catch (Exception ex) when (ex is not OperationCanceledException && !IsFatal(ex))
|
||||||
|
|||||||
@ -61,7 +61,7 @@ public static class StoryIntelligenceIllustrationCompatibility
|
|||||||
var relationshipPresentation = RelationshipPresentation(relationshipSafeText);
|
var relationshipPresentation = RelationshipPresentation(relationshipSafeText);
|
||||||
var entityPresentation = RelationshipPresentation(scopedText);
|
var entityPresentation = RelationshipPresentation(scopedText);
|
||||||
var pronounPresentation = PronounPresentation(scopedText);
|
var pronounPresentation = PronounPresentation(scopedText);
|
||||||
var namePresentation = NamePresentation(entityText);
|
var namePresentation = NamePresentation(nameText);
|
||||||
var presentation = FirstKnown(
|
var presentation = FirstKnown(
|
||||||
titlePresentation.Value,
|
titlePresentation.Value,
|
||||||
entityPresentation.Value,
|
entityPresentation.Value,
|
||||||
@ -172,12 +172,25 @@ public static class StoryIntelligenceIllustrationCompatibility
|
|||||||
rejected.Add($"age hard mismatch: wanted {evidence.AgeBand}, candidate {candidate.AgeBand}");
|
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
|
if (evidence.HasStrongPresentationEvidence
|
||||||
&& PresentationIncompatible(evidence.Presentation, candidate.Presentation))
|
&& PresentationIncompatible(evidence.Presentation, candidate.Presentation))
|
||||||
{
|
{
|
||||||
rejected.Add($"presentation hard mismatch: wanted {evidence.Presentation}, candidate {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)
|
if (!string.Equals(evidence.HairColour, HairColours.Unknown, StringComparison.OrdinalIgnoreCase)
|
||||||
&& !string.Equals(candidate.HairColour, HairColours.Unknown, StringComparison.OrdinalIgnoreCase)
|
&& !string.Equals(candidate.HairColour, HairColours.Unknown, StringComparison.OrdinalIgnoreCase)
|
||||||
&& KnownMismatch(evidence.HairColour, candidate.HairColour))
|
&& 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}");
|
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)
|
if (alreadyAssignedToAnotherSignificantCharacter && isNamedCharacter)
|
||||||
{
|
{
|
||||||
rejected.Add("already assigned to another significant named character");
|
rejected.Add("already assigned to another significant named character");
|
||||||
@ -209,6 +229,9 @@ public static class StoryIntelligenceIllustrationCompatibility
|
|||||||
public static string NormalizePresentation(string? value)
|
public static string NormalizePresentation(string? value)
|
||||||
{
|
{
|
||||||
var text = (value ?? string.Empty).ToLowerInvariant();
|
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, "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 (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;
|
if (text.Contains("androgynous", StringComparison.OrdinalIgnoreCase)) return Presentations.Androgynous;
|
||||||
@ -253,30 +276,46 @@ public static class StoryIntelligenceIllustrationCompatibility
|
|||||||
var padded = $" {text} ";
|
var padded = $" {text} ";
|
||||||
if (ContainsAny(text, "driving test centre", "driving test center", "test centre", "test center")) return "DrivingTestCentre";
|
if (ContainsAny(text, "driving test centre", "driving test center", "test centre", "test center")) return "DrivingTestCentre";
|
||||||
if (ContainsAny(text, "police station")) return "PoliceStation";
|
if (ContainsAny(text, "police station")) return "PoliceStation";
|
||||||
if (ContainsAny(text, "car interior", "vehicle interior", "inside the car", "inside car", "driver seat", "driver's seat", "passenger seat", "rear seat", "back seat", "boot", "cabin", "dashboard")) return "VehicleInterior";
|
if (ContainsAny(text, "driver seat", "driver's seat")) return "DriverSeat";
|
||||||
|
if (ContainsAny(text, "passenger seat")) return "PassengerSeat";
|
||||||
|
if (ContainsAny(text, "rear seat", "back seat")) return "RearSeat";
|
||||||
|
if (ContainsAny(text, "car interior", "vehicle interior", "inside the car", "inside car", "boot", "cabin", "dashboard")) return "VehicleInterior";
|
||||||
if (ContainsAny(text, "bathroom", "toilet", "washroom")) return "Bathroom";
|
if (ContainsAny(text, "bathroom", "toilet", "washroom")) return "Bathroom";
|
||||||
if (text.Contains("kitchen", StringComparison.OrdinalIgnoreCase)) return "Kitchen";
|
if (text.Contains("kitchen", StringComparison.OrdinalIgnoreCase)) return "Kitchen";
|
||||||
if (text.Contains("bedroom", StringComparison.OrdinalIgnoreCase)) return "Bedroom";
|
if (text.Contains("bedroom", StringComparison.OrdinalIgnoreCase)) return "Bedroom";
|
||||||
if (ContainsAny(text, "living room", "sitting room", "lounge")) return "LivingRoom";
|
if (ContainsAny(text, "living room", "sitting room", "lounge")) return "LivingRoom";
|
||||||
if (ContainsAny(text, "hall", "hallway", "corridor")) return "Hallway";
|
if (ContainsAny(text, "hall", "hallway", "corridor")) return "Hallway";
|
||||||
if (text.Contains("stair", StringComparison.OrdinalIgnoreCase)) return "Stairwell";
|
if (text.Contains("stair", StringComparison.OrdinalIgnoreCase)) return "Stairwell";
|
||||||
if (ContainsAny(text, "hospital", "derelict hospital")) return "Hospital";
|
if (ContainsAny(text, "derelict hospital", "abandoned hospital")) return "DerelictHospital";
|
||||||
|
if (ContainsAny(text, "hospital")) return "Hospital";
|
||||||
if (ContainsAny(text, "church", "chapel")) return "Church";
|
if (ContainsAny(text, "church", "chapel")) return "Church";
|
||||||
if (ContainsAny(text, "restaurant", "cafe", "café", "diner")) return "Restaurant";
|
if (ContainsAny(text, "restaurant", "cafe", "café", "diner")) return "Restaurant";
|
||||||
if (ContainsAny(text, "pub", "public house")) return "Pub";
|
if (ContainsAny(text, "pub", "public house")) return "Pub";
|
||||||
if (ContainsAny(text, "beach", "seafront")) return "Beach";
|
if (ContainsAny(text, "beach", "seafront")) return "Beach";
|
||||||
if (ContainsAny(text, "railway station", "train station", "station platform")) return "RailwayStation";
|
if (ContainsAny(text, "railway station", "train station", "station platform")) return "RailwayStation";
|
||||||
if (ContainsAny(text, "canal", "towpath")) return "Canal";
|
if (ContainsAny(text, "canal", "towpath")) return "Canal";
|
||||||
if (ContainsAny(text, "garage", "workshop")) return "Garage";
|
if (ContainsAny(text, "workshop")) return "Workshop";
|
||||||
|
if (ContainsAny(text, "garage")) return "Garage";
|
||||||
if (ContainsAny(text, "front door", "porch", "entrance", "house exterior", "outside the house", "exterior")) return "HouseExterior";
|
if (ContainsAny(text, "front door", "porch", "entrance", "house exterior", "outside the house", "exterior")) return "HouseExterior";
|
||||||
if (ContainsAny(text, "office", "desk", "study", "library")) return "Office";
|
if (ContainsAny(text, "office", "desk", "study", "library")) return "Office";
|
||||||
if (text.Contains("school", StringComparison.OrdinalIgnoreCase)) return "School";
|
if (text.Contains("school", StringComparison.OrdinalIgnoreCase)) return "School";
|
||||||
if (text.Contains("waiting room", StringComparison.OrdinalIgnoreCase)) return "WaitingRoom";
|
if (text.Contains("waiting room", StringComparison.OrdinalIgnoreCase)) return "WaitingRoom";
|
||||||
if (ContainsAny(text, "car park", "parking", "forecourt")) return "CarPark";
|
if (ContainsAny(text, "car park", "parking", "forecourt")) return "CarPark";
|
||||||
|
if (ContainsAny(text, "dual carriageway")) return "DualCarriageway";
|
||||||
|
if (ContainsAny(text, "motorway", "highway")) return "Motorway";
|
||||||
|
if (ContainsAny(text, "bridge")) return "Bridge";
|
||||||
|
if (ContainsAny(text, "tunnel")) return "Tunnel";
|
||||||
|
if (ContainsAny(text, "flyover")) return "Flyover";
|
||||||
|
if (ContainsAny(text, "underpass")) return "Underpass";
|
||||||
|
if (ContainsAny(text, " lane ") || text.EndsWith(" lane", StringComparison.OrdinalIgnoreCase)) return "Lane";
|
||||||
|
if (ContainsAny(text, " street ") || text.EndsWith(" street", StringComparison.OrdinalIgnoreCase)) return "Street";
|
||||||
|
if (ContainsAny(text, " avenue ") || text.EndsWith(" avenue", StringComparison.OrdinalIgnoreCase)) return "Avenue";
|
||||||
|
if (ContainsAny(text, " drive ") || text.EndsWith(" drive", StringComparison.OrdinalIgnoreCase)) return "Drive";
|
||||||
|
if (ContainsAny(text, " close ") || text.EndsWith(" close", StringComparison.OrdinalIgnoreCase)) return "Close";
|
||||||
if (ContainsAny(text,
|
if (ContainsAny(text,
|
||||||
"road", "street", "lane", "drive", " close ", "court", "way", "avenue", "terrace", "gardens",
|
"road", "court", "way", "terrace", "gardens",
|
||||||
"highway", "motorway", "bridge", "roundabout", "junction", "crossroads", "dual carriageway",
|
"roundabout", "junction", "crossroads",
|
||||||
"track", "footpath", " path ", "cycleway", "tunnel", "flyover", "underpass", "boulevard")
|
"track", "footpath", " path ", "cycleway", "boulevard")
|
||||||
|| RoadSuffixRegex.IsMatch(padded))
|
|| RoadSuffixRegex.IsMatch(padded))
|
||||||
{
|
{
|
||||||
return "Road";
|
return "Road";
|
||||||
@ -317,24 +356,32 @@ public static class StoryIntelligenceIllustrationCompatibility
|
|||||||
if (ContainsAny(text, "money", "cash", "coins", "banknote")) return "Money";
|
if (ContainsAny(text, "money", "cash", "coins", "banknote")) return "Money";
|
||||||
if (ContainsAny(text, "scarf", "scarves")) return "Clothing";
|
if (ContainsAny(text, "scarf", "scarves")) return "Clothing";
|
||||||
if (ContainsAny(text, "paperwork")) return "Document";
|
if (ContainsAny(text, "paperwork")) return "Document";
|
||||||
|
if (ContainsAny(text, "lipstick")) return "Lipstick";
|
||||||
if (ContainsAny(text, "ring", "necklace", "jewellery", "jewelry")) return "Jewellery";
|
if (ContainsAny(text, "ring", "necklace", "jewellery", "jewelry")) return "Jewellery";
|
||||||
|
if (ContainsAny(text, "shoes", "boots", "trainers")) return "Shoes";
|
||||||
|
if (ContainsAny(text, "dress")) return "Dress";
|
||||||
|
if (ContainsAny(text, "skirt")) return "Skirt";
|
||||||
|
if (ContainsAny(text, "stockings")) return "Stockings";
|
||||||
if (ContainsAny(text, "gun", "pistol", "rifle", "weapon")) return "Weapon";
|
if (ContainsAny(text, "gun", "pistol", "rifle", "weapon")) return "Weapon";
|
||||||
if (ContainsAny(padded, " hatchback ", " vw golf ", " volkswagen golf ", " golf gti ", " ford fiesta ", " fiesta ")) return "Hatchback";
|
if (ContainsAny(padded, " hatchback ", " vw golf ", " volkswagen golf ", " golf gti ", " ford fiesta ", " fiesta ")) return "Hatchback";
|
||||||
if (ContainsAny(padded, " family car ", " estate car ", " saloon ")) return "FamilyCar";
|
if (ContainsAny(padded, " estate car ", " estate ")) return "Estate";
|
||||||
|
if (ContainsAny(padded, " saloon ")) return "Saloon";
|
||||||
|
if (ContainsAny(padded, " family car ")) return "Car";
|
||||||
if (ContainsAny(padded, " sports car ", " tr6 ", " roadster ")) return "SportsCar";
|
if (ContainsAny(padded, " sports car ", " tr6 ", " roadster ")) return "SportsCar";
|
||||||
if (ContainsAny(padded, " van ", " transit ")) return "Van";
|
if (ContainsAny(padded, " van ", " transit ")) return "Van";
|
||||||
if (ContainsAny(padded, " motor car ", " car ", " vehicle ")) return "Car";
|
if (ContainsAny(padded, " motor car ", " car ", " vehicle ")) return "Car";
|
||||||
if (ContainsAny(text, "supercharger", "engine part")) return "CarPart";
|
if (ContainsAny(text, "supercharger", "engine part")) return "CarPart";
|
||||||
if (ContainsAny(text, "biscuit tin", "tin")) return "Tin";
|
if (ContainsAny(text, "biscuit tin", "tin")) return "Tin";
|
||||||
if (ContainsAny(text, "parcel", "package")) return "Parcel";
|
if (ContainsAny(text, "parcel", "package")) return "Parcel";
|
||||||
if (ContainsAny(text, "blouse", "skirt", "jumper", "jumpers", "sunglasses", "stockings")) return "Clothing";
|
if (ContainsAny(text, "blouse", "jumper", "jumpers", "sunglasses", "clothing", "clothes")) return "Clothing";
|
||||||
if (text.Contains("rucksack", StringComparison.OrdinalIgnoreCase)) return "Rucksack";
|
if (text.Contains("rucksack", StringComparison.OrdinalIgnoreCase)) return "Rucksack";
|
||||||
if (text.Contains("suitcase", StringComparison.OrdinalIgnoreCase)) return "Suitcase";
|
if (text.Contains("suitcase", StringComparison.OrdinalIgnoreCase)) return "Suitcase";
|
||||||
if (text.Contains("note", StringComparison.OrdinalIgnoreCase)) return "Note";
|
if (text.Contains("note", StringComparison.OrdinalIgnoreCase)) return "Note";
|
||||||
if (text.Contains("letter", StringComparison.OrdinalIgnoreCase)) return "Letter";
|
if (text.Contains("letter", StringComparison.OrdinalIgnoreCase)) return "Letter";
|
||||||
if (text.Contains("notebook", StringComparison.OrdinalIgnoreCase)) return "Notebook";
|
if (text.Contains("notebook", StringComparison.OrdinalIgnoreCase)) return "Notebook";
|
||||||
if (text.Contains("book", StringComparison.OrdinalIgnoreCase)) return "Book";
|
if (text.Contains("book", StringComparison.OrdinalIgnoreCase)) return "Book";
|
||||||
if (ContainsAny(text, "telephone", "phone", "recorder")) return "Telephone";
|
if (ContainsAny(text, "telephone", "phone", "mobile")) return "Phone";
|
||||||
|
if (ContainsAny(text, "recorder")) return "Recorder";
|
||||||
if (text.Contains("key", StringComparison.OrdinalIgnoreCase)) return "Keys";
|
if (text.Contains("key", StringComparison.OrdinalIgnoreCase)) return "Keys";
|
||||||
if (ContainsAny(text, "tr6")) return "Car";
|
if (ContainsAny(text, "tr6")) return "Car";
|
||||||
if (ContainsAny(text, "photo", "photograph")) return "Photograph";
|
if (ContainsAny(text, "photo", "photograph")) return "Photograph";
|
||||||
@ -358,6 +405,8 @@ public static class StoryIntelligenceIllustrationCompatibility
|
|||||||
|
|
||||||
public static bool LocationCompatible(string requestedType, string candidateType)
|
public static bool LocationCompatible(string requestedType, string candidateType)
|
||||||
=> requestedType == candidateType
|
=> requestedType == candidateType
|
||||||
|
|| (requestedType is "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 "FrontDoor" or "Porch" && candidateType is "HouseExterior")
|
||||||
|| (requestedType is "Kitchen" or "LivingRoom" or "Bedroom" && candidateType is "FlatInterior")
|
|| (requestedType is "Kitchen" or "LivingRoom" or "Bedroom" && candidateType is "FlatInterior")
|
||||||
|| (requestedType == "Bathroom" && candidateType == "Bathroom");
|
|| (requestedType == "Bathroom" && candidateType == "Bathroom");
|
||||||
@ -365,14 +414,19 @@ public static class StoryIntelligenceIllustrationCompatibility
|
|||||||
public static bool AssetCompatible(string requestedType, string candidateType)
|
public static bool AssetCompatible(string requestedType, string candidateType)
|
||||||
=> requestedType == candidateType
|
=> requestedType == candidateType
|
||||||
|| (requestedType == "Hatchback" && candidateType == "Car")
|
|| (requestedType == "Hatchback" && candidateType == "Car")
|
||||||
|| (requestedType == "FamilyCar" && candidateType == "Car")
|
|| (requestedType is "Saloon" or "Estate" && candidateType == "Car")
|
||||||
|| (requestedType == "Note" && candidateType is "Letter" or "Document")
|
|| (requestedType == "Note" && candidateType is "Letter" or "Document")
|
||||||
|| (requestedType == "Rucksack" && candidateType == "Rucksack");
|
|| (requestedType == "Rucksack" && candidateType == "Rucksack");
|
||||||
|
|
||||||
public static bool IsGroupEntity(string? value)
|
public static bool IsGroupEntity(string? value)
|
||||||
{
|
{
|
||||||
var clean = (value ?? string.Empty).Trim().ToLowerInvariant();
|
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("social worker", StringComparison.Ordinal)
|
||||||
|| clean.Contains("neighbour", StringComparison.Ordinal)
|
|| clean.Contains("neighbour", StringComparison.Ordinal)
|
||||||
|| clean.Contains("neighbor", StringComparison.Ordinal)
|
|| clean.Contains("neighbor", StringComparison.Ordinal)
|
||||||
@ -380,6 +434,7 @@ public static class StoryIntelligenceIllustrationCompatibility
|
|||||||
|| clean.Contains("student", StringComparison.Ordinal)
|
|| clean.Contains("student", StringComparison.Ordinal)
|
||||||
|| clean.Contains("examiner", StringComparison.Ordinal)
|
|| clean.Contains("examiner", StringComparison.Ordinal)
|
||||||
|| clean.Contains("uniformed", StringComparison.Ordinal)
|
|| clean.Contains("uniformed", StringComparison.Ordinal)
|
||||||
|
|| clean.Contains("girl", StringComparison.Ordinal)
|
||||||
|| clean.Contains("responder", StringComparison.Ordinal))
|
|| clean.Contains("responder", StringComparison.Ordinal))
|
||||||
|| clean is "crowd" or "uniformed people" or "police" or "social services";
|
|| 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, "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, "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, "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, " 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);
|
if (ContainsAny(text, "elderly", "senior", "old woman", "old man")) return new(AgeBands.Senior, 0.9m);
|
||||||
return EvidenceSignal.Unknown;
|
return EvidenceSignal.Unknown;
|
||||||
@ -462,7 +522,7 @@ public static class StoryIntelligenceIllustrationCompatibility
|
|||||||
|
|
||||||
private static EvidenceSignal NamePresentation(string text)
|
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);
|
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;
|
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")
|
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);
|
return new(Presentations.Feminine, 0.98m);
|
||||||
}
|
}
|
||||||
@ -542,8 +602,8 @@ public static class StoryIntelligenceIllustrationCompatibility
|
|||||||
=> (evidence, candidate) switch
|
=> (evidence, candidate) switch
|
||||||
{
|
{
|
||||||
(AgeBands.Child, not AgeBands.Child) => true,
|
(AgeBands.Child, not AgeBands.Child) => true,
|
||||||
(AgeBands.YoungTeen, AgeBands.YoungAdult or AgeBands.Adult or 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.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.Adult, AgeBands.Child or AgeBands.YoungTeen or AgeBands.OlderTeen) => true,
|
||||||
(AgeBands.MatureAdult, 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,
|
(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("hair", StringComparison.Ordinal),
|
||||||
lower.IndexOf("skin", StringComparison.Ordinal),
|
lower.IndexOf("skin", StringComparison.Ordinal),
|
||||||
|
lower.IndexOf("blonde", StringComparison.Ordinal),
|
||||||
|
lower.IndexOf("brunette", StringComparison.Ordinal),
|
||||||
|
lower.IndexOf("red haired", StringComparison.Ordinal),
|
||||||
|
lower.IndexOf("red-haired", StringComparison.Ordinal),
|
||||||
|
lower.IndexOf("fair haired", StringComparison.Ordinal),
|
||||||
|
lower.IndexOf("fair-haired", StringComparison.Ordinal),
|
||||||
|
lower.IndexOf("black haired", StringComparison.Ordinal),
|
||||||
|
lower.IndexOf("black-haired", StringComparison.Ordinal),
|
||||||
|
lower.IndexOf("brown haired", StringComparison.Ordinal),
|
||||||
|
lower.IndexOf("brown-haired", StringComparison.Ordinal),
|
||||||
|
lower.IndexOf("grey haired", StringComparison.Ordinal),
|
||||||
|
lower.IndexOf("grey-haired", StringComparison.Ordinal),
|
||||||
lower.IndexOf("glasses", StringComparison.Ordinal),
|
lower.IndexOf("glasses", StringComparison.Ordinal),
|
||||||
lower.IndexOf("spectacles", StringComparison.Ordinal),
|
lower.IndexOf("spectacles", StringComparison.Ordinal),
|
||||||
lower.IndexOf("beard", StringComparison.Ordinal),
|
lower.IndexOf("beard", StringComparison.Ordinal),
|
||||||
|
|||||||
@ -591,7 +591,7 @@ public sealed class StoryIntelligenceIllustrationMatchingService(
|
|||||||
Code = $"char-demand-{StableHash(demand.DemandKey)}",
|
Code = $"char-demand-{StableHash(demand.DemandKey)}",
|
||||||
Title = $"{age} {presentation.ToLowerInvariant()} character",
|
Title = $"{age} {presentation.ToLowerInvariant()} character",
|
||||||
Description = "Demand-created reusable character illustration for Story Intelligence matching.",
|
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",
|
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",
|
Mood = "neutral, story-ready, emotionally readable, subdued and cinematic",
|
||||||
ApparentAgeBand = age,
|
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)
|
private static Dictionary<string, CharacterObservation> BuildCharacterObservations(StoryIntelligenceExperiencePrototypeViewModel prototype)
|
||||||
{
|
{
|
||||||
var observations = new Dictionary<string, CharacterObservation>(StringComparer.OrdinalIgnoreCase);
|
var observations = new Dictionary<string, CharacterObservation>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|||||||
@ -279,6 +279,11 @@ public sealed class StoryIntelligenceClient(
|
|||||||
return (responseText, retryCount);
|
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)
|
if (!ShouldRetry(response.StatusCode) || attempt == maxAttempts)
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException($"OpenAI request failed with {(int)response.StatusCode} {response.StatusCode}: {TrimForLog(responseText)}");
|
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.ServiceUnavailable
|
||||||
or HttpStatusCode.GatewayTimeout;
|
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)
|
private static Task DelayForRetryAsync(int attempt, CancellationToken cancellationToken)
|
||||||
=> Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, attempt - 1)), cancellationToken);
|
=> Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, attempt - 1)), cancellationToken);
|
||||||
|
|
||||||
|
|||||||
@ -269,6 +269,7 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
|
|||||||
scenes.Add(BuildEmptyScene(activeSnapshot.Run));
|
scenes.Add(BuildEmptyScene(activeSnapshot.Run));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var appearancePreference = await storyMemoryRepository.GetAppearancePreferenceAsync(importSessionId);
|
||||||
var model = new StoryIntelligenceExperiencePrototypeViewModel
|
var model = new StoryIntelligenceExperiencePrototypeViewModel
|
||||||
{
|
{
|
||||||
Mode = replayMode ? "replay" : "real",
|
Mode = replayMode ? "replay" : "real",
|
||||||
@ -297,7 +298,8 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
|
|||||||
completedChapterRuns,
|
completedChapterRuns,
|
||||||
displayedProgressReason,
|
displayedProgressReason,
|
||||||
storyMemory,
|
storyMemory,
|
||||||
replayMode),
|
replayMode,
|
||||||
|
appearancePreference),
|
||||||
ProjectName = DisplayTitle(session.ProjectName, "Untitled project"),
|
ProjectName = DisplayTitle(session.ProjectName, "Untitled project"),
|
||||||
BookTitle = DisplayTitle(session.BookDisplayTitle, "Untitled book"),
|
BookTitle = DisplayTitle(session.BookDisplayTitle, "Untitled book"),
|
||||||
Scenes = scenes
|
Scenes = scenes
|
||||||
@ -532,7 +534,9 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
|
|||||||
Id = CharacterId(memoryCharacter.Name),
|
Id = CharacterId(memoryCharacter.Name),
|
||||||
LibraryCode = libraryCode,
|
LibraryCode = libraryCode,
|
||||||
Name = memoryCharacter.Name,
|
Name = memoryCharacter.Name,
|
||||||
Role = memoryCharacter.Role,
|
Role = string.Equals(CharacterId(memoryCharacter.Name), CharacterId(povName), StringComparison.OrdinalIgnoreCase)
|
||||||
|
? "POV character"
|
||||||
|
: NonPovRole(memoryCharacter.Role),
|
||||||
Relevance = memoryCharacter.Relevance,
|
Relevance = memoryCharacter.Relevance,
|
||||||
Weight = Math.Max(38, memoryCharacter.Weight - 18),
|
Weight = Math.Max(38, memoryCharacter.Weight - 18),
|
||||||
ImagePath = CharacterFallback(memoryCharacter.Name)
|
ImagePath = CharacterFallback(memoryCharacter.Name)
|
||||||
@ -550,10 +554,16 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
|
|||||||
private static string NonPovRole(SceneIntelligenceCharacter character)
|
private static string NonPovRole(SceneIntelligenceCharacter character)
|
||||||
{
|
{
|
||||||
var role = FirstConfigured(character.RoleInScene, character.MentionedOnly == true ? "Referenced" : "Scene 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)
|
return role.Contains("pov", StringComparison.OrdinalIgnoreCase)
|
||||||
|| role.Contains("point of view", StringComparison.OrdinalIgnoreCase)
|
|| role.Contains("point of view", StringComparison.OrdinalIgnoreCase)
|
||||||
|| role.Contains("narrator", StringComparison.OrdinalIgnoreCase)
|
|| role.Contains("narrator", StringComparison.OrdinalIgnoreCase)
|
||||||
? character.MentionedOnly == true ? "Referenced" : "Scene character"
|
? referenced ? "Referenced" : "Scene character"
|
||||||
: role;
|
: role;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1441,7 +1451,8 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
|
|||||||
int completedChapters,
|
int completedChapters,
|
||||||
string displayedProgressReason,
|
string displayedProgressReason,
|
||||||
StoryMemory storyMemory,
|
StoryMemory storyMemory,
|
||||||
bool replayMode = false)
|
bool replayMode = false,
|
||||||
|
string appearancePreference = "")
|
||||||
{
|
{
|
||||||
var failed = snapshots.Sum(snapshot => snapshot.Parsed.Count(item => item.Scene is null));
|
var failed = snapshots.Sum(snapshot => snapshot.Parsed.Count(item => item.Scene is null));
|
||||||
var warning = failed == 0 ? null : $"{failed:N0} scene result(s) skipped because they could not be parsed.";
|
var warning = failed == 0 ? null : $"{failed:N0} scene result(s) skipped because they could not be parsed.";
|
||||||
@ -1505,6 +1516,7 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
|
|||||||
ReplayState = replayMode ? "Paused at first available scene" : string.Empty,
|
ReplayState = replayMode ? "Paused at first available scene" : string.Empty,
|
||||||
SnapshotCacheStatus = replayMode ? "Reconstructed from saved analysis" : string.Empty,
|
SnapshotCacheStatus = replayMode ? "Reconstructed from saved analysis" : string.Empty,
|
||||||
RebuildVersion = replayMode ? "21R-replay-v1" : string.Empty,
|
RebuildVersion = replayMode ? "21R-replay-v1" : string.Empty,
|
||||||
|
AppearancePreference = appearancePreference,
|
||||||
FailedChaptersSkipped = failedChapterSnapshots.Count,
|
FailedChaptersSkipped = failedChapterSnapshots.Count,
|
||||||
FailedChapterSummary = failedChapterSummary,
|
FailedChapterSummary = failedChapterSummary,
|
||||||
PanelVisibleCounts = replayMode ? "relationships:2; knowledge:3; observations:2; discoveries:4; activity:4" : string.Empty,
|
PanelVisibleCounts = replayMode ? "relationships:2; knowledge:3; observations:2; discoveries:4; activity:4" : string.Empty,
|
||||||
@ -1605,13 +1617,25 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
|
|||||||
var characters = await storyMemoryRepository.ListCharactersAsync(importSessionId);
|
var characters = await storyMemoryRepository.ListCharactersAsync(importSessionId);
|
||||||
var locations = await storyMemoryRepository.ListLocationsAsync(importSessionId);
|
var locations = await storyMemoryRepository.ListLocationsAsync(importSessionId);
|
||||||
var assets = await storyMemoryRepository.ListAssetsAsync(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 characterIds = characters.ToDictionary(item => item.StoryMemoryCharacterID, item => item.CanonicalIdentityKey);
|
||||||
var locationIds = locations.ToDictionary(item => item.StoryMemoryLocationID, item => item.CanonicalIdentityKey);
|
var locationIds = locations.ToDictionary(item => item.StoryMemoryLocationID, item => item.CanonicalIdentityKey);
|
||||||
var assetIds = assets.ToDictionary(item => item.StoryMemoryAssetID, 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()))
|
var catalogue = (await illustrationRepository.ListAsync(new IllustrationLibraryFilter()))
|
||||||
.ToDictionary(item => item.IllustrationLibraryItemID);
|
.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 locationAssignments = BuildAssignmentLookup(assignments, StoryMemoryEntityTypes.Location, locationIds, catalogue);
|
||||||
var assetAssignments = BuildAssignmentLookup(assignments, StoryMemoryEntityTypes.Asset, assetIds, catalogue);
|
var assetAssignments = BuildAssignmentLookup(assignments, StoryMemoryEntityTypes.Asset, assetIds, catalogue);
|
||||||
|
|
||||||
@ -1634,7 +1658,8 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
|
|||||||
IReadOnlyList<StoryMemoryIllustrationAssignment> assignments,
|
IReadOnlyList<StoryMemoryIllustrationAssignment> assignments,
|
||||||
string entityType,
|
string entityType,
|
||||||
IReadOnlyDictionary<int, string> entityKeys,
|
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);
|
var lookup = new Dictionary<string, AssignmentImage>(StringComparer.OrdinalIgnoreCase);
|
||||||
foreach (var assignment in assignments.Where(item => item.EntityType == entityType && item.IllustrationLibraryItemID.HasValue))
|
foreach (var assignment in assignments.Where(item => item.EntityType == entityType && item.IllustrationLibraryItemID.HasValue))
|
||||||
@ -1651,7 +1676,35 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
|
|||||||
continue;
|
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;
|
return lookup;
|
||||||
@ -1709,8 +1762,13 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
|
|||||||
AssignmentConfidence = assignment.Confidence,
|
AssignmentConfidence = assignment.Confidence,
|
||||||
MatchingScore = assignment.MatchScore,
|
MatchingScore = assignment.MatchScore,
|
||||||
ReasonChosen = assignment.Reason,
|
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",
|
DemandStatus = "Read from durable Story Memory",
|
||||||
IllustrationAllocationStatus = status
|
IllustrationAllocationStatus = assignment.IsFallback ? assignment.AssignmentStatus : status
|
||||||
};
|
};
|
||||||
|
|
||||||
private string? ResolveExistingPublicUploadUrl(string? storedPath)
|
private string? ResolveExistingPublicUploadUrl(string? storedPath)
|
||||||
@ -2126,6 +2184,17 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
|
|||||||
return "unknown";
|
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"
|
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 mum", StringComparison.Ordinal)
|
||||||
|| clean.Contains("beth's mother", StringComparison.Ordinal))
|
|| clean.Contains("beth's mother", StringComparison.Ordinal))
|
||||||
@ -2146,6 +2215,9 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
|
|||||||
return clean;
|
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)
|
private static int CharacterWeight(SceneIntelligenceCharacter character)
|
||||||
=> character.MentionedOnly == true ? 46 : ConfidenceWeight(character.Confidence, 72);
|
=> 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 == "WaitingRoom") return "loc-generic-waiting-room";
|
||||||
if (type == "DrivingTestCentre") return "loc-driving-test-centre";
|
if (type == "DrivingTestCentre") return "loc-driving-test-centre";
|
||||||
if (type == "VehicleInterior") return "loc-vehicle-interior";
|
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 == "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 "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";
|
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,
|
string PublicUrl,
|
||||||
decimal Confidence,
|
decimal Confidence,
|
||||||
int MatchScore,
|
int MatchScore,
|
||||||
string? Reason);
|
string? Reason,
|
||||||
|
string? ResolvedAgeBand,
|
||||||
|
string? ResolvedPresentation,
|
||||||
|
string? CurrentEvidence,
|
||||||
|
string EvidenceVersion,
|
||||||
|
string AssignmentStatus,
|
||||||
|
bool IsFallback);
|
||||||
|
|
||||||
private sealed record CharacterCodeProfile(
|
private sealed record CharacterCodeProfile(
|
||||||
string Code,
|
string Code,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
141
PlotLine/Sql/143_Phase21U_DevelopmentStoryIntelligenceReset.sql
Normal file
141
PlotLine/Sql/143_Phase21U_DevelopmentStoryIntelligenceReset.sql
Normal file
@ -0,0 +1,141 @@
|
|||||||
|
SET ANSI_NULLS ON;
|
||||||
|
SET QUOTED_IDENTIFIER ON;
|
||||||
|
GO
|
||||||
|
|
||||||
|
/*
|
||||||
|
Phase 21U development reset.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
DECLARE @DevelopmentEmail nvarchar(256) = N'you@example.com';
|
||||||
|
EXEC dbo.StoryIntelligence_DevelopmentAccountReset @DevelopmentEmail = @DevelopmentEmail;
|
||||||
|
|
||||||
|
This intentionally deletes project/book/import data only for projects owned by
|
||||||
|
the supplied account. It also clears the Story Intelligence illustration
|
||||||
|
library tables because that library is global development data.
|
||||||
|
|
||||||
|
Physical files must be deleted separately from:
|
||||||
|
wwwroot/uploads/story-intelligence-library
|
||||||
|
*/
|
||||||
|
|
||||||
|
CREATE OR ALTER PROCEDURE dbo.StoryIntelligence_DevelopmentAccountReset
|
||||||
|
@DevelopmentEmail nvarchar(256)
|
||||||
|
AS
|
||||||
|
BEGIN
|
||||||
|
SET NOCOUNT ON;
|
||||||
|
SET XACT_ABORT ON;
|
||||||
|
|
||||||
|
DECLARE @UserID int;
|
||||||
|
SELECT @UserID = UserID
|
||||||
|
FROM dbo.AppUser
|
||||||
|
WHERE Email = @DevelopmentEmail;
|
||||||
|
|
||||||
|
IF @UserID IS NULL
|
||||||
|
THROW 52101, 'Development account email was not found.', 1;
|
||||||
|
|
||||||
|
DECLARE @ProjectIDs TABLE (ProjectID int NOT NULL PRIMARY KEY);
|
||||||
|
INSERT @ProjectIDs (ProjectID)
|
||||||
|
SELECT DISTINCT p.ProjectID
|
||||||
|
FROM dbo.Projects p
|
||||||
|
INNER JOIN dbo.ProjectUserAccess pua ON pua.ProjectID = p.ProjectID
|
||||||
|
WHERE pua.UserID = @UserID
|
||||||
|
AND pua.AccessRole = N'Owner'
|
||||||
|
AND pua.IsActive = 1;
|
||||||
|
|
||||||
|
DECLARE @DeletedProjects table (ProjectID int NOT NULL PRIMARY KEY, ProjectName nvarchar(200) NULL);
|
||||||
|
INSERT @DeletedProjects(ProjectID, ProjectName)
|
||||||
|
SELECT p.ProjectID, p.ProjectName
|
||||||
|
FROM dbo.Projects p
|
||||||
|
INNER JOIN @ProjectIDs ids ON ids.ProjectID = p.ProjectID;
|
||||||
|
|
||||||
|
BEGIN TRY
|
||||||
|
BEGIN TRANSACTION;
|
||||||
|
|
||||||
|
UPDATE dbo.StoryIntelligenceRuns
|
||||||
|
SET Status = CASE WHEN Status IN (N'Pending', N'Running') THEN N'Cancelled' ELSE Status END,
|
||||||
|
CurrentStage = N'Cancelled by Phase 21U reset',
|
||||||
|
CurrentMessage = N'Development reset invalidated this run.',
|
||||||
|
CompletedUtc = COALESCE(CompletedUtc, SYSUTCDATETIME()),
|
||||||
|
UpdatedUtc = SYSUTCDATETIME()
|
||||||
|
WHERE ProjectID IN (SELECT ProjectID FROM @ProjectIDs)
|
||||||
|
AND Status IN (N'Pending', N'Running');
|
||||||
|
|
||||||
|
UPDATE dbo.IllustrationLibraryItems
|
||||||
|
SET Status = CASE WHEN Status IN (N'Queued', N'Generating') THEN N'Failed' ELSE Status END,
|
||||||
|
LastError = N'Invalidated by Phase 21U development reset',
|
||||||
|
UpdatedUtc = SYSUTCDATETIME()
|
||||||
|
WHERE Category IN (N'Character', N'Location', N'Asset')
|
||||||
|
AND Status IN (N'Queued', N'Generating');
|
||||||
|
|
||||||
|
DELETE FROM dbo.StoryMemoryIllustrationAssignments;
|
||||||
|
DELETE FROM dbo.StoryMemoryIllustrationDemands;
|
||||||
|
DELETE FROM dbo.StoryMemoryRelationships;
|
||||||
|
DELETE FROM dbo.StoryMemoryCharacterAttributes;
|
||||||
|
DELETE FROM dbo.StoryMemoryCharacterAliases;
|
||||||
|
DELETE FROM dbo.StoryMemoryAssets;
|
||||||
|
DELETE FROM dbo.StoryMemoryLocations;
|
||||||
|
DELETE FROM dbo.StoryMemoryCharacters;
|
||||||
|
DELETE FROM dbo.StoryMemoryProcessedSceneResults;
|
||||||
|
DELETE FROM dbo.StoryMemoryImportPreferences;
|
||||||
|
DELETE FROM dbo.StoryIntelligenceCharacterIllustrationAssignments;
|
||||||
|
DELETE FROM dbo.StoryIntelligenceIllustrationDemands;
|
||||||
|
|
||||||
|
IF OBJECT_ID(N'dbo.IllustrationGenerationAttempts', N'U') IS NOT NULL
|
||||||
|
DELETE FROM dbo.IllustrationGenerationAttempts;
|
||||||
|
|
||||||
|
IF OBJECT_ID(N'dbo.IllustrationLibraryApprovalHistory', N'U') IS NOT NULL
|
||||||
|
DELETE FROM dbo.IllustrationLibraryApprovalHistory;
|
||||||
|
|
||||||
|
IF OBJECT_ID(N'dbo.IllustrationLibraryLineage', N'U') IS NOT NULL
|
||||||
|
DELETE FROM dbo.IllustrationLibraryLineage;
|
||||||
|
|
||||||
|
UPDATE dbo.IllustrationLibraryItems
|
||||||
|
SET ParentItemID = NULL
|
||||||
|
WHERE Category IN (N'Character', N'Location', N'Asset');
|
||||||
|
|
||||||
|
DELETE FROM dbo.IllustrationLibraryItems
|
||||||
|
WHERE Category IN (N'Character', N'Location', N'Asset');
|
||||||
|
|
||||||
|
COMMIT TRANSACTION;
|
||||||
|
END TRY
|
||||||
|
BEGIN CATCH
|
||||||
|
IF XACT_STATE() <> 0 ROLLBACK TRANSACTION;
|
||||||
|
THROW;
|
||||||
|
END CATCH;
|
||||||
|
|
||||||
|
DECLARE @ProjectID int;
|
||||||
|
DECLARE project_cursor CURSOR LOCAL FAST_FORWARD FOR
|
||||||
|
SELECT ProjectID FROM @ProjectIDs ORDER BY ProjectID;
|
||||||
|
|
||||||
|
OPEN project_cursor;
|
||||||
|
FETCH NEXT FROM project_cursor INTO @ProjectID;
|
||||||
|
WHILE @@FETCH_STATUS = 0
|
||||||
|
BEGIN
|
||||||
|
UPDATE dbo.Projects
|
||||||
|
SET IsArchived = 1,
|
||||||
|
ArchivedDate = COALESCE(ArchivedDate, SYSUTCDATETIME()),
|
||||||
|
ArchivedReason = N'Phase 21U development reset'
|
||||||
|
WHERE ProjectID = @ProjectID;
|
||||||
|
|
||||||
|
EXEC dbo.ProjectHardDelete_ForOwner @ProjectID = @ProjectID, @UserID = @UserID;
|
||||||
|
FETCH NEXT FROM project_cursor INTO @ProjectID;
|
||||||
|
END
|
||||||
|
CLOSE project_cursor;
|
||||||
|
DEALLOCATE project_cursor;
|
||||||
|
|
||||||
|
SELECT N'ProjectsDeleted' AS Metric, COUNT(*) AS Value FROM @DeletedProjects
|
||||||
|
UNION ALL SELECT N'RemainingOwnedProjects', COUNT(*)
|
||||||
|
FROM dbo.Projects p
|
||||||
|
INNER JOIN dbo.ProjectUserAccess pua ON pua.ProjectID = p.ProjectID
|
||||||
|
WHERE pua.UserID = @UserID AND pua.IsActive = 1
|
||||||
|
UNION ALL SELECT N'RemainingStoryIntelligenceRuns', COUNT(*)
|
||||||
|
FROM dbo.StoryIntelligenceRuns
|
||||||
|
WHERE UserID = @UserID
|
||||||
|
UNION ALL SELECT N'RemainingIllustrationLibraryItems', COUNT(*)
|
||||||
|
FROM dbo.IllustrationLibraryItems
|
||||||
|
WHERE Category IN (N'Character', N'Location', N'Asset');
|
||||||
|
|
||||||
|
SELECT ProjectID, ProjectName
|
||||||
|
FROM @DeletedProjects
|
||||||
|
ORDER BY ProjectID;
|
||||||
|
END;
|
||||||
|
GO
|
||||||
11
PlotLine/Sql/144_Phase21V_StoryMemoryImportContext.sql
Normal file
11
PlotLine/Sql/144_Phase21V_StoryMemoryImportContext.sql
Normal 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
|
||||||
@ -131,6 +131,7 @@ public sealed class StoryIntelligenceOverviewViewModel
|
|||||||
public Guid? ExistingBatchID { get; init; }
|
public Guid? ExistingBatchID { get; init; }
|
||||||
public StoryIntelligenceJobProgress? ExistingJob { get; init; }
|
public StoryIntelligenceJobProgress? ExistingJob { get; init; }
|
||||||
public bool CanStart { get; init; }
|
public bool CanStart { get; init; }
|
||||||
|
public string AppearancePreference { get; init; } = StoryMemoryAppearancePreferences.Balanced;
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class StoryIntelligenceProgressViewModel
|
public sealed class StoryIntelligenceProgressViewModel
|
||||||
|
|||||||
@ -72,6 +72,7 @@ public sealed class StoryIntelligenceExperienceSnapshotDiagnostics
|
|||||||
public string ReplayState { get; init; } = string.Empty;
|
public string ReplayState { get; init; } = string.Empty;
|
||||||
public string SnapshotCacheStatus { get; init; } = string.Empty;
|
public string SnapshotCacheStatus { get; init; } = string.Empty;
|
||||||
public string RebuildVersion { get; init; } = string.Empty;
|
public string RebuildVersion { get; init; } = string.Empty;
|
||||||
|
public string AppearancePreference { get; init; } = string.Empty;
|
||||||
public int FailedChaptersSkipped { get; init; }
|
public int FailedChaptersSkipped { get; init; }
|
||||||
public string FailedChapterSummary { get; init; } = string.Empty;
|
public string FailedChapterSummary { get; init; } = string.Empty;
|
||||||
public string PanelVisibleCounts { get; init; } = string.Empty;
|
public string PanelVisibleCounts { get; init; } = string.Empty;
|
||||||
|
|||||||
@ -117,6 +117,17 @@
|
|||||||
<option value="all derived visualisation state">All derived visualisation state</option>
|
<option value="all derived visualisation state">All derived visualisation state</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
<label>
|
||||||
|
Appearance Guidance
|
||||||
|
<select name="appearancePreference">
|
||||||
|
<option value="@StoryMemoryAppearancePreferences.ExplicitEvidenceOnly">Explicit evidence only</option>
|
||||||
|
<option value="@StoryMemoryAppearancePreferences.Balanced" selected="@(Model.Diagnostics.AppearancePreference == StoryMemoryAppearancePreferences.Balanced)">Balanced diverse cast</option>
|
||||||
|
<option value="@StoryMemoryAppearancePreferences.PredominantlyLight" selected="@(Model.Diagnostics.AppearancePreference == StoryMemoryAppearancePreferences.PredominantlyLight)">Predominantly light-skinned</option>
|
||||||
|
<option value="@StoryMemoryAppearancePreferences.PredominantlyMedium" selected="@(Model.Diagnostics.AppearancePreference == StoryMemoryAppearancePreferences.PredominantlyMedium)">Predominantly medium-skinned</option>
|
||||||
|
<option value="@StoryMemoryAppearancePreferences.PredominantlyDark" selected="@(Model.Diagnostics.AppearancePreference == StoryMemoryAppearancePreferences.PredominantlyDark)">Predominantly dark-skinned</option>
|
||||||
|
<option value="@StoryMemoryAppearancePreferences.CustomMix" selected="@(Model.Diagnostics.AppearancePreference == StoryMemoryAppearancePreferences.CustomMix)">Custom mix</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
<button type="submit">Rebuild</button>
|
<button type="submit">Rebuild</button>
|
||||||
</form>
|
</form>
|
||||||
}
|
}
|
||||||
@ -186,10 +197,6 @@
|
|||||||
<p class="story-exp-zone-label">Location</p>
|
<p class="story-exp-zone-label">Location</p>
|
||||||
<div class="story-exp-location" data-location></div>
|
<div class="story-exp-location" data-location></div>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
<section class="story-exp-ribbon-wrap" aria-label="Recent scenes and timeline">
|
<section class="story-exp-ribbon-wrap" aria-label="Recent scenes and timeline">
|
||||||
@ -350,6 +357,10 @@
|
|||||||
<dt>Unresolved characters</dt>
|
<dt>Unresolved characters</dt>
|
||||||
<dd data-diagnostic-key="unresolvedCharacterCount">@Model.Diagnostics.UnresolvedCharacterCount</dd>
|
<dd data-diagnostic-key="unresolvedCharacterCount">@Model.Diagnostics.UnresolvedCharacterCount</dd>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Appearance guidance</dt>
|
||||||
|
<dd data-diagnostic-key="appearancePreference">@Model.Diagnostics.AppearancePreference</dd>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt>Narrator confidence</dt>
|
<dt>Narrator confidence</dt>
|
||||||
<dd data-diagnostic-key="narratorConfidence">@Model.Diagnostics.NarratorConfidence</dd>
|
<dd data-diagnostic-key="narratorConfidence">@Model.Diagnostics.NarratorConfidence</dd>
|
||||||
|
|||||||
@ -80,7 +80,67 @@
|
|||||||
<form asp-action="SkipStoryIntelligence" method="post">
|
<form asp-action="SkipStoryIntelligence" method="post">
|
||||||
<button class="btn btn-outline-secondary" type="submit">Skip for now</button>
|
<button class="btn btn-outline-secondary" type="submit">Skip for now</button>
|
||||||
</form>
|
</form>
|
||||||
<form asp-action="StartStoryIntelligence" method="post">
|
<form asp-action="StartStoryIntelligence" method="post" class="onboarding-form-section">
|
||||||
|
<fieldset>
|
||||||
|
<legend>Unresolved character appearance</legend>
|
||||||
|
<p>Explicit manuscript evidence always wins. This only guides characters whose appearance is not described.</p>
|
||||||
|
<label>
|
||||||
|
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>
|
<button class="btn btn-primary" type="submit" disabled="@(!Model.CanStart)">Analyse manuscript</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -16,6 +16,8 @@
|
|||||||
--story-rose: #e38ca8;
|
--story-rose: #e38ca8;
|
||||||
--story-radius: 18px;
|
--story-radius: 18px;
|
||||||
--story-shadow: 0 28px 84px rgba(0, 0, 0, 0.32);
|
--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;
|
--progress-value: 0deg;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -287,16 +289,26 @@ input:focus-visible {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.story-exp-rebuild {
|
.story-exp-rebuild {
|
||||||
|
position: fixed;
|
||||||
|
left: 50%;
|
||||||
|
top: 66px;
|
||||||
|
z-index: 18;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 9px;
|
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: 1px solid rgba(151, 185, 218, 0.12);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
background: rgba(4, 12, 24, 0.74);
|
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 {
|
.story-exp-rebuild label {
|
||||||
@ -307,6 +319,7 @@ input:focus-visible {
|
|||||||
font-size: 0.76rem;
|
font-size: 0.76rem;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.story-exp-rebuild select,
|
.story-exp-rebuild select,
|
||||||
@ -319,7 +332,7 @@ input:focus-visible {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.story-exp-rebuild select {
|
.story-exp-rebuild select {
|
||||||
max-width: min(280px, 40vw);
|
max-width: min(240px, 24vw);
|
||||||
padding: 6px 22px 6px 9px;
|
padding: 6px 22px 6px 9px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -330,7 +343,7 @@ input:focus-visible {
|
|||||||
|
|
||||||
.story-exp-shell {
|
.story-exp-shell {
|
||||||
display: grid;
|
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;
|
gap: 12px;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
padding: 12px 18px 58px;
|
padding: 12px 18px 58px;
|
||||||
@ -339,8 +352,7 @@ input:focus-visible {
|
|||||||
.story-exp-left,
|
.story-exp-left,
|
||||||
.story-exp-right {
|
.story-exp-right {
|
||||||
display: grid;
|
display: grid;
|
||||||
align-content: start;
|
gap: 9px;
|
||||||
gap: 14px;
|
|
||||||
max-height: calc(100vh - 126px);
|
max-height: calc(100vh - 126px);
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
@ -355,11 +367,11 @@ input:focus-visible {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.story-exp-left {
|
.story-exp-left {
|
||||||
grid-template-rows: auto auto minmax(150px, 1fr) minmax(120px, 0.72fr);
|
grid-template-rows: auto auto minmax(0, auto) minmax(0, auto);
|
||||||
}
|
}
|
||||||
|
|
||||||
.story-exp-right {
|
.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,
|
.story-exp-left,
|
||||||
@ -381,16 +393,39 @@ input:focus-visible {
|
|||||||
|
|
||||||
.story-exp-progress,
|
.story-exp-progress,
|
||||||
.story-exp-panel {
|
.story-exp-panel {
|
||||||
padding: 11px;
|
padding: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.story-exp-panel {
|
.story-exp-panel {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-rows: auto auto;
|
align-content: start;
|
||||||
|
grid-template-rows: auto minmax(0, auto);
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: hidden;
|
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 {
|
.story-exp-panel--compact {
|
||||||
padding: 9px;
|
padding: 9px;
|
||||||
}
|
}
|
||||||
@ -504,8 +539,8 @@ input:focus-visible {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.story-exp-panel h2 {
|
.story-exp-panel h2 {
|
||||||
margin: 0 0 7px;
|
margin: 0 0 5px;
|
||||||
font-size: 0.88rem;
|
font-size: var(--story-side-title-font-size);
|
||||||
letter-spacing: 0.01em;
|
letter-spacing: 0.01em;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -524,10 +559,11 @@ input:focus-visible {
|
|||||||
.story-exp-feed li,
|
.story-exp-feed li,
|
||||||
.story-exp-insight {
|
.story-exp-insight {
|
||||||
border: 1px solid rgba(255, 255, 255, 0.065);
|
border: 1px solid rgba(255, 255, 255, 0.065);
|
||||||
border-radius: 9px;
|
border-radius: 7px;
|
||||||
padding: 7px 8px;
|
padding: 6px 7px;
|
||||||
background: rgba(255, 255, 255, 0.035);
|
background: rgba(255, 255, 255, 0.035);
|
||||||
color: var(--story-soft);
|
color: var(--story-soft);
|
||||||
|
font-size: var(--story-side-body-font-size);
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
@ -662,15 +698,15 @@ input:focus-visible {
|
|||||||
animation: drawLine 760ms cubic-bezier(.16, .84, .28, 1) forwards;
|
animation: drawLine 760ms cubic-bezier(.16, .84, .28, 1) forwards;
|
||||||
}
|
}
|
||||||
|
|
||||||
.story-exp-connection-group--presence .story-exp-connection,
|
.story-exp-connection-group--presence-cluster .story-exp-connection {
|
||||||
.story-exp-connection-group--presence-soft .story-exp-connection {
|
stroke: rgba(125, 193, 255, 0.62);
|
||||||
stroke: rgba(125, 193, 255, 0.52);
|
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--knowledge .story-exp-connection,
|
||||||
.story-exp-connection-group--asset-link .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 {
|
.story-exp-connection-group--support .story-exp-connection {
|
||||||
@ -678,15 +714,19 @@ input:focus-visible {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.story-exp-connection-group--conflict .story-exp-connection {
|
.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;
|
stroke-dasharray: 0.2 0.12;
|
||||||
}
|
}
|
||||||
|
|
||||||
.story-exp-connection-group--uncertain .story-exp-connection {
|
.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;
|
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 {
|
.story-exp-connection-group--knowledge .story-exp-connection {
|
||||||
stroke: rgba(182, 162, 255, 0.44);
|
stroke: rgba(182, 162, 255, 0.44);
|
||||||
}
|
}
|
||||||
@ -741,12 +781,7 @@ input:focus-visible {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.story-exp-zone--location .story-exp-zone-label {
|
.story-exp-zone--location .story-exp-zone-label {
|
||||||
left: 55%;
|
left: 80%;
|
||||||
top: 22px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.story-exp-zone--assets .story-exp-zone-label {
|
|
||||||
right: 8%;
|
|
||||||
top: 22px;
|
top: 22px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -778,8 +813,7 @@ input:focus-visible {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.story-character__image,
|
.story-character__image,
|
||||||
.story-location__image,
|
.story-location__image {
|
||||||
.story-asset__image {
|
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
display: block;
|
display: block;
|
||||||
@ -796,8 +830,7 @@ input:focus-visible {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.story-character__image img,
|
.story-character__image img,
|
||||||
.story-location__image img,
|
.story-location__image img {
|
||||||
.story-asset__image img {
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
@ -864,6 +897,71 @@ input:focus-visible {
|
|||||||
opacity: 0.9;
|
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 {
|
.story-location {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
display: grid;
|
display: grid;
|
||||||
@ -929,68 +1027,6 @@ input:focus-visible {
|
|||||||
white-space: nowrap;
|
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 {
|
.story-exp-ribbon-wrap {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-rows: auto minmax(104px, auto) 56px;
|
grid-template-rows: auto minmax(104px, auto) 56px;
|
||||||
@ -1186,7 +1222,7 @@ input:focus-visible {
|
|||||||
|
|
||||||
.story-exp-insight {
|
.story-exp-insight {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 2px;
|
gap: 1px;
|
||||||
border-color: rgba(255, 255, 255, 0.055);
|
border-color: rgba(255, 255, 255, 0.055);
|
||||||
background:
|
background:
|
||||||
linear-gradient(180deg, rgba(255, 255, 255, 0.038), rgba(255, 255, 255, 0.018));
|
linear-gradient(180deg, rgba(255, 255, 255, 0.038), rgba(255, 255, 255, 0.018));
|
||||||
@ -1200,25 +1236,18 @@ input:focus-visible {
|
|||||||
padding: 6px 7px;
|
padding: 6px 7px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.story-exp-panel--compact .story-exp-insight p {
|
.story-exp-insight p {
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 0.72rem;
|
font-size: var(--story-side-body-font-size);
|
||||||
line-height: 1.16;
|
line-height: 1.16;
|
||||||
-webkit-box-orient: vertical;
|
-webkit-box-orient: vertical;
|
||||||
-webkit-line-clamp: 2;
|
-webkit-line-clamp: 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.story-exp-more {
|
.story-exp-panel--compact .story-exp-insight p {
|
||||||
border: 1px solid rgba(151, 185, 218, 0.12);
|
-webkit-line-clamp: 2;
|
||||||
border-radius: 8px;
|
|
||||||
padding: 5px 7px;
|
|
||||||
background: rgba(255, 255, 255, 0.035);
|
|
||||||
color: var(--story-soft);
|
|
||||||
font-size: 0.72rem;
|
|
||||||
font-weight: 800;
|
|
||||||
text-align: left;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.story-exp-list li,
|
.story-exp-list li,
|
||||||
@ -1228,7 +1257,7 @@ input:focus-visible {
|
|||||||
|
|
||||||
.story-exp-insight strong {
|
.story-exp-insight strong {
|
||||||
color: var(--story-text);
|
color: var(--story-text);
|
||||||
font-size: 0.82rem;
|
font-size: var(--story-side-title-font-size);
|
||||||
line-height: 1.12;
|
line-height: 1.12;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1387,8 +1416,7 @@ input:focus-visible {
|
|||||||
transform: translateY(12px) scale(0.96);
|
transform: translateY(12px) scale(0.96);
|
||||||
}
|
}
|
||||||
|
|
||||||
.story-character.is-entering,
|
.story-character.is-entering {
|
||||||
.story-asset.is-entering {
|
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translate(var(--x, 0), var(--y, 0)) scale(0.82);
|
transform: translate(var(--x, 0), var(--y, 0)) scale(0.82);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -110,7 +110,7 @@
|
|||||||
const layout = sceneLayout(current);
|
const layout = sceneLayout(current);
|
||||||
reconcileCharacters(current, previous, layout);
|
reconcileCharacters(current, previous, layout);
|
||||||
reconcileLocation(current, previous);
|
reconcileLocation(current, previous);
|
||||||
reconcileAssets(current, previous, layout);
|
clearAssetStage();
|
||||||
reconcileRelationships(current);
|
reconcileRelationships(current);
|
||||||
updateKnowledgeThreads(current);
|
updateKnowledgeThreads(current);
|
||||||
updateSceneRibbon(current);
|
updateSceneRibbon(current);
|
||||||
@ -231,6 +231,13 @@
|
|||||||
function setEntityImage(image, url, resolution, altText) {
|
function setEntityImage(image, url, resolution, altText) {
|
||||||
if (!image) return;
|
if (!image) return;
|
||||||
image.alt = altText;
|
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 fallbackUrl = resolution?.fallbackImageUrl || url;
|
||||||
const nextUrl = failedImageUrls.has(url) ? fallbackUrl : url;
|
const nextUrl = failedImageUrls.has(url) ? fallbackUrl : url;
|
||||||
image.dataset.fallbackUrl = fallbackUrl || "";
|
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) {
|
function preloadSceneImages(scene) {
|
||||||
if (!scene) return;
|
if (!scene) return;
|
||||||
collectSceneImages(scene).forEach((url) => {
|
collectSceneImages(scene).forEach((url) => {
|
||||||
@ -265,7 +381,7 @@
|
|||||||
return [
|
return [
|
||||||
scene.location?.imagePath,
|
scene.location?.imagePath,
|
||||||
...(scene.characters || []).map((character) => character.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);
|
].filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -319,10 +435,13 @@
|
|||||||
node.style.setProperty("--opacity", String(position.opacity));
|
node.style.setProperty("--opacity", String(position.opacity));
|
||||||
node.style.setProperty("--z", String(position.z));
|
node.style.setProperty("--z", String(position.z));
|
||||||
node.dataset.layoutRole = position.layoutRole;
|
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);
|
text(node.querySelector("[data-character-name]"), character.name);
|
||||||
const label = conciseCharacterLabel(character, character.id === scene.povCharacterId);
|
const label = conciseCharacterLabel(character, character.id === scene.povCharacterId);
|
||||||
text(node.querySelector("[data-character-role]"), label);
|
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}` : ""}`;
|
node.title = `${character.name}: ${character.role}${character.relevance ? ` - ${character.relevance}` : ""}`;
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -371,40 +490,8 @@
|
|||||||
node.title = `${scene.location.name}: ${scene.location.label}`;
|
node.title = `${scene.location.name}: ${scene.location.label}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function reconcileAssets(scene, previous, layout) {
|
function clearAssetStage() {
|
||||||
const activeIds = new Set(scene.assets.map((asset) => asset.id));
|
dom.assets?.replaceChildren();
|
||||||
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 reconcileRelationships(scene) {
|
function reconcileRelationships(scene) {
|
||||||
@ -414,15 +501,12 @@
|
|||||||
detail: relationship.state,
|
detail: relationship.state,
|
||||||
tone: relationship.weight >= 90 ? "confirmed" : relationship.weight >= 70 ? "strengthening" : "emerging"
|
tone: relationship.weight >= 90 ? "confirmed" : relationship.weight >= 70 ? "strengthening" : "emerging"
|
||||||
}));
|
}));
|
||||||
renderInsights(dom.relationships, relationships.slice(0, 2));
|
renderInsights(dom.relationships, relationships.slice(0, 1));
|
||||||
renderOverflowSummary(dom.relationships, relationships.length - 2, "relationship");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateKnowledgeThreads(scene) {
|
function updateKnowledgeThreads(scene) {
|
||||||
renderInsights(dom.knowledge, (scene.knowledgeThreads || []).slice(0, 3));
|
renderInsights(dom.knowledge, (scene.knowledgeThreads || []).slice(0, 5));
|
||||||
renderOverflowSummary(dom.knowledge, (scene.knowledgeThreads || []).length - 3, "thread");
|
renderInsights(dom.observations, (scene.observations || []).slice(0, 4));
|
||||||
renderInsights(dom.observations, (scene.observations || []).slice(0, 2));
|
|
||||||
renderOverflowSummary(dom.observations, (scene.observations || []).length - 2, "observation");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateSceneRibbon(scene) {
|
function updateSceneRibbon(scene) {
|
||||||
@ -544,26 +628,6 @@
|
|||||||
if (dom.detailPanel) dom.detailPanel.hidden = true;
|
if (dom.detailPanel) dom.detailPanel.hidden = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderOverflowSummary(container, hiddenCount, noun) {
|
|
||||||
if (!container) return;
|
|
||||||
let node = container.querySelector("[data-insight-overflow]");
|
|
||||||
if (hiddenCount <= 0) {
|
|
||||||
node?.remove();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!node) {
|
|
||||||
node = document.createElement("button");
|
|
||||||
node.type = "button";
|
|
||||||
node.className = "story-exp-more";
|
|
||||||
node.dataset.insightOverflow = "true";
|
|
||||||
container.append(node);
|
|
||||||
}
|
|
||||||
|
|
||||||
node.textContent = `+${hiddenCount} more ${noun}${hiddenCount === 1 ? "" : "s"}`;
|
|
||||||
node.title = `${hiddenCount} additional ${noun}${hiddenCount === 1 ? "" : "s"} in this scene`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderList(container, items) {
|
function renderList(container, items) {
|
||||||
const activeIds = new Set(items.map((item, index) => feedId(item, index)));
|
const activeIds = new Set(items.map((item, index) => feedId(item, index)));
|
||||||
items.forEach((item, index) => {
|
items.forEach((item, index) => {
|
||||||
@ -618,7 +682,10 @@
|
|||||||
const activeIds = new Set();
|
const activeIds = new Set();
|
||||||
const occupiedRects = layoutRects(stageRect);
|
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 source = dom.visualStage.querySelector(`[data-character-id="${cssEscape(relationship.sourceId)}"]`);
|
||||||
const target = dom.visualStage.querySelector(`[data-character-id="${cssEscape(relationship.targetId)}"]`);
|
const target = dom.visualStage.querySelector(`[data-character-id="${cssEscape(relationship.targetId)}"]`);
|
||||||
if (!source || !target) return;
|
if (!source || !target) return;
|
||||||
@ -628,37 +695,22 @@
|
|||||||
const obstacles = connectionObstacles(stageRect, source, target);
|
const obstacles = connectionObstacles(stageRect, source, target);
|
||||||
const id = relationship.id || `relationship-${relationship.sourceId}-${relationship.targetId}`;
|
const id = relationship.id || `relationship-${relationship.sourceId}-${relationship.targetId}`;
|
||||||
activeIds.add(id);
|
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]");
|
const location = dom.visualStage.querySelector("[data-location-id]");
|
||||||
if (pov && location) {
|
const presentCharacters = scene.characters
|
||||||
const id = `presence-${scene.povCharacterId}-${location.dataset.locationId}`;
|
.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);
|
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);
|
removeInactiveSvgConnections(activeIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -681,7 +733,7 @@
|
|||||||
path.setAttribute("class", "story-exp-connection");
|
path.setAttribute("class", "story-exp-connection");
|
||||||
group.append(path);
|
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}`;
|
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.setAttribute("d", d);
|
||||||
path.style.strokeWidth = String(0.9 + Math.min(weight, 100) / 62);
|
path.style.strokeWidth = String(0.9 + Math.min(weight, 100) / 62);
|
||||||
@ -731,19 +783,7 @@
|
|||||||
<div class="story-character__text">
|
<div class="story-character__text">
|
||||||
<strong data-character-name></strong>
|
<strong data-character-name></strong>
|
||||||
<span data-character-role></span>
|
<span data-character-role></span>
|
||||||
</div>`;
|
<div class="story-entity-facts" data-character-facts></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>`;
|
</div>`;
|
||||||
return node;
|
return node;
|
||||||
}
|
}
|
||||||
@ -753,8 +793,7 @@
|
|||||||
const width = Math.max(760, bounds?.width || 1040);
|
const width = Math.max(760, bounds?.width || 1040);
|
||||||
const height = Math.max(500, bounds?.height || 560);
|
const height = Math.max(500, bounds?.height || 560);
|
||||||
return {
|
return {
|
||||||
characters: characterLayout(scene, width, height),
|
characters: characterLayout(scene, width, height)
|
||||||
assets: assetLayout(scene, width, height)
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -765,10 +804,10 @@
|
|||||||
const others = characters
|
const others = characters
|
||||||
.filter((character) => character.id !== pov?.id)
|
.filter((character) => character.id !== pov?.id)
|
||||||
.sort((a, b) => b.weight - a.weight);
|
.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) {
|
if (pov) {
|
||||||
positions.set(pov.id, {
|
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),
|
y: clamp(Math.round(height * 0.28 - povSize / 2), 54, height * 0.54 - povSize),
|
||||||
size: povSize,
|
size: povSize,
|
||||||
scale: 1,
|
scale: 1,
|
||||||
@ -780,11 +819,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const slots = [
|
const slots = [
|
||||||
{ x: 0.41, y: 0.48, role: "partner" },
|
{ x: 0.55, y: 0.46, role: "partner" },
|
||||||
{ x: 0.12, y: 0.55, role: "support" },
|
{ x: 0.14, y: 0.58, role: "support" },
|
||||||
{ x: 0.11, y: 0.19, role: "support" },
|
{ x: 0.12, y: 0.18, role: "support" },
|
||||||
{ x: 0.33, y: 0.70, role: "context" },
|
{ x: 0.42, y: 0.72, role: "context" },
|
||||||
{ x: 0.05, y: 0.36, 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()];
|
const placed = [...positions.values()];
|
||||||
others.forEach((character, index) => {
|
others.forEach((character, index) => {
|
||||||
@ -862,64 +904,14 @@
|
|||||||
const bounds = dom.visualStage?.getBoundingClientRect();
|
const bounds = dom.visualStage?.getBoundingClientRect();
|
||||||
const width = Math.max(820, bounds?.width || 1040);
|
const width = Math.max(820, bounds?.width || 1040);
|
||||||
const height = Math.max(500, bounds?.height || 560);
|
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 {
|
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),
|
y: Math.round(height * 0.46 - size / 2),
|
||||||
size
|
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) {
|
function avoidNodeCollisions(desired, size, placed, width, height) {
|
||||||
const padding = Math.max(16, size * 0.12);
|
const padding = Math.max(16, size * 0.12);
|
||||||
const candidates = [
|
const candidates = [
|
||||||
@ -965,11 +957,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function connectionPoint(node, parentRect, targetNode) {
|
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 centre = centrePoint(image, parentRect);
|
||||||
const rect = image.getBoundingClientRect();
|
const rect = image.getBoundingClientRect();
|
||||||
const target = targetNode ? centrePoint(targetNode, parentRect) : centre;
|
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 targetCentre = targetImage ? centrePoint(targetImage, parentRect) : target;
|
||||||
const radius = Math.min(rect.width, rect.height) * 0.5;
|
const radius = Math.min(rect.width, rect.height) * 0.5;
|
||||||
const angle = Math.atan2(targetCentre.y - centre.y, targetCentre.x - centre.x);
|
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 mid = { x: (start.x + end.x) / 2, y: (start.y + end.y) / 2 };
|
||||||
const dx = end.x - start.x;
|
const dx = end.x - start.x;
|
||||||
const dy = end.y - start.y;
|
const dy = end.y - start.y;
|
||||||
const length = Math.max(1, Math.hypot(dx, dy));
|
const length = Math.max(1, Math.hypot(dx, dy));
|
||||||
const normal = { x: -dy / length, y: dx / length };
|
const normal = { x: -dy / length, y: dx / length };
|
||||||
const tangent = { x: dx / length, y: dy / 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 direction = index % 2 === 0 ? 1 : -1;
|
||||||
const offsets = [direction * base, -direction * base, direction * base * 1.45, -direction * base * 1.45, 0];
|
const offsets = [direction * base, -direction * base, direction * base * 1.45, -direction * base * 1.45, 0];
|
||||||
let best = offsets[0];
|
let best = offsets[0];
|
||||||
@ -1020,14 +1026,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function layoutRects(parentRect) {
|
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));
|
.map((node) => rectRelativeTo(node, parentRect, node.className.includes("__text") ? 8 : 10));
|
||||||
}
|
}
|
||||||
|
|
||||||
function connectionObstacles(parentRect, source, target) {
|
function connectionObstacles(parentRect, source, target) {
|
||||||
const sourceImage = source.querySelector(".story-character__image, .story-location__image, .story-asset__image") || source;
|
const sourceImage = source.querySelector(".story-character__image, .story-location__image") || source;
|
||||||
const targetImage = target.querySelector(".story-character__image, .story-location__image, .story-asset__image") || target;
|
const targetImage = target.querySelector(".story-character__image, .story-location__image") || target;
|
||||||
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")]
|
||||||
.filter((node) => node !== sourceImage && node !== targetImage)
|
.filter((node) => node !== sourceImage && node !== targetImage)
|
||||||
.map((node) => rectRelativeTo(node, parentRect, node.className.includes("__text") ? 8 : 12));
|
.map((node) => rectRelativeTo(node, parentRect, node.className.includes("__text") ? 8 : 12));
|
||||||
}
|
}
|
||||||
@ -1558,7 +1564,7 @@
|
|||||||
const layout = sceneLayout(current);
|
const layout = sceneLayout(current);
|
||||||
reconcileCharacters(current, current, layout);
|
reconcileCharacters(current, current, layout);
|
||||||
reconcileLocation(current, current);
|
reconcileLocation(current, current);
|
||||||
reconcileAssets(current, current, layout);
|
clearAssetStage();
|
||||||
scheduleConnectionDraw();
|
scheduleConnectionDraw();
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -1568,7 +1574,7 @@
|
|||||||
const current = loadSceneState(state.index);
|
const current = loadSceneState(state.index);
|
||||||
const layout = sceneLayout(current);
|
const layout = sceneLayout(current);
|
||||||
reconcileCharacters(current, current, layout);
|
reconcileCharacters(current, current, layout);
|
||||||
reconcileAssets(current, current, layout);
|
clearAssetStage();
|
||||||
scheduleConnectionDraw();
|
scheduleConnectionDraw();
|
||||||
});
|
});
|
||||||
observer.observe(dom.visualStage);
|
observer.observe(dom.visualStage);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user