Compare commits

...

10 Commits

31 changed files with 4597 additions and 387 deletions

View File

@ -1,6 +1,9 @@
using System.Text.Json; using System.Text.Json;
using System.Net; using System.Net;
using System.Text; using System.Text;
using Microsoft.AspNetCore.Http.Metadata;
using Microsoft.AspNetCore.Mvc;
using PlotLine.Controllers;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using PlotLine.Models; using PlotLine.Models;
@ -40,7 +43,26 @@ var tests = new (string Name, Action Test)[]
("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),
("Illustration provider reports missing image model", IllustrationProviderReportsMissingImageModel), ("Illustration provider reports missing image model", IllustrationProviderReportsMissingImageModel),
("Illustration provider omits GPT image response format", IllustrationProviderOmitsGptImageResponseFormat) ("Illustration provider omits GPT image response format", IllustrationProviderOmitsGptImageResponseFormat),
("Illustration matching rejects adult masculine unknown art for Beth", IllustrationMatchingRejectsAdultMasculineUnknownArtForBeth),
("Illustration matching treats Mrs title as hard feminine evidence", IllustrationMatchingTreatsMrsTitleAsHardFeminineEvidence),
("Illustration matching infers mother and aunt as mature feminine", IllustrationMatchingInfersMotherAndAuntAsMatureFeminine),
("Illustration matching rejects duplicate named portrait allocation", IllustrationMatchingRejectsDuplicateNamedPortraitAllocation),
("Illustration semantic types prevent bathroom office and ambulance car mismatches", IllustrationSemanticTypesPreventObviousMismatches),
("Illustration demand archetypes stay broad and use active template", IllustrationDemandArchetypesStayBroadAndUseActiveTemplate),
("Story Intelligence evidence extraction prevents observed attribute leakage", StoryIntelligenceEvidenceExtractionPreventsObservedAttributeLeakage),
("Illustration assignment repair enforces explicit evidence groups and strict subtypes", IllustrationAssignmentRepairEnforcesExplicitEvidenceGroupsAndStrictSubtypes),
("Phase 21O preserves adult defaults and real-mode continuity", Phase21OPreservesAdultDefaultsAndRealModeContinuity),
("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),
("Phase 21R replay uses persisted data only", Phase21RReplayUsesPersistedDataOnly),
("Phase 21R scene browser and panels are replay friendly", Phase21RSceneBrowserAndPanelsAreReplayFriendly),
("Phase 21S uses durable Story Memory assignments", Phase21SUsesDurableStoryMemoryAssignments),
("Scan review post supports full-book form submissions", ScanReviewPostSupportsFullBookFormSubmissions),
("Story Intelligence experience boot does not serialise live model", StoryIntelligenceExperienceBootDoesNotSerialiseLiveModel),
("Live visualisation strips illustration diagnostics", LiveVisualisationStripsIllustrationDiagnostics),
("Illustration evidence trace does not recursively store previous evidence", IllustrationEvidenceTraceDoesNotRecursivelyStorePreviousEvidence)
}; };
foreach (var test in tests) foreach (var test in tests)
@ -340,13 +362,13 @@ static void IllustrationPromptBuilderSeparatesSpecAndPrompt()
var result = builder.Build(spec); var result = builder.Build(spec);
Assert(result.TemplateVersion == IllustrationPromptBuilder.CurrentTemplateVersion, "Template version was not recorded."); Assert(result.TemplateVersion == IllustrationPromptBuilder.CurrentTemplateVersion, "Template version was not recorded.");
Assert(result.TemplateVersion == "21D.2", "Illustration prompt template version should reflect the vivid art-direction reset."); Assert(result.TemplateVersion == "21J.3", "Illustration prompt template version should reflect the current approved art direction.");
Assert(result.Prompt.Contains("no visible text", StringComparison.OrdinalIgnoreCase), "Prompt did not include visual safety guidance."); Assert(result.Prompt.Contains("no visible text", StringComparison.OrdinalIgnoreCase), "Prompt did not include visual safety guidance.");
Assert(result.Prompt.Contains("Category composition rules", StringComparison.Ordinal), "Prompt did not include category composition rules."); Assert(result.Prompt.Contains("Category composition rules", StringComparison.Ordinal), "Prompt did not include category composition rules.");
Assert(result.Prompt.Contains("Structured details", StringComparison.Ordinal), "Prompt did not include structured details."); Assert(result.Prompt.Contains("Structured details", StringComparison.Ordinal), "Prompt did not include structured details.");
Assert(result.Prompt.Contains("modern cinematic digital illustration", StringComparison.OrdinalIgnoreCase), "Prompt did not include the new cinematic digital art direction."); Assert(result.Prompt.Contains("modern cinematic digital illustration", StringComparison.OrdinalIgnoreCase), "Prompt did not include the new cinematic digital art direction.");
Assert(result.Prompt.Contains("premium narrative game concept art", StringComparison.OrdinalIgnoreCase), "Prompt did not include premium game concept-art guidance."); Assert(result.Prompt.Contains("premium narrative game concept art", StringComparison.OrdinalIgnoreCase), "Prompt did not include premium game concept-art guidance.");
Assert(result.Prompt.Contains("bright balanced lighting", StringComparison.OrdinalIgnoreCase), "Prompt did not include brighter lighting guidance."); Assert(result.Prompt.Contains("balanced cinematic lighting", StringComparison.OrdinalIgnoreCase), "Prompt did not include current lighting guidance.");
Assert(result.Prompt.Contains("avoid muddy olive and brown grading", StringComparison.OrdinalIgnoreCase), "Prompt did not explicitly avoid the previous muddy grading failure mode."); Assert(result.Prompt.Contains("avoid muddy olive and brown grading", StringComparison.OrdinalIgnoreCase), "Prompt did not explicitly avoid the previous muddy grading failure mode.");
Assert(result.Prompt.Contains(spec.Code, StringComparison.Ordinal), "Prompt did not include stable code."); Assert(result.Prompt.Contains(spec.Code, StringComparison.Ordinal), "Prompt did not include stable code.");
Assert(result.SpecificationJson.Contains("\"code\": \"asset-folded-letter\"", StringComparison.Ordinal), "Specification JSON did not preserve the stable code."); Assert(result.SpecificationJson.Contains("\"code\": \"asset-folded-letter\"", StringComparison.Ordinal), "Specification JSON did not preserve the stable code.");
@ -453,6 +475,207 @@ static void StoryIntelligencePrototypeUsesStarterIllustrationCodes()
Assert(prototype.Scenes.SelectMany(scene => scene.Assets).All(asset => asset.ImagePath.EndsWith(".svg", StringComparison.OrdinalIgnoreCase)), "Asset SVG fallback should remain before library resolution."); Assert(prototype.Scenes.SelectMany(scene => scene.Assets).All(asset => asset.ImagePath.EndsWith(".svg", StringComparison.OrdinalIgnoreCase)), "Asset SVG fallback should remain before library resolution.");
} }
static void IllustrationMatchingRejectsAdultMasculineUnknownArtForBeth()
{
var beth = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Beth", ["Beth", "age 15", "fifteen-year-old girl"], []);
var middleAged = Metadata("char-family-relative", "MatureAdult", "Feminine");
var masculine = Metadata("char-middle-aged-weathered-man", "MatureAdult", "Masculine");
var unknown = Metadata("char-unknown-figure", "YoungAdult", "Androgynous", isUnknownFigure: true);
Assert(beth.AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.YoungTeen, $"Beth age should be YoungTeen, got {beth.AgeBand}.");
Assert(Rejected(beth, middleAged).Any(reason => reason.Contains("age hard mismatch", StringComparison.OrdinalIgnoreCase)), "Beth should reject mature adult art.");
Assert(Rejected(beth, masculine).Any(reason => reason.Contains("presentation hard mismatch", StringComparison.OrdinalIgnoreCase)), "Beth should reject masculine art.");
Assert(Rejected(beth, unknown).Any(reason => reason.Contains("Unknown Figure", StringComparison.OrdinalIgnoreCase)), "Beth should reject Unknown Figure art.");
}
static void IllustrationMatchingTreatsMrsTitleAsHardFeminineEvidence()
{
var mrsPatterson = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Mrs Patterson", ["Mrs Patterson", "neighbour"], []);
var masculine = Metadata("char-archival-contact", "Senior", "Masculine");
var androgynous = Metadata("char-neighbourhood-friend", "YoungAdult", "Androgynous");
var feminine = Metadata("char-family-relative", "MatureAdult", "Feminine");
Assert(mrsPatterson.Presentation == StoryIntelligenceIllustrationCompatibility.Presentations.Feminine, $"Mrs title should infer Feminine, got {mrsPatterson.Presentation}.");
Assert(Rejected(mrsPatterson, masculine).Any(reason => reason.Contains("presentation hard mismatch", StringComparison.OrdinalIgnoreCase)), "Mrs Patterson should reject masculine art.");
Assert(Rejected(mrsPatterson, androgynous).Count == 0, "Androgynous should not be hard-rejected solely by title.");
Assert(Rejected(mrsPatterson, feminine).Count == 0, "Suitable feminine candidate should remain valid.");
}
static void IllustrationMatchingInfersMotherAndAuntAsMatureFeminine()
{
var mother = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Beth's mother", ["her mother", "mother of Beth", "Beth is 15"], []);
var aunt = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Aunt Elen", ["Beth's aunt", "Aunt Elen"], []);
var teen = Metadata("char-young-helper", "YoungTeen", "Androgynous");
var masculine = Metadata("char-middle-aged-weathered-man", "MatureAdult", "Masculine");
Assert(mother.AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.MatureAdult, $"Mother should infer MatureAdult, got {mother.AgeBand}.");
Assert(mother.Presentation == StoryIntelligenceIllustrationCompatibility.Presentations.Feminine, "Mother should infer Feminine.");
Assert(aunt.AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.MatureAdult, $"Aunt should infer MatureAdult, got {aunt.AgeBand}.");
Assert(aunt.Presentation == StoryIntelligenceIllustrationCompatibility.Presentations.Feminine, "Aunt should infer Feminine.");
Assert(Rejected(mother, masculine).Any(reason => reason.Contains("presentation hard mismatch", StringComparison.OrdinalIgnoreCase)), "Mother should reject masculine art.");
Assert(Rejected(aunt, teen).Any(reason => reason.Contains("age hard mismatch", StringComparison.OrdinalIgnoreCase)), "Aunt should reject teen art.");
}
static void IllustrationMatchingRejectsDuplicateNamedPortraitAllocation()
{
var rosie = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Rosie", ["Rosie", "young woman"], []);
var candidate = Metadata("char-young-adult-red-haired-witness", "YoungAdult", "Feminine");
var rejected = StoryIntelligenceIllustrationCompatibility.CharacterHardRejections(rosie, candidate, isNamedCharacter: true, alreadyAssignedToAnotherSignificantCharacter: true);
Assert(rejected.Any(reason => reason.Contains("already assigned", StringComparison.OrdinalIgnoreCase)), "Distinct significant named characters should not silently share portraits.");
}
static void IllustrationSemanticTypesPreventObviousMismatches()
{
Assert(StoryIntelligenceIllustrationCompatibility.LocationType("bathroom at Beth's house") == "Bathroom", "Bathroom should classify as Bathroom.");
Assert(!StoryIntelligenceIllustrationCompatibility.LocationCompatible("Bathroom", "Office"), "Bathroom must reject office imagery.");
Assert(!StoryIntelligenceIllustrationCompatibility.LocationCompatible("Kitchen", "HouseExterior"), "Kitchen must reject house exterior imagery.");
Assert(StoryIntelligenceIllustrationCompatibility.LocationCompatible("Kitchen", "FlatInterior"), "Kitchen may use generic interior fallback.");
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("ambulance") == "Ambulance", "Ambulance should classify as Ambulance.");
Assert(!StoryIntelligenceIllustrationCompatibility.AssetCompatible("Ambulance", "Car"), "Ambulance must reject sports car imagery.");
Assert(!StoryIntelligenceIllustrationCompatibility.AssetCompatible("BodyBag", "Suitcase"), "Body bag must reject suitcase/box imagery.");
Assert(!StoryIntelligenceIllustrationCompatibility.AssetCompatible("Note", "Suitcase"), "Note must reject suitcase imagery.");
}
static void StoryIntelligenceEvidenceExtractionPreventsObservedAttributeLeakage()
{
var beth = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Beth", ["Beth is fifteen.", "Beth pushed red hair aside."], ["Beth's mother entered."]);
var maggie = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Maggie", ["Maggie is sixteen."], ["her mother spoke."]);
var socialWorker = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("social worker", ["the social worker arrived."], ["Beth pushed red hair aside."]);
var kevin = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Kevin", ["Kevin"], []);
var ladyExaminer = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("lady examiner", ["the lady examiner"], []);
var toddler = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("boy", ["the boy toddler"], []);
Assert(beth.AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.YoungTeen, $"Beth must not become MatureAdult, got {beth.AgeBand}.");
Assert(beth.HairColour == StoryIntelligenceIllustrationCompatibility.HairColours.Red, "Beth's own red-hair evidence should be retained.");
Assert(maggie.AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.OlderTeen, $"Maggie at sixteen must not become MatureAdult, got {maggie.AgeBand}.");
Assert(socialWorker.HairColour == StoryIntelligenceIllustrationCompatibility.HairColours.Unknown, $"Social worker should not inherit Beth's red hair, got {socialWorker.HairColour}.");
Assert(kevin.Presentation == StoryIntelligenceIllustrationCompatibility.Presentations.Masculine, $"Kevin should be Masculine, got {kevin.Presentation}.");
Assert(ladyExaminer.Presentation == StoryIntelligenceIllustrationCompatibility.Presentations.Feminine, $"Lady examiner should be Feminine, got {ladyExaminer.Presentation}.");
Assert(toddler.AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.Child, $"Toddler should be Child, got {toddler.AgeBand}.");
Assert(toddler.Presentation == StoryIntelligenceIllustrationCompatibility.Presentations.Masculine, $"Boy toddler should be Masculine, got {toddler.Presentation}.");
Assert(StoryIntelligenceIllustrationCompatibility.LocationType("Cock Hill Lane") == "Road", "Cock Hill Lane must classify as Road, not interior.");
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("money") == "Money", "Money must not classify as Car.");
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("bottle") == "Bottle", "Bottle must not classify as LockedBox.");
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("scarves") == "Clothing", "Scarves must not classify as Car.");
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("driving licence") == "Document", "Driving licence should classify as Document.");
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("ring") == "Jewellery", "Ring should classify as Jewellery.");
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("gun") == "Weapon", "Gun should classify as Weapon.");
}
static void IllustrationDemandArchetypesStayBroadAndUseActiveTemplate()
{
var evidence = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Beth", ["Beth", "age 15", "red hair", "light skin"], []);
var key = string.Join('|', "character", evidence.AgeBand, evidence.Presentation, evidence.HairColour, evidence.SkinTone).ToLowerInvariant();
Assert(key == "character|youngteen|feminine|red|light", $"Demand key was too broad or too specific: {key}");
Assert(!key.Contains("beth", StringComparison.OrdinalIgnoreCase), "Demand key must not include character name.");
Assert(!key.Contains("eye", StringComparison.OrdinalIgnoreCase), "Demand key must not include eye colour.");
Assert(IllustrationPromptBuilder.CurrentTemplateVersion == "21J.3", $"Demand generation should use the active shared template, got {IllustrationPromptBuilder.CurrentTemplateVersion}.");
}
static void IllustrationAssignmentRepairEnforcesExplicitEvidenceGroupsAndStrictSubtypes()
{
var femaleInstructor = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("female instructor", ["female instructor"], []);
var maleExaminers = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("younger male examiners", ["younger male examiners"], []);
var lad = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("tubby lad", ["tubby lad"], []);
var girl = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("girl", ["schoolgirl"], []);
var kevin = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Kevin", ["Kevin", "female instructor (Kevin's)"], ["female instructor", "Kevin's examiner"]);
var colin = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Colin", ["Colin mother waits nearby", "female instructor"], []);
var david = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("David", ["David the woman speaks first", "female examiner"], []);
Assert(femaleInstructor.Presentation == StoryIntelligenceIllustrationCompatibility.Presentations.Feminine, $"female instructor should be Feminine, got {femaleInstructor.Presentation}.");
Assert(maleExaminers.Presentation == StoryIntelligenceIllustrationCompatibility.Presentations.Masculine, $"male examiners should be Masculine, got {maleExaminers.Presentation}.");
Assert(lad.Presentation == StoryIntelligenceIllustrationCompatibility.Presentations.Masculine, $"lad should be Masculine, got {lad.Presentation}.");
Assert(lad.AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.Unknown, $"lad should not default to Child without explicit child-age evidence, got {lad.AgeBand}.");
Assert(girl.Presentation == StoryIntelligenceIllustrationCompatibility.Presentations.Feminine, $"girl should be Feminine, got {girl.Presentation}.");
Assert(girl.AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.Child, $"schoolgirl should infer Child, got {girl.AgeBand}.");
Assert(kevin.Presentation == StoryIntelligenceIllustrationCompatibility.Presentations.Masculine, $"Kevin should not inherit feminine presentation from his instructor, got {kevin.Presentation}.");
Assert(colin.Presentation == StoryIntelligenceIllustrationCompatibility.Presentations.Masculine, $"Colin should not inherit feminine presentation from neighbouring evidence, got {colin.Presentation}.");
Assert(colin.Warnings.Any(warning => warning.Contains("corrected Colin", StringComparison.OrdinalIgnoreCase)), "Colin correction should be visible in diagnostics warnings.");
Assert(david.Presentation == StoryIntelligenceIllustrationCompatibility.Presentations.Masculine, $"David should not inherit feminine presentation from neighbouring evidence, got {david.Presentation}.");
Assert(david.Warnings.Any(warning => warning.Contains("corrected David", StringComparison.OrdinalIgnoreCase)), "David correction should be visible in diagnostics warnings.");
Assert(StoryIntelligenceIllustrationCompatibility.IsGroupEntity("social workers"), "Social workers should be treated as a group entity.");
Assert(StoryIntelligenceIllustrationCompatibility.IsGroupEntity("younger male examiners"), "Plural examiners should be treated as a group entity.");
Assert(StoryIntelligenceIllustrationCompatibility.IsGroupEntity("uniformed people"), "Uniformed people should be treated as a group entity.");
Assert(!StoryIntelligenceIllustrationCompatibility.IsGroupEntity("social worker"), "Singular social worker should remain an individual entity.");
Assert(!StoryIntelligenceIllustrationCompatibility.LocationCompatible("Bathroom", "LaundryUtilityRoom"), "Bathroom must not match laundry room.");
Assert(!StoryIntelligenceIllustrationCompatibility.LocationCompatible("Road", "CarPark"), "Road must not match car park.");
Assert(!StoryIntelligenceIllustrationCompatibility.LocationCompatible("WaitingRoom", "FlatInterior"), "Waiting room must not match flat interior.");
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("Ford Fiesta") == "Hatchback", "Ford Fiesta should classify as Hatchback.");
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("Car") == "Car", "Car should classify as Car.");
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("supercharger") == "CarPart", "Supercharger should not classify as full car.");
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("biscuit tin") == "Tin", "Biscuit tin should not classify as Photograph.");
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("parcel") == "Parcel", "Parcel should not classify as Letter.");
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("pass certificate") == "Document", "Pass certificate should classify as Document.");
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("driving licence") == "Document", "Driving licence should classify as Document.");
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("thin cotton blouse") == "Clothing", "Blouse should classify as Clothing.");
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("two pairs of sunglasses") == "Clothing", "Sunglasses should classify as Clothing.");
var beth = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Beth", ["Beth is fifteen."], []);
var adult = Metadata("char-family-relative", "MatureAdult", "Feminine");
Assert(Rejected(beth, adult).Any(reason => reason.Contains("age hard mismatch", StringComparison.OrdinalIgnoreCase)), "Existing adult assignment must be discarded when Beth evidence says YoungTeen.");
}
static void Phase21OPreservesAdultDefaultsAndRealModeContinuity()
{
var unknownNamed = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Sarah", ["Sarah watches."], []);
var explicitGirl = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Mia", ["schoolgirl"], []);
var teacher = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Mrs Patterson", ["Mrs Patterson is a teacher."], []);
var examiner = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("driving examiner", ["female driving examiner"], []);
Assert(unknownNamed.AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.Unknown, $"Unknown age should remain Unknown before illustration fallback, got {unknownNamed.AgeBand}.");
Assert(explicitGirl.AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.Child, $"Schoolgirl should remain explicit child evidence, got {explicitGirl.AgeBand}.");
Assert(teacher.AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.Adult, $"Teacher title/role should infer Adult, got {teacher.AgeBand}.");
Assert(examiner.Presentation == StoryIntelligenceIllustrationCompatibility.Presentations.Feminine, $"Female examiner should infer Feminine, got {examiner.Presentation}.");
Assert(examiner.AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.Adult, $"Driving examiner should infer Adult, got {examiner.AgeBand}.");
Assert(StoryIntelligenceIllustrationCompatibility.LocationType("Bristol Road") == "Road", "Road names should classify as Road.");
Assert(StoryIntelligenceIllustrationCompatibility.LocationType("Cock Hill Lane") == "Road", "Lane names should classify as Road.");
Assert(StoryIntelligenceIllustrationCompatibility.AssetCompatible("Hatchback", "Car"), "Hatchbacks may fall back to neutral generic car artwork.");
Assert(!StoryIntelligenceIllustrationCompatibility.AssetCompatible("SportsCar", "Hatchback"), "Vehicle subtypes should remain strict.");
var view = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "PlotLine", "Views", "Development", "StoryIntelligenceExperience.cshtml"));
Assert(view.Contains("Return to Import", StringComparison.Ordinal), "Real visualisation should expose a return-to-import link.");
Assert(view.Contains("Model.Mode == \"simulation\" || Model.Mode == \"replay\"", StringComparison.Ordinal), "Prototype controls should be gated to simulation and replay modes.");
}
static void Phase21PUsesOneAuthoritativeCurrentVisualisationScene()
{
var root = Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..");
var viewModel = File.ReadAllText(Path.Combine(root, "PlotLine", "ViewModels", "StoryIntelligenceExperiencePrototypeViewModels.cs"));
var snapshotService = File.ReadAllText(Path.Combine(root, "PlotLine", "Services", "StoryIntelligenceVisualisationSnapshotService.cs"));
var browser = File.ReadAllText(Path.Combine(root, "PlotLine", "wwwroot", "js", "story-intelligence-experience-prototype.js"));
var view = File.ReadAllText(Path.Combine(root, "PlotLine", "Views", "Development", "StoryIntelligenceExperience.cshtml"));
Assert(viewModel.Contains("public bool IsCurrent", StringComparison.Ordinal), "Scene snapshots must expose an authoritative current-scene marker.");
Assert(snapshotService.Contains("IsCurrent = isCurrent", StringComparison.Ordinal), "Snapshot builder must mark the selected current scene.");
Assert(snapshotService.Contains("followingCount: 0", StringComparison.Ordinal), "Real import snapshots must not include following scenes that can pull the browser ahead.");
Assert(snapshotService.Contains("Story Intelligence snapshot synchronisation", StringComparison.Ordinal), "Snapshot builder must log synchronisation evidence.");
Assert(browser.Contains("scene?.isCurrent", StringComparison.Ordinal), "Browser must choose the current scene from the authoritative marker.");
Assert(browser.Contains("Story Intelligence browser synchronisation", StringComparison.Ordinal), "Browser must log synchronisation evidence.");
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")
=> new(
ageBand,
presentation,
hairColour,
"Unknown",
"Unknown",
"Unknown",
"Unknown",
isUnknownFigure,
false,
!isUnknownFigure,
IllustrationPromptBuilder.CurrentTemplateVersion,
"Test");
static IReadOnlyList<string> Rejected(CharacterEvidenceProfile evidence, IllustrationCharacterMetadata metadata)
=> StoryIntelligenceIllustrationCompatibility.CharacterHardRejections(evidence, metadata, isNamedCharacter: true, alreadyAssignedToAnotherSignificantCharacter: false);
static void StoryIntelligenceSimulationModeRemainsAvailable() static void StoryIntelligenceSimulationModeRemainsAvailable()
{ {
var prototype = StoryIntelligenceExperiencePrototypeData.Build(); var prototype = StoryIntelligenceExperiencePrototypeData.Build();
@ -576,6 +799,197 @@ static void IllustrationProviderOmitsGptImageResponseFormat()
Assert(result.ErrorMessage?.Contains("test failure", StringComparison.Ordinal) == true, "Stored error should include safe OpenAI error message."); Assert(result.ErrorMessage?.Contains("test failure", StringComparison.Ordinal) == true, "Stored error should include safe OpenAI error message.");
} }
static void ScanReviewPostSupportsFullBookFormSubmissions()
{
var method = typeof(OnboardingController).GetMethod(nameof(OnboardingController.SaveScanReview));
Assert(method is not null, "Scan review post action could not be found.");
var formLimits = method!.GetCustomAttributes(typeof(RequestFormLimitsAttribute), inherit: false)
.OfType<RequestFormLimitsAttribute>()
.SingleOrDefault();
Assert(formLimits is not null, "Scan review post action should configure expanded form limits.");
Assert(formLimits!.ValueCountLimit >= 20000, "Scan review form value limit should support full-book previews.");
Assert(formLimits.KeyLengthLimit >= 4096, "Scan review form key length limit should support indexed review fields.");
var requestSize = method.GetCustomAttributes(typeof(RequestSizeLimitAttribute), inherit: false)
.OfType<RequestSizeLimitAttribute>()
.SingleOrDefault();
Assert(((IRequestSizeLimitMetadata?)requestSize)?.MaxRequestBodySize >= 25 * 1024 * 1024, "Scan review request size limit should support full-book previews.");
}
static void StoryIntelligenceExperienceBootDoesNotSerialiseLiveModel()
{
var view = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Views/Development/StoryIntelligenceExperience.cshtml"));
Assert(!view.Contains("JsonSerializer.Serialize(Model", StringComparison.Ordinal), "Live experience page should not serialise the full view model into the Razor response.");
Assert(view.Contains("JsonSerializer.Serialize(bootModel", StringComparison.Ordinal), "Live experience page should serialise only boot data.");
}
static void LiveVisualisationStripsIllustrationDiagnostics()
{
var large = new string('x', 200_000);
var model = new StoryIntelligenceExperiencePrototypeViewModel
{
Diagnostics = new StoryIntelligenceExperienceSnapshotDiagnostics
{
CharacterConsistencyReport = large
},
Scenes =
[
new StoryIntelligenceExperienceSceneState
{
Id = "scene-result-1",
Location = new StoryIntelligenceExperienceLocationState(),
Characters =
[
new StoryIntelligenceExperienceCharacterState
{
Id = "character-result-beth",
Name = "Beth",
ImageResolution = new StoryIntelligenceExperienceImageResolution
{
RejectedCandidates = large,
PreviousEvidence = large,
CurrentEvidence = large,
EvidenceWarnings = large
}
}
]
}
]
};
StoryIntelligenceVisualisationSnapshotService.StripLiveDiagnostics(model);
var json = JsonSerializer.Serialize(model, JsonOptions());
Assert(!json.Contains(large, StringComparison.Ordinal), "Live snapshot should not contain large illustration diagnostic strings.");
Assert(json.Length < 20_000, $"Live snapshot remained too large after stripping diagnostics: {json.Length:N0} chars.");
}
static void IllustrationEvidenceTraceDoesNotRecursivelyStorePreviousEvidence()
{
var source = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Services/StoryIntelligenceIllustrationMatchingService.cs"));
Assert(!source.Contains("previousStoredEvidence = existing?.EvidenceJson", StringComparison.Ordinal), "Evidence trace must not recursively embed previous EvidenceJson.");
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\" || Model.Mode == \"replay\"", StringComparison.Ordinal), "Prototype controls must remain hidden in live mode while supporting replay.");
Assert(script.Contains("relationships.slice(0, 2)", StringComparison.Ordinal), "Relationship changes should be capped to the two most relevant visible cards.");
Assert(script.Contains("renderOverflowSummary", StringComparison.Ordinal), "Relationship overflow should be indicated compactly.");
Assert(css.Contains("max-height: calc(100vh - 126px)", StringComparison.Ordinal), "Side columns should be bounded to the viewport.");
Assert(css.Contains("overflow-y: auto", StringComparison.Ordinal), "Side columns should keep a single hidden-track vertical scroll.");
Assert(css.Contains("z-index: 180", StringComparison.Ordinal), "Character labels should render in a high label layer above nodes and paths.");
}
static void Phase21RReplayUsesPersistedDataOnly()
{
var snapshotService = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Services/StoryIntelligenceVisualisationSnapshotService.cs"));
var matcher = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Services/StoryIntelligenceIllustrationMatchingService.cs"));
var development = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Controllers/DevelopmentController.cs"));
Assert(snapshotService.Contains("BuildReplaySnapshotAsync", StringComparison.Ordinal), "Replay mode needs a dedicated snapshot builder.");
Assert(snapshotService.Contains("Mode = replayMode ? \"replay\" : \"real\"", StringComparison.Ordinal), "Replay snapshots should be explicitly labelled.");
Assert(snapshotService.Contains("ApplyPersistedStoryMemoryAssignmentsAsync(importSessionId, model)", StringComparison.Ordinal), "Replay must use durable Story Memory illustration assignments only.");
Assert(!snapshotService.Contains("await illustrationMatcher.ApplyPersistedCharacterIllustrationsAsync", StringComparison.Ordinal), "Replay should not use the legacy character-only matcher.");
Assert(!snapshotService.Contains("await illustrationMatcher.ApplySupportingIllustrationDemandAsync", StringComparison.Ordinal), "Snapshot rendering must not create location/asset demand.");
Assert(snapshotService.Contains("AiCallsDuringReplay = 0", StringComparison.Ordinal), "Replay diagnostics must state that no AI calls are made.");
Assert(development.Contains("BuildReplaySnapshotAsync(importSessionId.Value, userId)", StringComparison.Ordinal), "Development route must call the replay snapshot builder for mode=replay.");
Assert(development.Contains("RebuildStoryIntelligenceExperience", StringComparison.Ordinal), "Development route should expose a saved-analysis rebuild action.");
Assert(development.Contains("AI calls", StringComparison.Ordinal), "Rebuild action should report that it made no AI calls.");
}
static void Phase21SUsesDurableStoryMemoryAssignments()
{
var sql = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Sql/142_Phase21S_DurableStoryMemory.sql"));
var models = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Models/StoryMemoryModels.cs"));
var service = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Services/StoryMemoryServices.cs"));
var snapshot = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Services/StoryIntelligenceVisualisationSnapshotService.cs"));
var runner = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Services/PersistedStoryIntelligenceRunner.cs"));
var development = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Controllers/DevelopmentController.cs"));
Assert(sql.Contains("StoryMemoryCharacters", StringComparison.Ordinal), "Durable Story Memory character table is missing.");
Assert(sql.Contains("StoryMemoryLocations", StringComparison.Ordinal), "Durable Story Memory location table is missing.");
Assert(sql.Contains("StoryMemoryAssets", StringComparison.Ordinal), "Durable Story Memory asset table is missing.");
Assert(sql.Contains("StoryMemoryIllustrationAssignments", StringComparison.Ordinal), "Durable illustration assignment table is missing.");
Assert(sql.Contains("StoryMemoryIllustrationDemands", StringComparison.Ordinal), "Durable illustration demand table is missing.");
Assert(models.Contains("StoryMemoryAppearancePreferences", StringComparison.Ordinal), "Import-level appearance preference model is missing.");
Assert(service.Contains("IStoryMemoryService", StringComparison.Ordinal), "Durable Story Memory service is missing.");
Assert(service.Contains("QueueDemand = false", StringComparison.Ordinal) || service.Contains("QueueDemand", StringComparison.Ordinal), "Demand queueing must be explicit.");
Assert(service.Contains("InferCharacterEvidence", StringComparison.Ordinal), "Durable updater must use deterministic character evidence extraction.");
Assert(service.Contains("CharacterHardRejections", StringComparison.Ordinal), "Resolver must revalidate hard character constraints.");
Assert(service.Contains("LocationCompatible", StringComparison.Ordinal), "Resolver must enforce location compatibility.");
Assert(service.Contains("AssetCompatible", StringComparison.Ordinal), "Resolver must enforce asset compatibility.");
Assert(snapshot.Contains("ApplyPersistedStoryMemoryAssignmentsAsync", StringComparison.Ordinal), "Snapshot must read durable persisted assignments.");
Assert(runner.Contains("storyMemory.ProcessSceneResultAsync", StringComparison.Ordinal), "Importer must update Story Memory as scenes are persisted.");
Assert(development.Contains("StoryMemoryDiagnostics", StringComparison.Ordinal), "Development diagnostics endpoint is missing.");
Assert(development.Contains("RebuildImportSessionAsync", StringComparison.Ordinal), "Development rebuild action must call the durable memory rebuild.");
}
static void Phase21RSceneBrowserAndPanelsAreReplayFriendly()
{
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"));
var progressViewModel = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/ViewModels/OnboardingViewModels.cs"));
Assert(view.Contains("mode=replay", StringComparison.Ordinal), "Replay links should open the visualisation in replay mode.");
Assert(view.Contains("data-replay-chapter", StringComparison.Ordinal), "Replay scene browser should expose chapter selection.");
Assert(view.Contains("data-detail-panel", StringComparison.Ordinal), "Dense side-panel items should have an expanded detail presentation.");
Assert(view.Contains("Rebuild Visualisation from Saved Analysis", StringComparison.Ordinal), "Replay mode should expose the development rebuild action.");
Assert(script.Contains("const replayMode = root.dataset.mode === \"replay\"", StringComparison.Ordinal), "Browser script should distinguish replay mode.");
Assert(script.Contains("setupReplayBrowser", StringComparison.Ordinal), "Replay browser setup was not found.");
Assert(script.Contains("root.dataset.failedChapters", StringComparison.Ordinal), "Failed chapters should appear as replay browser gaps.");
Assert(script.Contains("if (!realMode || !snapshotUrl || state.terminal) return;", StringComparison.Ordinal), "Replay must not poll live snapshot endpoints.");
Assert(css.Contains(".story-exp-replay-browser", StringComparison.Ordinal), "Replay browser styling was not found.");
Assert(css.Contains("scrollbar-width: none", StringComparison.Ordinal), "Side-panel native scrollbar tracks should be suppressed.");
Assert(!css.Contains(".story-exp-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.");
}
static JsonSerializerOptions JsonOptions() static JsonSerializerOptions JsonOptions()
=> new() => new()
{ {

View File

@ -1,5 +1,7 @@
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using PlotLine.Data;
using PlotLine.Models;
using PlotLine.Services; using PlotLine.Services;
using PlotLine.ViewModels; using PlotLine.ViewModels;
@ -11,6 +13,8 @@ public sealed class DevelopmentController(
IWebHostEnvironment environment, IWebHostEnvironment environment,
ICurrentUserService currentUser, ICurrentUserService currentUser,
IIllustrationLibraryService illustrationLibrary, IIllustrationLibraryService illustrationLibrary,
IStoryMemoryService storyMemory,
IStoryMemoryRepository storyMemoryRepository,
IStoryIntelligenceVisualisationSnapshotService snapshots) : Controller IStoryIntelligenceVisualisationSnapshotService snapshots) : Controller
{ {
[HttpGet("StoryIntelligenceExperience")] [HttpGet("StoryIntelligenceExperience")]
@ -23,10 +27,13 @@ public sealed class DevelopmentController(
var requestedMode = string.IsNullOrWhiteSpace(mode) ? "real" : mode.Trim(); var requestedMode = string.IsNullOrWhiteSpace(mode) ? "real" : mode.Trim();
var simulationRequested = string.Equals(requestedMode, "simulation", StringComparison.OrdinalIgnoreCase); var simulationRequested = string.Equals(requestedMode, "simulation", StringComparison.OrdinalIgnoreCase);
var replayRequested = string.Equals(requestedMode, "replay", StringComparison.OrdinalIgnoreCase);
if (!simulationRequested && importSessionId.HasValue && currentUser.UserId is int userId) if (!simulationRequested && importSessionId.HasValue && currentUser.UserId is int userId)
{ {
var realModel = await snapshots.BuildImportSessionSnapshotAsync(importSessionId.Value, userId); var realModel = replayRequested
? await snapshots.BuildReplaySnapshotAsync(importSessionId.Value, userId)
: await snapshots.BuildImportSessionSnapshotAsync(importSessionId.Value, userId);
if (realModel is null) if (realModel is null)
{ {
return View(ErrorModel( return View(ErrorModel(
@ -83,6 +90,91 @@ public sealed class DevelopmentController(
return View(ErrorModel(requestedMode, importSessionId, "No development user is signed in.")); return View(ErrorModel(requestedMode, importSessionId, "No development user is signed in."));
} }
[HttpPost("StoryIntelligenceExperience/Rebuild")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> RebuildStoryIntelligenceExperience(
[FromForm] int importSessionId,
[FromForm] string? rebuildScope,
[FromForm] bool dryRun = false,
[FromForm] bool queueDemand = false,
[FromForm] int? maxDemandToQueue = null,
[FromForm] string? appearancePreference = null)
{
if (!environment.IsDevelopment())
{
return NotFound();
}
if (importSessionId <= 0 || currentUser.UserId is not int userId)
{
return BadRequest("A valid importSessionId is required.");
}
var rebuild = await storyMemory.RebuildImportSessionAsync(
importSessionId,
userId,
new StoryMemoryRebuildOptions
{
DryRun = dryRun,
RebuildAll = !string.Equals(rebuildScope, "incremental", StringComparison.OrdinalIgnoreCase),
QueueDemand = queueDemand,
MaxDemandToQueue = maxDemandToQueue,
AppearancePreference = string.IsNullOrWhiteSpace(appearancePreference)
? StoryMemoryAppearancePreferences.Balanced
: appearancePreference.Trim()
},
HttpContext.RequestAborted);
if (rebuild.DryRun)
{
TempData["StoryIntelligenceReplayMessage"] = $"Dry run complete. {rebuild.SceneResultsRead:N0} stored scene result(s), {rebuild.SceneResultsProcessed:N0} parseable. No database changes, AI calls, or image credits.";
}
else
{
TempData["StoryIntelligenceReplayMessage"] = $"Durable Story Memory rebuilt. {rebuild.SceneResultsProcessed:N0} scene result(s) processed, {rebuild.AssignmentsCreatedOrValidated:N0} assignment(s), {rebuild.DemandsCreated:N0} demand record(s), {rebuild.AiCalls:N0} AI calls.";
}
return RedirectToAction(nameof(StoryIntelligenceExperience), new { importSessionId, mode = "replay" });
}
[HttpGet("StoryMemoryDiagnostics")]
public async Task<IActionResult> StoryMemoryDiagnostics([FromQuery] int importSessionId)
{
if (!environment.IsDevelopment())
{
return NotFound();
}
if (importSessionId <= 0)
{
return BadRequest("A valid importSessionId is required.");
}
var diagnostics = await storyMemoryRepository.GetDiagnosticsAsync(importSessionId);
return Json(diagnostics);
}
[HttpGet("StoryIntelligenceIllustrationDiagnostics")]
public async Task<IActionResult> StoryIntelligenceIllustrationDiagnostics([FromQuery] int importSessionId)
{
if (!environment.IsDevelopment())
{
return NotFound();
}
if (importSessionId <= 0 || currentUser.UserId is not int userId)
{
return BadRequest("A valid importSessionId is required.");
}
var model = await snapshots.BuildImportSessionSnapshotAsync(importSessionId, userId);
if (model is null)
{
return NotFound($"Import Session {importSessionId:N0} could not be found for the current user, or it has no persisted chapter analysis runs yet.");
}
return View(model);
}
private static StoryIntelligenceExperiencePrototypeViewModel ErrorModel(string requestedMode, int? importSessionId, string message) private static StoryIntelligenceExperiencePrototypeViewModel ErrorModel(string requestedMode, int? importSessionId, string message)
=> new() => new()
{ {

View File

@ -490,6 +490,8 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
} }
[HttpPost("scan-review")] [HttpPost("scan-review")]
[RequestSizeLimit(25 * 1024 * 1024)]
[RequestFormLimits(ValueCountLimit = 20000, KeyLengthLimit = 4096, ValueLengthLimit = 1024 * 1024)]
[ValidateAntiForgeryToken] [ValidateAntiForgeryToken]
public async Task<IActionResult> SaveScanReview(ManuscriptScanReviewForm form, string intent = "save") public async Task<IActionResult> SaveScanReview(ManuscriptScanReviewForm form, string intent = "save")
{ {

View File

@ -8,7 +8,8 @@ namespace PlotLine.Controllers;
[Route("api/story-intelligence")] [Route("api/story-intelligence")]
public sealed class StoryIntelligenceVisualisationController( public sealed class StoryIntelligenceVisualisationController(
ICurrentUserService currentUser, ICurrentUserService currentUser,
IStoryIntelligenceVisualisationSnapshotService snapshots) : ControllerBase IStoryIntelligenceVisualisationSnapshotService snapshots,
ILogger<StoryIntelligenceVisualisationController> logger) : ControllerBase
{ {
[HttpGet("runs/{runId:int}/visualisation-snapshot")] [HttpGet("runs/{runId:int}/visualisation-snapshot")]
public async Task<IActionResult> GetSnapshot(int runId) public async Task<IActionResult> GetSnapshot(int runId)
@ -19,6 +20,18 @@ public sealed class StoryIntelligenceVisualisationController(
} }
var snapshot = await snapshots.BuildSnapshotAsync(runId, userId); var snapshot = await snapshots.BuildSnapshotAsync(runId, userId);
if (snapshot is not null)
{
var current = snapshot.Scenes.FirstOrDefault(scene => scene.IsCurrent) ?? snapshot.Scenes.LastOrDefault();
logger.LogInformation(
"Story Intelligence browser endpoint synchronisation: import session {ImportSessionId}; run {RunId}; current chapter {CurrentChapter}; current scene {CurrentScene}; scene result {SceneResultId}; snapshot version Endpoint; change token {ChangeToken}.",
snapshot.ImportSessionId,
snapshot.RunId,
current?.ChapterLabel,
current?.SceneNumber,
snapshot.Diagnostics.SelectedCurrentSceneResultId,
snapshot.ChangeToken);
}
return snapshot is null ? NotFound() : Ok(snapshot); return snapshot is null ? NotFound() : Ok(snapshot);
} }
@ -31,6 +44,18 @@ public sealed class StoryIntelligenceVisualisationController(
} }
var snapshot = await snapshots.BuildImportSessionSnapshotAsync(importSessionId, userId); var snapshot = await snapshots.BuildImportSessionSnapshotAsync(importSessionId, userId);
if (snapshot is not null)
{
var current = snapshot.Scenes.FirstOrDefault(scene => scene.IsCurrent) ?? snapshot.Scenes.LastOrDefault();
logger.LogInformation(
"Story Intelligence browser endpoint synchronisation: import session {ImportSessionId}; run {RunId}; current chapter {CurrentChapter}; current scene {CurrentScene}; scene result {SceneResultId}; snapshot version Endpoint; change token {ChangeToken}.",
snapshot.ImportSessionId,
snapshot.RunId,
current?.ChapterLabel,
current?.SceneNumber,
snapshot.Diagnostics.SelectedCurrentSceneResultId,
snapshot.ChangeToken);
}
return snapshot is null ? NotFound() : Ok(snapshot); return snapshot is null ? NotFound() : Ok(snapshot);
} }
} }

View File

@ -17,7 +17,27 @@ public sealed class StoryIntelligenceIllustrationMatchingRepository(ISqlConnecti
using var connection = connectionFactory.CreateConnection(); using var connection = connectionFactory.CreateConnection();
var rows = await connection.QueryAsync<StoryIntelligenceCharacterIllustrationAssignment>( var rows = await connection.QueryAsync<StoryIntelligenceCharacterIllustrationAssignment>(
""" """
SELECT * SELECT
StoryIntelligenceCharacterIllustrationAssignmentID,
ProjectID,
CharacterKey,
CharacterName,
IllustrationLibraryItemID,
StableCode,
MatchConfidence,
MatchingScore,
CAST(NULL AS nvarchar(max)) AS EvidenceJson,
CAST(NULL AS nvarchar(max)) AS CandidateDiagnosticsJson,
ReasonChosen,
ReasonReassigned,
IsActive,
CreatedUtc,
UpdatedUtc,
AssignmentStatus,
AssignedUtc,
LastValidatedUtc,
EvidenceVersion,
IsFallback
FROM dbo.StoryIntelligenceCharacterIllustrationAssignments FROM dbo.StoryIntelligenceCharacterIllustrationAssignments
WHERE ProjectID = @ProjectID WHERE ProjectID = @ProjectID
AND IsActive = 1; AND IsActive = 1;
@ -49,10 +69,14 @@ public sealed class StoryIntelligenceIllustrationMatchingRepository(ISqlConnecti
CandidateDiagnosticsJson = @CandidateDiagnosticsJson, CandidateDiagnosticsJson = @CandidateDiagnosticsJson,
ReasonChosen = @ReasonChosen, ReasonChosen = @ReasonChosen,
ReasonReassigned = @ReasonReassigned, ReasonReassigned = @ReasonReassigned,
AssignmentStatus = CASE WHEN @IllustrationLibraryItemID IS NULL THEN N'Fallback' ELSE N'Assigned' END,
LastValidatedUtc = SYSUTCDATETIME(),
EvidenceVersion = N'21K',
IsFallback = CASE WHEN @IllustrationLibraryItemID IS NULL THEN 1 ELSE 0 END,
UpdatedUtc = SYSUTCDATETIME() UpdatedUtc = SYSUTCDATETIME()
WHEN NOT MATCHED THEN WHEN NOT MATCHED THEN
INSERT (ProjectID, CharacterKey, CharacterName, IllustrationLibraryItemID, StableCode, MatchConfidence, MatchingScore, EvidenceJson, CandidateDiagnosticsJson, ReasonChosen, ReasonReassigned) INSERT (ProjectID, CharacterKey, CharacterName, IllustrationLibraryItemID, StableCode, MatchConfidence, MatchingScore, EvidenceJson, CandidateDiagnosticsJson, ReasonChosen, ReasonReassigned, AssignmentStatus, AssignedUtc, LastValidatedUtc, EvidenceVersion, IsFallback)
VALUES (@ProjectID, @CharacterKey, @CharacterName, @IllustrationLibraryItemID, @StableCode, @MatchConfidence, @MatchingScore, @EvidenceJson, @CandidateDiagnosticsJson, @ReasonChosen, @ReasonReassigned); VALUES (@ProjectID, @CharacterKey, @CharacterName, @IllustrationLibraryItemID, @StableCode, @MatchConfidence, @MatchingScore, @EvidenceJson, @CandidateDiagnosticsJson, @ReasonChosen, @ReasonReassigned, CASE WHEN @IllustrationLibraryItemID IS NULL THEN N'Fallback' ELSE N'Assigned' END, SYSUTCDATETIME(), SYSUTCDATETIME(), N'21K', CASE WHEN @IllustrationLibraryItemID IS NULL THEN 1 ELSE 0 END);
SELECT * SELECT *
FROM dbo.StoryIntelligenceCharacterIllustrationAssignments FROM dbo.StoryIntelligenceCharacterIllustrationAssignments

View File

@ -0,0 +1,412 @@
using Dapper;
using PlotLine.Models;
namespace PlotLine.Data;
public interface IStoryMemoryRepository
{
Task ClearDerivedStateAsync(int importSessionId);
Task<IReadOnlySet<int>> ListProcessedSceneResultIdsAsync(int importSessionId, string processorVersion);
Task MarkSceneResultProcessedAsync(int importSessionId, int sceneResultId, string processorVersion, string snapshotHash);
Task<StoryMemoryCharacter> UpsertCharacterAsync(StoryMemoryCharacterSave request);
Task AddCharacterAliasAsync(int characterId, string alias, string normalisedAlias, string reason, int sceneResultId, decimal confidence);
Task AddCharacterAttributeAsync(int characterId, string attributeType, string value, decimal confidence, bool isExplicit, string evidenceSummary, int sceneResultId);
Task<StoryMemoryLocation> UpsertLocationAsync(StoryMemoryLocationSave request);
Task<StoryMemoryAsset> UpsertAssetAsync(StoryMemoryAssetSave request);
Task UpsertRelationshipAsync(int importSessionId, string sourceEntityType, int sourceEntityId, string targetEntityType, int targetEntityId, string relationshipType, decimal confidence, bool isExplicit, int sceneResultId);
Task<IReadOnlyList<StoryMemoryCharacter>> ListCharactersAsync(int importSessionId);
Task<IReadOnlyList<StoryMemoryCharacterAttribute>> ListCharacterAttributesAsync(int importSessionId);
Task<IReadOnlyList<StoryMemoryLocation>> ListLocationsAsync(int importSessionId);
Task<IReadOnlyList<StoryMemoryAsset>> ListAssetsAsync(int importSessionId);
Task<IReadOnlyList<StoryMemoryIllustrationAssignment>> ListAssignmentsAsync(int importSessionId);
Task<StoryMemoryIllustrationAssignment?> GetAssignmentAsync(int importSessionId, string entityType, int entityId);
Task UpsertAssignmentAsync(StoryMemoryIllustrationAssignmentSave request);
Task InvalidateAssignmentAsync(int assignmentId, string reason);
Task<StoryMemoryIllustrationDemand> UpsertDemandAsync(StoryMemoryIllustrationDemandSave request);
Task<string> GetAppearancePreferenceAsync(int importSessionId);
Task SetAppearancePreferenceAsync(int importSessionId, string preference);
Task<StoryMemoryDiagnostics> GetDiagnosticsAsync(int importSessionId);
}
public sealed class StoryMemoryRepository(ISqlConnectionFactory connectionFactory) : IStoryMemoryRepository
{
public async Task ClearDerivedStateAsync(int importSessionId)
{
using var connection = connectionFactory.CreateConnection();
await connection.ExecuteAsync(
"""
DELETE FROM dbo.StoryMemoryIllustrationDemands WHERE ImportSessionID = @ImportSessionID;
DELETE FROM dbo.StoryMemoryIllustrationAssignments WHERE ImportSessionID = @ImportSessionID;
DELETE FROM dbo.StoryMemoryRelationships WHERE ImportSessionID = @ImportSessionID;
DELETE a
FROM dbo.StoryMemoryCharacterAttributes a
INNER JOIN dbo.StoryMemoryCharacters c ON c.StoryMemoryCharacterID = a.StoryMemoryCharacterID
WHERE c.ImportSessionID = @ImportSessionID;
DELETE aliasRows
FROM dbo.StoryMemoryCharacterAliases aliasRows
INNER JOIN dbo.StoryMemoryCharacters c ON c.StoryMemoryCharacterID = aliasRows.StoryMemoryCharacterID
WHERE c.ImportSessionID = @ImportSessionID;
DELETE FROM dbo.StoryMemoryAssets WHERE ImportSessionID = @ImportSessionID;
DELETE FROM dbo.StoryMemoryLocations WHERE ImportSessionID = @ImportSessionID;
DELETE FROM dbo.StoryMemoryCharacters WHERE ImportSessionID = @ImportSessionID;
DELETE FROM dbo.StoryMemoryProcessedSceneResults WHERE ImportSessionID = @ImportSessionID;
""",
new { ImportSessionID = importSessionId });
}
public async Task<IReadOnlySet<int>> ListProcessedSceneResultIdsAsync(int importSessionId, string processorVersion)
{
using var connection = connectionFactory.CreateConnection();
var ids = await connection.QueryAsync<int>(
"""
SELECT SceneResultID
FROM dbo.StoryMemoryProcessedSceneResults
WHERE ImportSessionID = @ImportSessionID
AND ProcessorVersion = @ProcessorVersion;
""",
new { ImportSessionID = importSessionId, ProcessorVersion = processorVersion });
return ids.ToHashSet();
}
public async Task MarkSceneResultProcessedAsync(int importSessionId, int sceneResultId, string processorVersion, string snapshotHash)
{
using var connection = connectionFactory.CreateConnection();
await connection.ExecuteAsync(
"""
MERGE dbo.StoryMemoryProcessedSceneResults WITH (HOLDLOCK) AS target
USING (SELECT @ImportSessionID AS ImportSessionID, @SceneResultID AS SceneResultID, @ProcessorVersion AS ProcessorVersion) AS source
ON target.ImportSessionID = source.ImportSessionID
AND target.SceneResultID = source.SceneResultID
AND target.ProcessorVersion = source.ProcessorVersion
WHEN MATCHED THEN
UPDATE SET SnapshotHash = @SnapshotHash, ProcessedUtc = SYSUTCDATETIME()
WHEN NOT MATCHED THEN
INSERT (ImportSessionID, SceneResultID, ProcessorVersion, SnapshotHash)
VALUES (@ImportSessionID, @SceneResultID, @ProcessorVersion, @SnapshotHash);
""",
new { ImportSessionID = importSessionId, SceneResultID = sceneResultId, ProcessorVersion = processorVersion, SnapshotHash = snapshotHash });
}
public async Task<StoryMemoryCharacter> UpsertCharacterAsync(StoryMemoryCharacterSave request)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleAsync<StoryMemoryCharacter>(
"""
MERGE dbo.StoryMemoryCharacters WITH (HOLDLOCK) AS target
USING (SELECT @ImportSessionID AS ImportSessionID, @CanonicalIdentityKey AS CanonicalIdentityKey) AS source
ON target.ImportSessionID = source.ImportSessionID
AND target.CanonicalIdentityKey = source.CanonicalIdentityKey
WHEN MATCHED THEN
UPDATE SET
DisplayName = CASE WHEN LEN(@DisplayName) > LEN(target.DisplayName) THEN @DisplayName ELSE target.DisplayName END,
EntityContext = @EntityContext,
SourceSectionType = @SourceSectionType,
IsNamed = CASE WHEN @IsNamed = 1 THEN 1 ELSE target.IsNamed END,
IsGroupEntity = CASE WHEN @IsGroupEntity = 1 THEN 1 ELSE target.IsGroupEntity END,
IsResolved = CASE WHEN @IsResolved = 1 THEN 1 ELSE target.IsResolved END,
IsNarrator = CASE WHEN @IsNarrator = 1 THEN 1 ELSE target.IsNarrator END,
IsPrimaryPOV = CASE WHEN @IsPrimaryPOV = 1 THEN 1 ELSE target.IsPrimaryPOV END,
Significance = CASE WHEN @Significance > target.Significance THEN @Significance ELSE target.Significance END,
LastUpdatedSceneResultID = @SceneResultID,
EvidenceVersion = target.EvidenceVersion + 1,
UpdatedUtc = SYSUTCDATETIME()
WHEN NOT MATCHED THEN
INSERT (ImportSessionID, ProjectID, BookID, CanonicalIdentityKey, DisplayName, EntityContext, SourceSectionType, IsNamed, IsGroupEntity, IsResolved, IsNarrator, IsPrimaryPOV, Significance, CreatedSceneResultID, LastUpdatedSceneResultID)
VALUES (@ImportSessionID, @ProjectID, @BookID, @CanonicalIdentityKey, @DisplayName, @EntityContext, @SourceSectionType, @IsNamed, @IsGroupEntity, @IsResolved, @IsNarrator, @IsPrimaryPOV, @Significance, @SceneResultID, @SceneResultID);
SELECT *
FROM dbo.StoryMemoryCharacters
WHERE ImportSessionID = @ImportSessionID
AND CanonicalIdentityKey = @CanonicalIdentityKey;
""",
request);
}
public async Task AddCharacterAliasAsync(int characterId, string alias, string normalisedAlias, string reason, int sceneResultId, decimal confidence)
{
using var connection = connectionFactory.CreateConnection();
await connection.ExecuteAsync(
"""
MERGE dbo.StoryMemoryCharacterAliases WITH (HOLDLOCK) AS target
USING (SELECT @StoryMemoryCharacterID AS StoryMemoryCharacterID, @NormalisedAlias AS NormalisedAlias, @SourceSceneResultID AS SourceSceneResultID) AS source
ON target.StoryMemoryCharacterID = source.StoryMemoryCharacterID
AND target.NormalisedAlias = source.NormalisedAlias
AND ISNULL(target.SourceSceneResultID, -1) = ISNULL(source.SourceSceneResultID, -1)
WHEN NOT MATCHED THEN
INSERT (StoryMemoryCharacterID, Alias, NormalisedAlias, ResolutionReason, SourceSceneResultID, Confidence)
VALUES (@StoryMemoryCharacterID, @Alias, @NormalisedAlias, @ResolutionReason, @SourceSceneResultID, @Confidence);
""",
new { StoryMemoryCharacterID = characterId, Alias = alias, NormalisedAlias = normalisedAlias, ResolutionReason = reason, SourceSceneResultID = sceneResultId, Confidence = confidence });
}
public async Task AddCharacterAttributeAsync(int characterId, string attributeType, string value, decimal confidence, bool isExplicit, string evidenceSummary, int sceneResultId)
{
using var connection = connectionFactory.CreateConnection();
await connection.ExecuteAsync(
"""
MERGE dbo.StoryMemoryCharacterAttributes WITH (HOLDLOCK) AS target
USING (SELECT @StoryMemoryCharacterID AS StoryMemoryCharacterID, @AttributeType AS AttributeType, @NormalisedValue AS NormalisedValue, @SourceSceneResultID AS SourceSceneResultID) AS source
ON target.StoryMemoryCharacterID = source.StoryMemoryCharacterID
AND target.AttributeType = source.AttributeType
AND target.NormalisedValue = source.NormalisedValue
AND ISNULL(target.SourceSceneResultID, -1) = ISNULL(source.SourceSceneResultID, -1)
AND target.SupersededUtc IS NULL
WHEN MATCHED THEN
UPDATE SET Confidence = CASE WHEN @Confidence > target.Confidence THEN @Confidence ELSE target.Confidence END,
IsExplicit = CASE WHEN @IsExplicit = 1 THEN 1 ELSE target.IsExplicit END,
EvidenceSummary = @EvidenceSummary
WHEN NOT MATCHED THEN
INSERT (StoryMemoryCharacterID, AttributeType, NormalisedValue, Confidence, IsExplicit, EvidenceSummary, SourceSceneResultID)
VALUES (@StoryMemoryCharacterID, @AttributeType, @NormalisedValue, @Confidence, @IsExplicit, @EvidenceSummary, @SourceSceneResultID);
""",
new { StoryMemoryCharacterID = characterId, AttributeType = attributeType, NormalisedValue = value, Confidence = confidence, IsExplicit = isExplicit, EvidenceSummary = evidenceSummary, SourceSceneResultID = sceneResultId });
}
public async Task<StoryMemoryLocation> UpsertLocationAsync(StoryMemoryLocationSave request)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleAsync<StoryMemoryLocation>(
"""
MERGE dbo.StoryMemoryLocations WITH (HOLDLOCK) AS target
USING (SELECT @ImportSessionID AS ImportSessionID, @CanonicalIdentityKey AS CanonicalIdentityKey) AS source
ON target.ImportSessionID = source.ImportSessionID
AND target.CanonicalIdentityKey = source.CanonicalIdentityKey
WHEN MATCHED THEN
UPDATE SET DisplayName = @DisplayName,
SemanticType = @SemanticType,
Subtype = @Subtype,
InteriorExterior = @InteriorExterior,
LastSeenSceneResultID = @SceneResultID,
UpdatedUtc = SYSUTCDATETIME()
WHEN NOT MATCHED THEN
INSERT (ImportSessionID, ProjectID, BookID, CanonicalIdentityKey, DisplayName, SemanticType, Subtype, InteriorExterior, FirstSeenSceneResultID, LastSeenSceneResultID)
VALUES (@ImportSessionID, @ProjectID, @BookID, @CanonicalIdentityKey, @DisplayName, @SemanticType, @Subtype, @InteriorExterior, @SceneResultID, @SceneResultID);
SELECT *
FROM dbo.StoryMemoryLocations
WHERE ImportSessionID = @ImportSessionID AND CanonicalIdentityKey = @CanonicalIdentityKey;
""",
request);
}
public async Task<StoryMemoryAsset> UpsertAssetAsync(StoryMemoryAssetSave request)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleAsync<StoryMemoryAsset>(
"""
MERGE dbo.StoryMemoryAssets WITH (HOLDLOCK) AS target
USING (SELECT @ImportSessionID AS ImportSessionID, @CanonicalIdentityKey AS CanonicalIdentityKey) AS source
ON target.ImportSessionID = source.ImportSessionID
AND target.CanonicalIdentityKey = source.CanonicalIdentityKey
WHEN MATCHED THEN
UPDATE SET DisplayName = @DisplayName,
SemanticType = @SemanticType,
Subtype = @Subtype,
Colour = @Colour,
Era = @Era,
LastSeenSceneResultID = @SceneResultID,
UpdatedUtc = SYSUTCDATETIME()
WHEN NOT MATCHED THEN
INSERT (ImportSessionID, ProjectID, BookID, CanonicalIdentityKey, DisplayName, SemanticType, Subtype, Colour, Era, FirstSeenSceneResultID, LastSeenSceneResultID)
VALUES (@ImportSessionID, @ProjectID, @BookID, @CanonicalIdentityKey, @DisplayName, @SemanticType, @Subtype, @Colour, @Era, @SceneResultID, @SceneResultID);
SELECT *
FROM dbo.StoryMemoryAssets
WHERE ImportSessionID = @ImportSessionID AND CanonicalIdentityKey = @CanonicalIdentityKey;
""",
request);
}
public async Task UpsertRelationshipAsync(int importSessionId, string sourceEntityType, int sourceEntityId, string targetEntityType, int targetEntityId, string relationshipType, decimal confidence, bool isExplicit, int sceneResultId)
{
using var connection = connectionFactory.CreateConnection();
await connection.ExecuteAsync(
"""
MERGE dbo.StoryMemoryRelationships WITH (HOLDLOCK) AS target
USING (SELECT @ImportSessionID AS ImportSessionID, @SourceEntityType AS SourceEntityType, @SourceEntityID AS SourceEntityID, @TargetEntityType AS TargetEntityType, @TargetEntityID AS TargetEntityID, @RelationshipType AS RelationshipType) AS source
ON target.ImportSessionID = source.ImportSessionID
AND target.SourceEntityType = source.SourceEntityType
AND target.SourceEntityID = source.SourceEntityID
AND target.TargetEntityType = source.TargetEntityType
AND target.TargetEntityID = source.TargetEntityID
AND target.RelationshipType = source.RelationshipType
AND target.Active = 1
WHEN MATCHED THEN
UPDATE SET Confidence = CASE WHEN @Confidence > target.Confidence THEN @Confidence ELSE target.Confidence END,
IsExplicit = CASE WHEN @IsExplicit = 1 THEN 1 ELSE target.IsExplicit END,
LastUpdatedSceneResultID = @SceneResultID,
UpdatedUtc = SYSUTCDATETIME()
WHEN NOT MATCHED THEN
INSERT (ImportSessionID, SourceEntityType, SourceEntityID, TargetEntityType, TargetEntityID, RelationshipType, Confidence, IsExplicit, FirstSeenSceneResultID, LastUpdatedSceneResultID)
VALUES (@ImportSessionID, @SourceEntityType, @SourceEntityID, @TargetEntityType, @TargetEntityID, @RelationshipType, @Confidence, @IsExplicit, @SceneResultID, @SceneResultID);
""",
new { ImportSessionID = importSessionId, SourceEntityType = sourceEntityType, SourceEntityID = sourceEntityId, TargetEntityType = targetEntityType, TargetEntityID = targetEntityId, RelationshipType = relationshipType, Confidence = confidence, IsExplicit = isExplicit, SceneResultID = sceneResultId });
}
public async Task<IReadOnlyList<StoryMemoryCharacter>> ListCharactersAsync(int importSessionId)
{
using var connection = connectionFactory.CreateConnection();
return (await connection.QueryAsync<StoryMemoryCharacter>("SELECT * FROM dbo.StoryMemoryCharacters WHERE ImportSessionID = @ImportSessionID ORDER BY Significance DESC, DisplayName;", new { ImportSessionID = importSessionId })).ToList();
}
public async Task<IReadOnlyList<StoryMemoryCharacterAttribute>> ListCharacterAttributesAsync(int importSessionId)
{
using var connection = connectionFactory.CreateConnection();
return (await connection.QueryAsync<StoryMemoryCharacterAttribute>(
"""
SELECT a.*
FROM dbo.StoryMemoryCharacterAttributes a
INNER JOIN dbo.StoryMemoryCharacters c ON c.StoryMemoryCharacterID = a.StoryMemoryCharacterID
WHERE c.ImportSessionID = @ImportSessionID
AND a.SupersededUtc IS NULL;
""",
new { ImportSessionID = importSessionId })).ToList();
}
public async Task<IReadOnlyList<StoryMemoryLocation>> ListLocationsAsync(int importSessionId)
{
using var connection = connectionFactory.CreateConnection();
return (await connection.QueryAsync<StoryMemoryLocation>("SELECT * FROM dbo.StoryMemoryLocations WHERE ImportSessionID = @ImportSessionID ORDER BY DisplayName;", new { ImportSessionID = importSessionId })).ToList();
}
public async Task<IReadOnlyList<StoryMemoryAsset>> ListAssetsAsync(int importSessionId)
{
using var connection = connectionFactory.CreateConnection();
return (await connection.QueryAsync<StoryMemoryAsset>("SELECT * FROM dbo.StoryMemoryAssets WHERE ImportSessionID = @ImportSessionID ORDER BY DisplayName;", new { ImportSessionID = importSessionId })).ToList();
}
public async Task<IReadOnlyList<StoryMemoryIllustrationAssignment>> ListAssignmentsAsync(int importSessionId)
{
using var connection = connectionFactory.CreateConnection();
return (await connection.QueryAsync<StoryMemoryIllustrationAssignment>("SELECT * FROM dbo.StoryMemoryIllustrationAssignments WHERE ImportSessionID = @ImportSessionID AND InvalidatedUtc IS NULL;", new { ImportSessionID = importSessionId })).ToList();
}
public async Task<StoryMemoryIllustrationAssignment?> GetAssignmentAsync(int importSessionId, string entityType, int entityId)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleOrDefaultAsync<StoryMemoryIllustrationAssignment>(
"SELECT * FROM dbo.StoryMemoryIllustrationAssignments WHERE ImportSessionID = @ImportSessionID AND EntityType = @EntityType AND EntityID = @EntityID AND InvalidatedUtc IS NULL;",
new { ImportSessionID = importSessionId, EntityType = entityType, EntityID = entityId });
}
public async Task UpsertAssignmentAsync(StoryMemoryIllustrationAssignmentSave request)
{
using var connection = connectionFactory.CreateConnection();
await connection.ExecuteAsync(
"""
MERGE dbo.StoryMemoryIllustrationAssignments WITH (HOLDLOCK) AS target
USING (SELECT @ImportSessionID AS ImportSessionID, @EntityType AS EntityType, @EntityID AS EntityID) AS source
ON target.ImportSessionID = source.ImportSessionID
AND target.EntityType = source.EntityType
AND target.EntityID = source.EntityID
AND target.InvalidatedUtc IS NULL
WHEN MATCHED THEN
UPDATE SET IllustrationLibraryItemID = @IllustrationLibraryItemID,
StableCode = @StableCode,
AssignmentStatus = @AssignmentStatus,
IsFallback = @IsFallback,
MatchScore = @MatchScore,
Confidence = @Confidence,
AssignmentReason = @AssignmentReason,
EvidenceVersion = @EvidenceVersion,
LastValidatedUtc = SYSUTCDATETIME(),
UpdatedUtc = SYSUTCDATETIME()
WHEN NOT MATCHED THEN
INSERT (ImportSessionID, EntityType, EntityID, IllustrationLibraryItemID, StableCode, AssignmentStatus, IsFallback, MatchScore, Confidence, AssignmentReason, EvidenceVersion)
VALUES (@ImportSessionID, @EntityType, @EntityID, @IllustrationLibraryItemID, @StableCode, @AssignmentStatus, @IsFallback, @MatchScore, @Confidence, @AssignmentReason, @EvidenceVersion);
""",
request);
}
public async Task InvalidateAssignmentAsync(int assignmentId, string reason)
{
using var connection = connectionFactory.CreateConnection();
await connection.ExecuteAsync(
"""
UPDATE dbo.StoryMemoryIllustrationAssignments
SET InvalidatedUtc = SYSUTCDATETIME(),
InvalidationReason = @Reason,
UpdatedUtc = SYSUTCDATETIME()
WHERE StoryMemoryIllustrationAssignmentID = @AssignmentID
AND InvalidatedUtc IS NULL;
""",
new { AssignmentID = assignmentId, Reason = reason });
}
public async Task<StoryMemoryIllustrationDemand> UpsertDemandAsync(StoryMemoryIllustrationDemandSave request)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleAsync<StoryMemoryIllustrationDemand>(
"""
MERGE dbo.StoryMemoryIllustrationDemands WITH (HOLDLOCK) AS target
USING (SELECT @ImportSessionID AS ImportSessionID, @EntityType AS EntityType, @ArchetypeKey AS ArchetypeKey) AS source
ON target.ImportSessionID = source.ImportSessionID
AND target.EntityType = source.EntityType
AND target.ArchetypeKey = source.ArchetypeKey
WHEN MATCHED THEN
UPDATE SET DemandCount = DemandCount + 1,
WaitingEntityCount = @WaitingEntityCount,
Status = @Status,
QueuedIllustrationLibraryItemID = COALESCE(@QueuedIllustrationLibraryItemID, target.QueuedIllustrationLibraryItemID),
GeneratedIllustrationLibraryItemID = COALESCE(@GeneratedIllustrationLibraryItemID, target.GeneratedIllustrationLibraryItemID),
UpdatedUtc = SYSUTCDATETIME()
WHEN NOT MATCHED THEN
INSERT (ImportSessionID, ProjectID, EntityType, ArchetypeKey, WaitingEntityCount, Status, QueuedIllustrationLibraryItemID, GeneratedIllustrationLibraryItemID)
VALUES (@ImportSessionID, @ProjectID, @EntityType, @ArchetypeKey, @WaitingEntityCount, @Status, @QueuedIllustrationLibraryItemID, @GeneratedIllustrationLibraryItemID);
SELECT *
FROM dbo.StoryMemoryIllustrationDemands
WHERE ImportSessionID = @ImportSessionID AND EntityType = @EntityType AND ArchetypeKey = @ArchetypeKey;
""",
request);
}
public async Task<string> GetAppearancePreferenceAsync(int importSessionId)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleOrDefaultAsync<string>(
"SELECT AppearancePreference FROM dbo.StoryMemoryImportPreferences WHERE ImportSessionID = @ImportSessionID;",
new { ImportSessionID = importSessionId })
?? StoryMemoryAppearancePreferences.Balanced;
}
public async Task SetAppearancePreferenceAsync(int importSessionId, string preference)
{
using var connection = connectionFactory.CreateConnection();
await connection.ExecuteAsync(
"""
MERGE dbo.StoryMemoryImportPreferences WITH (HOLDLOCK) AS target
USING (SELECT @ImportSessionID AS ImportSessionID) AS source
ON target.ImportSessionID = source.ImportSessionID
WHEN MATCHED THEN
UPDATE SET AppearancePreference = @AppearancePreference, UpdatedUtc = SYSUTCDATETIME()
WHEN NOT MATCHED THEN
INSERT (ImportSessionID, AppearancePreference)
VALUES (@ImportSessionID, @AppearancePreference);
""",
new { ImportSessionID = importSessionId, AppearancePreference = preference });
}
public async Task<StoryMemoryDiagnostics> GetDiagnosticsAsync(int importSessionId)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleAsync<StoryMemoryDiagnostics>(
"""
SELECT
@ImportSessionID AS ImportSessionID,
(SELECT COUNT(*) FROM dbo.StoryMemoryCharacters WHERE ImportSessionID = @ImportSessionID) AS CharacterCount,
(SELECT COUNT(*) FROM dbo.StoryMemoryLocations WHERE ImportSessionID = @ImportSessionID) AS LocationCount,
(SELECT COUNT(*) FROM dbo.StoryMemoryAssets WHERE ImportSessionID = @ImportSessionID) AS AssetCount,
(SELECT COUNT(*) FROM dbo.StoryMemoryCharacterAliases a INNER JOIN dbo.StoryMemoryCharacters c ON c.StoryMemoryCharacterID = a.StoryMemoryCharacterID WHERE c.ImportSessionID = @ImportSessionID) AS AliasCount,
(SELECT COUNT(*) FROM dbo.StoryMemoryCharacterAttributes a INNER JOIN dbo.StoryMemoryCharacters c ON c.StoryMemoryCharacterID = a.StoryMemoryCharacterID WHERE c.ImportSessionID = @ImportSessionID AND a.SupersededUtc IS NULL) AS AttributeCount,
(SELECT COUNT(*) FROM dbo.StoryMemoryRelationships WHERE ImportSessionID = @ImportSessionID AND Active = 1) AS RelationshipCount,
(SELECT COUNT(*) FROM dbo.StoryMemoryIllustrationAssignments WHERE ImportSessionID = @ImportSessionID AND InvalidatedUtc IS NULL) AS AssignmentCount,
(SELECT COUNT(*) FROM dbo.StoryMemoryIllustrationAssignments WHERE ImportSessionID = @ImportSessionID AND InvalidatedUtc IS NULL AND IsFallback = 1) AS FallbackCount,
(SELECT COUNT(*) FROM dbo.StoryMemoryIllustrationDemands WHERE ImportSessionID = @ImportSessionID) AS DemandCount,
(SELECT COUNT(*) FROM dbo.StoryMemoryProcessedSceneResults WHERE ImportSessionID = @ImportSessionID) AS ProcessedSceneCount,
(SELECT MAX(SceneResultID) FROM dbo.StoryMemoryProcessedSceneResults WHERE ImportSessionID = @ImportSessionID) AS LastProcessedSceneResultID,
COALESCE((SELECT AppearancePreference FROM dbo.StoryMemoryImportPreferences WHERE ImportSessionID = @ImportSessionID), N'Balanced') AS AppearancePreference;
""",
new { ImportSessionID = importSessionId });
}
}

View File

@ -14,6 +14,14 @@ public sealed class StoryIntelligenceCharacterIllustrationAssignment
public string? CandidateDiagnosticsJson { get; init; } public string? CandidateDiagnosticsJson { get; init; }
public string? ReasonChosen { get; init; } public string? ReasonChosen { get; init; }
public string? ReasonReassigned { get; init; } public string? ReasonReassigned { get; init; }
public int? ImportSessionID { get; init; }
public string AssignmentStatus { get; init; } = "Assigned";
public DateTime? AssignedUtc { get; init; }
public DateTime? LastValidatedUtc { get; init; }
public DateTime? InvalidatedUtc { get; init; }
public string? InvalidationReason { get; init; }
public string EvidenceVersion { get; init; } = "21K";
public bool IsFallback { get; init; }
public bool IsActive { get; init; } public bool IsActive { get; init; }
public DateTime CreatedUtc { get; init; } public DateTime CreatedUtc { get; init; }
public DateTime UpdatedUtc { get; init; } public DateTime UpdatedUtc { get; init; }
@ -46,6 +54,8 @@ public sealed class StoryIntelligenceIllustrationDemand
public string? FacialHair { get; init; } public string? FacialHair { get; init; }
public int RequestCount { get; init; } public int RequestCount { get; init; }
public DateTime LastRequestedUtc { get; init; } public DateTime LastRequestedUtc { get; init; }
public DateTime? QueuedUtc { get; init; }
public string GenerationSource { get; init; } = "Demand generation";
} }
public sealed record StoryIntelligenceIllustrationDemandRequest( public sealed record StoryIntelligenceIllustrationDemandRequest(

View File

@ -0,0 +1,231 @@
namespace PlotLine.Models;
public static class StoryMemoryEntityTypes
{
public const string Character = "Character";
public const string Location = "Location";
public const string Asset = "Asset";
}
public static class StoryMemoryAppearancePreferences
{
public const string ExplicitEvidenceOnly = "ExplicitEvidenceOnly";
public const string Balanced = "Balanced";
public const string PredominantlyLight = "PredominantlyLight";
public const string PredominantlyMedium = "PredominantlyMedium";
public const string PredominantlyDark = "PredominantlyDark";
public const string CustomMix = "CustomMix";
}
public sealed class StoryMemoryCharacter
{
public int StoryMemoryCharacterID { get; init; }
public int ImportSessionID { get; init; }
public int ProjectID { get; init; }
public int BookID { get; init; }
public string CanonicalIdentityKey { get; init; } = string.Empty;
public string DisplayName { get; init; } = string.Empty;
public string EntityContext { get; init; } = "Story";
public string SourceSectionType { get; init; } = "Story";
public bool IsNamed { get; init; }
public bool IsGroupEntity { get; init; }
public bool IsResolved { get; init; }
public bool IsNarrator { get; init; }
public bool IsPrimaryPOV { get; init; }
public int Significance { get; init; }
public int EvidenceVersion { get; init; }
public int? CreatedSceneResultID { get; init; }
public int? LastUpdatedSceneResultID { get; init; }
public DateTime CreatedUtc { get; init; }
public DateTime UpdatedUtc { get; init; }
}
public sealed record StoryMemoryCharacterSave(
int ImportSessionID,
int ProjectID,
int BookID,
string CanonicalIdentityKey,
string DisplayName,
string EntityContext,
string SourceSectionType,
bool IsNamed,
bool IsGroupEntity,
bool IsResolved,
bool IsNarrator,
bool IsPrimaryPOV,
int Significance,
int SceneResultID);
public sealed class StoryMemoryCharacterAttribute
{
public int StoryMemoryCharacterAttributeID { get; init; }
public int StoryMemoryCharacterID { get; init; }
public string AttributeType { get; init; } = string.Empty;
public string NormalisedValue { get; init; } = string.Empty;
public decimal Confidence { get; init; }
public bool IsExplicit { get; init; }
public string? EvidenceSummary { get; init; }
public int? SourceSceneResultID { get; init; }
}
public sealed class StoryMemoryLocation
{
public int StoryMemoryLocationID { get; init; }
public int ImportSessionID { get; init; }
public int ProjectID { get; init; }
public int BookID { get; init; }
public string CanonicalIdentityKey { get; init; } = string.Empty;
public string DisplayName { get; init; } = string.Empty;
public string SemanticType { get; init; } = string.Empty;
public string? Subtype { get; init; }
public string? InteriorExterior { get; init; }
public int? ParentLocationID { get; init; }
public int? FirstSeenSceneResultID { get; init; }
public int? LastSeenSceneResultID { get; init; }
}
public sealed record StoryMemoryLocationSave(
int ImportSessionID,
int ProjectID,
int BookID,
string CanonicalIdentityKey,
string DisplayName,
string SemanticType,
string? Subtype,
string? InteriorExterior,
int SceneResultID);
public sealed class StoryMemoryAsset
{
public int StoryMemoryAssetID { get; init; }
public int ImportSessionID { get; init; }
public int ProjectID { get; init; }
public int BookID { get; init; }
public string CanonicalIdentityKey { get; init; } = string.Empty;
public string DisplayName { get; init; } = string.Empty;
public string SemanticType { get; init; } = string.Empty;
public string? Subtype { get; init; }
public string? Colour { get; init; }
public string? Era { get; init; }
public int? FirstSeenSceneResultID { get; init; }
public int? LastSeenSceneResultID { get; init; }
}
public sealed record StoryMemoryAssetSave(
int ImportSessionID,
int ProjectID,
int BookID,
string CanonicalIdentityKey,
string DisplayName,
string SemanticType,
string? Subtype,
string? Colour,
string? Era,
int SceneResultID);
public sealed class StoryMemoryIllustrationAssignment
{
public int StoryMemoryIllustrationAssignmentID { get; init; }
public int ImportSessionID { get; init; }
public string EntityType { get; init; } = string.Empty;
public int EntityID { get; init; }
public int? IllustrationLibraryItemID { get; init; }
public string? StableCode { get; init; }
public string AssignmentStatus { get; init; } = string.Empty;
public bool IsFallback { get; init; }
public int MatchScore { get; init; }
public decimal Confidence { get; init; }
public string? AssignmentReason { get; init; }
public string EvidenceVersion { get; init; } = "21S";
public DateTime AssignedUtc { get; init; }
public DateTime LastValidatedUtc { get; init; }
public DateTime? InvalidatedUtc { get; init; }
public string? InvalidationReason { get; init; }
}
public sealed record StoryMemoryIllustrationAssignmentSave(
int ImportSessionID,
string EntityType,
int EntityID,
int? IllustrationLibraryItemID,
string? StableCode,
string AssignmentStatus,
bool IsFallback,
int MatchScore,
decimal Confidence,
string AssignmentReason,
string EvidenceVersion);
public sealed class StoryMemoryIllustrationDemand
{
public int StoryMemoryIllustrationDemandID { get; init; }
public int ImportSessionID { get; init; }
public int ProjectID { get; init; }
public string EntityType { get; init; } = string.Empty;
public string ArchetypeKey { get; init; } = string.Empty;
public int DemandCount { get; init; }
public int WaitingEntityCount { get; init; }
public string Status { get; init; } = string.Empty;
public int? QueuedIllustrationLibraryItemID { get; init; }
public int? GeneratedIllustrationLibraryItemID { get; init; }
}
public sealed record StoryMemoryIllustrationDemandSave(
int ImportSessionID,
int ProjectID,
string EntityType,
string ArchetypeKey,
int WaitingEntityCount,
string Status,
int? QueuedIllustrationLibraryItemID,
int? GeneratedIllustrationLibraryItemID);
public sealed class StoryMemoryDiagnostics
{
public int ImportSessionID { get; init; }
public int CharacterCount { get; init; }
public int LocationCount { get; init; }
public int AssetCount { get; init; }
public int AliasCount { get; init; }
public int AttributeCount { get; init; }
public int RelationshipCount { get; init; }
public int AssignmentCount { get; init; }
public int FallbackCount { get; init; }
public int DemandCount { get; init; }
public int ProcessedSceneCount { get; init; }
public int? LastProcessedSceneResultID { get; init; }
public string AppearancePreference { get; init; } = StoryMemoryAppearancePreferences.Balanced;
}
public sealed class StoryMemoryRebuildResult
{
public int ImportSessionID { get; init; }
public bool DryRun { get; init; }
public int SceneResultsRead { get; init; }
public int SceneResultsProcessed { get; init; }
public int CharactersDiscovered { get; init; }
public int LocationsDiscovered { get; init; }
public int AssetsDiscovered { get; init; }
public int RelationshipsDiscovered { get; init; }
public int AssignmentsCreatedOrValidated { get; init; }
public int DemandsCreated { get; init; }
public int FallbacksRemaining { get; init; }
public int AiCalls { get; init; }
public IReadOnlyList<string> Warnings { get; init; } = [];
}
public sealed class StoryMemoryRebuildOptions
{
public bool DryRun { get; init; }
public bool RebuildAll { get; init; }
public bool QueueDemand { get; init; }
public int? MaxDemandToQueue { get; init; }
public string AppearancePreference { get; init; } = StoryMemoryAppearancePreferences.Balanced;
}
public sealed class StoryMemoryResolveOptions
{
public bool QueueDemand { get; init; }
public int? MaxDemandToQueue { get; init; }
public string AppearancePreference { get; init; } = StoryMemoryAppearancePreferences.Balanced;
}

View File

@ -47,8 +47,12 @@ public class Program
builder.Configuration.GetSection("StoryIntelligence").Bind(options); builder.Configuration.GetSection("StoryIntelligence").Bind(options);
}); });
builder.Services.Configure<StoryIntelligencePricingOptions>(builder.Configuration.GetSection("StoryIntelligence:Pricing")); builder.Services.Configure<StoryIntelligencePricingOptions>(builder.Configuration.GetSection("StoryIntelligence:Pricing"));
var dataProtectionKeyPath = GetDataProtectionKeyPath(
builder.Environment.ContentRootPath,
builder.Configuration["DataProtection:KeysPath"]);
Directory.CreateDirectory(dataProtectionKeyPath);
builder.Services.AddDataProtection() builder.Services.AddDataProtection()
.PersistKeysToFileSystem(new DirectoryInfo(Path.Combine(builder.Environment.ContentRootPath, "App_Data", "DataProtectionKeys"))); .PersistKeysToFileSystem(new DirectoryInfo(dataProtectionKeyPath));
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie(options => .AddCookie(options =>
{ {
@ -145,6 +149,7 @@ public class Program
builder.Services.AddScoped<IStoryIntelligenceSourceRepository, StoryIntelligenceSourceRepository>(); builder.Services.AddScoped<IStoryIntelligenceSourceRepository, StoryIntelligenceSourceRepository>();
builder.Services.AddScoped<IStoryIntelligencePipelineRepository, StoryIntelligencePipelineRepository>(); builder.Services.AddScoped<IStoryIntelligencePipelineRepository, StoryIntelligencePipelineRepository>();
builder.Services.AddScoped<IStoryIntelligenceIllustrationMatchingRepository, StoryIntelligenceIllustrationMatchingRepository>(); builder.Services.AddScoped<IStoryIntelligenceIllustrationMatchingRepository, StoryIntelligenceIllustrationMatchingRepository>();
builder.Services.AddScoped<IStoryMemoryRepository, StoryMemoryRepository>();
builder.Services.AddScoped<IIllustrationLibraryRepository, IllustrationLibraryRepository>(); builder.Services.AddScoped<IIllustrationLibraryRepository, IllustrationLibraryRepository>();
builder.Services.AddScoped<IProjectAccessRepository, ProjectAccessRepository>(); builder.Services.AddScoped<IProjectAccessRepository, ProjectAccessRepository>();
builder.Services.AddScoped<IProjectCollaborationRepository, ProjectCollaborationRepository>(); builder.Services.AddScoped<IProjectCollaborationRepository, ProjectCollaborationRepository>();
@ -215,6 +220,7 @@ public class Program
builder.Services.AddScoped<IStoryIntelligenceRelationshipImportService, StoryIntelligenceRelationshipImportService>(); builder.Services.AddScoped<IStoryIntelligenceRelationshipImportService, StoryIntelligenceRelationshipImportService>();
builder.Services.AddScoped<IStoryIntelligenceKnowledgeImportService, StoryIntelligenceKnowledgeImportService>(); builder.Services.AddScoped<IStoryIntelligenceKnowledgeImportService, StoryIntelligenceKnowledgeImportService>();
builder.Services.AddScoped<IStoryIntelligenceVisualisationSnapshotService, StoryIntelligenceVisualisationSnapshotService>(); builder.Services.AddScoped<IStoryIntelligenceVisualisationSnapshotService, StoryIntelligenceVisualisationSnapshotService>();
builder.Services.AddScoped<IStoryMemoryService, StoryMemoryService>();
builder.Services.AddScoped<IStoryIntelligencePipelineStateService, StoryIntelligencePipelineStateService>(); builder.Services.AddScoped<IStoryIntelligencePipelineStateService, StoryIntelligencePipelineStateService>();
builder.Services.AddScoped<IStoryIntelligenceProgressNotifier, StoryIntelligenceProgressNotifier>(); builder.Services.AddScoped<IStoryIntelligenceProgressNotifier, StoryIntelligenceProgressNotifier>();
builder.Services.AddScoped<ISceneImportResolver, SceneImportResolver>(); builder.Services.AddScoped<ISceneImportResolver, SceneImportResolver>();
@ -275,4 +281,21 @@ public class Program
app.Run(); app.Run();
} }
private static string GetDataProtectionKeyPath(string contentRootPath, string? configuredPath)
{
if (!string.IsNullOrWhiteSpace(configuredPath))
{
return Path.GetFullPath(configuredPath);
}
var contentRoot = new DirectoryInfo(contentRootPath);
if (string.Equals(contentRoot.Name, "current", StringComparison.OrdinalIgnoreCase)
&& contentRoot.Parent is not null)
{
return Path.Combine(contentRoot.Parent.FullName, "App_Data", "DataProtectionKeys");
}
return Path.Combine(contentRoot.FullName, "App_Data", "DataProtectionKeys");
}
} }

View File

@ -16,7 +16,7 @@ public interface IIllustrationPromptBuilder
public sealed class IllustrationPromptBuilder : IIllustrationPromptBuilder public sealed class IllustrationPromptBuilder : IIllustrationPromptBuilder
{ {
public const string CurrentTemplateVersion = "21D.2"; public const string CurrentTemplateVersion = "21J.3";
private static readonly JsonSerializerOptions JsonOptions = new() private static readonly JsonSerializerOptions JsonOptions = new()
{ {
@ -73,14 +73,15 @@ public sealed class IllustrationPromptBuilder : IIllustrationPromptBuilder
Requirements: Requirements:
- representative visual identifier only, not a canonical depiction of any manuscript - representative visual identifier only, not a canonical depiction of any manuscript
- modern cinematic digital illustration with premium narrative game concept art quality - modern cinematic digital illustration with premium narrative game concept art quality
- vivid natural colour, contemporary polished rendering, realistic anatomy and believable proportions - subdued atmospheric background treatment with contemporary polished rendering, realistic anatomy and believable proportions
- bright balanced lighting, clear facial illumination where faces are present, and strong local contrast - balanced cinematic lighting, clear facial illumination where faces are present, and strong local contrast
- crisp subject definition with strong foreground-background separation and clean readable silhouettes - crisp subject definition with strong foreground-background separation and clean readable silhouettes
- natural but richer colour with bright blues, reds, greens, and warm skin tones where appropriate - natural controlled colour with rich but restrained accents and warm skin tones where appropriate
- polished promotional artwork for a premium story-driven adventure game, not a literal photograph - polished promotional artwork for a premium story-driven adventure game, not a literal photograph
- 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
- 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
- square composition that still reads clearly as a small thumbnail - square composition that still reads clearly as a small thumbnail
@ -100,7 +101,7 @@ public sealed class IllustrationPromptBuilder : IIllustrationPromptBuilder
private static string CategoryRules(string category) private static string CategoryRules(string category)
=> category switch => category switch
{ {
IllustrationLibraryCategories.Character => "Square image; head-and-shoulders or upper body; one person only; face-forward enough to read at small size; naturally expressive; cleanly lit face; distinct from background; suitable for circular cropping; avoid dark collars vanishing into black backgrounds.", IllustrationLibraryCategories.Character => "Square image; head-and-shoulders or upper body; one person only; face-forward enough to read at small size; naturally expressive; cleanly lit face; distinct from a subtle atmospheric story background; suitable for circular cropping; avoid bright flat studio backgrounds, plain colour blocks, dark collars vanishing into black backgrounds, and photographic headshot styling.",
IllustrationLibraryCategories.Location => "One clear focal environment; vivid and readable; clearly lit and visually inviting even when dramatic; rich colour and depth; strong composition for circular and landscape crops; no prominent people; no text or readable signage; do not default every location to nighttime, rain-soaked, blue-black, ominous, or haunted-looking.", IllustrationLibraryCategories.Location => "One clear focal environment; vivid and readable; clearly lit and visually inviting even when dramatic; rich colour and depth; strong composition for circular and landscape crops; no prominent people; no text or readable signage; do not default every location to nighttime, rain-soaked, blue-black, ominous, or haunted-looking.",
IllustrationLibraryCategories.Asset => "Square image; one dominant centred object; bright clean high-contrast presentation; crisp silhouette; clear separation from background; immediately recognisable at small size; entire object visible where practical.", IllustrationLibraryCategories.Asset => "Square image; one dominant centred object; bright clean high-contrast presentation; crisp silhouette; clear separation from background; immediately recognisable at small size; entire object visible where practical.",
_ => string.Empty _ => string.Empty
@ -219,7 +220,8 @@ public static class IllustrationStarterBatchDefinition
{ {
["libraryPurpose"] = "Story Intelligence representative identifier", ["libraryPurpose"] = "Story Intelligence representative identifier",
["privacy"] = "generic-no-manuscript-data", ["privacy"] = "generic-no-manuscript-data",
["style"] = "editorial-stylised-realism" ["style"] = "editorial-stylised-realism",
["generationSource"] = "Starter batch"
}; };
var common = new IllustrationGenerationSpecification var common = new IllustrationGenerationSpecification
@ -923,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)

View File

@ -253,6 +253,7 @@ public sealed class OnboardingStoryIntelligenceService(
SourceWordCount = run.SourceWordCount, SourceWordCount = run.SourceWordCount,
TotalTokens = run.TotalTokens, TotalTokens = run.TotalTokens,
TotalDurationMs = run.TotalDurationMs, TotalDurationMs = run.TotalDurationMs,
UpdatedUtc = run.UpdatedUtc,
EstimatedCostUSD = run.EstimatedCostUSD, EstimatedCostUSD = run.EstimatedCostUSD,
EstimatedCostGBP = run.EstimatedCostGBP, EstimatedCostGBP = run.EstimatedCostGBP,
FailureStage = run.FailureStage, FailureStage = run.FailureStage,
@ -579,8 +580,35 @@ public sealed class OnboardingStoryIntelligenceService(
return null; return null;
} }
var committedRuns = await pipelineState.ListCommittedRunsByBookAsync(bookId, userId); var committedRuns = (await pipelineState.ListCommittedRunsByBookAsync(bookId, userId))
if (committedRuns.Count == 0) .OrderBy(run => run.ChapterNumber)
.ThenBy(run => run.StoryIntelligenceRunID)
.Select(run => new OnboardingStoryIntelligenceBatchItem
{
TemporaryChapterKey = $"book-{state.BookID}-chapter-{run.ChapterID}",
ChapterNumber = Convert.ToInt32(run.ChapterNumber),
ChapterTitle = run.ChapterTitle,
ChapterID = run.ChapterID,
RunID = run.StoryIntelligenceRunID
})
.ToList();
var activeRuns = committedRuns.Count > 0
? []
: (await runs.ListRunsByBookForUserAsync(bookId, userId))
.Where(run => run.BookID == state.BookID)
.OrderBy(run => run.ChapterNumber ?? decimal.MaxValue)
.ThenBy(run => run.StoryIntelligenceRunID)
.Select(run => new OnboardingStoryIntelligenceBatchItem
{
TemporaryChapterKey = $"book-{state.BookID}-chapter-{run.ChapterID ?? run.StoryIntelligenceRunID}",
ChapterNumber = Convert.ToInt32(run.ChapterNumber ?? 0),
ChapterTitle = FirstConfigured(run.SourceFileName, run.CurrentMessage, run.ChapterNumber.HasValue ? $"Chapter {run.ChapterNumber:0.##}" : $"Run {run.StoryIntelligenceRunID:N0}")!,
ChapterID = run.ChapterID ?? 0,
RunID = run.StoryIntelligenceRunID
})
.ToList();
var batchItems = committedRuns.Count > 0 ? committedRuns : activeRuns;
if (batchItems.Count == 0)
{ {
return null; return null;
} }
@ -594,18 +622,7 @@ public sealed class OnboardingStoryIntelligenceService(
BookID = state.BookID, BookID = state.BookID,
ProjectName = state.ProjectName, ProjectName = state.ProjectName,
BookTitle = state.BookDisplayTitle, BookTitle = state.BookDisplayTitle,
Items = committedRuns Items = batchItems
.OrderBy(run => run.ChapterNumber)
.ThenBy(run => run.StoryIntelligenceRunID)
.Select(run => new OnboardingStoryIntelligenceBatchItem
{
TemporaryChapterKey = $"book-{state.BookID}-chapter-{run.ChapterID}",
ChapterNumber = Convert.ToInt32(run.ChapterNumber),
ChapterTitle = run.ChapterTitle,
ChapterID = run.ChapterID,
RunID = run.StoryIntelligenceRunID
})
.ToList()
}; };
var characterStageComplete = HasCompletedStage(state, StoryIntelligencePipelineStages.CharacterImport) var characterStageComplete = HasCompletedStage(state, StoryIntelligencePipelineStages.CharacterImport)

View File

@ -13,6 +13,7 @@ public interface IPersistedStoryIntelligenceRunner
public sealed class PersistedStoryIntelligenceRunner( public sealed class PersistedStoryIntelligenceRunner(
IStoryIntelligenceResultRepository repository, IStoryIntelligenceResultRepository repository,
IStoryIntelligencePipelineRepository pipelines,
IStoryPromptRepository prompts, IStoryPromptRepository prompts,
IStoryPromptVersionService versions, IStoryPromptVersionService versions,
IStoryPromptBuilder scenePromptBuilder, IStoryPromptBuilder scenePromptBuilder,
@ -22,6 +23,7 @@ public sealed class PersistedStoryIntelligenceRunner(
IOptions<StoryIntelligenceOptions> options, IOptions<StoryIntelligenceOptions> options,
IOptions<StoryIntelligencePricingOptions> pricingOptions, IOptions<StoryIntelligencePricingOptions> pricingOptions,
IStoryIntelligenceProgressNotifier notifier, IStoryIntelligenceProgressNotifier notifier,
IStoryMemoryService storyMemory,
ILogger<PersistedStoryIntelligenceRunner> logger) : IPersistedStoryIntelligenceRunner ILogger<PersistedStoryIntelligenceRunner> logger) : IPersistedStoryIntelligenceRunner
{ {
private const string ChapterPromptFile = "Chapter-Structure-Prompt-V2.md"; private const string ChapterPromptFile = "Chapter-Structure-Prompt-V2.md";
@ -304,7 +306,7 @@ public sealed class PersistedStoryIntelligenceRunner(
hasWarnings = hasWarnings || validation.Warnings.Count > 0; hasWarnings = hasWarnings || validation.Warnings.Count > 0;
await repository.SaveSceneResultAsync( var sceneResultId = await repository.SaveSceneResultAsync(
run.StoryIntelligenceRunID, run.StoryIntelligenceRunID,
chapterResultId, chapterResultId,
new StoryIntelligenceSceneResultSaveRequest new StoryIntelligenceSceneResultSaveRequest
@ -329,6 +331,30 @@ public sealed class PersistedStoryIntelligenceRunner(
TotalTokens = SumTokens(sceneAttempt.InputTokens, sceneAttempt.OutputTokens), TotalTokens = SumTokens(sceneAttempt.InputTokens, sceneAttempt.OutputTokens),
DurationMs = Convert.ToInt64(sceneAttempt.Duration.TotalMilliseconds) DurationMs = Convert.ToInt64(sceneAttempt.Duration.TotalMilliseconds)
}); });
var importSessionId = run.BookID.HasValue ? (await pipelines.GetByBookAsync(run.BookID.Value))?.StoryIntelligenceBookPipelineID : null;
logger.LogInformation(
"Story Intelligence importer synchronisation: import session {ImportSessionId}; run {RunId}; current chapter {ChapterNumber}; current scene {SceneNumber}; scene result {SceneResultId}; snapshot version ImportPipeline; change token n/a; status analysed.",
importSessionId,
run.StoryIntelligenceRunID,
run.ChapterNumber,
block.TemporarySceneNumber,
sceneResultId);
if (importSessionId.HasValue)
{
try
{
await storyMemory.ProcessSceneResultAsync(
importSessionId.Value,
run.UserID,
sceneResultId,
new StoryMemoryResolveOptions { QueueDemand = false },
cancellationToken);
}
catch (Exception ex) when (ex is not OperationCanceledException && !IsFatal(ex))
{
logger.LogError(ex, "Story Memory processing failed for import session {ImportSessionId}, scene result {SceneResultId}.", importSessionId, sceneResultId);
}
}
completedScenes++; completedScenes++;
await PublishAsync( await PublishAsync(
@ -721,7 +747,7 @@ public sealed class PersistedStoryIntelligenceRunner(
string message, string message,
AiJsonParseException? parseException = null) AiJsonParseException? parseException = null)
{ {
await repository.SaveSceneResultAsync( var sceneResultId = await repository.SaveSceneResultAsync(
run.StoryIntelligenceRunID, run.StoryIntelligenceRunID,
chapterResultId, chapterResultId,
new StoryIntelligenceSceneResultSaveRequest new StoryIntelligenceSceneResultSaveRequest
@ -752,6 +778,14 @@ public sealed class PersistedStoryIntelligenceRunner(
TotalTokens = SumTokens(parseException?.InputTokens, parseException?.OutputTokens), TotalTokens = SumTokens(parseException?.InputTokens, parseException?.OutputTokens),
DurationMs = parseException is null ? null : Convert.ToInt64(parseException.Duration.TotalMilliseconds) DurationMs = parseException is null ? null : Convert.ToInt64(parseException.Duration.TotalMilliseconds)
}); });
var importSessionId = run.BookID.HasValue ? (await pipelines.GetByBookAsync(run.BookID.Value))?.StoryIntelligenceBookPipelineID : null;
logger.LogInformation(
"Story Intelligence importer synchronisation: import session {ImportSessionId}; run {RunId}; current chapter {ChapterNumber}; current scene {SceneNumber}; scene result {SceneResultId}; snapshot version ImportPipeline; change token n/a; status failed.",
importSessionId,
run.StoryIntelligenceRunID,
run.ChapterNumber,
block.TemporarySceneNumber,
sceneResultId);
} }
private StoryIntelligenceStageModels ResolveStageModels(StoryIntelligenceQueuedRun run) private StoryIntelligenceStageModels ResolveStageModels(StoryIntelligenceQueuedRun run)

View File

@ -0,0 +1,689 @@
using System.Text.Json;
using PlotLine.Models;
namespace PlotLine.Services;
public static class StoryIntelligenceIllustrationCompatibility
{
public static class AgeBands
{
public const string Child = "Child";
public const string YoungTeen = "YoungTeen";
public const string OlderTeen = "OlderTeen";
public const string YoungAdult = "YoungAdult";
public const string Adult = "Adult";
public const string MatureAdult = "MatureAdult";
public const string Senior = "Senior";
public const string Unknown = "Unknown";
}
public static class Presentations
{
public const string Feminine = "Feminine";
public const string Masculine = "Masculine";
public const string Androgynous = "Androgynous";
public const string Unknown = "Unknown";
}
public static class HairColours
{
public const string Black = "Black";
public const string Brown = "Brown";
public const string Blonde = "Blonde";
public const string Red = "Red";
public const string Grey = "Grey";
public const string White = "White";
public const string Bald = "Bald";
public const string Unknown = "Unknown";
}
public static class SkinTones
{
public const string Light = "Light";
public const string Medium = "Medium";
public const string Dark = "Dark";
public const string Unknown = "Unknown";
}
public static CharacterEvidenceProfile InferCharacterEvidence(string name, IEnumerable<string> identityValues, IEnumerable<string> relationshipValues)
{
var identityText = $" {string.Join(' ', identityValues.Where(value => !string.IsNullOrWhiteSpace(value))).ToLowerInvariant()} ";
var identityScopedText = $" {string.Join(". ", identityValues.Where(value => !string.IsNullOrWhiteSpace(value))).ToLowerInvariant()} ";
var relationshipText = $" {string.Join(". ", relationshipValues.Where(value => !string.IsNullOrWhiteSpace(value))).ToLowerInvariant()} ";
var nameText = $" {name.ToLowerInvariant()} ";
var entityText = $"{nameText} {identityText}";
var scopedText = EntityScopedText($"{nameText} {identityScopedText}", name);
var relationshipSafeText = RelationshipScopedText(relationshipText, name);
var titlePresentation = TitlePresentation(scopedText);
var relationshipPresentation = RelationshipPresentation(relationshipSafeText);
var entityPresentation = RelationshipPresentation(scopedText);
var pronounPresentation = PronounPresentation(scopedText);
var namePresentation = NamePresentation(entityText);
var presentation = FirstKnown(
titlePresentation.Value,
entityPresentation.Value,
relationshipPresentation.Value,
pronounPresentation.Value,
namePresentation.Value,
Presentations.Unknown);
var presentationConfidence = new[] { titlePresentation.Confidence, entityPresentation.Confidence, relationshipPresentation.Confidence, pronounPresentation.Confidence, namePresentation.Confidence }.Max();
var warnings = new List<string>();
var guardedPresentation = ApplyPresentationGuard(
name,
scopedText,
presentation,
presentationConfidence,
warnings);
presentation = guardedPresentation.Value;
presentationConfidence = guardedPresentation.Confidence;
var explicitAge = ExplicitAgeBand(scopedText);
if (explicitAge.Value == AgeBands.Unknown)
{
explicitAge = EntityIdentityAgeBand(name, identityText);
}
var relationshipAge = RelationshipAgeBand(name, scopedText);
var namedAge = NameAgeBand(name);
var age = FirstKnown(explicitAge.Value, relationshipAge.Value, namedAge.Value, AgeBands.Unknown);
var ageConfidence = new[] { explicitAge.Confidence, relationshipAge.Confidence, namedAge.Confidence }.Max();
var attributeText = EntityAttributeText(entityText, name);
var hairColour = HairColour(attributeText);
var skinTone = SkinTone(attributeText);
var hairLength = HairLength(attributeText);
return new(
age,
presentation,
hairColour,
skinTone,
hairLength,
ContainsAny(attributeText, "glasses", "spectacles") ? "Yes" : "Unknown",
ContainsAny(attributeText, "moustache", "mustache") ? "Moustache" : ContainsAny(attributeText, "beard", "bearded") ? "Beard" : "Unknown",
ageConfidence,
presentationConfidence,
relationshipPresentation.Confidence,
namePresentation.Confidence,
pronounPresentation.Confidence,
warnings);
}
public static IllustrationCharacterMetadata CharacterMetadataFrom(IllustrationLibraryItem item)
{
IllustrationGenerationSpecification? specification = null;
Dictionary<string, string>? metadata = null;
if (!string.IsNullOrWhiteSpace(item.MetadataJson))
{
try
{
metadata = JsonSerializer.Deserialize<Dictionary<string, string>>(item.MetadataJson, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
}
catch (JsonException)
{
}
}
if (!string.IsNullOrWhiteSpace(item.SpecificationJson))
{
try
{
specification = JsonSerializer.Deserialize<IllustrationGenerationSpecification>(item.SpecificationJson, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
}
catch (JsonException)
{
}
}
var stableCode = item.StableCode ?? string.Empty;
var isUnknownFigure = BoolMetadata(metadata, "isUnknownFigure") || stableCode.Contains("unknown-figure", StringComparison.OrdinalIgnoreCase);
var isGenericFallback = BoolMetadata(metadata, "isGenericFallback") || stableCode.Contains("fallback", StringComparison.OrdinalIgnoreCase);
var suitableForNamed = !isUnknownFigure && !isGenericFallback && !BoolMetadata(metadata, "notSuitableForNamedCharacter");
return new(
NormalizeAgeBand(FirstMetadata(metadata, "ageBand", "apparentAgeBand") ?? specification?.ApparentAgeBand ?? stableCode),
NormalizePresentation(FirstMetadata(metadata, "presentation") ?? specification?.Presentation ?? stableCode),
NormalizeHairColour(FirstMetadata(metadata, "hairColour") ?? specification?.HairColour ?? stableCode),
NormalizeSkinTone(FirstMetadata(metadata, "skinTone") ?? specification?.SkinTone ?? stableCode),
NormalizeHairLength(FirstMetadata(metadata, "hairLength") ?? specification?.HairLength ?? stableCode),
FirstMetadata(metadata, "glasses") ?? (FeatureValue(specification?.Features, "Glasses") ? "Yes" : "Unknown"),
FirstMetadata(metadata, "facialHair") ?? (FeatureValue(specification?.Features, "Beard") ? "Beard" : FeatureValue(specification?.Features, "Moustache") ? "Moustache" : "Unknown"),
isUnknownFigure,
isGenericFallback,
suitableForNamed,
item.PromptTemplateVersion,
FirstMetadata(metadata, "generationSource", "source") ?? string.Empty);
}
public static IReadOnlyList<string> CharacterHardRejections(CharacterEvidenceProfile evidence, IllustrationCharacterMetadata candidate, bool isNamedCharacter, bool alreadyAssignedToAnotherSignificantCharacter)
{
var rejected = new List<string>();
if (isNamedCharacter && candidate.IsUnknownFigure)
{
rejected.Add("named character cannot use Unknown Figure artwork");
}
if (isNamedCharacter && !candidate.IsSuitableForNamedCharacter)
{
rejected.Add("candidate is not suitable for strict named-character matching");
}
if (AgeIncompatible(evidence.AgeBand, candidate.AgeBand))
{
rejected.Add($"age hard mismatch: wanted {evidence.AgeBand}, candidate {candidate.AgeBand}");
}
if (evidence.HasStrongPresentationEvidence
&& PresentationIncompatible(evidence.Presentation, candidate.Presentation))
{
rejected.Add($"presentation hard mismatch: wanted {evidence.Presentation}, candidate {candidate.Presentation}");
}
if (!string.Equals(evidence.HairColour, HairColours.Unknown, StringComparison.OrdinalIgnoreCase)
&& !string.Equals(candidate.HairColour, HairColours.Unknown, StringComparison.OrdinalIgnoreCase)
&& KnownMismatch(evidence.HairColour, candidate.HairColour))
{
rejected.Add($"hair colour hard mismatch: wanted {evidence.HairColour}, candidate {candidate.HairColour}");
}
if (alreadyAssignedToAnotherSignificantCharacter && isNamedCharacter)
{
rejected.Add("already assigned to another significant named character");
}
return rejected;
}
public static string NormalizeAgeBand(string? value)
{
var text = (value ?? string.Empty).Replace("-", string.Empty, StringComparison.OrdinalIgnoreCase).Replace(" ", string.Empty, StringComparison.OrdinalIgnoreCase).ToLowerInvariant();
if (text.Contains("youngteen") || text.Contains("13") || text.Contains("14") || text.Contains("15") || text.Contains("fifteen")) return AgeBands.YoungTeen;
if (text.Contains("olderteen") || text.Contains("teen") || text.Contains("16") || text.Contains("17") || text.Contains("18") || text.Contains("19")) return AgeBands.OlderTeen;
if (text.Contains("child")) return AgeBands.Child;
if (text.Contains("youngadult")) return AgeBands.YoungAdult;
if (text.Contains("middleaged") || text.Contains("matureadult") || text.Contains("parent") || text.Contains("mother") || text.Contains("father") || text.Contains("aunt") || text.Contains("uncle")) return AgeBands.MatureAdult;
if (text.Contains("adult")) return AgeBands.Adult;
if (text.Contains("senior") || text.Contains("elderly") || text.Contains("older") || text.Contains("grandmother") || text.Contains("grandfather")) return AgeBands.Senior;
return AgeBands.Unknown;
}
public static string NormalizePresentation(string? value)
{
var text = (value ?? string.Empty).ToLowerInvariant();
if (ContainsAny(text, "feminine", "female", "woman", "girl", "mother", "mum", "mrs", "miss", "ms", "aunt", "sister", "daughter", "wife", "grandmother")) return Presentations.Feminine;
if (ContainsAny(text, "masculine", "male", "man", "boy", "father", "dad", "mr", "uncle", "brother", "son", "husband", "grandfather")) return Presentations.Masculine;
if (text.Contains("androgynous", StringComparison.OrdinalIgnoreCase)) return Presentations.Androgynous;
return Presentations.Unknown;
}
public static string NormalizeHairColour(string? value)
{
var text = (value ?? string.Empty).ToLowerInvariant();
if (text.Contains("bald", StringComparison.OrdinalIgnoreCase)) return HairColours.Bald;
if (text.Contains("black", StringComparison.OrdinalIgnoreCase)) return HairColours.Black;
if (ContainsAny(text, "brown", "brunette", "darkbrown", "dark hair")) return HairColours.Brown;
if (ContainsAny(text, "blonde", "fair hair")) return HairColours.Blonde;
if (ContainsAny(text, "red", "auburn", "ginger")) return HairColours.Red;
if (ContainsAny(text, "grey", "gray")) return HairColours.Grey;
if (text.Contains("white", StringComparison.OrdinalIgnoreCase)) return HairColours.White;
return HairColours.Unknown;
}
public static string NormalizeSkinTone(string? value)
{
var text = (value ?? string.Empty).ToLowerInvariant();
if (ContainsAny(text, "deep", "dark", "black skin")) return SkinTones.Dark;
if (ContainsAny(text, "medium", "lightmedium", "olive", "brown skin")) return SkinTones.Medium;
if (ContainsAny(text, "light", "pale", "fair skin")) return SkinTones.Light;
return SkinTones.Unknown;
}
public static string NormalizeHairLength(string? value)
{
var text = (value ?? string.Empty).ToLowerInvariant();
if (text.Contains("bald", StringComparison.OrdinalIgnoreCase)) return "Bald";
if (text.Contains("short", StringComparison.OrdinalIgnoreCase)) return "Short";
if (text.Contains("long", StringComparison.OrdinalIgnoreCase)) return "Long";
if (text.Contains("medium", StringComparison.OrdinalIgnoreCase) || text.Contains("shoulder", StringComparison.OrdinalIgnoreCase)) return "Medium";
return "Unknown";
}
public static string LocationType(string? value)
{
var text = (value ?? string.Empty).ToLowerInvariant();
if (ContainsAny(text, "bathroom", "toilet", "washroom")) return "Bathroom";
if (text.Contains("kitchen", StringComparison.OrdinalIgnoreCase)) return "Kitchen";
if (text.Contains("bedroom", StringComparison.OrdinalIgnoreCase)) return "Bedroom";
if (ContainsAny(text, "living room", "sitting room", "lounge")) return "LivingRoom";
if (ContainsAny(text, "hall", "hallway", "corridor")) return "Hallway";
if (text.Contains("stair", StringComparison.OrdinalIgnoreCase)) return "Stairwell";
if (ContainsAny(text, "hospital", "derelict hospital")) return "Hospital";
if (ContainsAny(text, "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 (text.Contains("school", StringComparison.OrdinalIgnoreCase)) return "School";
if (text.Contains("waiting room", StringComparison.OrdinalIgnoreCase)) return "WaitingRoom";
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 (text.Contains("shop", StringComparison.OrdinalIgnoreCase)) return "Shop";
if (ContainsAny(text, "bench", "bus stop")) return "StreetFurniture";
if (ContainsAny(text, "flat", "apartment", "room")) return "FlatInterior";
return "UnknownInterior";
}
public static string AssetType(string? value)
{
var text = (value ?? string.Empty).ToLowerInvariant();
var padded = $" {text} ";
if (text.Contains("ambulance", StringComparison.OrdinalIgnoreCase)) return "Ambulance";
if (text.Contains("stretcher", StringComparison.OrdinalIgnoreCase)) return "Stretcher";
if (text.Contains("body bag", StringComparison.OrdinalIgnoreCase)) return "BodyBag";
if (text.Contains("bottle", StringComparison.OrdinalIgnoreCase)) return "Bottle";
if (ContainsAny(text, "money", "cash", "coins", "banknote")) return "Money";
if (ContainsAny(text, "scarf", "scarves")) return "Clothing";
if (ContainsAny(text, "driving licence", "driver licence", "provisional licence", "license", "licence", "pass certificate", "certificate", "paperwork")) return "Document";
if (ContainsAny(text, "ring", "necklace", "jewellery", "jewelry")) return "Jewellery";
if (ContainsAny(text, "gun", "pistol", "rifle", "weapon")) return "Weapon";
if (ContainsAny(padded, " hatchback ", " vw golf ", " volkswagen golf ", " golf gti ", " ford fiesta ", " fiesta ")) return "Hatchback";
if (ContainsAny(padded, " family car ", " estate car ", " saloon ")) return "FamilyCar";
if (ContainsAny(padded, " sports car ", " tr6 ", " roadster ")) return "SportsCar";
if (ContainsAny(padded, " van ", " transit ")) return "Van";
if (ContainsAny(padded, " motor car ", " car ", " vehicle ")) return "Car";
if (ContainsAny(text, "supercharger", "engine part")) return "CarPart";
if (ContainsAny(text, "biscuit tin", "tin")) return "Tin";
if (ContainsAny(text, "parcel", "package")) return "Parcel";
if (ContainsAny(text, "blouse", "skirt", "jumper", "jumpers", "sunglasses", "stockings")) return "Clothing";
if (text.Contains("rucksack", StringComparison.OrdinalIgnoreCase)) return "Rucksack";
if (text.Contains("suitcase", StringComparison.OrdinalIgnoreCase)) return "Suitcase";
if (text.Contains("note", StringComparison.OrdinalIgnoreCase)) return "Note";
if (text.Contains("letter", StringComparison.OrdinalIgnoreCase)) return "Letter";
if (text.Contains("notebook", StringComparison.OrdinalIgnoreCase)) return "Notebook";
if (text.Contains("book", StringComparison.OrdinalIgnoreCase)) return "Book";
if (ContainsAny(text, "telephone", "phone", "recorder")) return "Telephone";
if (text.Contains("key", StringComparison.OrdinalIgnoreCase)) return "Keys";
if (ContainsAny(text, "tr6")) return "Car";
if (ContainsAny(text, "photo", "photograph")) return "Photograph";
if (ContainsAny(text, "document", "paper")) return "Document";
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)
=> requestedType == candidateType
|| (requestedType is "FrontDoor" or "Porch" && candidateType is "HouseExterior")
|| (requestedType is "Kitchen" or "LivingRoom" or "Bedroom" && candidateType is "FlatInterior")
|| (requestedType == "Bathroom" && candidateType == "Bathroom");
public static bool AssetCompatible(string requestedType, string candidateType)
=> requestedType == candidateType
|| (requestedType == "Hatchback" && candidateType == "Car")
|| (requestedType == "FamilyCar" && candidateType == "Car")
|| (requestedType == "Note" && candidateType is "Letter" or "Document")
|| (requestedType == "Rucksack" && candidateType == "Rucksack");
public static bool IsGroupEntity(string? value)
{
var clean = (value ?? string.Empty).Trim().ToLowerInvariant();
return clean.EndsWith('s')
&& (clean.Contains("social worker", StringComparison.Ordinal)
|| clean.Contains("neighbour", StringComparison.Ordinal)
|| clean.Contains("neighbor", StringComparison.Ordinal)
|| clean.Contains("police officer", StringComparison.Ordinal)
|| clean.Contains("student", StringComparison.Ordinal)
|| clean.Contains("examiner", StringComparison.Ordinal)
|| clean.Contains("uniformed", StringComparison.Ordinal)
|| clean.Contains("responder", StringComparison.Ordinal))
|| clean is "crowd" or "uniformed people" or "police" or "social services";
}
public static string PresentationGuardWarning(string name, string attemptedPresentation, string correctedPresentation)
=> $"WARNING: Entity evidence guard corrected {name} presentation from {attemptedPresentation} to {correctedPresentation}; title/name hard evidence overrode leaked or unrelated evidence.";
private static EvidenceSignal ExplicitAgeBand(string text)
{
if (ContainsAny(text, "15", "fifteen")) return new(AgeBands.YoungTeen, 0.98m);
if (ContainsAny(text, "13", "thirteen", "14", "fourteen")) return new(AgeBands.YoungTeen, 0.98m);
if (ContainsAny(text, "16", "sixteen", "17", "seventeen", "18", "eighteen", "19", "nineteen")) return new(AgeBands.OlderTeen, 0.98m);
if (ContainsAny(text, "toddler", "baby", "infant", "newborn")) return new(AgeBands.Child, 0.98m);
if (ContainsAny(text, "child", "young child", "schoolboy", "schoolgirl", "ten-year-old", "ten year old", "aged 10", "aged 11", "aged 12")) return new(AgeBands.Child, 0.9m);
if (ContainsAny(text, "teenager", "teenage")) return new(AgeBands.OlderTeen, 0.86m);
if (ContainsAny(text, "young adult", "young woman", "young man")) return new(AgeBands.YoungAdult, 0.82m);
if (ContainsAny(text, "middle-aged", "middle aged", "mature adult")) return new(AgeBands.MatureAdult, 0.86m);
if (ContainsAny(text, " mr ", " mr. ", " mrs ", " mrs. ", " miss ", " miss. ", " ms ", " ms. ", " doctor", " dr ", " dr. ", " professor", " driving examiner", " teacher", " detective", " police officer", " nurse", " receptionist", " solicitor", " landlord", " manager", "social worker")) return new(AgeBands.Adult, 0.88m);
if (ContainsAny(text, "elderly", "senior", "old woman", "old man")) return new(AgeBands.Senior, 0.9m);
return EvidenceSignal.Unknown;
}
private static EvidenceSignal EntityIdentityAgeBand(string name, string identityText)
{
var cleanName = (name ?? string.Empty).Trim().ToLowerInvariant();
if (ContainsAny(cleanName, "mother", " mum", "father", " dad", "parent", "aunt", "uncle", "grandmother", "grandfather"))
{
return EvidenceSignal.Unknown;
}
return ExplicitAgeBand(identityText);
}
private static EvidenceSignal RelationshipAgeBand(string name, string text)
{
if (ContainsAny(text, "toddler", "baby", "newborn")) return new(AgeBands.Child, 0.96m);
var identity = $"{name} {text}";
if (ContainsAny(identity, "mother", " mum", "father", " dad", "parent", "aunt", "uncle")
&& !PossessiveOfOtherIdentity(name, text))
{
return new(AgeBands.MatureAdult, 0.82m);
}
if (ContainsAny(identity, "teacher", "driving examiner", "detective", "police officer", "nurse", "receptionist", "solicitor", "landlord", "manager", "professor", "doctor")
&& !PossessiveOfOtherIdentity(name, text))
{
return new(AgeBands.Adult, 0.82m);
}
if (ContainsAny(text, "grandmother", "grandfather")) return new(AgeBands.Senior, 0.82m);
if (ContainsAny(text, "daughter", "son", "sister", "brother")) return new(AgeBands.Unknown, 0.3m);
return EvidenceSignal.Unknown;
}
private static EvidenceSignal NameAgeBand(string name)
{
return EvidenceSignal.Unknown;
}
private static EvidenceSignal TitlePresentation(string text)
{
if (ContainsAny(text, " mrs ", " mrs. ", " miss ", " miss. ", " ms ", " ms. ", " lady ")) return new(Presentations.Feminine, 0.96m);
if (ContainsAny(text, " mr ", " mr. ", " sir ")) return new(Presentations.Masculine, 0.96m);
return EvidenceSignal.Unknown;
}
private static EvidenceSignal RelationshipPresentation(string text)
{
if (ContainsAny(text, "female", "mother", " mum", "mummy", "aunt", "sister", "daughter", "wife", "widow", "grandmother", " niece", "woman", "girl", "lady", "queen")) return new(Presentations.Feminine, 0.92m);
if (ContainsAny(text, "male", "gentleman", "father", " dad", "daddy", "uncle", "brother", "son", "husband", "widower", "grandfather", " nephew", "man", "boy", "lad", "bloke", "chap", "king")) return new(Presentations.Masculine, 0.92m);
return EvidenceSignal.Unknown;
}
private static EvidenceSignal PronounPresentation(string text)
{
if (ContainsAny(text, " she ", " her ", " hers ", " herself ")) return new(Presentations.Feminine, 0.86m);
if (ContainsAny(text, " he ", " him ", " his ", " himself ")) return new(Presentations.Masculine, 0.86m);
return EvidenceSignal.Unknown;
}
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, " adam ", " colin ", " david ", " kevin ", " simon ", " graham ", " gareth ", " john ", " michael ", " peter ", " richard ", " rick ", " george ")) return new(Presentations.Masculine, 0.82m);
return EvidenceSignal.Unknown;
}
private static EvidenceSignal ApplyPresentationGuard(string name, string scopedText, string presentation, decimal confidence, ICollection<string> warnings)
{
var nameSignal = NameOrTitlePresentation(name);
if (nameSignal.Value == Presentations.Unknown)
{
return new(presentation, confidence);
}
if (presentation == Presentations.Unknown || presentation == nameSignal.Value)
{
return new(nameSignal.Value, Math.Max(confidence, nameSignal.Confidence));
}
if (HasExplicitContradictoryPresentation(name, scopedText, presentation))
{
warnings.Add($"WARNING: {name} has explicit contradictory presentation evidence: {presentation} conflicts with {nameSignal.Value} name/title evidence.");
return new(presentation, confidence);
}
warnings.Add(PresentationGuardWarning(name, presentation, nameSignal.Value));
return new(nameSignal.Value, Math.Max(confidence, nameSignal.Confidence));
}
private static EvidenceSignal NameOrTitlePresentation(string name)
{
var clean = CleanWords(name);
if (clean.Length == 0)
{
return EvidenceSignal.Unknown;
}
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" or "richard" or "rick")
{
return new(Presentations.Masculine, 0.98m);
}
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")
{
return new(Presentations.Feminine, 0.98m);
}
return EvidenceSignal.Unknown;
}
private static bool HasExplicitContradictoryPresentation(string name, string scopedText, string attemptedPresentation)
{
var clean = CleanWords(name);
if (clean.Length == 0 || string.IsNullOrWhiteSpace(scopedText))
{
return false;
}
var text = CleanWords(scopedText);
var contradictoryTerms = attemptedPresentation == Presentations.Feminine
? new[] { "female", "woman", "girl" }
: attemptedPresentation == Presentations.Masculine
? new[] { "male", "man", "boy" }
: [];
return contradictoryTerms.Any(term =>
text.Contains($"{clean} is {term}", StringComparison.OrdinalIgnoreCase)
|| text.Contains($"{clean} was {term}", StringComparison.OrdinalIgnoreCase)
|| text.Contains($"{clean} as {term}", StringComparison.OrdinalIgnoreCase));
}
private static string HairColour(string text) => NormalizeHairColour(text);
private static string SkinTone(string text) => NormalizeSkinTone(text);
private static string HairLength(string text) => NormalizeHairLength(text);
private static bool AgeIncompatible(string evidence, string candidate)
=> (evidence, candidate) switch
{
(AgeBands.Child, not AgeBands.Child) => true,
(AgeBands.YoungTeen, AgeBands.YoungAdult or AgeBands.Adult or AgeBands.MatureAdult or AgeBands.Senior) => true,
(AgeBands.OlderTeen, AgeBands.MatureAdult or AgeBands.Senior) => true,
(AgeBands.Adult, AgeBands.Child or AgeBands.YoungTeen or AgeBands.OlderTeen) => true,
(AgeBands.MatureAdult, AgeBands.Child or AgeBands.YoungTeen or AgeBands.OlderTeen) => true,
(AgeBands.Senior, AgeBands.Child or AgeBands.YoungTeen or AgeBands.OlderTeen or AgeBands.YoungAdult) => true,
_ => false
};
private static bool PresentationIncompatible(string evidence, string candidate)
=> (evidence, candidate) is (Presentations.Feminine, Presentations.Masculine) or (Presentations.Masculine, Presentations.Feminine);
private static bool ExplicitKnown(string value, decimal confidence)
=> !string.IsNullOrWhiteSpace(value) && value != "Unknown" && confidence >= 0.85m;
private static bool KnownMismatch(string evidence, string candidate)
=> !string.Equals(evidence, "Unknown", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(candidate, "Unknown", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(evidence, candidate, StringComparison.OrdinalIgnoreCase);
private static string FirstKnown(params string[] values)
=> values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value) && !string.Equals(value, "Unknown", StringComparison.OrdinalIgnoreCase)) ?? "Unknown";
private static string? FirstMetadata(Dictionary<string, string>? metadata, params string[] keys)
=> keys.Select(key => metadata?.GetValueOrDefault(key)).FirstOrDefault(value => !string.IsNullOrWhiteSpace(value));
private static bool BoolMetadata(Dictionary<string, string>? metadata, string key)
=> bool.TryParse(metadata?.GetValueOrDefault(key), out var value) && value;
private static bool FeatureValue(IReadOnlyList<string>? features, string value)
=> features?.Any(feature => feature.Contains(value, StringComparison.OrdinalIgnoreCase)) == true;
private static bool ContainsAny(string text, params string[] needles)
=> needles.Any(needle => text.Contains(needle, StringComparison.OrdinalIgnoreCase));
private static bool StartsWithAny(string text, params string[] prefixes)
=> prefixes.Any(prefix => text.Equals(prefix, StringComparison.OrdinalIgnoreCase) || text.StartsWith(prefix + " ", StringComparison.OrdinalIgnoreCase));
private static string CleanWords(string value)
{
var chars = value.Trim().ToLowerInvariant().Select(ch => char.IsLetterOrDigit(ch) ? ch : ' ').ToArray();
return string.Join(' ', new string(chars).Split(' ', StringSplitOptions.RemoveEmptyEntries));
}
private static string EntityAttributeText(string text, string entityName)
{
var cleanName = (entityName ?? string.Empty).Trim().ToLowerInvariant();
if (string.IsNullOrWhiteSpace(cleanName))
{
return text;
}
var sentences = text.Split(['.', '!', '?', ';'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
var scoped = sentences
.Where(sentence => sentence.Contains(cleanName, StringComparison.OrdinalIgnoreCase))
.Where(sentence => AttributeNearEntity(sentence, cleanName))
.ToList();
return scoped.Count == 0 ? $" {cleanName} " : $" {string.Join(' ', scoped)} ";
}
private static string EntityScopedText(string text, string entityName)
{
var cleanName = (entityName ?? string.Empty).Trim().ToLowerInvariant();
if (string.IsNullOrWhiteSpace(cleanName))
{
return text;
}
var sentences = text.Split(['.', '!', '?', ';'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
var possessiveAscii = $"{cleanName}'s";
var possessiveCurly = $"{cleanName}\u2019s";
var scoped = sentences.Where(sentence => sentence.Contains(cleanName, StringComparison.OrdinalIgnoreCase)).ToList();
scoped = scoped
.Where(sentence => !sentence.Contains(possessiveAscii, StringComparison.OrdinalIgnoreCase)
&& !sentence.Contains(possessiveCurly, StringComparison.OrdinalIgnoreCase))
.ToList();
return scoped.Count == 0 ? $" {cleanName} " : $" {string.Join(' ', scoped)} ";
}
private static string RelationshipScopedText(string text, string entityName)
{
var cleanName = (entityName ?? string.Empty).Trim().ToLowerInvariant();
if (string.IsNullOrWhiteSpace(cleanName))
{
return text;
}
var possessiveAscii = $"{cleanName}'s";
var possessiveCurly = $"{cleanName}\u2019s";
if (text.Contains(possessiveAscii, StringComparison.OrdinalIgnoreCase)
|| text.Contains(possessiveCurly, StringComparison.OrdinalIgnoreCase))
{
return string.Empty;
}
var fragments = text.Split(['.', '!', '?', ';', ',', '|'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Where(fragment => !fragment.Contains(possessiveAscii, StringComparison.OrdinalIgnoreCase)
&& !fragment.Contains(possessiveCurly, StringComparison.OrdinalIgnoreCase))
.ToList();
return fragments.Count == 0 ? string.Empty : $" {string.Join(' ', fragments)} ";
}
private static bool AttributeNearEntity(string sentence, string cleanName)
{
var lower = sentence.ToLowerInvariant();
var entityIndex = lower.IndexOf(cleanName, StringComparison.Ordinal);
if (entityIndex < 0)
{
return false;
}
var attributeIndex = new[]
{
lower.IndexOf("hair", StringComparison.Ordinal),
lower.IndexOf("skin", StringComparison.Ordinal),
lower.IndexOf("glasses", StringComparison.Ordinal),
lower.IndexOf("spectacles", StringComparison.Ordinal),
lower.IndexOf("beard", StringComparison.Ordinal),
lower.IndexOf("moustache", StringComparison.Ordinal),
lower.IndexOf("mustache", StringComparison.Ordinal)
}
.Where(index => index >= 0)
.DefaultIfEmpty(-1)
.Min();
return attributeIndex >= 0 && Math.Abs(attributeIndex - entityIndex) <= 48;
}
private static bool PossessiveOfOtherIdentity(string name, string text)
{
var cleanName = (name ?? string.Empty).Trim().ToLowerInvariant();
if (cleanName.Contains("mother", StringComparison.Ordinal)
|| cleanName.Contains("mum", StringComparison.Ordinal)
|| cleanName.Contains("father", StringComparison.Ordinal)
|| cleanName.Contains("dad", StringComparison.Ordinal)
|| cleanName.Contains("aunt", StringComparison.Ordinal)
|| cleanName.Contains("uncle", StringComparison.Ordinal)
|| cleanName.Contains("teacher", StringComparison.Ordinal))
{
return false;
}
return ContainsAny(text, "'s mother", "'s mum", "'s father", "'s dad", "'s aunt", "'s uncle", " her mother", " his mother", " her mum", " his dad");
}
private sealed record EvidenceSignal(string Value, decimal Confidence)
{
public static EvidenceSignal Unknown { get; } = new("Unknown", 0);
}
}
public sealed record CharacterEvidenceProfile(
string AgeBand,
string Presentation,
string HairColour,
string SkinTone,
string HairLength,
string Glasses,
string FacialHair,
decimal AgeConfidence,
decimal PresentationConfidence,
decimal RelationshipConfidence,
decimal NameConfidence,
decimal PronounConfidence,
IReadOnlyList<string> Warnings)
{
public bool HasStrongPresentationEvidence => PresentationConfidence >= 0.8m;
}
public sealed record IllustrationCharacterMetadata(
string AgeBand,
string Presentation,
string HairColour,
string SkinTone,
string HairLength,
string Glasses,
string FacialHair,
bool IsUnknownFigure,
bool IsGenericFallback,
bool IsSuitableForNamedCharacter,
string? PromptTemplateVersion,
string GenerationSource);

View File

@ -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,
@ -68,6 +69,12 @@ public sealed class StoryIntelligenceIllustrationMatchingService(
foreach (var observation in observations.Values.OrderByDescending(item => item.Significance).ThenBy(item => item.Name)) foreach (var observation in observations.Values.OrderByDescending(item => item.Significance).ThenBy(item => item.Name))
{ {
if (StoryIntelligenceIllustrationCompatibility.IsGroupEntity(observation.Name))
{
ApplyGroupDiagnostics(prototype, observation);
continue;
}
var existing = existingAssignments.GetValueOrDefault(observation.CharacterKey); var existing = existingAssignments.GetValueOrDefault(observation.CharacterKey);
var ownExistingItemId = existing?.IllustrationLibraryItemID; var ownExistingItemId = existing?.IllustrationLibraryItemID;
if (ownExistingItemId.HasValue && duplicateExistingItemIds.Contains(ownExistingItemId.Value) && !retainedExistingItemIds.Add(ownExistingItemId.Value)) if (ownExistingItemId.HasValue && duplicateExistingItemIds.Contains(ownExistingItemId.Value) && !retainedExistingItemIds.Add(ownExistingItemId.Value))
@ -92,25 +99,41 @@ public sealed class StoryIntelligenceIllustrationMatchingService(
? allScores.FirstOrDefault(score => score.Candidate.Item.IllustrationLibraryItemID == existingItemId) ? allScores.FirstOrDefault(score => score.Candidate.Item.IllustrationLibraryItemID == existingItemId)
: null; : null;
var best = allScores var best = allScores
.Where(score => !score.IsAlreadyAssignedToAnotherSignificantCharacter) .Where(score => score.Rejections.Count == 0)
.FirstOrDefault(score => score.Score >= SuitableScore); .FirstOrDefault(score => score.Score >= SuitableScore);
var chosen = ChooseAssignment(observation, existing, existingScore, best); var chosen = ChooseAssignment(observation, existing, existingScore, best);
if (chosen is null) if (chosen is null)
{ {
var demandStatus = await RecordDemandAsync(projectId, observation, allScores); var demandStatus = await RecordDemandAsync(projectId, observation, allScores);
var fallbackCandidateDiagnostics = JsonSerializer.Serialize(allScores.Take(6).Select(MatchDiagnostics.From), JsonOptions);
var fallbackEvidenceJson = EvidenceTraceJson(observation, existing);
await assignments.UpsertAssignmentAsync(new StoryIntelligenceCharacterIllustrationAssignmentSave(
projectId,
observation.CharacterKey,
observation.Name,
null,
null,
0,
0,
fallbackEvidenceJson,
fallbackCandidateDiagnostics,
"No hard-compatible unused illustration exists; persisted neutral fallback and recorded library demand.",
null));
ApplyDemandDiagnostics(prototype, observation, allScores, demandStatus); ApplyDemandDiagnostics(prototype, observation, allScores, demandStatus);
continue; continue;
} }
var reasonReassigned = existingScore is not null && chosen.Candidate.Item.IllustrationLibraryItemID != existing?.IllustrationLibraryItemID var reasonReassigned = existingScore is not null && chosen.Candidate.Item.IllustrationLibraryItemID != existing?.IllustrationLibraryItemID
? ReassignmentReason(observation, existingScore, chosen) ? ReassignmentReason(observation, existingScore, chosen)
: existing is { IllustrationLibraryItemID: null }
? $"Assigned {chosen.Candidate.Item.StableCode} because generated demand or newly valid library art replaced a fallback assignment."
: null; : null;
var reason = reasonReassigned ?? (existing is null var reason = reasonReassigned ?? (existing is null
? $"Assigned {chosen.Candidate.Item.StableCode} from structured character evidence." ? $"Assigned {chosen.Candidate.Item.StableCode} from structured character evidence."
: "Preserved existing Story Intelligence character illustration assignment."); : $"Retained {chosen.Candidate.Item.StableCode}; existing assignment passed hard revalidation with score {chosen.Score:N0}.");
var candidateDiagnostics = JsonSerializer.Serialize(allScores.Take(6).Select(MatchDiagnostics.From), JsonOptions); var candidateDiagnostics = JsonSerializer.Serialize(allScores.Take(6).Select(MatchDiagnostics.From), JsonOptions);
var evidenceJson = JsonSerializer.Serialize(observation.Evidence, JsonOptions); var evidenceJson = EvidenceTraceJson(observation, existing);
var saved = await assignments.UpsertAssignmentAsync(new StoryIntelligenceCharacterIllustrationAssignmentSave( var saved = await assignments.UpsertAssignmentAsync(new StoryIntelligenceCharacterIllustrationAssignmentSave(
projectId, projectId,
@ -126,10 +149,229 @@ public sealed class StoryIntelligenceIllustrationMatchingService(
reasonReassigned)); reasonReassigned));
assignedItemIds.Add(chosen.Candidate.Item.IllustrationLibraryItemID); assignedItemIds.Add(chosen.Candidate.Item.IllustrationLibraryItemID);
ApplyAssignment(prototype, observation, chosen, saved, reason, reasonReassigned, candidateDiagnostics); ApplyAssignment(prototype, observation, existing, chosen, saved, reason, reasonReassigned, evidenceJson, candidateDiagnostics);
} }
} }
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,
@ -138,20 +380,25 @@ public sealed class StoryIntelligenceIllustrationMatchingService(
{ {
if (existingScore is not null && existingScore.Candidate.PublicUrl is not null) if (existingScore is not null && existingScore.Candidate.PublicUrl is not null)
{ {
if (existingScore.Rejections.Count > 0)
{
return best;
}
if (best is null) if (best is null)
{ {
return existingScore; return existingScore.Score >= SuitableScore ? existingScore : null;
} }
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;
} }
if (!materiallyWrong || best.Score < existingScore.Score + ReassignmentMargin) if (!materiallyWrong || best.Score < existingScore.Score + ReassignmentMargin)
{ {
return existingScore; return existingScore.Score >= SuitableScore ? existingScore : best;
} }
} }
@ -211,7 +458,52 @@ public sealed class StoryIntelligenceIllustrationMatchingService(
} }
private static string DemandKey(CharacterEvidence evidence) private static string DemandKey(CharacterEvidence evidence)
=> string.Join('|', evidence.AgeBand, evidence.Presentation, evidence.HairColour, evidence.SkinTone, evidence.HairLength, evidence.Glasses, evidence.FacialHair).ToLowerInvariant(); => string.Join('|', "character", evidence.AgeBand, evidence.Presentation, evidence.HairColour, evidence.SkinTone).ToLowerInvariant();
private static string EvidenceTraceJson(CharacterObservation observation, StoryIntelligenceCharacterIllustrationAssignment? existing)
=> JsonSerializer.Serialize(new
{
character = observation.Name,
identityKey = observation.CharacterKey,
canonicalIdentity = observation.CharacterKey,
rawExtractedEvidence = CompactEvidence(observation.IdentityEvidence),
sceneEvidence = CompactEvidence(observation.TextEvidence),
relationshipEvidence = CompactEvidence(observation.RelationshipEvidence),
mergedEvidence = CompactEvidence(observation.TextEvidence.Concat(observation.RelationshipEvidence)),
previousAssignment = existing is null
? null
: new
{
existing.StableCode,
existing.IllustrationLibraryItemID,
existing.MatchConfidence,
existing.MatchingScore,
evidenceChars = existing.EvidenceJson?.Length ?? 0,
evidenceHash = string.IsNullOrWhiteSpace(existing.EvidenceJson) ? null : StableHash(existing.EvidenceJson)
},
finalMatcherEvidence = observation.Evidence,
evidenceSource = "Story Intelligence visualisation character state",
canonicalMergeReason = "Character state ID from canonical visualisation identity"
}, JsonOptions);
private static IReadOnlyList<string> CompactEvidence(IEnumerable<string> evidence)
=> evidence
.Where(value => !string.IsNullOrWhiteSpace(value))
.Select(value => Trim(value, 160))
.Distinct(StringComparer.OrdinalIgnoreCase)
.Take(16)
.ToList();
private static string Trim(string? value, int maxLength)
{
if (string.IsNullOrWhiteSpace(value))
{
return string.Empty;
}
var clean = Regex.Replace(value.Trim(), @"\s+", " ");
return clean.Length <= maxLength ? clean : clean[..maxLength].TrimEnd() + "...";
}
private static IllustrationGenerationSpecification DemandSpecification(StoryIntelligenceIllustrationDemand demand) private static IllustrationGenerationSpecification DemandSpecification(StoryIntelligenceIllustrationDemand demand)
{ {
@ -219,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))
{ {
@ -238,29 +530,102 @@ public sealed class StoryIntelligenceIllustrationMatchingService(
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 and {skinTone.ToLowerInvariant()} skin tone range.",
Composition = "clear head-and-shoulders portrait with bright readable face, clean background 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", Mood = "neutral, story-ready, emotionally readable, subdued and cinematic",
ApparentAgeBand = age, ApparentAgeBand = age,
Presentation = presentation, Presentation = presentation,
HairColour = hairColour, HairColour = hairColour,
HairLength = hairLength, HairLength = hairLength,
SkinTone = skinTone, SkinTone = skinTone,
ClothingEra = "Contemporary", ClothingEra = "Contemporary",
ClothingStyle = "Plain reusable story wardrobe", ClothingStyle = "Understated reusable story wardrobe, not studio fashion styling",
Features = features, Features = features,
Palette = ["clear blue", "warm skin tones", "fresh accent colour"], Palette = ["dark navy atmosphere", "warm skin tones", "restrained amber accent"],
MetadataTags = ["character", "demand-generated", "story-intelligence"], MetadataTags = ["character", "demand-generated", "story-intelligence"],
Metadata = new(StringComparer.OrdinalIgnoreCase) Metadata = new(StringComparer.OrdinalIgnoreCase)
{ {
["libraryPurpose"] = "Story Intelligence demand-generated identifier", ["libraryPurpose"] = "Story Intelligence demand-generated identifier",
["privacy"] = "generic-no-manuscript-data", ["privacy"] = "generic-no-manuscript-data",
["style"] = "editorial-stylised-realism", ["style"] = "editorial-stylised-realism",
["styleIntent"] = "match-approved-starter-library",
["generationSource"] = "Demand generation",
["demandKey"] = demand.DemandKey, ["demandKey"] = demand.DemandKey,
["requestCount"] = demand.RequestCount.ToString("N0") ["requestCount"] = demand.RequestCount.ToString("N0")
} }
}; };
} }
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);
@ -299,7 +664,7 @@ public sealed class StoryIntelligenceIllustrationMatchingService(
foreach (var observation in observations.Values) foreach (var observation in observations.Values)
{ {
observation.Evidence = InferEvidence(observation.Name, observation.IdentityEvidence, observation.RelationshipEvidence); observation.Evidence = CharacterEvidence.From(StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence(observation.Name, observation.IdentityEvidence, observation.RelationshipEvidence));
} }
return observations; return observations;
@ -307,53 +672,7 @@ public sealed class StoryIntelligenceIllustrationMatchingService(
private static CharacterEvidence InferEvidence(string characterName, IEnumerable<string> identityValues, IEnumerable<string> relationshipValues) private static CharacterEvidence InferEvidence(string characterName, IEnumerable<string> identityValues, IEnumerable<string> relationshipValues)
{ {
var identityText = $" {string.Join(' ', identityValues.Where(value => !string.IsNullOrWhiteSpace(value))).ToLowerInvariant()} "; return CharacterEvidence.From(StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence(characterName, identityValues, relationshipValues));
var relationshipText = $" {string.Join(' ', relationshipValues.Where(value => !string.IsNullOrWhiteSpace(value))).ToLowerInvariant()} ";
var text = $"{identityText} {relationshipText}";
var relationshipPresentation = RelationshipPresentation(text);
var pronounPresentation = PronounPresentation(text);
var namePresentation = NamePresentation(identityText);
var presentation = FirstKnown(relationshipPresentation.Value, pronounPresentation.Value, namePresentation.Value, "Androgynous");
var cleanName = characterName.Trim().ToLowerInvariant();
var age = cleanName is "beth" or "grace" or "rosie" ? "Older Teen"
: ContainsAny(identityText, "grandfather", "grandmother", "elderly", "old man", "old woman", "older woman", "older man") ? "Elderly"
: ContainsAny(identityText, "middle aged", "middle-aged", "mother", " mum ", "father", " dad ", "parent", "aunt", "uncle") ? "Middle Aged"
: ContainsAny(identityText, "young teen", "child", "boy", "girl") ? "Child"
: ContainsAny(identityText, "older teen", "teenage", "teenager", "fifteen", "sixteen", "seventeen", " beth ", " grace ", " rosie ") ? "Older Teen"
: ContainsAny(identityText, "young adult", "student", "young woman", "young man") ? "Young Adult"
: "Unknown";
var hairColour = ContainsAny(text, "black hair", "dark hair") ? "Black"
: ContainsAny(text, "brown hair", "brunette") ? "Brown"
: ContainsAny(text, "blonde", "fair hair") ? "Blonde"
: ContainsAny(text, "red hair", "red-haired", "auburn", "ginger") ? "Red"
: ContainsAny(text, "grey hair", "gray hair") ? "Grey"
: ContainsAny(text, "white hair") ? "White"
: "Unknown";
var skinTone = ContainsAny(text, "light skin", "pale skin", "fair skin") ? "Light"
: ContainsAny(text, "medium skin", "olive skin", "brown skin") ? "Medium"
: ContainsAny(text, "dark skin", "deep skin", "black skin") ? "Dark"
: "Unknown";
var hairLength = ContainsAny(text, "bald", "shaved head") ? "Bald"
: ContainsAny(text, "short hair") ? "Short"
: ContainsAny(text, "long hair") ? "Long"
: ContainsAny(text, "medium hair", "shoulder length") ? "Medium"
: "Unknown";
var glasses = ContainsAny(text, "glasses", "spectacles") ? "Yes" : "Unknown";
var facialHair = ContainsAny(text, "moustache", "mustache") ? "Moustache"
: ContainsAny(text, "beard", "bearded") ? "Beard"
: "Unknown";
return new(
age,
presentation,
hairColour,
skinTone,
hairLength,
glasses,
facialHair,
relationshipPresentation.Confidence,
namePresentation.Confidence,
pronounPresentation.Confidence);
} }
private static bool ContainsAny(string text, params string[] needles) private static bool ContainsAny(string text, params string[] needles)
@ -410,7 +729,19 @@ public sealed class StoryIntelligenceIllustrationMatchingService(
private static MatchScore Score(CharacterCandidate candidate, CharacterObservation observation, bool alreadyAssigned) private static MatchScore Score(CharacterCandidate candidate, CharacterObservation observation, bool alreadyAssigned)
{ {
var score = 0; var score = 0;
var rejected = new List<string>(); var rejected = new List<string>(StoryIntelligenceIllustrationCompatibility.CharacterHardRejections(
observation.Evidence.Profile,
candidate.Metadata.Profile,
isNamedCharacter: !IsUnresolvedName(observation.Name),
alreadyAssignedToAnotherSignificantCharacter: alreadyAssigned && observation.IsSignificant));
if (observation.Evidence.AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.Unknown
&& candidate.Metadata.AgeBand is StoryIntelligenceIllustrationCompatibility.AgeBands.Child
or StoryIntelligenceIllustrationCompatibility.AgeBands.YoungTeen
or StoryIntelligenceIllustrationCompatibility.AgeBands.OlderTeen)
{
rejected.Add("unknown age should use an adult-compatible illustration until explicit child or teen evidence appears");
}
AddScore(observation.Evidence.AgeBand, candidate.Metadata.AgeBand, 25, "age band"); AddScore(observation.Evidence.AgeBand, candidate.Metadata.AgeBand, 25, "age band");
AddScore(observation.Evidence.Presentation, candidate.Metadata.Presentation, observation.Evidence.HasStrongPresentationEvidence ? 42 : 25, "presentation"); AddScore(observation.Evidence.Presentation, candidate.Metadata.Presentation, observation.Evidence.HasStrongPresentationEvidence ? 42 : 25, "presentation");
AddScore(observation.Evidence.HairColour, candidate.Metadata.HairColour, 20, "hair colour"); AddScore(observation.Evidence.HairColour, candidate.Metadata.HairColour, 20, "hair colour");
@ -424,18 +755,6 @@ public sealed class StoryIntelligenceIllustrationMatchingService(
score += 3; score += 3;
} }
if (observation.Evidence.HasStrongPresentationEvidence && KnownMismatch(observation.Evidence.Presentation, candidate.Metadata.Presentation))
{
score -= 60;
rejected.Add($"strong presentation evidence rejected {candidate.Metadata.Presentation}");
}
if (alreadyAssigned)
{
score -= 38;
rejected.Add("already assigned to another character");
}
return new(candidate, Math.Clamp(score, 0, 100), alreadyAssigned, rejected); return new(candidate, Math.Clamp(score, 0, 100), alreadyAssigned, rejected);
void AddScore(string evidence, string value, int weight, string label) void AddScore(string evidence, string value, int weight, string label)
@ -467,15 +786,19 @@ public sealed class StoryIntelligenceIllustrationMatchingService(
=> Math.Round(Math.Clamp(score, 0, 100) / 100m, 2); => Math.Round(Math.Clamp(score, 0, 100) / 100m, 2);
private static string ReassignmentReason(CharacterObservation observation, MatchScore existing, MatchScore chosen) private static string ReassignmentReason(CharacterObservation observation, MatchScore existing, MatchScore chosen)
=> $"Reassigned {observation.Name} because new structured evidence made {existing.Candidate.Item.StableCode} materially weaker than {chosen.Candidate.Item.StableCode}."; => existing.Rejections.Count > 0
? $"Reassigned {observation.Name} because {existing.Candidate.Item.StableCode} violated hard constraints: {string.Join("; ", existing.Rejections)}."
: $"Reassigned {observation.Name} because new structured evidence made {existing.Candidate.Item.StableCode} materially weaker than {chosen.Candidate.Item.StableCode}.";
private void ApplyAssignment( private void ApplyAssignment(
StoryIntelligenceExperiencePrototypeViewModel prototype, StoryIntelligenceExperiencePrototypeViewModel prototype,
CharacterObservation observation, CharacterObservation observation,
StoryIntelligenceCharacterIllustrationAssignment? existing,
MatchScore chosen, MatchScore chosen,
StoryIntelligenceCharacterIllustrationAssignment saved, StoryIntelligenceCharacterIllustrationAssignment saved,
string reason, string reason,
string? reasonReassigned, string? reasonReassigned,
string evidenceJson,
string candidateDiagnostics) string candidateDiagnostics)
{ {
foreach (var character in prototype.Scenes.SelectMany(scene => scene.Characters).Where(character => string.Equals(character.Id, observation.CharacterKey, StringComparison.OrdinalIgnoreCase))) foreach (var character in prototype.Scenes.SelectMany(scene => scene.Characters).Where(character => string.Equals(character.Id, observation.CharacterKey, StringComparison.OrdinalIgnoreCase)))
@ -499,6 +822,11 @@ public sealed class StoryIntelligenceIllustrationMatchingService(
RejectedCandidates = candidateDiagnostics, RejectedCandidates = candidateDiagnostics,
ReasonChosen = reason, ReasonChosen = reason,
ReasonReassigned = reasonReassigned, ReasonReassigned = reasonReassigned,
PreviousIllustration = existing?.StableCode,
PreviousEvidence = existing?.EvidenceJson,
CurrentEvidence = evidenceJson,
DemandKey = DemandKey(observation.Evidence),
EvidenceWarnings = string.Join(" ", observation.Evidence.Warnings),
DemandStatus = "Satisfied", DemandStatus = "Satisfied",
ResolvedPresentation = observation.Evidence.Presentation, ResolvedPresentation = observation.Evidence.Presentation,
ResolvedAgeBand = observation.Evidence.AgeBand, ResolvedAgeBand = observation.Evidence.AgeBand,
@ -537,6 +865,9 @@ public sealed class StoryIntelligenceIllustrationMatchingService(
MatchingScore = allScores.FirstOrDefault()?.Score, MatchingScore = allScores.FirstOrDefault()?.Score,
RejectedCandidates = diagnostics, RejectedCandidates = diagnostics,
ReasonChosen = "No suitable unused illustration matched structured character evidence.", ReasonChosen = "No suitable unused illustration matched structured character evidence.",
CurrentEvidence = EvidenceTraceJson(observation, null),
DemandKey = DemandKey(observation.Evidence),
EvidenceWarnings = string.Join(" ", observation.Evidence.Warnings),
DemandStatus = demandStatus, DemandStatus = demandStatus,
ResolvedPresentation = observation.Evidence.Presentation, ResolvedPresentation = observation.Evidence.Presentation,
ResolvedAgeBand = observation.Evidence.AgeBand, ResolvedAgeBand = observation.Evidence.AgeBand,
@ -574,6 +905,40 @@ public sealed class StoryIntelligenceIllustrationMatchingService(
private static bool IsUnknown(string? value) private static bool IsUnknown(string? value)
=> string.IsNullOrWhiteSpace(value) || string.Equals(value, "Unknown", StringComparison.OrdinalIgnoreCase); => string.IsNullOrWhiteSpace(value) || string.Equals(value, "Unknown", StringComparison.OrdinalIgnoreCase);
private static bool IsUnresolvedName(string? value)
{
var clean = (value ?? string.Empty).Trim().ToLowerInvariant();
return string.IsNullOrWhiteSpace(clean)
|| clean is "unknown" or "unknown figure" or "unidentified person" or "unidentified woman" or "unidentified man" or "narrator"
|| clean.StartsWith("unknown ", StringComparison.Ordinal)
|| clean.StartsWith("unidentified ", StringComparison.Ordinal);
}
private static void ApplyGroupDiagnostics(StoryIntelligenceExperiencePrototypeViewModel prototype, CharacterObservation observation)
{
foreach (var character in prototype.Scenes.SelectMany(scene => scene.Characters).Where(character => string.Equals(character.Id, observation.CharacterKey, StringComparison.OrdinalIgnoreCase)))
{
character.LibraryCode = string.Empty;
character.ImagePath = "/images/story-intelligence/prototype/character-group.svg";
character.ImageResolution = new StoryIntelligenceExperienceImageResolution
{
EntityId = character.Id,
StableCode = string.Empty,
IllustrationLibraryItemId = null,
Status = "GroupEntity",
FinalImageUrl = null,
FallbackImageUrl = character.ImagePath,
FallbackUsed = true,
ReasonChosen = "Collective group entity; reusable group fallback used without unique portrait assignment or demand.",
CurrentEvidence = EvidenceTraceJson(observation, null),
DemandStatus = "Not required for group entity",
ResolvedPresentation = "Unknown",
ResolvedAgeBand = "Unknown",
IllustrationAllocationStatus = "Group entity"
};
}
}
private static string NormaliseUnknown(string? value, string fallback) private static string NormaliseUnknown(string? value, string fallback)
=> IsUnknown(value) ? fallback : value!; => IsUnknown(value) ? fallback : value!;
@ -607,10 +972,26 @@ public sealed class StoryIntelligenceIllustrationMatchingService(
string FacialHair, string FacialHair,
decimal RelationshipConfidence, decimal RelationshipConfidence,
decimal NameConfidence, decimal NameConfidence,
decimal PronounConfidence) decimal PronounConfidence,
IReadOnlyList<string> Warnings,
CharacterEvidenceProfile Profile)
{ {
public bool HasStrongPresentationEvidence => RelationshipConfidence >= 0.8m || NameConfidence >= 0.75m || PronounConfidence >= 0.8m; public bool HasStrongPresentationEvidence => RelationshipConfidence >= 0.8m || NameConfidence >= 0.75m || PronounConfidence >= 0.8m;
public static CharacterEvidence Unknown { get; } = new("Unknown", "Androgynous", "Unknown", "Unknown", "Unknown", "Unknown", "Unknown", 0, 0, 0); public static CharacterEvidence Unknown { get; } = From(new CharacterEvidenceProfile("Unknown", "Unknown", "Unknown", "Unknown", "Unknown", "Unknown", "Unknown", 0, 0, 0, 0, 0, []));
public static CharacterEvidence From(CharacterEvidenceProfile profile)
=> new(
profile.AgeBand,
profile.Presentation,
profile.HairColour,
profile.SkinTone,
profile.HairLength,
profile.Glasses,
profile.FacialHair,
profile.RelationshipConfidence,
profile.NameConfidence,
profile.PronounConfidence,
profile.Warnings,
profile);
} }
private sealed record CharacterCandidate(IllustrationLibraryItem Item, CharacterIllustrationMetadata Metadata, string? PublicUrl) private sealed record CharacterCandidate(IllustrationLibraryItem Item, CharacterIllustrationMetadata Metadata, string? PublicUrl)
@ -622,100 +1003,21 @@ public sealed class StoryIntelligenceIllustrationMatchingService(
} }
} }
private sealed record CharacterIllustrationMetadata(string AgeBand, string Presentation, string HairColour, string SkinTone, string HairLength, string Glasses, string FacialHair) private sealed record CharacterIllustrationMetadata(string AgeBand, string Presentation, string HairColour, string SkinTone, string HairLength, string Glasses, string FacialHair, IllustrationCharacterMetadata Profile)
{ {
public static CharacterIllustrationMetadata? From(IllustrationLibraryItem item) public static CharacterIllustrationMetadata? From(IllustrationLibraryItem item)
{ {
IllustrationGenerationSpecification? specification = null; var profile = StoryIntelligenceIllustrationCompatibility.CharacterMetadataFrom(item);
Dictionary<string, string>? metadata = null;
if (!string.IsNullOrWhiteSpace(item.MetadataJson))
{
try
{
metadata = JsonSerializer.Deserialize<Dictionary<string, string>>(item.MetadataJson, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
}
catch (JsonException)
{
}
}
if (!string.IsNullOrWhiteSpace(item.SpecificationJson))
{
try
{
specification = JsonSerializer.Deserialize<IllustrationGenerationSpecification>(item.SpecificationJson, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
}
catch (JsonException)
{
}
}
return new( return new(
NormaliseAge(FirstMetadata(metadata, "ageBand", "apparentAgeBand") ?? specification?.ApparentAgeBand, item.StableCode), profile.AgeBand,
NormalisePresentation(FirstMetadata(metadata, "presentation") ?? specification?.Presentation, item.StableCode), profile.Presentation,
NormaliseHairColour(FirstMetadata(metadata, "hairColour") ?? specification?.HairColour, item.StableCode), profile.HairColour,
NormaliseSkinTone(FirstMetadata(metadata, "skinTone") ?? specification?.SkinTone, item.StableCode), profile.SkinTone,
NormaliseHairLength(FirstMetadata(metadata, "hairLength") ?? specification?.HairLength, item.StableCode), profile.HairLength,
FirstMetadata(metadata, "glasses") ?? (FeatureValue(specification?.Features, "Glasses") ? "Yes" : "Unknown"), profile.Glasses,
FirstMetadata(metadata, "facialHair") ?? (FeatureValue(specification?.Features, "Beard") ? "Beard" : FeatureValue(specification?.Features, "Moustache") ? "Moustache" : "Unknown")); profile.FacialHair,
} profile);
private static string? FirstMetadata(Dictionary<string, string>? metadata, params string[] keys)
=> keys.Select(key => metadata?.GetValueOrDefault(key)).FirstOrDefault(value => !string.IsNullOrWhiteSpace(value));
private static bool FeatureValue(IReadOnlyList<string>? features, string value)
=> features?.Any(feature => feature.Contains(value, StringComparison.OrdinalIgnoreCase)) == true;
private static string NormaliseAge(string? value, string code)
{
var text = $"{value} {code}".ToLowerInvariant();
if (text.Contains("child")) return "Child";
if (text.Contains("youngteen")) return "Young Teen";
if (text.Contains("teen")) return "Older Teen";
if (text.Contains("youngadult") || text.Contains("young-adult")) return "Young Adult";
if (text.Contains("middleaged") || text.Contains("middle-aged")) return "Middle Aged";
if (text.Contains("elderly") || text.Contains("older")) return "Elderly";
return "Unknown";
}
private static string NormalisePresentation(string? value, string code)
{
var text = $"{value} {code}".ToLowerInvariant();
if (text.Contains("feminine") || text.Contains("woman") || text.Contains("female")) return "Feminine";
if (text.Contains("masculine") || text.Contains("man") || text.Contains("male")) return "Masculine";
if (text.Contains("androgynous")) return "Androgynous";
return "Androgynous";
}
private static string NormaliseHairColour(string? value, string code)
{
var text = $"{value} {code}".ToLowerInvariant();
if (text.Contains("black")) return "Black";
if (text.Contains("brown") || text.Contains("brunette") || text.Contains("darkbrown")) return "Brown";
if (text.Contains("blonde")) return "Blonde";
if (text.Contains("red") || text.Contains("auburn")) return "Red";
if (text.Contains("grey") || text.Contains("gray")) return "Grey";
if (text.Contains("white")) return "White";
return "Unknown";
}
private static string NormaliseSkinTone(string? value, string code)
{
var text = $"{value} {code}".ToLowerInvariant();
if (text.Contains("deep") || text.Contains("dark")) return "Dark";
if (text.Contains("medium") || text.Contains("lightmedium")) return "Medium";
if (text.Contains("light")) return "Light";
return "Unknown";
}
private static string NormaliseHairLength(string? value, string code)
{
var text = $"{value} {code}".ToLowerInvariant();
if (text.Contains("bald")) return "Bald";
if (text.Contains("short")) return "Short";
if (text.Contains("long")) return "Long";
if (text.Contains("medium")) return "Medium";
return "Unknown";
} }
} }

View File

@ -0,0 +1,513 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using PlotLine.Data;
using PlotLine.Models;
namespace PlotLine.Services;
public interface IStoryMemoryService
{
Task<StoryMemoryRebuildResult> RebuildImportSessionAsync(int importSessionId, int userId, StoryMemoryRebuildOptions options, CancellationToken cancellationToken = default);
Task ProcessSceneResultAsync(int importSessionId, int userId, int sceneResultId, StoryMemoryResolveOptions options, CancellationToken cancellationToken = default);
Task ResolveIllustrationsAsync(int importSessionId, StoryMemoryResolveOptions options, CancellationToken cancellationToken = default);
}
public sealed class StoryMemoryService(
IStoryIntelligenceResultRepository results,
IStoryIntelligencePipelineRepository pipelines,
IStoryMemoryRepository memory,
IIllustrationLibraryRepository illustrationLibrary,
IUploadStorageService uploadStorage,
ILogger<StoryMemoryService> logger) : IStoryMemoryService
{
private const string ProcessorVersion = "21S";
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
WriteIndented = false
};
public async Task<StoryMemoryRebuildResult> RebuildImportSessionAsync(int importSessionId, int userId, StoryMemoryRebuildOptions options, CancellationToken cancellationToken = default)
{
var session = await pipelines.GetByIdForUserAsync(importSessionId, userId)
?? throw new InvalidOperationException($"Import Session {importSessionId} could not be found for this user.");
var runScenes = await LoadImportScenesAsync(session, cancellationToken);
var completedScenes = runScenes.Where(item => item.Scene is not null).ToList();
if (options.DryRun)
{
return new StoryMemoryRebuildResult
{
ImportSessionID = importSessionId,
DryRun = true,
SceneResultsRead = runScenes.Count,
SceneResultsProcessed = completedScenes.Count,
CharactersDiscovered = completedScenes.Sum(item => item.Scene!.Characters?.Count ?? 0),
LocationsDiscovered = completedScenes.Sum(item => (item.Scene!.Locations?.Count ?? 0) + (string.IsNullOrWhiteSpace(item.Scene.Setting?.LocationName) ? 0 : 1)),
AssetsDiscovered = completedScenes.Sum(item => item.Scene!.Assets?.Count ?? 0),
RelationshipsDiscovered = completedScenes.Sum(item => item.Scene!.Relationships?.Count ?? 0),
AiCalls = 0,
Warnings = runScenes.Where(item => item.Scene is null && !string.IsNullOrWhiteSpace(item.ParseError)).Select(item => item.ParseError!).Take(20).ToList()
};
}
if (options.RebuildAll)
{
await memory.ClearDerivedStateAsync(importSessionId);
}
await memory.SetAppearancePreferenceAsync(importSessionId, options.AppearancePreference);
var processed = await memory.ListProcessedSceneResultIdsAsync(importSessionId, ProcessorVersion);
var processedCount = 0;
foreach (var scene in completedScenes)
{
cancellationToken.ThrowIfCancellationRequested();
if (!options.RebuildAll && processed.Contains(scene.Result.SceneResultID))
{
continue;
}
await UpsertSceneMemoryAsync(session, scene.Result, scene.Scene!, cancellationToken);
await memory.MarkSceneResultProcessedAsync(importSessionId, scene.Result.SceneResultID, ProcessorVersion, StableContentHash(scene.Result.ParsedJson));
processedCount++;
}
await ResolveIllustrationsAsync(importSessionId, new StoryMemoryResolveOptions
{
QueueDemand = options.QueueDemand,
MaxDemandToQueue = options.MaxDemandToQueue,
AppearancePreference = options.AppearancePreference
}, cancellationToken);
var diagnostics = await memory.GetDiagnosticsAsync(importSessionId);
return new StoryMemoryRebuildResult
{
ImportSessionID = importSessionId,
DryRun = false,
SceneResultsRead = runScenes.Count,
SceneResultsProcessed = processedCount,
CharactersDiscovered = diagnostics.CharacterCount,
LocationsDiscovered = diagnostics.LocationCount,
AssetsDiscovered = diagnostics.AssetCount,
RelationshipsDiscovered = diagnostics.RelationshipCount,
AssignmentsCreatedOrValidated = diagnostics.AssignmentCount,
DemandsCreated = diagnostics.DemandCount,
FallbacksRemaining = diagnostics.FallbackCount,
AiCalls = 0
};
}
public async Task ProcessSceneResultAsync(int importSessionId, int userId, int sceneResultId, StoryMemoryResolveOptions options, CancellationToken cancellationToken = default)
{
var session = await pipelines.GetByIdForUserAsync(importSessionId, userId);
if (session is null)
{
logger.LogWarning("Story Memory skipped scene result {SceneResultId}: import session {ImportSessionId} was not visible to user {UserId}.", sceneResultId, importSessionId, userId);
return;
}
var scene = (await LoadImportScenesAsync(session, cancellationToken)).FirstOrDefault(item => item.Result.SceneResultID == sceneResultId);
if (scene is null || scene.Scene is null)
{
return;
}
await UpsertSceneMemoryAsync(session, scene.Result, scene.Scene, cancellationToken);
await memory.MarkSceneResultProcessedAsync(importSessionId, sceneResultId, ProcessorVersion, StableContentHash(scene.Result.ParsedJson));
await ResolveIllustrationsAsync(importSessionId, options, cancellationToken);
}
public async Task ResolveIllustrationsAsync(int importSessionId, StoryMemoryResolveOptions options, CancellationToken cancellationToken = default)
{
var characters = await memory.ListCharactersAsync(importSessionId);
var attributes = await memory.ListCharacterAttributesAsync(importSessionId);
var locations = await memory.ListLocationsAsync(importSessionId);
var assets = await memory.ListAssetsAsync(importSessionId);
var assignments = await memory.ListAssignmentsAsync(importSessionId);
var catalogue = (await illustrationLibrary.ListAsync(new IllustrationLibraryFilter()))
.Where(item => item.Status is IllustrationLibraryStatuses.Approved or IllustrationLibraryStatuses.Generated)
.Where(item => ResolveUploadUrl(item) is not null)
.ToList();
var usedItemIds = assignments
.Where(item => item.IllustrationLibraryItemID.HasValue)
.Select(item => item.IllustrationLibraryItemID!.Value)
.ToHashSet();
foreach (var character in characters.Where(item => item.EntityContext == "Story" && !item.IsGroupEntity))
{
cancellationToken.ThrowIfCancellationRequested();
var evidence = CharacterEvidence(character, attributes.Where(item => item.StoryMemoryCharacterID == character.StoryMemoryCharacterID));
var existing = assignments.FirstOrDefault(item => item.EntityType == StoryMemoryEntityTypes.Character && item.EntityID == character.StoryMemoryCharacterID);
var existingItem = existing?.IllustrationLibraryItemID is int existingId
? catalogue.FirstOrDefault(item => item.IllustrationLibraryItemID == existingId)
: null;
var existingValid = existingItem is not null && StoryIntelligenceIllustrationCompatibility
.CharacterHardRejections(evidence, StoryIntelligenceIllustrationCompatibility.CharacterMetadataFrom(existingItem), character.IsNamed, false)
.Count == 0;
var candidates = catalogue
.Where(item => item.Category == IllustrationLibraryCategories.Character)
.Select(item => ScoreCharacter(item, evidence, character.IsNamed, usedItemIds.Contains(item.IllustrationLibraryItemID) && item.IllustrationLibraryItemID != existing?.IllustrationLibraryItemID))
.Where(item => item.IsCompatible)
.OrderByDescending(item => item.Score)
.ThenBy(item => item.Item!.StableCode)
.ToList();
var chosen = candidates.FirstOrDefault();
if (chosen.Item is not null)
{
usedItemIds.Add(chosen.Item.IllustrationLibraryItemID);
await memory.UpsertAssignmentAsync(new StoryMemoryIllustrationAssignmentSave(
importSessionId,
StoryMemoryEntityTypes.Character,
character.StoryMemoryCharacterID,
chosen.Item.IllustrationLibraryItemID,
chosen.Item.StableCode,
"Assigned",
false,
chosen.Score,
Math.Min(0.99m, chosen.Score / 200m),
existingValid && existingItem?.IllustrationLibraryItemID == chosen.Item.IllustrationLibraryItemID
? $"Retained {chosen.Item.StableCode}; durable evidence passed hard revalidation."
: $"Assigned {chosen.Item.StableCode}; durable Story Memory was resolved against hard character constraints.",
ProcessorVersion));
continue;
}
await RecordDemandAsync(importSessionId, character.ProjectID, StoryMemoryEntityTypes.Character, DemandKey(character.CanonicalIdentityKey, evidence.AgeBand, evidence.Presentation), cancellationToken);
await memory.UpsertAssignmentAsync(new StoryMemoryIllustrationAssignmentSave(importSessionId, StoryMemoryEntityTypes.Character, character.StoryMemoryCharacterID, null, null, "DemandRecorded", true, 0, 0, "No compatible character illustration exists; durable demand recorded.", ProcessorVersion));
}
foreach (var location in locations)
{
cancellationToken.ThrowIfCancellationRequested();
var requestedType = StoryIntelligenceIllustrationCompatibility.LocationType($"{location.DisplayName} {location.SemanticType} {location.Subtype}");
var chosen = catalogue
.Where(item => item.Category == IllustrationLibraryCategories.Location)
.Select(item => (Item: item, Type: StoryIntelligenceIllustrationCompatibility.LocationType($"{item.StableCode} {item.DisplayTitle} {item.ShortDescription} {item.MetadataJson} {item.SpecificationJson}")))
.Where(item => StoryIntelligenceIllustrationCompatibility.LocationCompatible(requestedType, item.Type))
.OrderByDescending(item => string.Equals(item.Type, requestedType, StringComparison.OrdinalIgnoreCase) ? 100 : 50)
.ThenBy(item => item.Item.StableCode)
.FirstOrDefault();
if (chosen.Item is not null)
{
await memory.UpsertAssignmentAsync(new StoryMemoryIllustrationAssignmentSave(importSessionId, StoryMemoryEntityTypes.Location, location.StoryMemoryLocationID, chosen.Item.IllustrationLibraryItemID, chosen.Item.StableCode, "Assigned", false, 100, 0.9m, $"Assigned {chosen.Item.StableCode}; location type {requestedType} matched {chosen.Type}.", ProcessorVersion));
}
else
{
await RecordDemandAsync(importSessionId, location.ProjectID, StoryMemoryEntityTypes.Location, DemandKey(location.CanonicalIdentityKey, requestedType), cancellationToken);
await memory.UpsertAssignmentAsync(new StoryMemoryIllustrationAssignmentSave(importSessionId, StoryMemoryEntityTypes.Location, location.StoryMemoryLocationID, null, null, "DemandRecorded", true, 0, 0, $"No compatible {requestedType} location illustration exists; demand recorded.", ProcessorVersion));
}
}
foreach (var asset in assets)
{
cancellationToken.ThrowIfCancellationRequested();
var requestedType = StoryIntelligenceIllustrationCompatibility.AssetType($"{asset.DisplayName} {asset.SemanticType} {asset.Subtype}");
var requestedColour = StoryIntelligenceIllustrationCompatibility.AssetColour($"{asset.DisplayName} {asset.Colour}");
var chosen = catalogue
.Where(item => item.Category == IllustrationLibraryCategories.Asset)
.Select(item => (Item: item, Type: StoryIntelligenceIllustrationCompatibility.AssetType($"{item.StableCode} {item.DisplayTitle} {item.ShortDescription} {item.MetadataJson} {item.SpecificationJson}"), Colour: StoryIntelligenceIllustrationCompatibility.AssetColour($"{item.StableCode} {item.DisplayTitle} {item.ShortDescription} {item.MetadataJson} {item.SpecificationJson}")))
.Where(item => StoryIntelligenceIllustrationCompatibility.AssetCompatible(requestedType, item.Type))
.Where(item => requestedColour == "Unknown" || item.Colour == "Unknown" || string.Equals(requestedColour, item.Colour, StringComparison.OrdinalIgnoreCase))
.OrderByDescending(item => string.Equals(item.Type, requestedType, StringComparison.OrdinalIgnoreCase) ? 100 : 50)
.ThenByDescending(item => string.Equals(item.Colour, requestedColour, StringComparison.OrdinalIgnoreCase) ? 10 : 0)
.ThenBy(item => item.Item.StableCode)
.FirstOrDefault();
if (chosen.Item is not null)
{
await memory.UpsertAssignmentAsync(new StoryMemoryIllustrationAssignmentSave(importSessionId, StoryMemoryEntityTypes.Asset, asset.StoryMemoryAssetID, chosen.Item.IllustrationLibraryItemID, chosen.Item.StableCode, "Assigned", false, 100, 0.9m, $"Assigned {chosen.Item.StableCode}; asset type {requestedType} matched {chosen.Type}.", ProcessorVersion));
}
else
{
await RecordDemandAsync(importSessionId, asset.ProjectID, StoryMemoryEntityTypes.Asset, DemandKey(asset.CanonicalIdentityKey, requestedType, requestedColour), cancellationToken);
await memory.UpsertAssignmentAsync(new StoryMemoryIllustrationAssignmentSave(importSessionId, StoryMemoryEntityTypes.Asset, asset.StoryMemoryAssetID, null, null, "DemandRecorded", true, 0, 0, $"No compatible {requestedType} asset illustration exists; demand recorded.", ProcessorVersion));
}
}
}
private async Task UpsertSceneMemoryAsync(StoryIntelligenceBookPipelineState session, StoryIntelligenceSavedSceneResult result, SceneIntelligenceScene scene, CancellationToken cancellationToken)
{
var context = SourceSectionType(result, scene);
var sourceSectionType = context == "Story" ? "Story" : "Authorial";
foreach (var sceneCharacter in scene.Characters ?? [])
{
cancellationToken.ThrowIfCancellationRequested();
if (string.IsNullOrWhiteSpace(sceneCharacter.Name))
{
continue;
}
var key = CharacterKey(sceneCharacter.Name);
var isGroup = StoryIntelligenceIllustrationCompatibility.IsGroupEntity(sceneCharacter.Name);
var character = await memory.UpsertCharacterAsync(new StoryMemoryCharacterSave(
session.StoryIntelligenceBookPipelineID,
session.ProjectID,
session.BookID,
key,
sceneCharacter.Name.Trim(),
context,
sourceSectionType,
IsNamedCharacter(sceneCharacter.Name),
isGroup,
true,
string.Equals(scene.PointOfView?.CharacterName, sceneCharacter.Name, StringComparison.OrdinalIgnoreCase),
string.Equals(scene.PointOfView?.CharacterName, sceneCharacter.Name, StringComparison.OrdinalIgnoreCase),
CharacterSignificance(sceneCharacter),
result.SceneResultID));
await memory.AddCharacterAliasAsync(character.StoryMemoryCharacterID, sceneCharacter.Name.Trim(), Normalise(sceneCharacter.Name), "Scene character name", result.SceneResultID, sceneCharacter.Confidence ?? 0.8m);
foreach (var alias in sceneCharacter.Aliases ?? [])
{
await memory.AddCharacterAliasAsync(character.StoryMemoryCharacterID, alias, Normalise(alias), "Scene character alias", result.SceneResultID, sceneCharacter.Confidence ?? 0.7m);
}
var identityEvidence = new[] { sceneCharacter.Name, sceneCharacter.RoleInScene, sceneCharacter.Notes }
.Concat(sceneCharacter.Actions ?? [])
.Where(value => !string.IsNullOrWhiteSpace(value))
.Select(value => value!);
var relationshipEvidence = (scene.Relationships ?? [])
.Where(item => string.Equals(item.CharacterA, sceneCharacter.Name, StringComparison.OrdinalIgnoreCase) || string.Equals(item.CharacterB, sceneCharacter.Name, StringComparison.OrdinalIgnoreCase))
.Select(item => $"{item.RelationshipSignal} {item.Evidence}");
var inferred = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence(sceneCharacter.Name, identityEvidence, relationshipEvidence);
await AddKnownAttributeAsync(character.StoryMemoryCharacterID, "AgeBand", inferred.AgeBand, inferred.AgeConfidence, false, "Deterministic evidence inference", result.SceneResultID);
await AddKnownAttributeAsync(character.StoryMemoryCharacterID, "Presentation", inferred.Presentation, inferred.PresentationConfidence, inferred.PresentationConfidence >= 0.8m, "Deterministic evidence inference", result.SceneResultID);
await AddKnownAttributeAsync(character.StoryMemoryCharacterID, "HairColour", inferred.HairColour, 0.7m, false, "Deterministic evidence inference", result.SceneResultID);
await AddKnownAttributeAsync(character.StoryMemoryCharacterID, "SkinTone", inferred.SkinTone, 0.7m, false, "Deterministic evidence inference", result.SceneResultID);
await AddKnownAttributeAsync(character.StoryMemoryCharacterID, "HairLength", inferred.HairLength, 0.6m, false, "Deterministic evidence inference", result.SceneResultID);
}
if (!string.IsNullOrWhiteSpace(scene.Setting?.LocationName))
{
await UpsertLocationAsync(session, result, context, sourceSectionType, scene.Setting.LocationName, scene.Setting.LocationType, scene.Setting.GenericRoomType, scene.Setting.ParentLocationHint, scene.Setting.Confidence);
}
foreach (var location in scene.Locations ?? [])
{
if (!string.IsNullOrWhiteSpace(location.Name))
{
await UpsertLocationAsync(session, result, context, sourceSectionType, location.Name, location.LocationType, location.GenericRoomType, location.ParentLocationHint, location.Confidence);
}
}
foreach (var asset in scene.Assets ?? [])
{
if (string.IsNullOrWhiteSpace(asset.Name))
{
continue;
}
var semantic = StoryIntelligenceIllustrationCompatibility.AssetType($"{asset.Name} {asset.AssetType} {asset.Notes}");
await memory.UpsertAssetAsync(new StoryMemoryAssetSave(
session.StoryIntelligenceBookPipelineID,
session.ProjectID,
session.BookID,
AssetKey(asset.Name),
asset.Name.Trim(),
semantic,
asset.AssetType,
StoryIntelligenceIllustrationCompatibility.AssetColour($"{asset.Name} {asset.Notes}"),
null,
result.SceneResultID));
}
foreach (var relationship in scene.Relationships ?? [])
{
if (string.IsNullOrWhiteSpace(relationship.CharacterA) || string.IsNullOrWhiteSpace(relationship.CharacterB))
{
continue;
}
var source = await memory.UpsertCharacterAsync(new StoryMemoryCharacterSave(session.StoryIntelligenceBookPipelineID, session.ProjectID, session.BookID, CharacterKey(relationship.CharacterA), relationship.CharacterA.Trim(), context, sourceSectionType, IsNamedCharacter(relationship.CharacterA), StoryIntelligenceIllustrationCompatibility.IsGroupEntity(relationship.CharacterA), true, false, false, 40, result.SceneResultID));
var target = await memory.UpsertCharacterAsync(new StoryMemoryCharacterSave(session.StoryIntelligenceBookPipelineID, session.ProjectID, session.BookID, CharacterKey(relationship.CharacterB), relationship.CharacterB.Trim(), context, sourceSectionType, IsNamedCharacter(relationship.CharacterB), StoryIntelligenceIllustrationCompatibility.IsGroupEntity(relationship.CharacterB), true, false, false, 40, result.SceneResultID));
await memory.UpsertRelationshipAsync(session.StoryIntelligenceBookPipelineID, StoryMemoryEntityTypes.Character, source.StoryMemoryCharacterID, StoryMemoryEntityTypes.Character, target.StoryMemoryCharacterID, relationship.RelationshipSignal ?? "related", relationship.Confidence ?? 0.6m, !string.IsNullOrWhiteSpace(relationship.Evidence), result.SceneResultID);
}
}
private async Task UpsertLocationAsync(StoryIntelligenceBookPipelineState session, StoryIntelligenceSavedSceneResult result, string context, string sourceSectionType, string name, string? locationType, string? genericRoomType, string? parent, decimal? confidence)
{
var semantic = StoryIntelligenceIllustrationCompatibility.LocationType($"{name} {locationType} {genericRoomType}");
await memory.UpsertLocationAsync(new StoryMemoryLocationSave(session.StoryIntelligenceBookPipelineID, session.ProjectID, session.BookID, LocationKey(name), name.Trim(), semantic, genericRoomType ?? locationType, InferInteriorExterior(semantic), result.SceneResultID));
}
private async Task AddKnownAttributeAsync(int characterId, string attributeType, string value, decimal confidence, bool isExplicit, string evidence, int sceneResultId)
{
if (string.IsNullOrWhiteSpace(value) || string.Equals(value, "Unknown", StringComparison.OrdinalIgnoreCase))
{
return;
}
await memory.AddCharacterAttributeAsync(characterId, attributeType, value, confidence <= 0 ? 0.6m : confidence, isExplicit, evidence, sceneResultId);
}
private async Task<IReadOnlyList<SceneResultWithParse>> LoadImportScenesAsync(StoryIntelligenceBookPipelineState session, CancellationToken cancellationToken)
{
var runs = (await results.ListRunsAsync())
.Where(run => run.ProjectID == session.ProjectID && run.BookID == session.BookID)
.Select(run => new StoryIntelligenceSavedRun
{
StoryIntelligenceRunID = run.StoryIntelligenceRunID,
UserID = run.UserID,
ProjectID = run.ProjectID,
ProjectTitle = run.ProjectTitle,
BookID = run.BookID,
BookTitle = run.BookTitle,
ChapterID = run.ChapterID,
ChapterNumber = run.ChapterNumber,
Status = run.Status,
SourceType = run.SourceType,
CreatedUtc = run.CreatedUtc,
UpdatedUtc = run.CreatedUtc
})
.ToList();
var items = new List<SceneResultWithParse>();
foreach (var run in runs.OrderBy(item => item.ChapterNumber ?? decimal.MaxValue).ThenBy(item => item.StoryIntelligenceRunID))
{
cancellationToken.ThrowIfCancellationRequested();
var sceneResults = await results.ListSceneResultsAsync(run.StoryIntelligenceRunID);
items.AddRange(sceneResults.Select(item => new SceneResultWithParse(run, item, TryReadScene(item, out var error), error)));
}
return items
.OrderBy(item => item.Run.ChapterNumber ?? decimal.MaxValue)
.ThenBy(item => item.Result.TemporarySceneNumber)
.ThenBy(item => item.Result.SceneResultID)
.ToList();
}
private static SceneIntelligenceScene? TryReadScene(StoryIntelligenceSavedSceneResult result, out string? safeError)
{
safeError = null;
try
{
if (!string.IsNullOrWhiteSpace(result.ParsedJson))
{
var parsed = JsonSerializer.Deserialize<SceneIntelligenceScene>(result.ParsedJson, JsonOptions);
if (parsed?.SceneReference is not null || parsed?.Summary is not null || parsed?.Characters is not null)
{
return parsed;
}
using var document = JsonDocument.Parse(result.ParsedJson);
if (document.RootElement.TryGetProperty("parsedScene", out var parsedScene))
{
return parsedScene.Deserialize<SceneIntelligenceScene>(JsonOptions);
}
}
if (!string.IsNullOrWhiteSpace(result.OutputTextJson))
{
return JsonSerializer.Deserialize<SceneIntelligenceScene>(result.OutputTextJson, JsonOptions);
}
}
catch (JsonException ex)
{
safeError = $"SceneResultID {result.SceneResultID} could not be parsed: {ex.Message}";
}
return null;
}
private CharacterEvidenceProfile CharacterEvidence(StoryMemoryCharacter character, IEnumerable<StoryMemoryCharacterAttribute> attributes)
{
var values = attributes.Select(item => $"{item.AttributeType} {item.NormalisedValue}").ToList();
return StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence(character.DisplayName, values, []);
}
private (IllustrationLibraryItem? Item, int Score, bool IsCompatible) ScoreCharacter(IllustrationLibraryItem item, CharacterEvidenceProfile evidence, bool isNamed, bool alreadyUsed)
{
var metadata = StoryIntelligenceIllustrationCompatibility.CharacterMetadataFrom(item);
var rejections = StoryIntelligenceIllustrationCompatibility.CharacterHardRejections(evidence, metadata, isNamed, alreadyUsed);
if (rejections.Count > 0)
{
return (item, 0, false);
}
var score = 25;
if (string.Equals(evidence.AgeBand, metadata.AgeBand, StringComparison.OrdinalIgnoreCase)) score += 50;
if (string.Equals(evidence.Presentation, metadata.Presentation, StringComparison.OrdinalIgnoreCase)) score += 100;
if (string.Equals(evidence.HairColour, metadata.HairColour, StringComparison.OrdinalIgnoreCase)) score += 20;
if (string.Equals(evidence.SkinTone, metadata.SkinTone, StringComparison.OrdinalIgnoreCase)) score += 10;
if (item.Status == IllustrationLibraryStatuses.Approved) score += 10;
return (item, score, true);
}
private string? ResolveUploadUrl(IllustrationLibraryItem item)
=> ResolveExistingPublicUploadUrl(item.ThumbnailPath) ?? ResolveExistingPublicUploadUrl(item.FilePath);
private string? ResolveExistingPublicUploadUrl(string? storedPath)
{
var physicalPath = uploadStorage.TryResolvePublicPath(storedPath, "uploads/story-intelligence-library");
return physicalPath is not null && File.Exists(physicalPath) ? storedPath : null;
}
private async Task RecordDemandAsync(int importSessionId, int projectId, string entityType, string archetypeKey, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
await memory.UpsertDemandAsync(new StoryMemoryIllustrationDemandSave(importSessionId, projectId, entityType, archetypeKey, 1, "Recorded", null, null));
}
private static string CharacterKey(string? name) => $"character-result-{StableKey(CanonicalCharacterIdentity(name))}";
private static string LocationKey(string? name) => $"location-result-{StableKey(name)}";
private static string AssetKey(string? name) => $"asset-result-{StableKey(name)}";
private static string DemandKey(params string[] parts) => StableKey(string.Join("-", parts.Where(part => !string.IsNullOrWhiteSpace(part))));
private static string CanonicalCharacterIdentity(string? name)
{
var clean = (name ?? string.Empty).Trim();
if (clean.Length == 0) return "unknown";
var lower = clean.ToLowerInvariant();
return lower switch
{
"i" or "me" or "myself" or "narrator" => "pov-character",
"mother" or "mum" or "her mother" or "his mother" => "mother",
"father" or "dad" or "her father" or "his father" => "father",
_ => clean
};
}
private static string Normalise(string? value)
=> string.Join(' ', (value ?? string.Empty).Trim().ToLowerInvariant().Split(' ', StringSplitOptions.RemoveEmptyEntries));
private static string StableKey(string? value)
{
var normalised = Normalise(value);
if (normalised.Length == 0) return "unknown";
var sb = new StringBuilder(normalised.Length);
foreach (var character in normalised)
{
if (char.IsLetterOrDigit(character)) sb.Append(character);
else if (sb.Length == 0 || sb[^1] != '-') sb.Append('-');
}
return sb.ToString().Trim('-');
}
private static string StableContentHash(string? value)
{
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(value ?? string.Empty));
return Convert.ToHexString(bytes).ToLowerInvariant();
}
private static bool IsNamedCharacter(string? name)
=> !string.IsNullOrWhiteSpace(name)
&& !StoryIntelligenceIllustrationCompatibility.IsGroupEntity(name)
&& char.IsUpper(name.Trim()[0]);
private static int CharacterSignificance(SceneIntelligenceCharacter character)
=> character.MentionedOnly == true ? 25 : string.Equals(character.RoleInScene, "POV", StringComparison.OrdinalIgnoreCase) ? 100 : 60;
private static string InferInteriorExterior(string semanticType)
=> semanticType is "Road" or "CarPark" or "Beach" or "Canal" or "HouseExterior" or "StreetFurniture" ? "Exterior" : "Interior";
private static string SourceSectionType(StoryIntelligenceSavedSceneResult result, SceneIntelligenceScene scene)
{
var text = $"{result.SourceLabel} {scene.Summary?.Short} {scene.ScenePurpose?.ObservedFunction}".ToLowerInvariant();
return text.Contains("author", StringComparison.Ordinal) || text.Contains("acknowledg", StringComparison.Ordinal) || text.Contains("copyright", StringComparison.Ordinal) || text.Contains("preface", StringComparison.Ordinal)
? "Authorial"
: "Story";
}
private sealed record SceneResultWithParse(StoryIntelligenceSavedRun Run, StoryIntelligenceSavedSceneResult Result, SceneIntelligenceScene? Scene, string? ParseError);
}

View File

@ -0,0 +1,28 @@
/*
Phase 21J.3 demand illustration style consistency marker.
This does not queue generation or regenerate the library. It marks existing
demand-generated character illustrations produced before template 21J.3 so the
admin can deliberately regenerate them with the corrected art direction.
*/
UPDATE dbo.IllustrationLibraryItems
SET MetadataJson = JSON_MODIFY(
JSON_MODIFY(
JSON_MODIFY(
CASE WHEN ISJSON(MetadataJson) = 1 THEN MetadataJson ELSE N'{}' END,
'$.generationSource',
N'Demand generation'),
'$.styleReview',
N'Needs 21J.3 regeneration'),
'$.styleIntent',
N'match-approved-starter-library'),
AdminNotes = CONCAT(
COALESCE(NULLIF(AdminNotes, N''), N''),
CASE WHEN NULLIF(AdminNotes, N'') IS NULL THEN N'' ELSE N' ' END,
N'Phase 21J.3: demand-generated before the 21J.3 style template; regenerate with the current template before approval.'),
UpdatedUtc = SYSUTCDATETIME()
WHERE StableCode LIKE N'char-demand-%'
AND Status IN (N'Generated', N'Approved')
AND ISNULL(PromptTemplateVersion, N'') <> N'21J.3'
AND (AdminNotes IS NULL OR AdminNotes NOT LIKE N'%Phase 21J.3:%');

View File

@ -0,0 +1,56 @@
/*
Phase 21K illustration assignment audit fields.
Adds non-breaking persistence columns used by the forensic matcher to explain,
validate and safely invalidate illustration assignments. Existing rows remain
active and are backfilled with conservative defaults.
*/
IF COL_LENGTH(N'dbo.StoryIntelligenceCharacterIllustrationAssignments', N'ImportSessionID') IS NULL
ALTER TABLE dbo.StoryIntelligenceCharacterIllustrationAssignments ADD ImportSessionID int NULL;
IF COL_LENGTH(N'dbo.StoryIntelligenceCharacterIllustrationAssignments', N'AssignmentStatus') IS NULL
ALTER TABLE dbo.StoryIntelligenceCharacterIllustrationAssignments ADD AssignmentStatus nvarchar(40) NOT NULL CONSTRAINT DF_SICharacterIllustrationAssignments_AssignmentStatus DEFAULT N'Assigned';
IF COL_LENGTH(N'dbo.StoryIntelligenceCharacterIllustrationAssignments', N'AssignedUtc') IS NULL
ALTER TABLE dbo.StoryIntelligenceCharacterIllustrationAssignments ADD AssignedUtc datetime2(0) NULL;
IF COL_LENGTH(N'dbo.StoryIntelligenceCharacterIllustrationAssignments', N'LastValidatedUtc') IS NULL
ALTER TABLE dbo.StoryIntelligenceCharacterIllustrationAssignments ADD LastValidatedUtc datetime2(0) NULL;
IF COL_LENGTH(N'dbo.StoryIntelligenceCharacterIllustrationAssignments', N'InvalidatedUtc') IS NULL
ALTER TABLE dbo.StoryIntelligenceCharacterIllustrationAssignments ADD InvalidatedUtc datetime2(0) NULL;
IF COL_LENGTH(N'dbo.StoryIntelligenceCharacterIllustrationAssignments', N'InvalidationReason') IS NULL
ALTER TABLE dbo.StoryIntelligenceCharacterIllustrationAssignments ADD InvalidationReason nvarchar(500) NULL;
IF COL_LENGTH(N'dbo.StoryIntelligenceCharacterIllustrationAssignments', N'EvidenceVersion') IS NULL
ALTER TABLE dbo.StoryIntelligenceCharacterIllustrationAssignments ADD EvidenceVersion nvarchar(40) NOT NULL CONSTRAINT DF_SICharacterIllustrationAssignments_EvidenceVersion DEFAULT N'21K';
IF COL_LENGTH(N'dbo.StoryIntelligenceCharacterIllustrationAssignments', N'IsFallback') IS NULL
ALTER TABLE dbo.StoryIntelligenceCharacterIllustrationAssignments ADD IsFallback bit NOT NULL CONSTRAINT DF_SICharacterIllustrationAssignments_IsFallback DEFAULT 0;
EXEC(N'
UPDATE dbo.StoryIntelligenceCharacterIllustrationAssignments
SET AssignedUtc = COALESCE(AssignedUtc, CreatedUtc),
LastValidatedUtc = COALESCE(LastValidatedUtc, UpdatedUtc),
EvidenceVersion = COALESCE(NULLIF(EvidenceVersion, N''''), N''21K''),
AssignmentStatus = COALESCE(NULLIF(AssignmentStatus, N''''), N''Assigned'')
WHERE AssignedUtc IS NULL
OR LastValidatedUtc IS NULL
OR NULLIF(EvidenceVersion, N'''') IS NULL
OR NULLIF(AssignmentStatus, N'''') IS NULL;
');
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'IX_SICharacterIllustrationAssignments_ImportSession' AND object_id = OBJECT_ID(N'dbo.StoryIntelligenceCharacterIllustrationAssignments'))
BEGIN
EXEC(N'CREATE INDEX IX_SICharacterIllustrationAssignments_ImportSession
ON dbo.StoryIntelligenceCharacterIllustrationAssignments(ImportSessionID, CharacterKey, IsActive)
INCLUDE (IllustrationLibraryItemID, AssignmentStatus, IsFallback, LastValidatedUtc);');
END;
IF COL_LENGTH(N'dbo.StoryIntelligenceIllustrationDemands', N'QueuedUtc') IS NULL
ALTER TABLE dbo.StoryIntelligenceIllustrationDemands ADD QueuedUtc datetime2(0) NULL;
IF COL_LENGTH(N'dbo.StoryIntelligenceIllustrationDemands', N'GenerationSource') IS NULL
ALTER TABLE dbo.StoryIntelligenceIllustrationDemands ADD GenerationSource nvarchar(80) NOT NULL CONSTRAINT DF_StoryIntelligenceIllustrationDemands_GenerationSource DEFAULT N'Demand generation';

View File

@ -0,0 +1,256 @@
SET ANSI_NULLS ON;
SET QUOTED_IDENTIFIER ON;
GO
IF OBJECT_ID(N'dbo.StoryMemoryImportPreferences', N'U') IS NULL
BEGIN
CREATE TABLE dbo.StoryMemoryImportPreferences
(
StoryMemoryImportPreferenceID int IDENTITY(1,1) NOT NULL CONSTRAINT PK_StoryMemoryImportPreferences PRIMARY KEY,
ImportSessionID int NOT NULL,
AppearancePreference nvarchar(60) NOT NULL CONSTRAINT DF_StoryMemoryImportPreferences_AppearancePreference DEFAULT N'Balanced',
ResolverVersion nvarchar(40) NOT NULL CONSTRAINT DF_StoryMemoryImportPreferences_ResolverVersion DEFAULT N'21S',
CreatedUtc datetime2(0) NOT NULL CONSTRAINT DF_StoryMemoryImportPreferences_CreatedUtc DEFAULT SYSUTCDATETIME(),
UpdatedUtc datetime2(0) NOT NULL CONSTRAINT DF_StoryMemoryImportPreferences_UpdatedUtc DEFAULT SYSUTCDATETIME(),
CONSTRAINT FK_StoryMemoryImportPreferences_ImportSession FOREIGN KEY (ImportSessionID) REFERENCES dbo.StoryIntelligenceBookPipelines(StoryIntelligenceBookPipelineID)
);
END;
GO
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'UX_StoryMemoryImportPreferences_ImportSession' AND object_id = OBJECT_ID(N'dbo.StoryMemoryImportPreferences'))
CREATE UNIQUE INDEX UX_StoryMemoryImportPreferences_ImportSession ON dbo.StoryMemoryImportPreferences(ImportSessionID);
GO
IF OBJECT_ID(N'dbo.StoryMemoryProcessedSceneResults', N'U') IS NULL
BEGIN
CREATE TABLE dbo.StoryMemoryProcessedSceneResults
(
StoryMemoryProcessedSceneResultID int IDENTITY(1,1) NOT NULL CONSTRAINT PK_StoryMemoryProcessedSceneResults PRIMARY KEY,
ImportSessionID int NOT NULL,
SceneResultID int NOT NULL,
ProcessorVersion nvarchar(40) NOT NULL,
SnapshotHash nvarchar(64) NULL,
ProcessedUtc datetime2(0) NOT NULL CONSTRAINT DF_StoryMemoryProcessedSceneResults_ProcessedUtc DEFAULT SYSUTCDATETIME(),
CONSTRAINT FK_StoryMemoryProcessedSceneResults_ImportSession FOREIGN KEY (ImportSessionID) REFERENCES dbo.StoryIntelligenceBookPipelines(StoryIntelligenceBookPipelineID),
CONSTRAINT FK_StoryMemoryProcessedSceneResults_SceneResult FOREIGN KEY (SceneResultID) REFERENCES dbo.StoryIntelligenceSceneResults(SceneResultID)
);
END;
GO
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'UX_StoryMemoryProcessedSceneResults_SceneVersion' AND object_id = OBJECT_ID(N'dbo.StoryMemoryProcessedSceneResults'))
CREATE UNIQUE INDEX UX_StoryMemoryProcessedSceneResults_SceneVersion ON dbo.StoryMemoryProcessedSceneResults(ImportSessionID, SceneResultID, ProcessorVersion);
GO
IF OBJECT_ID(N'dbo.StoryMemoryCharacters', N'U') IS NULL
BEGIN
CREATE TABLE dbo.StoryMemoryCharacters
(
StoryMemoryCharacterID int IDENTITY(1,1) NOT NULL CONSTRAINT PK_StoryMemoryCharacters PRIMARY KEY,
ImportSessionID int NOT NULL,
ProjectID int NOT NULL,
BookID int NOT NULL,
CanonicalIdentityKey nvarchar(180) NOT NULL,
DisplayName nvarchar(220) NOT NULL,
EntityContext nvarchar(40) NOT NULL CONSTRAINT DF_StoryMemoryCharacters_EntityContext DEFAULT N'Story',
SourceSectionType nvarchar(40) NOT NULL CONSTRAINT DF_StoryMemoryCharacters_SourceSectionType DEFAULT N'Story',
IsNamed bit NOT NULL CONSTRAINT DF_StoryMemoryCharacters_IsNamed DEFAULT 0,
IsGroupEntity bit NOT NULL CONSTRAINT DF_StoryMemoryCharacters_IsGroupEntity DEFAULT 0,
IsResolved bit NOT NULL CONSTRAINT DF_StoryMemoryCharacters_IsResolved DEFAULT 0,
IsNarrator bit NOT NULL CONSTRAINT DF_StoryMemoryCharacters_IsNarrator DEFAULT 0,
IsPrimaryPOV bit NOT NULL CONSTRAINT DF_StoryMemoryCharacters_IsPrimaryPOV DEFAULT 0,
Significance int NOT NULL CONSTRAINT DF_StoryMemoryCharacters_Significance DEFAULT 0,
EvidenceVersion int NOT NULL CONSTRAINT DF_StoryMemoryCharacters_EvidenceVersion DEFAULT 1,
CreatedSceneResultID int NULL,
LastUpdatedSceneResultID int NULL,
CreatedUtc datetime2(0) NOT NULL CONSTRAINT DF_StoryMemoryCharacters_CreatedUtc DEFAULT SYSUTCDATETIME(),
UpdatedUtc datetime2(0) NOT NULL CONSTRAINT DF_StoryMemoryCharacters_UpdatedUtc DEFAULT SYSUTCDATETIME(),
CONSTRAINT FK_StoryMemoryCharacters_ImportSession FOREIGN KEY (ImportSessionID) REFERENCES dbo.StoryIntelligenceBookPipelines(StoryIntelligenceBookPipelineID)
);
END;
GO
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'UX_StoryMemoryCharacters_ImportCanonical' AND object_id = OBJECT_ID(N'dbo.StoryMemoryCharacters'))
CREATE UNIQUE INDEX UX_StoryMemoryCharacters_ImportCanonical ON dbo.StoryMemoryCharacters(ImportSessionID, CanonicalIdentityKey);
GO
IF OBJECT_ID(N'dbo.StoryMemoryCharacterAliases', N'U') IS NULL
BEGIN
CREATE TABLE dbo.StoryMemoryCharacterAliases
(
StoryMemoryCharacterAliasID int IDENTITY(1,1) NOT NULL CONSTRAINT PK_StoryMemoryCharacterAliases PRIMARY KEY,
StoryMemoryCharacterID int NOT NULL,
Alias nvarchar(220) NOT NULL,
NormalisedAlias nvarchar(220) NOT NULL,
ResolutionReason nvarchar(400) NULL,
SourceSceneResultID int NULL,
Confidence decimal(5,2) NOT NULL CONSTRAINT DF_StoryMemoryCharacterAliases_Confidence DEFAULT 0,
CreatedUtc datetime2(0) NOT NULL CONSTRAINT DF_StoryMemoryCharacterAliases_CreatedUtc DEFAULT SYSUTCDATETIME(),
CONSTRAINT FK_StoryMemoryCharacterAliases_Character FOREIGN KEY (StoryMemoryCharacterID) REFERENCES dbo.StoryMemoryCharacters(StoryMemoryCharacterID)
);
END;
GO
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'UX_StoryMemoryCharacterAliases_CharacterAliasScene' AND object_id = OBJECT_ID(N'dbo.StoryMemoryCharacterAliases'))
CREATE UNIQUE INDEX UX_StoryMemoryCharacterAliases_CharacterAliasScene ON dbo.StoryMemoryCharacterAliases(StoryMemoryCharacterID, NormalisedAlias, SourceSceneResultID);
GO
IF OBJECT_ID(N'dbo.StoryMemoryCharacterAttributes', N'U') IS NULL
BEGIN
CREATE TABLE dbo.StoryMemoryCharacterAttributes
(
StoryMemoryCharacterAttributeID int IDENTITY(1,1) NOT NULL CONSTRAINT PK_StoryMemoryCharacterAttributes PRIMARY KEY,
StoryMemoryCharacterID int NOT NULL,
AttributeType nvarchar(60) NOT NULL,
NormalisedValue nvarchar(160) NOT NULL,
Confidence decimal(5,2) NOT NULL CONSTRAINT DF_StoryMemoryCharacterAttributes_Confidence DEFAULT 0,
IsExplicit bit NOT NULL CONSTRAINT DF_StoryMemoryCharacterAttributes_IsExplicit DEFAULT 0,
EvidenceSummary nvarchar(500) NULL,
SourceSceneResultID int NULL,
SupersededUtc datetime2(0) NULL,
CreatedUtc datetime2(0) NOT NULL CONSTRAINT DF_StoryMemoryCharacterAttributes_CreatedUtc DEFAULT SYSUTCDATETIME(),
CONSTRAINT FK_StoryMemoryCharacterAttributes_Character FOREIGN KEY (StoryMemoryCharacterID) REFERENCES dbo.StoryMemoryCharacters(StoryMemoryCharacterID)
);
END;
GO
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'UX_StoryMemoryCharacterAttributes_Active' AND object_id = OBJECT_ID(N'dbo.StoryMemoryCharacterAttributes'))
CREATE UNIQUE INDEX UX_StoryMemoryCharacterAttributes_Active ON dbo.StoryMemoryCharacterAttributes(StoryMemoryCharacterID, AttributeType, NormalisedValue, SourceSceneResultID) WHERE SupersededUtc IS NULL;
GO
IF OBJECT_ID(N'dbo.StoryMemoryLocations', N'U') IS NULL
BEGIN
CREATE TABLE dbo.StoryMemoryLocations
(
StoryMemoryLocationID int IDENTITY(1,1) NOT NULL CONSTRAINT PK_StoryMemoryLocations PRIMARY KEY,
ImportSessionID int NOT NULL,
ProjectID int NOT NULL,
BookID int NOT NULL,
CanonicalIdentityKey nvarchar(180) NOT NULL,
DisplayName nvarchar(220) NOT NULL,
SemanticType nvarchar(80) NOT NULL,
Subtype nvarchar(80) NULL,
InteriorExterior nvarchar(40) NULL,
ParentLocationID int NULL,
FirstSeenSceneResultID int NULL,
LastSeenSceneResultID int NULL,
CreatedUtc datetime2(0) NOT NULL CONSTRAINT DF_StoryMemoryLocations_CreatedUtc DEFAULT SYSUTCDATETIME(),
UpdatedUtc datetime2(0) NOT NULL CONSTRAINT DF_StoryMemoryLocations_UpdatedUtc DEFAULT SYSUTCDATETIME(),
CONSTRAINT FK_StoryMemoryLocations_ImportSession FOREIGN KEY (ImportSessionID) REFERENCES dbo.StoryIntelligenceBookPipelines(StoryIntelligenceBookPipelineID)
);
END;
GO
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'UX_StoryMemoryLocations_ImportCanonical' AND object_id = OBJECT_ID(N'dbo.StoryMemoryLocations'))
CREATE UNIQUE INDEX UX_StoryMemoryLocations_ImportCanonical ON dbo.StoryMemoryLocations(ImportSessionID, CanonicalIdentityKey);
GO
IF OBJECT_ID(N'dbo.StoryMemoryAssets', N'U') IS NULL
BEGIN
CREATE TABLE dbo.StoryMemoryAssets
(
StoryMemoryAssetID int IDENTITY(1,1) NOT NULL CONSTRAINT PK_StoryMemoryAssets PRIMARY KEY,
ImportSessionID int NOT NULL,
ProjectID int NOT NULL,
BookID int NOT NULL,
CanonicalIdentityKey nvarchar(180) NOT NULL,
DisplayName nvarchar(220) NOT NULL,
SemanticType nvarchar(80) NOT NULL,
Subtype nvarchar(80) NULL,
Colour nvarchar(40) NULL,
Era nvarchar(60) NULL,
FirstSeenSceneResultID int NULL,
LastSeenSceneResultID int NULL,
CreatedUtc datetime2(0) NOT NULL CONSTRAINT DF_StoryMemoryAssets_CreatedUtc DEFAULT SYSUTCDATETIME(),
UpdatedUtc datetime2(0) NOT NULL CONSTRAINT DF_StoryMemoryAssets_UpdatedUtc DEFAULT SYSUTCDATETIME(),
CONSTRAINT FK_StoryMemoryAssets_ImportSession FOREIGN KEY (ImportSessionID) REFERENCES dbo.StoryIntelligenceBookPipelines(StoryIntelligenceBookPipelineID)
);
END;
GO
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'UX_StoryMemoryAssets_ImportCanonical' AND object_id = OBJECT_ID(N'dbo.StoryMemoryAssets'))
CREATE UNIQUE INDEX UX_StoryMemoryAssets_ImportCanonical ON dbo.StoryMemoryAssets(ImportSessionID, CanonicalIdentityKey);
GO
IF OBJECT_ID(N'dbo.StoryMemoryRelationships', N'U') IS NULL
BEGIN
CREATE TABLE dbo.StoryMemoryRelationships
(
StoryMemoryRelationshipID int IDENTITY(1,1) NOT NULL CONSTRAINT PK_StoryMemoryRelationships PRIMARY KEY,
ImportSessionID int NOT NULL,
SourceEntityType nvarchar(40) NOT NULL,
SourceEntityID int NOT NULL,
TargetEntityType nvarchar(40) NOT NULL,
TargetEntityID int NOT NULL,
RelationshipType nvarchar(120) NOT NULL,
Confidence decimal(5,2) NOT NULL CONSTRAINT DF_StoryMemoryRelationships_Confidence DEFAULT 0,
IsExplicit bit NOT NULL CONSTRAINT DF_StoryMemoryRelationships_IsExplicit DEFAULT 0,
FirstSeenSceneResultID int NULL,
LastUpdatedSceneResultID int NULL,
Active bit NOT NULL CONSTRAINT DF_StoryMemoryRelationships_Active DEFAULT 1,
CreatedUtc datetime2(0) NOT NULL CONSTRAINT DF_StoryMemoryRelationships_CreatedUtc DEFAULT SYSUTCDATETIME(),
UpdatedUtc datetime2(0) NOT NULL CONSTRAINT DF_StoryMemoryRelationships_UpdatedUtc DEFAULT SYSUTCDATETIME(),
CONSTRAINT FK_StoryMemoryRelationships_ImportSession FOREIGN KEY (ImportSessionID) REFERENCES dbo.StoryIntelligenceBookPipelines(StoryIntelligenceBookPipelineID)
);
END;
GO
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'UX_StoryMemoryRelationships_Active' AND object_id = OBJECT_ID(N'dbo.StoryMemoryRelationships'))
CREATE UNIQUE INDEX UX_StoryMemoryRelationships_Active ON dbo.StoryMemoryRelationships(ImportSessionID, SourceEntityType, SourceEntityID, TargetEntityType, TargetEntityID, RelationshipType) WHERE Active = 1;
GO
IF OBJECT_ID(N'dbo.StoryMemoryIllustrationAssignments', N'U') IS NULL
BEGIN
CREATE TABLE dbo.StoryMemoryIllustrationAssignments
(
StoryMemoryIllustrationAssignmentID int IDENTITY(1,1) NOT NULL CONSTRAINT PK_StoryMemoryIllustrationAssignments PRIMARY KEY,
ImportSessionID int NOT NULL,
EntityType nvarchar(40) NOT NULL,
EntityID int NOT NULL,
IllustrationLibraryItemID int NULL,
StableCode nvarchar(120) NULL,
AssignmentStatus nvarchar(40) NOT NULL,
IsFallback bit NOT NULL CONSTRAINT DF_StoryMemoryIllustrationAssignments_IsFallback DEFAULT 0,
MatchScore int NOT NULL CONSTRAINT DF_StoryMemoryIllustrationAssignments_MatchScore DEFAULT 0,
Confidence decimal(5,2) NOT NULL CONSTRAINT DF_StoryMemoryIllustrationAssignments_Confidence DEFAULT 0,
AssignmentReason nvarchar(700) NULL,
EvidenceVersion nvarchar(40) NOT NULL CONSTRAINT DF_StoryMemoryIllustrationAssignments_EvidenceVersion DEFAULT N'21S',
AssignedUtc datetime2(0) NOT NULL CONSTRAINT DF_StoryMemoryIllustrationAssignments_AssignedUtc DEFAULT SYSUTCDATETIME(),
LastValidatedUtc datetime2(0) NOT NULL CONSTRAINT DF_StoryMemoryIllustrationAssignments_LastValidatedUtc DEFAULT SYSUTCDATETIME(),
InvalidatedUtc datetime2(0) NULL,
InvalidationReason nvarchar(700) NULL,
CreatedUtc datetime2(0) NOT NULL CONSTRAINT DF_StoryMemoryIllustrationAssignments_CreatedUtc DEFAULT SYSUTCDATETIME(),
UpdatedUtc datetime2(0) NOT NULL CONSTRAINT DF_StoryMemoryIllustrationAssignments_UpdatedUtc DEFAULT SYSUTCDATETIME(),
CONSTRAINT FK_StoryMemoryIllustrationAssignments_ImportSession FOREIGN KEY (ImportSessionID) REFERENCES dbo.StoryIntelligenceBookPipelines(StoryIntelligenceBookPipelineID),
CONSTRAINT FK_StoryMemoryIllustrationAssignments_Illustration FOREIGN KEY (IllustrationLibraryItemID) REFERENCES dbo.IllustrationLibraryItems(IllustrationLibraryItemID)
);
END;
GO
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'UX_StoryMemoryIllustrationAssignments_Active' AND object_id = OBJECT_ID(N'dbo.StoryMemoryIllustrationAssignments'))
CREATE UNIQUE INDEX UX_StoryMemoryIllustrationAssignments_Active ON dbo.StoryMemoryIllustrationAssignments(ImportSessionID, EntityType, EntityID) WHERE InvalidatedUtc IS NULL;
GO
IF OBJECT_ID(N'dbo.StoryMemoryIllustrationDemands', N'U') IS NULL
BEGIN
CREATE TABLE dbo.StoryMemoryIllustrationDemands
(
StoryMemoryIllustrationDemandID int IDENTITY(1,1) NOT NULL CONSTRAINT PK_StoryMemoryIllustrationDemands PRIMARY KEY,
ImportSessionID int NOT NULL,
ProjectID int NOT NULL,
EntityType nvarchar(40) NOT NULL,
ArchetypeKey nvarchar(240) NOT NULL,
DemandCount int NOT NULL CONSTRAINT DF_StoryMemoryIllustrationDemands_DemandCount DEFAULT 1,
WaitingEntityCount int NOT NULL CONSTRAINT DF_StoryMemoryIllustrationDemands_WaitingEntityCount DEFAULT 1,
Status nvarchar(40) NOT NULL CONSTRAINT DF_StoryMemoryIllustrationDemands_Status DEFAULT N'Recorded',
QueuedIllustrationLibraryItemID int NULL,
GeneratedIllustrationLibraryItemID int NULL,
CreatedUtc datetime2(0) NOT NULL CONSTRAINT DF_StoryMemoryIllustrationDemands_CreatedUtc DEFAULT SYSUTCDATETIME(),
UpdatedUtc datetime2(0) NOT NULL CONSTRAINT DF_StoryMemoryIllustrationDemands_UpdatedUtc DEFAULT SYSUTCDATETIME(),
CONSTRAINT FK_StoryMemoryIllustrationDemands_ImportSession FOREIGN KEY (ImportSessionID) REFERENCES dbo.StoryIntelligenceBookPipelines(StoryIntelligenceBookPipelineID)
);
END;
GO
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'UX_StoryMemoryIllustrationDemands_ImportEntityArchetype' AND object_id = OBJECT_ID(N'dbo.StoryMemoryIllustrationDemands'))
CREATE UNIQUE INDEX UX_StoryMemoryIllustrationDemands_ImportEntityArchetype ON dbo.StoryMemoryIllustrationDemands(ImportSessionID, EntityType, ArchetypeKey);
GO

View File

@ -531,6 +531,7 @@ public sealed class StoryIntelligenceOnboardingChapterViewModel
public int? SourceWordCount { get; init; } public int? SourceWordCount { get; init; }
public int? TotalTokens { get; init; } public int? TotalTokens { get; init; }
public long? TotalDurationMs { get; init; } public long? TotalDurationMs { get; init; }
public DateTime? UpdatedUtc { get; init; }
public decimal? EstimatedCostUSD { get; init; } public decimal? EstimatedCostUSD { get; init; }
public decimal? EstimatedCostGBP { get; init; } public decimal? EstimatedCostGBP { get; init; }
public string? FailureStage { get; init; } public string? FailureStage { get; init; }

View File

@ -5,6 +5,7 @@ public sealed class StoryIntelligenceExperiencePrototypeViewModel
public string Mode { get; init; } = "simulation"; public string Mode { get; init; } = "simulation";
public int? RunId { get; init; } public int? RunId { get; init; }
public int? ImportSessionId { get; init; } public int? ImportSessionId { get; init; }
public int? BookId { get; init; }
public string? RunStatus { get; init; } public string? RunStatus { get; init; }
public string? ChangeToken { get; init; } public string? ChangeToken { get; init; }
public DateTime GeneratedUtc { get; init; } = DateTime.UtcNow; public DateTime GeneratedUtc { get; init; } = DateTime.UtcNow;
@ -26,10 +27,17 @@ public sealed class StoryIntelligenceExperienceSnapshotDiagnostics
public int? ActiveChapterRunId { get; init; } public int? ActiveChapterRunId { get; init; }
public string SessionStatus { get; init; } = string.Empty; public string SessionStatus { get; init; } = string.Empty;
public int OverallBookProgress { get; init; } public int OverallBookProgress { get; init; }
public int AuthoritativeImportProgress { get; init; }
public int CalculatedVisualisationProgress { get; init; }
public int TotalChapters { get; init; }
public int CompletedChapters { get; init; }
public string CurrentChapter { get; init; } = string.Empty; public string CurrentChapter { get; init; } = string.Empty;
public string CurrentScene { get; init; } = string.Empty; public string CurrentScene { get; init; } = string.Empty;
public int CompletedScenes { get; init; } public int CompletedScenes { get; init; }
public int TotalScenes { get; init; } public int TotalScenes { get; init; }
public int ParsedScenes { get; init; }
public string TerminalRunSessionStatus { get; init; } = string.Empty;
public string DisplayedProgressReason { get; init; } = string.Empty;
public string ProgressSource { get; init; } = string.Empty; public string ProgressSource { get; init; } = string.Empty;
public string CurrentBackendStage { get; init; } = string.Empty; public string CurrentBackendStage { get; init; } = string.Empty;
public int StoryMemoryCharacterCount { get; init; } public int StoryMemoryCharacterCount { get; init; }
@ -72,6 +80,7 @@ public sealed class StoryIntelligenceExperienceRunOption
public sealed class StoryIntelligenceExperienceSceneState public sealed class StoryIntelligenceExperienceSceneState
{ {
public string Id { get; init; } = string.Empty; public string Id { get; init; } = string.Empty;
public bool IsCurrent { get; init; }
public string ChapterLabel { get; init; } = string.Empty; public string ChapterLabel { get; init; } = string.Empty;
public int SceneNumber { get; init; } public int SceneNumber { get; init; }
public string Title { get; init; } = string.Empty; public string Title { get; init; } = string.Empty;
@ -141,10 +150,15 @@ public sealed class StoryIntelligenceExperienceImageResolution
public bool FallbackUsed { get; init; } = true; public bool FallbackUsed { get; init; } = true;
public decimal? AssignmentConfidence { get; init; } public decimal? AssignmentConfidence { get; init; }
public int? MatchingScore { get; init; } public int? MatchingScore { get; init; }
public string? RejectedCandidates { get; init; } public string? RejectedCandidates { get; set; }
public string? ReasonChosen { get; init; } public string? ReasonChosen { get; set; }
public string? ReasonReassigned { get; init; } public string? ReasonReassigned { get; set; }
public string? DemandStatus { get; init; } public string? PreviousIllustration { get; set; }
public string? PreviousEvidence { get; set; }
public string? CurrentEvidence { get; set; }
public string? DemandKey { get; set; }
public string? EvidenceWarnings { get; set; }
public string? DemandStatus { get; set; }
public string? ResolvedPresentation { get; init; } public string? ResolvedPresentation { get; init; }
public string? ResolvedAgeBand { get; init; } public string? ResolvedAgeBand { get; init; }
public decimal? RelationshipDerivedConfidence { get; init; } public decimal? RelationshipDerivedConfidence { get; init; }

View File

@ -118,6 +118,13 @@
@foreach (var item in Model.Items) @foreach (var item in Model.Items)
{ {
var preview = !string.IsNullOrWhiteSpace(item.ThumbnailPath) ? item.ThumbnailPath : item.FilePath; var preview = !string.IsNullOrWhiteSpace(item.ThumbnailPath) ? item.ThumbnailPath : item.FilePath;
var generationSource = item.StableCode.StartsWith("char-demand-", StringComparison.OrdinalIgnoreCase)
? "Demand generation"
: item.ParentItemID.HasValue
? "Manual regeneration"
: IllustrationStarterBatchDefinition.StarterCodes.Contains(item.StableCode)
? "Starter batch"
: "Manual library item";
<article class="project-card"> <article class="project-card">
<div class="project-card__body"> <div class="project-card__body">
<div class="d-flex justify-content-between gap-2 align-items-start"> <div class="d-flex justify-content-between gap-2 align-items-start">
@ -137,6 +144,10 @@
<dd><code>@item.StableCode</code></dd> <dd><code>@item.StableCode</code></dd>
<dt>Provider</dt> <dt>Provider</dt>
<dd>@(item.Provider ?? "Not generated") @(item.Model is null ? string.Empty : $" / {item.Model}")</dd> <dd>@(item.Provider ?? "Not generated") @(item.Model is null ? string.Empty : $" / {item.Model}")</dd>
<dt>Template</dt>
<dd>@(item.PromptTemplateVersion ?? "Not generated")</dd>
<dt>Source</dt>
<dd>@generationSource</dd>
<dt>Updated</dt> <dt>Updated</dt>
<dd>@item.UpdatedUtc.ToString("yyyy-MM-dd HH:mm") UTC</dd> <dd>@item.UpdatedUtc.ToString("yyyy-MM-dd HH:mm") UTC</dd>
@if (item.ApprovedUtc.HasValue) @if (item.ApprovedUtc.HasValue)

View File

@ -18,6 +18,24 @@
"error" => "No live snapshot", "error" => "No live snapshot",
_ => "Choose a session" _ => "Choose a session"
}; };
var bootModel = Model.Mode == "real"
? new StoryIntelligenceExperiencePrototypeViewModel
{
Mode = Model.Mode,
RunId = Model.RunId,
ImportSessionId = Model.ImportSessionId,
BookId = Model.BookId,
RunStatus = Model.RunStatus,
GeneratedUtc = Model.GeneratedUtc,
IsTerminal = false,
ProjectName = Model.ProjectName,
BookTitle = Model.BookTitle,
SnapshotMessage = Model.SnapshotMessage,
Diagnostics = Model.Diagnostics,
Scenes = Model.Scenes,
ChangeToken = Model.ChangeToken
}
: Model;
} }
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en" data-theme="dark" data-bs-theme="dark"> <html lang="en" data-theme="dark" data-bs-theme="dark">
@ -28,13 +46,14 @@
<link rel="stylesheet" href="~/css/story-intelligence-experience-prototype.css" asp-append-version="true" /> <link rel="stylesheet" href="~/css/story-intelligence-experience-prototype.css" asp-append-version="true" />
</head> </head>
<body> <body>
<script id="storyExperienceData" type="application/json">@Html.Raw(JsonSerializer.Serialize(Model, jsonOptions))</script> <script id="storyExperienceData" type="application/json">@Html.Raw(JsonSerializer.Serialize(bootModel, jsonOptions))</script>
<main class="story-exp" <main class="story-exp"
data-story-experience data-story-experience
data-image-diagnostics="true" data-image-diagnostics="true"
data-mode="@Model.Mode" data-mode="@Model.Mode"
data-run-id="@Model.RunId" data-run-id="@Model.RunId"
data-book-id="@Model.BookId"
data-import-session-id="@Model.ImportSessionId" data-import-session-id="@Model.ImportSessionId"
data-snapshot-url="@(Model.ImportSessionId.HasValue ? $"/api/story-intelligence/import-sessions/{Model.ImportSessionId.Value}/visualisation-snapshot" : Model.RunId.HasValue ? $"/api/story-intelligence/runs/{Model.RunId.Value}/visualisation-snapshot" : string.Empty)" data-snapshot-url="@(Model.ImportSessionId.HasValue ? $"/api/story-intelligence/import-sessions/{Model.ImportSessionId.Value}/visualisation-snapshot" : Model.RunId.HasValue ? $"/api/story-intelligence/runs/{Model.RunId.Value}/visualisation-snapshot" : string.Empty)"
data-change-token="@Model.ChangeToken" data-change-token="@Model.ChangeToken"
@ -51,7 +70,14 @@
<span data-stage-label>Preparing</span> <span data-stage-label>Preparing</span>
<span>Scene <strong data-current-scene>1</strong></span> <span>Scene <strong data-current-scene>1</strong></span>
</div> </div>
@if (Model.Mode == "real" && Model.BookId.HasValue)
{
<a class="story-exp-background" href="/onboarding/story-intelligence/book/@Model.BookId.Value/continue">Return to Import</a>
}
else
{
<a class="story-exp-background" href="/Development/StoryIntelligenceExperience?mode=simulation">Simulation</a> <a class="story-exp-background" href="/Development/StoryIntelligenceExperience?mode=simulation">Simulation</a>
}
</header> </header>
@if (Model.AvailableRuns.Count > 0) @if (Model.AvailableRuns.Count > 0)
@ -124,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>
@ -151,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>
@ -163,9 +184,15 @@
<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>
@if (Model.Mode == "simulation")
{
<aside class="story-exp-controls" aria-label="Prototype controls"> <aside class="story-exp-controls" aria-label="Prototype controls">
<div> <div>
<span>Prototype controls</span> <span>Prototype controls</span>
@ -180,6 +207,7 @@
<input type="range" min="3500" max="10000" step="500" value="6200" data-speed-control /> <input type="range" min="3500" max="10000" step="500" value="6200" data-speed-control />
</label> </label>
</aside> </aside>
}
<details class="story-exp-diagnostics" data-diagnostics> <details class="story-exp-diagnostics" data-diagnostics>
<summary>Diagnostics</summary> <summary>Diagnostics</summary>
@ -212,6 +240,22 @@
<dt>Overall book progress</dt> <dt>Overall book progress</dt>
<dd data-diagnostic-key="overallBookProgress">@Model.Diagnostics.OverallBookProgress</dd> <dd data-diagnostic-key="overallBookProgress">@Model.Diagnostics.OverallBookProgress</dd>
</div> </div>
<div>
<dt>Authoritative import progress</dt>
<dd data-diagnostic-key="authoritativeImportProgress">@Model.Diagnostics.AuthoritativeImportProgress</dd>
</div>
<div>
<dt>Calculated visualisation progress</dt>
<dd data-diagnostic-key="calculatedVisualisationProgress">@Model.Diagnostics.CalculatedVisualisationProgress</dd>
</div>
<div>
<dt>Total chapters</dt>
<dd data-diagnostic-key="totalChapters">@Model.Diagnostics.TotalChapters</dd>
</div>
<div>
<dt>Completed chapters</dt>
<dd data-diagnostic-key="completedChapters">@Model.Diagnostics.CompletedChapters</dd>
</div>
<div> <div>
<dt>Current chapter</dt> <dt>Current chapter</dt>
<dd data-diagnostic-key="currentChapter">@Model.Diagnostics.CurrentChapter</dd> <dd data-diagnostic-key="currentChapter">@Model.Diagnostics.CurrentChapter</dd>
@ -228,6 +272,18 @@
<dt>Total scenes</dt> <dt>Total scenes</dt>
<dd data-diagnostic-key="totalScenes">@Model.Diagnostics.TotalScenes</dd> <dd data-diagnostic-key="totalScenes">@Model.Diagnostics.TotalScenes</dd>
</div> </div>
<div>
<dt>Parsed scenes</dt>
<dd data-diagnostic-key="parsedScenes">@Model.Diagnostics.ParsedScenes</dd>
</div>
<div>
<dt>Terminal run/session status</dt>
<dd data-diagnostic-key="terminalRunSessionStatus">@Model.Diagnostics.TerminalRunSessionStatus</dd>
</div>
<div>
<dt>Displayed progress reason</dt>
<dd data-diagnostic-key="displayedProgressReason">@Model.Diagnostics.DisplayedProgressReason</dd>
</div>
<div> <div>
<dt>Progress source</dt> <dt>Progress source</dt>
<dd data-diagnostic-key="progressSource">@Model.Diagnostics.ProgressSource</dd> <dd data-diagnostic-key="progressSource">@Model.Diagnostics.ProgressSource</dd>

View File

@ -0,0 +1,153 @@
@model StoryIntelligenceExperiencePrototypeViewModel
@{
ViewData["Title"] = "Story Intelligence Illustration Diagnostics";
var characters = Model.Scenes.SelectMany(scene => scene.Characters.Select(character => new { Scene = scene, Character = character })).ToList();
var locations = Model.Scenes.Select(scene => new { Scene = scene, Location = scene.Location }).ToList();
var assets = Model.Scenes.SelectMany(scene => scene.Assets.Select(asset => new { Scene = scene, Asset = asset })).ToList();
}
<section class="stack-lg">
<div>
<p class="eyebrow">Development diagnostics</p>
<h1>Story Intelligence Illustration Diagnostics</h1>
<p class="muted">Import Session @Model.ImportSessionId · @Model.BookTitle · Snapshot @Model.GeneratedUtc.ToString("yyyy-MM-dd HH:mm:ss") UTC</p>
</div>
<div class="project-card">
<div class="project-card__body">
<h2>Characters</h2>
<div class="table-responsive">
<table class="table align-middle">
<thead>
<tr>
<th>Scene</th>
<th>Identity</th>
<th>Illustration</th>
<th>Resolved evidence</th>
<th>Revalidation</th>
<th>Assignment</th>
<th>Previous / change</th>
<th>Demand</th>
<th>Demand key</th>
<th>Candidate diagnostics</th>
</tr>
</thead>
<tbody>
@foreach (var row in characters)
{
<tr>
<td>@row.Scene.SceneNumber</td>
<td><strong>@row.Character.Name</strong><br /><span class="muted">@row.Character.Id</span></td>
<td><code>@(row.Character.ImageResolution.StableCode ?? row.Character.LibraryCode)</code><br />@row.Character.ImageResolution.Status</td>
<td>
Age @Display(row.Character.ImageResolution.ResolvedAgeBand)<br />
Presentation @Display(row.Character.ImageResolution.ResolvedPresentation)<br />
<span class="text-warning">@Display(row.Character.ImageResolution.EvidenceWarnings)</span>
</td>
<td>@Revalidation(row.Character.ImageResolution)</td>
<td>@Display(row.Character.ImageResolution.ReasonChosen)<br /><span class="muted">@Display(row.Character.ImageResolution.IllustrationAllocationStatus)</span></td>
<td>
Previous illustration: <code>@Display(row.Character.ImageResolution.PreviousIllustration)</code><br />
Change: @Display(row.Character.ImageResolution.ReasonReassigned)<br />
<details>
<summary>Evidence</summary>
<pre class="small mb-0">Previous: @Display(row.Character.ImageResolution.PreviousEvidence)
Current: @Display(row.Character.ImageResolution.CurrentEvidence)</pre>
</details>
</td>
<td>@Display(row.Character.ImageResolution.DemandStatus)</td>
<td><code>@Display(row.Character.ImageResolution.DemandKey)</code></td>
<td><pre class="small mb-0">@Display(row.Character.ImageResolution.RejectedCandidates)</pre></td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
<div class="project-card">
<div class="project-card__body">
<h2>Locations</h2>
<div class="table-responsive">
<table class="table align-middle">
<thead>
<tr>
<th>Scene</th>
<th>Location</th>
<th>Semantic type</th>
<th>Illustration</th>
<th>Fallback</th>
</tr>
</thead>
<tbody>
@foreach (var row in locations)
{
var type = StoryIntelligenceIllustrationCompatibility.LocationType($"{row.Location.Name} {row.Location.Label}");
<tr>
<td>@row.Scene.SceneNumber</td>
<td><strong>@row.Location.Name</strong><br /><span class="muted">@row.Location.Id</span></td>
<td>@type</td>
<td><code>@row.Location.LibraryCode</code><br />@row.Location.ImageResolution.Status</td>
<td>@row.Location.ImageResolution.FallbackUsed</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
<div class="project-card">
<div class="project-card__body">
<h2>Assets</h2>
<div class="table-responsive">
<table class="table align-middle">
<thead>
<tr>
<th>Scene</th>
<th>Asset</th>
<th>Semantic type</th>
<th>Illustration</th>
<th>Fallback</th>
</tr>
</thead>
<tbody>
@foreach (var row in assets)
{
var type = StoryIntelligenceIllustrationCompatibility.AssetType($"{row.Asset.Name} {row.Asset.Label}");
<tr>
<td>@row.Scene.SceneNumber</td>
<td><strong>@row.Asset.Name</strong><br /><span class="muted">@row.Asset.Id</span></td>
<td>@type</td>
<td><code>@row.Asset.LibraryCode</code><br />@row.Asset.ImageResolution.Status</td>
<td>@row.Asset.ImageResolution.FallbackUsed</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
</section>
@functions {
private static string Display(string? value)
=> string.IsNullOrWhiteSpace(value) ? "none" : value;
private static string Revalidation(StoryIntelligenceExperienceImageResolution resolution)
{
if (string.Equals(resolution.Status, "GroupEntity", StringComparison.OrdinalIgnoreCase))
{
return "Group entity";
}
if (resolution.FallbackUsed)
{
return "No compatible illustration";
}
return string.IsNullOrWhiteSpace(resolution.ReasonReassigned) ? "Passed" : "Reassigned";
}
}

View File

@ -113,6 +113,10 @@
<div data-story-run-id="@chapter.RunID" <div data-story-run-id="@chapter.RunID"
data-story-chapter-title="@chapter.ChapterTitle" data-story-chapter-title="@chapter.ChapterTitle"
data-story-raw-status="@chapter.Status" data-story-raw-status="@chapter.Status"
data-story-chapter-number="@chapter.ChapterNumber"
data-story-current-stage="@(chapter.CurrentStage ?? string.Empty)"
data-story-current-scene-number="@(chapter.CompletedScenes ?? 0)"
data-story-updated-utc="@(chapter.UpdatedUtc?.ToUniversalTime().ToString("O") ?? string.Empty)"
data-story-source-word-count="@(chapter.SourceWordCount ?? 0)" data-story-source-word-count="@(chapter.SourceWordCount ?? 0)"
data-story-duration-ms="@(chapter.TotalDurationMs ?? 0)"> data-story-duration-ms="@(chapter.TotalDurationMs ?? 0)">
<span data-story-run-status>@FriendlyStatus(chapter.Status)</span> <span data-story-run-status>@FriendlyStatus(chapter.Status)</span>

View File

@ -124,7 +124,7 @@ input:focus-visible {
} }
.story-exp-stage { .story-exp-stage {
grid-template-rows: auto minmax(0, 1fr) 126px; grid-template-rows: auto minmax(0, 1fr) 170px;
} }
.story-exp-stage-copy { .story-exp-stage-copy {
@ -133,24 +133,28 @@ input:focus-visible {
.story-exp-ribbon-wrap { .story-exp-ribbon-wrap {
gap: 8px; gap: 8px;
padding: 10px; grid-template-rows: minmax(92px, auto) 46px;
padding: 10px 12px 12px;
} }
.story-exp-ribbon { .story-exp-ribbon {
min-height: 70px; gap: 12px;
min-height: 92px;
} }
.story-scene-card { .story-scene-card {
flex-basis: 126px; flex-basis: clamp(132px, 14vw, 168px);
padding: 8px 10px; min-height: 84px;
padding: 10px 12px;
} }
.story-scene-card.is-current { .story-scene-card.is-current {
flex-basis: 172px; flex-basis: clamp(188px, 18vw, 224px);
min-height: 96px;
} }
.story-exp-timeline { .story-exp-timeline {
min-height: 34px; min-height: 46px;
} }
.story-exp-controls { .story-exp-controls {
@ -160,6 +164,20 @@ input:focus-visible {
} }
} }
@media (max-width: 1400px) and (min-width: 1181px) {
.story-exp-ribbon {
gap: 12px;
}
.story-scene-card {
flex-basis: clamp(132px, 11vw, 160px);
}
.story-scene-card.is-current {
flex-basis: clamp(188px, 15vw, 220px);
}
}
.story-exp-brand strong { .story-exp-brand strong {
font-size: 1.12rem; font-size: 1.12rem;
letter-spacing: 0.02em; letter-spacing: 0.02em;
@ -270,10 +288,10 @@ input:focus-visible {
.story-exp-shell { .story-exp-shell {
display: grid; display: grid;
grid-template-columns: minmax(206px, 0.54fr) minmax(760px, 2.44fr) minmax(214px, 0.56fr); grid-template-columns: minmax(180px, 0.46fr) minmax(820px, 2.72fr) minmax(188px, 0.48fr);
gap: 16px; gap: 12px;
min-height: 0; min-height: 0;
padding: 14px 22px 74px; padding: 12px 18px 58px;
} }
.story-exp-left, .story-exp-left,
@ -281,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,
@ -306,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;
@ -427,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,
@ -482,16 +524,15 @@ input:focus-visible {
.story-exp-stage { .story-exp-stage {
display: grid; display: grid;
grid-template-rows: auto minmax(0, 1fr) 154px; grid-template-rows: auto minmax(0, 1fr) 186px;
gap: 8px; gap: 8px;
min-height: 0; min-height: 0;
} }
.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;
} }
@ -506,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 {
@ -547,7 +594,7 @@ input:focus-visible {
.story-exp-connections { .story-exp-connections {
position: absolute; position: absolute;
inset: 0; inset: 0;
z-index: 1; z-index: 60;
width: 100%; width: 100%;
height: 100%; height: 100%;
pointer-events: none; pointer-events: none;
@ -623,7 +670,7 @@ input:focus-visible {
.story-exp-zone { .story-exp-zone {
position: absolute; position: absolute;
inset: 0; inset: 0;
z-index: 2; z-index: 20;
min-width: 0; min-width: 0;
min-height: 0; min-height: 0;
pointer-events: none; pointer-events: none;
@ -654,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%;
} }
@ -665,13 +713,13 @@ 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: var(--z, 3); 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;
} }
.story-character.is-pov { .story-character.is-pov {
z-index: 7; z-index: 48;
} }
.story-character.is-context .story-character__image { .story-character.is-context .story-character__image {
@ -681,6 +729,8 @@ input:focus-visible {
.story-character__image, .story-character__image,
.story-location__image, .story-location__image,
.story-asset__image { .story-asset__image {
position: relative;
z-index: 1;
display: block; display: block;
overflow: hidden; overflow: hidden;
border: 1px solid rgba(169, 205, 240, 0.36); border: 1px solid rgba(169, 205, 240, 0.36);
@ -709,6 +759,7 @@ input:focus-visible {
.story-character__text { .story-character__text {
position: absolute; position: absolute;
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;
@ -720,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%);
@ -799,7 +850,7 @@ input:focus-visible {
gap: 10px; gap: 10px;
width: var(--node-size, 190px); width: var(--node-size, 190px);
transform: translate(var(--x, 0), var(--y, 0)); transform: translate(var(--x, 0), var(--y, 0));
z-index: 4; z-index: 42;
pointer-events: auto; pointer-events: auto;
transition: opacity 560ms ease, transform 850ms cubic-bezier(.16, .84, .28, 1); transition: opacity 560ms ease, transform 850ms cubic-bezier(.16, .84, .28, 1);
} }
@ -823,6 +874,8 @@ input:focus-visible {
} }
.story-location__text { .story-location__text {
position: relative;
z-index: 80;
display: grid; display: grid;
justify-items: center; justify-items: center;
gap: 3px; gap: 3px;
@ -869,7 +922,7 @@ input:focus-visible {
width: max(var(--node-size, 78px), 104px); width: max(var(--node-size, 78px), 104px);
opacity: var(--opacity, 1); opacity: var(--opacity, 1);
transform: translate(var(--x, 0), var(--y, 0)) scale(var(--scale, 1)); transform: translate(var(--x, 0), var(--y, 0)) scale(var(--scale, 1));
z-index: var(--z, 3); z-index: calc(var(--z, 3) + 30);
pointer-events: auto; pointer-events: auto;
transition: transform 860ms cubic-bezier(.16, .84, .28, 1), opacity 620ms ease; transition: transform 860ms cubic-bezier(.16, .84, .28, 1), opacity 620ms ease;
} }
@ -883,6 +936,8 @@ input:focus-visible {
} }
.story-asset__text { .story-asset__text {
position: relative;
z-index: 80;
display: grid; display: grid;
justify-items: center; justify-items: center;
gap: 2px; gap: 2px;
@ -917,12 +972,12 @@ input:focus-visible {
.story-exp-ribbon-wrap { .story-exp-ribbon-wrap {
display: grid; display: grid;
grid-template-rows: minmax(76px, auto) 54px; grid-template-rows: minmax(104px, auto) 56px;
gap: 10px; gap: 12px;
min-width: 0; min-width: 0;
border: 1px solid rgba(151, 185, 218, 0.1); border: 1px solid rgba(151, 185, 218, 0.1);
border-radius: 24px; border-radius: 24px;
padding: 12px 14px 14px; padding: 14px 16px 16px;
background: rgba(6, 16, 30, 0.54); background: rgba(6, 16, 30, 0.54);
box-shadow: 0 20px 58px rgba(0, 0, 0, 0.22); box-shadow: 0 20px 58px rgba(0, 0, 0, 0.22);
backdrop-filter: blur(14px); backdrop-filter: blur(14px);
@ -933,24 +988,25 @@ input:focus-visible {
display: flex; display: flex;
align-items: stretch; align-items: stretch;
justify-content: center; justify-content: center;
gap: 12px; gap: 14px;
min-height: 76px; min-height: 104px;
overflow: hidden; overflow: hidden;
} }
.story-scene-card { .story-scene-card {
flex: 0 0 136px; flex: 0 0 clamp(174px, 16vw, 218px);
display: grid; display: grid;
grid-template-rows: auto minmax(0, 1fr) auto; grid-template-rows: auto minmax(0, 1fr) auto;
align-content: start; align-content: start;
gap: 5px; gap: 7px;
border: 1px solid rgba(255, 255, 255, 0.07); border: 1px solid rgba(255, 255, 255, 0.07);
border-radius: 15px; min-height: 94px;
padding: 9px 10px; border-radius: 14px;
padding: 12px 14px;
background: rgba(255, 255, 255, 0.032); background: rgba(255, 255, 255, 0.032);
color: var(--story-muted); color: var(--story-muted);
opacity: 0.68; opacity: 0.68;
transform: scale(0.91) translateY(4px); transform: scale(0.96) translateY(3px);
transition: transform 840ms cubic-bezier(.16, .84, .28, 1), opacity 760ms ease, background 760ms ease, border-color 760ms ease, box-shadow 760ms ease; transition: transform 840ms cubic-bezier(.16, .84, .28, 1), opacity 760ms ease, background 760ms ease, border-color 760ms ease, box-shadow 760ms ease;
} }
@ -959,12 +1015,13 @@ input:focus-visible {
} }
.story-scene-card.is-current { .story-scene-card.is-current {
flex-basis: 186px; flex-basis: clamp(224px, 20vw, 280px);
min-height: 106px;
border-color: rgba(215, 168, 93, 0.64); border-color: rgba(215, 168, 93, 0.64);
background: linear-gradient(180deg, rgba(215, 168, 93, 0.18), rgba(109, 181, 255, 0.075)); background: linear-gradient(180deg, rgba(215, 168, 93, 0.18), rgba(109, 181, 255, 0.075));
color: var(--story-text); color: var(--story-text);
opacity: 1; opacity: 1;
transform: scale(1.04) translateY(0); transform: scale(1.02) translateY(0);
box-shadow: 0 18px 42px rgba(215, 168, 93, 0.16); box-shadow: 0 18px 42px rgba(215, 168, 93, 0.16);
} }
@ -972,28 +1029,28 @@ input:focus-visible {
display: -webkit-box; display: -webkit-box;
overflow: hidden; overflow: hidden;
color: inherit; color: inherit;
font-size: 0.86rem; font-size: 0.92rem;
line-height: 1.12; line-height: 1.16;
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
-webkit-line-clamp: 2; -webkit-line-clamp: 2;
} }
.story-scene-card span { .story-scene-card span {
color: var(--story-muted); color: var(--story-muted);
font-size: 0.66rem; font-size: 0.68rem;
font-weight: 800; font-weight: 800;
letter-spacing: 0.04em; letter-spacing: 0.04em;
text-transform: uppercase; text-transform: uppercase;
} }
.story-scene-card small { .story-scene-card small {
display: -webkit-box; display: block;
overflow: hidden; overflow: hidden;
color: var(--story-muted); color: var(--story-muted);
font-size: 0.68rem; font-size: 0.7rem;
line-height: 1.1; line-height: 1.15;
-webkit-box-orient: vertical; text-overflow: ellipsis;
-webkit-line-clamp: 1; white-space: nowrap;
} }
.story-exp-timeline { .story-exp-timeline {
@ -1001,7 +1058,7 @@ input:focus-visible {
display: grid; display: grid;
grid-template-columns: repeat(var(--timeline-count, 1), minmax(0, 1fr)); grid-template-columns: repeat(var(--timeline-count, 1), minmax(0, 1fr));
gap: 0; gap: 0;
min-height: 54px; min-height: 56px;
} }
.story-exp-timeline::before { .story-exp-timeline::before {
@ -1061,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 {
@ -1073,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);

View File

@ -0,0 +1,17 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 240" role="img" aria-label="Unmatched story asset">
<defs>
<linearGradient id="bg" x1="0" x2="1" y1="0" y2="1">
<stop offset="0" stop-color="#15253d"/>
<stop offset="1" stop-color="#091521"/>
</linearGradient>
<linearGradient id="edge" x1="0" x2="1" y1="0" y2="1">
<stop offset="0" stop-color="#8fb9df"/>
<stop offset="1" stop-color="#d9a85f"/>
</linearGradient>
</defs>
<rect width="240" height="240" rx="120" fill="url(#bg)"/>
<circle cx="120" cy="120" r="82" fill="none" stroke="url(#edge)" stroke-width="6" opacity=".85"/>
<path d="M84 86h72a10 10 0 0 1 10 10v48a10 10 0 0 1-10 10H84a10 10 0 0 1-10-10V96a10 10 0 0 1 10-10Z" fill="#d8e5f1" opacity=".18"/>
<path d="M96 108h48M96 126h34" stroke="#d8e5f1" stroke-width="8" stroke-linecap="round" opacity=".72"/>
<circle cx="120" cy="176" r="8" fill="#d9a85f"/>
</svg>

After

Width:  |  Height:  |  Size: 931 B

View File

@ -0,0 +1,20 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 240" role="img" aria-label="Group of people">
<defs>
<linearGradient id="bg" x1="0" x2="1" y1="0" y2="1">
<stop offset="0" stop-color="#18283d"/>
<stop offset="1" stop-color="#07131f"/>
</linearGradient>
<linearGradient id="ring" x1="0" x2="1" y1="0" y2="1">
<stop offset="0" stop-color="#8fb9df"/>
<stop offset="1" stop-color="#d9a85f"/>
</linearGradient>
</defs>
<rect width="240" height="240" rx="120" fill="url(#bg)"/>
<circle cx="120" cy="120" r="86" fill="none" stroke="url(#ring)" stroke-width="6" opacity=".85"/>
<circle cx="120" cy="92" r="26" fill="#d8e5f1" opacity=".54"/>
<path d="M74 164c8-31 28-48 46-48s38 17 46 48" fill="#d8e5f1" opacity=".32"/>
<circle cx="76" cy="112" r="20" fill="#8fb9df" opacity=".38"/>
<path d="M42 166c7-25 21-38 34-38 11 0 22 8 29 24" fill="#8fb9df" opacity=".2"/>
<circle cx="164" cy="112" r="20" fill="#d9a85f" opacity=".42"/>
<path d="M135 152c7-16 18-24 29-24 13 0 27 13 34 38" fill="#d9a85f" opacity=".2"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -77,8 +77,9 @@
}; };
if (!scenes.length) { if (!scenes.length) {
state.changeToken = "";
state.currentSnapshot = { ...model, scenes: [] };
updateDiagnostics(); updateDiagnostics();
return;
} }
function loadSceneState(index) { function loadSceneState(index) {
@ -126,7 +127,14 @@
scenes = model.scenes || []; scenes = model.scenes || [];
state.changeToken = model.changeToken || state.changeToken; state.changeToken = model.changeToken || state.changeToken;
state.terminal = !!model.isTerminal; state.terminal = !!model.isTerminal;
state.index = realMode ? Math.max(0, scenes.length - 1) : Math.min(state.index, Math.max(0, scenes.length - 1)); if (!scenes.length) {
updateDiagnostics();
if (state.terminal) stopPolling("terminal model received");
return;
}
state.index = realMode ? authoritativeSceneIndex(scenes) : Math.min(state.index, Math.max(0, scenes.length - 1));
logSnapshotSynchronisation("snapshot-applied", model, loadSceneState(state.index));
if (model.projectName) { if (model.projectName) {
text(dom.projectName, model.projectName); text(dom.projectName, model.projectName);
} }
@ -153,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);
} }
@ -338,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) {
@ -352,9 +361,7 @@
} }
function updateSceneRibbon(scene) { function updateSceneRibbon(scene) {
const currentIndex = state.index; const visible = visibleSceneWindow();
const start = Math.max(0, Math.min(scenes.length - 7, currentIndex - 3));
const visible = scenes.slice(start, start + 7);
const activeIds = new Set(visible.map((item) => item.id)); const activeIds = new Set(visible.map((item) => item.id));
visible.forEach((item) => { visible.forEach((item) => {
let card = dom.ribbon.querySelector(`[data-scene-id="${cssEscape(item.id)}"]`); let card = dom.ribbon.querySelector(`[data-scene-id="${cssEscape(item.id)}"]`);
@ -367,19 +374,19 @@
} }
card.classList.toggle("is-current", item.id === scene.id); card.classList.toggle("is-current", item.id === scene.id);
card.classList.toggle("is-complete", item.sceneNumber < scene.sceneNumber); card.classList.toggle("is-complete", item.sceneNumber < scene.sceneNumber);
text(card.querySelector("span"), `${(item.chapterLabel || "Chapter").toUpperCase()} · SCENE ${item.sceneNumber}`); text(card.querySelector("span"), `${abbreviateChapter(item.chapterLabel)} · Scene ${item.sceneNumber}`);
text(card.querySelector("strong"), compactPhrase(cleanSceneTitle(item.title), item.id === scene.id ? 44 : 30)); text(card.querySelector("strong"), compactPhrase(cleanSceneTitle(item.title), 54));
text(card.querySelector("small"), compactPhrase(item.timelineEvent, 34)); text(card.querySelector("small"), compactPhrase(item.timelineEvent || item.summary, 48));
dom.ribbon.append(card); dom.ribbon.append(card);
}); });
removeInactive(dom.ribbon, "[data-scene-id]", "sceneId", activeIds); removeInactive(dom.ribbon, "[data-scene-id]", "sceneId", activeIds);
} }
function updateTimeline(scene) { function updateTimeline(scene) {
const completed = scenes.slice(0, state.index + 1); const visible = visibleSceneWindow();
dom.timeline.style.setProperty("--timeline-count", String(completed.length)); dom.timeline.style.setProperty("--timeline-count", String(visible.length));
const activeIds = new Set(completed.map((item) => item.id)); const activeIds = new Set(visible.map((item) => item.id));
completed.forEach((item) => { visible.forEach((item) => {
let marker = dom.timeline.querySelector(`[data-timeline-id="${cssEscape(item.id)}"]`); let marker = dom.timeline.querySelector(`[data-timeline-id="${cssEscape(item.id)}"]`);
if (!marker) { if (!marker) {
marker = document.createElement("div"); marker = document.createElement("div");
@ -389,19 +396,19 @@
settleEntering(marker); settleEntering(marker);
} }
marker.classList.toggle("is-current", item.id === scene.id); marker.classList.toggle("is-current", item.id === scene.id);
const shouldLabel = shouldShowTimelineLabel(completed, item, scene); marker.classList.toggle("is-complete", item.sceneNumber < scene.sceneNumber);
marker.classList.toggle("is-label-hidden", !shouldLabel); marker.classList.remove("is-label-hidden");
text(marker.querySelector("span"), shortTimelineLabel(item)); text(marker.querySelector("span"), shortTimelineLabel(item));
dom.timeline.append(marker); dom.timeline.append(marker);
}); });
removeInactive(dom.timeline, "[data-timeline-id]", "timelineId", activeIds); removeInactive(dom.timeline, "[data-timeline-id]", "timelineId", activeIds);
} }
function shouldShowTimelineLabel(items, item, scene) { function visibleSceneWindow() {
const index = items.findIndex((candidate) => candidate.id === item.id); const visibleCount = Math.min(5, scenes.length);
if (index === 0 || index === items.length - 1 || item.id === scene.id) return true; const currentIndex = Math.max(0, Math.min(state.index, scenes.length - 1));
const interval = items.length > 12 ? 5 : items.length > 8 ? 4 : 3; const start = Math.max(0, Math.min(scenes.length - visibleCount, currentIndex - Math.floor(visibleCount / 2)));
return index % interval === 0 && Math.abs(item.sceneNumber - scene.sceneNumber) > 1; return scenes.slice(start, start + visibleCount);
} }
function shortTimelineLabel(item) { function shortTimelineLabel(item) {
@ -452,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) => {
@ -1067,6 +1094,7 @@
} }
function startTimer() { function startTimer() {
if (!scenes.length) return;
stopTimer(); stopTimer();
if (!state.playing || !simulationMode) return; if (!state.playing || !simulationMode) return;
state.timer = window.setInterval(() => { state.timer = window.setInterval(() => {
@ -1213,7 +1241,25 @@
function activeScene(snapshot) { function activeScene(snapshot) {
const snapshotScenes = snapshot?.scenes || []; const snapshotScenes = snapshot?.scenes || [];
return snapshotScenes.length ? snapshotScenes[snapshotScenes.length - 1] : null; return snapshotScenes.find((scene) => scene?.isCurrent) || (snapshotScenes.length ? snapshotScenes[snapshotScenes.length - 1] : null);
}
function authoritativeSceneIndex(snapshotScenes) {
const index = (snapshotScenes || []).findIndex((scene) => scene?.isCurrent);
return index >= 0 ? index : Math.max(0, (snapshotScenes || []).length - 1);
}
function logSnapshotSynchronisation(eventName, snapshot, displayedScene) {
if (!realMode || !window.console?.info) return;
const authoritative = activeScene(snapshot);
console.info("Story Intelligence browser synchronisation", {
event: eventName,
changeToken: snapshot?.changeToken || "",
snapshotSceneId: authoritative?.id || "",
displayedSceneId: displayedScene?.id || "",
snapshotSceneNumber: authoritative?.sceneNumber || "",
displayedSceneNumber: displayedScene?.sceneNumber || ""
});
} }
function keyedById(items) { function keyedById(items) {
@ -1386,6 +1432,7 @@
}); });
window.addEventListener("resize", () => { window.addEventListener("resize", () => {
if (!scenes.length) return;
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);
@ -1396,6 +1443,7 @@
if (window.ResizeObserver && dom.visualStage) { if (window.ResizeObserver && dom.visualStage) {
const observer = new ResizeObserver(() => { const observer = new ResizeObserver(() => {
if (!scenes.length) return;
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);
@ -1405,8 +1453,12 @@
observer.observe(dom.visualStage); observer.observe(dom.visualStage);
} }
transitionToNextState(realMode ? Math.max(0, scenes.length - 1) : 0); if (scenes.length) {
transitionToNextState(realMode ? authoritativeSceneIndex(scenes) : 0);
startTimer(); startTimer();
} else {
updateDiagnostics();
}
startPolling(); startPolling();
if (realMode && !state.terminal) pollSnapshot(); if (realMode && !state.terminal) pollSnapshot();
})(); })();

View File

@ -243,6 +243,73 @@
} }
}; };
const parseNumber = (value) => Number.parseFloat(String(value || "").replace(/,/g, "")) || 0;
const parseTime = (value) => {
const parsed = Date.parse(value || "");
return Number.isNaN(parsed) ? 0 : parsed;
};
const isActiveStatus = (status) => status === "Pending" || status === "Running";
const authoritativeChapter = (chapters) => {
const active = chapters
.filter((chapter) => isActiveStatus(chapter.dataset.storyRawStatus || "")
&& parseNumber(chapter.dataset.storyCurrentSceneNumber) > 0)
.sort((left, right) => {
const byUpdated = parseTime(right.dataset.storyUpdatedUtc) - parseTime(left.dataset.storyUpdatedUtc);
if (byUpdated !== 0) return byUpdated;
return parseNumber(right.dataset.storyChapterNumber) - parseNumber(left.dataset.storyChapterNumber);
});
if (active.length > 0) return active[0];
const completed = chapters
.filter((chapter) => {
const status = chapter.dataset.storyRawStatus || "";
return status === "Completed" || status === "CompletedWithWarnings";
})
.sort((left, right) => {
const byUpdated = parseTime(right.dataset.storyUpdatedUtc) - parseTime(left.dataset.storyUpdatedUtc);
if (byUpdated !== 0) return byUpdated;
return parseNumber(right.dataset.storyChapterNumber) - parseNumber(left.dataset.storyChapterNumber);
});
return completed[0] || chapters[0] || null;
};
const applyAuthoritativeCursor = (root) => {
const chapters = [...root.querySelectorAll("[data-story-run-id]")];
const chapter = authoritativeChapter(chapters);
if (!chapter) return;
const status = chapter.dataset.storyRawStatus || "Pending";
const stage = chapter.dataset.storyCurrentStage || status;
const currentSceneNumber = chapter.dataset.storyCurrentSceneNumber || "";
const total = chapter.querySelector("[data-story-run-total]")?.textContent || "";
const completed = chapter.querySelector("[data-story-run-completed]")?.textContent || "";
const message = chapter.querySelector("[data-story-run-message]")?.textContent || "";
root.querySelectorAll("[data-story-current-chapter]").forEach((node) => {
node.textContent = chapter.dataset.storyChapterTitle || "Current chapter";
});
root.querySelectorAll("[data-story-current-stage]").forEach((node) => {
node.textContent = friendlyStage(stage, status);
});
root.querySelectorAll("[data-story-current-scene]").forEach((node) => {
node.textContent = currentSceneNumber ? numberText(currentSceneNumber) : "Waiting";
});
const totalNumber = Number.parseInt(total.replace(/,/g, "") || "0", 10) || 0;
const completedNumber = Number.parseInt(completed.replace(/,/g, "") || "0", 10) || 0;
const currentSceneDisplay = currentSceneNumber ? Number.parseInt(currentSceneNumber, 10) || completedNumber : completedNumber;
root.querySelectorAll("[data-story-chapter-progress-text]").forEach((node) => {
node.textContent = totalNumber > 0
? `Scene ${numberText(Math.min(Math.max(currentSceneDisplay, 0), totalNumber))} of ${numberText(totalNumber)}`
: "Finding scenes...";
});
root.querySelectorAll("[data-story-current-message]").forEach((node) => {
node.textContent = message;
});
};
const recalculateOnboardingTotals = (root) => { const recalculateOnboardingTotals = (root) => {
const hadActiveRuns = root.dataset.storyIntelligenceHasActiveRuns === "true"; const hadActiveRuns = root.dataset.storyIntelligenceHasActiveRuns === "true";
const chapters = [...root.querySelectorAll("[data-story-run-id]")]; const chapters = [...root.querySelectorAll("[data-story-run-id]")];
@ -306,6 +373,7 @@
root.querySelectorAll("[data-story-remaining]").forEach((node) => { root.querySelectorAll("[data-story-remaining]").forEach((node) => {
node.textContent = calculateRemaining(chapters, active); node.textContent = calculateRemaining(chapters, active);
}); });
applyAuthoritativeCursor(root);
if (hadActiveRuns && !active && root.dataset.reloadScheduled !== "true") { if (hadActiveRuns && !active && root.dataset.reloadScheduled !== "true") {
root.dataset.reloadScheduled = "true"; root.dataset.reloadScheduled = "true";
@ -335,6 +403,7 @@
const totalDurationMs = field(state, "totalDurationMs", "TotalDurationMs"); const totalDurationMs = field(state, "totalDurationMs", "TotalDurationMs");
const elapsed = field(state, "elapsedTime", "ElapsedTime") || "0 sec"; const elapsed = field(state, "elapsedTime", "ElapsedTime") || "0 sec";
const currentSceneNumber = field(state, "currentSceneNumber", "CurrentSceneNumber"); const currentSceneNumber = field(state, "currentSceneNumber", "CurrentSceneNumber");
const updatedUtc = field(state, "updatedUtc", "UpdatedUtc");
const displayMessage = friendlyMessage(message, stage, status, currentSceneNumber); const displayMessage = friendlyMessage(message, stage, status, currentSceneNumber);
const latestSummary = field(state, "latestSceneSummary", "LatestSceneSummary"); const latestSummary = field(state, "latestSceneSummary", "LatestSceneSummary");
const discoveries = field(state, "recentDiscoveries", "RecentDiscoveries") || []; const discoveries = field(state, "recentDiscoveries", "RecentDiscoveries") || [];
@ -344,6 +413,11 @@
node.textContent = statusLabel(state); node.textContent = statusLabel(state);
}); });
chapter.dataset.storyRawStatus = status; chapter.dataset.storyRawStatus = status;
chapter.dataset.storyCurrentStage = stage;
chapter.dataset.storyCurrentSceneNumber = completed || "0";
if (updatedUtc) {
chapter.dataset.storyUpdatedUtc = updatedUtc;
}
if (totalDurationMs !== null) { if (totalDurationMs !== null) {
chapter.dataset.storyDurationMs = String(totalDurationMs); chapter.dataset.storyDurationMs = String(totalDurationMs);
} }
@ -392,30 +466,12 @@
} }
}); });
root.querySelectorAll("[data-story-current-chapter]").forEach((node) => {
node.textContent = chapter.dataset.storyChapterTitle || "Current chapter";
});
root.querySelectorAll("[data-story-current-stage]").forEach((node) => {
node.textContent = friendlyStage(stage, status);
});
root.querySelectorAll("[data-story-current-scene]").forEach((node) => {
node.textContent = currentSceneNumber ? numberText(currentSceneNumber) : "Waiting";
});
const totalNumber = Number.parseInt(total || "0", 10) || 0; const totalNumber = Number.parseInt(total || "0", 10) || 0;
const completedNumber = Number.parseInt(completed || "0", 10) || 0; const completedNumber = Number.parseInt(completed || "0", 10) || 0;
const currentSceneDisplay = currentSceneNumber ? Number.parseInt(currentSceneNumber, 10) || completedNumber : completedNumber;
root.querySelectorAll("[data-story-chapter-progress-text]").forEach((node) => {
node.textContent = totalNumber > 0
? `Scene ${numberText(Math.min(Math.max(currentSceneDisplay, 0), totalNumber))} of ${numberText(totalNumber)}`
: "Finding scenes...";
});
root.querySelectorAll("[data-story-chapter-progress-bar]").forEach((node) => { root.querySelectorAll("[data-story-chapter-progress-bar]").forEach((node) => {
const percent = totalNumber > 0 ? Math.floor((Math.min(completedNumber, totalNumber) * 100) / totalNumber) : 0; const percent = totalNumber > 0 ? Math.floor((Math.min(completedNumber, totalNumber) * 100) / totalNumber) : 0;
node.style.width = `${Math.max(0, Math.min(100, percent))}%`; node.style.width = `${Math.max(0, Math.min(100, percent))}%`;
}); });
root.querySelectorAll("[data-story-current-message]").forEach((node) => {
node.textContent = displayMessage;
});
root.querySelectorAll("[data-story-elapsed]").forEach((node) => { root.querySelectorAll("[data-story-elapsed]").forEach((node) => {
node.textContent = elapsed; node.textContent = elapsed;
}); });

View File

@ -1920,6 +1920,21 @@
let scene = null; let scene = null;
let totalWordCount = 0; let totalWordCount = 0;
let detectedHeadingChapters = 0; let detectedHeadingChapters = 0;
let manuscriptStarted = false;
const isFrontMatterHeading = (value) => {
const clean = normalizeTitleForResolve(value || "", "").toLocaleLowerCase();
return [
"title page",
"copyright",
"dedication",
"contents",
"table of contents",
"acknowledgements",
"acknowledgments",
"about the author"
].includes(clean);
};
const finishScene = () => { const finishScene = () => {
scene = null; scene = null;
@ -1982,10 +1997,15 @@
return; return;
} }
documentText.push(text);
const words = countWords(text); const words = countWords(text);
if (isBuiltInHeading(paragraph, 1)) { if (isBuiltInHeading(paragraph, 1)) {
if (!manuscriptStarted && isFrontMatterHeading(text)) {
return;
}
manuscriptStarted = true;
documentText.push(text);
detectedHeadingChapters += 1; detectedHeadingChapters += 1;
startChapter(paragraph, text); startChapter(paragraph, text);
totalWordCount += words; totalWordCount += words;
@ -1995,9 +2015,15 @@
} }
if (!chapter) { if (!chapter) {
if (!manuscriptStarted) {
return;
}
startChapter(paragraph, "Opening pages", true); startChapter(paragraph, "Opening pages", true);
} }
documentText.push(text);
if (isBuiltInHeading(paragraph, 2)) { if (isBuiltInHeading(paragraph, 2)) {
startScene(paragraph, text); startScene(paragraph, text);
totalWordCount += words; totalWordCount += words;