Reset story intelligence memory extraction
This commit is contained in:
parent
311fbcbbb0
commit
8b2448ecd0
@ -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.");
|
||||
|
||||
|
||||
@ -86,9 +86,9 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
|
||||
|
||||
[HttpPost("story-intelligence/start")]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> StartStoryIntelligence()
|
||||
public async Task<IActionResult> 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.";
|
||||
|
||||
@ -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": "",
|
||||
|
||||
@ -12,6 +12,7 @@ public sealed class SceneIntelligenceScene
|
||||
public SceneIntelligencePointOfView? PointOfView { get; init; }
|
||||
public SceneIntelligenceSetting? Setting { get; init; }
|
||||
public List<SceneIntelligenceCharacter>? Characters { get; init; }
|
||||
public List<SceneIntelligenceCharacterAppearance>? CharacterAppearance { get; init; }
|
||||
public List<SceneIntelligenceLocation>? Locations { get; init; }
|
||||
public List<SceneIntelligenceAsset>? Assets { get; init; }
|
||||
public List<SceneIntelligenceRelationship>? Relationships { get; init; }
|
||||
@ -99,6 +100,33 @@ public sealed class SceneIntelligenceCharacter
|
||||
public Dictionary<string, JsonElement>? ExtensionData { get; init; }
|
||||
}
|
||||
|
||||
public sealed class SceneIntelligenceCharacterAppearance
|
||||
{
|
||||
public string? CanonicalName { get; init; }
|
||||
public List<string>? Aliases { get; init; }
|
||||
public string? Title { get; init; }
|
||||
public string? Presentation { get; init; }
|
||||
public string? ExplicitAge { get; init; }
|
||||
public string? InferredAgeBand { get; init; }
|
||||
public string? HairColour { get; init; }
|
||||
public string? HairLength { get; init; }
|
||||
public string? HairStyle { get; init; }
|
||||
public string? FacialHair { get; init; }
|
||||
public string? Glasses { get; init; }
|
||||
public string? Build { get; init; }
|
||||
public List<string>? DistinctiveFeatures { get; init; }
|
||||
public string? Clothing { get; init; }
|
||||
public string? RoleOrOccupation { get; init; }
|
||||
public string? RelationshipAgeEvidence { get; init; }
|
||||
public string? PronounEvidence { get; init; }
|
||||
public string? Evidence { get; init; }
|
||||
public decimal? Confidence { get; init; }
|
||||
public string? EvidenceKind { get; init; }
|
||||
|
||||
[JsonExtensionData]
|
||||
public Dictionary<string, JsonElement>? ExtensionData { get; init; }
|
||||
}
|
||||
|
||||
public sealed class SceneIntelligenceLocation
|
||||
{
|
||||
public string? Name { get; init; }
|
||||
|
||||
169
PlotLine/Services/CharacterAppearanceExtractionService.cs
Normal file
169
PlotLine/Services/CharacterAppearanceExtractionService.cs
Normal file
@ -0,0 +1,169 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using PlotLine.Models;
|
||||
|
||||
namespace PlotLine.Services;
|
||||
|
||||
public sealed record CharacterAppearanceFact(
|
||||
string CanonicalName,
|
||||
IReadOnlyList<string> Aliases,
|
||||
string AttributeType,
|
||||
string NormalisedValue,
|
||||
string EvidenceFragment,
|
||||
decimal Confidence,
|
||||
bool IsExplicit,
|
||||
string SourceScene);
|
||||
|
||||
public static class CharacterAppearanceExtractionService
|
||||
{
|
||||
private static readonly Regex MissCallMeRegex = new(@"\bMiss\s+(?<surname>[A-Z][A-Za-z'’-]+)\b[^.!?]{0,80}?\b(?:call me|called)\s+(?<first>[A-Z][A-Za-z'’-]+)\b", RegexOptions.Compiled);
|
||||
private static readonly Regex HairRegex = new(@"\b(?<name>Mum|Mother|Mrs\s+[A-Z][A-Za-z'’-]+|Miss\s+[A-Z][A-Za-z'’-]+|[A-Z][A-Za-z'’-]+)\b[^.!?]{0,90}?\b(?<length>(?i:long|short|shoulder-length|shoulder length|medium-length|medium length))?\s*(?<qualifier>(?i:natural)\s+)?(?<colour>(?i:red|auburn|ginger|blonde|fair|black|brown|brunette|grey|gray|white))\s+(?i:hair)\b", RegexOptions.Compiled);
|
||||
private static readonly Regex HairAdjectiveRegex = new(@"\b(?<name>Mum|Mother|Mrs\s+[A-Z][A-Za-z'’-]+|Miss\s+[A-Z][A-Za-z'’-]+|[A-Z][A-Za-z'’-]+)\b[^.!?]{0,60}?\b(?<colour>(?i:blonde|fair-haired|red-haired|auburn-haired|ginger-haired|black-haired|brown-haired|brunette|grey-haired|gray-haired|white-haired))\b", RegexOptions.Compiled);
|
||||
private static readonly Regex HairLeadingRegex = new(@"\b(?<length>(?i:long|short|shoulder-length|shoulder length|medium-length|medium length))?\s*(?<qualifier>(?i:natural)\s+)?(?<colour>(?i:red|auburn|ginger|blonde|fair|black|brown|brunette|grey|gray|white))\s+(?i:hair)(?:ed)?\s+(?<name>(?i:girl|woman|man|boy))\b", RegexOptions.Compiled);
|
||||
private static readonly Regex HairAdjectiveLeadingRegex = new(@"\b(?<colour>(?i:blonde|fair-haired|red-haired|auburn-haired|ginger-haired|black-haired|brown-haired|brunette|grey-haired|gray-haired|white-haired))\s+(?<name>(?i:girl|woman|man|boy|young woman|young man))\b", RegexOptions.Compiled);
|
||||
private static readonly Regex AgeRegex = new(@"\b(?<name>Mum|Mother|Mrs\s+[A-Z][A-Za-z'’-]+|Miss\s+[A-Z][A-Za-z'’-]+|[A-Z][A-Za-z'’-]+)\b[^.!?]{0,90}?\b(?<age>(?i:late teenage|teenage|fifteen|sixteen|seventeen|eighteen|nineteen|young woman|young man|adult woman|adult man|middle-aged|mature|mother|mum|driving examiner|driving instructor|teacher))\b", RegexOptions.Compiled);
|
||||
|
||||
public static IReadOnlyList<CharacterAppearanceFact> Extract(string sourceText, IEnumerable<string>? knownCharacterNames = null, string sourceScene = "Fixture")
|
||||
{
|
||||
var facts = new List<CharacterAppearanceFact>();
|
||||
if (string.IsNullOrWhiteSpace(sourceText))
|
||||
{
|
||||
return facts;
|
||||
}
|
||||
|
||||
var aliases = AliasMap(sourceText, knownCharacterNames);
|
||||
foreach (Match match in HairRegex.Matches(sourceText))
|
||||
{
|
||||
var rawName = match.Groups["name"].Value.Trim();
|
||||
AddHairFacts(facts, CanonicalName(rawName, aliases), AliasesFor(rawName, aliases), match, sourceScene);
|
||||
}
|
||||
|
||||
foreach (Match match in HairAdjectiveRegex.Matches(sourceText))
|
||||
{
|
||||
var rawName = match.Groups["name"].Value.Trim();
|
||||
AddHairFacts(facts, CanonicalName(rawName, aliases), AliasesFor(rawName, aliases), match, sourceScene);
|
||||
}
|
||||
|
||||
foreach (Match match in HairLeadingRegex.Matches(sourceText))
|
||||
{
|
||||
var descriptor = match.Groups["name"].Value.Trim();
|
||||
var candidate = (knownCharacterNames ?? []).FirstOrDefault(name => sourceText.Contains(name, StringComparison.OrdinalIgnoreCase));
|
||||
if (!string.IsNullOrWhiteSpace(candidate))
|
||||
{
|
||||
AddHairFacts(facts, CanonicalName(candidate, aliases), AliasesFor(candidate, aliases), match, sourceScene);
|
||||
}
|
||||
else
|
||||
{
|
||||
AddHairFacts(facts, descriptor, [descriptor], match, sourceScene);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (Match match in HairAdjectiveLeadingRegex.Matches(sourceText))
|
||||
{
|
||||
var descriptor = match.Groups["name"].Value.Trim();
|
||||
var candidate = (knownCharacterNames ?? []).FirstOrDefault(name => sourceText.Contains(name, StringComparison.OrdinalIgnoreCase));
|
||||
AddHairFacts(facts, string.IsNullOrWhiteSpace(candidate) ? descriptor : CanonicalName(candidate, aliases), string.IsNullOrWhiteSpace(candidate) ? [descriptor] : AliasesFor(candidate, aliases), match, sourceScene);
|
||||
}
|
||||
|
||||
foreach (Match match in AgeRegex.Matches(sourceText))
|
||||
{
|
||||
var rawName = match.Groups["name"].Value.Trim();
|
||||
var age = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence(rawName, [match.Value], []).AgeBand;
|
||||
if (age != StoryIntelligenceIllustrationCompatibility.AgeBands.Unknown)
|
||||
{
|
||||
facts.Add(new(CanonicalName(rawName, aliases), AliasesFor(rawName, aliases), "AgeBand", age, ShortFragment(match.Value), 0.9m, true, sourceScene));
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var (alias, canonical) in aliases)
|
||||
{
|
||||
facts.Add(new(canonical, AliasesFor(alias, aliases), "Alias", alias, AliasEvidence(sourceText, alias, canonical), 0.96m, true, sourceScene));
|
||||
}
|
||||
|
||||
return facts
|
||||
.GroupBy(fact => $"{fact.CanonicalName}|{fact.AttributeType}|{fact.NormalisedValue}|{fact.EvidenceFragment}", StringComparer.OrdinalIgnoreCase)
|
||||
.Select(group => group.First())
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public static string CanonicalName(string name, IReadOnlyDictionary<string, string>? aliases = null)
|
||||
{
|
||||
var clean = (name ?? string.Empty).Trim();
|
||||
if (aliases is not null && aliases.TryGetValue(Normalise(clean), out var canonical))
|
||||
{
|
||||
return canonical;
|
||||
}
|
||||
|
||||
return clean;
|
||||
}
|
||||
|
||||
public static IReadOnlyDictionary<string, string> AliasMap(string sourceText, IEnumerable<string>? knownCharacterNames = null)
|
||||
{
|
||||
var aliases = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (Match match in MissCallMeRegex.Matches(sourceText ?? string.Empty))
|
||||
{
|
||||
var first = match.Groups["first"].Value.Trim();
|
||||
var surname = match.Groups["surname"].Value.Trim();
|
||||
var canonical = $"{first} {surname}";
|
||||
aliases[Normalise(first)] = canonical;
|
||||
aliases[Normalise($"Miss {surname}")] = canonical;
|
||||
aliases[Normalise($"Miss {surname} ({first})")] = canonical;
|
||||
}
|
||||
|
||||
return aliases;
|
||||
}
|
||||
|
||||
private static void AddHairFacts(List<CharacterAppearanceFact> facts, string canonical, IReadOnlyList<string> aliases, Match match, string sourceScene)
|
||||
{
|
||||
var colour = StoryIntelligenceIllustrationCompatibility.NormalizeHairColour(match.Groups["colour"].Value);
|
||||
if (colour != StoryIntelligenceIllustrationCompatibility.HairColours.Unknown)
|
||||
{
|
||||
facts.Add(new(canonical, aliases, "HairColour", colour, ShortFragment(match.Value), 0.94m, true, sourceScene));
|
||||
}
|
||||
|
||||
var length = StoryIntelligenceIllustrationCompatibility.NormalizeHairLength(match.Groups["length"].Value);
|
||||
if (length != "Unknown")
|
||||
{
|
||||
facts.Add(new(canonical, aliases, "HairLength", length, ShortFragment(match.Value), 0.88m, true, sourceScene));
|
||||
}
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> AliasesFor(string name, IReadOnlyDictionary<string, string> aliases)
|
||||
{
|
||||
var canonical = CanonicalName(name, aliases);
|
||||
var list = aliases
|
||||
.Where(item => string.Equals(item.Value, canonical, StringComparison.OrdinalIgnoreCase))
|
||||
.Select(item => item.Key)
|
||||
.Append(Normalise(name))
|
||||
.Where(item => !string.IsNullOrWhiteSpace(item))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
return list.Count == 0 ? [name] : list;
|
||||
}
|
||||
|
||||
private static string AliasEvidence(string sourceText, string alias, string canonical)
|
||||
{
|
||||
var index = sourceText.IndexOf(alias, StringComparison.OrdinalIgnoreCase);
|
||||
if (index < 0)
|
||||
{
|
||||
index = sourceText.IndexOf(canonical.Split(' ')[0], StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
if (index < 0)
|
||||
{
|
||||
return canonical;
|
||||
}
|
||||
|
||||
var start = Math.Max(0, index - 60);
|
||||
var length = Math.Min(sourceText.Length - start, 140);
|
||||
return ShortFragment(sourceText.Substring(start, length));
|
||||
}
|
||||
|
||||
private static string ShortFragment(string value)
|
||||
=> Regex.Replace(value.Trim(), @"\s+", " ") is var clean && clean.Length > 160 ? clean[..160].TrimEnd() : clean;
|
||||
|
||||
private static 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));
|
||||
}
|
||||
}
|
||||
@ -9,7 +9,7 @@ namespace PlotLine.Services;
|
||||
public interface IOnboardingStoryIntelligenceService
|
||||
{
|
||||
Task<StoryIntelligenceOverviewViewModel> GetOverviewAsync();
|
||||
Task<OnboardingStoryIntelligenceBatch?> StartAsync();
|
||||
Task<OnboardingStoryIntelligenceBatch?> StartAsync(string? appearancePreference = null);
|
||||
Task<StoryIntelligenceProgressViewModel?> GetProgressAsync(Guid batchId);
|
||||
Task<StoryIntelligenceProgressViewModel?> CancelAsync(Guid batchId);
|
||||
Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> CommitAsync(Guid batchId, int runId);
|
||||
@ -83,7 +83,7 @@ public sealed class OnboardingStoryIntelligenceService(
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<OnboardingStoryIntelligenceBatch?> StartAsync()
|
||||
public async Task<OnboardingStoryIntelligenceBatch?> 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<StoryIntelligenceProgressViewModel?> 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<OnboardingStoryIntelligenceBatchItem> Items { get; init; } = [];
|
||||
public List<OnboardingStoryIntelligenceCharacterDecision> CharacterDecisions { get; } = [];
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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<string>? 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<string> KnownNames(string? name, IEnumerable<string>? 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)));
|
||||
|
||||
141
PlotLine/Sql/143_Phase21U_DevelopmentStoryIntelligenceReset.sql
Normal file
141
PlotLine/Sql/143_Phase21U_DevelopmentStoryIntelligenceReset.sql
Normal file
@ -0,0 +1,141 @@
|
||||
SET ANSI_NULLS ON;
|
||||
SET QUOTED_IDENTIFIER ON;
|
||||
GO
|
||||
|
||||
/*
|
||||
Phase 21U development reset.
|
||||
|
||||
Usage:
|
||||
DECLARE @DevelopmentEmail nvarchar(256) = N'you@example.com';
|
||||
EXEC dbo.StoryIntelligence_DevelopmentAccountReset @DevelopmentEmail = @DevelopmentEmail;
|
||||
|
||||
This intentionally deletes project/book/import data only for projects owned by
|
||||
the supplied account. It also clears the Story Intelligence illustration
|
||||
library tables because that library is global development data.
|
||||
|
||||
Physical files must be deleted separately from:
|
||||
wwwroot/uploads/story-intelligence-library
|
||||
*/
|
||||
|
||||
CREATE OR ALTER PROCEDURE dbo.StoryIntelligence_DevelopmentAccountReset
|
||||
@DevelopmentEmail nvarchar(256)
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
SET XACT_ABORT ON;
|
||||
|
||||
DECLARE @UserID int;
|
||||
SELECT @UserID = UserID
|
||||
FROM dbo.AppUser
|
||||
WHERE Email = @DevelopmentEmail;
|
||||
|
||||
IF @UserID IS NULL
|
||||
THROW 52101, 'Development account email was not found.', 1;
|
||||
|
||||
DECLARE @ProjectIDs TABLE (ProjectID int NOT NULL PRIMARY KEY);
|
||||
INSERT @ProjectIDs (ProjectID)
|
||||
SELECT DISTINCT p.ProjectID
|
||||
FROM dbo.Projects p
|
||||
INNER JOIN dbo.ProjectUserAccess pua ON pua.ProjectID = p.ProjectID
|
||||
WHERE pua.UserID = @UserID
|
||||
AND pua.AccessRole = N'Owner'
|
||||
AND pua.IsActive = 1;
|
||||
|
||||
DECLARE @DeletedProjects table (ProjectID int NOT NULL PRIMARY KEY, ProjectName nvarchar(200) NULL);
|
||||
INSERT @DeletedProjects(ProjectID, ProjectName)
|
||||
SELECT p.ProjectID, p.ProjectName
|
||||
FROM dbo.Projects p
|
||||
INNER JOIN @ProjectIDs ids ON ids.ProjectID = p.ProjectID;
|
||||
|
||||
BEGIN TRY
|
||||
BEGIN TRANSACTION;
|
||||
|
||||
UPDATE dbo.StoryIntelligenceRuns
|
||||
SET Status = CASE WHEN Status IN (N'Pending', N'Running') THEN N'Cancelled' ELSE Status END,
|
||||
CurrentStage = N'Cancelled by Phase 21U reset',
|
||||
CurrentMessage = N'Development reset invalidated this run.',
|
||||
CompletedUtc = COALESCE(CompletedUtc, SYSUTCDATETIME()),
|
||||
UpdatedUtc = SYSUTCDATETIME()
|
||||
WHERE ProjectID IN (SELECT ProjectID FROM @ProjectIDs)
|
||||
AND Status IN (N'Pending', N'Running');
|
||||
|
||||
UPDATE dbo.IllustrationLibraryItems
|
||||
SET Status = CASE WHEN Status IN (N'Queued', N'Generating') THEN N'Failed' ELSE Status END,
|
||||
LastError = N'Invalidated by Phase 21U development reset',
|
||||
UpdatedUtc = SYSUTCDATETIME()
|
||||
WHERE Category IN (N'Character', N'Location', N'Asset')
|
||||
AND Status IN (N'Queued', N'Generating');
|
||||
|
||||
DELETE FROM dbo.StoryMemoryIllustrationAssignments;
|
||||
DELETE FROM dbo.StoryMemoryIllustrationDemands;
|
||||
DELETE FROM dbo.StoryMemoryRelationships;
|
||||
DELETE FROM dbo.StoryMemoryCharacterAttributes;
|
||||
DELETE FROM dbo.StoryMemoryCharacterAliases;
|
||||
DELETE FROM dbo.StoryMemoryAssets;
|
||||
DELETE FROM dbo.StoryMemoryLocations;
|
||||
DELETE FROM dbo.StoryMemoryCharacters;
|
||||
DELETE FROM dbo.StoryMemoryProcessedSceneResults;
|
||||
DELETE FROM dbo.StoryMemoryImportPreferences;
|
||||
DELETE FROM dbo.StoryIntelligenceCharacterIllustrationAssignments;
|
||||
DELETE FROM dbo.StoryIntelligenceIllustrationDemands;
|
||||
|
||||
IF OBJECT_ID(N'dbo.IllustrationGenerationAttempts', N'U') IS NOT NULL
|
||||
DELETE FROM dbo.IllustrationGenerationAttempts;
|
||||
|
||||
IF OBJECT_ID(N'dbo.IllustrationLibraryApprovalHistory', N'U') IS NOT NULL
|
||||
DELETE FROM dbo.IllustrationLibraryApprovalHistory;
|
||||
|
||||
IF OBJECT_ID(N'dbo.IllustrationLibraryLineage', N'U') IS NOT NULL
|
||||
DELETE FROM dbo.IllustrationLibraryLineage;
|
||||
|
||||
UPDATE dbo.IllustrationLibraryItems
|
||||
SET ParentItemID = NULL
|
||||
WHERE Category IN (N'Character', N'Location', N'Asset');
|
||||
|
||||
DELETE FROM dbo.IllustrationLibraryItems
|
||||
WHERE Category IN (N'Character', N'Location', N'Asset');
|
||||
|
||||
COMMIT TRANSACTION;
|
||||
END TRY
|
||||
BEGIN CATCH
|
||||
IF XACT_STATE() <> 0 ROLLBACK TRANSACTION;
|
||||
THROW;
|
||||
END CATCH;
|
||||
|
||||
DECLARE @ProjectID int;
|
||||
DECLARE project_cursor CURSOR LOCAL FAST_FORWARD FOR
|
||||
SELECT ProjectID FROM @ProjectIDs ORDER BY ProjectID;
|
||||
|
||||
OPEN project_cursor;
|
||||
FETCH NEXT FROM project_cursor INTO @ProjectID;
|
||||
WHILE @@FETCH_STATUS = 0
|
||||
BEGIN
|
||||
UPDATE dbo.Projects
|
||||
SET IsArchived = 1,
|
||||
ArchivedDate = COALESCE(ArchivedDate, SYSUTCDATETIME()),
|
||||
ArchivedReason = N'Phase 21U development reset'
|
||||
WHERE ProjectID = @ProjectID;
|
||||
|
||||
EXEC dbo.ProjectHardDelete_ForOwner @ProjectID = @ProjectID, @UserID = @UserID;
|
||||
FETCH NEXT FROM project_cursor INTO @ProjectID;
|
||||
END
|
||||
CLOSE project_cursor;
|
||||
DEALLOCATE project_cursor;
|
||||
|
||||
SELECT N'ProjectsDeleted' AS Metric, COUNT(*) AS Value FROM @DeletedProjects
|
||||
UNION ALL SELECT N'RemainingOwnedProjects', COUNT(*)
|
||||
FROM dbo.Projects p
|
||||
INNER JOIN dbo.ProjectUserAccess pua ON pua.ProjectID = p.ProjectID
|
||||
WHERE pua.UserID = @UserID AND pua.IsActive = 1
|
||||
UNION ALL SELECT N'RemainingStoryIntelligenceRuns', COUNT(*)
|
||||
FROM dbo.StoryIntelligenceRuns
|
||||
WHERE UserID = @UserID
|
||||
UNION ALL SELECT N'RemainingIllustrationLibraryItems', COUNT(*)
|
||||
FROM dbo.IllustrationLibraryItems
|
||||
WHERE Category IN (N'Character', N'Location', N'Asset');
|
||||
|
||||
SELECT ProjectID, ProjectName
|
||||
FROM @DeletedProjects
|
||||
ORDER BY ProjectID;
|
||||
END;
|
||||
GO
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -117,6 +117,17 @@
|
||||
<option value="all derived visualisation state">All derived visualisation state</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Appearance Guidance
|
||||
<select name="appearancePreference">
|
||||
<option value="@StoryMemoryAppearancePreferences.ExplicitEvidenceOnly">Explicit evidence only</option>
|
||||
<option value="@StoryMemoryAppearancePreferences.Balanced" selected="@(Model.Diagnostics.AppearancePreference == StoryMemoryAppearancePreferences.Balanced)">Balanced diverse cast</option>
|
||||
<option value="@StoryMemoryAppearancePreferences.PredominantlyLight" selected="@(Model.Diagnostics.AppearancePreference == StoryMemoryAppearancePreferences.PredominantlyLight)">Predominantly light-skinned</option>
|
||||
<option value="@StoryMemoryAppearancePreferences.PredominantlyMedium" selected="@(Model.Diagnostics.AppearancePreference == StoryMemoryAppearancePreferences.PredominantlyMedium)">Predominantly medium-skinned</option>
|
||||
<option value="@StoryMemoryAppearancePreferences.PredominantlyDark" selected="@(Model.Diagnostics.AppearancePreference == StoryMemoryAppearancePreferences.PredominantlyDark)">Predominantly dark-skinned</option>
|
||||
<option value="@StoryMemoryAppearancePreferences.CustomMix" selected="@(Model.Diagnostics.AppearancePreference == StoryMemoryAppearancePreferences.CustomMix)">Custom mix</option>
|
||||
</select>
|
||||
</label>
|
||||
<button type="submit">Rebuild</button>
|
||||
</form>
|
||||
}
|
||||
@ -350,6 +361,10 @@
|
||||
<dt>Unresolved characters</dt>
|
||||
<dd data-diagnostic-key="unresolvedCharacterCount">@Model.Diagnostics.UnresolvedCharacterCount</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Appearance guidance</dt>
|
||||
<dd data-diagnostic-key="appearancePreference">@Model.Diagnostics.AppearancePreference</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Narrator confidence</dt>
|
||||
<dd data-diagnostic-key="narratorConfidence">@Model.Diagnostics.NarratorConfidence</dd>
|
||||
|
||||
@ -80,7 +80,17 @@
|
||||
<form asp-action="SkipStoryIntelligence" method="post">
|
||||
<button class="btn btn-outline-secondary" type="submit">Skip for now</button>
|
||||
</form>
|
||||
<form asp-action="StartStoryIntelligence" method="post">
|
||||
<form asp-action="StartStoryIntelligence" method="post" class="onboarding-form-section">
|
||||
<fieldset>
|
||||
<legend>Unresolved character appearance</legend>
|
||||
<p>Explicit manuscript evidence always wins. This only guides characters whose appearance is not described.</p>
|
||||
<label><input type="radio" name="appearancePreference" value="@StoryMemoryAppearancePreferences.ExplicitEvidenceOnly" /> Use explicit manuscript evidence only</label>
|
||||
<label><input type="radio" name="appearancePreference" value="@StoryMemoryAppearancePreferences.Balanced" checked /> Balanced diverse cast</label>
|
||||
<label><input type="radio" name="appearancePreference" value="@StoryMemoryAppearancePreferences.PredominantlyLight" /> Predominantly light-skinned cast</label>
|
||||
<label><input type="radio" name="appearancePreference" value="@StoryMemoryAppearancePreferences.PredominantlyMedium" /> Predominantly medium-skinned cast</label>
|
||||
<label><input type="radio" name="appearancePreference" value="@StoryMemoryAppearancePreferences.PredominantlyDark" /> Predominantly dark-skinned cast</label>
|
||||
<label><input type="radio" name="appearancePreference" value="@StoryMemoryAppearancePreferences.CustomMix" /> Custom mix</label>
|
||||
</fieldset>
|
||||
<button class="btn btn-primary" type="submit" disabled="@(!Model.CanStart)">Analyse manuscript</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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) => {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user