Phase 21Q - Production Visualisation Repair
This commit is contained in:
parent
fd37df65c6
commit
4e31784f46
@ -54,6 +54,8 @@ var tests = new (string Name, Action Test)[]
|
|||||||
("Illustration assignment repair enforces explicit evidence groups and strict subtypes", IllustrationAssignmentRepairEnforcesExplicitEvidenceGroupsAndStrictSubtypes),
|
("Illustration assignment repair enforces explicit evidence groups and strict subtypes", IllustrationAssignmentRepairEnforcesExplicitEvidenceGroupsAndStrictSubtypes),
|
||||||
("Phase 21O preserves adult defaults and real-mode continuity", Phase21OPreservesAdultDefaultsAndRealModeContinuity),
|
("Phase 21O preserves adult defaults and real-mode continuity", Phase21OPreservesAdultDefaultsAndRealModeContinuity),
|
||||||
("Phase 21P uses one authoritative current visualisation scene", Phase21PUsesOneAuthoritativeCurrentVisualisationScene),
|
("Phase 21P uses one authoritative current visualisation scene", Phase21PUsesOneAuthoritativeCurrentVisualisationScene),
|
||||||
|
("Phase 21Q repairs production visualisation regressions", Phase21QRepairsProductionVisualisationRegressions),
|
||||||
|
("Phase 21Q view keeps panels bounded and real controls hidden", Phase21QViewKeepsPanelsBoundedAndRealControlsHidden),
|
||||||
("Scan review post supports full-book form submissions", ScanReviewPostSupportsFullBookFormSubmissions),
|
("Scan review post supports full-book form submissions", ScanReviewPostSupportsFullBookFormSubmissions),
|
||||||
("Story Intelligence experience boot does not serialise live model", StoryIntelligenceExperienceBootDoesNotSerialiseLiveModel),
|
("Story Intelligence experience boot does not serialise live model", StoryIntelligenceExperienceBootDoesNotSerialiseLiveModel),
|
||||||
("Live visualisation strips illustration diagnostics", LiveVisualisationStripsIllustrationDiagnostics),
|
("Live visualisation strips illustration diagnostics", LiveVisualisationStripsIllustrationDiagnostics),
|
||||||
@ -628,7 +630,7 @@ static void Phase21OPreservesAdultDefaultsAndRealModeContinuity()
|
|||||||
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") == "Road", "Lane names should classify as Road.");
|
||||||
Assert(!StoryIntelligenceIllustrationCompatibility.AssetCompatible("Hatchback", "Car"), "Hatchbacks must not borrow 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.");
|
||||||
|
|
||||||
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"));
|
||||||
@ -653,11 +655,11 @@ 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)
|
static IllustrationCharacterMetadata Metadata(string code, string ageBand, string presentation, bool isUnknownFigure = false, string hairColour = "Unknown")
|
||||||
=> new(
|
=> new(
|
||||||
ageBand,
|
ageBand,
|
||||||
presentation,
|
presentation,
|
||||||
"Unknown",
|
hairColour,
|
||||||
"Unknown",
|
"Unknown",
|
||||||
"Unknown",
|
"Unknown",
|
||||||
"Unknown",
|
"Unknown",
|
||||||
@ -866,6 +868,60 @@ static void IllustrationEvidenceTraceDoesNotRecursivelyStorePreviousEvidence()
|
|||||||
Assert(source.Contains("evidenceHash", StringComparison.Ordinal), "Evidence trace should keep only a compact previous evidence hash.");
|
Assert(source.Contains("evidenceHash", StringComparison.Ordinal), "Evidence trace should keep only a compact previous evidence hash.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static void Phase21QRepairsProductionVisualisationRegressions()
|
||||||
|
{
|
||||||
|
var unknownNamed = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Sarah", ["Sarah watches from the lane."], []);
|
||||||
|
var mrColin = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Mr Colin Webb", ["Mr Colin Webb is a driving examiner."], []);
|
||||||
|
var maggie = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Maggie", ["Maggie is sixteen.", "Maggie pushed back her red hair."], []);
|
||||||
|
var darkHairCandidate = Metadata("char-young-adult-brunette-observer", "OlderTeen", "Feminine", hairColour: "Brown");
|
||||||
|
var teenCandidate = Metadata("char-young-helper", "YoungTeen", "Androgynous");
|
||||||
|
|
||||||
|
Assert(unknownNamed.AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.Unknown, $"Unknown named age must remain Unknown, got {unknownNamed.AgeBand}.");
|
||||||
|
Assert(mrColin.Presentation == StoryIntelligenceIllustrationCompatibility.Presentations.Masculine, $"Mr Colin should infer Masculine, got {mrColin.Presentation}.");
|
||||||
|
Assert(mrColin.AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.Adult, $"Driving examiner title/profession should infer Adult, got {mrColin.AgeBand}.");
|
||||||
|
Assert(Rejected(mrColin, teenCandidate).Any(reason => reason.Contains("age hard mismatch", StringComparison.OrdinalIgnoreCase)), "Adult title/profession should reject child/teen candidates.");
|
||||||
|
Assert(maggie.HairColour == StoryIntelligenceIllustrationCompatibility.HairColours.Red, $"Maggie red-hair evidence should be explicit, got {maggie.HairColour}.");
|
||||||
|
Assert(Rejected(maggie, darkHairCandidate).Any(reason => reason.Contains("hair colour hard mismatch", StringComparison.OrdinalIgnoreCase)), "Red-hair evidence must invalidate dark-haired assignments.");
|
||||||
|
|
||||||
|
Assert(StoryIntelligenceIllustrationCompatibility.LocationType("Cock Hill Lane") == "Road", "Cock Hill Lane should classify as Road/Lane.");
|
||||||
|
Assert(StoryIntelligenceIllustrationCompatibility.LocationType("derelict hospital waiting room") == "Hospital", "Derelict hospital should remain Hospital rather than generic interior.");
|
||||||
|
Assert(StoryIntelligenceIllustrationCompatibility.LocationType("bench beside the road") == "Road", "Bench context beside road should not become indoor room artwork.");
|
||||||
|
Assert(!StoryIntelligenceIllustrationCompatibility.LocationCompatible("Road", "CarPark"), "Lane/Road must not use car park artwork.");
|
||||||
|
Assert(!StoryIntelligenceIllustrationCompatibility.LocationCompatible("WaitingRoom", "FlatInterior"), "Waiting room must not use flat interior artwork.");
|
||||||
|
|
||||||
|
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("blue VW Golf") == "Hatchback", "Blue VW Golf should classify as Hatchback.");
|
||||||
|
Assert(StoryIntelligenceIllustrationCompatibility.AssetColour("blue VW Golf") == "Blue", "Blue VW Golf should retain explicit colour.");
|
||||||
|
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("TR6 sports car") == "SportsCar", "TR6 should remain a sports car.");
|
||||||
|
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("parcel") == "Parcel", "Parcel should not use letter/document imagery.");
|
||||||
|
Assert(!StoryIntelligenceIllustrationCompatibility.AssetCompatible("Hatchback", "SportsCar"), "Blue hatchback must not silently use red sports-car imagery.");
|
||||||
|
|
||||||
|
var serviceSource = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Services/StoryIntelligenceIllustrationMatchingService.cs"));
|
||||||
|
Assert(serviceSource.Contains("DemandGenerationThreshold = 1", StringComparison.Ordinal), "Live import demand should queue on first repeated missing archetype pass, not after dozens of scenes.");
|
||||||
|
Assert(serviceSource.Contains("ApplySupportingIllustrationDemandAsync", StringComparison.Ordinal), "Location and asset demand must run from the visualisation pipeline.");
|
||||||
|
Assert(serviceSource.Contains("MateriallyWrong(existingScore.Candidate.Metadata, observation.Evidence)", StringComparison.Ordinal), "Existing assignments must be revalidated against current evidence.");
|
||||||
|
Assert(serviceSource.Contains("BalancedSkinToneFallback", StringComparison.Ordinal), "Unresolved appearance should use deterministic project/book diversity rather than one global skin-tone default.");
|
||||||
|
|
||||||
|
var librarySource = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Services/IllustrationLibraryServices.cs"));
|
||||||
|
var applyApprovedSource = librarySource[librarySource.IndexOf("public async Task ApplyApprovedIllustrationsAsync", StringComparison.Ordinal)..];
|
||||||
|
Assert(!applyApprovedSource.Contains("StarterCodes.Contains", StringComparison.Ordinal), "Generated demand illustrations must not be filtered out by the runtime approved-image lookup.");
|
||||||
|
}
|
||||||
|
|
||||||
|
static void Phase21QViewKeepsPanelsBoundedAndRealControlsHidden()
|
||||||
|
{
|
||||||
|
var view = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Views/Development/StoryIntelligenceExperience.cshtml"));
|
||||||
|
var script = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/wwwroot/js/story-intelligence-experience-prototype.js"));
|
||||||
|
var css = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/wwwroot/css/story-intelligence-experience-prototype.css"));
|
||||||
|
|
||||||
|
Assert(!view.Contains("data-scene-title", StringComparison.Ordinal), "Redundant scene heading should be removed from the real visualisation.");
|
||||||
|
Assert(view.IndexOf("data-knowledge", StringComparison.Ordinal) < view.IndexOf("data-relationships", StringComparison.Ordinal), "Knowledge threads should be prioritised above relationship changes.");
|
||||||
|
Assert(view.Contains("Model.Mode == \"simulation\"", StringComparison.Ordinal), "Prototype controls must remain simulation-only.");
|
||||||
|
Assert(script.Contains("relationships.slice(0, 2)", StringComparison.Ordinal), "Relationship changes should be capped to the two most relevant visible cards.");
|
||||||
|
Assert(script.Contains("renderOverflowSummary", StringComparison.Ordinal), "Relationship overflow should be indicated compactly.");
|
||||||
|
Assert(css.Contains("max-height: calc(100vh - 126px)", StringComparison.Ordinal), "Side columns should be bounded to the viewport.");
|
||||||
|
Assert(css.Contains("overflow: auto", StringComparison.Ordinal), "Side-panel content should have internal scrolling.");
|
||||||
|
Assert(css.Contains("z-index: 180", StringComparison.Ordinal), "Character labels should render in a high label layer above nodes and paths.");
|
||||||
|
}
|
||||||
|
|
||||||
static JsonSerializerOptions JsonOptions()
|
static JsonSerializerOptions JsonOptions()
|
||||||
=> new()
|
=> new()
|
||||||
{
|
{
|
||||||
|
|||||||
@ -925,7 +925,6 @@ public sealed class IllustrationLibraryService(
|
|||||||
{
|
{
|
||||||
var items = await repository.ListAsync(new IllustrationLibraryFilter());
|
var items = await repository.ListAsync(new IllustrationLibraryFilter());
|
||||||
var lookup = items
|
var lookup = items
|
||||||
.Where(item => IllustrationStarterBatchDefinition.StarterCodes.Contains(item.StableCode))
|
|
||||||
.Where(item => item.Status is IllustrationLibraryStatuses.Approved or IllustrationLibraryStatuses.Generated)
|
.Where(item => item.Status is IllustrationLibraryStatuses.Approved or IllustrationLibraryStatuses.Generated)
|
||||||
.OrderBy(item => item.Status == IllustrationLibraryStatuses.Approved ? 0 : 1)
|
.OrderBy(item => item.Status == IllustrationLibraryStatuses.Approved ? 0 : 1)
|
||||||
.ThenByDescending(item => item.UpdatedUtc)
|
.ThenByDescending(item => item.UpdatedUtc)
|
||||||
|
|||||||
@ -253,14 +253,20 @@ public static class StoryIntelligenceIllustrationCompatibility
|
|||||||
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, "front door", "porch", "entrance", "house", "exterior")) return "HouseExterior";
|
if (ContainsAny(text, "hospital", "derelict hospital")) return "Hospital";
|
||||||
|
if (ContainsAny(text, "pub", "public house")) return "Pub";
|
||||||
|
if (ContainsAny(text, "beach", "seafront")) return "Beach";
|
||||||
|
if (ContainsAny(text, "railway station", "train station", "station platform")) return "RailwayStation";
|
||||||
|
if (ContainsAny(text, "canal", "towpath")) return "Canal";
|
||||||
|
if (ContainsAny(text, "garage")) return "Garage";
|
||||||
|
if (ContainsAny(text, "front door", "porch", "entrance", "house exterior", "outside the house", "exterior")) return "HouseExterior";
|
||||||
if (ContainsAny(text, "office", "desk", "study", "library")) return "Office";
|
if (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, "road", "street", "lane", "drive", "close", "avenue", "boulevard", "motorway", "bridge", "roundabout", "footpath", "track")) return "Road";
|
if (ContainsAny(text, "road", "street", "lane", "drive", "close", "avenue", "boulevard", "motorway", "bridge", "roundabout", "footpath", "path", "track")) return "Road";
|
||||||
if (ContainsAny(text, "car park", "parking")) return "CarPark";
|
if (ContainsAny(text, "car park", "parking")) return "CarPark";
|
||||||
if (text.Contains("shop", StringComparison.OrdinalIgnoreCase)) return "Shop";
|
if (text.Contains("shop", StringComparison.OrdinalIgnoreCase)) return "Shop";
|
||||||
if (text.Contains("hospital", StringComparison.OrdinalIgnoreCase)) return "Hospital";
|
if (ContainsAny(text, "bench", "bus stop")) return "StreetFurniture";
|
||||||
if (ContainsAny(text, "flat", "apartment", "room")) return "FlatInterior";
|
if (ContainsAny(text, "flat", "apartment", "room")) return "FlatInterior";
|
||||||
return "UnknownInterior";
|
return "UnknownInterior";
|
||||||
}
|
}
|
||||||
@ -301,6 +307,20 @@ public static class StoryIntelligenceIllustrationCompatibility
|
|||||||
return "UnknownObject";
|
return "UnknownObject";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static string AssetColour(string? value)
|
||||||
|
{
|
||||||
|
var text = (value ?? string.Empty).ToLowerInvariant();
|
||||||
|
if (ContainsAny(text, "blue", "navy")) return "Blue";
|
||||||
|
if (ContainsAny(text, "red", "scarlet", "crimson")) return "Red";
|
||||||
|
if (ContainsAny(text, "green")) return "Green";
|
||||||
|
if (ContainsAny(text, "yellow")) return "Yellow";
|
||||||
|
if (ContainsAny(text, "black")) return "Black";
|
||||||
|
if (ContainsAny(text, "white", "cream")) return "White";
|
||||||
|
if (ContainsAny(text, "grey", "gray", "silver")) return "Grey";
|
||||||
|
if (ContainsAny(text, "brown", "tan")) return "Brown";
|
||||||
|
return "Unknown";
|
||||||
|
}
|
||||||
|
|
||||||
public static bool LocationCompatible(string requestedType, string candidateType)
|
public static bool LocationCompatible(string requestedType, string candidateType)
|
||||||
=> requestedType == candidateType
|
=> requestedType == candidateType
|
||||||
|| (requestedType is "FrontDoor" or "Porch" && candidateType is "HouseExterior")
|
|| (requestedType is "FrontDoor" or "Porch" && candidateType is "HouseExterior")
|
||||||
@ -309,6 +329,8 @@ 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 == "FamilyCar" && candidateType == "Car")
|
||||||
|| (requestedType == "Note" && candidateType is "Letter" or "Document")
|
|| (requestedType == "Note" && candidateType is "Letter" or "Document")
|
||||||
|| (requestedType == "Rucksack" && candidateType == "Rucksack");
|
|| (requestedType == "Rucksack" && candidateType == "Rucksack");
|
||||||
|
|
||||||
@ -340,7 +362,7 @@ 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, " mr ", " mr. ", " mrs ", " mrs. ", " miss ", " miss. ", " ms ", " ms. ", " doctor", " dr ", " dr. ", " professor", " driving examiner", " teacher", " detective", " police officer", " nurse", " receptionist", " solicitor", " landlord", " manager")) 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;
|
||||||
}
|
}
|
||||||
@ -405,8 +427,8 @@ public static class StoryIntelligenceIllustrationCompatibility
|
|||||||
|
|
||||||
private static EvidenceSignal NamePresentation(string text)
|
private static EvidenceSignal NamePresentation(string text)
|
||||||
{
|
{
|
||||||
if (ContainsAny(text, " beth ", " maggie ", " rosie ", " grace ", " rebecca ", " annie ", " mary ", " susan ", " helen ", " elen ")) return new(Presentations.Feminine, 0.82m);
|
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, " adam ", " colin ", " david ", " kevin ", " simon ", " graham ", " gareth ", " john ", " michael ", " peter ", " 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -442,13 +464,13 @@ public static class StoryIntelligenceIllustrationCompatibility
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (StartsWithAny(clean, "mr", "father", "dad", "uncle", "brother", "son", "boy", "man", "male")
|
if (StartsWithAny(clean, "mr", "father", "dad", "uncle", "brother", "son", "boy", "man", "male")
|
||||||
|| clean is "adam" or "colin" or "david" or "kevin" or "simon" or "graham" or "gareth" or "john" or "michael" or "peter" or "george")
|
|| clean is "adam" or "colin" or "david" or "kevin" or "simon" or "graham" or "gareth" or "john" or "michael" or "peter" or "george" or "richard" or "rick")
|
||||||
{
|
{
|
||||||
return new(Presentations.Masculine, 0.98m);
|
return new(Presentations.Masculine, 0.98m);
|
||||||
}
|
}
|
||||||
|
|
||||||
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")
|
|| 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")
|
||||||
{
|
{
|
||||||
return new(Presentations.Feminine, 0.98m);
|
return new(Presentations.Feminine, 0.98m);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,6 +11,7 @@ namespace PlotLine.Services;
|
|||||||
public interface IStoryIntelligenceIllustrationMatchingService
|
public interface IStoryIntelligenceIllustrationMatchingService
|
||||||
{
|
{
|
||||||
Task ApplyCharacterIllustrationsAsync(int projectId, StoryIntelligenceExperiencePrototypeViewModel prototype);
|
Task ApplyCharacterIllustrationsAsync(int projectId, StoryIntelligenceExperiencePrototypeViewModel prototype);
|
||||||
|
Task ApplySupportingIllustrationDemandAsync(int projectId, StoryIntelligenceExperiencePrototypeViewModel prototype);
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class StoryIntelligenceIllustrationMatchingService(
|
public sealed class StoryIntelligenceIllustrationMatchingService(
|
||||||
@ -22,7 +23,7 @@ public sealed class StoryIntelligenceIllustrationMatchingService(
|
|||||||
{
|
{
|
||||||
private const int SuitableScore = 48;
|
private const int SuitableScore = 48;
|
||||||
private const int ReassignmentMargin = 24;
|
private const int ReassignmentMargin = 24;
|
||||||
private const int DemandGenerationThreshold = 3;
|
private const int DemandGenerationThreshold = 1;
|
||||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
{
|
{
|
||||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||||
@ -152,6 +153,225 @@ public sealed class StoryIntelligenceIllustrationMatchingService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task ApplySupportingIllustrationDemandAsync(int projectId, StoryIntelligenceExperiencePrototypeViewModel prototype)
|
||||||
|
{
|
||||||
|
if (projectId <= 0 || prototype.Scenes.Count == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var items = await library.ListAsync(new IllustrationLibraryFilter());
|
||||||
|
var lookup = items
|
||||||
|
.Where(item => item.Status is IllustrationLibraryStatuses.Approved or IllustrationLibraryStatuses.Generated)
|
||||||
|
.GroupBy(item => item.StableCode, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToDictionary(group => group.Key, group => group.ToList(), StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
foreach (var location in prototype.Scenes.Select(scene => scene.Location))
|
||||||
|
{
|
||||||
|
await ApplyLocationDemandAsync(projectId, location, lookup);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var asset in prototype.Scenes.SelectMany(scene => scene.Assets))
|
||||||
|
{
|
||||||
|
await ApplyAssetDemandAsync(projectId, asset, lookup);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ApplyLocationDemandAsync(
|
||||||
|
int projectId,
|
||||||
|
StoryIntelligenceExperienceLocationState location,
|
||||||
|
IReadOnlyDictionary<string, List<IllustrationLibraryItem>> lookup)
|
||||||
|
{
|
||||||
|
var type = StoryIntelligenceIllustrationCompatibility.LocationType($"{location.Name} {location.Label}");
|
||||||
|
if (type is "UnknownInterior")
|
||||||
|
{
|
||||||
|
location.ImageResolution.ReasonChosen ??= "Location type is unresolved; neutral location fallback retained and no finished unrelated artwork assigned.";
|
||||||
|
location.ImageResolution.DemandStatus ??= "Not queued until location type resolves";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!location.ImageResolution.FallbackUsed && !string.IsNullOrWhiteSpace(location.ImageResolution.FinalImageUrl))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var demandKey = $"location|{type}".ToLowerInvariant();
|
||||||
|
var code = $"loc-demand-{StableHash(demandKey)}";
|
||||||
|
location.LibraryCode = code;
|
||||||
|
|
||||||
|
if (TryApplyGeneratedSupportingImage(location.Id, code, location.ImagePath, lookup, out var resolution))
|
||||||
|
{
|
||||||
|
location.ImageResolution = WithDemandDiagnostics(
|
||||||
|
resolution,
|
||||||
|
demandKey,
|
||||||
|
"Satisfied by generated demand illustration",
|
||||||
|
$"Applied generated {type} location illustration from demand key {demandKey}.");
|
||||||
|
location.ImagePath = resolution.FinalImageUrl ?? location.ImagePath;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var demand = await assignments.UpsertDemandAsync(new StoryIntelligenceIllustrationDemandRequest(
|
||||||
|
projectId,
|
||||||
|
demandKey,
|
||||||
|
"Unknown",
|
||||||
|
"Unknown",
|
||||||
|
"Unknown",
|
||||||
|
"Unknown",
|
||||||
|
"Unknown",
|
||||||
|
"Unknown",
|
||||||
|
"Unknown"));
|
||||||
|
var queued = await QueueDemandSpecAsync(LocationDemandSpecification(code, demandKey, type));
|
||||||
|
location.ImageResolution = WithDemandDiagnostics(
|
||||||
|
location.ImageResolution,
|
||||||
|
demandKey,
|
||||||
|
queued
|
||||||
|
? $"Queued missing {type} location illustration ({demand.RequestCount:N0}/{DemandGenerationThreshold:N0})"
|
||||||
|
: $"Demand recorded for missing {type} location illustration ({demand.RequestCount:N0}/{DemandGenerationThreshold:N0})",
|
||||||
|
$"No generated compatible {type} location illustration exists; neutral typed fallback retained while reusable demand is generated.",
|
||||||
|
code,
|
||||||
|
queued ? "DemandQueued" : "DemandRecorded");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ApplyAssetDemandAsync(
|
||||||
|
int projectId,
|
||||||
|
StoryIntelligenceExperienceAssetState asset,
|
||||||
|
IReadOnlyDictionary<string, List<IllustrationLibraryItem>> lookup)
|
||||||
|
{
|
||||||
|
var evidenceText = $"{asset.Name} {asset.Label}";
|
||||||
|
var type = StoryIntelligenceIllustrationCompatibility.AssetType(evidenceText);
|
||||||
|
if (type is "UnknownObject")
|
||||||
|
{
|
||||||
|
asset.ImageResolution.ReasonChosen ??= "Asset type is unresolved; neutral asset fallback retained and no unrelated finished artwork assigned.";
|
||||||
|
asset.ImageResolution.DemandStatus ??= "Not queued until asset type resolves";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!asset.ImageResolution.FallbackUsed && !string.IsNullOrWhiteSpace(asset.ImageResolution.FinalImageUrl))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var colour = StoryIntelligenceIllustrationCompatibility.AssetColour(evidenceText);
|
||||||
|
var demandKey = $"asset|{type}|{colour}".ToLowerInvariant();
|
||||||
|
var code = $"asset-demand-{StableHash(demandKey)}";
|
||||||
|
asset.LibraryCode = code;
|
||||||
|
|
||||||
|
if (TryApplyGeneratedSupportingImage(asset.Id, code, asset.ImagePath, lookup, out var resolution))
|
||||||
|
{
|
||||||
|
asset.ImageResolution = WithDemandDiagnostics(
|
||||||
|
resolution,
|
||||||
|
demandKey,
|
||||||
|
"Satisfied by generated demand illustration",
|
||||||
|
$"Applied generated {type} asset illustration from demand key {demandKey}.");
|
||||||
|
asset.ImagePath = resolution.FinalImageUrl ?? asset.ImagePath;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var demand = await assignments.UpsertDemandAsync(new StoryIntelligenceIllustrationDemandRequest(
|
||||||
|
projectId,
|
||||||
|
demandKey,
|
||||||
|
"Unknown",
|
||||||
|
"Unknown",
|
||||||
|
"Unknown",
|
||||||
|
"Unknown",
|
||||||
|
"Unknown",
|
||||||
|
"Unknown",
|
||||||
|
"Unknown"));
|
||||||
|
var queued = await QueueDemandSpecAsync(AssetDemandSpecification(code, demandKey, type, colour));
|
||||||
|
asset.ImageResolution = WithDemandDiagnostics(
|
||||||
|
asset.ImageResolution,
|
||||||
|
demandKey,
|
||||||
|
queued
|
||||||
|
? $"Queued missing {colour.ToLowerInvariant()} {type} asset illustration ({demand.RequestCount:N0}/{DemandGenerationThreshold:N0})"
|
||||||
|
: $"Demand recorded for missing {colour.ToLowerInvariant()} {type} asset illustration ({demand.RequestCount:N0}/{DemandGenerationThreshold:N0})",
|
||||||
|
$"No generated compatible {type} asset illustration exists; neutral typed fallback retained while reusable demand is generated.",
|
||||||
|
code,
|
||||||
|
queued ? "DemandQueued" : "DemandRecorded");
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool TryApplyGeneratedSupportingImage(
|
||||||
|
string entityId,
|
||||||
|
string code,
|
||||||
|
string fallbackImageUrl,
|
||||||
|
IReadOnlyDictionary<string, List<IllustrationLibraryItem>> lookup,
|
||||||
|
out StoryIntelligenceExperienceImageResolution resolution)
|
||||||
|
{
|
||||||
|
if (lookup.TryGetValue(code, out var candidates))
|
||||||
|
{
|
||||||
|
foreach (var candidate in candidates)
|
||||||
|
{
|
||||||
|
var publicUrl = ResolveExistingPublicUploadUrl(candidate.ThumbnailPath) ?? ResolveExistingPublicUploadUrl(candidate.FilePath);
|
||||||
|
if (publicUrl is null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
resolution = new StoryIntelligenceExperienceImageResolution
|
||||||
|
{
|
||||||
|
EntityId = entityId,
|
||||||
|
StableCode = code,
|
||||||
|
IllustrationLibraryItemId = candidate.IllustrationLibraryItemID,
|
||||||
|
Status = candidate.Status,
|
||||||
|
FinalImageUrl = publicUrl,
|
||||||
|
FallbackImageUrl = fallbackImageUrl,
|
||||||
|
FallbackUsed = false
|
||||||
|
};
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resolution = new StoryIntelligenceExperienceImageResolution();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static StoryIntelligenceExperienceImageResolution WithDemandDiagnostics(
|
||||||
|
StoryIntelligenceExperienceImageResolution source,
|
||||||
|
string demandKey,
|
||||||
|
string demandStatus,
|
||||||
|
string reasonChosen,
|
||||||
|
string? stableCode = null,
|
||||||
|
string? status = null)
|
||||||
|
=> new()
|
||||||
|
{
|
||||||
|
EntityId = source.EntityId,
|
||||||
|
StableCode = stableCode ?? source.StableCode,
|
||||||
|
IllustrationLibraryItemId = source.IllustrationLibraryItemId,
|
||||||
|
Status = status ?? source.Status,
|
||||||
|
FinalImageUrl = source.FinalImageUrl,
|
||||||
|
FallbackImageUrl = source.FallbackImageUrl,
|
||||||
|
FallbackUsed = source.FallbackUsed,
|
||||||
|
AssignmentConfidence = source.AssignmentConfidence,
|
||||||
|
MatchingScore = source.MatchingScore,
|
||||||
|
RejectedCandidates = source.RejectedCandidates,
|
||||||
|
ReasonChosen = reasonChosen,
|
||||||
|
ReasonReassigned = source.ReasonReassigned,
|
||||||
|
PreviousIllustration = source.PreviousIllustration,
|
||||||
|
PreviousEvidence = source.PreviousEvidence,
|
||||||
|
CurrentEvidence = source.CurrentEvidence,
|
||||||
|
DemandKey = demandKey,
|
||||||
|
EvidenceWarnings = source.EvidenceWarnings,
|
||||||
|
DemandStatus = demandStatus,
|
||||||
|
ResolvedPresentation = source.ResolvedPresentation,
|
||||||
|
ResolvedAgeBand = source.ResolvedAgeBand,
|
||||||
|
RelationshipDerivedConfidence = source.RelationshipDerivedConfidence,
|
||||||
|
NameDerivedConfidence = source.NameDerivedConfidence,
|
||||||
|
PronounDerivedConfidence = source.PronounDerivedConfidence,
|
||||||
|
IllustrationReuseCount = source.IllustrationReuseCount,
|
||||||
|
IllustrationAllocationStatus = source.IllustrationAllocationStatus
|
||||||
|
};
|
||||||
|
|
||||||
|
private async Task<bool> QueueDemandSpecAsync(IllustrationGenerationSpecification specification)
|
||||||
|
{
|
||||||
|
var prompt = promptBuilder.Build(specification);
|
||||||
|
var item = await library.UpsertPlannedAsync(specification, prompt);
|
||||||
|
if (item.Status is IllustrationLibraryStatuses.Queued or IllustrationLibraryStatuses.Generating or IllustrationLibraryStatuses.Generated or IllustrationLibraryStatuses.Approved)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await library.QueueAsync(item.IllustrationLibraryItemID, null) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
private MatchScore? ChooseAssignment(
|
private MatchScore? ChooseAssignment(
|
||||||
CharacterObservation observation,
|
CharacterObservation observation,
|
||||||
StoryIntelligenceCharacterIllustrationAssignment? existing,
|
StoryIntelligenceCharacterIllustrationAssignment? existing,
|
||||||
@ -171,7 +391,7 @@ public sealed class StoryIntelligenceIllustrationMatchingService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
var materiallyWrong = MateriallyWrong(existingScore.Candidate.Metadata, observation.Evidence);
|
var materiallyWrong = MateriallyWrong(existingScore.Candidate.Metadata, observation.Evidence);
|
||||||
if (materiallyWrong && observation.Evidence.HasStrongPresentationEvidence)
|
if (materiallyWrong)
|
||||||
{
|
{
|
||||||
return best;
|
return best;
|
||||||
}
|
}
|
||||||
@ -291,7 +511,7 @@ public sealed class StoryIntelligenceIllustrationMatchingService(
|
|||||||
var presentation = NormaliseUnknown(demand.Presentation, "Androgynous");
|
var presentation = NormaliseUnknown(demand.Presentation, "Androgynous");
|
||||||
var hairColour = NormaliseUnknown(demand.HairColour, "Brown");
|
var hairColour = NormaliseUnknown(demand.HairColour, "Brown");
|
||||||
var hairLength = NormaliseUnknown(demand.HairLength, "Medium");
|
var hairLength = NormaliseUnknown(demand.HairLength, "Medium");
|
||||||
var skinTone = NormaliseUnknown(demand.SkinTone, "Medium");
|
var skinTone = NormaliseUnknown(demand.SkinTone, BalancedSkinToneFallback(demand.DemandKey));
|
||||||
var features = new List<string> { "DemandGenerated" };
|
var features = new List<string> { "DemandGenerated" };
|
||||||
if (string.Equals(demand.Glasses, "Yes", StringComparison.OrdinalIgnoreCase))
|
if (string.Equals(demand.Glasses, "Yes", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
@ -335,6 +555,77 @@ public sealed class StoryIntelligenceIllustrationMatchingService(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static IllustrationGenerationSpecification LocationDemandSpecification(string code, string demandKey, string type)
|
||||||
|
=> new()
|
||||||
|
{
|
||||||
|
Category = IllustrationLibraryCategories.Location,
|
||||||
|
Code = code,
|
||||||
|
Title = $"{ReadableToken(type)} location",
|
||||||
|
Description = "Demand-created reusable location illustration for Story Intelligence matching.",
|
||||||
|
VisualSubject = $"A clear reusable {ReadableToken(type).ToLowerInvariant()} environment suitable for manuscript visualisation.",
|
||||||
|
Composition = "single readable environment, no prominent people, strong central focal point, suitable for circular and wide crops",
|
||||||
|
Mood = "story-ready, atmospheric but clear, visually specific rather than generic",
|
||||||
|
LocationType = type,
|
||||||
|
Era = "Contemporary or late twentieth century",
|
||||||
|
Setting = type is "Road" ? "Exterior" : type is "CarPark" or "HouseExterior" ? "Exterior" : "Interior",
|
||||||
|
Palette = ["clear blue", "warm practical light", "balanced neutral accents"],
|
||||||
|
MetadataTags = ["location", "demand-generated", "story-intelligence"],
|
||||||
|
Metadata = new(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
["libraryPurpose"] = "Story Intelligence demand-generated location",
|
||||||
|
["privacy"] = "generic-no-manuscript-data",
|
||||||
|
["style"] = "editorial-stylised-realism",
|
||||||
|
["generationSource"] = "Demand generation",
|
||||||
|
["demandKey"] = demandKey,
|
||||||
|
["locationType"] = type
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private static IllustrationGenerationSpecification AssetDemandSpecification(string code, string demandKey, string type, string colour)
|
||||||
|
=> new()
|
||||||
|
{
|
||||||
|
Category = IllustrationLibraryCategories.Asset,
|
||||||
|
Code = code,
|
||||||
|
Title = $"{ReadableToken(colour)} {ReadableToken(type)}",
|
||||||
|
Description = "Demand-created reusable asset illustration for Story Intelligence matching.",
|
||||||
|
VisualSubject = colour == "Unknown"
|
||||||
|
? $"A clear reusable {ReadableToken(type).ToLowerInvariant()} object."
|
||||||
|
: $"A clear reusable {colour.ToLowerInvariant()} {ReadableToken(type).ToLowerInvariant()} object.",
|
||||||
|
Composition = "single dominant centred object, crisp silhouette, full object visible where practical, no text or logos",
|
||||||
|
Mood = "clean, readable, evidence-like, story-ready",
|
||||||
|
ObjectType = type,
|
||||||
|
Colour = colour,
|
||||||
|
Era = "Contemporary or late twentieth century",
|
||||||
|
Palette = colour == "Unknown"
|
||||||
|
? ["clear blue", "warm light", "balanced neutral"]
|
||||||
|
: [colour.ToLowerInvariant(), "clear blue", "warm light"],
|
||||||
|
MetadataTags = ["asset", "demand-generated", "story-intelligence"],
|
||||||
|
Metadata = new(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
["libraryPurpose"] = "Story Intelligence demand-generated asset",
|
||||||
|
["privacy"] = "generic-no-manuscript-data",
|
||||||
|
["style"] = "editorial-stylised-realism",
|
||||||
|
["generationSource"] = "Demand generation",
|
||||||
|
["demandKey"] = demandKey,
|
||||||
|
["objectType"] = type,
|
||||||
|
["colour"] = colour
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private static string ReadableToken(string value)
|
||||||
|
=> Regex.Replace(value, "([a-z])([A-Z])", "$1 $2");
|
||||||
|
|
||||||
|
private static string BalancedSkinToneFallback(string demandKey)
|
||||||
|
{
|
||||||
|
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(demandKey));
|
||||||
|
return (hash[0] % 3) switch
|
||||||
|
{
|
||||||
|
0 => "Light",
|
||||||
|
1 => "Medium",
|
||||||
|
_ => "Dark"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
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);
|
||||||
|
|||||||
@ -147,6 +147,7 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
|
|||||||
if (run.ProjectID.HasValue)
|
if (run.ProjectID.HasValue)
|
||||||
{
|
{
|
||||||
await illustrationMatcher.ApplyCharacterIllustrationsAsync(run.ProjectID.Value, model);
|
await illustrationMatcher.ApplyCharacterIllustrationsAsync(run.ProjectID.Value, model);
|
||||||
|
await illustrationMatcher.ApplySupportingIllustrationDemandAsync(run.ProjectID.Value, model);
|
||||||
ApplyIllustrationDiagnostics(model);
|
ApplyIllustrationDiagnostics(model);
|
||||||
}
|
}
|
||||||
StripLiveDiagnostics(model);
|
StripLiveDiagnostics(model);
|
||||||
@ -283,6 +284,7 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
|
|||||||
StabiliseCharacterIllustrations(model);
|
StabiliseCharacterIllustrations(model);
|
||||||
await illustrationLibrary.ApplyApprovedIllustrationsAsync(model);
|
await illustrationLibrary.ApplyApprovedIllustrationsAsync(model);
|
||||||
await illustrationMatcher.ApplyCharacterIllustrationsAsync(session.ProjectID, model);
|
await illustrationMatcher.ApplyCharacterIllustrationsAsync(session.ProjectID, model);
|
||||||
|
await illustrationMatcher.ApplySupportingIllustrationDemandAsync(session.ProjectID, model);
|
||||||
ApplyIllustrationDiagnostics(model);
|
ApplyIllustrationDiagnostics(model);
|
||||||
StripLiveDiagnostics(model);
|
StripLiveDiagnostics(model);
|
||||||
LogSnapshotSynchronisation(importSessionId, currentSnapshot, current, scenes, model.ChangeToken);
|
LogSnapshotSynchronisation(importSessionId, currentSnapshot, current, scenes, model.ChangeToken);
|
||||||
@ -433,7 +435,7 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
|
|||||||
Id = CharacterId(group.Key),
|
Id = CharacterId(group.Key),
|
||||||
LibraryCode = libraryCode,
|
LibraryCode = libraryCode,
|
||||||
Name = group.Key,
|
Name = group.Key,
|
||||||
Role = isPov ? "POV character" : FirstConfigured(character.RoleInScene, character.MentionedOnly == true ? "Referenced" : "Scene character"),
|
Role = isPov ? "POV character" : NonPovRole(character),
|
||||||
Relevance = Trim(FirstConfigured(character.Notes, string.Join(", ", character.Actions ?? []), character.MentionedOnly == true ? "Mentioned only" : "Present"), 60),
|
Relevance = Trim(FirstConfigured(character.Notes, string.Join(", ", character.Actions ?? []), character.MentionedOnly == true ? "Mentioned only" : "Present"), 60),
|
||||||
Weight = isPov ? 100 : CharacterWeight(character),
|
Weight = isPov ? 100 : CharacterWeight(character),
|
||||||
ImagePath = CharacterFallback(group.Key)
|
ImagePath = CharacterFallback(group.Key)
|
||||||
@ -484,6 +486,16 @@ public sealed class StoryIntelligenceVisualisationSnapshotService(
|
|||||||
.ToList();
|
.ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string NonPovRole(SceneIntelligenceCharacter character)
|
||||||
|
{
|
||||||
|
var role = FirstConfigured(character.RoleInScene, character.MentionedOnly == true ? "Referenced" : "Scene character");
|
||||||
|
return role.Contains("pov", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| role.Contains("point of view", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| role.Contains("narrator", StringComparison.OrdinalIgnoreCase)
|
||||||
|
? character.MentionedOnly == true ? "Referenced" : "Scene character"
|
||||||
|
: role;
|
||||||
|
}
|
||||||
|
|
||||||
private static string? ResolveNarratorName(SceneIntelligenceScene scene)
|
private static string? ResolveNarratorName(SceneIntelligenceScene scene)
|
||||||
{
|
{
|
||||||
var povName = Clean(scene.PointOfView?.CharacterName);
|
var povName = Clean(scene.PointOfView?.CharacterName);
|
||||||
|
|||||||
@ -150,8 +150,7 @@
|
|||||||
<section class="story-exp-stage" aria-labelledby="living-story-title">
|
<section class="story-exp-stage" aria-labelledby="living-story-title">
|
||||||
<div class="story-exp-stage-copy">
|
<div class="story-exp-stage-copy">
|
||||||
<p class="story-exp-eyebrow">Living story view</p>
|
<p class="story-exp-eyebrow">Living story view</p>
|
||||||
<h1 id="living-story-title" data-scene-title>Reading the manuscript</h1>
|
<p id="living-story-title" class="story-exp-scene-summary" data-scene-summary>Reading the manuscript</p>
|
||||||
<p data-scene-summary></p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="story-exp-visual" data-visual-stage>
|
<div class="story-exp-visual" data-visual-stage>
|
||||||
@ -177,10 +176,6 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<aside class="story-exp-right" aria-label="Story understanding">
|
<aside class="story-exp-right" aria-label="Story understanding">
|
||||||
<section class="story-exp-panel" aria-labelledby="relationships-title">
|
|
||||||
<h2 id="relationships-title">Relationship changes</h2>
|
|
||||||
<div class="story-exp-insight-list" data-relationships></div>
|
|
||||||
</section>
|
|
||||||
<section class="story-exp-panel" aria-labelledby="knowledge-title">
|
<section class="story-exp-panel" aria-labelledby="knowledge-title">
|
||||||
<h2 id="knowledge-title">Knowledge threads</h2>
|
<h2 id="knowledge-title">Knowledge threads</h2>
|
||||||
<div class="story-exp-insight-list" data-knowledge></div>
|
<div class="story-exp-insight-list" data-knowledge></div>
|
||||||
@ -189,6 +184,10 @@
|
|||||||
<h2 id="observations-title">Story observations</h2>
|
<h2 id="observations-title">Story observations</h2>
|
||||||
<div class="story-exp-insight-list" data-observations></div>
|
<div class="story-exp-insight-list" data-observations></div>
|
||||||
</section>
|
</section>
|
||||||
|
<section class="story-exp-panel story-exp-panel--compact" aria-labelledby="relationships-title">
|
||||||
|
<h2 id="relationships-title">Relationship changes</h2>
|
||||||
|
<div class="story-exp-insight-list" data-relationships></div>
|
||||||
|
</section>
|
||||||
</aside>
|
</aside>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@ -299,7 +299,17 @@ input:focus-visible {
|
|||||||
display: grid;
|
display: grid;
|
||||||
align-content: start;
|
align-content: start;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
|
max-height: calc(100vh - 126px);
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.story-exp-left {
|
||||||
|
grid-template-rows: auto auto minmax(150px, 1fr) minmax(120px, 0.72fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.story-exp-right {
|
||||||
|
grid-template-rows: minmax(190px, 1.2fr) minmax(150px, 0.95fr) auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.story-exp-left,
|
.story-exp-left,
|
||||||
@ -324,6 +334,17 @@ input:focus-visible {
|
|||||||
padding: 16px;
|
padding: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.story-exp-panel {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto minmax(0, 1fr);
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.story-exp-panel--compact {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.story-exp-progress-ring {
|
.story-exp-progress-ring {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: grid;
|
display: grid;
|
||||||
@ -445,6 +466,9 @@ input:focus-visible {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
list-style: none;
|
list-style: none;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
scrollbar-width: thin;
|
||||||
}
|
}
|
||||||
|
|
||||||
.story-exp-list li,
|
.story-exp-list li,
|
||||||
@ -507,9 +531,8 @@ input:focus-visible {
|
|||||||
|
|
||||||
.story-exp-stage-copy {
|
.story-exp-stage-copy {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(260px, 0.64fr) 1fr;
|
grid-template-columns: minmax(0, 1fr);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 18px;
|
|
||||||
min-height: 42px;
|
min-height: 42px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -524,12 +547,18 @@ input:focus-visible {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.story-exp-scene-summary,
|
||||||
.story-exp-stage-copy p:last-child {
|
.story-exp-stage-copy p:last-child {
|
||||||
grid-column: 2;
|
grid-column: 1;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--story-soft);
|
color: var(--story-soft);
|
||||||
font-size: clamp(0.78rem, 0.86vw, 0.92rem);
|
font-size: clamp(0.78rem, 0.86vw, 0.92rem);
|
||||||
line-height: 1.28;
|
line-height: 1.28;
|
||||||
|
max-width: 100%;
|
||||||
|
display: -webkit-box;
|
||||||
|
overflow: hidden;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.story-exp-visual {
|
.story-exp-visual {
|
||||||
@ -672,6 +701,7 @@ input:focus-visible {
|
|||||||
|
|
||||||
.story-exp-characters {
|
.story-exp-characters {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
z-index: 90;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
@ -683,7 +713,7 @@ input:focus-visible {
|
|||||||
height: var(--node-size, 150px);
|
height: var(--node-size, 150px);
|
||||||
transform: translate(var(--x, 0), var(--y, 0)) scale(var(--scale, 1));
|
transform: translate(var(--x, 0), var(--y, 0)) scale(var(--scale, 1));
|
||||||
opacity: var(--opacity, 1);
|
opacity: var(--opacity, 1);
|
||||||
z-index: calc(var(--z, 3) + 30);
|
z-index: calc(var(--z, 3) + 90);
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
transition: transform 1050ms cubic-bezier(.16, .84, .28, 1), opacity 780ms ease;
|
transition: transform 1050ms cubic-bezier(.16, .84, .28, 1), opacity 780ms ease;
|
||||||
}
|
}
|
||||||
@ -729,7 +759,7 @@ input:focus-visible {
|
|||||||
|
|
||||||
.story-character__text {
|
.story-character__text {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: 80;
|
z-index: 180;
|
||||||
top: calc(var(--node-size, 150px) + 9px);
|
top: calc(var(--node-size, 150px) + 9px);
|
||||||
left: 50%;
|
left: 50%;
|
||||||
display: grid;
|
display: grid;
|
||||||
@ -741,8 +771,8 @@ input:focus-visible {
|
|||||||
border: 1px solid rgba(151, 185, 218, 0.1);
|
border: 1px solid rgba(151, 185, 218, 0.1);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
padding: 5px 7px 6px;
|
padding: 5px 7px 6px;
|
||||||
background: rgba(4, 10, 20, 0.58);
|
background: rgba(4, 10, 20, 0.82);
|
||||||
box-shadow: 0 12px 26px rgba(0, 0, 0, 0.24);
|
box-shadow: 0 12px 26px rgba(0, 0, 0, 0.28), 0 0 0 1px rgba(4, 10, 20, 0.22);
|
||||||
backdrop-filter: blur(8px);
|
backdrop-filter: blur(8px);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
@ -1088,6 +1118,9 @@ input:focus-visible {
|
|||||||
.story-exp-insight-list {
|
.story-exp-insight-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 9px;
|
gap: 9px;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
scrollbar-width: thin;
|
||||||
}
|
}
|
||||||
|
|
||||||
.story-exp-insight {
|
.story-exp-insight {
|
||||||
@ -1100,6 +1133,31 @@ input:focus-visible {
|
|||||||
transition: opacity 560ms ease, transform 700ms cubic-bezier(.16, .84, .28, 1);
|
transition: opacity 560ms ease, transform 700ms cubic-bezier(.16, .84, .28, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.story-exp-panel--compact .story-exp-insight {
|
||||||
|
padding: 8px 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.story-exp-panel--compact .story-exp-insight p {
|
||||||
|
display: -webkit-box;
|
||||||
|
overflow: hidden;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.76rem;
|
||||||
|
line-height: 1.25;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.story-exp-more {
|
||||||
|
border: 1px solid rgba(151, 185, 218, 0.12);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 7px 9px;
|
||||||
|
background: rgba(255, 255, 255, 0.035);
|
||||||
|
color: var(--story-soft);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 800;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
.story-exp-list li,
|
.story-exp-list li,
|
||||||
.story-exp-feed li {
|
.story-exp-feed li {
|
||||||
transition: opacity 520ms ease, transform 640ms cubic-bezier(.16, .84, .28, 1);
|
transition: opacity 520ms ease, transform 640ms cubic-bezier(.16, .84, .28, 1);
|
||||||
|
|||||||
@ -161,7 +161,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function updateSceneCopy(scene) {
|
function updateSceneCopy(scene) {
|
||||||
text(dom.sceneTitle, `Scene ${scene.sceneNumber} • ${scene.title}`);
|
|
||||||
text(dom.sceneSummary, scene.summary);
|
text(dom.sceneSummary, scene.summary);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -346,12 +345,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function reconcileRelationships(scene) {
|
function reconcileRelationships(scene) {
|
||||||
renderInsights(dom.relationships, scene.relationships.map((relationship) => ({
|
const relationships = scene.relationships.map((relationship) => ({
|
||||||
id: relationship.id,
|
id: relationship.id,
|
||||||
title: relationship.label,
|
title: relationship.label,
|
||||||
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));
|
||||||
|
renderOverflowSummary(dom.relationships, relationships.length - 2, "relationship");
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateKnowledgeThreads(scene) {
|
function updateKnowledgeThreads(scene) {
|
||||||
@ -458,6 +459,26 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderOverflowSummary(container, hiddenCount, noun) {
|
||||||
|
if (!container) return;
|
||||||
|
let node = container.querySelector("[data-insight-overflow]");
|
||||||
|
if (hiddenCount <= 0) {
|
||||||
|
node?.remove();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!node) {
|
||||||
|
node = document.createElement("button");
|
||||||
|
node.type = "button";
|
||||||
|
node.className = "story-exp-more";
|
||||||
|
node.dataset.insightOverflow = "true";
|
||||||
|
container.append(node);
|
||||||
|
}
|
||||||
|
|
||||||
|
node.textContent = `+${hiddenCount} more ${noun}${hiddenCount === 1 ? "" : "s"}`;
|
||||||
|
node.title = `${hiddenCount} additional ${noun}${hiddenCount === 1 ? "" : "s"} in this scene`;
|
||||||
|
}
|
||||||
|
|
||||||
function renderList(container, items) {
|
function renderList(container, items) {
|
||||||
const activeIds = new Set(items.map((item, index) => feedId(item, index)));
|
const activeIds = new Set(items.map((item, index) => feedId(item, index)));
|
||||||
items.forEach((item, index) => {
|
items.forEach((item, index) => {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user