Compare commits
10 Commits
69e4ac8201
...
f8e320d867
| Author | SHA1 | Date | |
|---|---|---|---|
| f8e320d867 | |||
| 4e31784f46 | |||
| fd37df65c6 | |||
| 63f427066d | |||
| f80734a80c | |||
| a54da80ea2 | |||
| 7001359281 | |||
| 948ae2e54d | |||
| 2d2055f14a | |||
| ca73c9e6f3 |
@ -1,6 +1,9 @@
|
||||
using System.Text.Json;
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using Microsoft.AspNetCore.Http.Metadata;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using PlotLine.Controllers;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using PlotLine.Models;
|
||||
@ -40,7 +43,26 @@ var tests = new (string Name, Action Test)[]
|
||||
("Illustration bulk retry result reports counts", IllustrationBulkRetryResultReportsCounts),
|
||||
("Illustration bulk retry skips blocking duplicate stable codes", IllustrationBulkRetrySkipsBlockingDuplicateStableCodes),
|
||||
("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)
|
||||
@ -340,13 +362,13 @@ static void IllustrationPromptBuilderSeparatesSpecAndPrompt()
|
||||
var result = builder.Build(spec);
|
||||
|
||||
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("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("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("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(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.");
|
||||
@ -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.");
|
||||
}
|
||||
|
||||
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()
|
||||
{
|
||||
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.");
|
||||
}
|
||||
|
||||
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()
|
||||
=> new()
|
||||
{
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using PlotLine.Data;
|
||||
using PlotLine.Models;
|
||||
using PlotLine.Services;
|
||||
using PlotLine.ViewModels;
|
||||
|
||||
@ -11,6 +13,8 @@ public sealed class DevelopmentController(
|
||||
IWebHostEnvironment environment,
|
||||
ICurrentUserService currentUser,
|
||||
IIllustrationLibraryService illustrationLibrary,
|
||||
IStoryMemoryService storyMemory,
|
||||
IStoryMemoryRepository storyMemoryRepository,
|
||||
IStoryIntelligenceVisualisationSnapshotService snapshots) : Controller
|
||||
{
|
||||
[HttpGet("StoryIntelligenceExperience")]
|
||||
@ -23,10 +27,13 @@ public sealed class DevelopmentController(
|
||||
|
||||
var requestedMode = string.IsNullOrWhiteSpace(mode) ? "real" : mode.Trim();
|
||||
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)
|
||||
{
|
||||
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)
|
||||
{
|
||||
return View(ErrorModel(
|
||||
@ -83,6 +90,91 @@ public sealed class DevelopmentController(
|
||||
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)
|
||||
=> new()
|
||||
{
|
||||
|
||||
@ -490,6 +490,8 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
|
||||
}
|
||||
|
||||
[HttpPost("scan-review")]
|
||||
[RequestSizeLimit(25 * 1024 * 1024)]
|
||||
[RequestFormLimits(ValueCountLimit = 20000, KeyLengthLimit = 4096, ValueLengthLimit = 1024 * 1024)]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> SaveScanReview(ManuscriptScanReviewForm form, string intent = "save")
|
||||
{
|
||||
|
||||
@ -8,7 +8,8 @@ namespace PlotLine.Controllers;
|
||||
[Route("api/story-intelligence")]
|
||||
public sealed class StoryIntelligenceVisualisationController(
|
||||
ICurrentUserService currentUser,
|
||||
IStoryIntelligenceVisualisationSnapshotService snapshots) : ControllerBase
|
||||
IStoryIntelligenceVisualisationSnapshotService snapshots,
|
||||
ILogger<StoryIntelligenceVisualisationController> logger) : ControllerBase
|
||||
{
|
||||
[HttpGet("runs/{runId:int}/visualisation-snapshot")]
|
||||
public async Task<IActionResult> GetSnapshot(int runId)
|
||||
@ -19,6 +20,18 @@ public sealed class StoryIntelligenceVisualisationController(
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@ -31,6 +44,18 @@ public sealed class StoryIntelligenceVisualisationController(
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -17,7 +17,27 @@ public sealed class StoryIntelligenceIllustrationMatchingRepository(ISqlConnecti
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
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
|
||||
WHERE ProjectID = @ProjectID
|
||||
AND IsActive = 1;
|
||||
@ -49,10 +69,14 @@ public sealed class StoryIntelligenceIllustrationMatchingRepository(ISqlConnecti
|
||||
CandidateDiagnosticsJson = @CandidateDiagnosticsJson,
|
||||
ReasonChosen = @ReasonChosen,
|
||||
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()
|
||||
WHEN NOT MATCHED THEN
|
||||
INSERT (ProjectID, CharacterKey, CharacterName, IllustrationLibraryItemID, StableCode, MatchConfidence, MatchingScore, EvidenceJson, CandidateDiagnosticsJson, ReasonChosen, ReasonReassigned)
|
||||
VALUES (@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, 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 *
|
||||
FROM dbo.StoryIntelligenceCharacterIllustrationAssignments
|
||||
|
||||
412
PlotLine/Data/StoryMemoryRepository.cs
Normal file
412
PlotLine/Data/StoryMemoryRepository.cs
Normal 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 });
|
||||
}
|
||||
}
|
||||
@ -14,6 +14,14 @@ public sealed class StoryIntelligenceCharacterIllustrationAssignment
|
||||
public string? CandidateDiagnosticsJson { get; init; }
|
||||
public string? ReasonChosen { 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 DateTime CreatedUtc { get; init; }
|
||||
public DateTime UpdatedUtc { get; init; }
|
||||
@ -46,6 +54,8 @@ public sealed class StoryIntelligenceIllustrationDemand
|
||||
public string? FacialHair { get; init; }
|
||||
public int RequestCount { get; init; }
|
||||
public DateTime LastRequestedUtc { get; init; }
|
||||
public DateTime? QueuedUtc { get; init; }
|
||||
public string GenerationSource { get; init; } = "Demand generation";
|
||||
}
|
||||
|
||||
public sealed record StoryIntelligenceIllustrationDemandRequest(
|
||||
|
||||
231
PlotLine/Models/StoryMemoryModels.cs
Normal file
231
PlotLine/Models/StoryMemoryModels.cs
Normal 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;
|
||||
}
|
||||
@ -47,8 +47,12 @@ public class Program
|
||||
builder.Configuration.GetSection("StoryIntelligence").Bind(options);
|
||||
});
|
||||
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()
|
||||
.PersistKeysToFileSystem(new DirectoryInfo(Path.Combine(builder.Environment.ContentRootPath, "App_Data", "DataProtectionKeys")));
|
||||
.PersistKeysToFileSystem(new DirectoryInfo(dataProtectionKeyPath));
|
||||
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
|
||||
.AddCookie(options =>
|
||||
{
|
||||
@ -145,6 +149,7 @@ public class Program
|
||||
builder.Services.AddScoped<IStoryIntelligenceSourceRepository, StoryIntelligenceSourceRepository>();
|
||||
builder.Services.AddScoped<IStoryIntelligencePipelineRepository, StoryIntelligencePipelineRepository>();
|
||||
builder.Services.AddScoped<IStoryIntelligenceIllustrationMatchingRepository, StoryIntelligenceIllustrationMatchingRepository>();
|
||||
builder.Services.AddScoped<IStoryMemoryRepository, StoryMemoryRepository>();
|
||||
builder.Services.AddScoped<IIllustrationLibraryRepository, IllustrationLibraryRepository>();
|
||||
builder.Services.AddScoped<IProjectAccessRepository, ProjectAccessRepository>();
|
||||
builder.Services.AddScoped<IProjectCollaborationRepository, ProjectCollaborationRepository>();
|
||||
@ -215,6 +220,7 @@ public class Program
|
||||
builder.Services.AddScoped<IStoryIntelligenceRelationshipImportService, StoryIntelligenceRelationshipImportService>();
|
||||
builder.Services.AddScoped<IStoryIntelligenceKnowledgeImportService, StoryIntelligenceKnowledgeImportService>();
|
||||
builder.Services.AddScoped<IStoryIntelligenceVisualisationSnapshotService, StoryIntelligenceVisualisationSnapshotService>();
|
||||
builder.Services.AddScoped<IStoryMemoryService, StoryMemoryService>();
|
||||
builder.Services.AddScoped<IStoryIntelligencePipelineStateService, StoryIntelligencePipelineStateService>();
|
||||
builder.Services.AddScoped<IStoryIntelligenceProgressNotifier, StoryIntelligenceProgressNotifier>();
|
||||
builder.Services.AddScoped<ISceneImportResolver, SceneImportResolver>();
|
||||
@ -275,4 +281,21 @@ public class Program
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
@ -16,7 +16,7 @@ public interface IIllustrationPromptBuilder
|
||||
|
||||
public sealed class IllustrationPromptBuilder : IIllustrationPromptBuilder
|
||||
{
|
||||
public const string CurrentTemplateVersion = "21D.2";
|
||||
public const string CurrentTemplateVersion = "21J.3";
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
@ -73,14 +73,15 @@ public sealed class IllustrationPromptBuilder : IIllustrationPromptBuilder
|
||||
Requirements:
|
||||
- representative visual identifier only, not a canonical depiction of any manuscript
|
||||
- modern cinematic digital illustration with premium narrative game concept art quality
|
||||
- vivid natural colour, contemporary polished rendering, realistic anatomy and believable proportions
|
||||
- bright balanced lighting, clear facial illumination where faces are present, and strong local contrast
|
||||
- subdued atmospheric background treatment with contemporary polished rendering, realistic anatomy and believable proportions
|
||||
- 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
|
||||
- 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
|
||||
- subtle depth of field, clean modern rendering, and enough exposure to remain clear inside PlotDirector's dark navy interface
|
||||
- no visible text, captions, logos, watermarks, UI, signatures, or typography
|
||||
- no anime, cartoon, Pixar-like, caricature, celebrity likeness, or photoreal identity claim
|
||||
- no bright flat studio backdrop, no plain colour-block background, no stock-photo lighting, and no cartoon simplicity
|
||||
- avoid private manuscript details, real names, excerpts, or recognisable copyrighted characters
|
||||
- avoid muddy olive and brown grading, heavy sepia tones, underexposure, excessive darkness, low-contrast lighting, flat colour, antique oil-painting texture, distressed canvas texture, rough brushwork, overly sombre default expressions, backgrounds that merge into the subject, and monochrome or near-monochrome results
|
||||
- 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)
|
||||
=> 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.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
|
||||
@ -219,7 +220,8 @@ public static class IllustrationStarterBatchDefinition
|
||||
{
|
||||
["libraryPurpose"] = "Story Intelligence representative identifier",
|
||||
["privacy"] = "generic-no-manuscript-data",
|
||||
["style"] = "editorial-stylised-realism"
|
||||
["style"] = "editorial-stylised-realism",
|
||||
["generationSource"] = "Starter batch"
|
||||
};
|
||||
|
||||
var common = new IllustrationGenerationSpecification
|
||||
@ -923,7 +925,6 @@ public sealed class IllustrationLibraryService(
|
||||
{
|
||||
var items = await repository.ListAsync(new IllustrationLibraryFilter());
|
||||
var lookup = items
|
||||
.Where(item => IllustrationStarterBatchDefinition.StarterCodes.Contains(item.StableCode))
|
||||
.Where(item => item.Status is IllustrationLibraryStatuses.Approved or IllustrationLibraryStatuses.Generated)
|
||||
.OrderBy(item => item.Status == IllustrationLibraryStatuses.Approved ? 0 : 1)
|
||||
.ThenByDescending(item => item.UpdatedUtc)
|
||||
|
||||
@ -253,6 +253,7 @@ public sealed class OnboardingStoryIntelligenceService(
|
||||
SourceWordCount = run.SourceWordCount,
|
||||
TotalTokens = run.TotalTokens,
|
||||
TotalDurationMs = run.TotalDurationMs,
|
||||
UpdatedUtc = run.UpdatedUtc,
|
||||
EstimatedCostUSD = run.EstimatedCostUSD,
|
||||
EstimatedCostGBP = run.EstimatedCostGBP,
|
||||
FailureStage = run.FailureStage,
|
||||
@ -579,8 +580,35 @@ public sealed class OnboardingStoryIntelligenceService(
|
||||
return null;
|
||||
}
|
||||
|
||||
var committedRuns = await pipelineState.ListCommittedRunsByBookAsync(bookId, userId);
|
||||
if (committedRuns.Count == 0)
|
||||
var committedRuns = (await pipelineState.ListCommittedRunsByBookAsync(bookId, userId))
|
||||
.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;
|
||||
}
|
||||
@ -594,18 +622,7 @@ public sealed class OnboardingStoryIntelligenceService(
|
||||
BookID = state.BookID,
|
||||
ProjectName = state.ProjectName,
|
||||
BookTitle = state.BookDisplayTitle,
|
||||
Items = committedRuns
|
||||
.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()
|
||||
Items = batchItems
|
||||
};
|
||||
|
||||
var characterStageComplete = HasCompletedStage(state, StoryIntelligencePipelineStages.CharacterImport)
|
||||
|
||||
@ -13,6 +13,7 @@ public interface IPersistedStoryIntelligenceRunner
|
||||
|
||||
public sealed class PersistedStoryIntelligenceRunner(
|
||||
IStoryIntelligenceResultRepository repository,
|
||||
IStoryIntelligencePipelineRepository pipelines,
|
||||
IStoryPromptRepository prompts,
|
||||
IStoryPromptVersionService versions,
|
||||
IStoryPromptBuilder scenePromptBuilder,
|
||||
@ -22,6 +23,7 @@ public sealed class PersistedStoryIntelligenceRunner(
|
||||
IOptions<StoryIntelligenceOptions> options,
|
||||
IOptions<StoryIntelligencePricingOptions> pricingOptions,
|
||||
IStoryIntelligenceProgressNotifier notifier,
|
||||
IStoryMemoryService storyMemory,
|
||||
ILogger<PersistedStoryIntelligenceRunner> logger) : IPersistedStoryIntelligenceRunner
|
||||
{
|
||||
private const string ChapterPromptFile = "Chapter-Structure-Prompt-V2.md";
|
||||
@ -304,7 +306,7 @@ public sealed class PersistedStoryIntelligenceRunner(
|
||||
|
||||
hasWarnings = hasWarnings || validation.Warnings.Count > 0;
|
||||
|
||||
await repository.SaveSceneResultAsync(
|
||||
var sceneResultId = await repository.SaveSceneResultAsync(
|
||||
run.StoryIntelligenceRunID,
|
||||
chapterResultId,
|
||||
new StoryIntelligenceSceneResultSaveRequest
|
||||
@ -329,6 +331,30 @@ public sealed class PersistedStoryIntelligenceRunner(
|
||||
TotalTokens = SumTokens(sceneAttempt.InputTokens, sceneAttempt.OutputTokens),
|
||||
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++;
|
||||
await PublishAsync(
|
||||
@ -721,7 +747,7 @@ public sealed class PersistedStoryIntelligenceRunner(
|
||||
string message,
|
||||
AiJsonParseException? parseException = null)
|
||||
{
|
||||
await repository.SaveSceneResultAsync(
|
||||
var sceneResultId = await repository.SaveSceneResultAsync(
|
||||
run.StoryIntelligenceRunID,
|
||||
chapterResultId,
|
||||
new StoryIntelligenceSceneResultSaveRequest
|
||||
@ -752,6 +778,14 @@ public sealed class PersistedStoryIntelligenceRunner(
|
||||
TotalTokens = SumTokens(parseException?.InputTokens, parseException?.OutputTokens),
|
||||
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)
|
||||
|
||||
689
PlotLine/Services/StoryIntelligenceIllustrationCompatibility.cs
Normal file
689
PlotLine/Services/StoryIntelligenceIllustrationCompatibility.cs
Normal 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);
|
||||
@ -11,6 +11,7 @@ namespace PlotLine.Services;
|
||||
public interface IStoryIntelligenceIllustrationMatchingService
|
||||
{
|
||||
Task ApplyCharacterIllustrationsAsync(int projectId, StoryIntelligenceExperiencePrototypeViewModel prototype);
|
||||
Task ApplySupportingIllustrationDemandAsync(int projectId, StoryIntelligenceExperiencePrototypeViewModel prototype);
|
||||
}
|
||||
|
||||
public sealed class StoryIntelligenceIllustrationMatchingService(
|
||||
@ -22,7 +23,7 @@ public sealed class StoryIntelligenceIllustrationMatchingService(
|
||||
{
|
||||
private const int SuitableScore = 48;
|
||||
private const int ReassignmentMargin = 24;
|
||||
private const int DemandGenerationThreshold = 3;
|
||||
private const int DemandGenerationThreshold = 1;
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
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))
|
||||
{
|
||||
if (StoryIntelligenceIllustrationCompatibility.IsGroupEntity(observation.Name))
|
||||
{
|
||||
ApplyGroupDiagnostics(prototype, observation);
|
||||
continue;
|
||||
}
|
||||
|
||||
var existing = existingAssignments.GetValueOrDefault(observation.CharacterKey);
|
||||
var ownExistingItemId = existing?.IllustrationLibraryItemID;
|
||||
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)
|
||||
: null;
|
||||
var best = allScores
|
||||
.Where(score => !score.IsAlreadyAssignedToAnotherSignificantCharacter)
|
||||
.Where(score => score.Rejections.Count == 0)
|
||||
.FirstOrDefault(score => score.Score >= SuitableScore);
|
||||
|
||||
var chosen = ChooseAssignment(observation, existing, existingScore, best);
|
||||
if (chosen is null)
|
||||
{
|
||||
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);
|
||||
continue;
|
||||
}
|
||||
|
||||
var reasonReassigned = existingScore is not null && chosen.Candidate.Item.IllustrationLibraryItemID != existing?.IllustrationLibraryItemID
|
||||
? ReassignmentReason(observation, existingScore, chosen)
|
||||
: null;
|
||||
: existing is { IllustrationLibraryItemID: null }
|
||||
? $"Assigned {chosen.Candidate.Item.StableCode} because generated demand or newly valid library art replaced a fallback assignment."
|
||||
: null;
|
||||
var reason = reasonReassigned ?? (existing is null
|
||||
? $"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 evidenceJson = JsonSerializer.Serialize(observation.Evidence, JsonOptions);
|
||||
var evidenceJson = EvidenceTraceJson(observation, existing);
|
||||
|
||||
var saved = await assignments.UpsertAssignmentAsync(new StoryIntelligenceCharacterIllustrationAssignmentSave(
|
||||
projectId,
|
||||
@ -126,10 +149,229 @@ public sealed class StoryIntelligenceIllustrationMatchingService(
|
||||
reasonReassigned));
|
||||
|
||||
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(
|
||||
CharacterObservation observation,
|
||||
StoryIntelligenceCharacterIllustrationAssignment? existing,
|
||||
@ -138,20 +380,25 @@ public sealed class StoryIntelligenceIllustrationMatchingService(
|
||||
{
|
||||
if (existingScore is not null && existingScore.Candidate.PublicUrl is not null)
|
||||
{
|
||||
if (existingScore.Rejections.Count > 0)
|
||||
{
|
||||
return best;
|
||||
}
|
||||
|
||||
if (best is null)
|
||||
{
|
||||
return existingScore;
|
||||
return existingScore.Score >= SuitableScore ? existingScore : null;
|
||||
}
|
||||
|
||||
var materiallyWrong = MateriallyWrong(existingScore.Candidate.Metadata, observation.Evidence);
|
||||
if (materiallyWrong && observation.Evidence.HasStrongPresentationEvidence)
|
||||
if (materiallyWrong)
|
||||
{
|
||||
return best;
|
||||
}
|
||||
|
||||
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)
|
||||
=> 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)
|
||||
{
|
||||
@ -219,7 +511,7 @@ public sealed class StoryIntelligenceIllustrationMatchingService(
|
||||
var presentation = NormaliseUnknown(demand.Presentation, "Androgynous");
|
||||
var hairColour = NormaliseUnknown(demand.HairColour, "Brown");
|
||||
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" };
|
||||
if (string.Equals(demand.Glasses, "Yes", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
@ -238,29 +530,102 @@ public sealed class StoryIntelligenceIllustrationMatchingService(
|
||||
Title = $"{age} {presentation.ToLowerInvariant()} character",
|
||||
Description = "Demand-created reusable character illustration for Story Intelligence matching.",
|
||||
VisualSubject = $"{age} {presentation.ToLowerInvariant()} character with {hairLength.ToLowerInvariant()} {hairColour.ToLowerInvariant()} hair and {skinTone.ToLowerInvariant()} skin tone range.",
|
||||
Composition = "clear head-and-shoulders portrait with bright readable face, clean background separation, suitable for circular cropping",
|
||||
Mood = "neutral, story-ready, emotionally readable",
|
||||
Composition = "clear head-and-shoulders cinematic portrait with readable face, subtle atmospheric story background, clean separation, suitable for circular cropping",
|
||||
Mood = "neutral, story-ready, emotionally readable, subdued and cinematic",
|
||||
ApparentAgeBand = age,
|
||||
Presentation = presentation,
|
||||
HairColour = hairColour,
|
||||
HairLength = hairLength,
|
||||
SkinTone = skinTone,
|
||||
ClothingEra = "Contemporary",
|
||||
ClothingStyle = "Plain reusable story wardrobe",
|
||||
ClothingStyle = "Understated reusable story wardrobe, not studio fashion styling",
|
||||
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"],
|
||||
Metadata = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["libraryPurpose"] = "Story Intelligence demand-generated identifier",
|
||||
["privacy"] = "generic-no-manuscript-data",
|
||||
["style"] = "editorial-stylised-realism",
|
||||
["styleIntent"] = "match-approved-starter-library",
|
||||
["generationSource"] = "Demand generation",
|
||||
["demandKey"] = demand.DemandKey,
|
||||
["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)
|
||||
{
|
||||
var observations = new Dictionary<string, CharacterObservation>(StringComparer.OrdinalIgnoreCase);
|
||||
@ -299,7 +664,7 @@ public sealed class StoryIntelligenceIllustrationMatchingService(
|
||||
|
||||
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;
|
||||
@ -307,53 +672,7 @@ public sealed class StoryIntelligenceIllustrationMatchingService(
|
||||
|
||||
private static CharacterEvidence InferEvidence(string characterName, IEnumerable<string> identityValues, IEnumerable<string> relationshipValues)
|
||||
{
|
||||
var identityText = $" {string.Join(' ', identityValues.Where(value => !string.IsNullOrWhiteSpace(value))).ToLowerInvariant()} ";
|
||||
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);
|
||||
return CharacterEvidence.From(StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence(characterName, identityValues, relationshipValues));
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
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.Presentation, candidate.Metadata.Presentation, observation.Evidence.HasStrongPresentationEvidence ? 42 : 25, "presentation");
|
||||
AddScore(observation.Evidence.HairColour, candidate.Metadata.HairColour, 20, "hair colour");
|
||||
@ -424,18 +755,6 @@ public sealed class StoryIntelligenceIllustrationMatchingService(
|
||||
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);
|
||||
|
||||
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);
|
||||
|
||||
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(
|
||||
StoryIntelligenceExperiencePrototypeViewModel prototype,
|
||||
CharacterObservation observation,
|
||||
StoryIntelligenceCharacterIllustrationAssignment? existing,
|
||||
MatchScore chosen,
|
||||
StoryIntelligenceCharacterIllustrationAssignment saved,
|
||||
string reason,
|
||||
string? reasonReassigned,
|
||||
string evidenceJson,
|
||||
string candidateDiagnostics)
|
||||
{
|
||||
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,
|
||||
ReasonChosen = reason,
|
||||
ReasonReassigned = reasonReassigned,
|
||||
PreviousIllustration = existing?.StableCode,
|
||||
PreviousEvidence = existing?.EvidenceJson,
|
||||
CurrentEvidence = evidenceJson,
|
||||
DemandKey = DemandKey(observation.Evidence),
|
||||
EvidenceWarnings = string.Join(" ", observation.Evidence.Warnings),
|
||||
DemandStatus = "Satisfied",
|
||||
ResolvedPresentation = observation.Evidence.Presentation,
|
||||
ResolvedAgeBand = observation.Evidence.AgeBand,
|
||||
@ -537,6 +865,9 @@ public sealed class StoryIntelligenceIllustrationMatchingService(
|
||||
MatchingScore = allScores.FirstOrDefault()?.Score,
|
||||
RejectedCandidates = diagnostics,
|
||||
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,
|
||||
ResolvedPresentation = observation.Evidence.Presentation,
|
||||
ResolvedAgeBand = observation.Evidence.AgeBand,
|
||||
@ -574,6 +905,40 @@ public sealed class StoryIntelligenceIllustrationMatchingService(
|
||||
private static bool IsUnknown(string? value)
|
||||
=> 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)
|
||||
=> IsUnknown(value) ? fallback : value!;
|
||||
|
||||
@ -607,10 +972,26 @@ public sealed class StoryIntelligenceIllustrationMatchingService(
|
||||
string FacialHair,
|
||||
decimal RelationshipConfidence,
|
||||
decimal NameConfidence,
|
||||
decimal PronounConfidence)
|
||||
decimal PronounConfidence,
|
||||
IReadOnlyList<string> Warnings,
|
||||
CharacterEvidenceProfile Profile)
|
||||
{
|
||||
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)
|
||||
@ -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)
|
||||
{
|
||||
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 profile = StoryIntelligenceIllustrationCompatibility.CharacterMetadataFrom(item);
|
||||
|
||||
return new(
|
||||
NormaliseAge(FirstMetadata(metadata, "ageBand", "apparentAgeBand") ?? specification?.ApparentAgeBand, item.StableCode),
|
||||
NormalisePresentation(FirstMetadata(metadata, "presentation") ?? specification?.Presentation, item.StableCode),
|
||||
NormaliseHairColour(FirstMetadata(metadata, "hairColour") ?? specification?.HairColour, item.StableCode),
|
||||
NormaliseSkinTone(FirstMetadata(metadata, "skinTone") ?? specification?.SkinTone, item.StableCode),
|
||||
NormaliseHairLength(FirstMetadata(metadata, "hairLength") ?? specification?.HairLength, item.StableCode),
|
||||
FirstMetadata(metadata, "glasses") ?? (FeatureValue(specification?.Features, "Glasses") ? "Yes" : "Unknown"),
|
||||
FirstMetadata(metadata, "facialHair") ?? (FeatureValue(specification?.Features, "Beard") ? "Beard" : FeatureValue(specification?.Features, "Moustache") ? "Moustache" : "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 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";
|
||||
profile.AgeBand,
|
||||
profile.Presentation,
|
||||
profile.HairColour,
|
||||
profile.SkinTone,
|
||||
profile.HairLength,
|
||||
profile.Glasses,
|
||||
profile.FacialHair,
|
||||
profile);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
513
PlotLine/Services/StoryMemoryServices.cs
Normal file
513
PlotLine/Services/StoryMemoryServices.cs
Normal 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);
|
||||
}
|
||||
@ -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:%');
|
||||
@ -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';
|
||||
256
PlotLine/Sql/142_Phase21S_DurableStoryMemory.sql
Normal file
256
PlotLine/Sql/142_Phase21S_DurableStoryMemory.sql
Normal 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
|
||||
@ -531,6 +531,7 @@ public sealed class StoryIntelligenceOnboardingChapterViewModel
|
||||
public int? SourceWordCount { get; init; }
|
||||
public int? TotalTokens { get; init; }
|
||||
public long? TotalDurationMs { get; init; }
|
||||
public DateTime? UpdatedUtc { get; init; }
|
||||
public decimal? EstimatedCostUSD { get; init; }
|
||||
public decimal? EstimatedCostGBP { get; init; }
|
||||
public string? FailureStage { get; init; }
|
||||
|
||||
@ -5,6 +5,7 @@ public sealed class StoryIntelligenceExperiencePrototypeViewModel
|
||||
public string Mode { get; init; } = "simulation";
|
||||
public int? RunId { get; init; }
|
||||
public int? ImportSessionId { get; init; }
|
||||
public int? BookId { get; init; }
|
||||
public string? RunStatus { get; init; }
|
||||
public string? ChangeToken { get; init; }
|
||||
public DateTime GeneratedUtc { get; init; } = DateTime.UtcNow;
|
||||
@ -26,10 +27,17 @@ public sealed class StoryIntelligenceExperienceSnapshotDiagnostics
|
||||
public int? ActiveChapterRunId { get; init; }
|
||||
public string SessionStatus { get; init; } = string.Empty;
|
||||
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 CurrentScene { get; init; } = string.Empty;
|
||||
public int CompletedScenes { 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 CurrentBackendStage { get; init; } = string.Empty;
|
||||
public int StoryMemoryCharacterCount { get; init; }
|
||||
@ -72,6 +80,7 @@ public sealed class StoryIntelligenceExperienceRunOption
|
||||
public sealed class StoryIntelligenceExperienceSceneState
|
||||
{
|
||||
public string Id { get; init; } = string.Empty;
|
||||
public bool IsCurrent { get; init; }
|
||||
public string ChapterLabel { get; init; } = string.Empty;
|
||||
public int SceneNumber { get; init; }
|
||||
public string Title { get; init; } = string.Empty;
|
||||
@ -141,10 +150,15 @@ public sealed class StoryIntelligenceExperienceImageResolution
|
||||
public bool FallbackUsed { get; init; } = true;
|
||||
public decimal? AssignmentConfidence { get; init; }
|
||||
public int? MatchingScore { get; init; }
|
||||
public string? RejectedCandidates { get; init; }
|
||||
public string? ReasonChosen { get; init; }
|
||||
public string? ReasonReassigned { get; init; }
|
||||
public string? DemandStatus { get; init; }
|
||||
public string? RejectedCandidates { get; set; }
|
||||
public string? ReasonChosen { get; set; }
|
||||
public string? ReasonReassigned { get; set; }
|
||||
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? ResolvedAgeBand { get; init; }
|
||||
public decimal? RelationshipDerivedConfidence { get; init; }
|
||||
|
||||
@ -118,6 +118,13 @@
|
||||
@foreach (var item in Model.Items)
|
||||
{
|
||||
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">
|
||||
<div class="project-card__body">
|
||||
<div class="d-flex justify-content-between gap-2 align-items-start">
|
||||
@ -137,6 +144,10 @@
|
||||
<dd><code>@item.StableCode</code></dd>
|
||||
<dt>Provider</dt>
|
||||
<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>
|
||||
<dd>@item.UpdatedUtc.ToString("yyyy-MM-dd HH:mm") UTC</dd>
|
||||
@if (item.ApprovedUtc.HasValue)
|
||||
|
||||
@ -18,6 +18,24 @@
|
||||
"error" => "No live snapshot",
|
||||
_ => "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>
|
||||
<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" />
|
||||
</head>
|
||||
<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"
|
||||
data-story-experience
|
||||
data-image-diagnostics="true"
|
||||
data-mode="@Model.Mode"
|
||||
data-run-id="@Model.RunId"
|
||||
data-book-id="@Model.BookId"
|
||||
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-change-token="@Model.ChangeToken"
|
||||
@ -51,7 +70,14 @@
|
||||
<span data-stage-label>Preparing</span>
|
||||
<span>Scene <strong data-current-scene>1</strong></span>
|
||||
</div>
|
||||
<a class="story-exp-background" href="/Development/StoryIntelligenceExperience?mode=simulation">Simulation</a>
|
||||
@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>
|
||||
}
|
||||
</header>
|
||||
|
||||
@if (Model.AvailableRuns.Count > 0)
|
||||
@ -124,8 +150,7 @@
|
||||
<section class="story-exp-stage" aria-labelledby="living-story-title">
|
||||
<div class="story-exp-stage-copy">
|
||||
<p class="story-exp-eyebrow">Living story view</p>
|
||||
<h1 id="living-story-title" data-scene-title>Reading the manuscript</h1>
|
||||
<p data-scene-summary></p>
|
||||
<p id="living-story-title" class="story-exp-scene-summary" data-scene-summary>Reading the manuscript</p>
|
||||
</div>
|
||||
|
||||
<div class="story-exp-visual" data-visual-stage>
|
||||
@ -151,10 +176,6 @@
|
||||
</section>
|
||||
|
||||
<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">
|
||||
<h2 id="knowledge-title">Knowledge threads</h2>
|
||||
<div class="story-exp-insight-list" data-knowledge></div>
|
||||
@ -163,23 +184,30 @@
|
||||
<h2 id="observations-title">Story observations</h2>
|
||||
<div class="story-exp-insight-list" data-observations></div>
|
||||
</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>
|
||||
</section>
|
||||
|
||||
<aside class="story-exp-controls" aria-label="Prototype controls">
|
||||
<div>
|
||||
<span>Prototype controls</span>
|
||||
<small>@sourceLabel</small>
|
||||
</div>
|
||||
<button type="button" data-control="restart">Restart</button>
|
||||
<button type="button" data-control="previous">Previous</button>
|
||||
<button type="button" data-control="play">Pause</button>
|
||||
<button type="button" data-control="next">Next</button>
|
||||
<label>
|
||||
Speed
|
||||
<input type="range" min="3500" max="10000" step="500" value="6200" data-speed-control />
|
||||
</label>
|
||||
</aside>
|
||||
@if (Model.Mode == "simulation")
|
||||
{
|
||||
<aside class="story-exp-controls" aria-label="Prototype controls">
|
||||
<div>
|
||||
<span>Prototype controls</span>
|
||||
<small>@sourceLabel</small>
|
||||
</div>
|
||||
<button type="button" data-control="restart">Restart</button>
|
||||
<button type="button" data-control="previous">Previous</button>
|
||||
<button type="button" data-control="play">Pause</button>
|
||||
<button type="button" data-control="next">Next</button>
|
||||
<label>
|
||||
Speed
|
||||
<input type="range" min="3500" max="10000" step="500" value="6200" data-speed-control />
|
||||
</label>
|
||||
</aside>
|
||||
}
|
||||
|
||||
<details class="story-exp-diagnostics" data-diagnostics>
|
||||
<summary>Diagnostics</summary>
|
||||
@ -212,6 +240,22 @@
|
||||
<dt>Overall book progress</dt>
|
||||
<dd data-diagnostic-key="overallBookProgress">@Model.Diagnostics.OverallBookProgress</dd>
|
||||
</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>
|
||||
<dt>Current chapter</dt>
|
||||
<dd data-diagnostic-key="currentChapter">@Model.Diagnostics.CurrentChapter</dd>
|
||||
@ -228,6 +272,18 @@
|
||||
<dt>Total scenes</dt>
|
||||
<dd data-diagnostic-key="totalScenes">@Model.Diagnostics.TotalScenes</dd>
|
||||
</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>
|
||||
<dt>Progress source</dt>
|
||||
<dd data-diagnostic-key="progressSource">@Model.Diagnostics.ProgressSource</dd>
|
||||
|
||||
@ -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";
|
||||
}
|
||||
|
||||
}
|
||||
@ -113,6 +113,10 @@
|
||||
<div data-story-run-id="@chapter.RunID"
|
||||
data-story-chapter-title="@chapter.ChapterTitle"
|
||||
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-duration-ms="@(chapter.TotalDurationMs ?? 0)">
|
||||
<span data-story-run-status>@FriendlyStatus(chapter.Status)</span>
|
||||
|
||||
@ -124,7 +124,7 @@ input:focus-visible {
|
||||
}
|
||||
|
||||
.story-exp-stage {
|
||||
grid-template-rows: auto minmax(0, 1fr) 126px;
|
||||
grid-template-rows: auto minmax(0, 1fr) 170px;
|
||||
}
|
||||
|
||||
.story-exp-stage-copy {
|
||||
@ -133,24 +133,28 @@ input:focus-visible {
|
||||
|
||||
.story-exp-ribbon-wrap {
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
grid-template-rows: minmax(92px, auto) 46px;
|
||||
padding: 10px 12px 12px;
|
||||
}
|
||||
|
||||
.story-exp-ribbon {
|
||||
min-height: 70px;
|
||||
gap: 12px;
|
||||
min-height: 92px;
|
||||
}
|
||||
|
||||
.story-scene-card {
|
||||
flex-basis: 126px;
|
||||
padding: 8px 10px;
|
||||
flex-basis: clamp(132px, 14vw, 168px);
|
||||
min-height: 84px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.story-scene-card.is-current {
|
||||
flex-basis: 172px;
|
||||
flex-basis: clamp(188px, 18vw, 224px);
|
||||
min-height: 96px;
|
||||
}
|
||||
|
||||
.story-exp-timeline {
|
||||
min-height: 34px;
|
||||
min-height: 46px;
|
||||
}
|
||||
|
||||
.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 {
|
||||
font-size: 1.12rem;
|
||||
letter-spacing: 0.02em;
|
||||
@ -270,10 +288,10 @@ input:focus-visible {
|
||||
|
||||
.story-exp-shell {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(206px, 0.54fr) minmax(760px, 2.44fr) minmax(214px, 0.56fr);
|
||||
gap: 16px;
|
||||
grid-template-columns: minmax(180px, 0.46fr) minmax(820px, 2.72fr) minmax(188px, 0.48fr);
|
||||
gap: 12px;
|
||||
min-height: 0;
|
||||
padding: 14px 22px 74px;
|
||||
padding: 12px 18px 58px;
|
||||
}
|
||||
|
||||
.story-exp-left,
|
||||
@ -281,7 +299,17 @@ input:focus-visible {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 14px;
|
||||
max-height: calc(100vh - 126px);
|
||||
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,
|
||||
@ -306,6 +334,17 @@ input:focus-visible {
|
||||
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 {
|
||||
position: relative;
|
||||
display: grid;
|
||||
@ -427,6 +466,9 @@ input:focus-visible {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.story-exp-list li,
|
||||
@ -482,16 +524,15 @@ input:focus-visible {
|
||||
|
||||
.story-exp-stage {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr) 154px;
|
||||
grid-template-rows: auto minmax(0, 1fr) 186px;
|
||||
gap: 8px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.story-exp-stage-copy {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(260px, 0.64fr) 1fr;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
min-height: 42px;
|
||||
}
|
||||
|
||||
@ -506,12 +547,18 @@ input:focus-visible {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.story-exp-scene-summary,
|
||||
.story-exp-stage-copy p:last-child {
|
||||
grid-column: 2;
|
||||
grid-column: 1;
|
||||
margin: 0;
|
||||
color: var(--story-soft);
|
||||
font-size: clamp(0.78rem, 0.86vw, 0.92rem);
|
||||
line-height: 1.28;
|
||||
max-width: 100%;
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 3;
|
||||
}
|
||||
|
||||
.story-exp-visual {
|
||||
@ -547,7 +594,7 @@ input:focus-visible {
|
||||
.story-exp-connections {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
z-index: 60;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
@ -623,7 +670,7 @@ input:focus-visible {
|
||||
.story-exp-zone {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 2;
|
||||
z-index: 20;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
pointer-events: none;
|
||||
@ -654,6 +701,7 @@ input:focus-visible {
|
||||
|
||||
.story-exp-characters {
|
||||
position: relative;
|
||||
z-index: 90;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
@ -665,13 +713,13 @@ input:focus-visible {
|
||||
height: var(--node-size, 150px);
|
||||
transform: translate(var(--x, 0), var(--y, 0)) scale(var(--scale, 1));
|
||||
opacity: var(--opacity, 1);
|
||||
z-index: var(--z, 3);
|
||||
z-index: calc(var(--z, 3) + 90);
|
||||
pointer-events: auto;
|
||||
transition: transform 1050ms cubic-bezier(.16, .84, .28, 1), opacity 780ms ease;
|
||||
}
|
||||
|
||||
.story-character.is-pov {
|
||||
z-index: 7;
|
||||
z-index: 48;
|
||||
}
|
||||
|
||||
.story-character.is-context .story-character__image {
|
||||
@ -681,6 +729,8 @@ input:focus-visible {
|
||||
.story-character__image,
|
||||
.story-location__image,
|
||||
.story-asset__image {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(169, 205, 240, 0.36);
|
||||
@ -709,6 +759,7 @@ input:focus-visible {
|
||||
|
||||
.story-character__text {
|
||||
position: absolute;
|
||||
z-index: 180;
|
||||
top: calc(var(--node-size, 150px) + 9px);
|
||||
left: 50%;
|
||||
display: grid;
|
||||
@ -720,8 +771,8 @@ input:focus-visible {
|
||||
border: 1px solid rgba(151, 185, 218, 0.1);
|
||||
border-radius: 10px;
|
||||
padding: 5px 7px 6px;
|
||||
background: rgba(4, 10, 20, 0.58);
|
||||
box-shadow: 0 12px 26px rgba(0, 0, 0, 0.24);
|
||||
background: rgba(4, 10, 20, 0.82);
|
||||
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);
|
||||
text-align: center;
|
||||
transform: translateX(-50%);
|
||||
@ -799,7 +850,7 @@ input:focus-visible {
|
||||
gap: 10px;
|
||||
width: var(--node-size, 190px);
|
||||
transform: translate(var(--x, 0), var(--y, 0));
|
||||
z-index: 4;
|
||||
z-index: 42;
|
||||
pointer-events: auto;
|
||||
transition: opacity 560ms ease, transform 850ms cubic-bezier(.16, .84, .28, 1);
|
||||
}
|
||||
@ -823,6 +874,8 @@ input:focus-visible {
|
||||
}
|
||||
|
||||
.story-location__text {
|
||||
position: relative;
|
||||
z-index: 80;
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: 3px;
|
||||
@ -869,7 +922,7 @@ input:focus-visible {
|
||||
width: max(var(--node-size, 78px), 104px);
|
||||
opacity: var(--opacity, 1);
|
||||
transform: translate(var(--x, 0), var(--y, 0)) scale(var(--scale, 1));
|
||||
z-index: var(--z, 3);
|
||||
z-index: calc(var(--z, 3) + 30);
|
||||
pointer-events: auto;
|
||||
transition: transform 860ms cubic-bezier(.16, .84, .28, 1), opacity 620ms ease;
|
||||
}
|
||||
@ -883,6 +936,8 @@ input:focus-visible {
|
||||
}
|
||||
|
||||
.story-asset__text {
|
||||
position: relative;
|
||||
z-index: 80;
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: 2px;
|
||||
@ -917,12 +972,12 @@ input:focus-visible {
|
||||
|
||||
.story-exp-ribbon-wrap {
|
||||
display: grid;
|
||||
grid-template-rows: minmax(76px, auto) 54px;
|
||||
gap: 10px;
|
||||
grid-template-rows: minmax(104px, auto) 56px;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
border: 1px solid rgba(151, 185, 218, 0.1);
|
||||
border-radius: 24px;
|
||||
padding: 12px 14px 14px;
|
||||
padding: 14px 16px 16px;
|
||||
background: rgba(6, 16, 30, 0.54);
|
||||
box-shadow: 0 20px 58px rgba(0, 0, 0, 0.22);
|
||||
backdrop-filter: blur(14px);
|
||||
@ -933,24 +988,25 @@ input:focus-visible {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
min-height: 76px;
|
||||
gap: 14px;
|
||||
min-height: 104px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.story-scene-card {
|
||||
flex: 0 0 136px;
|
||||
flex: 0 0 clamp(174px, 16vw, 218px);
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||
align-content: start;
|
||||
gap: 5px;
|
||||
gap: 7px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.07);
|
||||
border-radius: 15px;
|
||||
padding: 9px 10px;
|
||||
min-height: 94px;
|
||||
border-radius: 14px;
|
||||
padding: 12px 14px;
|
||||
background: rgba(255, 255, 255, 0.032);
|
||||
color: var(--story-muted);
|
||||
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;
|
||||
}
|
||||
|
||||
@ -959,12 +1015,13 @@ input:focus-visible {
|
||||
}
|
||||
|
||||
.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);
|
||||
background: linear-gradient(180deg, rgba(215, 168, 93, 0.18), rgba(109, 181, 255, 0.075));
|
||||
color: var(--story-text);
|
||||
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);
|
||||
}
|
||||
|
||||
@ -972,28 +1029,28 @@ input:focus-visible {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
color: inherit;
|
||||
font-size: 0.86rem;
|
||||
line-height: 1.12;
|
||||
font-size: 0.92rem;
|
||||
line-height: 1.16;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
.story-scene-card span {
|
||||
color: var(--story-muted);
|
||||
font-size: 0.66rem;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.story-scene-card small {
|
||||
display: -webkit-box;
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
color: var(--story-muted);
|
||||
font-size: 0.68rem;
|
||||
line-height: 1.1;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 1;
|
||||
font-size: 0.7rem;
|
||||
line-height: 1.15;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.story-exp-timeline {
|
||||
@ -1001,7 +1058,7 @@ input:focus-visible {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(var(--timeline-count, 1), minmax(0, 1fr));
|
||||
gap: 0;
|
||||
min-height: 54px;
|
||||
min-height: 56px;
|
||||
}
|
||||
|
||||
.story-exp-timeline::before {
|
||||
@ -1061,6 +1118,9 @@ input:focus-visible {
|
||||
.story-exp-insight-list {
|
||||
display: grid;
|
||||
gap: 9px;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.story-exp-insight {
|
||||
@ -1073,6 +1133,31 @@ input:focus-visible {
|
||||
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-feed li {
|
||||
transition: opacity 520ms ease, transform 640ms cubic-bezier(.16, .84, .28, 1);
|
||||
|
||||
@ -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 |
@ -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 |
@ -77,8 +77,9 @@
|
||||
};
|
||||
|
||||
if (!scenes.length) {
|
||||
state.changeToken = "";
|
||||
state.currentSnapshot = { ...model, scenes: [] };
|
||||
updateDiagnostics();
|
||||
return;
|
||||
}
|
||||
|
||||
function loadSceneState(index) {
|
||||
@ -126,7 +127,14 @@
|
||||
scenes = model.scenes || [];
|
||||
state.changeToken = model.changeToken || state.changeToken;
|
||||
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) {
|
||||
text(dom.projectName, model.projectName);
|
||||
}
|
||||
@ -153,7 +161,6 @@
|
||||
}
|
||||
|
||||
function updateSceneCopy(scene) {
|
||||
text(dom.sceneTitle, `Scene ${scene.sceneNumber} • ${scene.title}`);
|
||||
text(dom.sceneSummary, scene.summary);
|
||||
}
|
||||
|
||||
@ -338,12 +345,14 @@
|
||||
}
|
||||
|
||||
function reconcileRelationships(scene) {
|
||||
renderInsights(dom.relationships, scene.relationships.map((relationship) => ({
|
||||
const relationships = scene.relationships.map((relationship) => ({
|
||||
id: relationship.id,
|
||||
title: relationship.label,
|
||||
detail: relationship.state,
|
||||
tone: relationship.weight >= 90 ? "confirmed" : relationship.weight >= 70 ? "strengthening" : "emerging"
|
||||
})));
|
||||
}));
|
||||
renderInsights(dom.relationships, relationships.slice(0, 2));
|
||||
renderOverflowSummary(dom.relationships, relationships.length - 2, "relationship");
|
||||
}
|
||||
|
||||
function updateKnowledgeThreads(scene) {
|
||||
@ -352,9 +361,7 @@
|
||||
}
|
||||
|
||||
function updateSceneRibbon(scene) {
|
||||
const currentIndex = state.index;
|
||||
const start = Math.max(0, Math.min(scenes.length - 7, currentIndex - 3));
|
||||
const visible = scenes.slice(start, start + 7);
|
||||
const visible = visibleSceneWindow();
|
||||
const activeIds = new Set(visible.map((item) => item.id));
|
||||
visible.forEach((item) => {
|
||||
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-complete", item.sceneNumber < scene.sceneNumber);
|
||||
text(card.querySelector("span"), `${(item.chapterLabel || "Chapter").toUpperCase()} · SCENE ${item.sceneNumber}`);
|
||||
text(card.querySelector("strong"), compactPhrase(cleanSceneTitle(item.title), item.id === scene.id ? 44 : 30));
|
||||
text(card.querySelector("small"), compactPhrase(item.timelineEvent, 34));
|
||||
text(card.querySelector("span"), `${abbreviateChapter(item.chapterLabel)} · Scene ${item.sceneNumber}`);
|
||||
text(card.querySelector("strong"), compactPhrase(cleanSceneTitle(item.title), 54));
|
||||
text(card.querySelector("small"), compactPhrase(item.timelineEvent || item.summary, 48));
|
||||
dom.ribbon.append(card);
|
||||
});
|
||||
removeInactive(dom.ribbon, "[data-scene-id]", "sceneId", activeIds);
|
||||
}
|
||||
|
||||
function updateTimeline(scene) {
|
||||
const completed = scenes.slice(0, state.index + 1);
|
||||
dom.timeline.style.setProperty("--timeline-count", String(completed.length));
|
||||
const activeIds = new Set(completed.map((item) => item.id));
|
||||
completed.forEach((item) => {
|
||||
const visible = visibleSceneWindow();
|
||||
dom.timeline.style.setProperty("--timeline-count", String(visible.length));
|
||||
const activeIds = new Set(visible.map((item) => item.id));
|
||||
visible.forEach((item) => {
|
||||
let marker = dom.timeline.querySelector(`[data-timeline-id="${cssEscape(item.id)}"]`);
|
||||
if (!marker) {
|
||||
marker = document.createElement("div");
|
||||
@ -389,19 +396,19 @@
|
||||
settleEntering(marker);
|
||||
}
|
||||
marker.classList.toggle("is-current", item.id === scene.id);
|
||||
const shouldLabel = shouldShowTimelineLabel(completed, item, scene);
|
||||
marker.classList.toggle("is-label-hidden", !shouldLabel);
|
||||
marker.classList.toggle("is-complete", item.sceneNumber < scene.sceneNumber);
|
||||
marker.classList.remove("is-label-hidden");
|
||||
text(marker.querySelector("span"), shortTimelineLabel(item));
|
||||
dom.timeline.append(marker);
|
||||
});
|
||||
removeInactive(dom.timeline, "[data-timeline-id]", "timelineId", activeIds);
|
||||
}
|
||||
|
||||
function shouldShowTimelineLabel(items, item, scene) {
|
||||
const index = items.findIndex((candidate) => candidate.id === item.id);
|
||||
if (index === 0 || index === items.length - 1 || item.id === scene.id) return true;
|
||||
const interval = items.length > 12 ? 5 : items.length > 8 ? 4 : 3;
|
||||
return index % interval === 0 && Math.abs(item.sceneNumber - scene.sceneNumber) > 1;
|
||||
function visibleSceneWindow() {
|
||||
const visibleCount = Math.min(5, scenes.length);
|
||||
const currentIndex = Math.max(0, Math.min(state.index, scenes.length - 1));
|
||||
const start = Math.max(0, Math.min(scenes.length - visibleCount, currentIndex - Math.floor(visibleCount / 2)));
|
||||
return scenes.slice(start, start + visibleCount);
|
||||
}
|
||||
|
||||
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) {
|
||||
const activeIds = new Set(items.map((item, index) => feedId(item, index)));
|
||||
items.forEach((item, index) => {
|
||||
@ -1067,6 +1094,7 @@
|
||||
}
|
||||
|
||||
function startTimer() {
|
||||
if (!scenes.length) return;
|
||||
stopTimer();
|
||||
if (!state.playing || !simulationMode) return;
|
||||
state.timer = window.setInterval(() => {
|
||||
@ -1213,7 +1241,25 @@
|
||||
|
||||
function activeScene(snapshot) {
|
||||
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) {
|
||||
@ -1386,6 +1432,7 @@
|
||||
});
|
||||
|
||||
window.addEventListener("resize", () => {
|
||||
if (!scenes.length) return;
|
||||
const current = loadSceneState(state.index);
|
||||
const layout = sceneLayout(current);
|
||||
reconcileCharacters(current, current, layout);
|
||||
@ -1396,6 +1443,7 @@
|
||||
|
||||
if (window.ResizeObserver && dom.visualStage) {
|
||||
const observer = new ResizeObserver(() => {
|
||||
if (!scenes.length) return;
|
||||
const current = loadSceneState(state.index);
|
||||
const layout = sceneLayout(current);
|
||||
reconcileCharacters(current, current, layout);
|
||||
@ -1405,8 +1453,12 @@
|
||||
observer.observe(dom.visualStage);
|
||||
}
|
||||
|
||||
transitionToNextState(realMode ? Math.max(0, scenes.length - 1) : 0);
|
||||
startTimer();
|
||||
if (scenes.length) {
|
||||
transitionToNextState(realMode ? authoritativeSceneIndex(scenes) : 0);
|
||||
startTimer();
|
||||
} else {
|
||||
updateDiagnostics();
|
||||
}
|
||||
startPolling();
|
||||
if (realMode && !state.terminal) pollSnapshot();
|
||||
})();
|
||||
|
||||
@ -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 hadActiveRuns = root.dataset.storyIntelligenceHasActiveRuns === "true";
|
||||
const chapters = [...root.querySelectorAll("[data-story-run-id]")];
|
||||
@ -306,6 +373,7 @@
|
||||
root.querySelectorAll("[data-story-remaining]").forEach((node) => {
|
||||
node.textContent = calculateRemaining(chapters, active);
|
||||
});
|
||||
applyAuthoritativeCursor(root);
|
||||
|
||||
if (hadActiveRuns && !active && root.dataset.reloadScheduled !== "true") {
|
||||
root.dataset.reloadScheduled = "true";
|
||||
@ -335,6 +403,7 @@
|
||||
const totalDurationMs = field(state, "totalDurationMs", "TotalDurationMs");
|
||||
const elapsed = field(state, "elapsedTime", "ElapsedTime") || "0 sec";
|
||||
const currentSceneNumber = field(state, "currentSceneNumber", "CurrentSceneNumber");
|
||||
const updatedUtc = field(state, "updatedUtc", "UpdatedUtc");
|
||||
const displayMessage = friendlyMessage(message, stage, status, currentSceneNumber);
|
||||
const latestSummary = field(state, "latestSceneSummary", "LatestSceneSummary");
|
||||
const discoveries = field(state, "recentDiscoveries", "RecentDiscoveries") || [];
|
||||
@ -344,6 +413,11 @@
|
||||
node.textContent = statusLabel(state);
|
||||
});
|
||||
chapter.dataset.storyRawStatus = status;
|
||||
chapter.dataset.storyCurrentStage = stage;
|
||||
chapter.dataset.storyCurrentSceneNumber = completed || "0";
|
||||
if (updatedUtc) {
|
||||
chapter.dataset.storyUpdatedUtc = updatedUtc;
|
||||
}
|
||||
if (totalDurationMs !== null) {
|
||||
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 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) => {
|
||||
const percent = totalNumber > 0 ? Math.floor((Math.min(completedNumber, totalNumber) * 100) / totalNumber) : 0;
|
||||
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) => {
|
||||
node.textContent = elapsed;
|
||||
});
|
||||
|
||||
@ -1920,6 +1920,21 @@
|
||||
let scene = null;
|
||||
let totalWordCount = 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 = () => {
|
||||
scene = null;
|
||||
@ -1982,10 +1997,15 @@
|
||||
return;
|
||||
}
|
||||
|
||||
documentText.push(text);
|
||||
const words = countWords(text);
|
||||
|
||||
if (isBuiltInHeading(paragraph, 1)) {
|
||||
if (!manuscriptStarted && isFrontMatterHeading(text)) {
|
||||
return;
|
||||
}
|
||||
|
||||
manuscriptStarted = true;
|
||||
documentText.push(text);
|
||||
detectedHeadingChapters += 1;
|
||||
startChapter(paragraph, text);
|
||||
totalWordCount += words;
|
||||
@ -1995,9 +2015,15 @@
|
||||
}
|
||||
|
||||
if (!chapter) {
|
||||
if (!manuscriptStarted) {
|
||||
return;
|
||||
}
|
||||
|
||||
startChapter(paragraph, "Opening pages", true);
|
||||
}
|
||||
|
||||
documentText.push(text);
|
||||
|
||||
if (isBuiltInHeading(paragraph, 2)) {
|
||||
startScene(paragraph, text);
|
||||
totalWordCount += words;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user