From 8b2448ecd0c19e154fa6032ed14f2a76f5eb5d81 Mon Sep 17 00:00:00 2001 From: Nick Beckley Date: Sat, 25 Jul 2026 19:29:36 +0000 Subject: [PATCH] Reset story intelligence memory extraction --- PlotLine.Tests/Program.cs | 92 +++++++++- PlotLine/Controllers/OnboardingController.cs | 4 +- PlotLine/Docs/AI/Scene-Prompt-V2.md | 47 ++++- PlotLine/Models/SceneIntelligenceModels.cs | 28 +++ .../CharacterAppearanceExtractionService.cs | 169 ++++++++++++++++++ .../OnboardingStoryIntelligenceService.cs | 18 +- ...ryIntelligenceIllustrationCompatibility.cs | 58 ++++-- ...ntelligenceVisualisationSnapshotService.cs | 8 +- PlotLine/Services/StoryMemoryServices.cs | 97 +++++++++- ...e21U_DevelopmentStoryIntelligenceReset.sql | 141 +++++++++++++++ PlotLine/ViewModels/OnboardingViewModels.cs | 1 + ...telligenceExperiencePrototypeViewModels.cs | 1 + .../StoryIntelligenceExperience.cshtml | 15 ++ .../Views/Onboarding/StoryIntelligence.cshtml | 12 +- ...tory-intelligence-experience-prototype.css | 35 ++-- ...story-intelligence-experience-prototype.js | 29 +-- 16 files changed, 677 insertions(+), 78 deletions(-) create mode 100644 PlotLine/Services/CharacterAppearanceExtractionService.cs create mode 100644 PlotLine/Sql/143_Phase21U_DevelopmentStoryIntelligenceReset.sql diff --git a/PlotLine.Tests/Program.cs b/PlotLine.Tests/Program.cs index 04cce24..1f62091 100644 --- a/PlotLine.Tests/Program.cs +++ b/PlotLine.Tests/Program.cs @@ -60,6 +60,8 @@ var tests = new (string Name, Action Test)[] ("Phase 21R scene browser and panels are replay friendly", Phase21RSceneBrowserAndPanelsAreReplayFriendly), ("Phase 21S uses durable Story Memory assignments", Phase21SUsesDurableStoryMemoryAssignments), ("Phase 21T repairs durable character understanding", Phase21TRepairsDurableCharacterUnderstanding), + ("Phase 21U extracts mandatory character appearance fixtures", Phase21UExtractsMandatoryCharacterAppearanceFixtures), + ("Phase 21U enforces alias age semantic and UI reset rules", Phase21UEnforcesAliasAgeSemanticAndUiResetRules), ("Story Intelligence visual polish keeps labels fixed and semantics specific", StoryIntelligenceVisualPolishKeepsLabelsFixedAndSemanticsSpecific), ("Scan review post supports full-book form submissions", ScanReviewPostSupportsFullBookFormSubmissions), ("Story Intelligence experience boot does not serialise live model", StoryIntelligenceExperienceBootDoesNotSerialiseLiveModel), @@ -557,7 +559,7 @@ static void StoryIntelligenceEvidenceExtractionPreventsObservedAttributeLeakage( Assert(toddler.AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.Child, $"Toddler should be Child, got {toddler.AgeBand}."); Assert(toddler.Presentation == StoryIntelligenceIllustrationCompatibility.Presentations.Masculine, $"Boy toddler should be Masculine, got {toddler.Presentation}."); - Assert(StoryIntelligenceIllustrationCompatibility.LocationType("Cock Hill Lane") == "Road", "Cock Hill Lane must classify as Road, not interior."); + Assert(StoryIntelligenceIllustrationCompatibility.LocationType("Cock Hill Lane") == "Lane", "Cock Hill Lane must classify as Lane, not interior."); Assert(StoryIntelligenceIllustrationCompatibility.AssetType("money") == "Money", "Money must not classify as Car."); Assert(StoryIntelligenceIllustrationCompatibility.AssetType("bottle") == "Bottle", "Bottle must not classify as LockedBox."); Assert(StoryIntelligenceIllustrationCompatibility.AssetType("scarves") == "Clothing", "Scarves must not classify as Car."); @@ -634,7 +636,7 @@ static void Phase21OPreservesAdultDefaultsAndRealModeContinuity() Assert(examiner.Presentation == StoryIntelligenceIllustrationCompatibility.Presentations.Feminine, $"Female examiner should infer Feminine, got {examiner.Presentation}."); Assert(examiner.AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.Adult, $"Driving examiner should infer Adult, got {examiner.AgeBand}."); Assert(StoryIntelligenceIllustrationCompatibility.LocationType("Bristol Road") == "Road", "Road names should classify as Road."); - Assert(StoryIntelligenceIllustrationCompatibility.LocationType("Cock Hill Lane") == "Road", "Lane names should classify as Road."); + Assert(StoryIntelligenceIllustrationCompatibility.LocationType("Cock Hill Lane") == "Lane", "Lane names should classify as Lane."); Assert(StoryIntelligenceIllustrationCompatibility.AssetCompatible("Hatchback", "Car"), "Hatchbacks may fall back to neutral generic car artwork."); Assert(!StoryIntelligenceIllustrationCompatibility.AssetCompatible("SportsCar", "Hatchback"), "Vehicle subtypes should remain strict."); @@ -898,8 +900,8 @@ static void Phase21QRepairsProductionVisualisationRegressions() Assert(maggie.HairColour == StoryIntelligenceIllustrationCompatibility.HairColours.Red, $"Maggie red-hair evidence should be explicit, got {maggie.HairColour}."); Assert(Rejected(maggie, darkHairCandidate).Any(reason => reason.Contains("hair colour hard mismatch", StringComparison.OrdinalIgnoreCase)), "Red-hair evidence must invalidate dark-haired assignments."); - Assert(StoryIntelligenceIllustrationCompatibility.LocationType("Cock Hill Lane") == "Road", "Cock Hill Lane should classify as Road/Lane."); - Assert(StoryIntelligenceIllustrationCompatibility.LocationType("derelict hospital waiting room") == "Hospital", "Derelict hospital should remain Hospital rather than generic interior."); + Assert(StoryIntelligenceIllustrationCompatibility.LocationType("Cock Hill Lane") == "Lane", "Cock Hill Lane should classify as Lane."); + Assert(StoryIntelligenceIllustrationCompatibility.LocationType("derelict hospital waiting room") == "DerelictHospital", "Derelict hospital should remain typed rather than generic interior."); Assert(StoryIntelligenceIllustrationCompatibility.LocationType("bench beside the road") == "Road", "Bench context beside road should not become indoor room artwork."); Assert(!StoryIntelligenceIllustrationCompatibility.LocationCompatible("Road", "CarPark"), "Lane/Road must not use car park artwork."); Assert(!StoryIntelligenceIllustrationCompatibility.LocationCompatible("WaitingRoom", "FlatInterior"), "Waiting room must not use flat interior artwork."); @@ -930,8 +932,8 @@ static void Phase21QViewKeepsPanelsBoundedAndRealControlsHidden() Assert(!view.Contains("data-scene-title", StringComparison.Ordinal), "Redundant scene heading should be removed from the real visualisation."); Assert(view.IndexOf("data-knowledge", StringComparison.Ordinal) < view.IndexOf("data-relationships", StringComparison.Ordinal), "Knowledge threads should be prioritised above relationship changes."); Assert(view.Contains("Model.Mode == \"simulation\" || Model.Mode == \"replay\"", StringComparison.Ordinal), "Prototype controls must remain hidden in live mode while supporting replay."); - Assert(script.Contains("relationships.slice(0, 2)", StringComparison.Ordinal), "Relationship changes should be capped to the two most relevant visible cards."); - Assert(script.Contains("renderOverflowSummary", StringComparison.Ordinal), "Relationship overflow should be indicated compactly."); + Assert(script.Contains("relationships.slice(0, 1)", StringComparison.Ordinal), "Relationship changes should be capped to the most relevant visible card."); + Assert(!script.Contains("renderOverflowSummary", StringComparison.Ordinal), "Side panels must not hide readable content behind '+N more' overflow controls."); Assert(css.Contains("max-height: calc(100vh - 126px)", StringComparison.Ordinal), "Side columns should be bounded to the viewport."); Assert(css.Contains("overflow-y: auto", StringComparison.Ordinal), "Side columns should keep a single hidden-track vertical scroll."); Assert(css.Contains("z-index: 180", StringComparison.Ordinal), "Character labels should render in a high label layer above nodes and paths."); @@ -1019,6 +1021,80 @@ static void Phase21TRepairsDurableCharacterUnderstanding() Assert(snapshot.Contains("ApplyPersistedStoryMemoryAssignmentsAsync(importSessionId, model)", StringComparison.Ordinal), "Live and replay must apply durable Story Memory assignments."); } +static void Phase21UExtractsMandatoryCharacterAppearanceFixtures() +{ + const string source = """ + Maggie, a late teenage girl, pushed back her long natural red hair while Beth, her late teenage sister, checked the mirror. + Beth was initially blonde, and Rosie was a blonde young woman. + Miss Davies smiled. "Miss Davies, but you can call me Zoe," she said. + Mum waited outside with Mrs Patterson while Colin, David, Graham, Rick and Rob spoke to the driving examiner. + """; + var facts = CharacterAppearanceExtractionService.Extract(source, ["Maggie", "Beth", "Rosie", "Miss Davies", "Zoe", "Mum", "Mrs Patterson", "Colin", "David", "Graham", "Rick", "Rob"]); + + Assert(facts.Any(fact => fact.CanonicalName == "Maggie" && fact.AttributeType == "HairColour" && fact.NormalisedValue == "Red"), "Maggie's red-hair evidence was not extracted."); + Assert(facts.Any(fact => fact.CanonicalName == "Maggie" && fact.AttributeType == "HairLength" && fact.NormalisedValue == "Long"), "Maggie's long-hair evidence was not extracted."); + Assert(facts.Any(fact => fact.CanonicalName == "Beth" && fact.AttributeType == "HairColour" && fact.NormalisedValue == "Blonde"), "Beth's blonde evidence was not extracted."); + Assert(facts.Any(fact => fact.CanonicalName == "Rosie" && fact.AttributeType == "HairColour" && fact.NormalisedValue == "Blonde"), "Rosie's blonde evidence was not extracted."); + Assert(facts.Any(fact => fact.CanonicalName == "Zoe Davies" && fact.AttributeType == "Alias" && fact.NormalisedValue == "miss davies"), "Miss Davies should resolve as an alias of Zoe Davies."); + Assert(facts.Any(fact => fact.CanonicalName == "Zoe Davies" && fact.AttributeType == "Alias" && fact.NormalisedValue == "zoe"), "Zoe should resolve as an alias of Zoe Davies."); + + var maggie = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Maggie", ["Maggie is a late teenage girl.", "Maggie has long natural red hair."], []); + var beth = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Beth", ["Beth is a late teenage girl.", "Beth was initially blonde."], []); + var rosie = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Rosie", ["Rosie is a blonde young woman."], []); + + Assert(maggie.AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.OlderTeen, $"Maggie should be OlderTeen, got {maggie.AgeBand}."); + Assert(maggie.HairColour == StoryIntelligenceIllustrationCompatibility.HairColours.Red, $"Maggie should be Red, got {maggie.HairColour}."); + Assert(beth.AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.OlderTeen, $"Beth should be OlderTeen, got {beth.AgeBand}."); + Assert(beth.HairColour == StoryIntelligenceIllustrationCompatibility.HairColours.Blonde, $"Beth should be Blonde, got {beth.HairColour}."); + Assert(rosie.AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.YoungAdult, $"Rosie should be YoungAdult, got {rosie.AgeBand}."); + Assert(rosie.HairColour == StoryIntelligenceIllustrationCompatibility.HairColours.Blonde, $"Rosie should be Blonde, got {rosie.HairColour}."); +} + +static void Phase21UEnforcesAliasAgeSemanticAndUiResetRules() +{ + var zoeAliases = CharacterAppearanceExtractionService.AliasMap("""Miss Davies, but you can call me Zoe.""", ["Miss Davies", "Zoe"]); + Assert(zoeAliases.TryGetValue("miss davies", out var missCanonical) && missCanonical == "Zoe Davies", "Miss Davies should canonicalise to Zoe Davies."); + Assert(zoeAliases.TryGetValue("zoe", out var zoeCanonical) && zoeCanonical == "Zoe Davies", "Zoe should canonicalise to Zoe Davies."); + + var mum = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Mum", ["Mum is mother of teenage daughters."], []); + var examiner = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("driving examiner", ["the driving examiner"], []); + var childCandidate = Metadata("char-child", "Child", "Feminine"); + Assert(Rejected(mum, childCandidate).Any(reason => reason.Contains("age hard mismatch", StringComparison.OrdinalIgnoreCase)), "Parent role should reject child portraits."); + Assert(examiner.AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.Adult, $"Driving examiner should infer Adult, got {examiner.AgeBand}."); + Assert(StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("girl", ["girl"], []).AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.Unknown, "Girl alone must not mean Child."); + + Assert(StoryIntelligenceIllustrationCompatibility.LocationType("Cock Hill Lane") == "Lane", "Cock Hill Lane should be Lane."); + Assert(StoryIntelligenceIllustrationCompatibility.LocationType("Mount Pleasant Road") == "Road", "Mount Pleasant Road should be Road."); + Assert(StoryIntelligenceIllustrationCompatibility.LocationType("car interior") == "VehicleInterior", "Car interior should be VehicleInterior."); + Assert(StoryIntelligenceIllustrationCompatibility.LocationType("bathroom") == "Bathroom", "Bathroom should be Bathroom."); + Assert(StoryIntelligenceIllustrationCompatibility.LocationType("waiting room") == "WaitingRoom", "Waiting room should be WaitingRoom."); + Assert(!StoryIntelligenceIllustrationCompatibility.LocationCompatible("VehicleInterior", "HouseExterior"), "VehicleInterior must not use building art."); + + Assert(StoryIntelligenceIllustrationCompatibility.AssetType("blue VW Golf") == "Hatchback", "Blue VW Golf should be Hatchback."); + Assert(StoryIntelligenceIllustrationCompatibility.AssetColour("blue VW Golf") == "Blue", "Blue VW Golf should be Blue."); + Assert(StoryIntelligenceIllustrationCompatibility.AssetType("red TR6") == "SportsCar", "TR6 should be SportsCar."); + Assert(StoryIntelligenceIllustrationCompatibility.AssetType("lipstick") == "Lipstick", "Lipstick should be Lipstick."); + Assert(StoryIntelligenceIllustrationCompatibility.AssetType("skirt") == "Skirt", "Skirt should be Skirt."); + Assert(!StoryIntelligenceIllustrationCompatibility.AssetCompatible("Hatchback", "SportsCar"), "Hatchback must not use sports-car art when demand generation is available."); + + var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); + var onboarding = File.ReadAllText(Path.Combine(root, "Views/Onboarding/StoryIntelligence.cshtml")); + var storyMemory = File.ReadAllText(Path.Combine(root, "Services/StoryMemoryServices.cs")); + var script = File.ReadAllText(Path.Combine(root, "wwwroot/js/story-intelligence-experience-prototype.js")); + var css = File.ReadAllText(Path.Combine(root, "wwwroot/css/story-intelligence-experience-prototype.css")); + var resetSql = File.ReadAllText(Path.Combine(root, "Sql/143_Phase21U_DevelopmentStoryIntelligenceReset.sql")); + var scenePrompt = File.ReadAllText(Path.Combine(root, "Docs/AI/Scene-Prompt-V2.md")); + + Assert(onboarding.Contains("name=\"appearancePreference\"", StringComparison.Ordinal), "Import setup must ask for appearance preference."); + Assert(storyMemory.Contains("CharacterAppearanceExtractionService.Extract", StringComparison.Ordinal), "Durable Story Memory must consume dedicated appearance extraction."); + Assert(storyMemory.Contains("Maximum one primary POV", StringComparison.Ordinal) || storyMemory.Contains("ResolveScenePov", StringComparison.Ordinal), "Story Memory must use a single POV resolver."); + Assert(!script.Contains("renderOverflowSummary", StringComparison.Ordinal), "Side panels must not use hidden '+N more' overflow controls."); + Assert(css.Contains("-webkit-line-clamp: 3", StringComparison.Ordinal), "Insight body text should be clamped inside its card."); + Assert(resetSql.Contains("StoryIntelligence_DevelopmentAccountReset", StringComparison.Ordinal), "Phase 21U reset procedure is missing."); + Assert(resetSql.Contains("ProjectHardDelete_ForOwner", StringComparison.Ordinal), "Reset must reuse owner-scoped project hard delete."); + Assert(scenePrompt.Contains("characterAppearance", StringComparison.OrdinalIgnoreCase), "Scene prompt must request structured character appearance."); +} + static void Phase21RSceneBrowserAndPanelsAreReplayFriendly() { var view = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Views/Development/StoryIntelligenceExperience.cshtml")); @@ -1053,11 +1129,11 @@ static void StoryIntelligenceVisualPolishKeepsLabelsFixedAndSemanticsSpecific() Assert(css.Contains("background: rgba(4, 10, 20, 0.91)", StringComparison.Ordinal), "Character caption opacity should be increased for readability."); Assert(css.Contains(".story-exp-insight span {\n display: none;", StringComparison.Ordinal), "Redundant insight tone headings should be hidden."); - Assert(StoryIntelligenceIllustrationCompatibility.LocationType("Cock Hill Lane") == "Road", "Cock Hill Lane should classify as Road."); + Assert(StoryIntelligenceIllustrationCompatibility.LocationType("Cock Hill Lane") == "Lane", "Cock Hill Lane should classify as Lane."); Assert(StoryIntelligenceIllustrationCompatibility.LocationType("Mount Pleasant Road") == "Road", "Mount Pleasant Road should classify as Road."); Assert(StoryIntelligenceIllustrationCompatibility.LocationType("Queens Court") == "Road", "Court should classify as Road."); Assert(StoryIntelligenceIllustrationCompatibility.LocationType("Station Gardens") == "Road", "Gardens should classify as Road."); - Assert(StoryIntelligenceIllustrationCompatibility.LocationType("driver seat inside the car") == "VehicleInterior", "Driver seat should classify as VehicleInterior."); + Assert(StoryIntelligenceIllustrationCompatibility.LocationType("driver seat inside the car") == "DriverSeat", "Driver seat should classify as DriverSeat."); Assert(StoryIntelligenceIllustrationCompatibility.LocationType("Driving Test Centre waiting room") == "DrivingTestCentre", "Driving test centre should have its own location family."); Assert(!StoryIntelligenceIllustrationCompatibility.LocationCompatible("VehicleInterior", "HouseExterior"), "Vehicle interiors must not resolve to houses or buildings."); diff --git a/PlotLine/Controllers/OnboardingController.cs b/PlotLine/Controllers/OnboardingController.cs index d2dad52..3617c05 100644 --- a/PlotLine/Controllers/OnboardingController.cs +++ b/PlotLine/Controllers/OnboardingController.cs @@ -86,9 +86,9 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard [HttpPost("story-intelligence/start")] [ValidateAntiForgeryToken] - public async Task StartStoryIntelligence() + public async Task StartStoryIntelligence([FromForm] string? appearancePreference) { - var job = await storyIntelligence.StartAsync(); + var job = await storyIntelligence.StartAsync(appearancePreference); if (job is null) { TempData["OnboardingStoryIntelligenceError"] = "Review the manuscript scan before starting Story Intelligence."; diff --git a/PlotLine/Docs/AI/Scene-Prompt-V2.md b/PlotLine/Docs/AI/Scene-Prompt-V2.md index 1b4b004..962c205 100644 --- a/PlotLine/Docs/AI/Scene-Prompt-V2.md +++ b/PlotLine/Docs/AI/Scene-Prompt-V2.md @@ -50,7 +50,8 @@ It should not: 7. Treat assets as meaningful story objects, not ordinary props. 8. Treat relationships as scene-local signals, not final records. 9. Treat metrics as descriptive intensity scores, not quality scores. -10. Return JSON only. +10. Extract explicit visual character appearance separately from story understanding. +11. Return JSON only. ## Strict Behavioural Rules @@ -229,6 +230,26 @@ For each character: Mentioned-only characters matter because later stages need to know who is referenced without assuming they were present. +## Character Appearance Rules + +Scene understanding is not enough for illustration matching. Report explicit or strongly supported visual facts in `characterAppearance`. + +For every named or recurring character with supported appearance evidence, include exactly these properties: `canonicalName`, `aliases`, `title`, `presentation`, `explicitAge`, `inferredAgeBand`, `hairColour`, `hairLength`, `hairStyle`, `facialHair`, `glasses`, `build`, `distinctiveFeatures`, `clothing`, `roleOrOccupation`, `relationshipAgeEvidence`, `pronounEvidence`, `evidence`, `confidence`, `evidenceKind`. + +Rules: + +- Use the manuscript as the authority. +- Keep unknown visual fields as `null`. +- Use `evidenceKind: "explicit"` for directly stated facts and `evidenceKind: "inferred"` for title, role, relationship or pronoun evidence. +- Keep `evidence` to a short exact fragment from the scene. +- Do not infer hair colour from nearby characters. +- Do not inherit appearance from the point of view character. +- Do not copy appearance evidence between identities. +- If the scene says `Miss Davies, but you can call me Zoe`, use canonicalName `Zoe Davies` and aliases `["Miss Davies", "Zoe", "Miss Davies (Zoe)"]`. +- Unknown age must not be marked as child. +- Adult titles and occupations such as Mr, Mrs, Miss, Ms, teacher, driving examiner, driving instructor, police officer, detective, solicitor, landlord, manager, nurse, receptionist, mother, father, aunt and uncle are adult evidence. +- Words like `girl`, `lad` or `young` alone are presentation/voice clues, not child-age evidence. + ## Location Reporting Rules Named locations: @@ -543,6 +564,30 @@ Return exactly one JSON object matching this shape: "notes": null } ], + "characterAppearance": [ + { + "canonicalName": "", + "aliases": [], + "title": null, + "presentation": null, + "explicitAge": null, + "inferredAgeBand": null, + "hairColour": null, + "hairLength": null, + "hairStyle": null, + "facialHair": null, + "glasses": null, + "build": null, + "distinctiveFeatures": [], + "clothing": null, + "roleOrOccupation": null, + "relationshipAgeEvidence": null, + "pronounEvidence": null, + "evidence": "", + "confidence": 0.0, + "evidenceKind": "explicit" + } + ], "locations": [ { "name": "", diff --git a/PlotLine/Models/SceneIntelligenceModels.cs b/PlotLine/Models/SceneIntelligenceModels.cs index 704eb1a..825bfe9 100644 --- a/PlotLine/Models/SceneIntelligenceModels.cs +++ b/PlotLine/Models/SceneIntelligenceModels.cs @@ -12,6 +12,7 @@ public sealed class SceneIntelligenceScene public SceneIntelligencePointOfView? PointOfView { get; init; } public SceneIntelligenceSetting? Setting { get; init; } public List? Characters { get; init; } + public List? CharacterAppearance { get; init; } public List? Locations { get; init; } public List? Assets { get; init; } public List? Relationships { get; init; } @@ -99,6 +100,33 @@ public sealed class SceneIntelligenceCharacter public Dictionary? ExtensionData { get; init; } } +public sealed class SceneIntelligenceCharacterAppearance +{ + public string? CanonicalName { get; init; } + public List? Aliases { get; init; } + public string? Title { get; init; } + public string? Presentation { get; init; } + public string? ExplicitAge { get; init; } + public string? InferredAgeBand { get; init; } + public string? HairColour { get; init; } + public string? HairLength { get; init; } + public string? HairStyle { get; init; } + public string? FacialHair { get; init; } + public string? Glasses { get; init; } + public string? Build { get; init; } + public List? DistinctiveFeatures { get; init; } + public string? Clothing { get; init; } + public string? RoleOrOccupation { get; init; } + public string? RelationshipAgeEvidence { get; init; } + public string? PronounEvidence { get; init; } + public string? Evidence { get; init; } + public decimal? Confidence { get; init; } + public string? EvidenceKind { get; init; } + + [JsonExtensionData] + public Dictionary? ExtensionData { get; init; } +} + public sealed class SceneIntelligenceLocation { public string? Name { get; init; } diff --git a/PlotLine/Services/CharacterAppearanceExtractionService.cs b/PlotLine/Services/CharacterAppearanceExtractionService.cs new file mode 100644 index 0000000..feb09d0 --- /dev/null +++ b/PlotLine/Services/CharacterAppearanceExtractionService.cs @@ -0,0 +1,169 @@ +using System.Text.RegularExpressions; +using PlotLine.Models; + +namespace PlotLine.Services; + +public sealed record CharacterAppearanceFact( + string CanonicalName, + IReadOnlyList Aliases, + string AttributeType, + string NormalisedValue, + string EvidenceFragment, + decimal Confidence, + bool IsExplicit, + string SourceScene); + +public static class CharacterAppearanceExtractionService +{ + private static readonly Regex MissCallMeRegex = new(@"\bMiss\s+(?[A-Z][A-Za-z'’-]+)\b[^.!?]{0,80}?\b(?:call me|called)\s+(?[A-Z][A-Za-z'’-]+)\b", RegexOptions.Compiled); + private static readonly Regex HairRegex = new(@"\b(?Mum|Mother|Mrs\s+[A-Z][A-Za-z'’-]+|Miss\s+[A-Z][A-Za-z'’-]+|[A-Z][A-Za-z'’-]+)\b[^.!?]{0,90}?\b(?(?i:long|short|shoulder-length|shoulder length|medium-length|medium length))?\s*(?(?i:natural)\s+)?(?(?i:red|auburn|ginger|blonde|fair|black|brown|brunette|grey|gray|white))\s+(?i:hair)\b", RegexOptions.Compiled); + private static readonly Regex HairAdjectiveRegex = new(@"\b(?Mum|Mother|Mrs\s+[A-Z][A-Za-z'’-]+|Miss\s+[A-Z][A-Za-z'’-]+|[A-Z][A-Za-z'’-]+)\b[^.!?]{0,60}?\b(?(?i:blonde|fair-haired|red-haired|auburn-haired|ginger-haired|black-haired|brown-haired|brunette|grey-haired|gray-haired|white-haired))\b", RegexOptions.Compiled); + private static readonly Regex HairLeadingRegex = new(@"\b(?(?i:long|short|shoulder-length|shoulder length|medium-length|medium length))?\s*(?(?i:natural)\s+)?(?(?i:red|auburn|ginger|blonde|fair|black|brown|brunette|grey|gray|white))\s+(?i:hair)(?:ed)?\s+(?(?i:girl|woman|man|boy))\b", RegexOptions.Compiled); + private static readonly Regex HairAdjectiveLeadingRegex = new(@"\b(?(?i:blonde|fair-haired|red-haired|auburn-haired|ginger-haired|black-haired|brown-haired|brunette|grey-haired|gray-haired|white-haired))\s+(?(?i:girl|woman|man|boy|young woman|young man))\b", RegexOptions.Compiled); + private static readonly Regex AgeRegex = new(@"\b(?Mum|Mother|Mrs\s+[A-Z][A-Za-z'’-]+|Miss\s+[A-Z][A-Za-z'’-]+|[A-Z][A-Za-z'’-]+)\b[^.!?]{0,90}?\b(?(?i:late teenage|teenage|fifteen|sixteen|seventeen|eighteen|nineteen|young woman|young man|adult woman|adult man|middle-aged|mature|mother|mum|driving examiner|driving instructor|teacher))\b", RegexOptions.Compiled); + + public static IReadOnlyList Extract(string sourceText, IEnumerable? knownCharacterNames = null, string sourceScene = "Fixture") + { + var facts = new List(); + if (string.IsNullOrWhiteSpace(sourceText)) + { + return facts; + } + + var aliases = AliasMap(sourceText, knownCharacterNames); + foreach (Match match in HairRegex.Matches(sourceText)) + { + var rawName = match.Groups["name"].Value.Trim(); + AddHairFacts(facts, CanonicalName(rawName, aliases), AliasesFor(rawName, aliases), match, sourceScene); + } + + foreach (Match match in HairAdjectiveRegex.Matches(sourceText)) + { + var rawName = match.Groups["name"].Value.Trim(); + AddHairFacts(facts, CanonicalName(rawName, aliases), AliasesFor(rawName, aliases), match, sourceScene); + } + + foreach (Match match in HairLeadingRegex.Matches(sourceText)) + { + var descriptor = match.Groups["name"].Value.Trim(); + var candidate = (knownCharacterNames ?? []).FirstOrDefault(name => sourceText.Contains(name, StringComparison.OrdinalIgnoreCase)); + if (!string.IsNullOrWhiteSpace(candidate)) + { + AddHairFacts(facts, CanonicalName(candidate, aliases), AliasesFor(candidate, aliases), match, sourceScene); + } + else + { + AddHairFacts(facts, descriptor, [descriptor], match, sourceScene); + } + } + + foreach (Match match in HairAdjectiveLeadingRegex.Matches(sourceText)) + { + var descriptor = match.Groups["name"].Value.Trim(); + var candidate = (knownCharacterNames ?? []).FirstOrDefault(name => sourceText.Contains(name, StringComparison.OrdinalIgnoreCase)); + AddHairFacts(facts, string.IsNullOrWhiteSpace(candidate) ? descriptor : CanonicalName(candidate, aliases), string.IsNullOrWhiteSpace(candidate) ? [descriptor] : AliasesFor(candidate, aliases), match, sourceScene); + } + + foreach (Match match in AgeRegex.Matches(sourceText)) + { + var rawName = match.Groups["name"].Value.Trim(); + var age = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence(rawName, [match.Value], []).AgeBand; + if (age != StoryIntelligenceIllustrationCompatibility.AgeBands.Unknown) + { + facts.Add(new(CanonicalName(rawName, aliases), AliasesFor(rawName, aliases), "AgeBand", age, ShortFragment(match.Value), 0.9m, true, sourceScene)); + } + } + + foreach (var (alias, canonical) in aliases) + { + facts.Add(new(canonical, AliasesFor(alias, aliases), "Alias", alias, AliasEvidence(sourceText, alias, canonical), 0.96m, true, sourceScene)); + } + + return facts + .GroupBy(fact => $"{fact.CanonicalName}|{fact.AttributeType}|{fact.NormalisedValue}|{fact.EvidenceFragment}", StringComparer.OrdinalIgnoreCase) + .Select(group => group.First()) + .ToList(); + } + + public static string CanonicalName(string name, IReadOnlyDictionary? aliases = null) + { + var clean = (name ?? string.Empty).Trim(); + if (aliases is not null && aliases.TryGetValue(Normalise(clean), out var canonical)) + { + return canonical; + } + + return clean; + } + + public static IReadOnlyDictionary AliasMap(string sourceText, IEnumerable? knownCharacterNames = null) + { + var aliases = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (Match match in MissCallMeRegex.Matches(sourceText ?? string.Empty)) + { + var first = match.Groups["first"].Value.Trim(); + var surname = match.Groups["surname"].Value.Trim(); + var canonical = $"{first} {surname}"; + aliases[Normalise(first)] = canonical; + aliases[Normalise($"Miss {surname}")] = canonical; + aliases[Normalise($"Miss {surname} ({first})")] = canonical; + } + + return aliases; + } + + private static void AddHairFacts(List facts, string canonical, IReadOnlyList aliases, Match match, string sourceScene) + { + var colour = StoryIntelligenceIllustrationCompatibility.NormalizeHairColour(match.Groups["colour"].Value); + if (colour != StoryIntelligenceIllustrationCompatibility.HairColours.Unknown) + { + facts.Add(new(canonical, aliases, "HairColour", colour, ShortFragment(match.Value), 0.94m, true, sourceScene)); + } + + var length = StoryIntelligenceIllustrationCompatibility.NormalizeHairLength(match.Groups["length"].Value); + if (length != "Unknown") + { + facts.Add(new(canonical, aliases, "HairLength", length, ShortFragment(match.Value), 0.88m, true, sourceScene)); + } + } + + private static IReadOnlyList AliasesFor(string name, IReadOnlyDictionary aliases) + { + var canonical = CanonicalName(name, aliases); + var list = aliases + .Where(item => string.Equals(item.Value, canonical, StringComparison.OrdinalIgnoreCase)) + .Select(item => item.Key) + .Append(Normalise(name)) + .Where(item => !string.IsNullOrWhiteSpace(item)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + return list.Count == 0 ? [name] : list; + } + + private static string AliasEvidence(string sourceText, string alias, string canonical) + { + var index = sourceText.IndexOf(alias, StringComparison.OrdinalIgnoreCase); + if (index < 0) + { + index = sourceText.IndexOf(canonical.Split(' ')[0], StringComparison.OrdinalIgnoreCase); + } + + if (index < 0) + { + return canonical; + } + + var start = Math.Max(0, index - 60); + var length = Math.Min(sourceText.Length - start, 140); + return ShortFragment(sourceText.Substring(start, length)); + } + + private static string ShortFragment(string value) + => Regex.Replace(value.Trim(), @"\s+", " ") is var clean && clean.Length > 160 ? clean[..160].TrimEnd() : clean; + + private static string Normalise(string? value) + { + var chars = (value ?? string.Empty).Trim().ToLowerInvariant().Select(ch => char.IsLetterOrDigit(ch) ? ch : ' ').ToArray(); + return string.Join(' ', new string(chars).Split(' ', StringSplitOptions.RemoveEmptyEntries)); + } +} diff --git a/PlotLine/Services/OnboardingStoryIntelligenceService.cs b/PlotLine/Services/OnboardingStoryIntelligenceService.cs index 352c8ab..0bcc99d 100644 --- a/PlotLine/Services/OnboardingStoryIntelligenceService.cs +++ b/PlotLine/Services/OnboardingStoryIntelligenceService.cs @@ -9,7 +9,7 @@ namespace PlotLine.Services; public interface IOnboardingStoryIntelligenceService { Task GetOverviewAsync(); - Task StartAsync(); + Task StartAsync(string? appearancePreference = null); Task GetProgressAsync(Guid batchId); Task CancelAsync(Guid batchId); Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> CommitAsync(Guid batchId, int runId); @@ -83,7 +83,7 @@ public sealed class OnboardingStoryIntelligenceService( }; } - public async Task StartAsync() + public async Task StartAsync(string? appearancePreference = null) { var userId = RequireUserId(); var context = await GetContextAsync(userId); @@ -189,6 +189,7 @@ public sealed class OnboardingStoryIntelligenceService( BookID = preview.BookID, ProjectName = wizard.SelectedProjectName, BookTitle = wizard.SelectedBookTitle, + AppearancePreference = NormaliseAppearancePreference(appearancePreference), Items = batchItems }; @@ -204,6 +205,18 @@ public sealed class OnboardingStoryIntelligenceService( return batch; } + private static string NormaliseAppearancePreference(string? value) + => value?.Trim() switch + { + StoryMemoryAppearancePreferences.ExplicitEvidenceOnly => StoryMemoryAppearancePreferences.ExplicitEvidenceOnly, + StoryMemoryAppearancePreferences.Balanced => StoryMemoryAppearancePreferences.Balanced, + StoryMemoryAppearancePreferences.PredominantlyLight => StoryMemoryAppearancePreferences.PredominantlyLight, + StoryMemoryAppearancePreferences.PredominantlyMedium => StoryMemoryAppearancePreferences.PredominantlyMedium, + StoryMemoryAppearancePreferences.PredominantlyDark => StoryMemoryAppearancePreferences.PredominantlyDark, + StoryMemoryAppearancePreferences.CustomMix => StoryMemoryAppearancePreferences.CustomMix, + _ => StoryMemoryAppearancePreferences.Balanced + }; + public async Task GetProgressAsync(Guid batchId) { var batch = await batchStore.GetAsync(RequireUserId(), batchId); @@ -919,6 +932,7 @@ public sealed class OnboardingStoryIntelligenceBatch public int BookID { get; init; } public string? ProjectName { get; init; } public string? BookTitle { get; init; } + public string AppearancePreference { get; init; } = StoryMemoryAppearancePreferences.Balanced; public DateTime CreatedUtc { get; init; } = DateTime.UtcNow; public IReadOnlyList Items { get; init; } = []; public List CharacterDecisions { get; } = []; diff --git a/PlotLine/Services/StoryIntelligenceIllustrationCompatibility.cs b/PlotLine/Services/StoryIntelligenceIllustrationCompatibility.cs index d170a3b..51a14e6 100644 --- a/PlotLine/Services/StoryIntelligenceIllustrationCompatibility.cs +++ b/PlotLine/Services/StoryIntelligenceIllustrationCompatibility.cs @@ -259,30 +259,46 @@ public static class StoryIntelligenceIllustrationCompatibility var padded = $" {text} "; if (ContainsAny(text, "driving test centre", "driving test center", "test centre", "test center")) return "DrivingTestCentre"; if (ContainsAny(text, "police station")) return "PoliceStation"; - if (ContainsAny(text, "car interior", "vehicle interior", "inside the car", "inside car", "driver seat", "driver's seat", "passenger seat", "rear seat", "back seat", "boot", "cabin", "dashboard")) return "VehicleInterior"; + if (ContainsAny(text, "driver seat", "driver's seat")) return "DriverSeat"; + if (ContainsAny(text, "passenger seat")) return "PassengerSeat"; + if (ContainsAny(text, "rear seat", "back seat")) return "RearSeat"; + if (ContainsAny(text, "car interior", "vehicle interior", "inside the car", "inside car", "boot", "cabin", "dashboard")) return "VehicleInterior"; if (ContainsAny(text, "bathroom", "toilet", "washroom")) return "Bathroom"; if (text.Contains("kitchen", StringComparison.OrdinalIgnoreCase)) return "Kitchen"; if (text.Contains("bedroom", StringComparison.OrdinalIgnoreCase)) return "Bedroom"; if (ContainsAny(text, "living room", "sitting room", "lounge")) return "LivingRoom"; if (ContainsAny(text, "hall", "hallway", "corridor")) return "Hallway"; if (text.Contains("stair", StringComparison.OrdinalIgnoreCase)) return "Stairwell"; - if (ContainsAny(text, "hospital", "derelict hospital")) return "Hospital"; + if (ContainsAny(text, "derelict hospital", "abandoned hospital")) return "DerelictHospital"; + if (ContainsAny(text, "hospital")) return "Hospital"; if (ContainsAny(text, "church", "chapel")) return "Church"; if (ContainsAny(text, "restaurant", "cafe", "café", "diner")) return "Restaurant"; if (ContainsAny(text, "pub", "public house")) return "Pub"; if (ContainsAny(text, "beach", "seafront")) return "Beach"; if (ContainsAny(text, "railway station", "train station", "station platform")) return "RailwayStation"; if (ContainsAny(text, "canal", "towpath")) return "Canal"; - if (ContainsAny(text, "garage", "workshop")) return "Garage"; + if (ContainsAny(text, "workshop")) return "Workshop"; + if (ContainsAny(text, "garage")) return "Garage"; if (ContainsAny(text, "front door", "porch", "entrance", "house exterior", "outside the house", "exterior")) return "HouseExterior"; if (ContainsAny(text, "office", "desk", "study", "library")) return "Office"; if (text.Contains("school", StringComparison.OrdinalIgnoreCase)) return "School"; if (text.Contains("waiting room", StringComparison.OrdinalIgnoreCase)) return "WaitingRoom"; if (ContainsAny(text, "car park", "parking", "forecourt")) return "CarPark"; + if (ContainsAny(text, "dual carriageway")) return "DualCarriageway"; + if (ContainsAny(text, "motorway", "highway")) return "Motorway"; + if (ContainsAny(text, "bridge")) return "Bridge"; + if (ContainsAny(text, "tunnel")) return "Tunnel"; + if (ContainsAny(text, "flyover")) return "Flyover"; + if (ContainsAny(text, "underpass")) return "Underpass"; + if (ContainsAny(text, " lane ") || text.EndsWith(" lane", StringComparison.OrdinalIgnoreCase)) return "Lane"; + if (ContainsAny(text, " street ") || text.EndsWith(" street", StringComparison.OrdinalIgnoreCase)) return "Street"; + if (ContainsAny(text, " avenue ") || text.EndsWith(" avenue", StringComparison.OrdinalIgnoreCase)) return "Avenue"; + if (ContainsAny(text, " drive ") || text.EndsWith(" drive", StringComparison.OrdinalIgnoreCase)) return "Drive"; + if (ContainsAny(text, " close ") || text.EndsWith(" close", StringComparison.OrdinalIgnoreCase)) return "Close"; if (ContainsAny(text, - "road", "street", "lane", "drive", " close ", "court", "way", "avenue", "terrace", "gardens", - "highway", "motorway", "bridge", "roundabout", "junction", "crossroads", "dual carriageway", - "track", "footpath", " path ", "cycleway", "tunnel", "flyover", "underpass", "boulevard") + "road", "court", "way", "terrace", "gardens", + "roundabout", "junction", "crossroads", + "track", "footpath", " path ", "cycleway", "boulevard") || RoadSuffixRegex.IsMatch(padded)) { return "Road"; @@ -323,24 +339,32 @@ public static class StoryIntelligenceIllustrationCompatibility if (ContainsAny(text, "money", "cash", "coins", "banknote")) return "Money"; if (ContainsAny(text, "scarf", "scarves")) return "Clothing"; if (ContainsAny(text, "paperwork")) return "Document"; + if (ContainsAny(text, "lipstick")) return "Lipstick"; if (ContainsAny(text, "ring", "necklace", "jewellery", "jewelry")) return "Jewellery"; + if (ContainsAny(text, "shoes", "boots", "trainers")) return "Shoes"; + if (ContainsAny(text, "dress")) return "Dress"; + if (ContainsAny(text, "skirt")) return "Skirt"; + if (ContainsAny(text, "stockings")) return "Stockings"; if (ContainsAny(text, "gun", "pistol", "rifle", "weapon")) return "Weapon"; if (ContainsAny(padded, " hatchback ", " vw golf ", " volkswagen golf ", " golf gti ", " ford fiesta ", " fiesta ")) return "Hatchback"; - if (ContainsAny(padded, " family car ", " estate car ", " saloon ")) return "FamilyCar"; + if (ContainsAny(padded, " estate car ", " estate ")) return "Estate"; + if (ContainsAny(padded, " saloon ")) return "Saloon"; + if (ContainsAny(padded, " family car ")) return "Car"; if (ContainsAny(padded, " sports car ", " tr6 ", " roadster ")) return "SportsCar"; if (ContainsAny(padded, " van ", " transit ")) return "Van"; if (ContainsAny(padded, " motor car ", " car ", " vehicle ")) return "Car"; if (ContainsAny(text, "supercharger", "engine part")) return "CarPart"; if (ContainsAny(text, "biscuit tin", "tin")) return "Tin"; if (ContainsAny(text, "parcel", "package")) return "Parcel"; - if (ContainsAny(text, "blouse", "skirt", "jumper", "jumpers", "sunglasses", "stockings")) return "Clothing"; + if (ContainsAny(text, "blouse", "jumper", "jumpers", "sunglasses", "clothing", "clothes")) return "Clothing"; if (text.Contains("rucksack", StringComparison.OrdinalIgnoreCase)) return "Rucksack"; if (text.Contains("suitcase", StringComparison.OrdinalIgnoreCase)) return "Suitcase"; if (text.Contains("note", StringComparison.OrdinalIgnoreCase)) return "Note"; if (text.Contains("letter", StringComparison.OrdinalIgnoreCase)) return "Letter"; if (text.Contains("notebook", StringComparison.OrdinalIgnoreCase)) return "Notebook"; if (text.Contains("book", StringComparison.OrdinalIgnoreCase)) return "Book"; - if (ContainsAny(text, "telephone", "phone", "recorder")) return "Telephone"; + if (ContainsAny(text, "telephone", "phone", "mobile")) return "Phone"; + if (ContainsAny(text, "recorder")) return "Recorder"; if (text.Contains("key", StringComparison.OrdinalIgnoreCase)) return "Keys"; if (ContainsAny(text, "tr6")) return "Car"; if (ContainsAny(text, "photo", "photograph")) return "Photograph"; @@ -364,6 +388,8 @@ public static class StoryIntelligenceIllustrationCompatibility public static bool LocationCompatible(string requestedType, string candidateType) => requestedType == candidateType + || (requestedType is "Lane" or "Street" or "Avenue" or "Drive" or "Close" && candidateType == "Road") + || (requestedType is "DriverSeat" or "PassengerSeat" or "RearSeat" && candidateType == "VehicleInterior") || (requestedType is "FrontDoor" or "Porch" && candidateType is "HouseExterior") || (requestedType is "Kitchen" or "LivingRoom" or "Bedroom" && candidateType is "FlatInterior") || (requestedType == "Bathroom" && candidateType == "Bathroom"); @@ -371,7 +397,7 @@ public static class StoryIntelligenceIllustrationCompatibility public static bool AssetCompatible(string requestedType, string candidateType) => requestedType == candidateType || (requestedType == "Hatchback" && candidateType == "Car") - || (requestedType == "FamilyCar" && candidateType == "Car") + || (requestedType is "Saloon" or "Estate" && candidateType == "Car") || (requestedType == "Note" && candidateType is "Letter" or "Document") || (requestedType == "Rucksack" && candidateType == "Rucksack"); @@ -673,6 +699,18 @@ public static class StoryIntelligenceIllustrationCompatibility { lower.IndexOf("hair", StringComparison.Ordinal), lower.IndexOf("skin", StringComparison.Ordinal), + lower.IndexOf("blonde", StringComparison.Ordinal), + lower.IndexOf("brunette", StringComparison.Ordinal), + lower.IndexOf("red haired", StringComparison.Ordinal), + lower.IndexOf("red-haired", StringComparison.Ordinal), + lower.IndexOf("fair haired", StringComparison.Ordinal), + lower.IndexOf("fair-haired", StringComparison.Ordinal), + lower.IndexOf("black haired", StringComparison.Ordinal), + lower.IndexOf("black-haired", StringComparison.Ordinal), + lower.IndexOf("brown haired", StringComparison.Ordinal), + lower.IndexOf("brown-haired", StringComparison.Ordinal), + lower.IndexOf("grey haired", StringComparison.Ordinal), + lower.IndexOf("grey-haired", StringComparison.Ordinal), lower.IndexOf("glasses", StringComparison.Ordinal), lower.IndexOf("spectacles", StringComparison.Ordinal), lower.IndexOf("beard", StringComparison.Ordinal), diff --git a/PlotLine/Services/StoryIntelligenceVisualisationSnapshotService.cs b/PlotLine/Services/StoryIntelligenceVisualisationSnapshotService.cs index 955b222..6823385 100644 --- a/PlotLine/Services/StoryIntelligenceVisualisationSnapshotService.cs +++ b/PlotLine/Services/StoryIntelligenceVisualisationSnapshotService.cs @@ -269,6 +269,7 @@ public sealed class StoryIntelligenceVisualisationSnapshotService( scenes.Add(BuildEmptyScene(activeSnapshot.Run)); } + var appearancePreference = await storyMemoryRepository.GetAppearancePreferenceAsync(importSessionId); var model = new StoryIntelligenceExperiencePrototypeViewModel { Mode = replayMode ? "replay" : "real", @@ -297,7 +298,8 @@ public sealed class StoryIntelligenceVisualisationSnapshotService( completedChapterRuns, displayedProgressReason, storyMemory, - replayMode), + replayMode, + appearancePreference), ProjectName = DisplayTitle(session.ProjectName, "Untitled project"), BookTitle = DisplayTitle(session.BookDisplayTitle, "Untitled book"), Scenes = scenes @@ -1449,7 +1451,8 @@ public sealed class StoryIntelligenceVisualisationSnapshotService( int completedChapters, string displayedProgressReason, StoryMemory storyMemory, - bool replayMode = false) + bool replayMode = false, + string appearancePreference = "") { var failed = snapshots.Sum(snapshot => snapshot.Parsed.Count(item => item.Scene is null)); var warning = failed == 0 ? null : $"{failed:N0} scene result(s) skipped because they could not be parsed."; @@ -1513,6 +1516,7 @@ public sealed class StoryIntelligenceVisualisationSnapshotService( ReplayState = replayMode ? "Paused at first available scene" : string.Empty, SnapshotCacheStatus = replayMode ? "Reconstructed from saved analysis" : string.Empty, RebuildVersion = replayMode ? "21R-replay-v1" : string.Empty, + AppearancePreference = appearancePreference, FailedChaptersSkipped = failedChapterSnapshots.Count, FailedChapterSummary = failedChapterSummary, PanelVisibleCounts = replayMode ? "relationships:2; knowledge:3; observations:2; discoveries:4; activity:4" : string.Empty, diff --git a/PlotLine/Services/StoryMemoryServices.cs b/PlotLine/Services/StoryMemoryServices.cs index e63a636..1731615 100644 --- a/PlotLine/Services/StoryMemoryServices.cs +++ b/PlotLine/Services/StoryMemoryServices.cs @@ -249,11 +249,17 @@ public sealed class StoryMemoryService( continue; } - var resolvedName = ResolveIdentityAlias(sceneCharacter.Name, identityAliases); + var sceneAppearanceText = CharacterText(sceneCharacter); + var knownNames = KnownNames(sceneCharacter.Name, sceneCharacter.Aliases); + var appearanceAliases = CharacterAppearanceExtractionService.AliasMap(sceneAppearanceText, knownNames); + var resolvedName = CharacterAppearanceExtractionService.CanonicalName( + ResolveIdentityAlias(sceneCharacter.Name, identityAliases), + appearanceAliases); var resolvedAliases = string.Equals(resolvedName, sceneCharacter.Name, StringComparison.OrdinalIgnoreCase) ? sceneCharacter.Aliases : (sceneCharacter.Aliases ?? []).Concat([sceneCharacter.Name!]).ToList(); var key = CharacterKey(resolvedName, resolvedAliases); + var displayName = CanonicalCharacterDisplayName(resolvedName, resolvedAliases); var isGroup = StoryIntelligenceIllustrationCompatibility.IsGroupEntity(sceneCharacter.Name); var resolvedPovName = ResolveScenePov(scene); var isPrimaryPov = string.Equals(resolvedPovName, sceneCharacter.Name, StringComparison.OrdinalIgnoreCase) @@ -264,7 +270,7 @@ public sealed class StoryMemoryService( session.ProjectID, session.BookID, key, - resolvedName.Trim(), + displayName, context, sourceSectionType, IsNamedCharacter(sceneCharacter.Name), @@ -280,7 +286,7 @@ public sealed class StoryMemoryService( await memory.AddCharacterAliasAsync(character.StoryMemoryCharacterID, alias, Normalise(alias), "Scene character alias", result.SceneResultID, sceneCharacter.Confidence ?? 0.7m); } - var identityEvidence = new[] { sceneCharacter.Name, resolvedName, sceneCharacter.RoleInScene, sceneCharacter.Notes } + var identityEvidence = new[] { sceneCharacter.Name, resolvedName, displayName, sceneCharacter.RoleInScene, sceneCharacter.Notes } .Concat(sceneCharacter.Actions ?? []) .Concat(sceneCharacter.Aliases ?? []) .Concat(ExtensionEvidence(sceneCharacter.ExtensionData)) @@ -298,6 +304,59 @@ public sealed class StoryMemoryService( await AddKnownAttributeAsync(character.StoryMemoryCharacterID, "HairColour", inferred.HairColour, 0.82m, inferred.HairColour != StoryIntelligenceIllustrationCompatibility.HairColours.Unknown, "Deterministic evidence inference", result.SceneResultID); await AddKnownAttributeAsync(character.StoryMemoryCharacterID, "SkinTone", inferred.SkinTone, 0.8m, inferred.SkinTone != StoryIntelligenceIllustrationCompatibility.SkinTones.Unknown, "Deterministic evidence inference", result.SceneResultID); await AddKnownAttributeAsync(character.StoryMemoryCharacterID, "HairLength", inferred.HairLength, 0.72m, inferred.HairLength != "Unknown", "Deterministic evidence inference", result.SceneResultID); + foreach (var fact in CharacterAppearanceExtractionService.Extract(sceneAppearanceText, [displayName, .. knownNames], $"SceneResultID {result.SceneResultID}")) + { + if (fact.AttributeType == "Alias") + { + await memory.AddCharacterAliasAsync(character.StoryMemoryCharacterID, fact.NormalisedValue, Normalise(fact.NormalisedValue), fact.EvidenceFragment, result.SceneResultID, fact.Confidence); + } + else + { + await AddKnownAttributeAsync(character.StoryMemoryCharacterID, fact.AttributeType, fact.NormalisedValue, fact.Confidence, fact.IsExplicit, fact.EvidenceFragment, result.SceneResultID); + } + } + } + + foreach (var appearance in scene.CharacterAppearance ?? []) + { + if (string.IsNullOrWhiteSpace(appearance.CanonicalName)) + { + continue; + } + + var aliases = appearance.Aliases ?? []; + var canonical = CharacterAppearanceExtractionService.CanonicalName( + appearance.CanonicalName, + CharacterAppearanceExtractionService.AliasMap(appearance.Evidence ?? string.Empty, [appearance.CanonicalName, .. aliases])); + var displayName = CanonicalCharacterDisplayName(canonical, aliases); + var character = await memory.UpsertCharacterAsync(new StoryMemoryCharacterSave( + session.StoryIntelligenceBookPipelineID, + session.ProjectID, + session.BookID, + CharacterKey(displayName, aliases), + displayName, + context, + sourceSectionType, + IsNamedCharacter(displayName), + StoryIntelligenceIllustrationCompatibility.IsGroupEntity(displayName), + true, + false, + false, + 55, + result.SceneResultID)); + + await memory.AddCharacterAliasAsync(character.StoryMemoryCharacterID, displayName, Normalise(displayName), "Structured character appearance canonical identity", result.SceneResultID, appearance.Confidence ?? 0.85m); + foreach (var alias in aliases.Where(alias => !string.IsNullOrWhiteSpace(alias))) + { + await memory.AddCharacterAliasAsync(character.StoryMemoryCharacterID, alias.Trim(), Normalise(alias), "Structured character appearance alias", result.SceneResultID, appearance.Confidence ?? 0.8m); + } + + var evidence = string.IsNullOrWhiteSpace(appearance.Evidence) ? "Structured character appearance extraction" : appearance.Evidence.Trim(); + var explicitFact = string.Equals(appearance.EvidenceKind, "explicit", StringComparison.OrdinalIgnoreCase); + await AddKnownAttributeAsync(character.StoryMemoryCharacterID, "AgeBand", StoryIntelligenceIllustrationCompatibility.NormalizeAgeBand(appearance.InferredAgeBand ?? appearance.ExplicitAge ?? appearance.RoleOrOccupation), appearance.Confidence ?? 0.82m, explicitFact || !string.IsNullOrWhiteSpace(appearance.ExplicitAge), evidence, result.SceneResultID); + await AddKnownAttributeAsync(character.StoryMemoryCharacterID, "Presentation", StoryIntelligenceIllustrationCompatibility.NormalizePresentation(appearance.Presentation ?? appearance.PronounEvidence ?? appearance.Title), appearance.Confidence ?? 0.82m, explicitFact, evidence, result.SceneResultID); + await AddKnownAttributeAsync(character.StoryMemoryCharacterID, "HairColour", StoryIntelligenceIllustrationCompatibility.NormalizeHairColour(appearance.HairColour), appearance.Confidence ?? 0.9m, explicitFact, evidence, result.SceneResultID); + await AddKnownAttributeAsync(character.StoryMemoryCharacterID, "HairLength", StoryIntelligenceIllustrationCompatibility.NormalizeHairLength(appearance.HairLength), appearance.Confidence ?? 0.86m, explicitFact, evidence, result.SceneResultID); } if (!string.IsNullOrWhiteSpace(scene.Setting?.LocationName)) @@ -343,8 +402,8 @@ public sealed class StoryMemoryService( var sourceName = ResolveIdentityAlias(relationship.CharacterA, identityAliases); var targetName = ResolveIdentityAlias(relationship.CharacterB, identityAliases); - var source = await memory.UpsertCharacterAsync(new StoryMemoryCharacterSave(session.StoryIntelligenceBookPipelineID, session.ProjectID, session.BookID, CharacterKey(sourceName), sourceName.Trim(), context, sourceSectionType, IsNamedCharacter(sourceName), StoryIntelligenceIllustrationCompatibility.IsGroupEntity(sourceName), true, false, false, 40, result.SceneResultID)); - var target = await memory.UpsertCharacterAsync(new StoryMemoryCharacterSave(session.StoryIntelligenceBookPipelineID, session.ProjectID, session.BookID, CharacterKey(targetName), targetName.Trim(), context, sourceSectionType, IsNamedCharacter(targetName), StoryIntelligenceIllustrationCompatibility.IsGroupEntity(targetName), true, false, false, 40, result.SceneResultID)); + var source = await memory.UpsertCharacterAsync(new StoryMemoryCharacterSave(session.StoryIntelligenceBookPipelineID, session.ProjectID, session.BookID, CharacterKey(sourceName), CanonicalCharacterDisplayName(sourceName, null), context, sourceSectionType, IsNamedCharacter(sourceName), StoryIntelligenceIllustrationCompatibility.IsGroupEntity(sourceName), true, false, false, 40, result.SceneResultID)); + var target = await memory.UpsertCharacterAsync(new StoryMemoryCharacterSave(session.StoryIntelligenceBookPipelineID, session.ProjectID, session.BookID, CharacterKey(targetName), CanonicalCharacterDisplayName(targetName, null), context, sourceSectionType, IsNamedCharacter(targetName), StoryIntelligenceIllustrationCompatibility.IsGroupEntity(targetName), 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); } } @@ -576,6 +635,16 @@ public sealed class StoryMemoryService( } } + foreach (var character in ordered.SelectMany(item => item.Scene?.Characters ?? [])) + { + var evidence = CharacterText(character); + var localAliases = CharacterAppearanceExtractionService.AliasMap(evidence, KnownNames(character.Name, character.Aliases)); + foreach (var alias in localAliases) + { + aliases[alias.Key] = alias.Value; + } + } + return aliases; } @@ -616,12 +685,30 @@ public sealed class StoryMemoryService( return normalised.Split(' ', StringSplitOptions.RemoveEmptyEntries).Length <= 2; } + private static string CanonicalCharacterDisplayName(string? name, IEnumerable? aliases) + { + var clean = (name ?? string.Empty).Trim(); + var known = KnownNames(clean, aliases); + var aliasMap = CharacterAppearanceExtractionService.AliasMap(string.Empty, known); + var canonical = CharacterAppearanceExtractionService.CanonicalName(clean, aliasMap); + return string.IsNullOrWhiteSpace(canonical) ? clean : canonical; + } + + private static IReadOnlyList KnownNames(string? name, IEnumerable? aliases) + => (aliases ?? []) + .Prepend(name ?? string.Empty) + .Where(value => !string.IsNullOrWhiteSpace(value)) + .Select(value => value.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + private static string CharacterText(SceneIntelligenceCharacter character) => string.Join(' ', new[] { character.Name, character.RoleInScene, character.Notes, + string.Join(' ', character.Aliases ?? []), string.Join(' ', character.Actions ?? []) } .Where(value => !string.IsNullOrWhiteSpace(value))); diff --git a/PlotLine/Sql/143_Phase21U_DevelopmentStoryIntelligenceReset.sql b/PlotLine/Sql/143_Phase21U_DevelopmentStoryIntelligenceReset.sql new file mode 100644 index 0000000..55ecb33 --- /dev/null +++ b/PlotLine/Sql/143_Phase21U_DevelopmentStoryIntelligenceReset.sql @@ -0,0 +1,141 @@ +SET ANSI_NULLS ON; +SET QUOTED_IDENTIFIER ON; +GO + +/* +Phase 21U development reset. + +Usage: + DECLARE @DevelopmentEmail nvarchar(256) = N'you@example.com'; + EXEC dbo.StoryIntelligence_DevelopmentAccountReset @DevelopmentEmail = @DevelopmentEmail; + +This intentionally deletes project/book/import data only for projects owned by +the supplied account. It also clears the Story Intelligence illustration +library tables because that library is global development data. + +Physical files must be deleted separately from: + wwwroot/uploads/story-intelligence-library +*/ + +CREATE OR ALTER PROCEDURE dbo.StoryIntelligence_DevelopmentAccountReset + @DevelopmentEmail nvarchar(256) +AS +BEGIN + SET NOCOUNT ON; + SET XACT_ABORT ON; + + DECLARE @UserID int; + SELECT @UserID = UserID + FROM dbo.AppUser + WHERE Email = @DevelopmentEmail; + + IF @UserID IS NULL + THROW 52101, 'Development account email was not found.', 1; + + DECLARE @ProjectIDs TABLE (ProjectID int NOT NULL PRIMARY KEY); + INSERT @ProjectIDs (ProjectID) + SELECT DISTINCT p.ProjectID + FROM dbo.Projects p + INNER JOIN dbo.ProjectUserAccess pua ON pua.ProjectID = p.ProjectID + WHERE pua.UserID = @UserID + AND pua.AccessRole = N'Owner' + AND pua.IsActive = 1; + + DECLARE @DeletedProjects table (ProjectID int NOT NULL PRIMARY KEY, ProjectName nvarchar(200) NULL); + INSERT @DeletedProjects(ProjectID, ProjectName) + SELECT p.ProjectID, p.ProjectName + FROM dbo.Projects p + INNER JOIN @ProjectIDs ids ON ids.ProjectID = p.ProjectID; + + BEGIN TRY + BEGIN TRANSACTION; + + UPDATE dbo.StoryIntelligenceRuns + SET Status = CASE WHEN Status IN (N'Pending', N'Running') THEN N'Cancelled' ELSE Status END, + CurrentStage = N'Cancelled by Phase 21U reset', + CurrentMessage = N'Development reset invalidated this run.', + CompletedUtc = COALESCE(CompletedUtc, SYSUTCDATETIME()), + UpdatedUtc = SYSUTCDATETIME() + WHERE ProjectID IN (SELECT ProjectID FROM @ProjectIDs) + AND Status IN (N'Pending', N'Running'); + + UPDATE dbo.IllustrationLibraryItems + SET Status = CASE WHEN Status IN (N'Queued', N'Generating') THEN N'Failed' ELSE Status END, + LastError = N'Invalidated by Phase 21U development reset', + UpdatedUtc = SYSUTCDATETIME() + WHERE Category IN (N'Character', N'Location', N'Asset') + AND Status IN (N'Queued', N'Generating'); + + DELETE FROM dbo.StoryMemoryIllustrationAssignments; + DELETE FROM dbo.StoryMemoryIllustrationDemands; + DELETE FROM dbo.StoryMemoryRelationships; + DELETE FROM dbo.StoryMemoryCharacterAttributes; + DELETE FROM dbo.StoryMemoryCharacterAliases; + DELETE FROM dbo.StoryMemoryAssets; + DELETE FROM dbo.StoryMemoryLocations; + DELETE FROM dbo.StoryMemoryCharacters; + DELETE FROM dbo.StoryMemoryProcessedSceneResults; + DELETE FROM dbo.StoryMemoryImportPreferences; + DELETE FROM dbo.StoryIntelligenceCharacterIllustrationAssignments; + DELETE FROM dbo.StoryIntelligenceIllustrationDemands; + + IF OBJECT_ID(N'dbo.IllustrationGenerationAttempts', N'U') IS NOT NULL + DELETE FROM dbo.IllustrationGenerationAttempts; + + IF OBJECT_ID(N'dbo.IllustrationLibraryApprovalHistory', N'U') IS NOT NULL + DELETE FROM dbo.IllustrationLibraryApprovalHistory; + + IF OBJECT_ID(N'dbo.IllustrationLibraryLineage', N'U') IS NOT NULL + DELETE FROM dbo.IllustrationLibraryLineage; + + UPDATE dbo.IllustrationLibraryItems + SET ParentItemID = NULL + WHERE Category IN (N'Character', N'Location', N'Asset'); + + DELETE FROM dbo.IllustrationLibraryItems + WHERE Category IN (N'Character', N'Location', N'Asset'); + + COMMIT TRANSACTION; + END TRY + BEGIN CATCH + IF XACT_STATE() <> 0 ROLLBACK TRANSACTION; + THROW; + END CATCH; + + DECLARE @ProjectID int; + DECLARE project_cursor CURSOR LOCAL FAST_FORWARD FOR + SELECT ProjectID FROM @ProjectIDs ORDER BY ProjectID; + + OPEN project_cursor; + FETCH NEXT FROM project_cursor INTO @ProjectID; + WHILE @@FETCH_STATUS = 0 + BEGIN + UPDATE dbo.Projects + SET IsArchived = 1, + ArchivedDate = COALESCE(ArchivedDate, SYSUTCDATETIME()), + ArchivedReason = N'Phase 21U development reset' + WHERE ProjectID = @ProjectID; + + EXEC dbo.ProjectHardDelete_ForOwner @ProjectID = @ProjectID, @UserID = @UserID; + FETCH NEXT FROM project_cursor INTO @ProjectID; + END + CLOSE project_cursor; + DEALLOCATE project_cursor; + + SELECT N'ProjectsDeleted' AS Metric, COUNT(*) AS Value FROM @DeletedProjects + UNION ALL SELECT N'RemainingOwnedProjects', COUNT(*) + FROM dbo.Projects p + INNER JOIN dbo.ProjectUserAccess pua ON pua.ProjectID = p.ProjectID + WHERE pua.UserID = @UserID AND pua.IsActive = 1 + UNION ALL SELECT N'RemainingStoryIntelligenceRuns', COUNT(*) + FROM dbo.StoryIntelligenceRuns + WHERE UserID = @UserID + UNION ALL SELECT N'RemainingIllustrationLibraryItems', COUNT(*) + FROM dbo.IllustrationLibraryItems + WHERE Category IN (N'Character', N'Location', N'Asset'); + + SELECT ProjectID, ProjectName + FROM @DeletedProjects + ORDER BY ProjectID; +END; +GO diff --git a/PlotLine/ViewModels/OnboardingViewModels.cs b/PlotLine/ViewModels/OnboardingViewModels.cs index d2842f8..c00a546 100644 --- a/PlotLine/ViewModels/OnboardingViewModels.cs +++ b/PlotLine/ViewModels/OnboardingViewModels.cs @@ -131,6 +131,7 @@ public sealed class StoryIntelligenceOverviewViewModel public Guid? ExistingBatchID { get; init; } public StoryIntelligenceJobProgress? ExistingJob { get; init; } public bool CanStart { get; init; } + public string AppearancePreference { get; init; } = StoryMemoryAppearancePreferences.Balanced; } public sealed class StoryIntelligenceProgressViewModel diff --git a/PlotLine/ViewModels/StoryIntelligenceExperiencePrototypeViewModels.cs b/PlotLine/ViewModels/StoryIntelligenceExperiencePrototypeViewModels.cs index c4f4b6a..6b35293 100644 --- a/PlotLine/ViewModels/StoryIntelligenceExperiencePrototypeViewModels.cs +++ b/PlotLine/ViewModels/StoryIntelligenceExperiencePrototypeViewModels.cs @@ -72,6 +72,7 @@ public sealed class StoryIntelligenceExperienceSnapshotDiagnostics public string ReplayState { get; init; } = string.Empty; public string SnapshotCacheStatus { get; init; } = string.Empty; public string RebuildVersion { get; init; } = string.Empty; + public string AppearancePreference { get; init; } = string.Empty; public int FailedChaptersSkipped { get; init; } public string FailedChapterSummary { get; init; } = string.Empty; public string PanelVisibleCounts { get; init; } = string.Empty; diff --git a/PlotLine/Views/Development/StoryIntelligenceExperience.cshtml b/PlotLine/Views/Development/StoryIntelligenceExperience.cshtml index 9675d71..2cccfb5 100644 --- a/PlotLine/Views/Development/StoryIntelligenceExperience.cshtml +++ b/PlotLine/Views/Development/StoryIntelligenceExperience.cshtml @@ -117,6 +117,17 @@ + } @@ -350,6 +361,10 @@
Unresolved characters
@Model.Diagnostics.UnresolvedCharacterCount
+
+
Appearance guidance
+
@Model.Diagnostics.AppearancePreference
+
Narrator confidence
@Model.Diagnostics.NarratorConfidence
diff --git a/PlotLine/Views/Onboarding/StoryIntelligence.cshtml b/PlotLine/Views/Onboarding/StoryIntelligence.cshtml index 125b7a4..ee27c38 100644 --- a/PlotLine/Views/Onboarding/StoryIntelligence.cshtml +++ b/PlotLine/Views/Onboarding/StoryIntelligence.cshtml @@ -80,7 +80,17 @@
-
+ +
+ Unresolved character appearance +

Explicit manuscript evidence always wins. This only guides characters whose appearance is not described.

+ + + + + + +
diff --git a/PlotLine/wwwroot/css/story-intelligence-experience-prototype.css b/PlotLine/wwwroot/css/story-intelligence-experience-prototype.css index e33e30c..be701c9 100644 --- a/PlotLine/wwwroot/css/story-intelligence-experience-prototype.css +++ b/PlotLine/wwwroot/css/story-intelligence-experience-prototype.css @@ -340,7 +340,7 @@ input:focus-visible { .story-exp-right { display: grid; align-content: start; - gap: 14px; + gap: 9px; max-height: calc(100vh - 126px); min-height: 0; overflow-x: hidden; @@ -355,11 +355,11 @@ input:focus-visible { } .story-exp-left { - grid-template-rows: auto auto minmax(150px, 1fr) minmax(120px, 0.72fr); + grid-template-rows: auto auto minmax(170px, 1fr) minmax(96px, 0.56fr); } .story-exp-right { - grid-template-rows: minmax(190px, 1.2fr) minmax(150px, 0.95fr) auto; + grid-template-rows: minmax(218px, 1.35fr) minmax(185px, 1.08fr) auto; } .story-exp-left, @@ -381,7 +381,7 @@ input:focus-visible { .story-exp-progress, .story-exp-panel { - padding: 11px; + padding: 8px; } .story-exp-panel { @@ -504,8 +504,8 @@ input:focus-visible { } .story-exp-panel h2 { - margin: 0 0 7px; - font-size: 0.88rem; + margin: 0 0 5px; + font-size: 0.82rem; letter-spacing: 0.01em; } @@ -524,8 +524,8 @@ input:focus-visible { .story-exp-feed li, .story-exp-insight { border: 1px solid rgba(255, 255, 255, 0.065); - border-radius: 9px; - padding: 7px 8px; + border-radius: 7px; + padding: 6px 7px; background: rgba(255, 255, 255, 0.035); color: var(--story-soft); min-width: 0; @@ -1186,7 +1186,7 @@ input:focus-visible { .story-exp-insight { display: grid; - gap: 2px; + gap: 1px; border-color: rgba(255, 255, 255, 0.055); background: linear-gradient(180deg, rgba(255, 255, 255, 0.038), rgba(255, 255, 255, 0.018)); @@ -1200,25 +1200,18 @@ input:focus-visible { padding: 6px 7px; } -.story-exp-panel--compact .story-exp-insight p { +.story-exp-insight p { display: -webkit-box; overflow: hidden; margin: 0; font-size: 0.72rem; line-height: 1.16; -webkit-box-orient: vertical; - -webkit-line-clamp: 2; + -webkit-line-clamp: 3; } -.story-exp-more { - border: 1px solid rgba(151, 185, 218, 0.12); - border-radius: 8px; - padding: 5px 7px; - background: rgba(255, 255, 255, 0.035); - color: var(--story-soft); - font-size: 0.72rem; - font-weight: 800; - text-align: left; +.story-exp-panel--compact .story-exp-insight p { + -webkit-line-clamp: 2; } .story-exp-list li, @@ -1228,7 +1221,7 @@ input:focus-visible { .story-exp-insight strong { color: var(--story-text); - font-size: 0.82rem; + font-size: 0.76rem; line-height: 1.12; } diff --git a/PlotLine/wwwroot/js/story-intelligence-experience-prototype.js b/PlotLine/wwwroot/js/story-intelligence-experience-prototype.js index d759a7b..fd8942f 100644 --- a/PlotLine/wwwroot/js/story-intelligence-experience-prototype.js +++ b/PlotLine/wwwroot/js/story-intelligence-experience-prototype.js @@ -414,15 +414,12 @@ detail: relationship.state, tone: relationship.weight >= 90 ? "confirmed" : relationship.weight >= 70 ? "strengthening" : "emerging" })); - renderInsights(dom.relationships, relationships.slice(0, 2)); - renderOverflowSummary(dom.relationships, relationships.length - 2, "relationship"); + renderInsights(dom.relationships, relationships.slice(0, 1)); } function updateKnowledgeThreads(scene) { - renderInsights(dom.knowledge, (scene.knowledgeThreads || []).slice(0, 3)); - renderOverflowSummary(dom.knowledge, (scene.knowledgeThreads || []).length - 3, "thread"); - renderInsights(dom.observations, (scene.observations || []).slice(0, 2)); - renderOverflowSummary(dom.observations, (scene.observations || []).length - 2, "observation"); + renderInsights(dom.knowledge, (scene.knowledgeThreads || []).slice(0, 5)); + renderInsights(dom.observations, (scene.observations || []).slice(0, 4)); } function updateSceneRibbon(scene) { @@ -544,26 +541,6 @@ if (dom.detailPanel) dom.detailPanel.hidden = true; } - function renderOverflowSummary(container, hiddenCount, noun) { - if (!container) return; - let node = container.querySelector("[data-insight-overflow]"); - if (hiddenCount <= 0) { - node?.remove(); - return; - } - - if (!node) { - node = document.createElement("button"); - node.type = "button"; - node.className = "story-exp-more"; - node.dataset.insightOverflow = "true"; - container.append(node); - } - - node.textContent = `+${hiddenCount} more ${noun}${hiddenCount === 1 ? "" : "s"}`; - node.title = `${hiddenCount} additional ${noun}${hiddenCount === 1 ? "" : "s"} in this scene`; - } - function renderList(container, items) { const activeIds = new Set(items.map((item, index) => feedId(item, index))); items.forEach((item, index) => {