Compare commits

...

52 Commits

Author SHA1 Message Date
c9a8234745 Fix road-family location image demand rules 2026-07-30 19:18:32 +00:00
cea45d1278 Restore Story Intelligence relationship lines 2026-07-29 21:01:07 +00:00
8e7f720141 Simplify Story Intelligence character area border 2026-07-29 19:57:01 +00:00
24a050e3b5 Fix Story Intelligence presence callout bounds 2026-07-29 19:31:32 +00:00
7dd14f13a9 Improve Story Intelligence visualisation assignments 2026-07-29 19:11:52 +00:00
9bb738f07e Clarify story stage presence and portraits 2026-07-26 18:33:14 +00:00
35c56d2c2c Give story characters more stage space 2026-07-26 16:09:39 +00:00
d103953eb5 Recognise Sophie presentation evidence 2026-07-26 15:42:07 +00:00
b2b7fbc735 Strengthen pronoun presentation evidence 2026-07-26 15:38:31 +00:00
17144c64d3 Rebalance story stage after removing assets 2026-07-26 15:27:49 +00:00
588a223b44 Focus story visualisation on insights 2026-07-26 15:21:06 +00:00
049628b295 Improve road locations and age evidence 2026-07-26 15:10:13 +00:00
9db13a7fd6 Cap only active image demand jobs 2026-07-26 14:30:36 +00:00
72148565e2 Tighten story intelligence evidence visuals 2026-07-26 12:16:34 +00:00
d7fe153816 Add progressive evidence cards to Story Intelligence 2026-07-26 11:44:03 +00:00
f02569b7d4 Make Story Intelligence side panels fluid 2026-07-26 11:32:49 +00:00
24bafca765 Avoid retrying exhausted OpenAI quota 2026-07-25 21:01:25 +00:00
0f83f739c5 Add Story Intelligence worker pause switch 2026-07-25 20:56:52 +00:00
02bc4fd2de Strengthen story intelligence portrait context 2026-07-25 20:40:07 +00:00
61b4d9d288 Queue story memory illustration demands 2026-07-25 19:57:52 +00:00
8b2448ecd0 Reset story intelligence memory extraction 2026-07-25 19:29:36 +00:00
311fbcbbb0 Phase 21T - Forensic Character Understanding Audit 2026-07-24 20:12:50 +00:00
d40c607d9b Commit remaining Story Intelligence changes 2026-07-16 21:09:35 +00:00
08a014e2ce Phase 21T - Story Intelligence Visual Clarity and Semantics 2026-07-16 21:08:55 +00:00
f8e320d867 Phase 21S - Durable Story Memory Illustration Pipeline 2026-07-15 22:08:40 +00:00
4e31784f46 Phase 21Q - Production Visualisation Repair 2026-07-15 20:47:23 +00:00
fd37df65c6 Phase 21P.1 - Synchronisation Hotfix 2026-07-14 21:14:02 +00:00
63f427066d Phase 21O - Story Intelligence Usability and Continuity Refinement 2026-07-14 20:25:44 +00:00
f80734a80c Phase 21N.1 - Story Intelligence Payload and Resume Repair 2026-07-14 20:00:06 +00:00
a54da80ea2 Phase 21N - Fix Entity Evidence Corruption 2026-07-13 21:36:52 +00:00
7001359281 Phase 21M - Deterministic Illustration Assignment Repair 2026-07-13 21:15:49 +00:00
948ae2e54d Phase 21L - Repair Story Intelligence Evidence Extraction 2026-07-13 20:40:13 +00:00
2d2055f14a Phase 21K - Forensic Illustration Matching Repair 2026-07-13 20:05:37 +00:00
ca73c9e6f3 Phase 21J.3 - Illustration Style and Prototype Final Polish 2026-07-13 19:23:44 +00:00
69e4ac8201 Phase 21J.2 - Character Identity and Illustration Matching 2026-07-12 21:33:32 +00:00
b513bd7df0 Phase 21J.1 - Story Intelligence Visualisation Fixes 2026-07-12 20:59:08 +00:00
4ebbfdab92 Phase 21J - Story Memory Visualisation Refinement 2026-07-12 20:37:49 +00:00
b78a9bea9b Phase 21I - Intelligent Illustration Matching Engine 2026-07-12 20:03:01 +00:00
fd85473258 Phase 21H - Story Intelligence Live Import Stability 2026-07-12 19:16:12 +00:00
2b7a4b599d Phase 21H.1 - Fix Active Import Session Selection 2026-07-12 19:00:38 +00:00
8985efafbc Phase 21H - Book-Level Story Intelligence Import Sessions 2026-07-12 16:35:54 +00:00
34fdfb77fe Phase 21G.1 - Fix Live Snapshot Stalling 2026-07-12 16:17:45 +00:00
9989dfec1e Phase 21G - Live Story Intelligence Visualisation Engine 2026-07-12 15:46:27 +00:00
88315070a2 Phase 21F - Real Story Intelligence Snapshot Integration 2026-07-12 15:09:26 +00:00
1f984c8e1e Phase 21E.2 - Prototype Visual Polish 2026-07-12 14:54:03 +00:00
35208591fd Phase 21E.1 - Story Intelligence Visualisation Cleanup 2026-07-12 14:46:10 +00:00
f0b444e0ee Phase 21E - Connect Illustration Library Images 2026-07-12 14:16:56 +00:00
07a5477034 Phase 21D - Story Intelligence Illustration Library 2026-07-11 23:33:15 +00:00
57df5646e6 Phase 21B.2 - Story Intelligence Experience Composition Refinement 2026-07-11 20:49:15 +00:00
e3d18b4a6b Phase 21B.1 - Story Intelligence Experience Visual Refinement 2026-07-11 20:23:27 +00:00
0ebccf9ac8 Phase 21B - Story Intelligence Experience Prototype 2026-07-11 20:00:06 +00:00
f4e6af844b docs: add phase 21 story intelligence visualisation docs 2026-07-11 19:34:00 +00:00
103 changed files with 19459 additions and 46 deletions

View File

@ -0,0 +1,745 @@
# Story Intelligence Illustration Selection Technical Explanation
This document explains how Story Intelligence currently chooses illustrations, tracing the execution path from scene analysis through Story Memory, illustration matching, demand generation, snapshot construction, and browser rendering.
No code changes are described here as recommendations only; this is documentation of the current implementation.
## Complete Execution Path
```mermaid
flowchart TD
A[PersistedStoryIntelligenceRunner] -->|Chapter prompt AI call| B[Chapter Structure JSON]
B -->|Scene boundaries| C[Scene Intelligence prompt AI calls]
C -->|ParsedJson saved| D[StoryIntelligenceSceneResults]
D --> E[StoryIntelligenceVisualisationSnapshotService]
E -->|Build scene states and Story Memory| F[Prototype ViewModel]
F --> G[IllustrationLibraryService.ApplyApprovedIllustrationsAsync]
G -->|Exact stable-code lookup| H[Starter/generated image if code matches]
H --> I[StoryIntelligenceIllustrationMatchingService]
I -->|Characters| J[Assignment revalidation and scoring]
I -->|Locations/assets| K[Demand lookup/creation]
J --> L[StoryIntelligenceCharacterIllustrationAssignments]
K --> M[StoryIntelligenceIllustrationDemands and queued IllustrationLibraryItems]
L --> N[Visualisation JSON]
M --> N
N --> O[Browser visualisation]
```
```mermaid
sequenceDiagram
participant Scene as Scene Analysis
participant Memory as Story Memory
participant Match as Illustration Matcher
participant Demand as Demand Generator
participant Snap as Snapshot Builder
participant UI as Visualisation
Scene->>Scene: OpenAI chapter + scene prompts
Scene->>Snap: Persisted scene result JSON
Snap->>Memory: Replay analysed scenes in order
Memory-->>Snap: Carried characters, relationships, POV
Snap->>Match: Character/location/asset view model
Match->>Match: Read library + previous assignments
Match->>Demand: No compatible illustration
Demand->>Demand: Upsert demand + queue planned image
Match-->>Snap: Assigned/fallback image resolution
Snap-->>UI: Snapshot JSON
UI->>UI: Render imagePath/fallback
```
## Main Services and Responsibilities
### PersistedStoryIntelligenceRunner
Responsible for running Story Intelligence analysis and persisting results.
Input:
- Queued `StoryIntelligenceQueuedRun`
- Chapter source text
- Chapter Structure prompt
- Scene Intelligence prompt
- OpenAI model settings
Output:
- Persisted chapter structure result
- Persisted scene intelligence result
- Run progress/status
Database reads:
- Queued Story Intelligence run via `StoryIntelligenceRun_ClaimNextPending`
- Current run state for cancellation checks
Database writes:
- `StoryIntelligenceRuns`
- `StoryIntelligenceChapterResults`
- `StoryIntelligenceSceneResults`
AI calls:
- Chapter Structure prompt
- Scene Intelligence prompt per scene
- JSON repair retry prompt if parsing fails
Illustration role:
- None directly. It only creates the structured scene data later consumed by the visualisation and matcher.
### StoryIntelligenceVisualisationSnapshotService
Responsible for reconstructing visualisation state from persisted analysis.
Input:
- Import session ID or run ID
- Persisted run and scene-result records
- Parsed scene JSON
Output:
- `StoryIntelligenceExperiencePrototypeViewModel`
- Scene states with characters, location, assets, relationships, knowledge, activity, discoveries
- In-memory Story Memory
Database reads:
- `StoryIntelligenceBookPipelines`
- `StoryIntelligenceRuns`
- `StoryIntelligenceSceneResults`
Database writes:
- None directly.
Cached/derived values:
- `ChangeToken`
- Session change token
- Replay cursor
- In-memory `StoryMemory`
- Stable visual IDs
- Stable fallback library codes
AI calls:
- None.
### IllustrationLibraryService.ApplyApprovedIllustrationsAsync
Responsible for the first image pass, using exact stable-code lookup.
Input:
- Prototype view model with `LibraryCode` values on characters, locations and assets
Output:
- Updated image paths and `ImageResolution` objects where a matching library item exists
Database reads:
- `IllustrationLibraryItems`
Database writes:
- None.
Matching behaviour:
- Exact `StableCode` match only.
- Uses `Approved` before `Generated`.
- Then newest item first.
### StoryIntelligenceIllustrationMatchingService
Responsible for final character assignment, revalidation, scoring, and demand creation.
Input:
- Project ID
- Fully built visualisation prototype view model
- Existing illustration library
- Existing active character assignments
Output:
- Character image assignments
- Character assignment diagnostics
- Demand records when no suitable illustration exists
- Queued planned illustration items when demand threshold is reached
Database reads:
- `IllustrationLibraryItems`
- `StoryIntelligenceCharacterIllustrationAssignments`
Database writes:
- `StoryIntelligenceCharacterIllustrationAssignments`
- `StoryIntelligenceIllustrationDemands`
- `IllustrationLibraryItems` through planned/queued demand records
AI calls:
- None directly.
- It queues illustration generation. A background image-generation worker later calls OpenAI Images.
### IllustrationGenerationRunner and OpenAIIllustrationImageProvider
Responsible for generating queued illustration-library images.
Input:
- Queued `IllustrationLibraryItems`
- Prompt/specification JSON
Output:
- Stored generated image files
- Updated illustration-library item status
Database reads:
- `IllustrationLibraryItems`
Database writes:
- `IllustrationLibraryItems`
AI calls:
- `POST https://api.openai.com/v1/images/generations`
## Character Illustration Selection
Character matching is a two-layer process.
First, `StoryIntelligenceVisualisationSnapshotService` gives each character a likely starter `LibraryCode`. Then `IllustrationLibraryService.ApplyApprovedIllustrationsAsync` resolves exact library-code matches. After that, `StoryIntelligenceIllustrationMatchingService.ApplyCharacterIllustrationsAsync` performs the newer assignment and revalidation pass.
### Where Age Is Determined
Age is determined in two places.
Initial stable-code age:
- Class: `StoryIntelligenceVisualisationSnapshotService.CharacterProfile`
- Method: `DetectAgeBand`
- Used only to pick a starter code.
- Defaults to `YoungAdult`.
Final matcher age:
- Class: `StoryIntelligenceIllustrationCompatibility`
- Method: `InferCharacterEvidence`
- Produces a `CharacterEvidenceProfile`.
- Used by `StoryIntelligenceIllustrationMatchingService.Score`.
### Where Inferred Age Is Determined
Inferred age comes from:
- Explicit age terms: `13`, `14`, `15`, `16`, `17`, `18`, `19`
- Words: `toddler`, `baby`, `infant`, `child`, `schoolboy`, `schoolgirl`, `teenager`, `young adult`, `middle-aged`, `elderly`
- Role/title terms: `Mr`, `Mrs`, `Miss`, `Ms`, `Dr`, `Professor`, `teacher`, `detective`, `police officer`, `nurse`, `receptionist`, `solicitor`, `landlord`, `manager`
- Relationship terms: `mother`, `father`, `parent`, `aunt`, `uncle`, `grandmother`, `grandfather`
### Where Gender/Presentation Is Determined
Presentation is determined by `StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence`.
Sources, in priority order:
1. Title presentation
2. Entity relationship/presentation words in scoped text
3. Relationship evidence
4. Pronouns
5. Hard-coded name list
6. Unknown
### Whether Names Influence Gender
Yes. Names are hard-coded.
Examples:
- Feminine: Beth, Maggie, Rosie, Grace, Zoe, Rebecca, Annie, Mary, Sharon, Catherine, Susan, Helen, Elen
- Masculine: Adam, Colin, David, Kevin, Simon, Graham, Gareth, John, Michael, Peter, Richard, Rick, George
There is also a presentation guard that can override leaked relationship/pronoun evidence using name/title evidence unless explicit contradictory evidence appears.
### Whether Titles Influence Gender
Yes.
- `Mrs`, `Miss`, `Ms`, `Lady` infer feminine.
- `Mr`, `Sir` infer masculine.
- `Dr` and `Professor` infer adult age but not masculine/feminine presentation by themselves.
### Whether Occupations Influence Age
Yes. Occupations such as teacher, driving examiner, detective, police officer, nurse, receptionist, solicitor, landlord, manager, professor and doctor infer adult age.
### Hair Colour, Eye Colour and Ethnicity
Hair colour is extracted in `StoryIntelligenceIllustrationCompatibility.HairColour`.
Recognised values include:
- Bald
- Black
- Brown/brunette/dark hair
- Blonde/fair hair
- Red/auburn/ginger
- Grey/gray
- White
Hair length is also extracted.
Eye colour is not currently extracted for matching.
Ethnicity is not currently extracted for matching. The matcher uses skin tone, not ethnicity.
### Previously Learned Evidence
Previously learned Story Memory evidence can affect the character states that enter the matcher, but there is no persistent Story Memory evidence table read by the matcher.
Previous assignment evidence is stored as JSON in `StoryIntelligenceCharacterIllustrationAssignments`, but `ListAssignmentsAsync` currently returns `EvidenceJson` and `CandidateDiagnosticsJson` as null. That means previous evidence is not rehydrated and used as a true evidence source during matching.
### Conflict Resolution
Conflict handling is mostly rule-based:
- `FirstKnown` picks the first non-unknown signal from prioritised evidence.
- The presentation guard prevents obvious gender leakage from overriding strong name/title evidence.
- Hard rejections eliminate impossible illustration candidates.
- Existing assignments are revalidated against current evidence.
- Replacement requires hard failure, material wrongness, or a better score by margin.
### Hard Rules for Characters
A candidate is rejected if:
- Named character uses Unknown Figure artwork.
- Candidate is marked unsuitable for named-character matching.
- Age is incompatible.
- Strong presentation evidence contradicts candidate presentation.
- Known hair colour contradicts candidate hair colour.
- Candidate is already assigned to another significant named character.
Additional rule:
- If character age evidence is unknown, child/teen candidate artwork is rejected until explicit child/teen evidence appears.
### Character Weighted Scoring
Final matcher score in `StoryIntelligenceIllustrationMatchingService.Score`:
- Age exact match: `+25`
- Age evidence unknown: `+12`
- Candidate age unknown: `+8` plus rejection note
- Presentation exact match with strong evidence: `+42`
- Presentation exact match without strong evidence: `+25`
- Presentation evidence unknown: half weight
- Candidate presentation unknown: one-third weight plus rejection note
- Hair colour exact match: `+20`
- Hair colour evidence unknown: `+10`
- Candidate hair colour unknown: `+6` plus rejection note
- Skin tone exact match: `+20`
- Skin tone evidence unknown: `+10`
- Candidate skin tone unknown: `+6` plus rejection note
- Hair length exact match: `+8`
- Glasses exact match: `+7`
- Facial hair exact match: `+7`
- Approved library item: `+3`
Score is clamped between `0` and `100`.
Minimum suitable score:
- `48`
Reassignment margin:
- `24`
Older stable-code scoring before the final matcher:
- Age exact: `+80`
- Compatible age: `+30`
- Age mismatch: `-45`
- Presentation exact: `+24`
- Hair colour exact: `+18`
- Hair length exact: `+10`
- Signal keyword match: `+14` each
### Why Existing Illustrations Are Retained
An existing illustration is retained when:
- Existing assignment still resolves to a public image URL.
- Existing candidate has no hard rejections.
- Existing candidate is not materially wrong for known age, presentation, hair colour or skin tone.
- No better candidate exists, or the best candidate does not beat existing score by the reassignment margin.
- Existing score is at least `48`.
### Why Existing Illustrations Are Replaced
An existing illustration is replaced when:
- It violates hard constraints.
- It is materially wrong for age, presentation, hair colour or skin tone.
- A better compatible candidate beats the existing score by at least `24`.
- The existing assignment was fallback/null and a generated valid item is now available.
### When Demand Is Created
A character demand record is created when no hard-compatible unused illustration scores at least `48`.
Demand key:
```text
character|age|presentation|hairColour|skinTone
```
Writes:
- `StoryIntelligenceIllustrationDemands`
If request count reaches threshold, currently `1`, it creates or queues:
```text
char-demand-{hash}
```
### When Generated Illustrations Become Eligible
A generated illustration becomes eligible when:
- It exists in `IllustrationLibraryItems`
- Status is `Generated` or `Approved`
- It has a valid thumbnail or file path
- A later snapshot/matcher pass reads it from the library
### Why Duplicate Portraits Sometimes Occur
Duplicate portraits can occur because:
1. Exact stable-code resolution can apply the same starter code before final assignment.
2. Duplicate assignment cleanup only handles assignment records, not every transient fallback/stable-code image.
3. Low-significance or unresolved identities can share generic fallbacks.
4. Character identity canonicalisation can split one person into multiple keys.
5. Existing duplicate assignment handling keeps the first retained item and clears later duplicates only during the matcher pass.
### Why Unknown Figure Can Still Appear
Unknown Figure can still appear when:
- The initial stable-code phase chooses `char-unknown-figure`.
- The character is unresolved/generic, so named-character hard rejection does not apply.
- The matcher records demand and falls back to an existing SVG/fallback.
- The library contains generated/approved unknown figure art used by exact code before the final assignment overrides it.
## Location Illustration Selection
### Classification
Locations are built by `StoryIntelligenceVisualisationSnapshotService.BuildLocation`.
It chooses:
1. Present-in-scene locations before mentioned-only locations.
2. More specific names.
3. Higher confidence.
4. Fallback to scene setting.
### Semantic Typing
Semantic type is determined by `StoryIntelligenceIllustrationCompatibility.LocationType`.
Examples:
- Bathroom
- Kitchen
- Bedroom
- LivingRoom
- Hallway
- Stairwell
- Hospital
- Pub
- Beach
- RailwayStation
- Canal
- Garage
- HouseExterior
- Office
- School
- WaitingRoom
- Road
- CarPark
- Shop
- StreetFurniture
- FlatInterior
- UnknownInterior
### Matching
Location matching has two phases:
1. Initial stable-code mapping in `LocationLibraryCode`.
2. Demand-based exact generated lookup in `ApplyLocationDemandAsync`.
Important current behaviour:
- Bathroom, WaitingRoom and Road intentionally return no starter code.
- This prevents borrowing wrong generic interiors.
- Generated demand selection uses exact code `loc-demand-{hash}`.
There is a `LocationCompatible` helper, but generated supporting image selection currently uses exact demand stable-code lookup rather than weighted compatibility scoring.
### Demand Creation
Location demand is created if:
- Location type is not `UnknownInterior`.
- Current image is still fallback.
- No generated demand image exists for the exact demand code.
Demand key:
```text
location|type
```
Writes:
- `StoryIntelligenceIllustrationDemands`
- `IllustrationLibraryItems` planned/queued demand item
### Reuse Rules
Locations are reusable by type/demand key. They are not unique per manuscript location.
## Asset Illustration Selection
### Classification
Assets are built by `StoryIntelligenceVisualisationSnapshotService.BuildAssets`.
It:
- Excludes mentioned-only assets.
- Excludes low-value/background objects.
- Groups by cleaned asset name.
- Picks highest-confidence entry per name.
- Takes top five by asset weight.
### Semantic Typing
Asset type is determined by `StoryIntelligenceIllustrationCompatibility.AssetType`.
Examples:
- Ambulance
- Stretcher
- BodyBag
- Bottle
- Money
- Clothing
- Document
- Jewellery
- Weapon
- Hatchback
- FamilyCar
- SportsCar
- Van
- Car
- CarPart
- Tin
- Parcel
- Rucksack
- Suitcase
- Note
- Letter
- Notebook
- Book
- Telephone
- Keys
- Photograph
- UnknownObject
### Colour Handling
Colour is determined by `AssetColour`.
Recognised values:
- Blue
- Red
- Green
- Yellow
- Black
- White
- Grey
- Brown
- Unknown
### Matching
Asset matching has two phases:
1. Initial stable-code mapping in `AssetLibraryCode`.
2. Demand-based exact generated lookup in `ApplyAssetDemandAsync`.
Starter mappings include:
- Letter/Note/Document -> `asset-folded-letter`
- Notebook/Book -> `asset-worn-notebook`
- Car -> `asset-red-classic-car`
- Keys -> `asset-old-keys`
- Telephone -> `asset-phone-recorder`
- Photograph -> `asset-photo-print`
- Rucksack -> `asset-rucksack`
Generated demand selection uses exact stable code:
```text
asset-demand-{hash}
```
There is an `AssetCompatible` helper, but generated supporting-image selection currently uses exact demand code rather than weighted compatibility scoring.
### Demand Creation
Asset demand is created if:
- Asset type is not `UnknownObject`.
- Current image is fallback.
- No generated image exists for exact demand code.
Demand key:
```text
asset|type|colour
```
Writes:
- `StoryIntelligenceIllustrationDemands`
- `IllustrationLibraryItems` planned/queued demand item
## Database Table Summary
Reads:
- `StoryIntelligenceRuns`
- `StoryIntelligenceChapterResults`
- `StoryIntelligenceSceneResults`
- `StoryIntelligenceBookPipelines`
- `IllustrationLibraryItems`
- `StoryIntelligenceCharacterIllustrationAssignments`
Writes:
- `StoryIntelligenceRuns`
- `StoryIntelligenceChapterResults`
- `StoryIntelligenceSceneResults`
- `StoryIntelligenceCharacterIllustrationAssignments`
- `StoryIntelligenceIllustrationDemands`
- `IllustrationLibraryItems`
No dedicated persistent Story Memory table is currently used in the illustration matcher.
## Cached Values Used
The system uses these cached or derived values:
- Stored `ParsedJson` in scene results
- Stored illustration library metadata/specification JSON
- Stored generated file/thumbnail paths
- Stored active character assignments
- Stored demand keys/request counts
- Snapshot `ChangeToken`
- Replay cursor index
- In-memory Story Memory during snapshot construction
- Stable hashes for visual entity IDs and demand codes
## Previous Assignments Considered
The matcher considers active previous assignments from `StoryIntelligenceCharacterIllustrationAssignments`.
Used fields include:
- `CharacterKey`
- `IllustrationLibraryItemID`
- `StableCode`
- `MatchConfidence`
- `MatchingScore`
- `ReasonChosen`
- `ReasonReassigned`
Current limitation:
- Previous `EvidenceJson` and `CandidateDiagnosticsJson` are not returned by `ListAssignmentsAsync`; they are selected as null.
## Story Memory Evidence Considered
Story Memory can influence:
- Added remembered characters
- POV identity
- Character role/relevance
- Carried relationships
- Inferred kinship relationships
- Rough presentation and age band
Story Memory does not directly persist or supply a structured evidence object to the final matcher. It affects the visual character state, and the matcher then infers evidence from that state.
## Character Evidence Ignored
Currently ignored or mostly lost for matching:
- Eye colour
- Ethnicity
- Clothing details
- Body type
- Exact age beyond broad age band
- Many action-level descriptors
- Raw manuscript text
- Prior assignment evidence JSON
- Scene observations unless transformed into role/relevance/relationship text
## Five Biggest Weaknesses
1. Two image systems compete: stable-code starter matching and final assignment matching.
2. Story Memory is rebuilt in memory and is not a durable structured evidence store.
3. Character evidence is compressed before matching.
4. Locations and assets do not have a real weighted matcher.
5. Previous evidence is stored but not reloaded as evidence for conflict resolution.
## Five Places Evidence Can Be Lost
1. AI scene JSON may omit physical details.
2. Snapshot character state drops raw action and descriptor detail.
3. Story Memory stores only simplified role/relevance/age/presentation.
4. Matcher ignores eye colour, ethnicity, clothing, build and exact age.
5. Assignment repository does not return previous evidence JSON to the matcher.
## Five Places Incorrect Assignments Can Occur
1. Initial stable-code fallback picks a plausible but wrong starter image.
2. Hard-coded name gender lists misclassify unusual or ambiguous names.
3. Relationship/family terms leak onto the wrong character.
4. Asset/location keyword order maps vague text too broadly.
5. Canonical identity splitting creates multiple assignment keys for one person.
## Five Quickest Improvements
1. Store structured evidence per canonical identity and reload it during matching.
2. Remove or demote pre-matcher stable-code character assignment for live data.
3. Add weighted matchers for locations and assets.
4. Add eye colour, ethnicity/skin-tone source, clothing and exact-age evidence fields.
5. Expand diagnostics to show winning candidate, rejected candidates and exact evidence source per entity.

View File

@ -1,6 +1,14 @@
using System.Text.Json; using System.Text.Json;
using System.Net;
using System.Text;
using Microsoft.AspNetCore.Http.Metadata;
using Microsoft.AspNetCore.Mvc;
using PlotLine.Controllers;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using PlotLine.Models; using PlotLine.Models;
using PlotLine.Services; using PlotLine.Services;
using PlotLine.ViewModels;
var tests = new (string Name, Action Test)[] var tests = new (string Name, Action Test)[]
{ {
@ -21,7 +29,45 @@ var tests = new (string Name, Action Test)[]
("Asset canonical keys merge trivial variants", AssetCanonicalKeysMergeTrivialVariants), ("Asset canonical keys merge trivial variants", AssetCanonicalKeysMergeTrivialVariants),
("Relationship signals map to broad lookup types", RelationshipSignalsMapToBroadTypes), ("Relationship signals map to broad lookup types", RelationshipSignalsMapToBroadTypes),
("Knowledge signals map to existing knowledge states", KnowledgeSignalsMapToExistingStates), ("Knowledge signals map to existing knowledge states", KnowledgeSignalsMapToExistingStates),
("Knowledge duplicate statements share canonical keys", KnowledgeDuplicateStatementsShareCanonicalKeys) ("Knowledge duplicate statements share canonical keys", KnowledgeDuplicateStatementsShareCanonicalKeys),
("Illustration prompt builder separates spec and prompt", IllustrationPromptBuilderSeparatesSpecAndPrompt),
("Illustration specification validation catches missing typed fields", IllustrationSpecificationValidationCatchesMissingTypedFields),
("Illustration category validation rejects unknown values", IllustrationCategoryValidationRejectsUnknownValues),
("Illustration status transitions protect approval flow", IllustrationStatusTransitionsProtectApprovalFlow),
("Illustration storage codes are normalised safely", IllustrationStorageCodesAreNormalisedSafely),
("Illustration starter batch has required category counts", IllustrationStarterBatchHasRequiredCategoryCounts),
("Illustration starter generation plan skips successful items", IllustrationStarterGenerationPlanSkipsSuccessfulItems),
("Story Intelligence prototype uses starter illustration codes", StoryIntelligencePrototypeUsesStarterIllustrationCodes),
("Story Intelligence simulation mode remains available", StoryIntelligenceSimulationModeRemainsAvailable),
("Story Intelligence does not retry exhausted billing quota", StoryIntelligenceDoesNotRetryExhaustedBillingQuota),
("Story Intelligence visualisation contract omits raw manuscript text", StoryIntelligenceVisualisationContractOmitsRawManuscriptText),
("Illustration bulk retry result reports counts", IllustrationBulkRetryResultReportsCounts),
("Illustration bulk retry skips blocking duplicate stable codes", IllustrationBulkRetrySkipsBlockingDuplicateStableCodes),
("Illustration provider reports missing image model", IllustrationProviderReportsMissingImageModel),
("Illustration provider omits GPT image response format", IllustrationProviderOmitsGptImageResponseFormat),
("Illustration matching rejects adult masculine unknown art for Beth", IllustrationMatchingRejectsAdultMasculineUnknownArtForBeth),
("Illustration matching treats Mrs title as hard feminine evidence", IllustrationMatchingTreatsMrsTitleAsHardFeminineEvidence),
("Illustration matching infers mother and aunt as mature feminine", IllustrationMatchingInfersMotherAndAuntAsMatureFeminine),
("Illustration matching rejects duplicate named portrait allocation", IllustrationMatchingRejectsDuplicateNamedPortraitAllocation),
("Illustration semantic types prevent bathroom office and ambulance car mismatches", IllustrationSemanticTypesPreventObviousMismatches),
("Illustration demand archetypes stay broad and use active template", IllustrationDemandArchetypesStayBroadAndUseActiveTemplate),
("Story Intelligence evidence extraction prevents observed attribute leakage", StoryIntelligenceEvidenceExtractionPreventsObservedAttributeLeakage),
("Illustration assignment repair enforces explicit evidence groups and strict subtypes", IllustrationAssignmentRepairEnforcesExplicitEvidenceGroupsAndStrictSubtypes),
("Phase 21O preserves adult defaults and real-mode continuity", Phase21OPreservesAdultDefaultsAndRealModeContinuity),
("Phase 21P uses one authoritative current visualisation scene", Phase21PUsesOneAuthoritativeCurrentVisualisationScene),
("Phase 21Q repairs production visualisation regressions", Phase21QRepairsProductionVisualisationRegressions),
("Phase 21Q view keeps panels bounded and real controls hidden", Phase21QViewKeepsPanelsBoundedAndRealControlsHidden),
("Phase 21R replay uses persisted data only", Phase21RReplayUsesPersistedDataOnly),
("Phase 21R scene browser and panels are replay friendly", Phase21RSceneBrowserAndPanelsAreReplayFriendly),
("Phase 21S uses durable Story Memory assignments", Phase21SUsesDurableStoryMemoryAssignments),
("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),
("Live visualisation strips illustration diagnostics", LiveVisualisationStripsIllustrationDiagnostics),
("Illustration evidence trace does not recursively store previous evidence", IllustrationEvidenceTraceDoesNotRecursivelyStorePreviousEvidence)
}; };
foreach (var test in tests) foreach (var test in tests)
@ -314,6 +360,909 @@ static string CanonicalKnowledgeKey(string statement)
return (string)method!.Invoke(null, [statement])!; return (string)method!.Invoke(null, [statement])!;
} }
static void IllustrationPromptBuilderSeparatesSpecAndPrompt()
{
var spec = IllustrationStarterBatchDefinition.All.Single(x => x.Code == "asset-folded-letter");
var builder = new IllustrationPromptBuilder();
var result = builder.Build(spec);
Assert(result.TemplateVersion == IllustrationPromptBuilder.CurrentTemplateVersion, "Template version was not recorded.");
Assert(result.TemplateVersion == "21J.3", "Illustration prompt template version should reflect the current approved art direction.");
Assert(result.Prompt.Contains("no visible text", StringComparison.OrdinalIgnoreCase), "Prompt did not include visual safety guidance.");
Assert(result.Prompt.Contains("Category composition rules", StringComparison.Ordinal), "Prompt did not include category composition rules.");
Assert(result.Prompt.Contains("Structured details", StringComparison.Ordinal), "Prompt did not include structured details.");
Assert(result.Prompt.Contains("modern cinematic digital illustration", StringComparison.OrdinalIgnoreCase), "Prompt did not include the new cinematic digital art direction.");
Assert(result.Prompt.Contains("premium narrative game concept art", StringComparison.OrdinalIgnoreCase), "Prompt did not include premium game concept-art guidance.");
Assert(result.Prompt.Contains("balanced cinematic lighting", StringComparison.OrdinalIgnoreCase), "Prompt did not include current lighting guidance.");
Assert(result.Prompt.Contains("avoid muddy olive and brown grading", StringComparison.OrdinalIgnoreCase), "Prompt did not explicitly avoid the previous muddy grading failure mode.");
Assert(result.Prompt.Contains(spec.Code, StringComparison.Ordinal), "Prompt did not include stable code.");
Assert(result.SpecificationJson.Contains("\"code\": \"asset-folded-letter\"", StringComparison.Ordinal), "Specification JSON did not preserve the stable code.");
Assert(result.MetadataJson.Contains("generic-no-manuscript-data", StringComparison.Ordinal), "Metadata JSON did not preserve privacy metadata.");
}
static void IllustrationSpecificationValidationCatchesMissingTypedFields()
{
var invalid = new IllustrationGenerationSpecification
{
Category = IllustrationLibraryCategories.Character,
Code = "char-invalid",
Title = "Invalid",
Mood = "Calm"
};
var errors = invalid.Validate();
Assert(errors.Any(error => error.Contains("Apparent age band", StringComparison.OrdinalIgnoreCase)), "Character age-band validation should run.");
Assert(errors.Any(error => error.Contains("Presentation", StringComparison.OrdinalIgnoreCase)), "Character presentation validation should run.");
}
static void IllustrationCategoryValidationRejectsUnknownValues()
{
Assert(IllustrationLibraryCategories.IsValid(IllustrationLibraryCategories.Character), "Character should be valid.");
Assert(IllustrationLibraryCategories.IsValid(IllustrationLibraryCategories.Location), "Location should be valid.");
Assert(IllustrationLibraryCategories.IsValid(IllustrationLibraryCategories.Asset), "Asset should be valid.");
Assert(!IllustrationLibraryCategories.IsValid("Manuscript"), "Unknown category should be rejected.");
}
static void IllustrationStatusTransitionsProtectApprovalFlow()
{
Assert(IllustrationLibraryStatuses.CanTransition(IllustrationLibraryStatuses.Planned, IllustrationLibraryStatuses.Queued), "Planned should queue.");
Assert(IllustrationLibraryStatuses.CanTransition(IllustrationLibraryStatuses.Generating, IllustrationLibraryStatuses.Generated), "Generating should become generated.");
Assert(IllustrationLibraryStatuses.CanTransition(IllustrationLibraryStatuses.Generated, IllustrationLibraryStatuses.Approved), "Generated should approve.");
Assert(IllustrationLibraryStatuses.CanTransition(IllustrationLibraryStatuses.Approved, IllustrationLibraryStatuses.Superseded), "Approved should become superseded.");
Assert(!IllustrationLibraryStatuses.CanTransition(IllustrationLibraryStatuses.Planned, IllustrationLibraryStatuses.Approved), "Planned should not approve directly.");
Assert(!IllustrationLibraryStatuses.CanTransition(IllustrationLibraryStatuses.Queued, IllustrationLibraryStatuses.Approved), "Queued should not approve directly.");
}
static void IllustrationStorageCodesAreNormalisedSafely()
{
Assert(IllustrationLibraryStorageService.SafeSegment("Asset Letter_01.png") == "asset-letter-01-png", "Storage code was not normalised.");
var failed = false;
try
{
IllustrationLibraryStorageService.SafeSegment("../");
}
catch (ArgumentException)
{
failed = true;
}
Assert(failed, "Unsafe storage code should be rejected.");
}
static void IllustrationStarterBatchHasRequiredCategoryCounts()
{
Assert(IllustrationStarterBatchDefinition.All.Count(x => x.Category == IllustrationLibraryCategories.Character) == 12, "Starter characters should total 12.");
Assert(IllustrationStarterBatchDefinition.All.Count(x => x.Category == IllustrationLibraryCategories.Location) == 6, "Starter locations should total 6.");
Assert(IllustrationStarterBatchDefinition.All.Count(x => x.Category == IllustrationLibraryCategories.Asset) == 10, "Starter assets should total 10.");
Assert(IllustrationStarterBatchDefinition.All.Select(x => x.Code).Distinct(StringComparer.OrdinalIgnoreCase).Count() == IllustrationStarterBatchDefinition.All.Count, "Starter codes should be unique.");
}
static void IllustrationStarterGenerationPlanSkipsSuccessfulItems()
{
var generatedCharacter = new IllustrationLibraryItem
{
IllustrationLibraryItemID = 1,
Category = IllustrationLibraryCategories.Character,
StableCode = "char-young-adult-brunette-observer",
Status = IllustrationLibraryStatuses.Generated,
UpdatedUtc = DateTime.UtcNow
};
var failedAsset = new IllustrationLibraryItem
{
IllustrationLibraryItemID = 2,
Category = IllustrationLibraryCategories.Asset,
StableCode = "asset-folded-letter",
Status = IllustrationLibraryStatuses.Failed,
UpdatedUtc = DateTime.UtcNow
};
var plan = IllustrationStarterBatchDefinition.BuildGenerationPlan([generatedCharacter, failedAsset]);
Assert(plan.CharactersToGenerate == 11, $"Expected 11 characters after one generated item, got {plan.CharactersToGenerate}.");
Assert(plan.AssetsToGenerate == 10, $"Expected failed starter asset to remain eligible, got {plan.AssetsToGenerate}.");
Assert(!IllustrationStarterBatchDefinition.IsStarterCodeEligibleForGeneration(generatedCharacter.StableCode, [generatedCharacter, failedAsset]), "Generated starter should not be eligible.");
Assert(IllustrationStarterBatchDefinition.IsStarterCodeEligibleForGeneration(failedAsset.StableCode, [generatedCharacter, failedAsset]), "Failed starter should be eligible.");
}
static void StoryIntelligencePrototypeUsesStarterIllustrationCodes()
{
var prototype = StoryIntelligenceExperiencePrototypeData.Build();
var codes = prototype.Scenes
.SelectMany(scene => scene.Characters.Select(character => character.LibraryCode)
.Concat([scene.Location.LibraryCode])
.Concat(scene.Assets.Select(asset => asset.LibraryCode)))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
Assert(codes.Count > 0, "Prototype should carry explicit library codes.");
Assert(codes.All(code => IllustrationStarterBatchDefinition.StarterCodes.Contains(code)), "Prototype requested a stable code that is not in the starter library.");
Assert(prototype.Scenes.SelectMany(scene => scene.Characters).All(character => character.ImagePath.EndsWith(".svg", StringComparison.OrdinalIgnoreCase)), "Character SVG fallback should remain before library resolution.");
Assert(prototype.Scenes.All(scene => scene.Location.ImagePath.EndsWith(".svg", StringComparison.OrdinalIgnoreCase)), "Location SVG fallback should remain before library resolution.");
Assert(prototype.Scenes.SelectMany(scene => scene.Assets).All(asset => asset.ImagePath.EndsWith(".svg", StringComparison.OrdinalIgnoreCase)), "Asset SVG fallback should remain before library resolution.");
}
static void IllustrationMatchingRejectsAdultMasculineUnknownArtForBeth()
{
var beth = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Beth", ["Beth", "age 15", "fifteen-year-old girl"], []);
var middleAged = Metadata("char-family-relative", "MatureAdult", "Feminine");
var masculine = Metadata("char-middle-aged-weathered-man", "MatureAdult", "Masculine");
var unknown = Metadata("char-unknown-figure", "YoungAdult", "Androgynous", isUnknownFigure: true);
Assert(beth.AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.YoungTeen, $"Beth age should be YoungTeen, got {beth.AgeBand}.");
Assert(Rejected(beth, middleAged).Any(reason => reason.Contains("age hard mismatch", StringComparison.OrdinalIgnoreCase)), "Beth should reject mature adult art.");
Assert(Rejected(beth, masculine).Any(reason => reason.Contains("presentation hard mismatch", StringComparison.OrdinalIgnoreCase)), "Beth should reject masculine art.");
Assert(Rejected(beth, unknown).Any(reason => reason.Contains("Unknown Figure", StringComparison.OrdinalIgnoreCase)), "Beth should reject Unknown Figure art.");
}
static void IllustrationMatchingTreatsMrsTitleAsHardFeminineEvidence()
{
var mrsPatterson = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Mrs Patterson", ["Mrs Patterson", "neighbour"], []);
var masculine = Metadata("char-archival-contact", "Senior", "Masculine");
var androgynous = Metadata("char-neighbourhood-friend", "YoungAdult", "Androgynous");
var feminine = Metadata("char-family-relative", "MatureAdult", "Feminine");
Assert(mrsPatterson.Presentation == StoryIntelligenceIllustrationCompatibility.Presentations.Feminine, $"Mrs title should infer Feminine, got {mrsPatterson.Presentation}.");
Assert(Rejected(mrsPatterson, masculine).Any(reason => reason.Contains("presentation hard mismatch", StringComparison.OrdinalIgnoreCase)), "Mrs Patterson should reject masculine art.");
Assert(Rejected(mrsPatterson, androgynous).Count == 0, "Androgynous should not be hard-rejected solely by title.");
Assert(Rejected(mrsPatterson, feminine).Count == 0, "Suitable feminine candidate should remain valid.");
}
static void IllustrationMatchingInfersMotherAndAuntAsMatureFeminine()
{
var mother = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Beth's mother", ["her mother", "mother of Beth", "Beth is 15"], []);
var aunt = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Aunt Elen", ["Beth's aunt", "Aunt Elen"], []);
var teen = Metadata("char-young-helper", "YoungTeen", "Androgynous");
var masculine = Metadata("char-middle-aged-weathered-man", "MatureAdult", "Masculine");
Assert(mother.AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.MatureAdult, $"Mother should infer MatureAdult, got {mother.AgeBand}.");
Assert(mother.Presentation == StoryIntelligenceIllustrationCompatibility.Presentations.Feminine, "Mother should infer Feminine.");
Assert(aunt.AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.MatureAdult, $"Aunt should infer MatureAdult, got {aunt.AgeBand}.");
Assert(aunt.Presentation == StoryIntelligenceIllustrationCompatibility.Presentations.Feminine, "Aunt should infer Feminine.");
Assert(Rejected(mother, masculine).Any(reason => reason.Contains("presentation hard mismatch", StringComparison.OrdinalIgnoreCase)), "Mother should reject masculine art.");
Assert(Rejected(aunt, teen).Any(reason => reason.Contains("age hard mismatch", StringComparison.OrdinalIgnoreCase)), "Aunt should reject teen art.");
}
static void IllustrationMatchingRejectsDuplicateNamedPortraitAllocation()
{
var rosie = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Rosie", ["Rosie", "young woman"], []);
var candidate = Metadata("char-young-adult-red-haired-witness", "YoungAdult", "Feminine");
var rejected = StoryIntelligenceIllustrationCompatibility.CharacterHardRejections(rosie, candidate, isNamedCharacter: true, alreadyAssignedToAnotherSignificantCharacter: true);
Assert(rejected.Any(reason => reason.Contains("already assigned", StringComparison.OrdinalIgnoreCase)), "Distinct significant named characters should not silently share portraits.");
}
static void IllustrationSemanticTypesPreventObviousMismatches()
{
Assert(StoryIntelligenceIllustrationCompatibility.LocationType("bathroom at Beth's house") == "Bathroom", "Bathroom should classify as Bathroom.");
Assert(!StoryIntelligenceIllustrationCompatibility.LocationCompatible("Bathroom", "Office"), "Bathroom must reject office imagery.");
Assert(!StoryIntelligenceIllustrationCompatibility.LocationCompatible("Kitchen", "HouseExterior"), "Kitchen must reject house exterior imagery.");
Assert(StoryIntelligenceIllustrationCompatibility.LocationCompatible("Kitchen", "FlatInterior"), "Kitchen may use generic interior fallback.");
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("ambulance") == "Ambulance", "Ambulance should classify as Ambulance.");
Assert(!StoryIntelligenceIllustrationCompatibility.AssetCompatible("Ambulance", "Car"), "Ambulance must reject sports car imagery.");
Assert(!StoryIntelligenceIllustrationCompatibility.AssetCompatible("BodyBag", "Suitcase"), "Body bag must reject suitcase/box imagery.");
Assert(!StoryIntelligenceIllustrationCompatibility.AssetCompatible("Note", "Suitcase"), "Note must reject suitcase imagery.");
}
static void StoryIntelligenceEvidenceExtractionPreventsObservedAttributeLeakage()
{
var beth = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Beth", ["Beth is fifteen.", "Beth pushed red hair aside."], ["Beth's mother entered."]);
var maggie = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Maggie", ["Maggie is sixteen."], ["her mother spoke."]);
var socialWorker = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("social worker", ["the social worker arrived."], ["Beth pushed red hair aside."]);
var kevin = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Kevin", ["Kevin"], []);
var ladyExaminer = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("lady examiner", ["the lady examiner"], []);
var toddler = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("boy", ["the boy toddler"], []);
Assert(beth.AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.YoungTeen, $"Beth must not become MatureAdult, got {beth.AgeBand}.");
Assert(beth.HairColour == StoryIntelligenceIllustrationCompatibility.HairColours.Red, "Beth's own red-hair evidence should be retained.");
Assert(maggie.AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.OlderTeen, $"Maggie at sixteen must not become MatureAdult, got {maggie.AgeBand}.");
Assert(socialWorker.HairColour == StoryIntelligenceIllustrationCompatibility.HairColours.Unknown, $"Social worker should not inherit Beth's red hair, got {socialWorker.HairColour}.");
Assert(kevin.Presentation == StoryIntelligenceIllustrationCompatibility.Presentations.Masculine, $"Kevin should be Masculine, got {kevin.Presentation}.");
Assert(ladyExaminer.Presentation == StoryIntelligenceIllustrationCompatibility.Presentations.Feminine, $"Lady examiner should be Feminine, got {ladyExaminer.Presentation}.");
Assert(toddler.AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.Child, $"Toddler should be Child, got {toddler.AgeBand}.");
Assert(toddler.Presentation == StoryIntelligenceIllustrationCompatibility.Presentations.Masculine, $"Boy toddler should be Masculine, got {toddler.Presentation}.");
Assert(StoryIntelligenceIllustrationCompatibility.LocationType("Cock Hill Lane") == "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.");
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("driving licence") == "DrivingLicence", "Driving licence should classify as DrivingLicence.");
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("ring") == "Jewellery", "Ring should classify as Jewellery.");
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("gun") == "Weapon", "Gun should classify as Weapon.");
}
static void IllustrationDemandArchetypesStayBroadAndUseActiveTemplate()
{
var evidence = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Beth", ["Beth", "age 15", "red hair", "light skin"], []);
var key = string.Join('|', "character", evidence.AgeBand, evidence.Presentation, evidence.HairColour, evidence.SkinTone).ToLowerInvariant();
Assert(key == "character|youngteen|feminine|red|light", $"Demand key was too broad or too specific: {key}");
Assert(!key.Contains("beth", StringComparison.OrdinalIgnoreCase), "Demand key must not include character name.");
Assert(!key.Contains("eye", StringComparison.OrdinalIgnoreCase), "Demand key must not include eye colour.");
Assert(IllustrationPromptBuilder.CurrentTemplateVersion == "21J.3", $"Demand generation should use the active shared template, got {IllustrationPromptBuilder.CurrentTemplateVersion}.");
}
static void IllustrationAssignmentRepairEnforcesExplicitEvidenceGroupsAndStrictSubtypes()
{
var femaleInstructor = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("female instructor", ["female instructor"], []);
var maleExaminers = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("younger male examiners", ["younger male examiners"], []);
var lad = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("tubby lad", ["tubby lad"], []);
var girl = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("girl", ["schoolgirl"], []);
var kevin = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Kevin", ["Kevin", "female instructor (Kevin's)"], ["female instructor", "Kevin's examiner"]);
var colin = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Colin", ["Colin mother waits nearby", "female instructor"], []);
var david = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("David", ["David the woman speaks first", "female examiner"], []);
Assert(femaleInstructor.Presentation == StoryIntelligenceIllustrationCompatibility.Presentations.Feminine, $"female instructor should be Feminine, got {femaleInstructor.Presentation}.");
Assert(maleExaminers.Presentation == StoryIntelligenceIllustrationCompatibility.Presentations.Masculine, $"male examiners should be Masculine, got {maleExaminers.Presentation}.");
Assert(lad.Presentation == StoryIntelligenceIllustrationCompatibility.Presentations.Masculine, $"lad should be Masculine, got {lad.Presentation}.");
Assert(lad.AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.Unknown, $"lad should not default to Child without explicit child-age evidence, got {lad.AgeBand}.");
Assert(girl.Presentation == StoryIntelligenceIllustrationCompatibility.Presentations.Feminine, $"girl should be Feminine, got {girl.Presentation}.");
Assert(girl.AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.Child, $"schoolgirl should infer Child, got {girl.AgeBand}.");
Assert(kevin.Presentation == StoryIntelligenceIllustrationCompatibility.Presentations.Masculine, $"Kevin should not inherit feminine presentation from his instructor, got {kevin.Presentation}.");
Assert(colin.Presentation == StoryIntelligenceIllustrationCompatibility.Presentations.Masculine, $"Colin should not inherit feminine presentation from neighbouring evidence, got {colin.Presentation}.");
Assert(colin.Warnings.Any(warning => warning.Contains("corrected Colin", StringComparison.OrdinalIgnoreCase)), "Colin correction should be visible in diagnostics warnings.");
Assert(david.Presentation == StoryIntelligenceIllustrationCompatibility.Presentations.Masculine, $"David should not inherit feminine presentation from neighbouring evidence, got {david.Presentation}.");
Assert(david.Warnings.Any(warning => warning.Contains("corrected David", StringComparison.OrdinalIgnoreCase)), "David correction should be visible in diagnostics warnings.");
Assert(StoryIntelligenceIllustrationCompatibility.IsGroupEntity("social workers"), "Social workers should be treated as a group entity.");
Assert(StoryIntelligenceIllustrationCompatibility.IsGroupEntity("younger male examiners"), "Plural examiners should be treated as a group entity.");
Assert(StoryIntelligenceIllustrationCompatibility.IsGroupEntity("uniformed people"), "Uniformed people should be treated as a group entity.");
Assert(!StoryIntelligenceIllustrationCompatibility.IsGroupEntity("social worker"), "Singular social worker should remain an individual entity.");
Assert(!StoryIntelligenceIllustrationCompatibility.LocationCompatible("Bathroom", "LaundryUtilityRoom"), "Bathroom must not match laundry room.");
Assert(!StoryIntelligenceIllustrationCompatibility.LocationCompatible("Road", "CarPark"), "Road must not match car park.");
Assert(!StoryIntelligenceIllustrationCompatibility.LocationCompatible("WaitingRoom", "FlatInterior"), "Waiting room must not match flat interior.");
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("Ford Fiesta") == "Hatchback", "Ford Fiesta should classify as Hatchback.");
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("Car") == "Car", "Car should classify as Car.");
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("supercharger") == "CarPart", "Supercharger should not classify as full car.");
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("biscuit tin") == "Tin", "Biscuit tin should not classify as Photograph.");
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("parcel") == "Parcel", "Parcel should not classify as Letter.");
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("pass certificate") == "Certificate", "Pass certificate should classify as Certificate.");
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("driving licence") == "DrivingLicence", "Driving licence should classify as DrivingLicence.");
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("thin cotton blouse") == "Clothing", "Blouse should classify as Clothing.");
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("two pairs of sunglasses") == "Clothing", "Sunglasses should classify as Clothing.");
var beth = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Beth", ["Beth is fifteen."], []);
var adult = Metadata("char-family-relative", "MatureAdult", "Feminine");
Assert(Rejected(beth, adult).Any(reason => reason.Contains("age hard mismatch", StringComparison.OrdinalIgnoreCase)), "Existing adult assignment must be discarded when Beth evidence says YoungTeen.");
}
static void Phase21OPreservesAdultDefaultsAndRealModeContinuity()
{
var unknownNamed = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Sarah", ["Sarah watches."], []);
var explicitGirl = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Mia", ["schoolgirl"], []);
var teacher = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Mrs Patterson", ["Mrs Patterson is a teacher."], []);
var examiner = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("driving examiner", ["female driving examiner"], []);
Assert(unknownNamed.AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.Unknown, $"Unknown age should remain Unknown before illustration fallback, got {unknownNamed.AgeBand}.");
Assert(explicitGirl.AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.Child, $"Schoolgirl should remain explicit child evidence, got {explicitGirl.AgeBand}.");
Assert(teacher.AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.Adult, $"Teacher title/role should infer Adult, got {teacher.AgeBand}.");
Assert(examiner.Presentation == StoryIntelligenceIllustrationCompatibility.Presentations.Feminine, $"Female examiner should infer Feminine, got {examiner.Presentation}.");
Assert(examiner.AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.Adult, $"Driving examiner should infer Adult, got {examiner.AgeBand}.");
Assert(StoryIntelligenceIllustrationCompatibility.LocationType("Bristol Road") == "Road", "Road names should classify as Road.");
Assert(StoryIntelligenceIllustrationCompatibility.LocationType("Cock Hill Lane") == "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.");
var view = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "PlotLine", "Views", "Development", "StoryIntelligenceExperience.cshtml"));
Assert(view.Contains("Return to Import", StringComparison.Ordinal), "Real visualisation should expose a return-to-import link.");
Assert(view.Contains("Model.Mode == \"simulation\" || Model.Mode == \"replay\"", StringComparison.Ordinal), "Prototype controls should be gated to simulation and replay modes.");
}
static void Phase21PUsesOneAuthoritativeCurrentVisualisationScene()
{
var root = Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..");
var viewModel = File.ReadAllText(Path.Combine(root, "PlotLine", "ViewModels", "StoryIntelligenceExperiencePrototypeViewModels.cs"));
var snapshotService = File.ReadAllText(Path.Combine(root, "PlotLine", "Services", "StoryIntelligenceVisualisationSnapshotService.cs"));
var browser = File.ReadAllText(Path.Combine(root, "PlotLine", "wwwroot", "js", "story-intelligence-experience-prototype.js"));
var view = File.ReadAllText(Path.Combine(root, "PlotLine", "Views", "Development", "StoryIntelligenceExperience.cshtml"));
Assert(viewModel.Contains("public bool IsCurrent", StringComparison.Ordinal), "Scene snapshots must expose an authoritative current-scene marker.");
Assert(snapshotService.Contains("IsCurrent = isCurrent", StringComparison.Ordinal), "Snapshot builder must mark the selected current scene.");
Assert(snapshotService.Contains("followingCount: 0", StringComparison.Ordinal), "Real import snapshots must not include following scenes that can pull the browser ahead.");
Assert(snapshotService.Contains("Story Intelligence snapshot synchronisation", StringComparison.Ordinal), "Snapshot builder must log synchronisation evidence.");
Assert(browser.Contains("scene?.isCurrent", StringComparison.Ordinal), "Browser must choose the current scene from the authoritative marker.");
Assert(browser.Contains("Story Intelligence browser synchronisation", StringComparison.Ordinal), "Browser must log synchronisation evidence.");
Assert(view.Contains("Scenes = Model.Scenes", StringComparison.Ordinal), "Initial real page payload must include the same scene collection as polled snapshots.");
}
static IllustrationCharacterMetadata Metadata(string code, string ageBand, string presentation, bool isUnknownFigure = false, string hairColour = "Unknown", string skinTone = "Unknown")
=> new(
ageBand,
presentation,
hairColour,
skinTone,
"Unknown",
"Unknown",
"Unknown",
isUnknownFigure,
false,
!isUnknownFigure,
IllustrationPromptBuilder.CurrentTemplateVersion,
"Test");
static StoryMemoryCharacterAttribute Attribute(string type, string value, decimal confidence, bool isExplicit, int sceneResultId)
=> new()
{
AttributeType = type,
NormalisedValue = value,
Confidence = confidence,
IsExplicit = isExplicit,
SourceSceneResultID = sceneResultId
};
static IReadOnlyList<string> Rejected(CharacterEvidenceProfile evidence, IllustrationCharacterMetadata metadata)
=> StoryIntelligenceIllustrationCompatibility.CharacterHardRejections(evidence, metadata, isNamedCharacter: true, alreadyAssignedToAnotherSignificantCharacter: false);
static void StoryIntelligenceSimulationModeRemainsAvailable()
{
var prototype = StoryIntelligenceExperiencePrototypeData.Build();
Assert(prototype.Scenes.Count >= 7, "Simulation should keep the existing multi-scene visual dataset.");
Assert(prototype.Scenes.Any(scene => scene.PovCharacterId == "rosie"), "Simulation should still exercise POV transitions.");
}
static void StoryIntelligenceDoesNotRetryExhaustedBillingQuota()
{
var client = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Services/StoryIntelligenceOpenAIInfrastructure.cs"));
Assert(client.Contains("IsInsufficientQuota(responseText)", StringComparison.Ordinal), "OpenAI insufficient_quota responses must be detected before retrying.");
Assert(client.Contains("request was not retried", StringComparison.Ordinal), "OpenAI insufficient_quota responses should fail clearly without retry churn.");
}
static void StoryIntelligenceVisualisationContractOmitsRawManuscriptText()
{
var model = new StoryIntelligenceExperiencePrototypeViewModel
{
Mode = "real",
RunId = 42,
ChangeToken = "stable-token",
ProjectName = "Project",
BookTitle = "Book",
Scenes =
[
new StoryIntelligenceExperienceSceneState
{
Id = "scene-result-1",
SceneNumber = 1,
Title = "Analysed scene",
Summary = "Safe summary",
Location = new StoryIntelligenceExperienceLocationState { Id = "location-result-room", Name = "Room" }
}
]
};
var json = JsonSerializer.Serialize(model, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
Assert(json.Contains("\"changeToken\":\"stable-token\"", StringComparison.Ordinal), "Snapshot contract should expose a change token.");
Assert(!json.Contains("sourceText", StringComparison.OrdinalIgnoreCase), "Snapshot contract should not expose raw manuscript source text.");
Assert(!json.Contains("rawResponseJson", StringComparison.OrdinalIgnoreCase), "Snapshot contract should not expose raw AI responses.");
Assert(!json.Contains("prompt", StringComparison.OrdinalIgnoreCase), "Snapshot contract should not expose prompts.");
}
static void IllustrationBulkRetryResultReportsCounts()
{
var result = new IllustrationBulkRetryResult(12, 4, 3);
Assert(result.Message.Contains("12 failed illustration", StringComparison.Ordinal), "Queued count should be reported.");
Assert(result.Message.Contains("4 skipped", StringComparison.Ordinal), "Skipped count should be reported.");
Assert(result.Message.Contains("3 reached the retry limit", StringComparison.Ordinal), "Retry-limit count should be reported.");
}
static void IllustrationBulkRetrySkipsBlockingDuplicateStableCodes()
{
var failed = new IllustrationLibraryItem
{
IllustrationLibraryItemID = 27,
StableCode = "asset-photo-print",
Status = IllustrationLibraryStatuses.Failed,
IsActive = true,
ParentItemID = null
};
var planned = new IllustrationLibraryItem
{
IllustrationLibraryItemID = 38,
StableCode = "asset-photo-print",
Status = IllustrationLibraryStatuses.Planned,
IsActive = true,
ParentItemID = null
};
var approved = new IllustrationLibraryItem
{
IllustrationLibraryItemID = 55,
StableCode = "asset-photo-print",
Status = IllustrationLibraryStatuses.Approved,
IsActive = true,
ParentItemID = null
};
Assert(IllustrationLibraryService.ResolveBulkRetryTarget(failed, [failed, approved]) is null, "Failed duplicate should be skipped when an approved active sibling exists.");
Assert(IllustrationLibraryService.ResolveBulkRetryTarget(failed, [failed, planned])?.IllustrationLibraryItemID == planned.IllustrationLibraryItemID, "Failed duplicate should queue its planned active sibling.");
}
static void IllustrationProviderReportsMissingImageModel()
{
var provider = new OpenAIIllustrationImageProvider(
new HttpClient(),
Options.Create(new StoryIntelligenceOptions { ApiKey = "configured-for-test" }),
NullLogger<OpenAIIllustrationImageProvider>.Instance);
Assert(!provider.IsConfigured, "Provider should not be configured without an image model.");
Assert(provider.MissingConfiguration.Count == 1, "Only image model should be missing when API key exists.");
Assert(provider.MissingConfiguration[0].Contains("image-generation model", StringComparison.OrdinalIgnoreCase), "Missing image model should be named.");
}
static void IllustrationProviderOmitsGptImageResponseFormat()
{
string? payload = null;
var handler = new CaptureImageRequestHandler(request =>
{
payload = request.Content?.ReadAsStringAsync().GetAwaiter().GetResult();
return new HttpResponseMessage(HttpStatusCode.BadRequest)
{
Content = new StringContent(
"""{"error":{"message":"test failure","code":"invalid_request_error"}}""",
Encoding.UTF8,
"application/json")
};
});
var provider = new OpenAIIllustrationImageProvider(
new HttpClient(handler),
Options.Create(new StoryIntelligenceOptions
{
ApiKey = "configured-for-test",
ImageGenerationModel = "gpt-image-1",
ImageGenerationSize = "1024x1024"
}),
NullLogger<OpenAIIllustrationImageProvider>.Instance);
var result = provider.GenerateAsync(
new IllustrationImageGenerationRequest(1, "asset-folded-letter", IllustrationLibraryCategories.Asset, "Prompt", IllustrationPromptBuilder.CurrentTemplateVersion),
CancellationToken.None).GetAwaiter().GetResult();
Assert(payload is not null, "Provider did not send a payload.");
Assert(!payload!.Contains("response_format", StringComparison.Ordinal), "GPT image payload should not include response_format.");
Assert(payload.Contains("\"model\":\"gpt-image-1\"", StringComparison.Ordinal), "Payload should include the configured image model.");
Assert(result.ErrorMessage?.Contains("invalid_request_error", StringComparison.Ordinal) == true, "Stored error should include safe OpenAI error code.");
Assert(result.ErrorMessage?.Contains("test failure", StringComparison.Ordinal) == true, "Stored error should include safe OpenAI error message.");
}
static void ScanReviewPostSupportsFullBookFormSubmissions()
{
var method = typeof(OnboardingController).GetMethod(nameof(OnboardingController.SaveScanReview));
Assert(method is not null, "Scan review post action could not be found.");
var formLimits = method!.GetCustomAttributes(typeof(RequestFormLimitsAttribute), inherit: false)
.OfType<RequestFormLimitsAttribute>()
.SingleOrDefault();
Assert(formLimits is not null, "Scan review post action should configure expanded form limits.");
Assert(formLimits!.ValueCountLimit >= 20000, "Scan review form value limit should support full-book previews.");
Assert(formLimits.KeyLengthLimit >= 4096, "Scan review form key length limit should support indexed review fields.");
var requestSize = method.GetCustomAttributes(typeof(RequestSizeLimitAttribute), inherit: false)
.OfType<RequestSizeLimitAttribute>()
.SingleOrDefault();
Assert(((IRequestSizeLimitMetadata?)requestSize)?.MaxRequestBodySize >= 25 * 1024 * 1024, "Scan review request size limit should support full-book previews.");
}
static void StoryIntelligenceExperienceBootDoesNotSerialiseLiveModel()
{
var view = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Views/Development/StoryIntelligenceExperience.cshtml"));
Assert(!view.Contains("JsonSerializer.Serialize(Model", StringComparison.Ordinal), "Live experience page should not serialise the full view model into the Razor response.");
Assert(view.Contains("JsonSerializer.Serialize(bootModel", StringComparison.Ordinal), "Live experience page should serialise only boot data.");
}
static void LiveVisualisationStripsIllustrationDiagnostics()
{
var large = new string('x', 200_000);
var model = new StoryIntelligenceExperiencePrototypeViewModel
{
Diagnostics = new StoryIntelligenceExperienceSnapshotDiagnostics
{
CharacterConsistencyReport = large
},
Scenes =
[
new StoryIntelligenceExperienceSceneState
{
Id = "scene-result-1",
Location = new StoryIntelligenceExperienceLocationState(),
Characters =
[
new StoryIntelligenceExperienceCharacterState
{
Id = "character-result-beth",
Name = "Beth",
ImageResolution = new StoryIntelligenceExperienceImageResolution
{
RejectedCandidates = large,
PreviousEvidence = large,
CurrentEvidence = large,
EvidenceWarnings = large
}
}
]
}
]
};
StoryIntelligenceVisualisationSnapshotService.StripLiveDiagnostics(model);
var json = JsonSerializer.Serialize(model, JsonOptions());
Assert(!json.Contains(large, StringComparison.Ordinal), "Live snapshot should not contain large illustration diagnostic strings.");
Assert(json.Length < 20_000, $"Live snapshot remained too large after stripping diagnostics: {json.Length:N0} chars.");
}
static void IllustrationEvidenceTraceDoesNotRecursivelyStorePreviousEvidence()
{
var source = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Services/StoryIntelligenceIllustrationMatchingService.cs"));
Assert(!source.Contains("previousStoredEvidence = existing?.EvidenceJson", StringComparison.Ordinal), "Evidence trace must not recursively embed previous EvidenceJson.");
Assert(source.Contains("evidenceHash", StringComparison.Ordinal), "Evidence trace should keep only a compact previous evidence hash.");
}
static void Phase21QRepairsProductionVisualisationRegressions()
{
var unknownNamed = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Sarah", ["Sarah watches from the lane."], []);
var mrColin = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Mr Colin Webb", ["Mr Colin Webb is a driving examiner."], []);
var maggie = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Maggie", ["Maggie is sixteen.", "Maggie pushed back her red hair."], []);
var darkHairCandidate = Metadata("char-young-adult-brunette-observer", "OlderTeen", "Feminine", hairColour: "Brown");
var teenCandidate = Metadata("char-young-helper", "YoungTeen", "Androgynous");
Assert(unknownNamed.AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.Unknown, $"Unknown named age must remain Unknown, got {unknownNamed.AgeBand}.");
Assert(mrColin.Presentation == StoryIntelligenceIllustrationCompatibility.Presentations.Masculine, $"Mr Colin should infer Masculine, got {mrColin.Presentation}.");
Assert(mrColin.AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.Adult, $"Driving examiner title/profession should infer Adult, got {mrColin.AgeBand}.");
Assert(Rejected(mrColin, teenCandidate).Any(reason => reason.Contains("age hard mismatch", StringComparison.OrdinalIgnoreCase)), "Adult title/profession should reject child/teen candidates.");
Assert(maggie.HairColour == StoryIntelligenceIllustrationCompatibility.HairColours.Red, $"Maggie red-hair evidence should be explicit, got {maggie.HairColour}.");
Assert(Rejected(maggie, darkHairCandidate).Any(reason => reason.Contains("hair colour hard mismatch", StringComparison.OrdinalIgnoreCase)), "Red-hair evidence must invalidate dark-haired assignments.");
Assert(StoryIntelligenceIllustrationCompatibility.LocationType("Cock Hill Lane") == "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.");
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("blue VW Golf") == "Hatchback", "Blue VW Golf should classify as Hatchback.");
Assert(StoryIntelligenceIllustrationCompatibility.AssetColour("blue VW Golf") == "Blue", "Blue VW Golf should retain explicit colour.");
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("TR6 sports car") == "SportsCar", "TR6 should remain a sports car.");
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("parcel") == "Parcel", "Parcel should not use letter/document imagery.");
Assert(!StoryIntelligenceIllustrationCompatibility.AssetCompatible("Hatchback", "SportsCar"), "Blue hatchback must not silently use red sports-car imagery.");
var serviceSource = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Services/StoryIntelligenceIllustrationMatchingService.cs"));
Assert(serviceSource.Contains("DemandGenerationThreshold = 1", StringComparison.Ordinal), "Live import demand should queue on first repeated missing archetype pass, not after dozens of scenes.");
Assert(serviceSource.Contains("ApplySupportingIllustrationDemandAsync", StringComparison.Ordinal), "Location and asset demand must run from the visualisation pipeline.");
Assert(serviceSource.Contains("MateriallyWrong(existingScore.Candidate.Metadata, observation.Evidence)", StringComparison.Ordinal), "Existing assignments must be revalidated against current evidence.");
Assert(serviceSource.Contains("BalancedSkinToneFallback", StringComparison.Ordinal), "Unresolved appearance should use deterministic project/book diversity rather than one global skin-tone default.");
var librarySource = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Services/IllustrationLibraryServices.cs"));
var applyApprovedSource = librarySource[librarySource.IndexOf("public async Task ApplyApprovedIllustrationsAsync", StringComparison.Ordinal)..];
Assert(!applyApprovedSource.Contains("StarterCodes.Contains", StringComparison.Ordinal), "Generated demand illustrations must not be filtered out by the runtime approved-image lookup.");
}
static void Phase21QViewKeepsPanelsBoundedAndRealControlsHidden()
{
var view = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Views/Development/StoryIntelligenceExperience.cshtml"));
var script = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/wwwroot/js/story-intelligence-experience-prototype.js"));
var css = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/wwwroot/css/story-intelligence-experience-prototype.css"));
Assert(!view.Contains("data-scene-title", StringComparison.Ordinal), "Redundant scene heading should be removed from the real visualisation.");
Assert(view.IndexOf("data-knowledge", StringComparison.Ordinal) < view.IndexOf("data-relationships", StringComparison.Ordinal), "Knowledge threads should be prioritised above relationship changes.");
Assert(view.Contains("Model.Mode == \"simulation\" || Model.Mode == \"replay\"", StringComparison.Ordinal), "Prototype controls must remain hidden in live mode while supporting replay.");
Assert(script.Contains("relationships.slice(0, 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.");
}
static void Phase21RReplayUsesPersistedDataOnly()
{
var snapshotService = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Services/StoryIntelligenceVisualisationSnapshotService.cs"));
var matcher = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Services/StoryIntelligenceIllustrationMatchingService.cs"));
var development = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Controllers/DevelopmentController.cs"));
Assert(snapshotService.Contains("BuildReplaySnapshotAsync", StringComparison.Ordinal), "Replay mode needs a dedicated snapshot builder.");
Assert(snapshotService.Contains("Mode = replayMode ? \"replay\" : \"real\"", StringComparison.Ordinal), "Replay snapshots should be explicitly labelled.");
Assert(snapshotService.Contains("ApplyPersistedStoryMemoryAssignmentsAsync(importSessionId, model)", StringComparison.Ordinal), "Replay must use durable Story Memory illustration assignments only.");
Assert(!snapshotService.Contains("await illustrationMatcher.ApplyPersistedCharacterIllustrationsAsync", StringComparison.Ordinal), "Replay should not use the legacy character-only matcher.");
Assert(!snapshotService.Contains("await illustrationMatcher.ApplySupportingIllustrationDemandAsync", StringComparison.Ordinal), "Snapshot rendering must not create location/asset demand.");
Assert(snapshotService.Contains("AiCallsDuringReplay = 0", StringComparison.Ordinal), "Replay diagnostics must state that no AI calls are made.");
Assert(development.Contains("BuildReplaySnapshotAsync(importSessionId.Value, userId)", StringComparison.Ordinal), "Development route must call the replay snapshot builder for mode=replay.");
Assert(development.Contains("RebuildStoryIntelligenceExperience", StringComparison.Ordinal), "Development route should expose a saved-analysis rebuild action.");
Assert(development.Contains("AI calls", StringComparison.Ordinal), "Rebuild action should report that it made no AI calls.");
}
static void Phase21SUsesDurableStoryMemoryAssignments()
{
var sql = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Sql/142_Phase21S_DurableStoryMemory.sql"));
var models = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Models/StoryMemoryModels.cs"));
var service = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Services/StoryMemoryServices.cs"));
var snapshot = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Services/StoryIntelligenceVisualisationSnapshotService.cs"));
var runner = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Services/PersistedStoryIntelligenceRunner.cs"));
var development = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Controllers/DevelopmentController.cs"));
Assert(sql.Contains("StoryMemoryCharacters", StringComparison.Ordinal), "Durable Story Memory character table is missing.");
Assert(sql.Contains("StoryMemoryLocations", StringComparison.Ordinal), "Durable Story Memory location table is missing.");
Assert(sql.Contains("StoryMemoryAssets", StringComparison.Ordinal), "Durable Story Memory asset table is missing.");
Assert(sql.Contains("StoryMemoryIllustrationAssignments", StringComparison.Ordinal), "Durable illustration assignment table is missing.");
Assert(sql.Contains("StoryMemoryIllustrationDemands", StringComparison.Ordinal), "Durable illustration demand table is missing.");
Assert(models.Contains("StoryMemoryAppearancePreferences", StringComparison.Ordinal), "Import-level appearance preference model is missing.");
Assert(service.Contains("IStoryMemoryService", StringComparison.Ordinal), "Durable Story Memory service is missing.");
Assert(service.Contains("QueueDemand = false", StringComparison.Ordinal) || service.Contains("QueueDemand", StringComparison.Ordinal), "Demand queueing must be explicit.");
Assert(service.Contains("InferCharacterEvidence", StringComparison.Ordinal), "Durable updater must use deterministic character evidence extraction.");
Assert(service.Contains("CharacterHardRejections", StringComparison.Ordinal), "Resolver must revalidate hard character constraints.");
Assert(service.Contains("LocationCompatible", StringComparison.Ordinal), "Resolver must enforce location compatibility.");
Assert(service.Contains("AssetCompatible", StringComparison.Ordinal), "Resolver must enforce asset compatibility.");
Assert(snapshot.Contains("ApplyPersistedStoryMemoryAssignmentsAsync", StringComparison.Ordinal), "Snapshot must read durable persisted assignments.");
Assert(runner.Contains("storyMemory.ProcessSceneResultAsync", StringComparison.Ordinal), "Importer must update Story Memory as scenes are persisted.");
Assert(development.Contains("StoryMemoryDiagnostics", StringComparison.Ordinal), "Development diagnostics endpoint is missing.");
Assert(development.Contains("RebuildImportSessionAsync", StringComparison.Ordinal), "Development rebuild action must call the durable memory rebuild.");
}
static void Phase21TRepairsDurableCharacterUnderstanding()
{
var maggie = StoryMemoryService.ResolveCharacterEvidenceFromAttributes(
"Maggie",
[
Attribute("AgeBand", "Adult", 0.88m, false, 10),
Attribute("AgeBand", "OlderTeen", 0.98m, true, 20),
Attribute("Presentation", "Feminine", 0.98m, true, 1),
Attribute("HairColour", "Red", 0.82m, true, 20),
Attribute("HairLength", "Long", 0.72m, true, 20)
]);
Assert(maggie.AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.OlderTeen, $"Explicit later teen evidence should win, got {maggie.AgeBand}.");
Assert(maggie.HairColour == StoryIntelligenceIllustrationCompatibility.HairColours.Red, $"Durable red-hair evidence should reach resolver, got {maggie.HairColour}.");
Assert(maggie.HairLength == "Long", $"Durable hair length should reach resolver, got {maggie.HairLength}.");
var unknown = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Sarah", ["Sarah watches."], []);
var childCandidate = Metadata("char-demand-child-feminine", "Child", "Feminine");
Assert(Rejected(unknown, childCandidate).Any(reason => reason.Contains("unknown age", StringComparison.OrdinalIgnoreCase)), "Unknown named characters must reject child portraits.");
var adult = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Mrs Patterson", ["Mrs Patterson is a teacher."], []);
var teenCandidate = Metadata("char-demand-olderteen-feminine", "OlderTeen", "Feminine");
Assert(Rejected(adult, teenCandidate).Any(reason => reason.Contains("age hard mismatch", StringComparison.OrdinalIgnoreCase)), "Adult title/occupation evidence must reject teen portraits.");
var olderTeen = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Colin", ["Colin is a learner driver at the driving test centre."], []);
var childMasculineCandidate = Metadata("char-demand-child-masculine", "Child", "Masculine");
Assert(Rejected(olderTeen, childMasculineCandidate).Any(reason => reason.Contains("age hard mismatch", StringComparison.OrdinalIgnoreCase)), "Older teen evidence must reject child portraits.");
Assert(StoryIntelligenceIllustrationCompatibility.IsGroupEntity("Group of girls"), "Group of girls should not reserve a unique character portrait.");
Assert(StoryIntelligenceIllustrationCompatibility.IsGroupEntity("pair of girls"), "Pair of girls should not reserve a unique character portrait.");
var service = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Services/StoryMemoryServices.cs"));
var snapshot = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Services/StoryIntelligenceVisualisationSnapshotService.cs"));
Assert(service.Contains("BuildCrossSceneIdentityAliases", StringComparison.Ordinal), "Rebuild should repair cross-scene descriptive-to-named character identities from saved analysis.");
Assert(service.Contains("ExtensionEvidence(sceneCharacter.ExtensionData)", StringComparison.Ordinal), "Saved scene appearance extension data must be included in durable evidence.");
Assert(!snapshot.Contains("memoryCharacter", StringComparison.Ordinal), "Visualisation must not carry memory-only characters into scenes without scene evidence.");
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(!CharacterAppearanceExtractionService.Extract("Beth went next door and mumbled, 'Mum.'", ["Beth", "Mum"]).Any(fact => fact.CanonicalName == "Beth" && fact.AttributeType == "AgeBand" && fact.NormalisedValue == StoryIntelligenceIllustrationCompatibility.AgeBands.MatureAdult), "Nearby Mum text must not age Beth into a mature adult.");
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}.");
var maggieMemory = StoryMemoryService.ResolveCharacterEvidenceFromAttributes("Maggie", [
new StoryMemoryCharacterAttribute { AttributeType = "HairColour", NormalisedValue = StoryIntelligenceIllustrationCompatibility.HairColours.Red, Confidence = 0.95m, IsExplicit = true }
]);
Assert(maggieMemory.SkinTone == StoryIntelligenceIllustrationCompatibility.SkinTones.Light, $"Natural red hair should default unknown skin tone to Light, got {maggieMemory.SkinTone}.");
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 redHairedMaggie = StoryMemoryService.ResolveCharacterEvidenceFromAttributes("Maggie", [
new StoryMemoryCharacterAttribute { AttributeType = "HairColour", NormalisedValue = StoryIntelligenceIllustrationCompatibility.HairColours.Red, Confidence = 0.95m, IsExplicit = true }
]);
var bethFromLeakyEvidence = StoryMemoryService.ResolveCharacterEvidenceFromAttributes("Beth", [
new StoryMemoryCharacterAttribute { AttributeType = "AgeBand", NormalisedValue = StoryIntelligenceIllustrationCompatibility.AgeBands.MatureAdult, Confidence = 0.9m, IsExplicit = true, EvidenceSummary = "Beth goes next door and mumbles 'Mum'" },
new StoryMemoryCharacterAttribute { AttributeType = "AgeBand", NormalisedValue = StoryIntelligenceIllustrationCompatibility.AgeBands.YoungAdult, Confidence = 0.9m, IsExplicit = true, EvidenceSummary = "No fifteen-year-old girl with a future." },
new StoryMemoryCharacterAttribute { AttributeType = "Presentation", NormalisedValue = StoryIntelligenceIllustrationCompatibility.Presentations.Masculine, Confidence = 0.95m, IsExplicit = true, EvidenceSummary = "I checked my look in the mirror." }
]);
var mrsWithTeenContext = StoryMemoryService.ApplyImportContextToEvidence(
StoryMemoryService.ResolveCharacterEvidenceFromAttributes("Mrs Patterson", [
new StoryMemoryCharacterAttribute { AttributeType = "AgeBand", NormalisedValue = StoryIntelligenceIllustrationCompatibility.AgeBands.Adult, Confidence = 0.88m, IsExplicit = true }
]),
"Mrs Patterson",
new StoryMemoryImportContext(StoryMemoryAppearancePreferences.PredominantlyLight, "OlderTeen", "1980s", "UK"));
var rosieBirthday = StoryMemoryService.ResolveCharacterEvidenceFromAttributes("Rosie", [
new StoryMemoryCharacterAttribute { AttributeType = "AgeBand", NormalisedValue = StoryIntelligenceIllustrationCompatibility.AgeBands.Child, Confidence = 0.98m, IsExplicit = true, EvidenceSummary = "Deterministic evidence inference" },
new StoryMemoryCharacterAttribute { AttributeType = "AgeBand", NormalisedValue = StoryIntelligenceIllustrationCompatibility.AgeBands.YoungAdult, Confidence = 0.90m, IsExplicit = true, EvidenceSummary = "She may be eighteen now, but she is still the baby of the group." }
]);
var alisonPronoun = StoryIntelligenceIllustrationCompatibility.NormalizePresentation("She was a nurse at Selly Oak Hospital.");
var alisonName = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Alison", ["Alison"], []);
var sophie = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Sophie", ["Sophie glared at me. She called the pub hers."], ["Sophie is Rebecca's sister."]);
var gregFromRosieAction = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Greg", ["Greg offers to take Rosie to hospital."], []);
var judyName = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Judy", ["Judy"], []);
var philName = StoryIntelligenceIllustrationCompatibility.InferCharacterEvidence("Phil", ["Phil"], []);
var childCandidate = Metadata("char-child", "Child", "Feminine");
var darkRedCandidate = Metadata("char-red-dark", "OlderTeen", "Feminine", hairColour: "Red", skinTone: "Dark");
Assert(bethFromLeakyEvidence.AgeBand != StoryIntelligenceIllustrationCompatibility.AgeBands.MatureAdult, $"Leaked family-role evidence should not age Beth up, got {bethFromLeakyEvidence.AgeBand}.");
Assert(bethFromLeakyEvidence.Presentation == StoryIntelligenceIllustrationCompatibility.Presentations.Feminine, $"Name/title guard should repair leaked Beth presentation, got {bethFromLeakyEvidence.Presentation}.");
Assert(mrsWithTeenContext.AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.Adult, $"Adult-role titles must not be overridden by primary age context, got {mrsWithTeenContext.AgeBand}.");
Assert(rosieBirthday.AgeBand == StoryIntelligenceIllustrationCompatibility.AgeBands.YoungAdult, $"Rosie's explicit eighteen-year-old evidence must beat weak child leakage, got {rosieBirthday.AgeBand}.");
Assert(alisonPronoun == StoryIntelligenceIllustrationCompatibility.Presentations.Feminine, $"Direct she/her pronoun evidence should infer Feminine, got {alisonPronoun}.");
Assert(alisonName.Presentation == StoryIntelligenceIllustrationCompatibility.Presentations.Feminine, $"Common feminine name Alison should infer Feminine, got {alisonName.Presentation}.");
Assert(sophie.Presentation == StoryIntelligenceIllustrationCompatibility.Presentations.Feminine, $"Sophie/she/sister evidence should infer Feminine, got {sophie.Presentation}.");
Assert(gregFromRosieAction.Presentation != StoryIntelligenceIllustrationCompatibility.Presentations.Feminine, "Greg must not inherit feminine presentation from action text mentioning Rosie.");
Assert(judyName.Presentation == StoryIntelligenceIllustrationCompatibility.Presentations.Feminine, $"Common feminine name Judy should infer Feminine, got {judyName.Presentation}.");
Assert(philName.Presentation == StoryIntelligenceIllustrationCompatibility.Presentations.Masculine, $"Common masculine name Phil should infer Masculine, got {philName.Presentation}.");
Assert(Rejected(mum, childCandidate).Any(reason => reason.Contains("age hard mismatch", StringComparison.OrdinalIgnoreCase)), "Parent role should reject child portraits.");
Assert(Rejected(redHairedMaggie, darkRedCandidate).Any(reason => reason.Contains("skin tone hard mismatch", StringComparison.OrdinalIgnoreCase)), "Red-haired light-skin evidence must reject dark-skin generated 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.LocationCompatible("Street", "Road"), "Side streets should be able to reuse road artwork while demand-specific street art is pending.");
Assert(StoryIntelligenceIllustrationCompatibility.LocationCompatible("Road", "Street"), "Named roads should be able to reuse street artwork when it is the closest generated road-family image.");
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 adminController = File.ReadAllText(Path.Combine(root, "Controllers/AdminController.cs"));
var adminDiagnostics = File.ReadAllText(Path.Combine(root, "Views/Admin/StoryIntelligenceDiagnostics.cshtml"));
var program = File.ReadAllText(Path.Combine(root, "Program.cs"));
var storyMemory = File.ReadAllText(Path.Combine(root, "Services/StoryMemoryServices.cs"));
var matchingService = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceIllustrationMatchingService.cs"));
var snapshot = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceVisualisationSnapshotService.cs"));
var developmentReset = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceDevelopmentResetService.cs"));
var persistedRunner = File.ReadAllText(Path.Combine(root, "Services/PersistedStoryIntelligenceRunner.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(onboarding.Contains("name=\"primaryAgeGroup\"", StringComparison.Ordinal), "Import setup must ask for primary age group.");
Assert(onboarding.Contains("name=\"storyEra\"", StringComparison.Ordinal), "Import setup must ask for era.");
Assert(onboarding.Contains("name=\"storyLocation\"", StringComparison.Ordinal), "Import setup must ask for location.");
Assert(onboarding.Contains("<select class=\"form-control\" name=\"appearancePreference\"", StringComparison.Ordinal), "Appearance guidance must be a dropdown, not a radio list.");
Assert(onboarding.Contains("<select class=\"form-control\" name=\"primaryAgeGroup\"", StringComparison.Ordinal), "Primary age group must be a dropdown.");
Assert(onboarding.Contains("<select class=\"form-control\" name=\"storyEra\"", StringComparison.Ordinal), "Story era must be a dropdown.");
Assert(onboarding.Contains("<select class=\"form-control\" name=\"storyLocation\"", StringComparison.Ordinal), "Story location must be a dropdown.");
Assert(storyMemory.Contains("CharacterAppearanceExtractionService.Extract", StringComparison.Ordinal), "Durable Story Memory must consume dedicated appearance extraction.");
Assert(storyMemory.Contains("SkinToneInstruction(skinTone)", StringComparison.Ordinal), "Demand generation must use hard skin-tone wording.");
Assert(storyMemory.Contains("HairColours.Red", StringComparison.Ordinal) && storyMemory.Contains("SkinTones.Light", StringComparison.Ordinal), "Red-hair demand evidence must force light skin when skin is otherwise unknown.");
Assert(storyMemory.Contains("ApplyPrimaryAgeContext", StringComparison.Ordinal), "Primary age-group context must guide generic adult/unknown character ages.");
Assert(persistedRunner.Contains("QueueDemand = true", StringComparison.Ordinal), "Live imports must queue missing illustration demand, not only record it.");
Assert(persistedRunner.Contains("MaxDemandToQueue = 24", StringComparison.Ordinal), "Live imports must cap automatic demand generation.");
Assert(storyMemory.Contains("UpsertPlannedAsync(specification", StringComparison.Ordinal), "Durable Story Memory demand must create reusable illustration library items.");
Assert(storyMemory.Contains("QueueAsync(planned.IllustrationLibraryItemID", StringComparison.Ordinal), "Durable Story Memory demand must queue generated library items for the worker.");
Assert(storyMemory.Contains("demand.IllustrationLibraryItemId", StringComparison.Ordinal), "Demand fallback assignments must retain queued item IDs so generated images can attach later.");
Assert(storyMemory.Contains("var categoryCap = maxDemandToQueue;", StringComparison.Ordinal), "Automatic demand generation should cap in-flight work per category, not starve locations and assets behind a divided cap.");
Assert(storyMemory.Contains("or IllustrationLibraryStatuses.Generating);", StringComparison.Ordinal), "Demand generation cap must count only in-flight jobs, not already generated preserved images.");
Assert(!storyMemory.Contains("or IllustrationLibraryStatuses.Generated\r\n or IllustrationLibraryStatuses.Approved", StringComparison.Ordinal), "Generated or approved preserved images must not permanently fill the demand queue cap.");
Assert(storyMemory.Contains("\"Flyover\"", StringComparison.Ordinal) && storyMemory.Contains("concrete overpass", StringComparison.Ordinal), "Location prompts must distinguish road-under-flyover scenes from generic roads.");
Assert(storyMemory.Contains("semanticType is \"Road\" or \"Lane\" or \"Street\"", StringComparison.Ordinal), "Road-family locations must be treated as exterior scenes.");
Assert(matchingService.Contains("type is \"Road\" or \"Lane\" or \"Street\"", StringComparison.Ordinal), "Live location demand generation must treat lane/street/road as exterior transport scenes.");
Assert(matchingService.Contains("no indoor corridor", StringComparison.Ordinal), "Road-family location prompts must explicitly reject indoor corridor imagery.");
Assert(storyMemory.Contains("evidence.HairColour", StringComparison.Ordinal), "Character demand keys/specs must include durable hair colour evidence.");
Assert(storyMemory.Contains("Maximum one primary POV", StringComparison.Ordinal) || storyMemory.Contains("ResolveScenePov", StringComparison.Ordinal), "Story Memory must use a single POV resolver.");
Assert(!snapshot.Contains(".Where(character => !sceneCharacterNames.Contains(character.Name))", StringComparison.Ordinal), "Visualisation must not fill scene character slots from memory-only characters.");
Assert(snapshot.Contains("PovResolutionReason = resolved is null ? \"unresolved narrator\" : \"inherited same-chapter POV\"", StringComparison.Ordinal), "Generic narrators should inherit only within the current chapter.");
Assert(!snapshot.Contains("?? bookNarratorName\r\n ?? narratorResolvedName", StringComparison.Ordinal), "Generic narrator resolution must not fall through to stale book-level narrator identity.");
Assert(snapshot.Contains("BuildObservations(scene, resolvedPovName)", StringComparison.Ordinal), "Observation cards should receive the resolved POV identity.");
Assert(snapshot.Contains("NamedPovText", StringComparison.Ordinal), "Observation card text should replace generic narrator wording when POV is known.");
Assert(program.Contains("IStoryIntelligenceDevelopmentResetService", StringComparison.Ordinal), "Admin reset service must be registered.");
Assert(adminController.Contains("ResetStoryIntelligenceImports", StringComparison.Ordinal), "Admin diagnostics must expose the reset post action.");
Assert(adminDiagnostics.Contains("Reset my Story Intelligence data", StringComparison.Ordinal), "Admin diagnostics page must show the reset button.");
Assert(developmentReset.Contains("PreservedIllustrationItems", StringComparison.Ordinal), "Admin reset result must report preserved generated images.");
Assert(!developmentReset.Contains("DELETE FROM dbo.IllustrationLibraryItems", StringComparison.Ordinal), "Admin reset must preserve generated illustration library records.");
Assert(!script.Contains("renderPresenceCallout", StringComparison.Ordinal), "Stage should not draw a bordered character container.");
Assert(script.Contains("drawConnection(id, cluster, locationPoint, \"presence-cluster\", 54, `${presentCharacters.length} here`", StringComparison.Ordinal), "Presence connector should show the location join label.");
Assert(!css.Contains(".story-exp-presence-callout", StringComparison.Ordinal), "Character container styling should be removed.");
Assert(css.Contains("white-space: normal", StringComparison.Ordinal), "Location captions should wrap rather than ellipsise.");
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.");
Assert(File.Exists(Path.Combine(root, "Sql/144_Phase21V_StoryMemoryImportContext.sql")), "Import context SQL migration is missing.");
}
static void Phase21RSceneBrowserAndPanelsAreReplayFriendly()
{
var view = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Views/Development/StoryIntelligenceExperience.cshtml"));
var script = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/wwwroot/js/story-intelligence-experience-prototype.js"));
var css = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/wwwroot/css/story-intelligence-experience-prototype.css"));
var progressViewModel = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/ViewModels/OnboardingViewModels.cs"));
Assert(view.Contains("mode=replay", StringComparison.Ordinal), "Replay links should open the visualisation in replay mode.");
Assert(view.Contains("data-replay-chapter", StringComparison.Ordinal), "Replay scene browser should expose chapter selection.");
Assert(view.Contains("data-detail-panel", StringComparison.Ordinal), "Dense side-panel items should have an expanded detail presentation.");
Assert(view.Contains("Rebuild Visualisation from Saved Analysis", StringComparison.Ordinal), "Replay mode should expose the development rebuild action.");
Assert(script.Contains("const replayMode = root.dataset.mode === \"replay\"", StringComparison.Ordinal), "Browser script should distinguish replay mode.");
Assert(script.Contains("setupReplayBrowser", StringComparison.Ordinal), "Replay browser setup was not found.");
Assert(script.Contains("root.dataset.failedChapters", StringComparison.Ordinal), "Failed chapters should appear as replay browser gaps.");
Assert(script.Contains("if (!realMode || !snapshotUrl || state.terminal) return;", StringComparison.Ordinal), "Replay must not poll live snapshot endpoints.");
Assert(css.Contains(".story-exp-replay-browser", StringComparison.Ordinal), "Replay browser styling was not found.");
Assert(css.Contains("scrollbar-width: none", StringComparison.Ordinal), "Side-panel native scrollbar tracks should be suppressed.");
Assert(css.Contains(".story-exp-rebuild {\n position: fixed;", StringComparison.Ordinal), "Replay rebuild controls should be fixed so they do not resize the whole screen.");
Assert(css.Contains(".story-exp-panel:has(.story-exp-insight-list:empty)", StringComparison.Ordinal), "Empty side panels should collapse instead of reserving large blank space.");
Assert(css.Contains("grid-template-columns: minmax(180px, 0.42fr) minmax(660px, 1.55fr) minmax(380px, 0.84fr);", StringComparison.Ordinal), "Right insight rail should be widened now that assets are not stage nodes.");
Assert(css.Contains("--story-side-body-font-size: 0.72rem;", StringComparison.Ordinal), "Left and right side panels should share body text sizing.");
Assert(!view.Contains("data-assets", StringComparison.Ordinal), "Asset visualisation lane should not be rendered on the main stage.");
Assert(script.Contains("Assets are recorded in insights/diagnostics but not rendered as stage nodes.", StringComparison.Ordinal), "Asset data should remain available without drawing asset stage nodes.");
Assert(script.Contains("width * 0.82 - size / 2", StringComparison.Ordinal), "Location should be a larger right-side anchor once asset nodes are removed.");
Assert(script.Contains("{ x: 0.68, y: 0.26, role: \"context\" }", StringComparison.Ordinal), "Character layout should use more of the stage width.");
Assert(script.Contains("scene.relationships", StringComparison.Ordinal), "Relationship lines should be rendered on the stage.");
Assert(script.Contains("presence-cluster", StringComparison.Ordinal), "Presence should still be shown as a location join.");
Assert(css.Contains(".story-exp-zone--location .story-exp-zone-label {\n left: 80%;", StringComparison.Ordinal), "Location lane label should follow the compact right-side anchor.");
Assert(!css.Contains("grid-template-rows: minmax(218px, 1.35fr) minmax(185px, 1.08fr) auto", StringComparison.Ordinal), "Right rail must not use fixed-height panel tracks.");
Assert(!css.Contains("grid-template-rows: auto auto minmax(170px, 1fr) minmax(96px, 0.56fr)", StringComparison.Ordinal), "Left rail must not use fixed-height panel tracks.");
Assert(script.Contains("shouldUseCharacterPortrait", StringComparison.Ordinal), "Characters should use evidence cards until portrait evidence is strong enough.");
Assert(script.Contains("shouldUseAssetImage", StringComparison.Ordinal), "Assets should use text cards until an object-specific image is confident enough.");
Assert(script.Contains("appearance pending", StringComparison.Ordinal), "Character evidence cards should expose unresolved appearance state.");
Assert(!css.Contains(".story-exp-insight-list {\n display: grid;\n gap: 9px;\n min-height: 0;\n overflow: auto", StringComparison.Ordinal), "Nested insight lists should not create native scrollbars.");
Assert(progressViewModel.Contains("public int? ImportSessionID", StringComparison.Ordinal), "Import progress page should carry the import session for replay links.");
}
static void StoryIntelligenceVisualPolishKeepsLabelsFixedAndSemanticsSpecific()
{
var script = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/wwwroot/js/story-intelligence-experience-prototype.js"));
var css = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/wwwroot/css/story-intelligence-experience-prototype.css"));
var snapshot = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Services/StoryIntelligenceVisualisationSnapshotService.cs"));
Assert(!script.Contains("node.dataset.labelSide", StringComparison.Ordinal), "Character labels must not use dynamic side positioning.");
Assert(!css.Contains(".story-character[data-label-side=\"left\"]", StringComparison.Ordinal), "Left-floating character labels must be removed.");
Assert(!css.Contains(".story-character[data-layout-role=\"partner\"] .story-character__text", StringComparison.Ordinal), "Partner labels must not float beside portraits.");
Assert(css.Contains("top: calc(var(--node-size, 150px) + 8px)", StringComparison.Ordinal), "Character captions should be fixed below portraits.");
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") == "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") == "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.");
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("passport") == "Passport", "Passport should not be Generic Object.");
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("sealed envelope") == "Envelope", "Envelope should not be Generic Object.");
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("Highway Code book") == "DrivingManual", "Highway Code should classify as a driving manual.");
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("traffic sign") == "TrafficSign", "Traffic sign should classify as TrafficSign.");
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("wall clock") == "Clock", "Clock should classify as Clock.");
Assert(StoryIntelligenceIllustrationCompatibility.AssetType("park bench") == "Bench", "Bench should classify as Bench.");
Assert(snapshot.Contains("type is \"Road\" or \"Lane\" or \"Street\"", StringComparison.Ordinal), "Road-family scene locations need a generic road fallback illustration.");
Assert(snapshot.Contains("\"loc-vehicle-interior\" => $\"{FallbackRoot}/location-vehicle-interior.svg\"", StringComparison.Ordinal), "Vehicle interiors need a non-building fallback illustration.");
Assert(snapshot.Contains("\"asset-document\" => $\"{FallbackRoot}/asset-document.svg\"", StringComparison.Ordinal), "Documents need a recognisable document fallback.");
Assert(snapshot.Contains("\"asset-traffic-sign\" => $\"{FallbackRoot}/asset-traffic-sign.svg\"", StringComparison.Ordinal), "Traffic signs need a recognisable fallback.");
}
static JsonSerializerOptions JsonOptions() static JsonSerializerOptions JsonOptions()
=> new() => new()
{ {
@ -328,3 +1277,9 @@ static void Assert(bool condition, string message)
throw new InvalidOperationException(message); throw new InvalidOperationException(message);
} }
} }
internal sealed class CaptureImageRequestHandler(Func<HttpRequestMessage, HttpResponseMessage> respond) : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
=> Task.FromResult(respond(request));
}

View File

@ -13,13 +13,15 @@ public sealed class AdminController(
IFeatureRequestService featureRequests, IFeatureRequestService featureRequests,
IEmailService emails, IEmailService emails,
IStoryIntelligenceDiagnosticsService storyIntelligenceDiagnostics, IStoryIntelligenceDiagnosticsService storyIntelligenceDiagnostics,
IStoryIntelligenceDevelopmentResetService storyIntelligenceDevelopmentReset,
IStoryIntelligenceDryRunService storyIntelligenceDryRun, IStoryIntelligenceDryRunService storyIntelligenceDryRun,
IChapterStructureDryRunService chapterStructureDryRun, IChapterStructureDryRunService chapterStructureDryRun,
IChapterStoryIntelligenceDryRunService chapterStoryIntelligenceDryRun, IChapterStoryIntelligenceDryRunService chapterStoryIntelligenceDryRun,
IStoryIntelligenceResultPersistenceService storyIntelligenceResults, IStoryIntelligenceResultPersistenceService storyIntelligenceResults,
IStoryIntelligenceExistingChapterQueueService existingChapterQueue, IStoryIntelligenceExistingChapterQueueService existingChapterQueue,
IStoryIntelligenceFullChapterTestService fullChapterTest, IStoryIntelligenceFullChapterTestService fullChapterTest,
IStoryIntelligenceImportCommitService storyIntelligenceImportCommit) : Controller IStoryIntelligenceImportCommitService storyIntelligenceImportCommit,
IIllustrationLibraryService illustrationLibrary) : Controller
{ {
[HttpGet("")] [HttpGet("")]
[HttpGet("index")] [HttpGet("index")]
@ -37,12 +39,141 @@ public sealed class AdminController(
return View(); return View();
} }
[HttpGet("story-intelligence/illustration-library")]
public async Task<IActionResult> StoryIntelligenceIllustrationLibrary([FromQuery] IllustrationLibraryFilter filter)
{
return View(await illustrationLibrary.BuildAdminViewModelAsync(filter));
}
[HttpPost("story-intelligence/illustration-library/starter-plan")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> CreateIllustrationStarterPlan()
{
var result = await illustrationLibrary.EnsureStarterPlanAsync();
TempData[result.Succeeded ? "AdminMessage" : "AdminError"] = result.Message;
return RedirectToAction(nameof(StoryIntelligenceIllustrationLibrary));
}
[HttpPost("story-intelligence/illustration-library/generate-starter")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> GenerateIllustrationStarterLibrary(IllustrationStarterGenerationForm form)
{
var result = await illustrationLibrary.GenerateStarterLibraryAsync(form.Confirmed);
TempData[result.Succeeded ? "AdminMessage" : "AdminError"] = result.Message;
return RedirectToAction(nameof(StoryIntelligenceIllustrationLibrary));
}
[HttpPost("story-intelligence/illustration-library/queue")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> QueueIllustrationLibraryItems(IllustrationLibraryQueueForm form)
{
var result = await illustrationLibrary.QueueAsync(null, form.Category, form.Confirmed);
TempData[result.Succeeded ? "AdminMessage" : "AdminError"] = result.Message;
return RedirectToAction(nameof(StoryIntelligenceIllustrationLibrary), new { category = form.Category });
}
[HttpPost("story-intelligence/illustration-library/requeue-failed")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> RequeueFailedIllustrationLibraryItems(IllustrationLibraryBulkRetryForm form)
{
var result = await illustrationLibrary.RequeueAllFailedAsync();
TempData[result.QueuedCount > 0 ? "AdminMessage" : "AdminError"] = result.Message;
return RedirectToAction(nameof(StoryIntelligenceIllustrationLibrary), new { category = form.Category, status = form.Status, search = form.Search });
}
[HttpPost("story-intelligence/illustration-library/{id:int}/queue")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> QueueIllustrationLibraryItem(int id, IllustrationLibraryQueueForm form)
{
var result = await illustrationLibrary.QueueAsync(id, null, form.Confirmed);
TempData[result.Succeeded ? "AdminMessage" : "AdminError"] = result.Message;
return RedirectToAction(nameof(StoryIntelligenceIllustrationLibrary), PreserveIllustrationLibraryFilter(form));
}
[HttpPost("story-intelligence/illustration-library/{id:int}/approve")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> ApproveIllustrationLibraryItem(int id, IllustrationLibraryQueueForm form)
{
if (currentUser.UserId is not int userId)
{
return Forbid();
}
var result = await illustrationLibrary.ApproveAsync(id, userId);
TempData[result.Succeeded ? "AdminMessage" : "AdminError"] = result.Message;
return RedirectToAction(nameof(StoryIntelligenceIllustrationLibrary), PreserveIllustrationLibraryFilter(form));
}
[HttpPost("story-intelligence/illustration-library/{id:int}/reject")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> RejectIllustrationLibraryItem(int id, IllustrationLibraryRejectForm form)
{
if (currentUser.UserId is not int userId)
{
return Forbid();
}
var result = await illustrationLibrary.RejectAsync(id, userId, form.Reason);
TempData[result.Succeeded ? "AdminMessage" : "AdminError"] = result.Message;
return RedirectToAction(nameof(StoryIntelligenceIllustrationLibrary), PreserveIllustrationLibraryFilter(form));
}
[HttpPost("story-intelligence/illustration-library/{id:int}/regenerate")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> RegenerateIllustrationLibraryItem(int id, IllustrationLibraryQueueForm form)
{
var result = await illustrationLibrary.RegenerateAsync(id, form.Confirmed);
TempData[result.Succeeded ? "AdminMessage" : "AdminError"] = result.Message;
return RedirectToAction(nameof(StoryIntelligenceIllustrationLibrary), PreserveIllustrationLibraryFilter(form));
}
[HttpPost("story-intelligence/illustration-library/{id:int}/disable")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> DisableIllustrationLibraryItem(int id, IllustrationLibraryQueueForm form)
{
var result = await illustrationLibrary.DisableAsync(id);
TempData[result.Succeeded ? "AdminMessage" : "AdminError"] = result.Message;
return RedirectToAction(nameof(StoryIntelligenceIllustrationLibrary), PreserveIllustrationLibraryFilter(form));
}
[HttpPost("story-intelligence/illustration-library/{id:int}/metadata")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> UpdateIllustrationLibraryMetadata(int id, IllustrationLibraryMetadataForm form)
{
var result = await illustrationLibrary.UpdateMetadataAsync(id, form);
TempData[result.Succeeded ? "AdminMessage" : "AdminError"] = result.Message;
return RedirectToAction(nameof(StoryIntelligenceIllustrationLibrary), PreserveIllustrationLibraryFilter(form));
}
private static object PreserveIllustrationLibraryFilter(IllustrationLibraryQueueForm form)
=> new { category = form.Category, status = form.Status, search = form.Search };
private static object PreserveIllustrationLibraryFilter(IllustrationLibraryRejectForm form)
=> new { category = form.Category, status = form.Status, search = form.Search };
private static object PreserveIllustrationLibraryFilter(IllustrationLibraryMetadataForm form)
=> new { category = form.Category, status = form.Status, search = form.Search };
[HttpGet("story-intelligence-diagnostics")] [HttpGet("story-intelligence-diagnostics")]
public async Task<IActionResult> StoryIntelligenceDiagnostics(CancellationToken cancellationToken) public async Task<IActionResult> StoryIntelligenceDiagnostics(CancellationToken cancellationToken)
{ {
return View(await storyIntelligenceDiagnostics.GetDiagnosticsAsync(cancellationToken)); return View(await storyIntelligenceDiagnostics.GetDiagnosticsAsync(cancellationToken));
} }
[HttpPost("story-intelligence-diagnostics/reset-imports")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> ResetStoryIntelligenceImports(CancellationToken cancellationToken)
{
if (currentUser.UserId is not int userId)
{
return Forbid();
}
var result = await storyIntelligenceDevelopmentReset.ResetOwnedProjectsAndImportsAsync(userId, cancellationToken);
TempData[result.Success ? "AdminMessage" : "AdminError"] = result.Message;
return RedirectToAction(nameof(StoryIntelligenceDiagnostics));
}
[HttpGet("story-intelligence-test")] [HttpGet("story-intelligence-test")]
public IActionResult StoryIntelligenceTest() public IActionResult StoryIntelligenceTest()
{ {

View File

@ -0,0 +1,193 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using PlotLine.Data;
using PlotLine.Models;
using PlotLine.Services;
using PlotLine.ViewModels;
namespace PlotLine.Controllers;
[Authorize(Policy = "AdminOnly")]
[Route("Development")]
public sealed class DevelopmentController(
IWebHostEnvironment environment,
ICurrentUserService currentUser,
IIllustrationLibraryService illustrationLibrary,
IStoryMemoryService storyMemory,
IStoryMemoryRepository storyMemoryRepository,
IStoryIntelligenceVisualisationSnapshotService snapshots) : Controller
{
[HttpGet("StoryIntelligenceExperience")]
public async Task<IActionResult> StoryIntelligenceExperience([FromQuery] string? mode, [FromQuery] int? importSessionId, [FromQuery] int? runId)
{
if (!environment.IsDevelopment())
{
return NotFound();
}
var requestedMode = string.IsNullOrWhiteSpace(mode) ? "real" : mode.Trim();
var simulationRequested = string.Equals(requestedMode, "simulation", StringComparison.OrdinalIgnoreCase);
var replayRequested = string.Equals(requestedMode, "replay", StringComparison.OrdinalIgnoreCase);
if (!simulationRequested && importSessionId.HasValue && currentUser.UserId is int userId)
{
var realModel = replayRequested
? await snapshots.BuildReplaySnapshotAsync(importSessionId.Value, userId)
: await snapshots.BuildImportSessionSnapshotAsync(importSessionId.Value, userId);
if (realModel is null)
{
return View(ErrorModel(
requestedMode,
importSessionId,
$"Import Session {importSessionId.Value:N0} could not be found for the current user, or it has no persisted chapter analysis runs yet."));
}
return View(realModel);
}
if (!simulationRequested && runId.HasValue && currentUser.UserId is int legacyUserId)
{
var realModel = await snapshots.BuildSnapshotAsync(runId.Value, legacyUserId);
if (realModel is null)
{
return View(ErrorModel(
requestedMode,
null,
$"Run {runId.Value:N0} could not be found for the current user. Live visualisation URLs should use importSessionId for book imports."));
}
return View(realModel);
}
if (simulationRequested)
{
var model = StoryIntelligenceExperiencePrototypeData.Build();
await illustrationLibrary.ApplyApprovedIllustrationsAsync(model);
return View(model);
}
if (currentUser.UserId is int selectorUserId)
{
var sessions = await snapshots.ListImportSessionOptionsAsync(selectorUserId);
return View(new StoryIntelligenceExperiencePrototypeViewModel
{
Mode = "selector",
AvailableRuns = sessions,
ProjectName = "Development",
BookTitle = "Story Intelligence",
SnapshotMessage = sessions.Count == 0
? "No persisted Story Intelligence import sessions were found for the current user. Start a real import, then refresh this page."
: "Choose a Story Intelligence import session, or explicitly open simulation mode.",
Diagnostics = new StoryIntelligenceExperienceSnapshotDiagnostics
{
RequestedMode = requestedMode,
RequestedImportSessionId = importSessionId,
SnapshotSource = "Real"
}
});
}
return View(ErrorModel(requestedMode, importSessionId, "No development user is signed in."));
}
[HttpPost("StoryIntelligenceExperience/Rebuild")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> RebuildStoryIntelligenceExperience(
[FromForm] int importSessionId,
[FromForm] string? rebuildScope,
[FromForm] bool dryRun = false,
[FromForm] bool queueDemand = false,
[FromForm] int? maxDemandToQueue = null,
[FromForm] string? appearancePreference = null)
{
if (!environment.IsDevelopment())
{
return NotFound();
}
if (importSessionId <= 0 || currentUser.UserId is not int userId)
{
return BadRequest("A valid importSessionId is required.");
}
var rebuild = await storyMemory.RebuildImportSessionAsync(
importSessionId,
userId,
new StoryMemoryRebuildOptions
{
DryRun = dryRun,
RebuildAll = !string.Equals(rebuildScope, "incremental", StringComparison.OrdinalIgnoreCase),
QueueDemand = queueDemand,
MaxDemandToQueue = maxDemandToQueue,
AppearancePreference = string.IsNullOrWhiteSpace(appearancePreference)
? StoryMemoryAppearancePreferences.Balanced
: appearancePreference.Trim()
},
HttpContext.RequestAborted);
if (rebuild.DryRun)
{
TempData["StoryIntelligenceReplayMessage"] = $"Dry run complete. {rebuild.SceneResultsRead:N0} stored scene result(s), {rebuild.SceneResultsProcessed:N0} parseable. No database changes, AI calls, or image credits.";
}
else
{
TempData["StoryIntelligenceReplayMessage"] = $"Durable Story Memory rebuilt. {rebuild.SceneResultsProcessed:N0} scene result(s) processed, {rebuild.AssignmentsCreatedOrValidated:N0} assignment(s), {rebuild.DemandsCreated:N0} demand record(s), {rebuild.AiCalls:N0} AI calls.";
}
return RedirectToAction(nameof(StoryIntelligenceExperience), new { importSessionId, mode = "replay" });
}
[HttpGet("StoryMemoryDiagnostics")]
public async Task<IActionResult> StoryMemoryDiagnostics([FromQuery] int importSessionId)
{
if (!environment.IsDevelopment())
{
return NotFound();
}
if (importSessionId <= 0)
{
return BadRequest("A valid importSessionId is required.");
}
var diagnostics = await storyMemoryRepository.GetDiagnosticsAsync(importSessionId);
return Json(diagnostics);
}
[HttpGet("StoryIntelligenceIllustrationDiagnostics")]
public async Task<IActionResult> StoryIntelligenceIllustrationDiagnostics([FromQuery] int importSessionId)
{
if (!environment.IsDevelopment())
{
return NotFound();
}
if (importSessionId <= 0 || currentUser.UserId is not int userId)
{
return BadRequest("A valid importSessionId is required.");
}
var model = await snapshots.BuildImportSessionSnapshotAsync(importSessionId, userId);
if (model is null)
{
return NotFound($"Import Session {importSessionId:N0} could not be found for the current user, or it has no persisted chapter analysis runs yet.");
}
return View(model);
}
private static StoryIntelligenceExperiencePrototypeViewModel ErrorModel(string requestedMode, int? importSessionId, string message)
=> new()
{
Mode = "error",
ProjectName = "Development",
BookTitle = "Story Intelligence",
SnapshotMessage = message,
Diagnostics = new StoryIntelligenceExperienceSnapshotDiagnostics
{
RequestedMode = requestedMode,
RequestedImportSessionId = importSessionId,
SnapshotSource = "Real",
Warning = message
}
};
}

View File

@ -86,9 +86,13 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
[HttpPost("story-intelligence/start")] [HttpPost("story-intelligence/start")]
[ValidateAntiForgeryToken] [ValidateAntiForgeryToken]
public async Task<IActionResult> StartStoryIntelligence() public async Task<IActionResult> StartStoryIntelligence(
[FromForm] string? appearancePreference,
[FromForm] string? primaryAgeGroup,
[FromForm] string? storyEra,
[FromForm] string? storyLocation)
{ {
var job = await storyIntelligence.StartAsync(); var job = await storyIntelligence.StartAsync(appearancePreference, primaryAgeGroup, storyEra, storyLocation);
if (job is null) if (job is null)
{ {
TempData["OnboardingStoryIntelligenceError"] = "Review the manuscript scan before starting Story Intelligence."; TempData["OnboardingStoryIntelligenceError"] = "Review the manuscript scan before starting Story Intelligence.";
@ -490,6 +494,8 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
} }
[HttpPost("scan-review")] [HttpPost("scan-review")]
[RequestSizeLimit(25 * 1024 * 1024)]
[RequestFormLimits(ValueCountLimit = 20000, KeyLengthLimit = 4096, ValueLengthLimit = 1024 * 1024)]
[ValidateAntiForgeryToken] [ValidateAntiForgeryToken]
public async Task<IActionResult> SaveScanReview(ManuscriptScanReviewForm form, string intent = "save") public async Task<IActionResult> SaveScanReview(ManuscriptScanReviewForm form, string intent = "save")
{ {

View File

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

View File

@ -0,0 +1,214 @@
using System.Data;
using Dapper;
using PlotLine.Models;
namespace PlotLine.Data;
public interface IIllustrationLibraryRepository
{
Task<IReadOnlyList<IllustrationLibraryItem>> ListAsync(IllustrationLibraryFilter filter);
Task<IllustrationLibrarySummary> GetSummaryAsync();
Task<IllustrationLibraryItem?> GetAsync(int illustrationLibraryItemId);
Task<IllustrationLibraryItem> UpsertPlannedAsync(IllustrationGenerationSpecification specification, IllustrationPromptBuildResult prompt);
Task<int> QueueAsync(int? illustrationLibraryItemId, string? category);
Task<IllustrationLibraryItem?> ClaimNextAsync();
Task MarkGeneratedAsync(int illustrationLibraryItemId, IllustrationStoredImage image, IllustrationImageGenerationResult providerResult, string prompt, string templateVersion);
Task MarkFailedAsync(int illustrationLibraryItemId, string errorMessage);
Task ApproveAsync(int illustrationLibraryItemId, int userId);
Task RejectAsync(int illustrationLibraryItemId, int userId, string? reason);
Task<IllustrationLibraryItem?> CreateRegenerationAsync(int illustrationLibraryItemId, string? adminNotes);
Task DisableAsync(int illustrationLibraryItemId);
Task UpdateMetadataAsync(int illustrationLibraryItemId, string displayTitle, string? shortDescription, string? metadataJson, string? adminNotes);
Task<IReadOnlyList<IllustrationLibraryItem>> GetApprovedAsync();
Task<int> SupersedeRecoveredFailuresAsync();
}
public sealed class IllustrationLibraryRepository(ISqlConnectionFactory connectionFactory) : IIllustrationLibraryRepository
{
public async Task<IReadOnlyList<IllustrationLibraryItem>> ListAsync(IllustrationLibraryFilter filter)
{
using var connection = connectionFactory.CreateConnection();
var rows = await connection.QueryAsync<IllustrationLibraryItem>(
"dbo.IllustrationLibraryItem_List",
new
{
Category = string.IsNullOrWhiteSpace(filter.Category) ? null : filter.Category,
Status = string.IsNullOrWhiteSpace(filter.Status) ? null : filter.Status,
Search = string.IsNullOrWhiteSpace(filter.Search) ? null : filter.Search
},
commandType: CommandType.StoredProcedure);
return rows.ToList();
}
public async Task<IllustrationLibrarySummary> GetSummaryAsync()
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleAsync<IllustrationLibrarySummary>(
"dbo.IllustrationLibraryItem_Summary",
commandType: CommandType.StoredProcedure);
}
public async Task<IllustrationLibraryItem?> GetAsync(int illustrationLibraryItemId)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleOrDefaultAsync<IllustrationLibraryItem>(
"dbo.IllustrationLibraryItem_Get",
new { IllustrationLibraryItemID = illustrationLibraryItemId },
commandType: CommandType.StoredProcedure);
}
public async Task<IllustrationLibraryItem> UpsertPlannedAsync(IllustrationGenerationSpecification specification, IllustrationPromptBuildResult prompt)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleAsync<IllustrationLibraryItem>(
"dbo.IllustrationLibraryItem_UpsertPlanned",
new
{
Category = specification.Category,
StableCode = specification.Code,
DisplayTitle = specification.Title,
ShortDescription = specification.Description,
MetadataJson = prompt.MetadataJson,
SpecificationJson = prompt.SpecificationJson
},
commandType: CommandType.StoredProcedure);
}
public async Task<int> QueueAsync(int? illustrationLibraryItemId, string? category)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleAsync<int>(
"dbo.IllustrationLibraryItem_Queue",
new { IllustrationLibraryItemID = illustrationLibraryItemId, Category = category },
commandType: CommandType.StoredProcedure);
}
public async Task<IllustrationLibraryItem?> ClaimNextAsync()
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleOrDefaultAsync<IllustrationLibraryItem>(
"dbo.IllustrationLibraryItem_ClaimNext",
commandType: CommandType.StoredProcedure);
}
public async Task MarkGeneratedAsync(int illustrationLibraryItemId, IllustrationStoredImage image, IllustrationImageGenerationResult providerResult, string prompt, string templateVersion)
{
using var connection = connectionFactory.CreateConnection();
await connection.ExecuteAsync(
"dbo.IllustrationLibraryItem_MarkGenerated",
new
{
IllustrationLibraryItemID = illustrationLibraryItemId,
image.FilePath,
image.ThumbnailPath,
image.Width,
image.Height,
image.FileSizeBytes,
image.MimeType,
Provider = providerResult.Provider,
Model = providerResult.Model,
PromptText = prompt,
PromptTemplateVersion = templateVersion,
ProviderRequestID = providerResult.ProviderRequestID
},
commandType: CommandType.StoredProcedure);
}
public async Task MarkFailedAsync(int illustrationLibraryItemId, string errorMessage)
{
using var connection = connectionFactory.CreateConnection();
await connection.ExecuteAsync(
"dbo.IllustrationLibraryItem_MarkFailed",
new { IllustrationLibraryItemID = illustrationLibraryItemId, LastError = errorMessage },
commandType: CommandType.StoredProcedure);
}
public async Task ApproveAsync(int illustrationLibraryItemId, int userId)
{
using var connection = connectionFactory.CreateConnection();
await connection.ExecuteAsync(
"dbo.IllustrationLibraryItem_Approve",
new { IllustrationLibraryItemID = illustrationLibraryItemId, ApprovedByUserID = userId },
commandType: CommandType.StoredProcedure);
}
public async Task RejectAsync(int illustrationLibraryItemId, int userId, string? reason)
{
using var connection = connectionFactory.CreateConnection();
await connection.ExecuteAsync(
"dbo.IllustrationLibraryItem_Reject",
new { IllustrationLibraryItemID = illustrationLibraryItemId, RejectedByUserID = userId, RejectionReason = reason },
commandType: CommandType.StoredProcedure);
}
public async Task<IllustrationLibraryItem?> CreateRegenerationAsync(int illustrationLibraryItemId, string? adminNotes)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleOrDefaultAsync<IllustrationLibraryItem>(
"dbo.IllustrationLibraryItem_CreateRegeneration",
new { IllustrationLibraryItemID = illustrationLibraryItemId, AdminNotes = adminNotes },
commandType: CommandType.StoredProcedure);
}
public async Task DisableAsync(int illustrationLibraryItemId)
{
using var connection = connectionFactory.CreateConnection();
await connection.ExecuteAsync(
"dbo.IllustrationLibraryItem_Disable",
new { IllustrationLibraryItemID = illustrationLibraryItemId },
commandType: CommandType.StoredProcedure);
}
public async Task UpdateMetadataAsync(int illustrationLibraryItemId, string displayTitle, string? shortDescription, string? metadataJson, string? adminNotes)
{
using var connection = connectionFactory.CreateConnection();
await connection.ExecuteAsync(
"dbo.IllustrationLibraryItem_UpdateMetadata",
new
{
IllustrationLibraryItemID = illustrationLibraryItemId,
DisplayTitle = displayTitle,
ShortDescription = shortDescription,
MetadataJson = metadataJson,
AdminNotes = adminNotes
},
commandType: CommandType.StoredProcedure);
}
public async Task<IReadOnlyList<IllustrationLibraryItem>> GetApprovedAsync()
{
using var connection = connectionFactory.CreateConnection();
var rows = await connection.QueryAsync<IllustrationLibraryItem>(
"dbo.IllustrationLibraryItem_GetApproved",
commandType: CommandType.StoredProcedure);
return rows.ToList();
}
public async Task<int> SupersedeRecoveredFailuresAsync()
{
using var connection = connectionFactory.CreateConnection();
return await connection.ExecuteAsync(
"""
SET ANSI_NULLS ON;
SET QUOTED_IDENTIFIER ON;
UPDATE failed
SET Status = N'Superseded',
IsActive = 0,
UpdatedUtc = SYSUTCDATETIME(),
AdminNotes = CONCAT(ISNULL(NULLIF(failed.AdminNotes, N''), N''), CASE WHEN NULLIF(failed.AdminNotes, N'') IS NULL THEN N'' ELSE N' ' END, N'Superseded after a replacement illustration generated successfully.')
FROM dbo.IllustrationLibraryItems failed
WHERE failed.Status = N'Failed'
AND failed.IsActive = 1
AND EXISTS
(
SELECT 1
FROM dbo.IllustrationLibraryItems replacement
WHERE replacement.IllustrationLibraryItemID <> failed.IllustrationLibraryItemID
AND replacement.StableCode = failed.StableCode
AND replacement.IsActive = 1
AND replacement.Status IN (N'Generated', N'Approved')
);
""");
}
}

View File

@ -0,0 +1,117 @@
using Dapper;
using PlotLine.Models;
namespace PlotLine.Data;
public interface IStoryIntelligenceIllustrationMatchingRepository
{
Task<IReadOnlyList<StoryIntelligenceCharacterIllustrationAssignment>> ListAssignmentsAsync(int projectId);
Task<StoryIntelligenceCharacterIllustrationAssignment> UpsertAssignmentAsync(StoryIntelligenceCharacterIllustrationAssignmentSave request);
Task<StoryIntelligenceIllustrationDemand> UpsertDemandAsync(StoryIntelligenceIllustrationDemandRequest request);
}
public sealed class StoryIntelligenceIllustrationMatchingRepository(ISqlConnectionFactory connectionFactory) : IStoryIntelligenceIllustrationMatchingRepository
{
public async Task<IReadOnlyList<StoryIntelligenceCharacterIllustrationAssignment>> ListAssignmentsAsync(int projectId)
{
using var connection = connectionFactory.CreateConnection();
var rows = await connection.QueryAsync<StoryIntelligenceCharacterIllustrationAssignment>(
"""
SELECT
StoryIntelligenceCharacterIllustrationAssignmentID,
ProjectID,
CharacterKey,
CharacterName,
IllustrationLibraryItemID,
StableCode,
MatchConfidence,
MatchingScore,
CAST(NULL AS nvarchar(max)) AS EvidenceJson,
CAST(NULL AS nvarchar(max)) AS CandidateDiagnosticsJson,
ReasonChosen,
ReasonReassigned,
IsActive,
CreatedUtc,
UpdatedUtc,
AssignmentStatus,
AssignedUtc,
LastValidatedUtc,
EvidenceVersion,
IsFallback
FROM dbo.StoryIntelligenceCharacterIllustrationAssignments
WHERE ProjectID = @ProjectID
AND IsActive = 1;
""",
new { ProjectID = projectId });
return rows.ToList();
}
public async Task<StoryIntelligenceCharacterIllustrationAssignment> UpsertAssignmentAsync(StoryIntelligenceCharacterIllustrationAssignmentSave request)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleAsync<StoryIntelligenceCharacterIllustrationAssignment>(
"""
SET XACT_ABORT ON;
MERGE dbo.StoryIntelligenceCharacterIllustrationAssignments WITH (HOLDLOCK) AS target
USING (SELECT @ProjectID AS ProjectID, @CharacterKey AS CharacterKey) AS source
ON target.ProjectID = source.ProjectID
AND target.CharacterKey = source.CharacterKey
AND target.IsActive = 1
WHEN MATCHED THEN
UPDATE SET
CharacterName = @CharacterName,
IllustrationLibraryItemID = @IllustrationLibraryItemID,
StableCode = @StableCode,
MatchConfidence = @MatchConfidence,
MatchingScore = @MatchingScore,
EvidenceJson = @EvidenceJson,
CandidateDiagnosticsJson = @CandidateDiagnosticsJson,
ReasonChosen = @ReasonChosen,
ReasonReassigned = @ReasonReassigned,
AssignmentStatus = CASE WHEN @IllustrationLibraryItemID IS NULL THEN N'Fallback' ELSE N'Assigned' END,
LastValidatedUtc = SYSUTCDATETIME(),
EvidenceVersion = N'21K',
IsFallback = CASE WHEN @IllustrationLibraryItemID IS NULL THEN 1 ELSE 0 END,
UpdatedUtc = SYSUTCDATETIME()
WHEN NOT MATCHED THEN
INSERT (ProjectID, CharacterKey, CharacterName, IllustrationLibraryItemID, StableCode, MatchConfidence, MatchingScore, EvidenceJson, CandidateDiagnosticsJson, ReasonChosen, ReasonReassigned, AssignmentStatus, AssignedUtc, LastValidatedUtc, EvidenceVersion, IsFallback)
VALUES (@ProjectID, @CharacterKey, @CharacterName, @IllustrationLibraryItemID, @StableCode, @MatchConfidence, @MatchingScore, @EvidenceJson, @CandidateDiagnosticsJson, @ReasonChosen, @ReasonReassigned, CASE WHEN @IllustrationLibraryItemID IS NULL THEN N'Fallback' ELSE N'Assigned' END, SYSUTCDATETIME(), SYSUTCDATETIME(), N'21K', CASE WHEN @IllustrationLibraryItemID IS NULL THEN 1 ELSE 0 END);
SELECT *
FROM dbo.StoryIntelligenceCharacterIllustrationAssignments
WHERE ProjectID = @ProjectID
AND CharacterKey = @CharacterKey
AND IsActive = 1;
""",
request);
}
public async Task<StoryIntelligenceIllustrationDemand> UpsertDemandAsync(StoryIntelligenceIllustrationDemandRequest request)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleAsync<StoryIntelligenceIllustrationDemand>(
"""
SET XACT_ABORT ON;
MERGE dbo.StoryIntelligenceIllustrationDemands WITH (HOLDLOCK) AS target
USING (SELECT @ProjectID AS ProjectID, @DemandKey AS DemandKey) AS source
ON ISNULL(target.ProjectID, -1) = ISNULL(source.ProjectID, -1)
AND target.DemandKey = source.DemandKey
WHEN MATCHED THEN
UPDATE SET
RequestCount = RequestCount + 1,
LastRequestedUtc = SYSUTCDATETIME(),
UpdatedUtc = SYSUTCDATETIME()
WHEN NOT MATCHED THEN
INSERT (ProjectID, DemandKey, AgeBand, Presentation, HairColour, SkinTone, HairLength, Glasses, FacialHair)
VALUES (@ProjectID, @DemandKey, @AgeBand, @Presentation, @HairColour, @SkinTone, @HairLength, @Glasses, @FacialHair);
SELECT *
FROM dbo.StoryIntelligenceIllustrationDemands
WHERE ISNULL(ProjectID, -1) = ISNULL(@ProjectID, -1)
AND DemandKey = @DemandKey;
""",
request);
}
}

View File

@ -6,6 +6,7 @@ namespace PlotLine.Data;
public interface IStoryIntelligencePipelineRepository public interface IStoryIntelligencePipelineRepository
{ {
Task<StoryIntelligenceBookPipelineState?> GetByIdForUserAsync(int importSessionId, int userId);
Task<StoryIntelligenceBookPipelineState?> GetByBookAsync(int bookId); Task<StoryIntelligenceBookPipelineState?> GetByBookAsync(int bookId);
Task<StoryIntelligenceBookPipelineState?> GetByBookForUserAsync(int bookId, int userId); Task<StoryIntelligenceBookPipelineState?> GetByBookForUserAsync(int bookId, int userId);
Task<IReadOnlyList<StoryIntelligenceBookPipelineState>> ListForUserAsync(int userId); Task<IReadOnlyList<StoryIntelligenceBookPipelineState>> ListForUserAsync(int userId);
@ -15,6 +16,15 @@ public interface IStoryIntelligencePipelineRepository
public sealed class StoryIntelligencePipelineRepository(ISqlConnectionFactory connectionFactory) : IStoryIntelligencePipelineRepository public sealed class StoryIntelligencePipelineRepository(ISqlConnectionFactory connectionFactory) : IStoryIntelligencePipelineRepository
{ {
public async Task<StoryIntelligenceBookPipelineState?> GetByIdForUserAsync(int importSessionId, int userId)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleOrDefaultAsync<StoryIntelligenceBookPipelineState>(
"dbo.StoryIntelligenceBookPipeline_GetByIDForUser",
new { StoryIntelligenceBookPipelineID = importSessionId, UserID = userId },
commandType: CommandType.StoredProcedure);
}
public async Task<StoryIntelligenceBookPipelineState?> GetByBookAsync(int bookId) public async Task<StoryIntelligenceBookPipelineState?> GetByBookAsync(int bookId)
{ {
using var connection = connectionFactory.CreateConnection(); using var connection = connectionFactory.CreateConnection();

View File

@ -30,6 +30,7 @@ public interface IStoryIntelligenceResultRepository
Task<int> SaveChapterResultAsync(int runId, StoryIntelligenceChapterResultSaveRequest chapter); Task<int> SaveChapterResultAsync(int runId, StoryIntelligenceChapterResultSaveRequest chapter);
Task<int> SaveSceneResultAsync(int runId, int? chapterResultId, StoryIntelligenceSceneResultSaveRequest scene); Task<int> SaveSceneResultAsync(int runId, int? chapterResultId, StoryIntelligenceSceneResultSaveRequest scene);
Task<IReadOnlyList<StoryIntelligenceSavedRunListItem>> ListRunsAsync(); Task<IReadOnlyList<StoryIntelligenceSavedRunListItem>> ListRunsAsync();
Task<IReadOnlyList<StoryIntelligenceSavedRun>> ListRunsByBookForUserAsync(int bookId, int userId);
Task<StoryIntelligenceSavedRun?> GetRunAsync(int runId); Task<StoryIntelligenceSavedRun?> GetRunAsync(int runId);
Task<StoryIntelligenceSavedChapterResult?> GetChapterResultAsync(int runId); Task<StoryIntelligenceSavedChapterResult?> GetChapterResultAsync(int runId);
Task<IReadOnlyList<StoryIntelligenceSavedSceneResult>> ListSceneResultsAsync(int runId); Task<IReadOnlyList<StoryIntelligenceSavedSceneResult>> ListSceneResultsAsync(int runId);
@ -350,6 +351,16 @@ public sealed class StoryIntelligenceResultRepository(ISqlConnectionFactory conn
return rows.ToList(); return rows.ToList();
} }
public async Task<IReadOnlyList<StoryIntelligenceSavedRun>> ListRunsByBookForUserAsync(int bookId, int userId)
{
using var connection = connectionFactory.CreateConnection();
var rows = await connection.QueryAsync<StoryIntelligenceSavedRun>(
"dbo.StoryIntelligenceRun_ListByBookForUser",
new { BookID = bookId, UserID = userId },
commandType: CommandType.StoredProcedure);
return rows.ToList();
}
public async Task<StoryIntelligenceSavedRun?> GetRunAsync(int runId) public async Task<StoryIntelligenceSavedRun?> GetRunAsync(int runId)
{ {
using var connection = connectionFactory.CreateConnection(); using var connection = connectionFactory.CreateConnection();

View File

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

View File

@ -50,7 +50,8 @@ It should not:
7. Treat assets as meaningful story objects, not ordinary props. 7. Treat assets as meaningful story objects, not ordinary props.
8. Treat relationships as scene-local signals, not final records. 8. Treat relationships as scene-local signals, not final records.
9. Treat metrics as descriptive intensity scores, not quality scores. 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 ## 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. 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 ## Location Reporting Rules
Named locations: Named locations:
@ -543,6 +564,30 @@ Return exactly one JSON object matching this shape:
"notes": null "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": [ "locations": [
{ {
"name": "", "name": "",

View File

@ -0,0 +1,514 @@
# PlotDirector Story Intelligence Experience
**Version:** Draft 2\
**Status:** Phase 21 Design Vision\
**Priority:** High
------------------------------------------------------------------------
# Purpose
Replace the traditional Story Intelligence progress page with a living
visual experience that allows authors to watch PlotDirector build an
understanding of their manuscript.
This is **not** simply a better progress bar.
It is intended to become one of PlotDirector's defining features, giving
authors confidence that the AI genuinely understands their story while
analysis is taking place.
------------------------------------------------------------------------
# Design Philosophy
## Story before structure
The Story Intelligence Experience is **not** intended to display a
complete technical graph of every entity in the manuscript.
Its purpose is to visualise PlotDirector's current understanding of the
story in a way that feels alive, engaging and emotionally recognisable.
The screen should always answer:
> **"What story is PlotDirector understanding right now?"**
rather than:
> **"What data currently exists?"**
------------------------------------------------------------------------
## Narrative focus
The display should behave like a camera following the narrative.
As each scene completes, the visual emphasis naturally shifts towards
the people, places, objects and relationships that matter in that part
of the story.
Everything should not receive equal importance.
The visualisation should constantly rebalance around the current
narrative focus.
------------------------------------------------------------------------
## Current understanding only
The visualisation always represents PlotDirector's **best current
understanding**.
It is **not** intended to preserve historical graph states.
As new scenes are analysed, discoveries simply update the current
picture.
When Story Intelligence completes and the author approves the imported
data, the visualisation naturally transitions to using canonical
PlotDirector data.
------------------------------------------------------------------------
## Demonstrate understanding, not AI
The purpose of the experience is not to impress users with AI
processing.
It is to demonstrate understanding.
The author should finish watching believing:
> PlotDirector genuinely understands my novel.
------------------------------------------------------------------------
# Overall User Flow
Welcome
Project
Book
Word Companion
Scan manuscript
Review detected chapters
Start Story Intelligence
At this point the wizard ends.
The user chooses one of two experiences.
------------------------------------------------------------------------
# Experience One -- Continue in Background
Ideal for users who simply wish to import.
Analysis continues entirely server-side.
Closing Word or the browser never interrupts analysis.
Completion provides:
- Email notification
- In-app notification
- Resume from the Projects page
- Review Imported Scenes
------------------------------------------------------------------------
# Experience Two -- Watch Story Intelligence
A dedicated full-screen experience.
Minimal chrome.
No wizard.
No forms.
No distractions.
Think:
- Mission Control
- A modern strategy game
- Apple-quality animation
- Calm, cinematic motion
This is the visual representation of PlotDirector reading and
understanding the author's story.
------------------------------------------------------------------------
# Screen Layout
## Header
- PlotDirector
- Story Intelligence
- Current Project
- Current Book
- Analysis Stage
- Continue in Background
------------------------------------------------------------------------
## Left Panel
Administrative information.
Contains:
- Overall progress
- Percentage complete
- Estimated remaining time
- Chapters analysed
- Scenes analysed
- Current stage
- Latest discoveries
- Live activity feed
This area should feel informative rather than technical.
------------------------------------------------------------------------
## Centre
The Living Story View.
This is the heart of the experience.
It should not attempt to display the whole book equally.
Instead it presents the current centre of gravity of the narrative.
The display should naturally revolve around:
- Current POV character
- Important scene characters
- Active location
- Story-significant assets
- Current relationships
- Active knowledge threads
------------------------------------------------------------------------
## Bottom Centre
Recent scenes.
The current scene occupies the centre.
As scenes complete they gently move left.
New scenes appear on the right.
This should feel like the story is progressing rather than a queue being
processed.
Beneath this is the timeline, gradually filling with dates and important
events.
------------------------------------------------------------------------
## Right Panel
Higher-level understanding.
Contains:
- Recent relationship discoveries
- Knowledge threads
- Story observations
- Interesting narrative insights
These should feel insightful rather than diagnostic.
------------------------------------------------------------------------
# Living Story View
The visualisation should feel like a camera following the story.
Characters, locations and assets should dominate the display using
imagery rather than abstract icons.
The scene currently being analysed determines the focus.
As new discoveries occur:
- Characters appear.
- Locations appear.
- Assets appear.
- Relationships grow.
- Knowledge threads emerge.
- Timeline events appear.
The graph gently reorganises itself.
Nothing should feel abrupt.
------------------------------------------------------------------------
# Visual Hierarchy
Priority should naturally be:
1. POV character
2. Main characters in the current scene
3. Current location
4. Important story assets
5. Active relationships
6. Knowledge threads
7. Less significant supporting entities
Minor characters may remain visible but should naturally appear smaller
and less prominent.
The display should never become visually cluttered.
------------------------------------------------------------------------
# Imagery First
Portraits, locations and assets are fundamental to the emotional impact
of the experience.
Generic illustrated portraits and representative imagery are entirely
acceptable during Story Intelligence.
The author may later replace them with custom images.
The visualisation should avoid reducing important entities to anonymous
circles wherever imagery is available.
People connect emotionally with faces, places and objects.
------------------------------------------------------------------------
# Character Portraits
Initially use a curated built-in library.
Approximately 300--500 illustrations.
Categorised by:
- Age
- Gender
- Hair colour
- General appearance
These are visual identifiers, not canonical artwork.
------------------------------------------------------------------------
# Location Images
Representative imagery chosen from a curated library.
Examples:
- Detached house
- Victorian home
- Farm
- School
- Church
- Canal
- Flat
- Police station
The purpose is recognition rather than perfect accuracy.
------------------------------------------------------------------------
# Asset Images
Representative images for common story assets.
Examples:
- Car
- Letter
- Passport
- Suitcase
- Notebook
- Jewellery
- Backpack
- Weapon
Again, these are visual identifiers only.
------------------------------------------------------------------------
# Live Discovery Feed
Examples:
- Character discovered
- Location discovered
- Asset discovered
- Relationship detected
- Timeline event identified
- Knowledge thread opened
Older discoveries gently fade as new discoveries arrive.
------------------------------------------------------------------------
# Story Observations
These should feel intelligent.
Examples:
- Rosie has become central to the story.
- Beth now appears in seven scenes.
- Several names may refer to the same person.
- A mystery thread has emerged.
Avoid technical language.
------------------------------------------------------------------------
# Current Scene
Display:
- Scene number
- Scene title (if available)
- Brief summary
- Current progress
When a scene completes it joins the timeline.
------------------------------------------------------------------------
# Timeline
A growing ribbon across the bottom.
Scenes move naturally from right to left.
Dates and events accumulate.
Later this same component becomes PlotDirector's permanent timeline.
------------------------------------------------------------------------
# Animation Philosophy
Every animation should communicate meaning.
Examples:
- Character appears → newly discovered.
- Relationship line grows → connection identified.
- Character grows → increased narrative importance.
- Timeline moves → scene completed.
- Knowledge thread appears → mystery recognised.
Avoid decorative animation.
Every movement should reinforce understanding.
------------------------------------------------------------------------
# Current-State Model
The visualisation always displays the current accumulated understanding.
There is no requirement to replay previous graph states.
If understanding changes, the visualisation simply updates.
The browser always shows the latest truth.
------------------------------------------------------------------------
# Technology Direction
Current preferred architecture:
- ASP.NET Core
- SQL Server
- SignalR
- Existing Story Intelligence results
- Interactive graph renderer
- HTML/CSS dashboard
- Smooth animation
The renderer itself is an implementation detail.
The user experience described in this document is the priority.
------------------------------------------------------------------------
# Completion
When Story Intelligence completes:
Story Intelligence Complete
Your manuscript is ready for review.
Buttons:
- Review Imported Scenes
- Explore Story Map
------------------------------------------------------------------------
# Long-Term Vision
The technology developed here becomes the foundation for:
- Story Map
- Character Explorer
- Relationship Explorer
- Location Explorer
- Asset Explorer
- Timeline Explorer
- Knowledge Explorer
- Continuity Explorer
The Story Intelligence experience is cinematic.
The permanent Story Map is analytical.
Both share the same underlying story data while presenting it
differently.
------------------------------------------------------------------------
# Ultimate Design Goal
The author should never feel they are watching a progress bar.
They should feel they are watching their world come alive.
By the time analysis finishes they should have greater confidence that
PlotDirector genuinely understands the novel they have spent months or
years creating.
The visualisation should become one of PlotDirector's signature features
and something authors remember, recommend and enjoy watching.

View File

@ -0,0 +1,263 @@
using System.Text.Json.Serialization;
namespace PlotLine.Models;
public static class IllustrationLibraryCategories
{
public const string Character = "Character";
public const string Location = "Location";
public const string Asset = "Asset";
public static readonly IReadOnlyList<string> All = [Character, Location, Asset];
public static bool IsValid(string? category)
=> All.Any(item => string.Equals(item, category, StringComparison.OrdinalIgnoreCase));
public static string StorageFolder(string category)
=> category switch
{
Character => "characters",
Location => "locations",
Asset => "assets",
_ => throw new ArgumentOutOfRangeException(nameof(category), category, "Unknown illustration category.")
};
}
public static class IllustrationLibraryStatuses
{
public const string Planned = "Planned";
public const string Queued = "Queued";
public const string Generating = "Generating";
public const string Generated = "Generated";
public const string Approved = "Approved";
public const string Rejected = "Rejected";
public const string Failed = "Failed";
public const string Superseded = "Superseded";
public const string Disabled = "Disabled";
public static readonly IReadOnlySet<string> All = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
Planned,
Queued,
Generating,
Generated,
Approved,
Rejected,
Failed,
Superseded,
Disabled
};
public static bool IsValid(string? status)
=> !string.IsNullOrWhiteSpace(status) && All.Contains(status);
public static bool CanTransition(string from, string to)
=> (from, to) switch
{
(Planned, Queued) => true,
(Queued, Generating) => true,
(Generating, Generated) => true,
(Generating, Failed) => true,
(Generated, Approved) => true,
(Generated, Rejected) => true,
(Approved, Superseded) => true,
(Rejected, Queued) => true,
(Failed, Queued) => true,
(Approved, Disabled) => true,
(Disabled, Queued) => true,
_ when string.Equals(from, to, StringComparison.OrdinalIgnoreCase) => true,
_ => false
};
}
public sealed class IllustrationGenerationSpecification
{
public string Category { get; init; } = string.Empty;
public string Code { get; init; } = string.Empty;
public string Title { get; init; } = string.Empty;
public string Description { get; init; } = string.Empty;
public string VisualSubject { get; init; } = string.Empty;
public string Composition { get; init; } = string.Empty;
public string Mood { get; init; } = string.Empty;
public string? ApparentAgeBand { get; init; }
public string? Presentation { get; init; }
public string? HairColour { get; init; }
public string? HairLength { get; init; }
public string? SkinTone { get; init; }
public string? ClothingEra { get; init; }
public string? ClothingStyle { get; init; }
public string? LocationType { get; init; }
public string? Era { get; init; }
public string? Setting { get; init; }
public string? ObjectType { get; init; }
public string? Colour { get; init; }
public IReadOnlyList<string> Features { get; init; } = [];
public IReadOnlyList<string> Palette { get; init; } = [];
public IReadOnlyList<string> MetadataTags { get; init; } = [];
public Dictionary<string, string> Metadata { get; init; } = new(StringComparer.OrdinalIgnoreCase);
public IReadOnlyList<string> Validate()
{
var errors = new List<string>();
if (!IllustrationLibraryCategories.IsValid(Category))
{
errors.Add("Category must be Character, Location or Asset.");
}
if (string.IsNullOrWhiteSpace(Code))
{
errors.Add("Stable code is required.");
}
if (string.IsNullOrWhiteSpace(Title))
{
errors.Add("Title is required.");
}
if (string.IsNullOrWhiteSpace(Mood))
{
errors.Add("Mood is required.");
}
if (Category == IllustrationLibraryCategories.Character)
{
Require(ApparentAgeBand, "Apparent age band is required for characters.");
Require(Presentation, "Presentation is required for characters.");
}
else if (Category == IllustrationLibraryCategories.Location)
{
Require(LocationType, "Location type is required for locations.");
}
else if (Category == IllustrationLibraryCategories.Asset)
{
Require(ObjectType, "Object type is required for assets.");
}
return errors;
void Require(string? value, string message)
{
if (string.IsNullOrWhiteSpace(value))
{
errors.Add(message);
}
}
}
}
public sealed class IllustrationLibraryItem
{
public int IllustrationLibraryItemID { get; set; }
public string Category { get; set; } = string.Empty;
public string StableCode { get; set; } = string.Empty;
public string DisplayTitle { get; set; } = string.Empty;
public string? ShortDescription { get; set; }
public string? MetadataJson { get; set; }
public string? SpecificationJson { get; set; }
public string? FilePath { get; set; }
public string? ThumbnailPath { get; set; }
public int? Width { get; set; }
public int? Height { get; set; }
public long? FileSizeBytes { get; set; }
public string? MimeType { get; set; }
public string Status { get; set; } = IllustrationLibraryStatuses.Planned;
public bool IsActive { get; set; }
public bool IsApproved { get; set; }
public DateTime CreatedUtc { get; set; }
public DateTime UpdatedUtc { get; set; }
public DateTime? ApprovedUtc { get; set; }
public int? ApprovedByUserID { get; set; }
public string? Provider { get; set; }
public string? Model { get; set; }
public string? PromptText { get; set; }
public string? Prompt { get; set; }
public string? PromptTemplateVersion { get; set; }
public string? ProviderRequestID { get; set; }
public int AttemptCount { get; set; }
public int GenerationAttemptCount { get; set; }
public int? ParentItemID { get; set; }
public string? RejectionReason { get; set; }
public DateTime? RejectedUtc { get; set; }
public int? RejectedByUserID { get; set; }
public string? AdminNotes { get; set; }
public int UsageCount { get; set; }
public string? LastError { get; set; }
public string? ErrorMessage { get; set; }
public DateTime? QueuedUtc { get; set; }
public DateTime? StartedUtc { get; set; }
public DateTime? CompletedUtc { get; set; }
}
public sealed class IllustrationLibrarySummary
{
public int TotalCount { get; set; }
public int PlannedCount { get; set; }
public int QueuedCount { get; set; }
public int GeneratingCount { get; set; }
public int GeneratedCount { get; set; }
public int ApprovedCount { get; set; }
public int RejectedCount { get; set; }
public int FailedCount { get; set; }
public int SupersededCount { get; set; }
public int DisabledCount { get; set; }
}
public sealed class IllustrationLibraryFilter
{
public string? Category { get; init; }
public string? Status { get; init; }
public string? Search { get; init; }
}
public sealed record IllustrationPromptBuildResult(
string Prompt,
string TemplateVersion,
string SpecificationJson,
string MetadataJson);
public sealed record IllustrationImageGenerationRequest(
int IllustrationLibraryItemID,
string StableCode,
string Category,
string Prompt,
string TemplateVersion);
public sealed record IllustrationImageGenerationResult(
bool Succeeded,
byte[]? ImageBytes = null,
string? MimeType = null,
string? Provider = null,
string? Model = null,
string? ProviderRequestID = null,
string? ErrorMessage = null)
{
[JsonIgnore]
public bool HasImage => ImageBytes is { Length: > 0 };
}
public sealed record IllustrationStoredImage(
string FilePath,
string ThumbnailPath,
int Width,
int Height,
long FileSizeBytes,
string MimeType);
public sealed record IllustrationLibraryActionResult(bool Succeeded, string Message);
public sealed record IllustrationBulkRetryResult(
int QueuedCount,
int SkippedCount,
int RetryLimitReachedCount)
{
public string Message
=> $"{QueuedCount:N0} failed illustration(s) queued. {SkippedCount:N0} skipped. {RetryLimitReachedCount:N0} reached the retry limit.";
}
public sealed record IllustrationStarterGenerationPlan(
int CharactersToGenerate,
int LocationsToGenerate,
int AssetsToGenerate)
{
public int TotalToGenerate => CharactersToGenerate + LocationsToGenerate + AssetsToGenerate;
}

View File

@ -12,6 +12,7 @@ public sealed class SceneIntelligenceScene
public SceneIntelligencePointOfView? PointOfView { get; init; } public SceneIntelligencePointOfView? PointOfView { get; init; }
public SceneIntelligenceSetting? Setting { get; init; } public SceneIntelligenceSetting? Setting { get; init; }
public List<SceneIntelligenceCharacter>? Characters { get; init; } public List<SceneIntelligenceCharacter>? Characters { get; init; }
public List<SceneIntelligenceCharacterAppearance>? CharacterAppearance { get; init; }
public List<SceneIntelligenceLocation>? Locations { get; init; } public List<SceneIntelligenceLocation>? Locations { get; init; }
public List<SceneIntelligenceAsset>? Assets { get; init; } public List<SceneIntelligenceAsset>? Assets { get; init; }
public List<SceneIntelligenceRelationship>? Relationships { get; init; } public List<SceneIntelligenceRelationship>? Relationships { get; init; }
@ -99,6 +100,33 @@ public sealed class SceneIntelligenceCharacter
public Dictionary<string, JsonElement>? ExtensionData { get; init; } 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 sealed class SceneIntelligenceLocation
{ {
public string? Name { get; init; } public string? Name { get; init; }

View File

@ -0,0 +1,70 @@
namespace PlotLine.Models;
public sealed class StoryIntelligenceCharacterIllustrationAssignment
{
public int StoryIntelligenceCharacterIllustrationAssignmentID { get; init; }
public int ProjectID { get; init; }
public string CharacterKey { get; init; } = string.Empty;
public string CharacterName { get; init; } = string.Empty;
public int? IllustrationLibraryItemID { get; init; }
public string? StableCode { get; init; }
public decimal MatchConfidence { get; init; }
public int MatchingScore { get; init; }
public string? EvidenceJson { get; init; }
public string? CandidateDiagnosticsJson { get; init; }
public string? ReasonChosen { get; init; }
public string? ReasonReassigned { get; init; }
public int? ImportSessionID { get; init; }
public string AssignmentStatus { get; init; } = "Assigned";
public DateTime? AssignedUtc { get; init; }
public DateTime? LastValidatedUtc { get; init; }
public DateTime? InvalidatedUtc { get; init; }
public string? InvalidationReason { get; init; }
public string EvidenceVersion { get; init; } = "21K";
public bool IsFallback { get; init; }
public bool IsActive { get; init; }
public DateTime CreatedUtc { get; init; }
public DateTime UpdatedUtc { get; init; }
}
public sealed record StoryIntelligenceCharacterIllustrationAssignmentSave(
int ProjectID,
string CharacterKey,
string CharacterName,
int? IllustrationLibraryItemID,
string? StableCode,
decimal MatchConfidence,
int MatchingScore,
string? EvidenceJson,
string? CandidateDiagnosticsJson,
string? ReasonChosen,
string? ReasonReassigned);
public sealed class StoryIntelligenceIllustrationDemand
{
public int StoryIntelligenceIllustrationDemandID { get; init; }
public int? ProjectID { get; init; }
public string DemandKey { get; init; } = string.Empty;
public string AgeBand { get; init; } = string.Empty;
public string Presentation { get; init; } = string.Empty;
public string HairColour { get; init; } = string.Empty;
public string SkinTone { get; init; } = string.Empty;
public string? HairLength { get; init; }
public string? Glasses { get; init; }
public string? FacialHair { get; init; }
public int RequestCount { get; init; }
public DateTime LastRequestedUtc { get; init; }
public DateTime? QueuedUtc { get; init; }
public string GenerationSource { get; init; } = "Demand generation";
}
public sealed record StoryIntelligenceIllustrationDemandRequest(
int? ProjectID,
string DemandKey,
string AgeBand,
string Presentation,
string HairColour,
string SkinTone,
string? HairLength,
string? Glasses,
string? FacialHair);

View File

@ -99,6 +99,8 @@ public sealed class StoryIntelligenceOptions
public string Model { get; init; } = string.Empty; public string Model { get; init; } = string.Empty;
public string ChapterStructureModel { get; init; } = string.Empty; public string ChapterStructureModel { get; init; } = string.Empty;
public string SceneIntelligenceModel { get; init; } = string.Empty; public string SceneIntelligenceModel { get; init; } = string.Empty;
public string ImageGenerationModel { get; init; } = string.Empty;
public string ImageGenerationSize { get; init; } = "1024x1024";
public int MaxOutputTokens { get; init; } = 4000; public int MaxOutputTokens { get; init; } = 4000;
public int? SceneIntelligenceMaxOutputTokens { get; init; } public int? SceneIntelligenceMaxOutputTokens { get; init; }
public int TimeoutSeconds { get; init; } = 120; public int TimeoutSeconds { get; init; } = 120;

View File

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

View File

@ -47,8 +47,12 @@ public class Program
builder.Configuration.GetSection("StoryIntelligence").Bind(options); builder.Configuration.GetSection("StoryIntelligence").Bind(options);
}); });
builder.Services.Configure<StoryIntelligencePricingOptions>(builder.Configuration.GetSection("StoryIntelligence:Pricing")); builder.Services.Configure<StoryIntelligencePricingOptions>(builder.Configuration.GetSection("StoryIntelligence:Pricing"));
var dataProtectionKeyPath = GetDataProtectionKeyPath(
builder.Environment.ContentRootPath,
builder.Configuration["DataProtection:KeysPath"]);
Directory.CreateDirectory(dataProtectionKeyPath);
builder.Services.AddDataProtection() builder.Services.AddDataProtection()
.PersistKeysToFileSystem(new DirectoryInfo(Path.Combine(builder.Environment.ContentRootPath, "App_Data", "DataProtectionKeys"))); .PersistKeysToFileSystem(new DirectoryInfo(dataProtectionKeyPath));
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie(options => .AddCookie(options =>
{ {
@ -144,6 +148,9 @@ public class Program
builder.Services.AddScoped<IStoryIntelligenceResultRepository, StoryIntelligenceResultRepository>(); builder.Services.AddScoped<IStoryIntelligenceResultRepository, StoryIntelligenceResultRepository>();
builder.Services.AddScoped<IStoryIntelligenceSourceRepository, StoryIntelligenceSourceRepository>(); builder.Services.AddScoped<IStoryIntelligenceSourceRepository, StoryIntelligenceSourceRepository>();
builder.Services.AddScoped<IStoryIntelligencePipelineRepository, StoryIntelligencePipelineRepository>(); builder.Services.AddScoped<IStoryIntelligencePipelineRepository, StoryIntelligencePipelineRepository>();
builder.Services.AddScoped<IStoryIntelligenceIllustrationMatchingRepository, StoryIntelligenceIllustrationMatchingRepository>();
builder.Services.AddScoped<IStoryMemoryRepository, StoryMemoryRepository>();
builder.Services.AddScoped<IIllustrationLibraryRepository, IllustrationLibraryRepository>();
builder.Services.AddScoped<IProjectAccessRepository, ProjectAccessRepository>(); builder.Services.AddScoped<IProjectAccessRepository, ProjectAccessRepository>();
builder.Services.AddScoped<IProjectCollaborationRepository, ProjectCollaborationRepository>(); builder.Services.AddScoped<IProjectCollaborationRepository, ProjectCollaborationRepository>();
builder.Services.AddScoped<IProjectActivityRepository, ProjectActivityRepository>(); builder.Services.AddScoped<IProjectActivityRepository, ProjectActivityRepository>();
@ -200,6 +207,7 @@ public class Program
builder.Services.AddScoped<IStorySceneValidator, StorySceneValidator>(); builder.Services.AddScoped<IStorySceneValidator, StorySceneValidator>();
builder.Services.AddScoped<IChapterStructureValidator, ChapterStructureValidator>(); builder.Services.AddScoped<IChapterStructureValidator, ChapterStructureValidator>();
builder.Services.AddScoped<IStoryIntelligenceDiagnosticsService, StoryIntelligenceDiagnosticsService>(); builder.Services.AddScoped<IStoryIntelligenceDiagnosticsService, StoryIntelligenceDiagnosticsService>();
builder.Services.AddScoped<IStoryIntelligenceDevelopmentResetService, StoryIntelligenceDevelopmentResetService>();
builder.Services.AddScoped<IStoryIntelligenceDryRunService, StoryIntelligenceDryRunService>(); builder.Services.AddScoped<IStoryIntelligenceDryRunService, StoryIntelligenceDryRunService>();
builder.Services.AddScoped<IChapterStructureDryRunService, ChapterStructureDryRunService>(); builder.Services.AddScoped<IChapterStructureDryRunService, ChapterStructureDryRunService>();
builder.Services.AddScoped<IChapterStoryIntelligenceDryRunService, ChapterStoryIntelligenceDryRunService>(); builder.Services.AddScoped<IChapterStoryIntelligenceDryRunService, ChapterStoryIntelligenceDryRunService>();
@ -212,10 +220,18 @@ public class Program
builder.Services.AddScoped<IStoryIntelligenceAssetImportService, StoryIntelligenceAssetImportService>(); builder.Services.AddScoped<IStoryIntelligenceAssetImportService, StoryIntelligenceAssetImportService>();
builder.Services.AddScoped<IStoryIntelligenceRelationshipImportService, StoryIntelligenceRelationshipImportService>(); builder.Services.AddScoped<IStoryIntelligenceRelationshipImportService, StoryIntelligenceRelationshipImportService>();
builder.Services.AddScoped<IStoryIntelligenceKnowledgeImportService, StoryIntelligenceKnowledgeImportService>(); builder.Services.AddScoped<IStoryIntelligenceKnowledgeImportService, StoryIntelligenceKnowledgeImportService>();
builder.Services.AddScoped<IStoryIntelligenceVisualisationSnapshotService, StoryIntelligenceVisualisationSnapshotService>();
builder.Services.AddScoped<IStoryMemoryService, StoryMemoryService>();
builder.Services.AddScoped<IStoryIntelligencePipelineStateService, StoryIntelligencePipelineStateService>(); builder.Services.AddScoped<IStoryIntelligencePipelineStateService, StoryIntelligencePipelineStateService>();
builder.Services.AddScoped<IStoryIntelligenceProgressNotifier, StoryIntelligenceProgressNotifier>(); builder.Services.AddScoped<IStoryIntelligenceProgressNotifier, StoryIntelligenceProgressNotifier>();
builder.Services.AddScoped<ISceneImportResolver, SceneImportResolver>(); builder.Services.AddScoped<ISceneImportResolver, SceneImportResolver>();
builder.Services.AddScoped<IPersistedStoryIntelligenceRunner, PersistedStoryIntelligenceRunner>(); builder.Services.AddScoped<IPersistedStoryIntelligenceRunner, PersistedStoryIntelligenceRunner>();
builder.Services.AddScoped<IIllustrationPromptBuilder, IllustrationPromptBuilder>();
builder.Services.AddScoped<IIllustrationLibraryStorageService, IllustrationLibraryStorageService>();
builder.Services.AddHttpClient<IIllustrationImageProvider, OpenAIIllustrationImageProvider>();
builder.Services.AddScoped<IIllustrationGenerationRunner, IllustrationGenerationRunner>();
builder.Services.AddScoped<IIllustrationLibraryService, IllustrationLibraryService>();
builder.Services.AddScoped<IStoryIntelligenceIllustrationMatchingService, StoryIntelligenceIllustrationMatchingService>();
builder.Services.AddScoped<IProjectAccessService, ProjectAccessService>(); builder.Services.AddScoped<IProjectAccessService, ProjectAccessService>();
builder.Services.AddScoped<ISubscriptionService, SubscriptionService>(); builder.Services.AddScoped<ISubscriptionService, SubscriptionService>();
builder.Services.AddScoped<IStripeBillingService, StripeBillingService>(); builder.Services.AddScoped<IStripeBillingService, StripeBillingService>();
@ -230,8 +246,15 @@ public class Program
builder.Services.AddScoped<ICurrentUserService, CurrentUserService>(); builder.Services.AddScoped<ICurrentUserService, CurrentUserService>();
builder.Services.AddScoped<IFeatureRequestService, FeatureRequestService>(); builder.Services.AddScoped<IFeatureRequestService, FeatureRequestService>();
builder.Services.AddHostedService<EmailQueueWorker>(); builder.Services.AddHostedService<EmailQueueWorker>();
if (builder.Configuration.GetValue("StoryIntelligence:WorkersEnabled", true))
{
builder.Services.AddHostedService<StoryIntelligenceWorker>(); builder.Services.AddHostedService<StoryIntelligenceWorker>();
builder.Services.AddHostedService<PersistedStoryIntelligenceWorker>(); builder.Services.AddHostedService<PersistedStoryIntelligenceWorker>();
}
if (builder.Configuration.GetValue("Illustrations:GenerationWorkerEnabled", true))
{
builder.Services.AddHostedService<IllustrationGenerationWorker>();
}
var app = builder.Build(); var app = builder.Build();
@ -265,4 +288,21 @@ public class Program
app.Run(); app.Run();
} }
private static string GetDataProtectionKeyPath(string contentRootPath, string? configuredPath)
{
if (!string.IsNullOrWhiteSpace(configuredPath))
{
return Path.GetFullPath(configuredPath);
}
var contentRoot = new DirectoryInfo(contentRootPath);
if (string.Equals(contentRoot.Name, "current", StringComparison.OrdinalIgnoreCase)
&& contentRoot.Parent is not null)
{
return Path.Combine(contentRoot.Parent.FullName, "App_Data", "DataProtectionKeys");
}
return Path.Combine(contentRoot.FullName, "App_Data", "DataProtectionKeys");
}
} }

View File

@ -0,0 +1,191 @@
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();
if (LooksLikeLeakedRelationshipAge(rawName, match.Groups["age"].Value))
{
continue;
}
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 bool LooksLikeLeakedRelationshipAge(string rawName, string rawAge)
{
var name = Normalise(rawName);
var age = Normalise(rawAge);
var relationshipAge = age is "mother" or "mum" or "father" or "dad" or "aunt" or "uncle";
if (!relationshipAge)
{
return false;
}
return name is not ("mum" or "mother" or "dad" or "father" or "aunt" or "uncle")
&& !name.StartsWith("mrs ", StringComparison.OrdinalIgnoreCase)
&& !name.StartsWith("miss ", StringComparison.OrdinalIgnoreCase)
&& !name.StartsWith("ms ", StringComparison.OrdinalIgnoreCase)
&& !name.StartsWith("mr ", StringComparison.OrdinalIgnoreCase);
}
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));
}
}

File diff suppressed because it is too large Load Diff

View File

@ -9,7 +9,7 @@ namespace PlotLine.Services;
public interface IOnboardingStoryIntelligenceService public interface IOnboardingStoryIntelligenceService
{ {
Task<StoryIntelligenceOverviewViewModel> GetOverviewAsync(); Task<StoryIntelligenceOverviewViewModel> GetOverviewAsync();
Task<OnboardingStoryIntelligenceBatch?> StartAsync(); Task<OnboardingStoryIntelligenceBatch?> StartAsync(string? appearancePreference = null, string? primaryAgeGroup = null, string? storyEra = null, string? storyLocation = null);
Task<StoryIntelligenceProgressViewModel?> GetProgressAsync(Guid batchId); Task<StoryIntelligenceProgressViewModel?> GetProgressAsync(Guid batchId);
Task<StoryIntelligenceProgressViewModel?> CancelAsync(Guid batchId); Task<StoryIntelligenceProgressViewModel?> CancelAsync(Guid batchId);
Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> CommitAsync(Guid batchId, int runId); Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> CommitAsync(Guid batchId, int runId);
@ -40,6 +40,7 @@ public sealed class OnboardingStoryIntelligenceService(
IStoryIntelligenceRelationshipImportService relationshipImport, IStoryIntelligenceRelationshipImportService relationshipImport,
IStoryIntelligenceKnowledgeImportService knowledgeImport, IStoryIntelligenceKnowledgeImportService knowledgeImport,
IStoryIntelligencePipelineStateService pipelineState, IStoryIntelligencePipelineStateService pipelineState,
IStoryMemoryRepository storyMemory,
IStoryIntelligenceClient client, IStoryIntelligenceClient client,
IOnboardingStoryIntelligenceBatchStore batchStore, IOnboardingStoryIntelligenceBatchStore batchStore,
IStoryIntelligenceProgressNotifier notifier, IStoryIntelligenceProgressNotifier notifier,
@ -83,7 +84,7 @@ public sealed class OnboardingStoryIntelligenceService(
}; };
} }
public async Task<OnboardingStoryIntelligenceBatch?> StartAsync() public async Task<OnboardingStoryIntelligenceBatch?> StartAsync(string? appearancePreference = null, string? primaryAgeGroup = null, string? storyEra = null, string? storyLocation = null)
{ {
var userId = RequireUserId(); var userId = RequireUserId();
var context = await GetContextAsync(userId); var context = await GetContextAsync(userId);
@ -161,6 +162,7 @@ public sealed class OnboardingStoryIntelligenceService(
ChapterID = chapterId, ChapterID = chapterId,
RunID = runId RunID = runId
}); });
await pipelineState.RecordChapterAnalysisQueuedAsync(preview.ProjectID, preview.BookID, runId);
await notifier.PublishAsync(new StoryIntelligenceRunProgressEvent await notifier.PublishAsync(new StoryIntelligenceRunProgressEvent
{ {
@ -189,9 +191,23 @@ public sealed class OnboardingStoryIntelligenceService(
BookID = preview.BookID, BookID = preview.BookID,
ProjectName = wizard.SelectedProjectName, ProjectName = wizard.SelectedProjectName,
BookTitle = wizard.SelectedBookTitle, BookTitle = wizard.SelectedBookTitle,
AppearancePreference = NormaliseAppearancePreference(appearancePreference),
PrimaryAgeGroup = CleanContext(primaryAgeGroup),
StoryEra = CleanContext(storyEra),
StoryLocation = CleanContext(storyLocation),
Items = batchItems Items = batchItems
}; };
if (await pipelineState.GetForBookAsync(preview.BookID, userId) is { } pipeline)
{
await storyMemory.SetImportContextAsync(
pipeline.StoryIntelligenceBookPipelineID,
batch.AppearancePreference,
batch.PrimaryAgeGroup,
batch.StoryEra,
batch.StoryLocation);
}
await batchStore.SaveAsync(batch); await batchStore.SaveAsync(batch);
logger.LogInformation( logger.LogInformation(
"Queued onboarding Story Intelligence batch {BatchID}. UserID={UserID} ProjectID={ProjectID} BookID={BookID} Chapters={ChapterCount}", "Queued onboarding Story Intelligence batch {BatchID}. UserID={UserID} ProjectID={ProjectID} BookID={BookID} Chapters={ChapterCount}",
@ -204,6 +220,24 @@ public sealed class OnboardingStoryIntelligenceService(
return batch; 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
};
private static string CleanContext(string? value)
{
var clean = value?.Trim();
return string.IsNullOrWhiteSpace(clean) ? string.Empty : clean[..Math.Min(clean.Length, 160)];
}
public async Task<StoryIntelligenceProgressViewModel?> GetProgressAsync(Guid batchId) public async Task<StoryIntelligenceProgressViewModel?> GetProgressAsync(Guid batchId)
{ {
var batch = await batchStore.GetAsync(RequireUserId(), batchId); var batch = await batchStore.GetAsync(RequireUserId(), batchId);
@ -253,6 +287,7 @@ public sealed class OnboardingStoryIntelligenceService(
SourceWordCount = run.SourceWordCount, SourceWordCount = run.SourceWordCount,
TotalTokens = run.TotalTokens, TotalTokens = run.TotalTokens,
TotalDurationMs = run.TotalDurationMs, TotalDurationMs = run.TotalDurationMs,
UpdatedUtc = run.UpdatedUtc,
EstimatedCostUSD = run.EstimatedCostUSD, EstimatedCostUSD = run.EstimatedCostUSD,
EstimatedCostGBP = run.EstimatedCostGBP, EstimatedCostGBP = run.EstimatedCostGBP,
FailureStage = run.FailureStage, FailureStage = run.FailureStage,
@ -275,10 +310,12 @@ public sealed class OnboardingStoryIntelligenceService(
var assetReview = await assetImport.BuildReviewAsync(batch); var assetReview = await assetImport.BuildReviewAsync(batch);
var relationshipReview = await relationshipImport.BuildReviewAsync(batch); var relationshipReview = await relationshipImport.BuildReviewAsync(batch);
var knowledgeReview = await knowledgeImport.BuildReviewAsync(batch); var knowledgeReview = await knowledgeImport.BuildReviewAsync(batch);
var pipeline = await pipelineState.GetForBookAsync(batch.BookID, batch.UserID);
return new StoryIntelligenceProgressViewModel return new StoryIntelligenceProgressViewModel
{ {
BatchID = batch.BatchID, BatchID = batch.BatchID,
PreviewID = batch.PreviewID, PreviewID = batch.PreviewID,
ImportSessionID = pipeline?.StoryIntelligenceBookPipelineID,
ProjectID = batch.ProjectID, ProjectID = batch.ProjectID,
BookID = batch.BookID, BookID = batch.BookID,
ProjectName = batch.ProjectName, ProjectName = batch.ProjectName,
@ -579,8 +616,35 @@ public sealed class OnboardingStoryIntelligenceService(
return null; return null;
} }
var committedRuns = await pipelineState.ListCommittedRunsByBookAsync(bookId, userId); var committedRuns = (await pipelineState.ListCommittedRunsByBookAsync(bookId, userId))
if (committedRuns.Count == 0) .OrderBy(run => run.ChapterNumber)
.ThenBy(run => run.StoryIntelligenceRunID)
.Select(run => new OnboardingStoryIntelligenceBatchItem
{
TemporaryChapterKey = $"book-{state.BookID}-chapter-{run.ChapterID}",
ChapterNumber = Convert.ToInt32(run.ChapterNumber),
ChapterTitle = run.ChapterTitle,
ChapterID = run.ChapterID,
RunID = run.StoryIntelligenceRunID
})
.ToList();
var activeRuns = committedRuns.Count > 0
? []
: (await runs.ListRunsByBookForUserAsync(bookId, userId))
.Where(run => run.BookID == state.BookID)
.OrderBy(run => run.ChapterNumber ?? decimal.MaxValue)
.ThenBy(run => run.StoryIntelligenceRunID)
.Select(run => new OnboardingStoryIntelligenceBatchItem
{
TemporaryChapterKey = $"book-{state.BookID}-chapter-{run.ChapterID ?? run.StoryIntelligenceRunID}",
ChapterNumber = Convert.ToInt32(run.ChapterNumber ?? 0),
ChapterTitle = FirstConfigured(run.SourceFileName, run.CurrentMessage, run.ChapterNumber.HasValue ? $"Chapter {run.ChapterNumber:0.##}" : $"Run {run.StoryIntelligenceRunID:N0}")!,
ChapterID = run.ChapterID ?? 0,
RunID = run.StoryIntelligenceRunID
})
.ToList();
var batchItems = committedRuns.Count > 0 ? committedRuns : activeRuns;
if (batchItems.Count == 0)
{ {
return null; return null;
} }
@ -594,18 +658,7 @@ public sealed class OnboardingStoryIntelligenceService(
BookID = state.BookID, BookID = state.BookID,
ProjectName = state.ProjectName, ProjectName = state.ProjectName,
BookTitle = state.BookDisplayTitle, BookTitle = state.BookDisplayTitle,
Items = committedRuns Items = batchItems
.OrderBy(run => run.ChapterNumber)
.ThenBy(run => run.StoryIntelligenceRunID)
.Select(run => new OnboardingStoryIntelligenceBatchItem
{
TemporaryChapterKey = $"book-{state.BookID}-chapter-{run.ChapterID}",
ChapterNumber = Convert.ToInt32(run.ChapterNumber),
ChapterTitle = run.ChapterTitle,
ChapterID = run.ChapterID,
RunID = run.StoryIntelligenceRunID
})
.ToList()
}; };
var characterStageComplete = HasCompletedStage(state, StoryIntelligencePipelineStages.CharacterImport) var characterStageComplete = HasCompletedStage(state, StoryIntelligencePipelineStages.CharacterImport)
@ -900,6 +953,10 @@ public sealed class OnboardingStoryIntelligenceBatch
public int BookID { get; init; } public int BookID { get; init; }
public string? ProjectName { get; init; } public string? ProjectName { get; init; }
public string? BookTitle { get; init; } public string? BookTitle { get; init; }
public string AppearancePreference { get; init; } = StoryMemoryAppearancePreferences.Balanced;
public string PrimaryAgeGroup { get; init; } = string.Empty;
public string StoryEra { get; init; } = string.Empty;
public string StoryLocation { get; init; } = string.Empty;
public DateTime CreatedUtc { get; init; } = DateTime.UtcNow; public DateTime CreatedUtc { get; init; } = DateTime.UtcNow;
public IReadOnlyList<OnboardingStoryIntelligenceBatchItem> Items { get; init; } = []; public IReadOnlyList<OnboardingStoryIntelligenceBatchItem> Items { get; init; } = [];
public List<OnboardingStoryIntelligenceCharacterDecision> CharacterDecisions { get; } = []; public List<OnboardingStoryIntelligenceCharacterDecision> CharacterDecisions { get; } = [];

View File

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

View File

@ -0,0 +1,249 @@
using Dapper;
using PlotLine.Data;
namespace PlotLine.Services;
public interface IStoryIntelligenceDevelopmentResetService
{
Task<StoryIntelligenceDevelopmentResetResult> ResetOwnedProjectsAndImportsAsync(int userId, CancellationToken cancellationToken = default);
}
public sealed class StoryIntelligenceDevelopmentResetService(
ISqlConnectionFactory connectionFactory,
ILogger<StoryIntelligenceDevelopmentResetService> logger) : IStoryIntelligenceDevelopmentResetService
{
public async Task<StoryIntelligenceDevelopmentResetResult> ResetOwnedProjectsAndImportsAsync(int userId, CancellationToken cancellationToken = default)
{
using var connection = connectionFactory.CreateConnection();
var command = new CommandDefinition(PrepareResetSql, new { UserID = userId }, cancellationToken: cancellationToken, commandTimeout: 120);
var summary = await connection.QuerySingleAsync<PreparedResetSummary>(command);
foreach (var projectId in summary.ProjectIdList)
{
var deleteCommand = new CommandDefinition(
"dbo.ProjectHardDelete_ForOwner",
new { ProjectID = projectId, UserID = userId },
commandType: System.Data.CommandType.StoredProcedure,
cancellationToken: cancellationToken,
commandTimeout: 120);
await connection.ExecuteAsync(deleteCommand);
}
var verificationCommand = new CommandDefinition(VerifyResetSql, new { UserID = userId }, cancellationToken: cancellationToken, commandTimeout: 120);
var verification = await connection.QuerySingleAsync<ResetVerification>(verificationCommand);
var result = new StoryIntelligenceDevelopmentResetResult(
summary.ProjectIdList.Count,
summary.ImportSessionCount,
summary.RunCount,
verification.RemainingProjects,
verification.RemainingImports,
verification.RemainingRuns,
verification.PreservedIllustrationItems);
logger.LogWarning(
"Admin Story Intelligence reset completed for user {UserId}. DeletedProjects={DeletedProjects} DeletedImports={DeletedImports} DeletedRuns={DeletedRuns} PreservedIllustrationItems={PreservedIllustrationItems}.",
userId,
result.DeletedProjects,
result.DeletedImportSessions,
result.DeletedRuns,
result.PreservedIllustrationItems);
return result;
}
private const string PrepareResetSql = """
SET NOCOUNT ON;
DECLARE @ProjectIDs TABLE (ProjectID int NOT NULL PRIMARY KEY);
DECLARE @BookIDs TABLE (BookID int NOT NULL PRIMARY KEY);
DECLARE @ImportSessionIDs TABLE (ImportSessionID int NOT NULL PRIMARY KEY);
DECLARE @RunIDs TABLE (StoryIntelligenceRunID 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;
INSERT @BookIDs (BookID)
SELECT b.BookID
FROM dbo.Books b
INNER JOIN @ProjectIDs p ON p.ProjectID = b.ProjectID;
INSERT @ImportSessionIDs (ImportSessionID)
SELECT StoryIntelligenceBookPipelineID
FROM dbo.StoryIntelligenceBookPipelines pipeline
WHERE EXISTS (SELECT 1 FROM @ProjectIDs p WHERE p.ProjectID = pipeline.ProjectID)
OR EXISTS (SELECT 1 FROM @BookIDs b WHERE b.BookID = pipeline.BookID);
INSERT @RunIDs (StoryIntelligenceRunID)
SELECT StoryIntelligenceRunID
FROM dbo.StoryIntelligenceRuns run
WHERE run.UserID = @UserID
AND (
run.ProjectID IS NULL
OR EXISTS (SELECT 1 FROM @ProjectIDs p WHERE p.ProjectID = run.ProjectID)
OR EXISTS (SELECT 1 FROM @BookIDs b WHERE b.BookID = run.BookID)
);
DECLARE @ImportSessionCount int = (SELECT COUNT(*) FROM @ImportSessionIDs);
DECLARE @RunCount int = (SELECT COUNT(*) FROM @RunIDs);
UPDATE dbo.StoryIntelligenceRuns
SET Status = CASE WHEN Status IN (N'Pending', N'Running') THEN N'Cancelled' ELSE Status END,
CurrentStage = N'Cancelled before admin reset',
CurrentMessage = N'Admin reset invalidated this run while preserving generated illustrations.',
CancellationRequestedUtc = COALESCE(CancellationRequestedUtc, SYSUTCDATETIME()),
CancelledUtc = COALESCE(CancelledUtc, SYSUTCDATETIME()),
CompletedUtc = COALESCE(CompletedUtc, SYSUTCDATETIME()),
UpdatedUtc = SYSUTCDATETIME()
WHERE EXISTS (SELECT 1 FROM @RunIDs ids WHERE ids.StoryIntelligenceRunID = StoryIntelligenceRuns.StoryIntelligenceRunID)
AND Status IN (N'Pending', N'Running');
DELETE FROM dbo.StoryMemoryIllustrationAssignments
WHERE EXISTS (SELECT 1 FROM @ImportSessionIDs ids WHERE ids.ImportSessionID = StoryMemoryIllustrationAssignments.ImportSessionID);
DELETE FROM dbo.StoryMemoryIllustrationDemands
WHERE EXISTS (SELECT 1 FROM @ImportSessionIDs ids WHERE ids.ImportSessionID = StoryMemoryIllustrationDemands.ImportSessionID);
DELETE FROM dbo.StoryMemoryRelationships
WHERE EXISTS (SELECT 1 FROM @ImportSessionIDs ids WHERE ids.ImportSessionID = StoryMemoryRelationships.ImportSessionID);
DELETE FROM dbo.StoryMemoryCharacterAttributes
WHERE EXISTS
(
SELECT 1
FROM dbo.StoryMemoryCharacters c
INNER JOIN @ImportSessionIDs ids ON ids.ImportSessionID = c.ImportSessionID
WHERE c.StoryMemoryCharacterID = StoryMemoryCharacterAttributes.StoryMemoryCharacterID
);
DELETE FROM dbo.StoryMemoryCharacterAliases
WHERE EXISTS
(
SELECT 1
FROM dbo.StoryMemoryCharacters c
INNER JOIN @ImportSessionIDs ids ON ids.ImportSessionID = c.ImportSessionID
WHERE c.StoryMemoryCharacterID = StoryMemoryCharacterAliases.StoryMemoryCharacterID
);
DELETE FROM dbo.StoryMemoryAssets
WHERE EXISTS (SELECT 1 FROM @ImportSessionIDs ids WHERE ids.ImportSessionID = StoryMemoryAssets.ImportSessionID);
DELETE FROM dbo.StoryMemoryLocations
WHERE EXISTS (SELECT 1 FROM @ImportSessionIDs ids WHERE ids.ImportSessionID = StoryMemoryLocations.ImportSessionID);
DELETE FROM dbo.StoryMemoryCharacters
WHERE EXISTS (SELECT 1 FROM @ImportSessionIDs ids WHERE ids.ImportSessionID = StoryMemoryCharacters.ImportSessionID);
DELETE FROM dbo.StoryMemoryProcessedSceneResults
WHERE EXISTS (SELECT 1 FROM @ImportSessionIDs ids WHERE ids.ImportSessionID = StoryMemoryProcessedSceneResults.ImportSessionID);
DELETE FROM dbo.StoryMemoryImportPreferences
WHERE EXISTS (SELECT 1 FROM @ImportSessionIDs ids WHERE ids.ImportSessionID = StoryMemoryImportPreferences.ImportSessionID);
DELETE FROM dbo.StoryIntelligenceCharacterIllustrationAssignments
WHERE EXISTS (SELECT 1 FROM @ImportSessionIDs ids WHERE ids.ImportSessionID = StoryIntelligenceCharacterIllustrationAssignments.ImportSessionID)
OR EXISTS (SELECT 1 FROM @ProjectIDs p WHERE p.ProjectID = StoryIntelligenceCharacterIllustrationAssignments.ProjectID);
DELETE FROM dbo.StoryIntelligenceIllustrationDemands
WHERE EXISTS (SELECT 1 FROM @ProjectIDs p WHERE p.ProjectID = StoryIntelligenceIllustrationDemands.ProjectID);
DELETE FROM dbo.StoryIntelligenceBookPipelines
WHERE EXISTS (SELECT 1 FROM @ImportSessionIDs ids WHERE ids.ImportSessionID = StoryIntelligenceBookPipelines.StoryIntelligenceBookPipelineID);
DELETE FROM dbo.StoryIntelligenceSceneResults
WHERE EXISTS (SELECT 1 FROM @RunIDs ids WHERE ids.StoryIntelligenceRunID = StoryIntelligenceSceneResults.StoryIntelligenceRunID);
DELETE FROM dbo.StoryIntelligenceChapterResults
WHERE EXISTS (SELECT 1 FROM @RunIDs ids WHERE ids.StoryIntelligenceRunID = StoryIntelligenceChapterResults.StoryIntelligenceRunID);
DELETE FROM dbo.StoryIntelligenceImportCommits
WHERE EXISTS (SELECT 1 FROM @RunIDs ids WHERE ids.StoryIntelligenceRunID = StoryIntelligenceImportCommits.StoryIntelligenceRunID);
DELETE FROM dbo.StoryIntelligenceRuns
WHERE EXISTS (SELECT 1 FROM @RunIDs ids WHERE ids.StoryIntelligenceRunID = StoryIntelligenceRuns.StoryIntelligenceRunID);
UPDATE dbo.Projects
SET IsArchived = 1,
ArchivedDate = COALESCE(ArchivedDate, SYSUTCDATETIME()),
ArchivedReason = N'Admin Story Intelligence reset preserving generated illustrations',
UpdatedDate = SYSUTCDATETIME()
WHERE EXISTS (SELECT 1 FROM @ProjectIDs ids WHERE ids.ProjectID = Projects.ProjectID);
SELECT
ProjectIds = COALESCE(STRING_AGG(CONVERT(varchar(20), ProjectID), ','), ''),
ImportSessionCount = @ImportSessionCount,
RunCount = @RunCount
FROM @ProjectIDs;
""";
private const string VerifyResetSql = """
SELECT
RemainingProjects = (
SELECT COUNT(*)
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
),
RemainingImports = (
SELECT COUNT(*)
FROM dbo.StoryIntelligenceBookPipelines pipeline
INNER JOIN dbo.ProjectUserAccess pua ON pua.ProjectID = pipeline.ProjectID
WHERE pua.UserID = @UserID AND pua.AccessRole = N'Owner' AND pua.IsActive = 1
),
RemainingRuns = (
SELECT COUNT(*)
FROM dbo.StoryIntelligenceRuns
WHERE UserID = @UserID
),
PreservedIllustrationItems = (
SELECT COUNT(*)
FROM dbo.IllustrationLibraryItems
WHERE Category IN (N'Character', N'Location', N'Asset')
);
""";
private sealed class PreparedResetSummary
{
public string ProjectIds { get; init; } = string.Empty;
public int ImportSessionCount { get; init; }
public int RunCount { get; init; }
public IReadOnlyList<int> ProjectIdList => SplitProjectIds(ProjectIds);
}
private sealed class ResetVerification
{
public int RemainingProjects { get; init; }
public int RemainingImports { get; init; }
public int RemainingRuns { get; init; }
public int PreservedIllustrationItems { get; init; }
}
private static IReadOnlyList<int> SplitProjectIds(string projectIds)
=> string.IsNullOrWhiteSpace(projectIds)
? []
: projectIds.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(value => int.TryParse(value, out var id) ? id : 0)
.Where(id => id > 0)
.ToArray();
}
public sealed record StoryIntelligenceDevelopmentResetResult(
int DeletedProjects,
int DeletedImportSessions,
int DeletedRuns,
int RemainingProjects,
int RemainingImportSessions,
int RemainingRuns,
int PreservedIllustrationItems)
{
public bool Success => RemainingProjects == 0 && RemainingImportSessions == 0 && RemainingRuns == 0;
public string Message => Success
? $"Reset complete. Deleted {DeletedProjects:N0} project(s), {DeletedImportSessions:N0} import(s) and {DeletedRuns:N0} run(s). Preserved {PreservedIllustrationItems:N0} generated image record(s)."
: $"Reset finished with remaining data: {RemainingProjects:N0} project(s), {RemainingImportSessions:N0} import(s), {RemainingRuns:N0} run(s). Preserved {PreservedIllustrationItems:N0} generated image record(s).";
}

View File

@ -13,6 +13,7 @@ public sealed class StoryIntelligenceExistingChapterQueueService(
IStoryIntelligenceSourceRepository sources, IStoryIntelligenceSourceRepository sources,
IStoryIntelligenceResultRepository runs, IStoryIntelligenceResultRepository runs,
IStoryIntelligenceClient client, IStoryIntelligenceClient client,
IStoryIntelligencePipelineStateService pipelines,
ILogger<StoryIntelligenceExistingChapterQueueService> logger) : IStoryIntelligenceExistingChapterQueueService ILogger<StoryIntelligenceExistingChapterQueueService> logger) : IStoryIntelligenceExistingChapterQueueService
{ {
private const string NoStoredChapterTextMessage = "No stored chapter text is currently available for this chapter. A future Word Companion/document extraction phase is required."; private const string NoStoredChapterTextMessage = "No stored chapter text is currently available for this chapter. A future Word Companion/document extraction phase is required.";
@ -77,6 +78,7 @@ public sealed class StoryIntelligenceExistingChapterQueueService(
clientStatus.SceneIntelligenceModel), clientStatus.SceneIntelligenceModel),
PromptVersionsSummary = "Chapter=Chapter-Structure-Prompt-V2; Scene=Scene-Prompt-V2" PromptVersionsSummary = "Chapter=Chapter-Structure-Prompt-V2; Scene=Scene-Prompt-V2"
}); });
await pipelines.RecordChapterAnalysisQueuedAsync(selected.ProjectID, selected.BookID, runId);
logger.LogInformation( logger.LogInformation(
"Queued Story Intelligence run {StoryIntelligenceRunID} from existing chapter {ChapterID}. ProjectID={ProjectID} BookID={BookID} SourceWordCount={SourceWordCount} SourceCharacterCount={SourceCharacterCount}", "Queued Story Intelligence run {StoryIntelligenceRunID} from existing chapter {ChapterID}. ProjectID={ProjectID} BookID={BookID} SourceWordCount={SourceWordCount} SourceCharacterCount={SourceCharacterCount}",

View File

@ -0,0 +1,241 @@
using PlotLine.ViewModels;
namespace PlotLine.Services;
public static class StoryIntelligenceExperiencePrototypeData
{
private const string AssetRoot = "/images/story-intelligence/prototype";
public static StoryIntelligenceExperiencePrototypeViewModel Build()
=> new()
{
Mode = "simulation",
Diagnostics = new StoryIntelligenceExperienceSnapshotDiagnostics
{
RequestedMode = "simulation",
SnapshotSource = "Simulation"
},
ProjectName = "Alpha Flame",
BookTitle = "The Doweries Manuscript",
Scenes =
[
Scene(
"scene-01", 1, "A key turns in the rain", "Beth arrives at The Doweries and finds Maggie's letter waiting where it should not be.",
"Detecting scene structure", "Fri 18 Oct", "Beth reaches The Doweries", 8, 1, 12, "42 min",
"beth", Location("doweries", "The Doweries", "Rain-darkened estate house", "location-doweries.svg"),
Characters(("beth", "Beth", "POV character", "Searching", 100, "character-beth.svg"), ("maggie", "Maggie", "Absent presence", "Remembered", 42, "character-maggie.svg"), ("graham", "Graham", "Name in the letter", "Unseen", 26, "character-graham.svg")),
Assets(("letter", "Letter", "Unopened evidence", 88, "asset-letter.svg"), ("notebook", "Notebook", "Beth's notes", 54, "asset-notebook.svg")),
Relationships(("rel-beth-maggie", "beth", "maggie", "Family tie detected", "grief and loyalty", 72)),
Threads(("thread-maggie-secret", "Why did Maggie hide the letter?", "A secret begins around the flat and the missing key.", "mystery")),
Observations(("obs-arrival", "The story opens with withheld knowledge.", "The reader knows the letter matters before Beth understands why.", "insight")),
Discoveries("Possible character found: Maggie", "Location found: The Doweries", "Asset found: Letter"),
Feed("Chapter 1 queued", "Scene boundary detected", "Beth identified as point of view")),
Scene(
"scene-02", 2, "Maggie's flat", "Inside Maggie's flat, Beth finds signs that someone searched the room before her.",
"Reading scenes", "Fri 18 Oct", "Flat searched before Beth arrives", 17, 1, 12, "38 min",
"beth", Location("maggie-flat", "Maggie's flat", "A small flat inside The Doweries", "location-flat.svg"),
Characters(("beth", "Beth", "POV character", "Investigating", 100, "character-beth.svg"), ("maggie", "Maggie", "Absent presence", "Everywhere in the room", 64, "character-maggie.svg"), ("rosie", "Rosie", "Neighbour", "Possible witness", 38, "character-rosie.svg")),
Assets(("letter", "Letter", "Hidden in plain sight", 92, "asset-letter.svg"), ("rucksack", "Rucksack", "Recently moved", 72, "asset-rucksack.svg"), ("notebook", "Notebook", "Fresh page torn out", 58, "asset-notebook.svg")),
Relationships(("rel-beth-maggie", "beth", "maggie", "Relationship strengthened", "protective grief", 82), ("rel-beth-rosie", "beth", "rosie", "Witness connection", "cautious trust", 45)),
Threads(("thread-maggie-secret", "Maggie's missing page", "A torn notebook page suggests Maggie prepared for Beth's arrival.", "mystery"), ("thread-reader-knows", "Reader knows the flat was searched.", "Beth notices the disturbed objects but not yet the pattern.", "secret")),
Observations(("obs-flat", "The location narrows from estate to flat.", "PlotDirector treats Maggie's flat as the active location rather than showing the parent estate.", "insight")),
Discoveries("Location refined: Maggie's flat", "Possible asset found: Rucksack", "Knowledge thread opened: missing page"),
Feed("Scene 2 analysis started", "Flat recognised as child location", "Beth-Maggie relationship evidence increased")),
Scene(
"scene-03", 3, "Rosie at the landing", "Rosie interrupts Beth and reveals Graham visited Maggie two nights before.",
"Reading scenes", "Fri 18 Oct", "Graham placed near Maggie", 26, 2, 12, "34 min",
"beth", Location("maggie-flat", "Maggie's flat", "Landing light spilling through the open door", "location-flat.svg"),
Characters(("beth", "Beth", "POV character", "Questioning", 100, "character-beth.svg"), ("rosie", "Rosie", "Witness", "Nervous but helpful", 76, "character-rosie.svg"), ("graham", "Graham", "Named suspect", "Recently present", 58, "character-graham.svg"), ("maggie", "Maggie", "Absent presence", "Connected to Graham", 44, "character-maggie.svg")),
Assets(("letter", "Letter", "Still unread", 70, "asset-letter.svg"), ("notebook", "Notebook", "Confirms missing page", 62, "asset-notebook.svg")),
Relationships(("rel-beth-rosie", "beth", "rosie", "Relationship discovered", "cautious trust", 64), ("rel-graham-maggie", "graham", "maggie", "Possible conflict detected", "recent visit", 70)),
Threads(("thread-graham", "Why did Graham visit Maggie?", "Rosie's memory puts Graham at the flat before the search.", "question"), ("thread-maggie-secret", "Maggie's missing page", "The missing page may describe Graham's visit.", "mystery")),
Observations(("obs-rosie", "A witness changes the centre of gravity.", "Rosie grows because she supplies new causal information.", "insight")),
Discoveries("Possible relationship found: Graham and Maggie", "Character relevance increased: Rosie", "Timeline clue found: two nights before"),
Feed("Rosie promoted to important scene character", "Graham inferred from dialogue", "Relationship signal found")),
Scene(
"scene-04", 4, "The red TR6", "Beth spots Graham's red TR6 outside the estate and realises he has returned.",
"Extracting assets", "Fri 18 Oct", "Red TR6 returns to The Doweries", 36, 2, 12, "30 min",
"beth", Location("doweries", "The Doweries", "Driveway under sodium light", "location-doweries.svg"),
Characters(("beth", "Beth", "POV character", "Watching", 100, "character-beth.svg"), ("graham", "Graham", "Active threat", "Returned", 82, "character-graham.svg"), ("rosie", "Rosie", "Witness", "Off to the side", 36, "character-rosie.svg")),
Assets(("red-tr6", "Red TR6", "Graham's car", 96, "asset-red-tr6.svg"), ("letter", "Letter", "Unopened evidence", 56, "asset-letter.svg"), ("keys", "Keys", "Estate access", 48, "asset-keys.svg")),
Relationships(("rel-beth-graham", "beth", "graham", "Possible conflict detected", "avoidance", 78), ("rel-graham-maggie", "graham", "maggie", "Relationship strengthened", "unexplained visit", 74)),
Threads(("thread-graham", "Graham returns before Beth can leave.", "The car turns a memory into an immediate threat.", "question")),
Observations(("obs-tr6", "An asset becomes narrative evidence.", "The car is not background colour; it proves Graham is physically present.", "insight")),
Discoveries("Asset found: Red TR6", "Relationship signal found: Beth and Graham", "Location changed: The Doweries driveway"),
Feed("Scene 4 analysed", "Vehicle classified as significant asset", "Conflict confidence increased")),
Scene(
"scene-05", 5, "Graham on the stairs", "Graham blocks Beth on the stairwell and asks whether she has opened Maggie's letter.",
"Inferring relationships", "Fri 18 Oct", "Graham confronts Beth", 47, 3, 12, "26 min",
"beth", Location("stairwell", "The Doweries stairwell", "Narrow stairwell between flat and exit", "location-stairwell.svg"),
Characters(("beth", "Beth", "POV character", "Cornered", 100, "character-beth.svg"), ("graham", "Graham", "Opposing force", "Pressing", 96, "character-graham.svg"), ("maggie", "Maggie", "Absent pressure", "The reason for the question", 46, "character-maggie.svg")),
Assets(("letter", "Letter", "Graham wants it", 96, "asset-letter.svg"), ("keys", "Keys", "Exit blocked", 55, "asset-keys.svg")),
Relationships(("rel-beth-graham", "beth", "graham", "Relationship changed", "open conflict", 95), ("rel-graham-maggie", "graham", "maggie", "Suspicion strengthened", "knows about letter", 84)),
Threads(("thread-letter", "What is in Maggie's letter?", "Graham's question proves the letter contains something he fears.", "mystery"), ("thread-reader-knows", "Reader knows Graham is afraid of the letter.", "Beth now knows the letter has leverage.", "secret")),
Observations(("obs-stairs", "The relationship graph tightens.", "The scene is now Beth versus Graham, with the letter acting as pressure point.", "insight")),
Discoveries("Relationship changed: Beth and Graham", "Knowledge thread opened: letter contents", "Asset ownership pressure detected: Letter"),
Feed("Graham promoted to primary opposing character", "Letter confidence increased", "Conflict classified")),
Scene(
"scene-06", 6, "Rosie's warning", "Rosie pulls Beth into the laundry room and warns her not to trust Graham's version of Maggie.",
"Reading scenes", "Fri 18 Oct", "Rosie contradicts Graham", 58, 3, 12, "22 min",
"rosie", Location("laundry", "Laundry room", "Low-lit utility room behind the stairwell", "location-laundry.svg"),
Characters(("rosie", "Rosie", "POV character", "Warning Beth", 100, "character-rosie.svg"), ("beth", "Beth", "Protected listener", "Reassessing", 82, "character-beth.svg"), ("graham", "Graham", "Absent threat", "His story is challenged", 58, "character-graham.svg"), ("maggie", "Maggie", "Remembered", "Misrepresented", 42, "character-maggie.svg")),
Assets(("notebook", "Notebook", "Rosie saw it", 78, "asset-notebook.svg"), ("letter", "Letter", "Still decisive", 70, "asset-letter.svg")),
Relationships(("rel-beth-rosie", "beth", "rosie", "Relationship strengthened", "protective trust", 86), ("rel-rosie-graham", "rosie", "graham", "Possible conflict detected", "fear and mistrust", 74)),
Threads(("thread-graham", "Graham's account is unreliable.", "Rosie introduces contradiction and a motive to lie.", "question"), ("thread-letter", "The letter may expose Graham.", "Both Rosie and Graham orbit the same hidden evidence.", "mystery")),
Observations(("obs-pov-shift", "The POV changes to Rosie.", "The camera follows the emotional source of new information, not just the protagonist.", "insight")),
Discoveries("POV changed: Rosie", "Relationship strengthened: Beth and Rosie", "Contradictory evidence detected"),
Feed("Scene 6 started", "POV character changed", "Knowledge thread updated")),
Scene(
"scene-07", 7, "Notebook page", "Beth reconstructs the torn notebook page and links the missing line to the red TR6.",
"Building knowledge", "Fri 18 Oct", "Notebook and car clue connect", 69, 4, 12, "18 min",
"beth", Location("maggie-flat", "Maggie's flat", "Desk, lamplight and torn paper edge", "location-flat.svg"),
Characters(("beth", "Beth", "POV character", "Connecting clues", 100, "character-beth.svg"), ("maggie", "Maggie", "Absent author", "Left a trail", 70, "character-maggie.svg"), ("graham", "Graham", "Implied target", "Named by evidence", 50, "character-graham.svg")),
Assets(("notebook", "Notebook", "Torn page reconstructed", 96, "asset-notebook.svg"), ("red-tr6", "Red TR6", "Matches hidden note", 84, "asset-red-tr6.svg"), ("letter", "Letter", "Next evidence", 72, "asset-letter.svg")),
Relationships(("rel-beth-maggie", "beth", "maggie", "Relationship strengthened", "Maggie left Beth a trail", 90), ("rel-graham-maggie", "graham", "maggie", "Relationship changed", "direct evidence", 88)),
Threads(("thread-letter", "The letter is part of a trail.", "Maggie appears to have staged evidence for Beth to find.", "mystery"), ("thread-tr6", "The car links Graham to the missing page.", "Asset evidence connects two earlier scenes.", "secret")),
Observations(("obs-thread", "Knowledge threads begin to braid.", "PlotDirector links asset, relationship and location evidence into one story question.", "insight")),
Discoveries("Knowledge thread opened: Red TR6 link", "Relationship updated: Graham and Maggie", "Asset association found: Notebook and Red TR6"),
Feed("Scene 7 analysed", "Notebook promoted to key asset", "Cross-scene link inferred")),
Scene(
"scene-08", 8, "The call", "Beth hears Maggie's recorded message and realises the warning was meant for Rosie too.",
"Building knowledge", "Fri 18 Oct", "Recorded message expands the secret", 79, 4, 12, "14 min",
"beth", Location("maggie-flat", "Maggie's flat", "Phone speaker glowing on the table", "location-flat.svg"),
Characters(("beth", "Beth", "POV character", "Listening", 100, "character-beth.svg"), ("maggie", "Maggie", "Voice on recording", "Direct presence", 88, "character-maggie.svg"), ("rosie", "Rosie", "Named ally", "Also warned", 64, "character-rosie.svg"), ("graham", "Graham", "Threat", "Closing in", 54, "character-graham.svg")),
Assets(("letter", "Letter", "Names the danger", 86, "asset-letter.svg"), ("phone", "Phone", "Recorded message", 92, "asset-phone.svg"), ("notebook", "Notebook", "Supports warning", 66, "asset-notebook.svg")),
Relationships(("rel-beth-maggie", "beth", "maggie", "Relationship changed", "message from beyond absence", 94), ("rel-maggie-rosie", "maggie", "rosie", "Relationship discovered", "trusted ally", 72)),
Threads(("thread-letter", "Maggie's warning has two recipients.", "The secret is bigger than Beth's inheritance of the letter.", "mystery"), ("thread-reader-knows", "Reader now knows Rosie was part of Maggie's plan.", "Beth understands Rosie differently.", "secret")),
Observations(("obs-message", "An absent character becomes visually central.", "Maggie's portrait grows because the scene gives her direct agency.", "insight")),
Discoveries("Relationship discovered: Maggie and Rosie", "Asset found: Phone recording", "Observation created: absent character has agency"),
Feed("Scene 8 analysed", "Maggie promoted through recorded evidence", "Knowledge thread expanded")),
Scene(
"scene-09", 9, "Car park exit", "Beth and Rosie try to leave, but the red TR6 is parked across the only exit.",
"Inferring continuity", "Fri 18 Oct", "Exit blocked", 89, 5, 12, "9 min",
"beth", Location("car-park", "The Doweries car park", "Wet tarmac and one blocked exit", "location-car-park.svg"),
Characters(("beth", "Beth", "POV character", "Deciding", 100, "character-beth.svg"), ("rosie", "Rosie", "Ally", "At Beth's side", 84, "character-rosie.svg"), ("graham", "Graham", "Threat", "Controls the exit", 78, "character-graham.svg")),
Assets(("red-tr6", "Red TR6", "Blocks escape", 98, "asset-red-tr6.svg"), ("letter", "Letter", "Motive for pursuit", 74, "asset-letter.svg"), ("keys", "Keys", "Possible escape route", 62, "asset-keys.svg")),
Relationships(("rel-beth-rosie", "beth", "rosie", "Relationship strengthened", "active alliance", 92), ("rel-beth-graham", "beth", "graham", "Relationship intensified", "physical obstruction", 98)),
Threads(("thread-exit", "How do Beth and Rosie leave?", "The location and asset combine into an immediate continuity problem.", "question"), ("thread-letter", "Graham's pursuit confirms the letter's value.", "The hidden evidence now drives action.", "mystery")),
Observations(("obs-blocked", "Location, asset and relationship align.", "The car park is important because the red TR6 changes what characters can do.", "insight")),
Discoveries("Continuity observation: exit blocked", "Relationship intensified: Beth and Graham", "Asset state changed: Red TR6 blocks exit"),
Feed("Scene 9 analysed", "Location changed to car park", "Asset movement inferred")),
Scene(
"scene-10", 10, "The opened letter", "Beth opens Maggie's letter and the hidden pattern around Graham finally becomes explicit.",
"Preparing review", "Fri 18 Oct", "Letter reveals Graham's motive", 100, 5, 12, "Ready to review",
"beth", Location("car-park", "The Doweries car park", "Car lights, rain and an opened letter", "location-car-park.svg"),
Characters(("beth", "Beth", "POV character", "Understands", 100, "character-beth.svg"), ("maggie", "Maggie", "Truth teller", "Her plan revealed", 90, "character-maggie.svg"), ("rosie", "Rosie", "Ally", "Confirmed", 78, "character-rosie.svg"), ("graham", "Graham", "Exposed", "Motive visible", 74, "character-graham.svg")),
Assets(("letter", "Letter", "Opened evidence", 100, "asset-letter.svg"), ("red-tr6", "Red TR6", "Proof of presence", 76, "asset-red-tr6.svg"), ("notebook", "Notebook", "Corroborates letter", 68, "asset-notebook.svg")),
Relationships(("rel-beth-maggie", "beth", "maggie", "Relationship resolved", "trusted inheritance", 96), ("rel-beth-rosie", "beth", "rosie", "Relationship confirmed", "alliance", 90), ("rel-beth-graham", "beth", "graham", "Conflict confirmed", "motive exposed", 98)),
Threads(("thread-letter", "Maggie's letter explains Graham's motive.", "The central question becomes review-ready.", "mystery"), ("thread-tr6", "The Red TR6 evidence is confirmed.", "The car, notebook and letter point to the same conclusion.", "secret")),
Observations(("obs-complete", "PlotDirector has a coherent story map for this sequence.", "Characters, location, assets and relationships now tell one readable story.", "insight")),
Discoveries("Analysis complete: 10 scenes ready for review", "Relationship confirmed: Beth and Rosie", "Knowledge thread resolved: Letter contents"),
Feed("Scene 10 analysed", "Chapter sequence ready", "Review data prepared"))
]
};
private static StoryIntelligenceExperienceSceneState Scene(
string id,
int number,
string title,
string summary,
string stage,
string timelineLabel,
string timelineEvent,
int progress,
int chapters,
int sceneTotal,
string remaining,
string pov,
StoryIntelligenceExperienceLocationState location,
IReadOnlyList<StoryIntelligenceExperienceCharacterState> characters,
IReadOnlyList<StoryIntelligenceExperienceAssetState> assets,
IReadOnlyList<StoryIntelligenceExperienceRelationshipState> relationships,
IReadOnlyList<StoryIntelligenceExperienceTextItem> threads,
IReadOnlyList<StoryIntelligenceExperienceTextItem> observations,
IReadOnlyList<string> discoveries,
IReadOnlyList<string> feed)
=> new()
{
Id = id,
ChapterLabel = $"Chapter {Math.Max(1, chapters)}",
SceneNumber = number,
Title = title,
Summary = summary,
Stage = stage,
TimelineLabel = timelineLabel,
TimelineEvent = timelineEvent,
ProgressPercent = progress,
ChaptersAnalysed = chapters,
ChapterTotal = 5,
ScenesAnalysed = number,
SceneTotal = sceneTotal,
ElapsedTime = $"{Math.Max(1, number * 2)}m",
EstimatedRemaining = remaining,
PovCharacterId = pov,
Location = location,
Characters = characters,
Assets = assets,
Relationships = relationships,
KnowledgeThreads = threads,
Observations = observations,
LatestDiscoveries = discoveries,
ActivityFeed = feed
};
private static StoryIntelligenceExperienceLocationState Location(string id, string name, string label, string image)
=> new() { Id = id, LibraryCode = LocationLibraryCode(id), Name = name, Label = label, ImagePath = $"{AssetRoot}/{image}" };
private static IReadOnlyList<StoryIntelligenceExperienceCharacterState> Characters(params (string Id, string Name, string Role, string Relevance, int Weight, string Image)[] items)
=> items.Select(item => new StoryIntelligenceExperienceCharacterState { Id = item.Id, LibraryCode = CharacterLibraryCode(item.Id), Name = item.Name, Role = item.Role, Relevance = item.Relevance, Weight = item.Weight, ImagePath = $"{AssetRoot}/{item.Image}" }).ToList();
private static IReadOnlyList<StoryIntelligenceExperienceAssetState> Assets(params (string Id, string Name, string Label, int Weight, string Image)[] items)
=> items.Select(item => new StoryIntelligenceExperienceAssetState { Id = item.Id, LibraryCode = AssetLibraryCode(item.Id), Name = item.Name, Label = item.Label, Weight = item.Weight, ImagePath = $"{AssetRoot}/{item.Image}" }).ToList();
private static IReadOnlyList<StoryIntelligenceExperienceRelationshipState> Relationships(params (string Id, string Source, string Target, string Label, string State, int Weight)[] items)
=> items.Select(item => new StoryIntelligenceExperienceRelationshipState { Id = item.Id, SourceId = item.Source, TargetId = item.Target, Label = item.Label, State = item.State, Weight = item.Weight }).ToList();
private static IReadOnlyList<StoryIntelligenceExperienceTextItem> Threads(params (string Id, string Title, string Detail, string Tone)[] items)
=> TextItems(items);
private static IReadOnlyList<StoryIntelligenceExperienceTextItem> Observations(params (string Id, string Title, string Detail, string Tone)[] items)
=> TextItems(items);
private static IReadOnlyList<StoryIntelligenceExperienceTextItem> TextItems(params (string Id, string Title, string Detail, string Tone)[] items)
=> items.Select(item => new StoryIntelligenceExperienceTextItem { Id = item.Id, Title = item.Title, Detail = item.Detail, Tone = item.Tone }).ToList();
private static IReadOnlyList<string> Discoveries(params string[] items) => items;
private static IReadOnlyList<string> Feed(params string[] items) => items;
private static string CharacterLibraryCode(string id)
=> id switch
{
"beth" => "char-young-adult-brunette-observer",
"rosie" => "char-young-adult-red-haired-witness",
"graham" => "char-middle-aged-weathered-man",
"maggie" => "char-older-soft-presence",
_ => "char-unknown-figure"
};
private static string LocationLibraryCode(string id)
=> id switch
{
"doweries" => "loc-estate-house-exterior",
"maggie-flat" => "loc-small-flat-interior",
"laundry" => "loc-laundry-utility-room",
"stairwell" => "loc-narrow-stairwell",
"car-park" => "loc-wet-car-park",
_ => "loc-domestic-desk"
};
private static string AssetLibraryCode(string id)
=> id switch
{
"letter" => "asset-folded-letter",
"notebook" => "asset-worn-notebook",
"red-tr6" => "asset-red-classic-car",
"keys" => "asset-old-keys",
"rucksack" => "asset-rucksack",
"phone" => "asset-phone-recorder",
_ => "asset-locked-box"
};
}

View File

@ -16,6 +16,7 @@ public interface IStoryIntelligenceFullChapterTestService
public sealed class StoryIntelligenceFullChapterTestService( public sealed class StoryIntelligenceFullChapterTestService(
IStoryIntelligenceResultRepository runs, IStoryIntelligenceResultRepository runs,
IStoryIntelligencePipelineStateService pipelines,
IOptions<StoryIntelligenceOptions> options, IOptions<StoryIntelligenceOptions> options,
IOptions<StoryIntelligencePricingOptions> pricingOptions, IOptions<StoryIntelligencePricingOptions> pricingOptions,
ILogger<StoryIntelligenceFullChapterTestService> logger) : IStoryIntelligenceFullChapterTestService ILogger<StoryIntelligenceFullChapterTestService> logger) : IStoryIntelligenceFullChapterTestService
@ -104,6 +105,10 @@ public sealed class StoryIntelligenceFullChapterTestService(
Model = StoryIntelligenceOptions.BuildModelSummary(modelSelection.ChapterStructureModel, modelSelection.SceneIntelligenceModel), Model = StoryIntelligenceOptions.BuildModelSummary(modelSelection.ChapterStructureModel, modelSelection.SceneIntelligenceModel),
PromptVersionsSummary = PromptVersionsSummary PromptVersionsSummary = PromptVersionsSummary
}); });
if (form.ProjectID is int projectId && form.BookID is int bookId)
{
await pipelines.RecordChapterAnalysisQueuedAsync(projectId, bookId, runId);
}
logger.LogInformation( logger.LogInformation(
"Queued full chapter Story Intelligence test run {StoryIntelligenceRunID}. UserID={UserID} ProjectID={ProjectID} BookID={BookID} ChapterID={ChapterID} CharacterCount={CharacterCount} WordCount={WordCount} ParagraphCount={ParagraphCount} EstimatedInputTokens={EstimatedInputTokens} ChapterStructureModel={ChapterStructureModel} SceneIntelligenceModel={SceneIntelligenceModel} SourceFileName={SourceFileName}", "Queued full chapter Story Intelligence test run {StoryIntelligenceRunID}. UserID={UserID} ProjectID={ProjectID} BookID={BookID} ChapterID={ChapterID} CharacterCount={CharacterCount} WordCount={WordCount} ParagraphCount={ParagraphCount} EstimatedInputTokens={EstimatedInputTokens} ChapterStructureModel={ChapterStructureModel} SceneIntelligenceModel={SceneIntelligenceModel} SourceFileName={SourceFileName}",

View File

@ -0,0 +1,797 @@
using System.Text.Json;
using System.Text.RegularExpressions;
using PlotLine.Models;
namespace PlotLine.Services;
public static class StoryIntelligenceIllustrationCompatibility
{
private static readonly Regex RoadSuffixRegex = new(@"\b[A-Z0-9][\w' -]*\s(road|street|lane|drive|close|court|way|avenue|terrace|gardens|highway|motorway|bridge|roundabout|junction|crossroads|carriageway|track|footpath|path|cycleway|tunnel|flyover|underpass)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled);
public static class AgeBands
{
public const string Child = "Child";
public const string YoungTeen = "YoungTeen";
public const string OlderTeen = "OlderTeen";
public const string YoungAdult = "YoungAdult";
public const string Adult = "Adult";
public const string MatureAdult = "MatureAdult";
public const string Senior = "Senior";
public const string Unknown = "Unknown";
}
public static class Presentations
{
public const string Feminine = "Feminine";
public const string Masculine = "Masculine";
public const string Androgynous = "Androgynous";
public const string Unknown = "Unknown";
}
public static class HairColours
{
public const string Black = "Black";
public const string Brown = "Brown";
public const string Blonde = "Blonde";
public const string Red = "Red";
public const string Grey = "Grey";
public const string White = "White";
public const string Bald = "Bald";
public const string Unknown = "Unknown";
}
public static class SkinTones
{
public const string Light = "Light";
public const string Medium = "Medium";
public const string Dark = "Dark";
public const string Unknown = "Unknown";
}
public static CharacterEvidenceProfile InferCharacterEvidence(string name, IEnumerable<string> identityValues, IEnumerable<string> relationshipValues)
{
var identityText = $" {string.Join(' ', identityValues.Where(value => !string.IsNullOrWhiteSpace(value))).ToLowerInvariant()} ";
var identityScopedText = $" {string.Join(". ", identityValues.Where(value => !string.IsNullOrWhiteSpace(value))).ToLowerInvariant()} ";
var relationshipText = $" {string.Join(". ", relationshipValues.Where(value => !string.IsNullOrWhiteSpace(value))).ToLowerInvariant()} ";
var nameText = $" {name.ToLowerInvariant()} ";
var entityText = $"{nameText} {identityText}";
var scopedText = EntityScopedText($"{nameText} {identityScopedText}", name);
var relationshipSafeText = RelationshipScopedText(relationshipText, name);
var titlePresentation = TitlePresentation(scopedText);
var relationshipPresentation = RelationshipPresentation(relationshipSafeText);
var entityPresentation = RelationshipPresentation(scopedText);
var pronounPresentation = PronounPresentation(scopedText);
var namePresentation = NamePresentation(nameText);
var presentation = FirstKnown(
titlePresentation.Value,
entityPresentation.Value,
relationshipPresentation.Value,
pronounPresentation.Value,
namePresentation.Value,
Presentations.Unknown);
var presentationConfidence = new[] { titlePresentation.Confidence, entityPresentation.Confidence, relationshipPresentation.Confidence, pronounPresentation.Confidence, namePresentation.Confidence }.Max();
var warnings = new List<string>();
var guardedPresentation = ApplyPresentationGuard(
name,
scopedText,
presentation,
presentationConfidence,
warnings);
presentation = guardedPresentation.Value;
presentationConfidence = guardedPresentation.Confidence;
var explicitAge = ExplicitAgeBand(scopedText);
if (explicitAge.Value == AgeBands.Unknown)
{
explicitAge = EntityIdentityAgeBand(name, identityText);
}
var relationshipAge = RelationshipAgeBand(name, scopedText);
var namedAge = NameAgeBand(name);
var age = FirstKnown(explicitAge.Value, relationshipAge.Value, namedAge.Value, AgeBands.Unknown);
var ageConfidence = new[] { explicitAge.Confidence, relationshipAge.Confidence, namedAge.Confidence }.Max();
var attributeText = EntityAttributeText(entityText, name);
var hairColour = HairColour(attributeText);
var skinTone = SkinTone(attributeText);
var hairLength = HairLength(attributeText);
return new(
age,
presentation,
hairColour,
skinTone,
hairLength,
ContainsAny(attributeText, "glasses", "spectacles") ? "Yes" : "Unknown",
ContainsAny(attributeText, "moustache", "mustache") ? "Moustache" : ContainsAny(attributeText, "beard", "bearded") ? "Beard" : "Unknown",
ageConfidence,
presentationConfidence,
relationshipPresentation.Confidence,
namePresentation.Confidence,
pronounPresentation.Confidence,
warnings);
}
public static IllustrationCharacterMetadata CharacterMetadataFrom(IllustrationLibraryItem item)
{
IllustrationGenerationSpecification? specification = null;
Dictionary<string, string>? metadata = null;
if (!string.IsNullOrWhiteSpace(item.MetadataJson))
{
try
{
metadata = JsonSerializer.Deserialize<Dictionary<string, string>>(item.MetadataJson, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
}
catch (JsonException)
{
}
}
if (!string.IsNullOrWhiteSpace(item.SpecificationJson))
{
try
{
specification = JsonSerializer.Deserialize<IllustrationGenerationSpecification>(item.SpecificationJson, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
}
catch (JsonException)
{
}
}
var stableCode = item.StableCode ?? string.Empty;
var isUnknownFigure = BoolMetadata(metadata, "isUnknownFigure") || stableCode.Contains("unknown-figure", StringComparison.OrdinalIgnoreCase);
var isGenericFallback = BoolMetadata(metadata, "isGenericFallback") || stableCode.Contains("fallback", StringComparison.OrdinalIgnoreCase);
var suitableForNamed = !isUnknownFigure && !isGenericFallback && !BoolMetadata(metadata, "notSuitableForNamedCharacter");
return new(
NormalizeAgeBand(FirstMetadata(metadata, "ageBand", "apparentAgeBand") ?? specification?.ApparentAgeBand ?? stableCode),
NormalizePresentation(FirstMetadata(metadata, "presentation") ?? specification?.Presentation ?? stableCode),
NormalizeHairColour(FirstMetadata(metadata, "hairColour") ?? specification?.HairColour ?? stableCode),
NormalizeSkinTone(FirstMetadata(metadata, "skinTone") ?? specification?.SkinTone ?? stableCode),
NormalizeHairLength(FirstMetadata(metadata, "hairLength") ?? specification?.HairLength ?? stableCode),
FirstMetadata(metadata, "glasses") ?? (FeatureValue(specification?.Features, "Glasses") ? "Yes" : "Unknown"),
FirstMetadata(metadata, "facialHair") ?? (FeatureValue(specification?.Features, "Beard") ? "Beard" : FeatureValue(specification?.Features, "Moustache") ? "Moustache" : "Unknown"),
isUnknownFigure,
isGenericFallback,
suitableForNamed,
item.PromptTemplateVersion,
FirstMetadata(metadata, "generationSource", "source") ?? string.Empty);
}
public static IReadOnlyList<string> CharacterHardRejections(CharacterEvidenceProfile evidence, IllustrationCharacterMetadata candidate, bool isNamedCharacter, bool alreadyAssignedToAnotherSignificantCharacter)
{
var rejected = new List<string>();
if (isNamedCharacter && candidate.IsUnknownFigure)
{
rejected.Add("named character cannot use Unknown Figure artwork");
}
if (isNamedCharacter && !candidate.IsSuitableForNamedCharacter)
{
rejected.Add("candidate is not suitable for strict named-character matching");
}
if (AgeIncompatible(evidence.AgeBand, candidate.AgeBand))
{
rejected.Add($"age hard mismatch: wanted {evidence.AgeBand}, candidate {candidate.AgeBand}");
}
if (string.Equals(evidence.AgeBand, AgeBands.Unknown, StringComparison.OrdinalIgnoreCase)
&& candidate.AgeBand is AgeBands.Child or AgeBands.YoungTeen or AgeBands.OlderTeen)
{
rejected.Add("unknown age requires adult-compatible artwork unless explicit child or teen evidence exists");
}
if (evidence.HasStrongPresentationEvidence
&& PresentationIncompatible(evidence.Presentation, candidate.Presentation))
{
rejected.Add($"presentation hard mismatch: wanted {evidence.Presentation}, candidate {candidate.Presentation}");
}
if (!string.Equals(evidence.HairColour, HairColours.Unknown, StringComparison.OrdinalIgnoreCase)
&& string.Equals(candidate.HairColour, HairColours.Unknown, StringComparison.OrdinalIgnoreCase)
&& isNamedCharacter)
{
rejected.Add($"named character with {evidence.HairColour} hair needs hair-specific artwork");
}
if (!string.Equals(evidence.HairColour, HairColours.Unknown, StringComparison.OrdinalIgnoreCase)
&& !string.Equals(candidate.HairColour, HairColours.Unknown, StringComparison.OrdinalIgnoreCase)
&& KnownMismatch(evidence.HairColour, candidate.HairColour))
{
rejected.Add($"hair colour hard mismatch: wanted {evidence.HairColour}, candidate {candidate.HairColour}");
}
if (!string.Equals(evidence.SkinTone, SkinTones.Unknown, StringComparison.OrdinalIgnoreCase)
&& !string.Equals(candidate.SkinTone, SkinTones.Unknown, StringComparison.OrdinalIgnoreCase)
&& KnownMismatch(evidence.SkinTone, candidate.SkinTone))
{
rejected.Add($"skin tone hard mismatch: wanted {evidence.SkinTone}, candidate {candidate.SkinTone}");
}
if (alreadyAssignedToAnotherSignificantCharacter && isNamedCharacter)
{
rejected.Add("already assigned to another significant named character");
}
return rejected;
}
public static string NormalizeAgeBand(string? value)
{
var text = (value ?? string.Empty).Replace("-", string.Empty, StringComparison.OrdinalIgnoreCase).Replace(" ", string.Empty, StringComparison.OrdinalIgnoreCase).ToLowerInvariant();
if (text.Contains("youngteen") || text.Contains("13") || text.Contains("14") || text.Contains("15") || text.Contains("fifteen")) return AgeBands.YoungTeen;
if (text.Contains("olderteen") || text.Contains("teen") || text.Contains("16") || text.Contains("17") || text.Contains("18") || text.Contains("19")) return AgeBands.OlderTeen;
if (text.Contains("child")) return AgeBands.Child;
if (text.Contains("youngadult")) return AgeBands.YoungAdult;
if (text.Contains("middleaged") || text.Contains("matureadult") || text.Contains("parent") || text.Contains("mother") || text.Contains("father") || text.Contains("aunt") || text.Contains("uncle")) return AgeBands.MatureAdult;
if (text.Contains("adult")) return AgeBands.Adult;
if (text.Contains("senior") || text.Contains("elderly") || text.Contains("older") || text.Contains("grandmother") || text.Contains("grandfather")) return AgeBands.Senior;
return AgeBands.Unknown;
}
public static string NormalizePresentation(string? value)
{
var text = (value ?? string.Empty).ToLowerInvariant();
var padded = $" {text} ";
if (ContainsAny(padded, " she ", " her ", " hers ", " herself ")) return Presentations.Feminine;
if (ContainsAny(padded, " he ", " him ", " his ", " himself ")) return Presentations.Masculine;
if (ContainsAny(text, "feminine", "female", "woman", "girl", "mother", "mum", "mrs", "miss", "ms", "aunt", "sister", "daughter", "wife", "grandmother")) return Presentations.Feminine;
if (ContainsAny(text, "masculine", "male", "man", "boy", "father", "dad", "mr", "uncle", "brother", "son", "husband", "grandfather")) return Presentations.Masculine;
if (text.Contains("androgynous", StringComparison.OrdinalIgnoreCase)) return Presentations.Androgynous;
return Presentations.Unknown;
}
public static string NormalizeHairColour(string? value)
{
var text = (value ?? string.Empty).ToLowerInvariant();
if (text.Contains("bald", StringComparison.OrdinalIgnoreCase)) return HairColours.Bald;
if (text.Contains("black", StringComparison.OrdinalIgnoreCase)) return HairColours.Black;
if (ContainsAny(text, "brown", "brunette", "darkbrown", "dark hair")) return HairColours.Brown;
if (ContainsAny(text, "blonde", "fair hair")) return HairColours.Blonde;
if (ContainsAny(text, "red", "auburn", "ginger")) return HairColours.Red;
if (ContainsAny(text, "grey", "gray")) return HairColours.Grey;
if (text.Contains("white", StringComparison.OrdinalIgnoreCase)) return HairColours.White;
return HairColours.Unknown;
}
public static string NormalizeSkinTone(string? value)
{
var text = (value ?? string.Empty).ToLowerInvariant();
if (ContainsAny(text, "deep", "dark", "black skin")) return SkinTones.Dark;
if (ContainsAny(text, "medium", "lightmedium", "olive", "brown skin")) return SkinTones.Medium;
if (ContainsAny(text, "light", "pale", "fair skin")) return SkinTones.Light;
return SkinTones.Unknown;
}
public static string NormalizeHairLength(string? value)
{
var text = (value ?? string.Empty).ToLowerInvariant();
if (text.Contains("bald", StringComparison.OrdinalIgnoreCase)) return "Bald";
if (text.Contains("short", StringComparison.OrdinalIgnoreCase)) return "Short";
if (text.Contains("long", StringComparison.OrdinalIgnoreCase)) return "Long";
if (text.Contains("medium", StringComparison.OrdinalIgnoreCase) || text.Contains("shoulder", StringComparison.OrdinalIgnoreCase)) return "Medium";
return "Unknown";
}
public static string LocationType(string? value)
{
var text = (value ?? string.Empty).ToLowerInvariant();
var padded = $" {text} ";
if (ContainsAny(text, "dual carriageway")) return "DualCarriageway";
if (ContainsAny(text, "motorway", "highway")) return "Motorway";
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", "court", "way", "terrace", "gardens",
"roundabout", "junction", "crossroads",
"track", "footpath", " path ", "cycleway", "boulevard")
|| RoadSuffixRegex.IsMatch(padded))
{
return "Road";
}
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, "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, "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, "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, "bridge")) return "Bridge";
if (ContainsAny(text, "tunnel")) return "Tunnel";
if (text.Contains("shop", StringComparison.OrdinalIgnoreCase)) return "Shop";
if (ContainsAny(text, "bench", "bus stop")) return "StreetFurniture";
if (ContainsAny(text, "flat", "apartment", "room")) return "FlatInterior";
return "UnknownInterior";
}
public static string AssetType(string? value)
{
var text = (value ?? string.Empty).ToLowerInvariant();
var padded = $" {text} ";
if (ContainsAny(text, "driving licence", "driver licence", "provisional licence", "license", "licence")) return "DrivingLicence";
if (ContainsAny(text, "highway code")) return "DrivingManual";
if (ContainsAny(text, "passport")) return "Passport";
if (ContainsAny(text, "certificate", "pass certificate")) return "Certificate";
if (ContainsAny(text, "envelope")) return "Envelope";
if (ContainsAny(text, "traffic sign", "road sign", "street sign")) return "TrafficSign";
if (ContainsAny(text, "clock", "watch")) return "Clock";
if (ContainsAny(text, "wallet", "purse")) return "Wallet";
if (ContainsAny(text, "handbag", "bag")) return "Handbag";
if (ContainsAny(text, "newspaper")) return "Newspaper";
if (ContainsAny(text, "magazine")) return "Magazine";
if (ContainsAny(text, "painting", "picture frame")) return "Painting";
if (ContainsAny(text, "bench")) return "Bench";
if (ContainsAny(text, "tree")) return "Tree";
if (ContainsAny(text, "lamp", "light")) return "Lamp";
if (ContainsAny(text, "window")) return "Window";
if (ContainsAny(text, "door")) return "Door";
if (ContainsAny(text, "fence", "gate")) return "Fence";
if (ContainsAny(text, "gift", "present")) return "Gift";
if (text.Contains("ambulance", StringComparison.OrdinalIgnoreCase)) return "Ambulance";
if (text.Contains("stretcher", StringComparison.OrdinalIgnoreCase)) return "Stretcher";
if (text.Contains("body bag", StringComparison.OrdinalIgnoreCase)) return "BodyBag";
if (text.Contains("bottle", StringComparison.OrdinalIgnoreCase)) return "Bottle";
if (ContainsAny(text, "money", "cash", "coins", "banknote")) return "Money";
if (ContainsAny(text, "scarf", "scarves")) return "Clothing";
if (ContainsAny(text, "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, " 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", "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", "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";
if (ContainsAny(text, "document", "paper")) return "Document";
return "UnknownObject";
}
public static string AssetColour(string? value)
{
var text = (value ?? string.Empty).ToLowerInvariant();
if (ContainsAny(text, "blue", "navy")) return "Blue";
if (ContainsAny(text, "red", "scarlet", "crimson")) return "Red";
if (ContainsAny(text, "green")) return "Green";
if (ContainsAny(text, "yellow")) return "Yellow";
if (ContainsAny(text, "black")) return "Black";
if (ContainsAny(text, "white", "cream")) return "White";
if (ContainsAny(text, "grey", "gray", "silver")) return "Grey";
if (ContainsAny(text, "brown", "tan")) return "Brown";
return "Unknown";
}
public static bool LocationCompatible(string requestedType, string candidateType)
=> requestedType == candidateType
|| (requestedType is "Road" or "Lane" or "Street" or "Avenue" or "Drive" or "Close" && candidateType is "Road" or "Lane" or "Street")
|| (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");
public static bool AssetCompatible(string requestedType, string candidateType)
=> requestedType == candidateType
|| (requestedType == "Hatchback" && candidateType == "Car")
|| (requestedType is "Saloon" or "Estate" && candidateType == "Car")
|| (requestedType == "Note" && candidateType is "Letter" or "Document")
|| (requestedType == "Rucksack" && candidateType == "Rucksack");
public static bool IsGroupEntity(string? value)
{
var clean = (value ?? string.Empty).Trim().ToLowerInvariant();
return clean.StartsWith("group of ", StringComparison.Ordinal)
|| clean.StartsWith("pair of ", StringComparison.Ordinal)
|| clean.StartsWith("two ", StringComparison.Ordinal)
|| clean.Contains(" group of ", StringComparison.Ordinal)
|| clean.Contains(" / group of ", StringComparison.Ordinal)
|| clean.EndsWith('s')
&& (clean.Contains("social worker", StringComparison.Ordinal)
|| clean.Contains("neighbour", StringComparison.Ordinal)
|| clean.Contains("neighbor", StringComparison.Ordinal)
|| clean.Contains("police officer", StringComparison.Ordinal)
|| clean.Contains("student", StringComparison.Ordinal)
|| clean.Contains("examiner", StringComparison.Ordinal)
|| clean.Contains("uniformed", StringComparison.Ordinal)
|| clean.Contains("girl", StringComparison.Ordinal)
|| clean.Contains("responder", StringComparison.Ordinal))
|| clean is "crowd" or "uniformed people" or "police" or "social services";
}
public static string PresentationGuardWarning(string name, string attemptedPresentation, string correctedPresentation)
=> $"WARNING: Entity evidence guard corrected {name} presentation from {attemptedPresentation} to {correctedPresentation}; title/name hard evidence overrode leaked or unrelated evidence.";
private static EvidenceSignal ExplicitAgeBand(string text)
{
if (ContainsAny(text, "15", "fifteen")) return new(AgeBands.YoungTeen, 0.98m);
if (ContainsAny(text, "13", "thirteen", "14", "fourteen")) return new(AgeBands.YoungTeen, 0.98m);
if (ContainsAny(text, "16", "sixteen", "17", "seventeen", "18", "eighteen", "19", "nineteen")) return new(AgeBands.OlderTeen, 0.98m);
if (ContainsAny(text, "toddler", "baby", "infant", "newborn")) return new(AgeBands.Child, 0.98m);
if (ContainsAny(text, "child", "young child", "schoolboy", "schoolgirl", "ten-year-old", "ten year old", "aged 10", "aged 11", "aged 12")) return new(AgeBands.Child, 0.9m);
if (ContainsAny(text, "teenager", "teenage")) return new(AgeBands.OlderTeen, 0.86m);
if (ContainsAny(text, "young adult", "young woman", "young man")) return new(AgeBands.YoungAdult, 0.82m);
if (ContainsAny(text, "middle-aged", "middle aged", "mature adult")) return new(AgeBands.MatureAdult, 0.86m);
if (ContainsAny(text, "driving test centre", "driving test center", "learner driver", "driving test")
&& !ContainsAny(text, " is a driving examiner", " as a driving examiner", " female driving examiner", " male driving examiner", " driving instructor", " is an instructor", " as an instructor"))
{
return new(AgeBands.OlderTeen, 0.9m);
}
if (ContainsAny(text, " mr ", " mr. ", " mrs ", " mrs. ", " miss ", " miss. ", " ms ", " ms. ", " doctor", " dr ", " dr. ", " professor", " driving examiner", " teacher", " detective", " police officer", " nurse", " receptionist", " solicitor", " landlord", " manager", "social worker")) return new(AgeBands.Adult, 0.88m);
if (ContainsAny(text, "elderly", "senior", "old woman", "old man")) return new(AgeBands.Senior, 0.9m);
return EvidenceSignal.Unknown;
}
private static EvidenceSignal EntityIdentityAgeBand(string name, string identityText)
{
var cleanName = (name ?? string.Empty).Trim().ToLowerInvariant();
if (ContainsAny(cleanName, "mother", " mum", "father", " dad", "parent", "aunt", "uncle", "grandmother", "grandfather"))
{
return EvidenceSignal.Unknown;
}
return ExplicitAgeBand(identityText);
}
private static EvidenceSignal RelationshipAgeBand(string name, string text)
{
if (ContainsAny(text, "toddler", "baby", "newborn")) return new(AgeBands.Child, 0.96m);
var identity = $"{name} {text}";
if (ContainsAny(identity, "mother", " mum", "father", " dad", "parent", "aunt", "uncle")
&& !PossessiveOfOtherIdentity(name, text))
{
return new(AgeBands.MatureAdult, 0.82m);
}
if (ContainsAny(identity, "teacher", "driving examiner", "detective", "police officer", "nurse", "receptionist", "solicitor", "landlord", "manager", "professor", "doctor")
&& !PossessiveOfOtherIdentity(name, text))
{
return new(AgeBands.Adult, 0.82m);
}
if (ContainsAny(text, "grandmother", "grandfather")) return new(AgeBands.Senior, 0.82m);
if (ContainsAny(text, "daughter", "son", "sister", "brother")) return new(AgeBands.Unknown, 0.3m);
return EvidenceSignal.Unknown;
}
private static EvidenceSignal NameAgeBand(string name)
{
return EvidenceSignal.Unknown;
}
private static EvidenceSignal TitlePresentation(string text)
{
if (ContainsAny(text, " mrs ", " mrs. ", " miss ", " miss. ", " ms ", " ms. ", " lady ")) return new(Presentations.Feminine, 0.96m);
if (ContainsAny(text, " mr ", " mr. ", " sir ")) return new(Presentations.Masculine, 0.96m);
return EvidenceSignal.Unknown;
}
private static EvidenceSignal RelationshipPresentation(string text)
{
if (ContainsAny(text, "female", "mother", " mum", "mummy", "aunt", "sister", "daughter", "wife", "widow", "grandmother", " niece", "woman", "girl", "lady", "queen")) return new(Presentations.Feminine, 0.92m);
if (ContainsAny(text, "male", "gentleman", "father", " dad", "daddy", "uncle", "brother", "son", "husband", "widower", "grandfather", " nephew", "man", "boy", "lad", "bloke", "chap", "king")) return new(Presentations.Masculine, 0.92m);
return EvidenceSignal.Unknown;
}
private static EvidenceSignal PronounPresentation(string text)
{
if (ContainsAny(text, " she ", " her ", " hers ", " herself ")) return new(Presentations.Feminine, 0.86m);
if (ContainsAny(text, " he ", " him ", " his ", " himself ")) return new(Presentations.Masculine, 0.86m);
return EvidenceSignal.Unknown;
}
private static EvidenceSignal NamePresentation(string text)
{
if (ContainsAny(text, " alison ", " annie ", " anna ", " anne ", " beth ", " catherine ", " chrissy ", " grace ", " helen ", " elen ", " judy ", " katherine ", " maggie ", " mary ", " rebecca ", " rosie ", " sharon ", " sophie ", " susan ", " zoe ")) return new(Presentations.Feminine, 0.82m);
if (ContainsAny(text, " adam ", " colin ", " david ", " gareth ", " george ", " graham ", " greg ", " john ", " kevin ", " michael ", " peter ", " phil ", " richard ", " rick ", " rob ", " roger ", " simon ")) return new(Presentations.Masculine, 0.82m);
return EvidenceSignal.Unknown;
}
private static EvidenceSignal ApplyPresentationGuard(string name, string scopedText, string presentation, decimal confidence, ICollection<string> warnings)
{
var nameSignal = NameOrTitlePresentation(name);
if (nameSignal.Value == Presentations.Unknown)
{
return new(presentation, confidence);
}
if (presentation == Presentations.Unknown || presentation == nameSignal.Value)
{
return new(nameSignal.Value, Math.Max(confidence, nameSignal.Confidence));
}
if (HasExplicitContradictoryPresentation(name, scopedText, presentation))
{
warnings.Add($"WARNING: {name} has explicit contradictory presentation evidence: {presentation} conflicts with {nameSignal.Value} name/title evidence.");
return new(presentation, confidence);
}
warnings.Add(PresentationGuardWarning(name, presentation, nameSignal.Value));
return new(nameSignal.Value, Math.Max(confidence, nameSignal.Confidence));
}
private static EvidenceSignal NameOrTitlePresentation(string name)
{
var clean = CleanWords(name);
if (clean.Length == 0)
{
return EvidenceSignal.Unknown;
}
if (StartsWithAny(clean, "mr", "father", "dad", "uncle", "brother", "son", "boy", "man", "male")
|| clean is "adam" or "colin" or "david" or "gareth" or "george" or "graham" or "greg" or "john" or "kevin" or "michael" or "peter" or "phil" or "richard" or "rick" or "rob" or "roger" or "simon")
{
return new(Presentations.Masculine, 0.98m);
}
if (StartsWithAny(clean, "mrs", "miss", "ms", "mother", "mum", "mom", "aunt", "sister", "daughter", "girl", "woman", "female")
|| clean is "alison" or "annie" or "anna" or "anne" or "beth" or "catherine" or "chrissy" or "grace" or "helen" or "elen" or "judy" or "katherine" or "maggie" or "mary" or "rebecca" or "rosie" or "sharon" or "sophie" or "susan" or "zoe")
{
return new(Presentations.Feminine, 0.98m);
}
return EvidenceSignal.Unknown;
}
private static bool HasExplicitContradictoryPresentation(string name, string scopedText, string attemptedPresentation)
{
var clean = CleanWords(name);
if (clean.Length == 0 || string.IsNullOrWhiteSpace(scopedText))
{
return false;
}
var text = CleanWords(scopedText);
var contradictoryTerms = attemptedPresentation == Presentations.Feminine
? new[] { "female", "woman", "girl" }
: attemptedPresentation == Presentations.Masculine
? new[] { "male", "man", "boy" }
: [];
return contradictoryTerms.Any(term =>
text.Contains($"{clean} is {term}", StringComparison.OrdinalIgnoreCase)
|| text.Contains($"{clean} was {term}", StringComparison.OrdinalIgnoreCase)
|| text.Contains($"{clean} as {term}", StringComparison.OrdinalIgnoreCase));
}
private static string HairColour(string text) => NormalizeHairColour(text);
private static string SkinTone(string text) => NormalizeSkinTone(text);
private static string HairLength(string text) => NormalizeHairLength(text);
private static bool AgeIncompatible(string evidence, string candidate)
=> (evidence, candidate) switch
{
(AgeBands.Child, not AgeBands.Child) => true,
(AgeBands.YoungTeen, AgeBands.Child or AgeBands.YoungAdult or AgeBands.Adult or AgeBands.MatureAdult or AgeBands.Senior) => true,
(AgeBands.OlderTeen, AgeBands.Child or AgeBands.YoungTeen or AgeBands.MatureAdult or AgeBands.Senior) => true,
(AgeBands.Adult, AgeBands.Child or AgeBands.YoungTeen or AgeBands.OlderTeen) => true,
(AgeBands.MatureAdult, AgeBands.Child or AgeBands.YoungTeen or AgeBands.OlderTeen) => true,
(AgeBands.Senior, AgeBands.Child or AgeBands.YoungTeen or AgeBands.OlderTeen or AgeBands.YoungAdult) => true,
_ => false
};
private static bool PresentationIncompatible(string evidence, string candidate)
=> (evidence, candidate) is (Presentations.Feminine, Presentations.Masculine) or (Presentations.Masculine, Presentations.Feminine);
private static bool ExplicitKnown(string value, decimal confidence)
=> !string.IsNullOrWhiteSpace(value) && value != "Unknown" && confidence >= 0.85m;
private static bool KnownMismatch(string evidence, string candidate)
=> !string.Equals(evidence, "Unknown", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(candidate, "Unknown", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(evidence, candidate, StringComparison.OrdinalIgnoreCase);
private static string FirstKnown(params string[] values)
=> values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value) && !string.Equals(value, "Unknown", StringComparison.OrdinalIgnoreCase)) ?? "Unknown";
private static string? FirstMetadata(Dictionary<string, string>? metadata, params string[] keys)
=> keys.Select(key => metadata?.GetValueOrDefault(key)).FirstOrDefault(value => !string.IsNullOrWhiteSpace(value));
private static bool BoolMetadata(Dictionary<string, string>? metadata, string key)
=> bool.TryParse(metadata?.GetValueOrDefault(key), out var value) && value;
private static bool FeatureValue(IReadOnlyList<string>? features, string value)
=> features?.Any(feature => feature.Contains(value, StringComparison.OrdinalIgnoreCase)) == true;
private static bool ContainsAny(string text, params string[] needles)
=> needles.Any(needle => text.Contains(needle, StringComparison.OrdinalIgnoreCase));
private static bool StartsWithAny(string text, params string[] prefixes)
=> prefixes.Any(prefix => text.Equals(prefix, StringComparison.OrdinalIgnoreCase) || text.StartsWith(prefix + " ", StringComparison.OrdinalIgnoreCase));
private static string CleanWords(string value)
{
var chars = value.Trim().ToLowerInvariant().Select(ch => char.IsLetterOrDigit(ch) ? ch : ' ').ToArray();
return string.Join(' ', new string(chars).Split(' ', StringSplitOptions.RemoveEmptyEntries));
}
private static string EntityAttributeText(string text, string entityName)
{
var cleanName = (entityName ?? string.Empty).Trim().ToLowerInvariant();
if (string.IsNullOrWhiteSpace(cleanName))
{
return text;
}
var sentences = text.Split(['.', '!', '?', ';'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
var scoped = sentences
.Where(sentence => sentence.Contains(cleanName, StringComparison.OrdinalIgnoreCase))
.Where(sentence => AttributeNearEntity(sentence, cleanName))
.ToList();
return scoped.Count == 0 ? $" {cleanName} " : $" {string.Join(' ', scoped)} ";
}
private static string EntityScopedText(string text, string entityName)
{
var cleanName = (entityName ?? string.Empty).Trim().ToLowerInvariant();
if (string.IsNullOrWhiteSpace(cleanName))
{
return text;
}
var sentences = text.Split(['.', '!', '?', ';'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
var possessiveAscii = $"{cleanName}'s";
var possessiveCurly = $"{cleanName}\u2019s";
var scoped = sentences.Where(sentence => sentence.Contains(cleanName, StringComparison.OrdinalIgnoreCase)).ToList();
scoped = scoped
.Where(sentence => !sentence.Contains(possessiveAscii, StringComparison.OrdinalIgnoreCase)
&& !sentence.Contains(possessiveCurly, StringComparison.OrdinalIgnoreCase))
.ToList();
return scoped.Count == 0 ? $" {cleanName} " : $" {string.Join(' ', scoped)} ";
}
private static string RelationshipScopedText(string text, string entityName)
{
var cleanName = (entityName ?? string.Empty).Trim().ToLowerInvariant();
if (string.IsNullOrWhiteSpace(cleanName))
{
return text;
}
var possessiveAscii = $"{cleanName}'s";
var possessiveCurly = $"{cleanName}\u2019s";
if (text.Contains(possessiveAscii, StringComparison.OrdinalIgnoreCase)
|| text.Contains(possessiveCurly, StringComparison.OrdinalIgnoreCase))
{
return string.Empty;
}
var fragments = text.Split(['.', '!', '?', ';', ',', '|'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Where(fragment => !fragment.Contains(possessiveAscii, StringComparison.OrdinalIgnoreCase)
&& !fragment.Contains(possessiveCurly, StringComparison.OrdinalIgnoreCase))
.ToList();
return fragments.Count == 0 ? string.Empty : $" {string.Join(' ', fragments)} ";
}
private static bool AttributeNearEntity(string sentence, string cleanName)
{
var lower = sentence.ToLowerInvariant();
var entityIndex = lower.IndexOf(cleanName, StringComparison.Ordinal);
if (entityIndex < 0)
{
return false;
}
var attributeIndex = new[]
{
lower.IndexOf("hair", StringComparison.Ordinal),
lower.IndexOf("skin", StringComparison.Ordinal),
lower.IndexOf("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),
lower.IndexOf("moustache", StringComparison.Ordinal),
lower.IndexOf("mustache", StringComparison.Ordinal)
}
.Where(index => index >= 0)
.DefaultIfEmpty(-1)
.Min();
return attributeIndex >= 0 && Math.Abs(attributeIndex - entityIndex) <= 48;
}
private static bool PossessiveOfOtherIdentity(string name, string text)
{
var cleanName = (name ?? string.Empty).Trim().ToLowerInvariant();
if (cleanName.Contains("mother", StringComparison.Ordinal)
|| cleanName.Contains("mum", StringComparison.Ordinal)
|| cleanName.Contains("father", StringComparison.Ordinal)
|| cleanName.Contains("dad", StringComparison.Ordinal)
|| cleanName.Contains("aunt", StringComparison.Ordinal)
|| cleanName.Contains("uncle", StringComparison.Ordinal)
|| cleanName.Contains("teacher", StringComparison.Ordinal))
{
return false;
}
return ContainsAny(text, "'s mother", "'s mum", "'s father", "'s dad", "'s aunt", "'s uncle", " her mother", " his mother", " her mum", " his dad");
}
private sealed record EvidenceSignal(string Value, decimal Confidence)
{
public static EvidenceSignal Unknown { get; } = new("Unknown", 0);
}
}
public sealed record CharacterEvidenceProfile(
string AgeBand,
string Presentation,
string HairColour,
string SkinTone,
string HairLength,
string Glasses,
string FacialHair,
decimal AgeConfidence,
decimal PresentationConfidence,
decimal RelationshipConfidence,
decimal NameConfidence,
decimal PronounConfidence,
IReadOnlyList<string> Warnings)
{
public bool HasStrongPresentationEvidence => PresentationConfidence >= 0.8m;
}
public sealed record IllustrationCharacterMetadata(
string AgeBand,
string Presentation,
string HairColour,
string SkinTone,
string HairLength,
string Glasses,
string FacialHair,
bool IsUnknownFigure,
bool IsGenericFallback,
bool IsSuitableForNamedCharacter,
string? PromptTemplateVersion,
string GenerationSource);

File diff suppressed because it is too large Load Diff

View File

@ -279,6 +279,11 @@ public sealed class StoryIntelligenceClient(
return (responseText, retryCount); return (responseText, retryCount);
} }
if (IsInsufficientQuota(responseText))
{
throw new InvalidOperationException($"OpenAI billing quota exhausted; request was not retried: {TrimForLog(responseText)}");
}
if (!ShouldRetry(response.StatusCode) || attempt == maxAttempts) if (!ShouldRetry(response.StatusCode) || attempt == maxAttempts)
{ {
throw new InvalidOperationException($"OpenAI request failed with {(int)response.StatusCode} {response.StatusCode}: {TrimForLog(responseText)}"); throw new InvalidOperationException($"OpenAI request failed with {(int)response.StatusCode} {response.StatusCode}: {TrimForLog(responseText)}");
@ -318,6 +323,10 @@ public sealed class StoryIntelligenceClient(
or HttpStatusCode.ServiceUnavailable or HttpStatusCode.ServiceUnavailable
or HttpStatusCode.GatewayTimeout; or HttpStatusCode.GatewayTimeout;
internal static bool IsInsufficientQuota(string responseText)
=> responseText.Contains("\"insufficient_quota\"", StringComparison.OrdinalIgnoreCase)
|| responseText.Contains("exceeded your current quota", StringComparison.OrdinalIgnoreCase);
private static Task DelayForRetryAsync(int attempt, CancellationToken cancellationToken) private static Task DelayForRetryAsync(int attempt, CancellationToken cancellationToken)
=> Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, attempt - 1)), cancellationToken); => Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, attempt - 1)), cancellationToken);

View File

@ -8,6 +8,7 @@ public interface IStoryIntelligencePipelineStateService
Task<StoryIntelligenceBookPipelineState?> GetForBookAsync(int bookId, int userId); Task<StoryIntelligenceBookPipelineState?> GetForBookAsync(int bookId, int userId);
Task<IReadOnlyList<StoryIntelligenceBookPipelineState>> ListForUserAsync(int userId); Task<IReadOnlyList<StoryIntelligenceBookPipelineState>> ListForUserAsync(int userId);
Task<StoryIntelligenceBookPipelineState?> EnsureForBookAsync(int bookId, int userId); Task<StoryIntelligenceBookPipelineState?> EnsureForBookAsync(int bookId, int userId);
Task RecordChapterAnalysisQueuedAsync(int projectId, int bookId, int runId);
Task RecordSceneImportAsync(int projectId, int bookId, int runId); Task RecordSceneImportAsync(int projectId, int bookId, int runId);
Task RecordCharacterImportAsync(int projectId, int bookId); Task RecordCharacterImportAsync(int projectId, int bookId);
Task RecordLocationImportAsync(int projectId, int bookId); Task RecordLocationImportAsync(int projectId, int bookId);
@ -78,6 +79,21 @@ public sealed class StoryIntelligencePipelineStateService(
}); });
} }
public async Task RecordChapterAnalysisQueuedAsync(int projectId, int bookId, int runId)
{
await pipelines.UpsertAsync(new StoryIntelligenceBookPipelineSaveRequest
{
ProjectID = projectId,
BookID = bookId,
CurrentStage = StoryIntelligencePipelineStages.Chapters,
LastCompletedStage = null,
CurrentReviewStage = null,
Status = StoryIntelligencePipelineStatuses.InProgress,
CompletedUtc = null,
LastRunID = runId
});
}
public async Task RecordCharacterImportAsync(int projectId, int bookId) public async Task RecordCharacterImportAsync(int projectId, int bookId)
{ {
await pipelines.UpsertAsync(new StoryIntelligenceBookPipelineSaveRequest await pipelines.UpsertAsync(new StoryIntelligenceBookPipelineSaveRequest

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,383 @@
SET ANSI_NULLS ON;
GO
SET QUOTED_IDENTIFIER ON;
GO
IF OBJECT_ID(N'dbo.IllustrationLibraryItems', N'U') IS NULL
BEGIN
CREATE TABLE dbo.IllustrationLibraryItems
(
IllustrationLibraryItemID int IDENTITY(1,1) NOT NULL CONSTRAINT PK_IllustrationLibraryItems PRIMARY KEY,
Category nvarchar(40) NOT NULL,
StableCode nvarchar(120) NOT NULL,
DisplayTitle nvarchar(200) NOT NULL,
ShortDescription nvarchar(500) NULL,
MetadataJson nvarchar(max) NULL,
SpecificationJson nvarchar(max) NULL,
FilePath nvarchar(500) NULL,
ThumbnailPath nvarchar(500) NULL,
Width int NULL,
Height int NULL,
FileSizeBytes bigint NULL,
MimeType nvarchar(100) NULL,
Status nvarchar(40) NOT NULL CONSTRAINT DF_IllustrationLibraryItems_Status DEFAULT N'Planned',
IsActive bit NOT NULL CONSTRAINT DF_IllustrationLibraryItems_IsActive DEFAULT 1,
IsApproved bit NOT NULL CONSTRAINT DF_IllustrationLibraryItems_IsApproved DEFAULT 0,
CreatedUtc datetime2(0) NOT NULL CONSTRAINT DF_IllustrationLibraryItems_CreatedUtc DEFAULT SYSUTCDATETIME(),
UpdatedUtc datetime2(0) NOT NULL CONSTRAINT DF_IllustrationLibraryItems_UpdatedUtc DEFAULT SYSUTCDATETIME(),
ApprovedUtc datetime2(0) NULL,
ApprovedByUserID int NULL,
Provider nvarchar(80) NULL,
Model nvarchar(120) NULL,
PromptText nvarchar(max) NULL,
PromptTemplateVersion nvarchar(40) NULL,
ProviderRequestID nvarchar(160) NULL,
AttemptCount int NOT NULL CONSTRAINT DF_IllustrationLibraryItems_AttemptCount DEFAULT 0,
ParentItemID int NULL,
RejectionReason nvarchar(500) NULL,
AdminNotes nvarchar(1000) NULL,
UsageCount int NOT NULL CONSTRAINT DF_IllustrationLibraryItems_UsageCount DEFAULT 0,
LastError nvarchar(1000) NULL,
QueuedUtc datetime2(0) NULL,
StartedUtc datetime2(0) NULL,
CompletedUtc datetime2(0) NULL,
CONSTRAINT CK_IllustrationLibraryItems_Category CHECK (Category IN (N'Character', N'Location', N'Asset')),
CONSTRAINT CK_IllustrationLibraryItems_Status CHECK (Status IN (N'Planned', N'Queued', N'Generating', N'Generated', N'Approved', N'Rejected', N'Failed', N'Disabled'))
);
END;
GO
IF NOT EXISTS (SELECT 1 FROM sys.foreign_keys WHERE name = N'FK_IllustrationLibraryItems_Parent')
BEGIN
ALTER TABLE dbo.IllustrationLibraryItems
ADD CONSTRAINT FK_IllustrationLibraryItems_Parent
FOREIGN KEY (ParentItemID) REFERENCES dbo.IllustrationLibraryItems(IllustrationLibraryItemID);
END;
GO
IF NOT EXISTS (SELECT 1 FROM sys.foreign_keys WHERE name = N'FK_IllustrationLibraryItems_ApprovedBy')
AND OBJECT_ID(N'dbo.AppUser', N'U') IS NOT NULL
BEGIN
ALTER TABLE dbo.IllustrationLibraryItems
ADD CONSTRAINT FK_IllustrationLibraryItems_ApprovedBy
FOREIGN KEY (ApprovedByUserID) REFERENCES dbo.AppUser(UserID);
END;
GO
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'IX_IllustrationLibraryItems_Status' AND object_id = OBJECT_ID(N'dbo.IllustrationLibraryItems'))
CREATE INDEX IX_IllustrationLibraryItems_Status ON dbo.IllustrationLibraryItems(Status, Category, UpdatedUtc DESC);
GO
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'IX_IllustrationLibraryItems_Code' AND object_id = OBJECT_ID(N'dbo.IllustrationLibraryItems'))
CREATE INDEX IX_IllustrationLibraryItems_Code ON dbo.IllustrationLibraryItems(StableCode, Status, IsActive);
GO
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'UX_IllustrationLibraryItems_ActivePlannedCode' AND object_id = OBJECT_ID(N'dbo.IllustrationLibraryItems'))
CREATE UNIQUE INDEX UX_IllustrationLibraryItems_ActivePlannedCode
ON dbo.IllustrationLibraryItems(StableCode)
WHERE ParentItemID IS NULL AND Status IN (N'Planned', N'Queued', N'Generating', N'Generated', N'Approved') AND IsActive = 1;
GO
CREATE OR ALTER PROCEDURE dbo.IllustrationLibraryItem_List
@Category nvarchar(40) = NULL,
@Status nvarchar(40) = NULL,
@Search nvarchar(120) = NULL
AS
BEGIN
SET NOCOUNT ON;
SELECT *
FROM dbo.IllustrationLibraryItems
WHERE (@Category IS NULL OR Category = @Category)
AND (@Status IS NULL OR Status = @Status)
AND (
@Search IS NULL
OR StableCode LIKE N'%' + @Search + N'%'
OR DisplayTitle LIKE N'%' + @Search + N'%'
OR ShortDescription LIKE N'%' + @Search + N'%'
)
ORDER BY
CASE Status
WHEN N'Generating' THEN 1
WHEN N'Queued' THEN 2
WHEN N'Generated' THEN 3
WHEN N'Planned' THEN 4
WHEN N'Failed' THEN 5
WHEN N'Rejected' THEN 6
WHEN N'Approved' THEN 7
ELSE 8
END,
UpdatedUtc DESC,
IllustrationLibraryItemID DESC;
END;
GO
CREATE OR ALTER PROCEDURE dbo.IllustrationLibraryItem_Summary
AS
BEGIN
SET NOCOUNT ON;
SELECT
COUNT(1) AS TotalCount,
ISNULL(SUM(CASE WHEN Status = N'Planned' THEN 1 ELSE 0 END), 0) AS PlannedCount,
ISNULL(SUM(CASE WHEN Status = N'Queued' THEN 1 ELSE 0 END), 0) AS QueuedCount,
ISNULL(SUM(CASE WHEN Status = N'Generating' THEN 1 ELSE 0 END), 0) AS GeneratingCount,
ISNULL(SUM(CASE WHEN Status = N'Generated' THEN 1 ELSE 0 END), 0) AS GeneratedCount,
ISNULL(SUM(CASE WHEN Status = N'Approved' THEN 1 ELSE 0 END), 0) AS ApprovedCount,
ISNULL(SUM(CASE WHEN Status = N'Rejected' THEN 1 ELSE 0 END), 0) AS RejectedCount,
ISNULL(SUM(CASE WHEN Status = N'Failed' THEN 1 ELSE 0 END), 0) AS FailedCount,
ISNULL(SUM(CASE WHEN Status = N'Disabled' THEN 1 ELSE 0 END), 0) AS DisabledCount
FROM dbo.IllustrationLibraryItems;
END;
GO
CREATE OR ALTER PROCEDURE dbo.IllustrationLibraryItem_Get
@IllustrationLibraryItemID int
AS
BEGIN
SET NOCOUNT ON;
SELECT * FROM dbo.IllustrationLibraryItems WHERE IllustrationLibraryItemID = @IllustrationLibraryItemID;
END;
GO
CREATE OR ALTER PROCEDURE dbo.IllustrationLibraryItem_UpsertPlanned
@Category nvarchar(40),
@StableCode nvarchar(120),
@DisplayTitle nvarchar(200),
@ShortDescription nvarchar(500) = NULL,
@MetadataJson nvarchar(max) = NULL,
@SpecificationJson nvarchar(max) = NULL
AS
BEGIN
SET NOCOUNT ON;
IF EXISTS (
SELECT 1
FROM dbo.IllustrationLibraryItems
WHERE StableCode = @StableCode
AND ParentItemID IS NULL
AND IsActive = 1
AND Status IN (N'Planned', N'Queued', N'Generating', N'Generated', N'Approved')
)
BEGIN
SELECT * FROM dbo.IllustrationLibraryItems
WHERE StableCode = @StableCode
AND ParentItemID IS NULL
AND IsActive = 1
AND Status IN (N'Planned', N'Queued', N'Generating', N'Generated', N'Approved');
RETURN;
END;
INSERT dbo.IllustrationLibraryItems
(Category, StableCode, DisplayTitle, ShortDescription, MetadataJson, SpecificationJson, Status)
VALUES
(@Category, @StableCode, @DisplayTitle, @ShortDescription, @MetadataJson, @SpecificationJson, N'Planned');
SELECT * FROM dbo.IllustrationLibraryItems WHERE IllustrationLibraryItemID = SCOPE_IDENTITY();
END;
GO
CREATE OR ALTER PROCEDURE dbo.IllustrationLibraryItem_Queue
@IllustrationLibraryItemID int = NULL,
@Category nvarchar(40) = NULL
AS
BEGIN
SET NOCOUNT ON;
UPDATE dbo.IllustrationLibraryItems
SET Status = N'Queued',
QueuedUtc = SYSUTCDATETIME(),
UpdatedUtc = SYSUTCDATETIME(),
LastError = NULL
WHERE (@IllustrationLibraryItemID IS NULL OR IllustrationLibraryItemID = @IllustrationLibraryItemID)
AND (@Category IS NULL OR Category = @Category)
AND Status IN (N'Planned', N'Failed', N'Rejected')
AND IsActive = 1;
SELECT @@ROWCOUNT AS AffectedRows;
END;
GO
CREATE OR ALTER PROCEDURE dbo.IllustrationLibraryItem_ClaimNext
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON;
BEGIN TRANSACTION;
DECLARE @Claimed TABLE (IllustrationLibraryItemID int NOT NULL);
;WITH nextItem AS
(
SELECT TOP (1) *
FROM dbo.IllustrationLibraryItems WITH (UPDLOCK, READPAST, ROWLOCK)
WHERE Status = N'Queued' AND IsActive = 1
ORDER BY QueuedUtc, IllustrationLibraryItemID
)
UPDATE nextItem
SET Status = N'Generating',
AttemptCount = AttemptCount + 1,
StartedUtc = SYSUTCDATETIME(),
UpdatedUtc = SYSUTCDATETIME()
OUTPUT inserted.IllustrationLibraryItemID INTO @Claimed;
SELECT item.*
FROM dbo.IllustrationLibraryItems item
INNER JOIN @Claimed claimed ON claimed.IllustrationLibraryItemID = item.IllustrationLibraryItemID;
COMMIT TRANSACTION;
END;
GO
CREATE OR ALTER PROCEDURE dbo.IllustrationLibraryItem_MarkGenerated
@IllustrationLibraryItemID int,
@FilePath nvarchar(500),
@ThumbnailPath nvarchar(500),
@Width int,
@Height int,
@FileSizeBytes bigint,
@MimeType nvarchar(100),
@Provider nvarchar(80),
@Model nvarchar(120),
@PromptText nvarchar(max),
@PromptTemplateVersion nvarchar(40),
@ProviderRequestID nvarchar(160) = NULL
AS
BEGIN
SET NOCOUNT ON;
UPDATE dbo.IllustrationLibraryItems
SET Status = N'Generated',
FilePath = @FilePath,
ThumbnailPath = @ThumbnailPath,
Width = @Width,
Height = @Height,
FileSizeBytes = @FileSizeBytes,
MimeType = @MimeType,
Provider = @Provider,
Model = @Model,
PromptText = @PromptText,
PromptTemplateVersion = @PromptTemplateVersion,
ProviderRequestID = @ProviderRequestID,
CompletedUtc = SYSUTCDATETIME(),
UpdatedUtc = SYSUTCDATETIME(),
LastError = NULL
WHERE IllustrationLibraryItemID = @IllustrationLibraryItemID
AND Status = N'Generating';
END;
GO
CREATE OR ALTER PROCEDURE dbo.IllustrationLibraryItem_MarkFailed
@IllustrationLibraryItemID int,
@LastError nvarchar(1000)
AS
BEGIN
SET NOCOUNT ON;
UPDATE dbo.IllustrationLibraryItems
SET Status = N'Failed',
LastError = @LastError,
CompletedUtc = SYSUTCDATETIME(),
UpdatedUtc = SYSUTCDATETIME()
WHERE IllustrationLibraryItemID = @IllustrationLibraryItemID
AND Status = N'Generating';
END;
GO
CREATE OR ALTER PROCEDURE dbo.IllustrationLibraryItem_Approve
@IllustrationLibraryItemID int,
@ApprovedByUserID int
AS
BEGIN
SET NOCOUNT ON;
DECLARE @StableCode nvarchar(120);
SELECT @StableCode = StableCode FROM dbo.IllustrationLibraryItems WHERE IllustrationLibraryItemID = @IllustrationLibraryItemID;
UPDATE dbo.IllustrationLibraryItems
SET IsApproved = 0,
Status = CASE WHEN Status = N'Approved' THEN N'Generated' ELSE Status END,
UpdatedUtc = SYSUTCDATETIME()
WHERE StableCode = @StableCode
AND IllustrationLibraryItemID <> @IllustrationLibraryItemID
AND Status = N'Approved';
UPDATE dbo.IllustrationLibraryItems
SET Status = N'Approved',
IsApproved = 1,
ApprovedUtc = SYSUTCDATETIME(),
ApprovedByUserID = @ApprovedByUserID,
UpdatedUtc = SYSUTCDATETIME()
WHERE IllustrationLibraryItemID = @IllustrationLibraryItemID
AND Status = N'Generated'
AND FilePath IS NOT NULL;
END;
GO
CREATE OR ALTER PROCEDURE dbo.IllustrationLibraryItem_Reject
@IllustrationLibraryItemID int,
@RejectionReason nvarchar(500) = NULL
AS
BEGIN
SET NOCOUNT ON;
UPDATE dbo.IllustrationLibraryItems
SET Status = N'Rejected',
IsApproved = 0,
RejectionReason = @RejectionReason,
UpdatedUtc = SYSUTCDATETIME()
WHERE IllustrationLibraryItemID = @IllustrationLibraryItemID
AND Status IN (N'Generated', N'Approved');
END;
GO
CREATE OR ALTER PROCEDURE dbo.IllustrationLibraryItem_Disable
@IllustrationLibraryItemID int
AS
BEGIN
SET NOCOUNT ON;
UPDATE dbo.IllustrationLibraryItems
SET Status = N'Disabled',
IsActive = 0,
IsApproved = 0,
UpdatedUtc = SYSUTCDATETIME()
WHERE IllustrationLibraryItemID = @IllustrationLibraryItemID;
END;
GO
CREATE OR ALTER PROCEDURE dbo.IllustrationLibraryItem_UpdateMetadata
@IllustrationLibraryItemID int,
@DisplayTitle nvarchar(200),
@ShortDescription nvarchar(500) = NULL,
@MetadataJson nvarchar(max) = NULL,
@AdminNotes nvarchar(1000) = NULL
AS
BEGIN
SET NOCOUNT ON;
UPDATE dbo.IllustrationLibraryItems
SET DisplayTitle = @DisplayTitle,
ShortDescription = @ShortDescription,
MetadataJson = @MetadataJson,
AdminNotes = @AdminNotes,
UpdatedUtc = SYSUTCDATETIME()
WHERE IllustrationLibraryItemID = @IllustrationLibraryItemID;
END;
GO
CREATE OR ALTER PROCEDURE dbo.IllustrationLibraryItem_GetApproved
AS
BEGIN
SET NOCOUNT ON;
SELECT *
FROM dbo.IllustrationLibraryItems
WHERE Status = N'Approved'
AND IsApproved = 1
AND IsActive = 1
AND FilePath IS NOT NULL
ORDER BY StableCode, ApprovedUtc DESC;
END;
GO

View File

@ -0,0 +1,317 @@
SET ANSI_NULLS ON;
GO
SET QUOTED_IDENTIFIER ON;
GO
IF COL_LENGTH(N'dbo.IllustrationLibraryItems', N'DisplayName') IS NULL
ALTER TABLE dbo.IllustrationLibraryItems ADD DisplayName nvarchar(200) NULL;
GO
IF COL_LENGTH(N'dbo.IllustrationLibraryItems', N'Description') IS NULL
ALTER TABLE dbo.IllustrationLibraryItems ADD Description nvarchar(500) NULL;
GO
IF COL_LENGTH(N'dbo.IllustrationLibraryItems', N'Prompt') IS NULL
ALTER TABLE dbo.IllustrationLibraryItems ADD Prompt nvarchar(max) NULL;
GO
IF COL_LENGTH(N'dbo.IllustrationLibraryItems', N'GenerationAttemptCount') IS NULL
ALTER TABLE dbo.IllustrationLibraryItems ADD GenerationAttemptCount int NOT NULL CONSTRAINT DF_IllustrationLibraryItems_GenerationAttemptCount DEFAULT 0;
GO
IF COL_LENGTH(N'dbo.IllustrationLibraryItems', N'ErrorMessage') IS NULL
ALTER TABLE dbo.IllustrationLibraryItems ADD ErrorMessage nvarchar(1000) NULL;
GO
IF COL_LENGTH(N'dbo.IllustrationLibraryItems', N'RejectedUtc') IS NULL
ALTER TABLE dbo.IllustrationLibraryItems ADD RejectedUtc datetime2(0) NULL;
GO
IF COL_LENGTH(N'dbo.IllustrationLibraryItems', N'RejectedByUserID') IS NULL
ALTER TABLE dbo.IllustrationLibraryItems ADD RejectedByUserID int NULL;
GO
UPDATE dbo.IllustrationLibraryItems
SET DisplayName = ISNULL(DisplayName, DisplayTitle),
Description = ISNULL(Description, ShortDescription),
Prompt = ISNULL(Prompt, PromptText),
GenerationAttemptCount = CASE WHEN GenerationAttemptCount = 0 THEN AttemptCount ELSE GenerationAttemptCount END,
ErrorMessage = ISNULL(ErrorMessage, LastError)
WHERE DisplayName IS NULL
OR Description IS NULL
OR Prompt IS NULL
OR ErrorMessage IS NULL
OR GenerationAttemptCount = 0;
GO
IF EXISTS (SELECT 1 FROM sys.check_constraints WHERE name = N'CK_IllustrationLibraryItems_Status')
ALTER TABLE dbo.IllustrationLibraryItems DROP CONSTRAINT CK_IllustrationLibraryItems_Status;
GO
ALTER TABLE dbo.IllustrationLibraryItems
ADD CONSTRAINT CK_IllustrationLibraryItems_Status
CHECK (Status IN (N'Planned', N'Queued', N'Generating', N'Generated', N'Approved', N'Rejected', N'Failed', N'Superseded', N'Disabled'));
GO
IF NOT EXISTS (SELECT 1 FROM sys.foreign_keys WHERE name = N'FK_IllustrationLibraryItems_RejectedBy')
AND OBJECT_ID(N'dbo.AppUser', N'U') IS NOT NULL
BEGIN
ALTER TABLE dbo.IllustrationLibraryItems
ADD CONSTRAINT FK_IllustrationLibraryItems_RejectedBy
FOREIGN KEY (RejectedByUserID) REFERENCES dbo.AppUser(UserID);
END;
GO
CREATE OR ALTER PROCEDURE dbo.IllustrationLibraryItem_List
@Category nvarchar(40) = NULL,
@Status nvarchar(40) = NULL,
@Search nvarchar(120) = NULL
AS
BEGIN
SET NOCOUNT ON;
SELECT *
FROM dbo.IllustrationLibraryItems
WHERE (@Category IS NULL OR Category = @Category)
AND (@Status IS NULL OR Status = @Status)
AND (
@Search IS NULL
OR StableCode LIKE N'%' + @Search + N'%'
OR ISNULL(DisplayName, DisplayTitle) LIKE N'%' + @Search + N'%'
OR ISNULL(Description, ShortDescription) LIKE N'%' + @Search + N'%'
)
ORDER BY
CASE Status
WHEN N'Generating' THEN 1
WHEN N'Queued' THEN 2
WHEN N'Generated' THEN 3
WHEN N'Planned' THEN 4
WHEN N'Failed' THEN 5
WHEN N'Rejected' THEN 6
WHEN N'Approved' THEN 7
WHEN N'Superseded' THEN 8
ELSE 9
END,
UpdatedUtc DESC,
IllustrationLibraryItemID DESC;
END;
GO
CREATE OR ALTER PROCEDURE dbo.IllustrationLibraryItem_Summary
AS
BEGIN
SET NOCOUNT ON;
SELECT
COUNT(1) AS TotalCount,
ISNULL(SUM(CASE WHEN Status = N'Planned' THEN 1 ELSE 0 END), 0) AS PlannedCount,
ISNULL(SUM(CASE WHEN Status = N'Queued' THEN 1 ELSE 0 END), 0) AS QueuedCount,
ISNULL(SUM(CASE WHEN Status = N'Generating' THEN 1 ELSE 0 END), 0) AS GeneratingCount,
ISNULL(SUM(CASE WHEN Status = N'Generated' THEN 1 ELSE 0 END), 0) AS GeneratedCount,
ISNULL(SUM(CASE WHEN Status = N'Approved' THEN 1 ELSE 0 END), 0) AS ApprovedCount,
ISNULL(SUM(CASE WHEN Status = N'Rejected' THEN 1 ELSE 0 END), 0) AS RejectedCount,
ISNULL(SUM(CASE WHEN Status = N'Failed' THEN 1 ELSE 0 END), 0) AS FailedCount,
ISNULL(SUM(CASE WHEN Status = N'Superseded' THEN 1 ELSE 0 END), 0) AS SupersededCount,
ISNULL(SUM(CASE WHEN Status = N'Disabled' THEN 1 ELSE 0 END), 0) AS DisabledCount
FROM dbo.IllustrationLibraryItems;
END;
GO
CREATE OR ALTER PROCEDURE dbo.IllustrationLibraryItem_UpsertPlanned
@Category nvarchar(40),
@StableCode nvarchar(120),
@DisplayTitle nvarchar(200),
@ShortDescription nvarchar(500) = NULL,
@MetadataJson nvarchar(max) = NULL,
@SpecificationJson nvarchar(max) = NULL
AS
BEGIN
SET NOCOUNT ON;
IF EXISTS (
SELECT 1
FROM dbo.IllustrationLibraryItems
WHERE StableCode = @StableCode
AND ParentItemID IS NULL
AND IsActive = 1
AND Status IN (N'Planned', N'Queued', N'Generating', N'Generated', N'Approved')
)
BEGIN
SELECT * FROM dbo.IllustrationLibraryItems
WHERE StableCode = @StableCode
AND ParentItemID IS NULL
AND IsActive = 1
AND Status IN (N'Planned', N'Queued', N'Generating', N'Generated', N'Approved');
RETURN;
END;
INSERT dbo.IllustrationLibraryItems
(Category, StableCode, DisplayTitle, DisplayName, ShortDescription, Description, MetadataJson, SpecificationJson, Status)
VALUES
(@Category, @StableCode, @DisplayTitle, @DisplayTitle, @ShortDescription, @ShortDescription, @MetadataJson, @SpecificationJson, N'Planned');
SELECT * FROM dbo.IllustrationLibraryItems WHERE IllustrationLibraryItemID = SCOPE_IDENTITY();
END;
GO
CREATE OR ALTER PROCEDURE dbo.IllustrationLibraryItem_ClaimNext
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON;
BEGIN TRANSACTION;
DECLARE @Claimed TABLE (IllustrationLibraryItemID int NOT NULL);
;WITH nextItem AS
(
SELECT TOP (1) *
FROM dbo.IllustrationLibraryItems WITH (UPDLOCK, READPAST, ROWLOCK)
WHERE Status = N'Queued'
AND IsActive = 1
AND GenerationAttemptCount < 3
ORDER BY QueuedUtc, IllustrationLibraryItemID
)
UPDATE nextItem
SET Status = N'Generating',
AttemptCount = AttemptCount + 1,
GenerationAttemptCount = GenerationAttemptCount + 1,
StartedUtc = SYSUTCDATETIME(),
UpdatedUtc = SYSUTCDATETIME()
OUTPUT inserted.IllustrationLibraryItemID INTO @Claimed;
SELECT item.*
FROM dbo.IllustrationLibraryItems item
INNER JOIN @Claimed claimed ON claimed.IllustrationLibraryItemID = item.IllustrationLibraryItemID;
COMMIT TRANSACTION;
END;
GO
CREATE OR ALTER PROCEDURE dbo.IllustrationLibraryItem_MarkGenerated
@IllustrationLibraryItemID int,
@FilePath nvarchar(500),
@ThumbnailPath nvarchar(500),
@Width int,
@Height int,
@FileSizeBytes bigint,
@MimeType nvarchar(100),
@Provider nvarchar(80),
@Model nvarchar(120),
@PromptText nvarchar(max),
@PromptTemplateVersion nvarchar(40),
@ProviderRequestID nvarchar(160) = NULL
AS
BEGIN
SET NOCOUNT ON;
UPDATE dbo.IllustrationLibraryItems
SET Status = N'Generated',
FilePath = @FilePath,
ThumbnailPath = @ThumbnailPath,
Width = @Width,
Height = @Height,
FileSizeBytes = @FileSizeBytes,
MimeType = @MimeType,
Provider = @Provider,
Model = @Model,
PromptText = @PromptText,
Prompt = @PromptText,
PromptTemplateVersion = @PromptTemplateVersion,
ProviderRequestID = @ProviderRequestID,
CompletedUtc = SYSUTCDATETIME(),
UpdatedUtc = SYSUTCDATETIME(),
LastError = NULL,
ErrorMessage = NULL
WHERE IllustrationLibraryItemID = @IllustrationLibraryItemID
AND Status = N'Generating';
END;
GO
CREATE OR ALTER PROCEDURE dbo.IllustrationLibraryItem_MarkFailed
@IllustrationLibraryItemID int,
@LastError nvarchar(1000)
AS
BEGIN
SET NOCOUNT ON;
UPDATE dbo.IllustrationLibraryItems
SET Status = N'Failed',
LastError = @LastError,
ErrorMessage = @LastError,
CompletedUtc = SYSUTCDATETIME(),
UpdatedUtc = SYSUTCDATETIME()
WHERE IllustrationLibraryItemID = @IllustrationLibraryItemID
AND Status = N'Generating';
END;
GO
CREATE OR ALTER PROCEDURE dbo.IllustrationLibraryItem_Approve
@IllustrationLibraryItemID int,
@ApprovedByUserID int
AS
BEGIN
SET NOCOUNT ON;
DECLARE @StableCode nvarchar(120);
SELECT @StableCode = StableCode FROM dbo.IllustrationLibraryItems WHERE IllustrationLibraryItemID = @IllustrationLibraryItemID;
UPDATE dbo.IllustrationLibraryItems
SET IsApproved = 0,
Status = N'Superseded',
UpdatedUtc = SYSUTCDATETIME()
WHERE StableCode = @StableCode
AND IllustrationLibraryItemID <> @IllustrationLibraryItemID
AND Status = N'Approved';
UPDATE dbo.IllustrationLibraryItems
SET Status = N'Approved',
IsApproved = 1,
ApprovedUtc = SYSUTCDATETIME(),
ApprovedByUserID = @ApprovedByUserID,
UpdatedUtc = SYSUTCDATETIME()
WHERE IllustrationLibraryItemID = @IllustrationLibraryItemID
AND Status = N'Generated'
AND FilePath IS NOT NULL;
END;
GO
CREATE OR ALTER PROCEDURE dbo.IllustrationLibraryItem_Reject
@IllustrationLibraryItemID int,
@RejectedByUserID int,
@RejectionReason nvarchar(500) = NULL
AS
BEGIN
SET NOCOUNT ON;
UPDATE dbo.IllustrationLibraryItems
SET Status = N'Rejected',
IsApproved = 0,
RejectedUtc = SYSUTCDATETIME(),
RejectedByUserID = @RejectedByUserID,
RejectionReason = @RejectionReason,
UpdatedUtc = SYSUTCDATETIME()
WHERE IllustrationLibraryItemID = @IllustrationLibraryItemID
AND Status IN (N'Generated', N'Approved');
END;
GO
CREATE OR ALTER PROCEDURE dbo.IllustrationLibraryItem_CreateRegeneration
@IllustrationLibraryItemID int,
@AdminNotes nvarchar(1000) = NULL
AS
BEGIN
SET NOCOUNT ON;
INSERT dbo.IllustrationLibraryItems
(Category, StableCode, DisplayTitle, DisplayName, ShortDescription, Description, MetadataJson, SpecificationJson, Status, ParentItemID, AdminNotes)
SELECT Category, StableCode, DisplayTitle, ISNULL(DisplayName, DisplayTitle), ShortDescription, ISNULL(Description, ShortDescription),
MetadataJson, SpecificationJson, N'Planned', IllustrationLibraryItemID, @AdminNotes
FROM dbo.IllustrationLibraryItems
WHERE IllustrationLibraryItemID = @IllustrationLibraryItemID
AND Status IN (N'Generated', N'Approved', N'Rejected', N'Failed', N'Superseded');
SELECT * FROM dbo.IllustrationLibraryItems WHERE IllustrationLibraryItemID = SCOPE_IDENTITY();
END;
GO

View File

@ -0,0 +1,141 @@
SET ANSI_NULLS ON;
GO
SET QUOTED_IDENTIFIER ON;
GO
;WITH LatestBookRun AS
(
SELECT CAST(r.ProjectID AS int) AS ProjectID,
CAST(r.BookID AS int) AS BookID,
r.StoryIntelligenceRunID,
r.Status AS RunStatus,
r.CurrentStage,
ROW_NUMBER() OVER (PARTITION BY r.BookID ORDER BY r.StoryIntelligenceRunID DESC) AS RowNumber,
MAX(CASE WHEN r.Status IN (N'Pending', N'Running') THEN 1 ELSE 0 END) OVER (PARTITION BY r.BookID) AS HasActiveRun
FROM dbo.StoryIntelligenceRuns r
INNER JOIN dbo.Books b ON b.BookID = r.BookID
INNER JOIN dbo.Projects p ON p.ProjectID = r.ProjectID
WHERE r.ProjectID IS NOT NULL
AND r.BookID IS NOT NULL
AND b.IsArchived = 0
AND p.IsArchived = 0
)
MERGE dbo.StoryIntelligenceBookPipelines AS target
USING
(
SELECT ProjectID,
BookID,
StoryIntelligenceRunID,
CASE WHEN HasActiveRun = 1 THEN N'InProgress' ELSE N'NeedsReview' END AS Status,
CASE
WHEN HasActiveRun = 1 THEN N'Chapters'
ELSE N'CharacterReview'
END AS CurrentStage,
CASE WHEN HasActiveRun = 1 THEN NULL ELSE N'SceneImport' END AS LastCompletedStage,
CASE WHEN HasActiveRun = 1 THEN NULL ELSE N'CharacterReview' END AS CurrentReviewStage
FROM LatestBookRun
WHERE RowNumber = 1
) AS source
ON target.BookID = source.BookID
WHEN MATCHED THEN
UPDATE SET ProjectID = source.ProjectID,
LastRunID = source.StoryIntelligenceRunID,
Status = CASE WHEN source.Status = N'InProgress' THEN source.Status ELSE target.Status END,
CurrentStage = CASE WHEN source.Status = N'InProgress' THEN source.CurrentStage ELSE target.CurrentStage END,
UpdatedUtc = SYSUTCDATETIME()
WHEN NOT MATCHED THEN
INSERT (ProjectID, BookID, CurrentStage, LastCompletedStage, CurrentReviewStage, Status, CompletedUtc, LastRunID)
VALUES (source.ProjectID, source.BookID, source.CurrentStage, source.LastCompletedStage, source.CurrentReviewStage, source.Status, NULL, source.StoryIntelligenceRunID);
GO
CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceBookPipeline_GetByIDForUser
@StoryIntelligenceBookPipelineID int,
@UserID int
AS
BEGIN
SET NOCOUNT ON;
SELECT p.StoryIntelligenceBookPipelineID, p.ProjectID, project.ProjectName,
p.BookID, book.BookTitle, book.Subtitle AS BookSubtitle,
p.CurrentStage, p.LastCompletedStage, p.CurrentReviewStage, p.Status,
p.CompletedUtc, p.LastRunID, p.CreatedUtc, p.UpdatedUtc
FROM dbo.StoryIntelligenceBookPipelines p
INNER JOIN dbo.Projects project ON project.ProjectID = p.ProjectID
INNER JOIN dbo.Books book ON book.BookID = p.BookID
INNER JOIN dbo.ProjectUserAccess access ON access.ProjectID = p.ProjectID
AND access.UserID = @UserID
AND access.IsActive = 1
WHERE p.StoryIntelligenceBookPipelineID = @StoryIntelligenceBookPipelineID
AND book.IsArchived = 0
AND project.IsArchived = 0;
END;
GO
CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceRun_ListByBookForUser
@BookID int,
@UserID int
AS
BEGIN
SET NOCOUNT ON;
SELECT
r.StoryIntelligenceRunID,
r.UserID,
r.ProjectID,
p.ProjectName AS ProjectTitle,
r.BookID,
b.BookTitle,
r.ChapterID,
r.ChapterNumber,
r.Status,
r.SourceType,
r.SourceText,
r.SourceFileName,
r.SourceFileSizeBytes,
r.SourceWordCount,
r.SourceCharacterCount,
r.SourceParagraphCount,
r.SourceChapterCount,
r.SourceDetectedImagesCount,
r.SourceDetectedTablesCount,
r.SourceDetectedFootnotesCount,
r.SourceDetectedCommentsCount,
r.CurrentStage,
r.CurrentMessage,
r.TotalDetectedScenes,
r.CompletedScenes,
r.FailedScenes,
r.CancellationRequestedUtc,
r.CancelledUtc,
r.PromptVersion,
r.PromptVersionsSummary,
r.Model,
r.StartedUtc,
r.CompletedUtc,
r.FailureStage,
r.TotalInputTokens,
r.TotalOutputTokens,
r.TotalTokens,
r.TotalDurationMs,
r.EstimatedCostGBP,
r.EstimatedCostUSD,
r.ErrorMessage,
r.ErrorDetail,
r.CreatedUtc,
r.UpdatedUtc
FROM dbo.StoryIntelligenceRuns r
INNER JOIN dbo.Projects p ON p.ProjectID = r.ProjectID
INNER JOIN dbo.Books b ON b.BookID = r.BookID
INNER JOIN dbo.ProjectUserAccess access ON access.ProjectID = r.ProjectID
AND access.UserID = @UserID
AND access.IsActive = 1
LEFT JOIN dbo.Chapters chapter ON chapter.ChapterID = r.ChapterID
WHERE r.BookID = @BookID
AND r.ProjectID IS NOT NULL
AND b.IsArchived = 0
AND p.IsArchived = 0
ORDER BY COALESCE(chapter.SortOrder, 2147483647),
COALESCE(r.ChapterNumber, chapter.ChapterNumber, 999999),
r.StoryIntelligenceRunID;
END;
GO

View File

@ -0,0 +1,79 @@
SET ANSI_NULLS ON;
SET QUOTED_IDENTIFIER ON;
GO
IF OBJECT_ID(N'dbo.StoryIntelligenceCharacterIllustrationAssignments', N'U') IS NULL
BEGIN
CREATE TABLE dbo.StoryIntelligenceCharacterIllustrationAssignments
(
StoryIntelligenceCharacterIllustrationAssignmentID int IDENTITY(1,1) NOT NULL CONSTRAINT PK_StoryIntelligenceCharacterIllustrationAssignments PRIMARY KEY,
ProjectID int NOT NULL,
CharacterKey nvarchar(160) NOT NULL,
CharacterName nvarchar(200) NOT NULL,
IllustrationLibraryItemID int NULL,
StableCode nvarchar(120) NULL,
MatchConfidence decimal(5,2) NOT NULL CONSTRAINT DF_SICharacterIllustrationAssignments_MatchConfidence DEFAULT 0,
MatchingScore int NOT NULL CONSTRAINT DF_SICharacterIllustrationAssignments_MatchingScore DEFAULT 0,
EvidenceJson nvarchar(max) NULL,
CandidateDiagnosticsJson nvarchar(max) NULL,
ReasonChosen nvarchar(500) NULL,
ReasonReassigned nvarchar(500) NULL,
IsActive bit NOT NULL CONSTRAINT DF_SICharacterIllustrationAssignments_IsActive DEFAULT 1,
CreatedUtc datetime2(0) NOT NULL CONSTRAINT DF_SICharacterIllustrationAssignments_CreatedUtc DEFAULT SYSUTCDATETIME(),
UpdatedUtc datetime2(0) NOT NULL CONSTRAINT DF_SICharacterIllustrationAssignments_UpdatedUtc DEFAULT SYSUTCDATETIME()
);
END;
GO
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'UX_SICharacterIllustrationAssignments_ProjectCharacter' AND object_id = OBJECT_ID(N'dbo.StoryIntelligenceCharacterIllustrationAssignments'))
BEGIN
CREATE UNIQUE INDEX UX_SICharacterIllustrationAssignments_ProjectCharacter
ON dbo.StoryIntelligenceCharacterIllustrationAssignments(ProjectID, CharacterKey)
WHERE IsActive = 1;
END;
GO
IF NOT EXISTS (SELECT 1 FROM sys.foreign_keys WHERE name = N'FK_SICharacterIllustrationAssignments_Project')
BEGIN
ALTER TABLE dbo.StoryIntelligenceCharacterIllustrationAssignments
ADD CONSTRAINT FK_SICharacterIllustrationAssignments_Project
FOREIGN KEY (ProjectID) REFERENCES dbo.Projects(ProjectID);
END;
GO
IF NOT EXISTS (SELECT 1 FROM sys.foreign_keys WHERE name = N'FK_SICharacterIllustrationAssignments_Illustration')
BEGIN
ALTER TABLE dbo.StoryIntelligenceCharacterIllustrationAssignments
ADD CONSTRAINT FK_SICharacterIllustrationAssignments_Illustration
FOREIGN KEY (IllustrationLibraryItemID) REFERENCES dbo.IllustrationLibraryItems(IllustrationLibraryItemID);
END;
GO
IF OBJECT_ID(N'dbo.StoryIntelligenceIllustrationDemands', N'U') IS NULL
BEGIN
CREATE TABLE dbo.StoryIntelligenceIllustrationDemands
(
StoryIntelligenceIllustrationDemandID int IDENTITY(1,1) NOT NULL CONSTRAINT PK_StoryIntelligenceIllustrationDemands PRIMARY KEY,
ProjectID int NULL,
DemandKey nvarchar(240) NOT NULL,
AgeBand nvarchar(40) NOT NULL,
Presentation nvarchar(40) NOT NULL,
HairColour nvarchar(40) NOT NULL,
SkinTone nvarchar(40) NOT NULL,
HairLength nvarchar(40) NULL,
Glasses nvarchar(20) NULL,
FacialHair nvarchar(40) NULL,
RequestCount int NOT NULL CONSTRAINT DF_StoryIntelligenceIllustrationDemands_RequestCount DEFAULT 1,
LastRequestedUtc datetime2(0) NOT NULL CONSTRAINT DF_StoryIntelligenceIllustrationDemands_LastRequestedUtc DEFAULT SYSUTCDATETIME(),
CreatedUtc datetime2(0) NOT NULL CONSTRAINT DF_StoryIntelligenceIllustrationDemands_CreatedUtc DEFAULT SYSUTCDATETIME(),
UpdatedUtc datetime2(0) NOT NULL CONSTRAINT DF_StoryIntelligenceIllustrationDemands_UpdatedUtc DEFAULT SYSUTCDATETIME()
);
END;
GO
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'UX_StoryIntelligenceIllustrationDemands_ProjectDemand' AND object_id = OBJECT_ID(N'dbo.StoryIntelligenceIllustrationDemands'))
BEGIN
CREATE UNIQUE INDEX UX_StoryIntelligenceIllustrationDemands_ProjectDemand
ON dbo.StoryIntelligenceIllustrationDemands(ProjectID, DemandKey);
END;
GO

View File

@ -0,0 +1,62 @@
SET ANSI_NULLS ON;
SET QUOTED_IDENTIFIER ON;
GO
UPDATE dbo.IllustrationLibraryItems
SET MetadataJson = JSON_MODIFY(
JSON_MODIFY(
JSON_MODIFY(
JSON_MODIFY(
JSON_MODIFY(
JSON_MODIFY(
JSON_MODIFY(
JSON_MODIFY(
CASE WHEN ISJSON(MetadataJson) = 1 THEN MetadataJson ELSE N'{}' END,
'$.category',
Category),
'$.metadataConfidence',
COALESCE(JSON_VALUE(MetadataJson, '$.metadataConfidence'), N'High')),
'$.ageBand',
COALESCE(JSON_VALUE(MetadataJson, '$.ageBand'), JSON_VALUE(SpecificationJson, '$.apparentAgeBand'))),
'$.presentation',
COALESCE(JSON_VALUE(MetadataJson, '$.presentation'), JSON_VALUE(SpecificationJson, '$.presentation'))),
'$.hairColour',
COALESCE(JSON_VALUE(MetadataJson, '$.hairColour'), JSON_VALUE(SpecificationJson, '$.hairColour'))),
'$.hairLength',
COALESCE(JSON_VALUE(MetadataJson, '$.hairLength'), JSON_VALUE(SpecificationJson, '$.hairLength'))),
'$.skinTone',
COALESCE(JSON_VALUE(MetadataJson, '$.skinTone'), JSON_VALUE(SpecificationJson, '$.skinTone'))),
'$.source',
COALESCE(JSON_VALUE(MetadataJson, '$.source'), N'specification-backfill')),
UpdatedUtc = SYSUTCDATETIME()
WHERE Category = N'Character'
AND IsActive = 1
AND ISJSON(SpecificationJson) = 1
AND (
ISJSON(MetadataJson) <> 1
OR JSON_VALUE(MetadataJson, '$.category') IS NULL
OR JSON_VALUE(MetadataJson, '$.metadataConfidence') IS NULL
OR JSON_VALUE(MetadataJson, '$.ageBand') IS NULL
OR JSON_VALUE(MetadataJson, '$.presentation') IS NULL
OR JSON_VALUE(MetadataJson, '$.hairColour') IS NULL
OR JSON_VALUE(MetadataJson, '$.skinTone') IS NULL
);
GO
UPDATE dbo.IllustrationLibraryItems
SET MetadataJson = JSON_MODIFY(
JSON_MODIFY(
CASE WHEN ISJSON(MetadataJson) = 1 THEN MetadataJson ELSE N'{}' END,
'$.category',
Category),
'$.metadataConfidence',
COALESCE(JSON_VALUE(MetadataJson, '$.metadataConfidence'), N'High')),
UpdatedUtc = SYSUTCDATETIME()
WHERE Category IN (N'Location', N'Asset')
AND IsActive = 1
AND (
ISJSON(MetadataJson) <> 1
OR JSON_VALUE(MetadataJson, '$.category') IS NULL
OR JSON_VALUE(MetadataJson, '$.metadataConfidence') IS NULL
);
GO

View File

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

View File

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

View File

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

View File

@ -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

View File

@ -0,0 +1,11 @@
IF COL_LENGTH(N'dbo.StoryMemoryImportPreferences', N'PrimaryAgeGroup') IS NULL
ALTER TABLE dbo.StoryMemoryImportPreferences ADD PrimaryAgeGroup nvarchar(120) NULL;
GO
IF COL_LENGTH(N'dbo.StoryMemoryImportPreferences', N'StoryEra') IS NULL
ALTER TABLE dbo.StoryMemoryImportPreferences ADD StoryEra nvarchar(120) NULL;
GO
IF COL_LENGTH(N'dbo.StoryMemoryImportPreferences', N'StoryLocation') IS NULL
ALTER TABLE dbo.StoryMemoryImportPreferences ADD StoryLocation nvarchar(160) NULL;
GO

View File

@ -0,0 +1,69 @@
using PlotLine.Models;
namespace PlotLine.ViewModels;
public sealed class IllustrationLibraryAdminViewModel
{
public IllustrationLibraryFilter Filter { get; init; } = new();
public IllustrationLibrarySummary Summary { get; init; } = new();
public IReadOnlyList<IllustrationLibraryItem> Items { get; init; } = [];
public IReadOnlyList<string> Categories { get; init; } = IllustrationLibraryCategories.All;
public IReadOnlyList<string> Statuses { get; init; } =
[
IllustrationLibraryStatuses.Planned,
IllustrationLibraryStatuses.Queued,
IllustrationLibraryStatuses.Generating,
IllustrationLibraryStatuses.Generated,
IllustrationLibraryStatuses.Approved,
IllustrationLibraryStatuses.Rejected,
IllustrationLibraryStatuses.Failed,
IllustrationLibraryStatuses.Superseded,
IllustrationLibraryStatuses.Disabled
];
public int StarterCharacterCount { get; init; }
public int StarterLocationCount { get; init; }
public int StarterAssetCount { get; init; }
public IllustrationStarterGenerationPlan StarterGenerationPlan { get; init; } = new(0, 0, 0);
public string StoragePathHint { get; init; } = "/uploads/story-intelligence-library";
public string ProviderStatus { get; init; } = string.Empty;
public int CompletedTotal => Summary.GeneratedCount + Summary.ApprovedCount + Summary.FailedCount + Summary.SupersededCount;
}
public sealed class IllustrationLibraryQueueForm
{
public bool Confirmed { get; set; }
public string? Category { get; set; }
public string? Status { get; set; }
public string? Search { get; set; }
}
public sealed class IllustrationLibraryBulkRetryForm
{
public string? Category { get; set; }
public string? Status { get; set; }
public string? Search { get; set; }
}
public sealed class IllustrationStarterGenerationForm
{
public bool Confirmed { get; set; }
}
public sealed class IllustrationLibraryRejectForm
{
public string? Reason { get; set; }
public string? Category { get; set; }
public string? Status { get; set; }
public string? Search { get; set; }
}
public sealed class IllustrationLibraryMetadataForm
{
public string? DisplayTitle { get; set; }
public string? ShortDescription { get; set; }
public string? MetadataJson { get; set; }
public string? AdminNotes { get; set; }
public string? Category { get; set; }
public string? Status { get; set; }
public string? Search { get; set; }
}

View File

@ -131,6 +131,7 @@ public sealed class StoryIntelligenceOverviewViewModel
public Guid? ExistingBatchID { get; init; } public Guid? ExistingBatchID { get; init; }
public StoryIntelligenceJobProgress? ExistingJob { get; init; } public StoryIntelligenceJobProgress? ExistingJob { get; init; }
public bool CanStart { get; init; } public bool CanStart { get; init; }
public string AppearancePreference { get; init; } = StoryMemoryAppearancePreferences.Balanced;
} }
public sealed class StoryIntelligenceProgressViewModel public sealed class StoryIntelligenceProgressViewModel
@ -138,6 +139,7 @@ public sealed class StoryIntelligenceProgressViewModel
public StoryIntelligenceJobProgress Job { get; init; } = new(); public StoryIntelligenceJobProgress Job { get; init; } = new();
public Guid? BatchID { get; init; } public Guid? BatchID { get; init; }
public Guid? PreviewID { get; init; } public Guid? PreviewID { get; init; }
public int? ImportSessionID { get; init; }
public int ProjectID { get; init; } public int ProjectID { get; init; }
public int BookID { get; init; } public int BookID { get; init; }
public string? ProjectName { get; init; } public string? ProjectName { get; init; }
@ -531,6 +533,7 @@ public sealed class StoryIntelligenceOnboardingChapterViewModel
public int? SourceWordCount { get; init; } public int? SourceWordCount { get; init; }
public int? TotalTokens { get; init; } public int? TotalTokens { get; init; }
public long? TotalDurationMs { get; init; } public long? TotalDurationMs { get; init; }
public DateTime? UpdatedUtc { get; init; }
public decimal? EstimatedCostUSD { get; init; } public decimal? EstimatedCostUSD { get; init; }
public decimal? EstimatedCostGBP { get; init; } public decimal? EstimatedCostGBP { get; init; }
public string? FailureStage { get; init; } public string? FailureStage { get; init; }

View File

@ -0,0 +1,204 @@
namespace PlotLine.ViewModels;
public sealed class StoryIntelligenceExperiencePrototypeViewModel
{
public string Mode { get; init; } = "simulation";
public int? RunId { get; init; }
public int? ImportSessionId { get; init; }
public int? BookId { get; init; }
public string? RunStatus { get; init; }
public string? ChangeToken { get; init; }
public DateTime GeneratedUtc { get; init; } = DateTime.UtcNow;
public bool IsTerminal { get; init; }
public string? SnapshotMessage { get; init; }
public StoryIntelligenceExperienceSnapshotDiagnostics Diagnostics { get; init; } = new();
public IReadOnlyList<StoryIntelligenceExperienceRunOption> AvailableRuns { get; init; } = [];
public string ProjectName { get; init; } = string.Empty;
public string BookTitle { get; init; } = string.Empty;
public IReadOnlyList<StoryIntelligenceExperienceSceneState> Scenes { get; init; } = [];
}
public sealed class StoryIntelligenceExperienceSnapshotDiagnostics
{
public string RequestedMode { get; init; } = string.Empty;
public int? CurrentImportId { get; init; }
public int? RequestedImportSessionId { get; init; }
public int? ResolvedImportSessionId { get; init; }
public int? ActiveChapterRunId { get; init; }
public string SessionStatus { get; init; } = string.Empty;
public int OverallBookProgress { get; init; }
public int AuthoritativeImportProgress { get; init; }
public int CalculatedVisualisationProgress { get; init; }
public int TotalChapters { get; init; }
public int CompletedChapters { get; init; }
public string CurrentChapter { get; init; } = string.Empty;
public string CurrentScene { get; init; } = string.Empty;
public int CompletedScenes { get; init; }
public int TotalScenes { get; init; }
public int ParsedScenes { get; init; }
public string TerminalRunSessionStatus { get; init; } = string.Empty;
public string DisplayedProgressReason { get; init; } = string.Empty;
public string ProgressSource { get; init; } = string.Empty;
public string CurrentBackendStage { get; init; } = string.Empty;
public int StoryMemoryCharacterCount { get; init; }
public int ResolvedCharacterCount { get; init; }
public int UnresolvedCharacterCount { get; init; }
public decimal NarratorConfidence { get; init; }
public int IllustrationAssignmentCount { get; set; }
public int IllustrationChangeCount { get; set; }
public string CharacterConsistencyReport { get; set; } = string.Empty;
public string RawScenePov { get; init; } = string.Empty;
public string InheritedChapterPov { get; init; } = string.Empty;
public string ResolvedPovIdentity { get; init; } = string.Empty;
public string PovResolutionReason { get; init; } = string.Empty;
public decimal PovConfidence { get; init; }
public bool PovChangeDetected { get; init; }
public string SnapshotSource { get; init; } = string.Empty;
public string RunStatus { get; init; } = string.Empty;
public int PersistedCompletedSceneCount { get; init; }
public int TotalPersistedSceneResultCount { get; init; }
public int SuccessfullyParsedSceneResultCount { get; init; }
public int FailedSceneResultCount { get; init; }
public int? HighestPersistedSceneResultId { get; init; }
public int? HighestParsedSceneResultId { get; init; }
public int? SelectedCurrentSceneResultId { get; init; }
public string SelectedChapterAndSceneOrder { get; init; } = string.Empty;
public string CurrentChangeToken { get; init; } = string.Empty;
public string? AnalysisCursor { get; init; }
public string? Warning { get; init; }
public int ReplayIndex { get; init; }
public int TotalReplayableScenes { get; init; }
public string ReplayMilestone { get; init; } = string.Empty;
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;
public string PanelHiddenCounts { get; init; } = string.Empty;
public int AiCallsDuringReplay { get; init; }
}
public sealed class StoryIntelligenceExperienceRunOption
{
public int RunId { get; init; }
public int? ImportSessionId { get; init; }
public string Label { get; init; } = string.Empty;
public string Status { get; init; } = string.Empty;
public DateTime CreatedUtc { get; init; }
}
public sealed class StoryIntelligenceExperienceSceneState
{
public string Id { get; init; } = string.Empty;
public bool IsCurrent { get; init; }
public int? ChapterRunId { get; init; }
public decimal? ChapterNumber { get; init; }
public int? SceneResultId { get; init; }
public int ReplayIndex { get; init; }
public int GlobalSceneIndex { get; init; }
public string ChapterLabel { get; init; } = string.Empty;
public int SceneNumber { get; init; }
public string Title { get; init; } = string.Empty;
public string Summary { get; init; } = string.Empty;
public string Stage { get; init; } = string.Empty;
public string TimelineLabel { get; init; } = string.Empty;
public string TimelineEvent { get; init; } = string.Empty;
public int ProgressPercent { get; init; }
public int ChaptersAnalysed { get; init; }
public int ChapterTotal { get; init; }
public int ScenesAnalysed { get; init; }
public int SceneTotal { get; init; }
public string ElapsedTime { get; init; } = string.Empty;
public string EstimatedRemaining { get; init; } = string.Empty;
public string PovCharacterId { get; init; } = string.Empty;
public StoryIntelligenceExperienceLocationState Location { get; init; } = new();
public IReadOnlyList<StoryIntelligenceExperienceCharacterState> Characters { get; init; } = [];
public IReadOnlyList<StoryIntelligenceExperienceAssetState> Assets { get; init; } = [];
public IReadOnlyList<StoryIntelligenceExperienceRelationshipState> Relationships { get; init; } = [];
public IReadOnlyList<StoryIntelligenceExperienceTextItem> KnowledgeThreads { get; init; } = [];
public IReadOnlyList<StoryIntelligenceExperienceTextItem> Observations { get; init; } = [];
public IReadOnlyList<string> LatestDiscoveries { get; init; } = [];
public IReadOnlyList<string> ActivityFeed { get; init; } = [];
}
public sealed class StoryIntelligenceExperienceCharacterState
{
public string Id { get; init; } = string.Empty;
public string LibraryCode { get; set; } = string.Empty;
public string Name { get; init; } = string.Empty;
public string Role { get; init; } = string.Empty;
public string Relevance { get; init; } = string.Empty;
public int Weight { get; init; }
public string ImagePath { get; set; } = string.Empty;
public StoryIntelligenceExperienceImageResolution ImageResolution { get; set; } = new();
}
public sealed class StoryIntelligenceExperienceLocationState
{
public string Id { get; init; } = string.Empty;
public string LibraryCode { get; set; } = string.Empty;
public string Name { get; init; } = string.Empty;
public string Label { get; init; } = string.Empty;
public string ImagePath { get; set; } = string.Empty;
public StoryIntelligenceExperienceImageResolution ImageResolution { get; set; } = new();
}
public sealed class StoryIntelligenceExperienceAssetState
{
public string Id { get; init; } = string.Empty;
public string LibraryCode { get; set; } = string.Empty;
public string Name { get; init; } = string.Empty;
public string Label { get; init; } = string.Empty;
public int Weight { get; init; }
public string ImagePath { get; set; } = string.Empty;
public StoryIntelligenceExperienceImageResolution ImageResolution { get; set; } = new();
}
public sealed class StoryIntelligenceExperienceImageResolution
{
public string EntityId { get; init; } = string.Empty;
public string StableCode { get; init; } = string.Empty;
public int? IllustrationLibraryItemId { get; init; }
public string? Status { get; init; }
public string? FinalImageUrl { get; init; }
public string FallbackImageUrl { get; init; } = string.Empty;
public bool FallbackUsed { get; init; } = true;
public decimal? AssignmentConfidence { get; init; }
public int? MatchingScore { get; init; }
public string? RejectedCandidates { get; set; }
public string? ReasonChosen { get; set; }
public string? ReasonReassigned { get; set; }
public string? PreviousIllustration { get; set; }
public string? PreviousEvidence { get; set; }
public string? CurrentEvidence { get; set; }
public string? DemandKey { get; set; }
public string? EvidenceWarnings { get; set; }
public string? DemandStatus { get; set; }
public string? ResolvedPresentation { get; init; }
public string? ResolvedAgeBand { get; init; }
public decimal? RelationshipDerivedConfidence { get; init; }
public decimal? NameDerivedConfidence { get; init; }
public decimal? PronounDerivedConfidence { get; init; }
public int? IllustrationReuseCount { get; init; }
public string? IllustrationAllocationStatus { get; init; }
}
public sealed class StoryIntelligenceExperienceRelationshipState
{
public string Id { get; init; } = string.Empty;
public string SourceId { get; init; } = string.Empty;
public string TargetId { get; init; } = string.Empty;
public string Label { get; init; } = string.Empty;
public string State { get; init; } = string.Empty;
public int Weight { get; init; }
}
public sealed class StoryIntelligenceExperienceTextItem
{
public string Id { get; init; } = string.Empty;
public string Title { get; init; } = string.Empty;
public string Detail { get; init; } = string.Empty;
public string Tone { get; init; } = string.Empty;
}

View File

@ -88,6 +88,7 @@
<a class="btn btn-outline-primary btn-sm" asp-action="StoryIntelligenceFullChapterTest">Full chapter test</a> <a class="btn btn-outline-primary btn-sm" asp-action="StoryIntelligenceFullChapterTest">Full chapter test</a>
<a class="btn btn-outline-primary btn-sm" asp-action="StoryIntelligenceExistingChapter">Queue chapter</a> <a class="btn btn-outline-primary btn-sm" asp-action="StoryIntelligenceExistingChapter">Queue chapter</a>
<a class="btn btn-outline-primary btn-sm" asp-action="StoryIntelligenceRuns">Saved runs</a> <a class="btn btn-outline-primary btn-sm" asp-action="StoryIntelligenceRuns">Saved runs</a>
<a class="btn btn-outline-primary btn-sm" asp-action="StoryIntelligenceIllustrationLibrary">Illustration library</a>
</div> </div>
</article> </article>
<article class="project-card"> <article class="project-card">

View File

@ -23,6 +23,16 @@
<div class="alert alert-warning">@Model.ErrorMessage</div> <div class="alert alert-warning">@Model.ErrorMessage</div>
} }
@if (TempData["AdminMessage"] is string message)
{
<div class="alert alert-success">@message</div>
}
@if (TempData["AdminError"] is string error)
{
<div class="alert alert-danger">@error</div>
}
<section class="edit-panel"> <section class="edit-panel">
<h2>OpenAI configuration</h2> <h2>OpenAI configuration</h2>
<div class="row g-3"> <div class="row g-3">
@ -59,6 +69,14 @@
</dl> </dl>
</section> </section>
<section class="edit-panel">
<h2>Development reset</h2>
<p>Delete your projects, books, Story Intelligence imports, runs and story memory. Generated image library records and files are preserved.</p>
<form asp-action="ResetStoryIntelligenceImports" method="post">
<button class="btn btn-outline-danger" type="submit">Reset my Story Intelligence data</button>
</form>
</section>
<section class="edit-panel"> <section class="edit-panel">
<h2>Prompt repository</h2> <h2>Prompt repository</h2>
<div class="row g-3"> <div class="row g-3">

View File

@ -0,0 +1,252 @@
@model IllustrationLibraryAdminViewModel
@{
ViewData["Title"] = "Illustration Library";
}
<div class="page-heading compact">
<div>
<p class="eyebrow">Admin / Story Intelligence</p>
<h1>Illustration Library</h1>
<p class="lead-text">Representative visual identifiers for the Story Intelligence experience. These are reusable library illustrations, not canonical manuscript artwork.</p>
</div>
</div>
<nav class="mb-3" aria-label="Breadcrumb">
<a asp-controller="Projects" asp-action="Index">Projects</a>
<span class="muted"> / </span>
<a asp-action="Index">Admin</a>
<span class="muted"> / Illustration Library</span>
</nav>
@if (TempData["AdminMessage"] is string message)
{
<div class="alert alert-success">@message</div>
}
@if (TempData["AdminError"] is string error)
{
<div class="alert alert-danger">@error</div>
}
<section class="edit-panel">
<div class="d-flex flex-wrap gap-3 align-items-center justify-content-between">
<div>
<p class="eyebrow">Generation workflow</p>
<h2 class="mb-1">Planned → queued → generating → generated</h2>
<p class="muted mb-0">@Model.ProviderStatus</p>
<p class="muted mb-0">Storage: @Model.StoragePathHint</p>
</div>
<div class="d-flex flex-wrap gap-2">
<form asp-action="GenerateIllustrationStarterLibrary" method="post">
@Html.AntiForgeryToken()
<input type="hidden" name="Confirmed" value="true" />
<button type="submit" class="btn btn-primary">Generate Starter Library</button>
</form>
<form asp-action="RequeueFailedIllustrationLibraryItems" method="post">
@Html.AntiForgeryToken()
<input type="hidden" name="Category" value="@Model.Filter.Category" />
<input type="hidden" name="Status" value="@Model.Filter.Status" />
<input type="hidden" name="Search" value="@Model.Filter.Search" />
<button type="submit" class="btn btn-outline-primary">Requeue All Failed</button>
</form>
</div>
</div>
</section>
<section class="edit-panel">
<div class="row g-3">
<div class="col-6 col-md-3 col-xl">
<div class="border rounded p-3 h-100"><p class="eyebrow">Total</p><h3>@Model.Summary.TotalCount.ToString("N0")</h3></div>
</div>
<div class="col-6 col-md-3 col-xl">
<div class="border rounded p-3 h-100"><p class="eyebrow">Planned</p><h3>@Model.Summary.PlannedCount.ToString("N0")</h3></div>
</div>
<div class="col-6 col-md-3 col-xl">
<div class="border rounded p-3 h-100"><p class="eyebrow">Queued</p><h3>@Model.Summary.QueuedCount.ToString("N0")</h3></div>
</div>
<div class="col-6 col-md-3 col-xl">
<div class="border rounded p-3 h-100"><p class="eyebrow">Generated</p><h3>@Model.Summary.GeneratedCount.ToString("N0")</h3></div>
</div>
<div class="col-6 col-md-3 col-xl">
<div class="border rounded p-3 h-100"><p class="eyebrow">Approved</p><h3>@Model.Summary.ApprovedCount.ToString("N0")</h3></div>
</div>
<div class="col-6 col-md-3 col-xl">
<div class="border rounded p-3 h-100"><p class="eyebrow">Failed</p><h3>@Model.Summary.FailedCount.ToString("N0")</h3></div>
</div>
<div class="col-6 col-md-3 col-xl">
<div class="border rounded p-3 h-100"><p class="eyebrow">Superseded</p><h3>@Model.Summary.SupersededCount.ToString("N0")</h3></div>
</div>
<div class="col-6 col-md-3 col-xl">
<div class="border rounded p-3 h-100"><p class="eyebrow">Completed</p><h3>@Model.CompletedTotal.ToString("N0")</h3></div>
</div>
</div>
<p class="muted mt-3 mb-0">Starter library: @Model.StarterCharacterCount characters, @Model.StarterLocationCount locations, @Model.StarterAssetCount assets. Next generation click will queue @Model.StarterGenerationPlan.TotalToGenerate total.</p>
</section>
<section class="edit-panel">
<form method="get" class="row g-2 align-items-end">
<div class="col-md-3">
<label class="form-label" for="category">Category</label>
<select class="form-select" id="category" name="Category">
<option value="">All</option>
@foreach (var category in Model.Categories)
{
<option value="@category" selected="@(string.Equals(Model.Filter.Category, category, StringComparison.OrdinalIgnoreCase))">@category</option>
}
</select>
</div>
<div class="col-md-3">
<label class="form-label" for="status">Status</label>
<select class="form-select" id="status" name="Status">
<option value="">All</option>
@foreach (var status in Model.Statuses)
{
<option value="@status" selected="@(string.Equals(Model.Filter.Status, status, StringComparison.OrdinalIgnoreCase))">@status</option>
}
</select>
</div>
<div class="col-md-4">
<label class="form-label" for="search">Search</label>
<input class="form-control" id="search" name="Search" value="@Model.Filter.Search" />
</div>
<div class="col-md-2">
<button type="submit" class="btn btn-outline-primary w-100">Filter</button>
</div>
</form>
</section>
<section class="project-card-grid">
@foreach (var item in Model.Items)
{
var preview = !string.IsNullOrWhiteSpace(item.ThumbnailPath) ? item.ThumbnailPath : item.FilePath;
var generationSource = item.StableCode.StartsWith("char-demand-", StringComparison.OrdinalIgnoreCase)
? "Demand generation"
: item.ParentItemID.HasValue
? "Manual regeneration"
: IllustrationStarterBatchDefinition.StarterCodes.Contains(item.StableCode)
? "Starter batch"
: "Manual library item";
<article class="project-card">
<div class="project-card__body">
<div class="d-flex justify-content-between gap-2 align-items-start">
<div>
<p class="eyebrow">@item.Category / @item.Status</p>
<h2>@item.DisplayTitle</h2>
</div>
<span class="badge bg-secondary">@item.AttemptCount.ToString("N0") tries</span>
</div>
@if (!string.IsNullOrWhiteSpace(preview))
{
<img src="@preview" alt="@item.DisplayTitle" class="img-fluid rounded border mb-3" loading="lazy" />
}
<p class="project-card__description">@item.ShortDescription</p>
<dl class="small mb-3">
<dt>Code</dt>
<dd><code>@item.StableCode</code></dd>
<dt>Provider</dt>
<dd>@(item.Provider ?? "Not generated") @(item.Model is null ? string.Empty : $" / {item.Model}")</dd>
<dt>Template</dt>
<dd>@(item.PromptTemplateVersion ?? "Not generated")</dd>
<dt>Source</dt>
<dd>@generationSource</dd>
<dt>Updated</dt>
<dd>@item.UpdatedUtc.ToString("yyyy-MM-dd HH:mm") UTC</dd>
@if (item.ApprovedUtc.HasValue)
{
<dt>Approved</dt>
<dd>@item.ApprovedUtc.Value.ToString("yyyy-MM-dd HH:mm") UTC</dd>
}
@if (item.RejectedUtc.HasValue)
{
<dt>Rejected</dt>
<dd>@item.RejectedUtc.Value.ToString("yyyy-MM-dd HH:mm") UTC</dd>
}
@if (!string.IsNullOrWhiteSpace(item.LastError))
{
<dt>Failure</dt>
<dd>@(item.ErrorMessage ?? item.LastError)</dd>
}
</dl>
<details class="mb-3">
<summary>Prompt and metadata</summary>
<label class="form-label mt-2">Prompt</label>
<textarea class="form-control" rows="6" readonly>@(item.Prompt ?? item.PromptText)</textarea>
<label class="form-label mt-2">Specification</label>
<textarea class="form-control" rows="6" readonly>@item.SpecificationJson</textarea>
<label class="form-label mt-2">Metadata</label>
<textarea class="form-control" rows="5" readonly>@item.MetadataJson</textarea>
</details>
<form asp-action="UpdateIllustrationLibraryMetadata" asp-route-id="@item.IllustrationLibraryItemID" method="post" class="mb-2">
@Html.AntiForgeryToken()
<input type="hidden" name="DisplayTitle" value="@item.DisplayTitle" />
<input type="hidden" name="ShortDescription" value="@item.ShortDescription" />
<input type="hidden" name="MetadataJson" value="@item.MetadataJson" />
<input type="hidden" name="Category" value="@Model.Filter.Category" />
<input type="hidden" name="Status" value="@Model.Filter.Status" />
<input type="hidden" name="Search" value="@Model.Filter.Search" />
<label class="form-label">Admin notes</label>
<textarea class="form-control mb-2" name="AdminNotes" rows="2">@item.AdminNotes</textarea>
<button type="submit" class="btn btn-outline-primary btn-sm">Save notes</button>
</form>
</div>
<div class="project-card__actions">
@if (item.Status is IllustrationLibraryStatuses.Planned or IllustrationLibraryStatuses.Failed or IllustrationLibraryStatuses.Rejected)
{
<form asp-action="QueueIllustrationLibraryItem" asp-route-id="@item.IllustrationLibraryItemID" method="post">
@Html.AntiForgeryToken()
<input type="hidden" name="Confirmed" value="true" />
<input type="hidden" name="Category" value="@Model.Filter.Category" />
<input type="hidden" name="Status" value="@Model.Filter.Status" />
<input type="hidden" name="Search" value="@Model.Filter.Search" />
<button type="submit" class="btn btn-primary btn-sm">Queue</button>
</form>
}
@if (item.Status == IllustrationLibraryStatuses.Generated)
{
<form asp-action="ApproveIllustrationLibraryItem" asp-route-id="@item.IllustrationLibraryItemID" method="post">
@Html.AntiForgeryToken()
<input type="hidden" name="Category" value="@Model.Filter.Category" />
<input type="hidden" name="Status" value="@Model.Filter.Status" />
<input type="hidden" name="Search" value="@Model.Filter.Search" />
<button type="submit" class="btn btn-success btn-sm">Approve</button>
</form>
<form asp-action="RejectIllustrationLibraryItem" asp-route-id="@item.IllustrationLibraryItemID" method="post">
@Html.AntiForgeryToken()
<input type="hidden" name="Reason" value="Rejected from Studio review" />
<input type="hidden" name="Category" value="@Model.Filter.Category" />
<input type="hidden" name="Status" value="@Model.Filter.Status" />
<input type="hidden" name="Search" value="@Model.Filter.Search" />
<button type="submit" class="btn btn-outline-danger btn-sm">Reject</button>
</form>
}
@if (item.Status is IllustrationLibraryStatuses.Generated or IllustrationLibraryStatuses.Approved or IllustrationLibraryStatuses.Rejected or IllustrationLibraryStatuses.Failed or IllustrationLibraryStatuses.Superseded)
{
<form asp-action="RegenerateIllustrationLibraryItem" asp-route-id="@item.IllustrationLibraryItemID" method="post">
@Html.AntiForgeryToken()
<input type="hidden" name="Confirmed" value="true" />
<input type="hidden" name="Category" value="@Model.Filter.Category" />
<input type="hidden" name="Status" value="@Model.Filter.Status" />
<input type="hidden" name="Search" value="@Model.Filter.Search" />
<button type="submit" class="btn btn-outline-primary btn-sm">Regenerate</button>
</form>
}
@if (item.Status != IllustrationLibraryStatuses.Disabled)
{
<form asp-action="DisableIllustrationLibraryItem" asp-route-id="@item.IllustrationLibraryItemID" method="post" onsubmit="return confirm('Disable this library illustration?');">
@Html.AntiForgeryToken()
<input type="hidden" name="Category" value="@Model.Filter.Category" />
<input type="hidden" name="Status" value="@Model.Filter.Status" />
<input type="hidden" name="Search" value="@Model.Filter.Search" />
<button type="submit" class="btn btn-outline-secondary btn-sm">Disable</button>
</form>
}
</div>
</article>
}
</section>
@if (Model.Items.Count == 0)
{
<section class="edit-panel">
<p class="mb-0">No library items match this filter.</p>
</section>
}

View File

@ -0,0 +1,549 @@
@using System.Text.Json
@model StoryIntelligenceExperiencePrototypeViewModel
@{
Layout = null;
var jsonOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
var statusLabel = Model.Mode switch
{
"simulation" => "Simulation",
"replay" => "Replay",
"real" when Model.IsTerminal => "Analysis Complete",
"real" => "Live Analysis",
"error" => "Import Session Error",
_ => "Select Import Session"
};
var sourceLabel = Model.Mode switch
{
"simulation" => "Simulation only",
"replay" => $"Saved analysis from Import Session {Model.ImportSessionId:N0}",
"real" => "Real snapshot",
"error" => "No live snapshot",
_ => "Choose a session"
};
var bootModel = Model.Mode == "real"
? new StoryIntelligenceExperiencePrototypeViewModel
{
Mode = Model.Mode,
RunId = Model.RunId,
ImportSessionId = Model.ImportSessionId,
BookId = Model.BookId,
RunStatus = Model.RunStatus,
GeneratedUtc = Model.GeneratedUtc,
IsTerminal = false,
ProjectName = Model.ProjectName,
BookTitle = Model.BookTitle,
SnapshotMessage = Model.SnapshotMessage,
Diagnostics = Model.Diagnostics,
Scenes = Model.Scenes,
ChangeToken = Model.ChangeToken
}
: Model;
}
<!DOCTYPE html>
<html lang="en" data-theme="dark" data-bs-theme="dark">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Story Intelligence Experience Prototype - PlotDirector</title>
<link rel="stylesheet" href="~/css/story-intelligence-experience-prototype.css" asp-append-version="true" />
</head>
<body>
<script id="storyExperienceData" type="application/json">@Html.Raw(JsonSerializer.Serialize(bootModel, jsonOptions))</script>
<main class="story-exp"
data-story-experience
data-image-diagnostics="true"
data-mode="@Model.Mode"
data-run-id="@Model.RunId"
data-book-id="@Model.BookId"
data-import-session-id="@Model.ImportSessionId"
data-failed-chapters="@Model.Diagnostics.FailedChapterSummary"
data-snapshot-url="@(Model.ImportSessionId.HasValue ? $"/api/story-intelligence/import-sessions/{Model.ImportSessionId.Value}/visualisation-snapshot" : Model.RunId.HasValue ? $"/api/story-intelligence/runs/{Model.RunId.Value}/visualisation-snapshot" : string.Empty)"
data-change-token="@Model.ChangeToken"
data-terminal="@Model.IsTerminal.ToString().ToLowerInvariant()">
<header class="story-exp-header" aria-label="Story Intelligence status">
<div class="story-exp-brand">
<strong>PlotDirector</strong>
<span>Story Intelligence</span>
</div>
<div class="story-exp-context">
<span data-experience-status>@statusLabel</span>
<span data-project-name>@Model.ProjectName</span>
<span data-book-title>@Model.BookTitle</span>
<span data-stage-label>Preparing</span>
<span>Scene <strong data-current-scene>1</strong></span>
</div>
@if ((Model.Mode == "real" || Model.Mode == "replay") && Model.BookId.HasValue)
{
<a class="story-exp-background" href="/onboarding/story-intelligence/book/@Model.BookId.Value/continue">Return to Import</a>
}
else
{
<a class="story-exp-background" href="/Development/StoryIntelligenceExperience?mode=simulation">Simulation</a>
}
</header>
@if (Model.AvailableRuns.Count > 0)
{
<nav class="story-exp-run-selector" aria-label="Development Story Intelligence import sessions">
<strong>Story Intelligence import sessions</strong>
@foreach (var run in Model.AvailableRuns)
{
<a href="/Development/StoryIntelligenceExperience?importSessionId=@run.ImportSessionId&mode=replay">Replay @run.Label</a>
}
</nav>
}
@if (!string.IsNullOrWhiteSpace(Model.SnapshotMessage))
{
<aside class="story-exp-status-message" role="status" data-snapshot-message>
@Model.SnapshotMessage
</aside>
}
@if (TempData["StoryIntelligenceReplayMessage"] is string replayMessage)
{
<aside class="story-exp-status-message" role="status">
@replayMessage
</aside>
}
@if (Model.Mode == "replay" && Model.ImportSessionId.HasValue)
{
<form class="story-exp-rebuild" asp-action="RebuildStoryIntelligenceExperience" method="post">
<input type="hidden" name="importSessionId" value="@Model.ImportSessionId.Value" />
<label>
Rebuild Visualisation from Saved Analysis
<select name="rebuildScope">
<option value="snapshots only">Snapshots only</option>
<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>
}
<section class="story-exp-shell" aria-label="Story Intelligence visual prototype">
<aside class="story-exp-left" aria-label="Analysis progress">
<div class="story-exp-progress">
<p class="story-exp-eyebrow">Overall progress</p>
<div class="story-exp-progress-ring" aria-hidden="true">
<div class="story-exp-progress-orbit"></div>
<div class="story-exp-progress-core">
<strong data-progress-percent>0%</strong>
<span>complete</span>
</div>
</div>
<div class="story-exp-progress-times">
<div>
<span>Elapsed</span>
<strong data-elapsed-time>0m</strong>
</div>
<div>
<span>Remaining</span>
<strong data-estimated-remaining>Calculating</strong>
</div>
</div>
</div>
<dl class="story-exp-stats">
<div>
<dt>Chapters analysed</dt>
<dd data-chapter-count>0 of 5</dd>
</div>
<div>
<dt>Scenes analysed</dt>
<dd data-scene-count>0 of 12</dd>
</div>
<div>
<dt>Current stage</dt>
<dd data-current-stage>Preparing</dd>
</div>
</dl>
<section class="story-exp-panel" aria-labelledby="latest-discoveries-title">
<h2 id="latest-discoveries-title">Latest discoveries</h2>
<ul class="story-exp-list" data-discoveries></ul>
</section>
<section class="story-exp-panel" aria-labelledby="activity-feed-title">
<h2 id="activity-feed-title">Live activity</h2>
<ol class="story-exp-feed" data-activity-feed></ol>
</section>
</aside>
<section class="story-exp-stage" aria-labelledby="living-story-title">
<div class="story-exp-stage-copy">
<p class="story-exp-eyebrow">Living story view</p>
<p id="living-story-title" class="story-exp-scene-summary" data-scene-summary>Reading the manuscript</p>
</div>
<div class="story-exp-visual" data-visual-stage>
<svg class="story-exp-connections" data-connections aria-hidden="true"></svg>
<div class="story-exp-zone story-exp-zone--characters" aria-label="Characters">
<p class="story-exp-zone-label">Characters</p>
<div class="story-exp-characters" data-characters></div>
</div>
<div class="story-exp-zone story-exp-zone--location" aria-label="Active location">
<p class="story-exp-zone-label">Location</p>
<div class="story-exp-location" data-location></div>
</div>
</div>
<section class="story-exp-ribbon-wrap" aria-label="Recent scenes and timeline">
@if (Model.Mode == "replay")
{
<div class="story-exp-replay-browser" aria-label="Replay scene browser">
<button type="button" data-control="first">First</button>
<button type="button" data-control="previous">Previous</button>
<label>
Chapter
<select data-replay-chapter></select>
</label>
<label>
Scene
<select data-replay-scene></select>
</label>
<button type="button" data-control="next">Next</button>
<button type="button" data-control="last">Last</button>
</div>
}
<div class="story-exp-ribbon" data-scene-ribbon></div>
<div class="story-exp-timeline" data-timeline></div>
</section>
</section>
<aside class="story-exp-right" aria-label="Story understanding">
<section class="story-exp-panel" aria-labelledby="knowledge-title">
<h2 id="knowledge-title">Knowledge threads</h2>
<div class="story-exp-insight-list" data-knowledge></div>
</section>
<section class="story-exp-panel" aria-labelledby="observations-title">
<h2 id="observations-title">Story observations</h2>
<div class="story-exp-insight-list" data-observations></div>
</section>
<section class="story-exp-panel story-exp-panel--compact" aria-labelledby="relationships-title">
<h2 id="relationships-title">Relationship changes</h2>
<div class="story-exp-insight-list" data-relationships></div>
</section>
</aside>
</section>
@if (Model.Mode == "simulation" || Model.Mode == "replay")
{
<aside class="story-exp-controls" aria-label="Prototype controls">
<div>
<span>@(Model.Mode == "replay" ? "Replay controls" : "Prototype controls")</span>
<small>@sourceLabel</small>
</div>
<button type="button" data-control="restart">Restart</button>
<button type="button" data-control="previous">Previous</button>
<button type="button" data-control="play">Pause</button>
<button type="button" data-control="next">Next</button>
<label>
Speed
<select data-speed-control>
<option value="8000">0.5x</option>
<option value="4200" selected>1x</option>
<option value="2200">2x</option>
<option value="1100">4x</option>
<option value="550">8x</option>
</select>
</label>
</aside>
}
<details class="story-exp-diagnostics" data-diagnostics>
<summary>Diagnostics</summary>
<dl>
<div>
<dt>Current Import ID</dt>
<dd data-diagnostic-key="currentImportId">@Model.Diagnostics.CurrentImportId</dd>
</div>
<div>
<dt>Requested mode</dt>
<dd data-diagnostic-key="requestedMode">@Model.Diagnostics.RequestedMode</dd>
</div>
<div>
<dt>Requested ImportSessionID</dt>
<dd data-diagnostic-key="requestedImportSessionId">@Model.Diagnostics.RequestedImportSessionId</dd>
</div>
<div>
<dt>Resolved ImportSessionID</dt>
<dd data-diagnostic-key="resolvedImportSessionId">@Model.Diagnostics.ResolvedImportSessionId</dd>
</div>
<div>
<dt>Active chapter RunID</dt>
<dd data-diagnostic-key="activeChapterRunId">@Model.Diagnostics.ActiveChapterRunId</dd>
</div>
<div>
<dt>Session status</dt>
<dd data-diagnostic-key="sessionStatus">@Model.Diagnostics.SessionStatus</dd>
</div>
<div>
<dt>Overall book progress</dt>
<dd data-diagnostic-key="overallBookProgress">@Model.Diagnostics.OverallBookProgress</dd>
</div>
<div>
<dt>Authoritative import progress</dt>
<dd data-diagnostic-key="authoritativeImportProgress">@Model.Diagnostics.AuthoritativeImportProgress</dd>
</div>
<div>
<dt>Calculated visualisation progress</dt>
<dd data-diagnostic-key="calculatedVisualisationProgress">@Model.Diagnostics.CalculatedVisualisationProgress</dd>
</div>
<div>
<dt>Total chapters</dt>
<dd data-diagnostic-key="totalChapters">@Model.Diagnostics.TotalChapters</dd>
</div>
<div>
<dt>Completed chapters</dt>
<dd data-diagnostic-key="completedChapters">@Model.Diagnostics.CompletedChapters</dd>
</div>
<div>
<dt>Current chapter</dt>
<dd data-diagnostic-key="currentChapter">@Model.Diagnostics.CurrentChapter</dd>
</div>
<div>
<dt>Current scene</dt>
<dd data-diagnostic-key="currentScene">@Model.Diagnostics.CurrentScene</dd>
</div>
<div>
<dt>Completed scenes</dt>
<dd data-diagnostic-key="completedScenes">@Model.Diagnostics.CompletedScenes</dd>
</div>
<div>
<dt>Total scenes</dt>
<dd data-diagnostic-key="totalScenes">@Model.Diagnostics.TotalScenes</dd>
</div>
<div>
<dt>Parsed scenes</dt>
<dd data-diagnostic-key="parsedScenes">@Model.Diagnostics.ParsedScenes</dd>
</div>
<div>
<dt>Terminal run/session status</dt>
<dd data-diagnostic-key="terminalRunSessionStatus">@Model.Diagnostics.TerminalRunSessionStatus</dd>
</div>
<div>
<dt>Displayed progress reason</dt>
<dd data-diagnostic-key="displayedProgressReason">@Model.Diagnostics.DisplayedProgressReason</dd>
</div>
<div>
<dt>Progress source</dt>
<dd data-diagnostic-key="progressSource">@Model.Diagnostics.ProgressSource</dd>
</div>
<div>
<dt>Current backend stage</dt>
<dd data-diagnostic-key="currentBackendStage">@Model.Diagnostics.CurrentBackendStage</dd>
</div>
<div>
<dt>Story Memory character count</dt>
<dd data-diagnostic-key="storyMemoryCharacterCount">@Model.Diagnostics.StoryMemoryCharacterCount</dd>
</div>
<div>
<dt>Resolved characters</dt>
<dd data-diagnostic-key="resolvedCharacterCount">@Model.Diagnostics.ResolvedCharacterCount</dd>
</div>
<div>
<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>
</div>
<div>
<dt>Raw scene POV</dt>
<dd data-diagnostic-key="rawScenePov">@Model.Diagnostics.RawScenePov</dd>
</div>
<div>
<dt>Inherited chapter POV</dt>
<dd data-diagnostic-key="inheritedChapterPov">@Model.Diagnostics.InheritedChapterPov</dd>
</div>
<div>
<dt>Resolved POV identity</dt>
<dd data-diagnostic-key="resolvedPovIdentity">@Model.Diagnostics.ResolvedPovIdentity</dd>
</div>
<div>
<dt>POV resolution reason</dt>
<dd data-diagnostic-key="povResolutionReason">@Model.Diagnostics.PovResolutionReason</dd>
</div>
<div>
<dt>POV confidence</dt>
<dd data-diagnostic-key="povConfidence">@Model.Diagnostics.PovConfidence</dd>
</div>
<div>
<dt>POV change detected</dt>
<dd data-diagnostic-key="povChangeDetected">@Model.Diagnostics.PovChangeDetected</dd>
</div>
<div>
<dt>Illustration assignments</dt>
<dd data-diagnostic-key="illustrationAssignmentCount">@Model.Diagnostics.IllustrationAssignmentCount</dd>
</div>
<div>
<dt>Illustration changes</dt>
<dd data-diagnostic-key="illustrationChangeCount">@Model.Diagnostics.IllustrationChangeCount</dd>
</div>
<div>
<dt>Character consistency report</dt>
<dd data-diagnostic-key="characterConsistencyReport">@Model.Diagnostics.CharacterConsistencyReport</dd>
</div>
<div>
<dt>Current polling interval</dt>
<dd data-diagnostic-key="currentPollingInterval">Initial</dd>
</div>
<div>
<dt>Latest snapshot age</dt>
<dd data-diagnostic-key="latestSnapshotAge">Initial</dd>
</div>
<div>
<dt>Snapshot source</dt>
<dd data-diagnostic-key="snapshotSource">@Model.Diagnostics.SnapshotSource</dd>
</div>
<div>
<dt>Replay index</dt>
<dd data-diagnostic-key="replayIndex">@Model.Diagnostics.ReplayIndex</dd>
</div>
<div>
<dt>Replayable scenes</dt>
<dd data-diagnostic-key="totalReplayableScenes">@Model.Diagnostics.TotalReplayableScenes</dd>
</div>
<div>
<dt>Replay state</dt>
<dd data-diagnostic-key="replayState">@Model.Diagnostics.ReplayState</dd>
</div>
<div>
<dt>Failed chapters skipped</dt>
<dd data-diagnostic-key="failedChaptersSkipped">@Model.Diagnostics.FailedChaptersSkipped</dd>
</div>
<div>
<dt>Failed chapter gaps</dt>
<dd data-diagnostic-key="failedChapterSummary">@Model.Diagnostics.FailedChapterSummary</dd>
</div>
<div>
<dt>AI calls during replay</dt>
<dd data-diagnostic-key="aiCallsDuringReplay">@Model.Diagnostics.AiCallsDuringReplay</dd>
</div>
<div>
<dt>Current Snapshot Version</dt>
<dd data-diagnostics-current>0</dd>
</div>
<div>
<dt>Previous Snapshot Version</dt>
<dd data-diagnostics-previous>0</dd>
</div>
<div>
<dt>Change Token</dt>
<dd data-diagnostics-token>@Model.ChangeToken</dd>
</div>
<div>
<dt>Animation Queue Length</dt>
<dd data-diagnostics-queue>0</dd>
</div>
<div>
<dt>Last Refresh Time</dt>
<dd data-diagnostics-refresh>Initial</dd>
</div>
<div>
<dt>Snapshot Build Time</dt>
<dd data-diagnostics-build>0ms</dd>
</div>
<div>
<dt>Operations detected</dt>
<dd data-diagnostics-operations>InitialRender</dd>
</div>
<div>
<dt>Run status</dt>
<dd data-diagnostic-key="runStatus">@Model.Diagnostics.RunStatus</dd>
</div>
<div>
<dt>Persisted completed scenes</dt>
<dd data-diagnostic-key="persistedCompletedSceneCount">@Model.Diagnostics.PersistedCompletedSceneCount</dd>
</div>
<div>
<dt>Total persisted scene results</dt>
<dd data-diagnostic-key="totalPersistedSceneResultCount">@Model.Diagnostics.TotalPersistedSceneResultCount</dd>
</div>
<div>
<dt>Successfully parsed scene results</dt>
<dd data-diagnostic-key="successfullyParsedSceneResultCount">@Model.Diagnostics.SuccessfullyParsedSceneResultCount</dd>
</div>
<div>
<dt>Failed/unparseable scene results</dt>
<dd data-diagnostic-key="failedSceneResultCount">@Model.Diagnostics.FailedSceneResultCount</dd>
</div>
<div>
<dt>Highest persisted SceneResultID</dt>
<dd data-diagnostic-key="highestPersistedSceneResultId">@Model.Diagnostics.HighestPersistedSceneResultId</dd>
</div>
<div>
<dt>Highest parsed SceneResultID</dt>
<dd data-diagnostic-key="highestParsedSceneResultId">@Model.Diagnostics.HighestParsedSceneResultId</dd>
</div>
<div>
<dt>Selected current SceneResultID</dt>
<dd data-diagnostic-key="selectedCurrentSceneResultId">@Model.Diagnostics.SelectedCurrentSceneResultId</dd>
</div>
<div>
<dt>Selected chapter and scene order</dt>
<dd data-diagnostic-key="selectedChapterAndSceneOrder">@Model.Diagnostics.SelectedChapterAndSceneOrder</dd>
</div>
<div>
<dt>Analysis cursor</dt>
<dd data-diagnostic-key="analysisCursor">@Model.Diagnostics.AnalysisCursor</dd>
</div>
<div>
<dt>Snapshot warning</dt>
<dd data-diagnostic-key="warning">@Model.Diagnostics.Warning</dd>
</div>
<div>
<dt>Last successful snapshot fetch</dt>
<dd data-diagnostic-key="lastSuccessfulSnapshotFetch">Initial</dd>
</div>
<div>
<dt>Last snapshot HTTP status</dt>
<dd data-diagnostic-key="lastSnapshotHttpStatus">Initial</dd>
</div>
<div>
<dt>Consecutive unchanged polls</dt>
<dd data-diagnostic-key="consecutiveUnchangedPolls">0</dd>
</div>
<div>
<dt>Consecutive failed polls</dt>
<dd data-diagnostic-key="consecutiveFailedPolls">0</dd>
</div>
<div>
<dt>Polling</dt>
<dd data-diagnostic-key="pollingActive">Initialising</dd>
</div>
<div>
<dt>Polling stopped reason</dt>
<dd data-diagnostic-key="pollingStoppedReason">none</dd>
</div>
</dl>
</details>
<aside class="story-exp-detail" data-detail-panel hidden>
<button type="button" class="story-exp-detail__close" data-detail-close>Close</button>
<span data-detail-tone></span>
<h2 data-detail-title></h2>
<p data-detail-body></p>
</aside>
</main>
<script src="~/js/story-intelligence-experience-prototype.js" asp-append-version="true"></script>
</body>
</html>

View File

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

View File

@ -80,7 +80,67 @@
<form asp-action="SkipStoryIntelligence" method="post"> <form asp-action="SkipStoryIntelligence" method="post">
<button class="btn btn-outline-secondary" type="submit">Skip for now</button> <button class="btn btn-outline-secondary" type="submit">Skip for now</button>
</form> </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>
Appearance baseline
<select class="form-control" name="appearancePreference">
<option value="@StoryMemoryAppearancePreferences.Balanced">Balanced diverse cast</option>
<option value="@StoryMemoryAppearancePreferences.ExplicitEvidenceOnly">Explicit evidence only</option>
<option value="@StoryMemoryAppearancePreferences.PredominantlyLight">Predominantly light-skinned cast</option>
<option value="@StoryMemoryAppearancePreferences.PredominantlyMedium">Predominantly medium-skinned cast</option>
<option value="@StoryMemoryAppearancePreferences.PredominantlyDark">Predominantly dark-skinned cast</option>
<option value="@StoryMemoryAppearancePreferences.CustomMix">Custom mix</option>
</select>
</label>
</fieldset>
<fieldset>
<legend>Story context for generated portraits</legend>
<p>These guide generated images when the manuscript is silent. Explicit manuscript evidence still wins.</p>
<label>
Primary character age group
<select class="form-control" name="primaryAgeGroup">
<option value="">No baseline</option>
<option value="Late teen / early 20s">Late teen / early 20s</option>
<option value="Young teen">Young teen</option>
<option value="Older teen">Older teen</option>
<option value="Young adult">Young adult</option>
<option value="Adult">Adult</option>
<option value="Middle-aged adult">Middle-aged adult</option>
<option value="Senior">Senior</option>
</select>
</label>
<label>
Era
<select class="form-control" name="storyEra">
<option value="">No baseline</option>
<option value="1980s">1980s</option>
<option value="1970s">1970s</option>
<option value="1990s">1990s</option>
<option value="2000s">2000s</option>
<option value="Contemporary">Contemporary</option>
<option value="Victorian">Victorian</option>
<option value="Medieval">Medieval</option>
<option value="Near future">Near future</option>
</select>
</label>
<label>
Location
<select class="form-control" name="storyLocation">
<option value="">No baseline</option>
<option value="UK">UK</option>
<option value="United States">United States</option>
<option value="Europe">Europe</option>
<option value="Urban">Urban</option>
<option value="Suburban">Suburban</option>
<option value="Rural">Rural</option>
<option value="Fantasy world">Fantasy world</option>
<option value="Science fiction setting">Science fiction setting</option>
</select>
</label>
</fieldset>
<button class="btn btn-primary" type="submit" disabled="@(!Model.CanStart)">Analyse manuscript</button> <button class="btn btn-primary" type="submit" disabled="@(!Model.CanStart)">Analyse manuscript</button>
</form> </form>
</div> </div>

View File

@ -113,6 +113,10 @@
<div data-story-run-id="@chapter.RunID" <div data-story-run-id="@chapter.RunID"
data-story-chapter-title="@chapter.ChapterTitle" data-story-chapter-title="@chapter.ChapterTitle"
data-story-raw-status="@chapter.Status" data-story-raw-status="@chapter.Status"
data-story-chapter-number="@chapter.ChapterNumber"
data-story-current-stage="@(chapter.CurrentStage ?? string.Empty)"
data-story-current-scene-number="@(chapter.CompletedScenes ?? 0)"
data-story-updated-utc="@(chapter.UpdatedUtc?.ToUniversalTime().ToString("O") ?? string.Empty)"
data-story-source-word-count="@(chapter.SourceWordCount ?? 0)" data-story-source-word-count="@(chapter.SourceWordCount ?? 0)"
data-story-duration-ms="@(chapter.TotalDurationMs ?? 0)"> data-story-duration-ms="@(chapter.TotalDurationMs ?? 0)">
<span data-story-run-status>@FriendlyStatus(chapter.Status)</span> <span data-story-run-status>@FriendlyStatus(chapter.Status)</span>
@ -137,6 +141,11 @@
{ {
<a class="btn btn-outline-primary" asp-controller="Projects" asp-action="Index">Continue in background</a> <a class="btn btn-outline-primary" asp-controller="Projects" asp-action="Index">Continue in background</a>
} }
@if (Model.ImportSessionID.HasValue)
{
<a class="btn btn-outline-primary"
href="/Development/StoryIntelligenceExperience?importSessionId=@Model.ImportSessionID.Value&mode=replay">Replay Story Intelligence</a>
}
@if (!Model.HasActiveRuns && Model.Chapters.Any(chapter => chapter.IsRunComplete || chapter.IsFailed || chapter.HasCompletedCommit)) @if (!Model.HasActiveRuns && Model.Chapters.Any(chapter => chapter.IsRunComplete || chapter.IsFailed || chapter.HasCompletedCommit))
{ {
<a class="btn btn-primary" <a class="btn btn-primary"

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 180 180" role="img" aria-label="Bag or wallet">
<rect width="180" height="180" rx="28" fill="#081524"/>
<rect x="38" y="68" width="104" height="70" rx="12" fill="#473326" stroke="#d7a85d" stroke-width="5"/>
<path d="M65 70c0-24 50-24 50 0" fill="none" stroke="#d9edf5" stroke-width="7" stroke-linecap="round"/>
<path d="M100 102h28" stroke="#f2c46b" stroke-width="7" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 460 B

View File

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 180 180" role="img" aria-label="Bench">
<rect width="180" height="180" rx="28" fill="#081524"/>
<path d="M36 82h108M46 108h88" stroke="#d7a85d" stroke-width="13" stroke-linecap="round"/>
<path d="M52 108v36M128 108v36M40 144h104" stroke="#7dc1ff" stroke-width="7" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 355 B

View File

@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 180 180" role="img" aria-label="Book">
<rect width="180" height="180" rx="28" fill="#071321"/>
<path d="M46 28h78c8 0 14 6 14 14v110H58c-7 0-12-5-12-12z" fill="#23384c"/>
<path d="M58 28v124" stroke="#d7a85d" stroke-width="8"/>
<path d="M76 56h42M76 76h32" stroke="#d9edf5" stroke-width="6" stroke-linecap="round"/>
<path d="M58 152h80" stroke="#6fa8cc" stroke-width="6" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 466 B

View File

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 180 180" role="img" aria-label="Bottle">
<rect width="180" height="180" rx="28" fill="#081524"/>
<path d="M76 30h28v36c15 8 23 21 23 40v36c0 8-7 14-15 14H68c-8 0-15-6-15-14v-36c0-19 8-32 23-40z" fill="#2c6f78" stroke="#7dc1ff" stroke-width="5"/>
<path d="M74 100h32" stroke="#d7a85d" stroke-width="13" stroke-linecap="round"/>
<path d="M74 31h32" stroke="#d9edf5" stroke-width="7" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 474 B

View File

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 180 180" role="img" aria-label="Clock">
<rect width="180" height="180" rx="28" fill="#081524"/>
<circle cx="90" cy="90" r="55" fill="#d9edf5" stroke="#d7a85d" stroke-width="7"/>
<path d="M90 56v36l24 16" stroke="#20384d" stroke-width="7" stroke-linecap="round"/>
<circle cx="90" cy="90" r="5" fill="#20384d"/>
</svg>

After

Width:  |  Height:  |  Size: 378 B

View File

@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 180 180" role="img" aria-label="Document">
<rect width="180" height="180" rx="28" fill="#091625"/>
<rect x="44" y="28" width="92" height="124" rx="8" fill="#d9edf5"/>
<path d="M112 28v30h24" fill="#b9ced8"/>
<path d="M58 74h64M58 94h52M58 114h60" stroke="#20384d" stroke-width="6" stroke-linecap="round"/>
<rect x="58" y="42" width="32" height="20" rx="4" fill="#d7a85d"/>
</svg>

After

Width:  |  Height:  |  Size: 443 B

View File

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 180 180" role="img" aria-label="Envelope">
<rect width="180" height="180" rx="28" fill="#081524"/>
<rect x="28" y="50" width="124" height="82" rx="8" fill="#d9edf5"/>
<path d="M31 56 90 99l59-43M31 128l42-40M149 128l-42-40" fill="none" stroke="#20384d" stroke-width="6" stroke-linecap="round"/>
<path d="M112 72h22" stroke="#d7a85d" stroke-width="7" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 443 B

View File

@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 180 180" role="img" aria-label="Environmental object">
<rect width="180" height="180" rx="28" fill="#071321"/>
<path d="M42 134h96" stroke="#d7a85d" stroke-width="8" stroke-linecap="round"/>
<path d="M69 134V74h42v60" fill="#142b3f" stroke="#7dc1ff" stroke-width="6"/>
<path d="M54 76c10-34 62-34 72 0" fill="#2f6f53"/>
<path d="M90 46v34" stroke="#8a6841" stroke-width="8" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 469 B

View File

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

After

Width:  |  Height:  |  Size: 931 B

View File

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 220 220">
<rect width="220" height="220" rx="24" fill="#102035"/>
<circle cx="84" cy="84" r="34" fill="none" stroke="#d7a85d" stroke-width="12"/>
<path d="M108 108l62 62M142 142l18-18M158 158l18-18" stroke="#d7a85d" stroke-width="13" stroke-linecap="round"/>
<path d="M55 150c24 20 61 25 94 12" stroke="#8fb4d4" stroke-width="5" fill="none" opacity=".55"/>
</svg>

After

Width:  |  Height:  |  Size: 425 B

View File

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 220 220">
<rect width="220" height="220" rx="24" fill="#102035"/>
<path d="M42 70h136v90H42z" fill="#f0d8a8" stroke="#c49b5b" stroke-width="5"/>
<path d="M42 72l68 54 68-54M42 160l54-48M178 160l-54-48" fill="none" stroke="#9d7440" stroke-width="5"/>
<circle cx="110" cy="126" r="14" fill="#9c2f2f"/>
</svg>

After

Width:  |  Height:  |  Size: 368 B

View File

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 220 220">
<rect width="220" height="220" rx="24" fill="#102035"/>
<rect x="62" y="36" width="104" height="148" rx="8" fill="#274257" stroke="#b9c9d6" stroke-width="5"/>
<path d="M82 66h58M82 92h58M82 118h46M82 144h52" stroke="#d7a85d" stroke-width="5" stroke-linecap="round"/>
<path d="M62 58h-16M62 90h-16M62 122h-16M62 154h-16" stroke="#d7e3ee" stroke-width="5" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 457 B

View File

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 180 180" role="img" aria-label="Parcel">
<rect width="180" height="180" rx="28" fill="#071321"/>
<rect x="38" y="50" width="104" height="92" rx="8" fill="#8a6841" stroke="#d7a85d" stroke-width="5"/>
<path d="M90 51v90M39 84h102" stroke="#f2d28a" stroke-width="7"/>
<path d="M58 114h32" stroke="#20384d" stroke-width="6" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 413 B

View File

@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 220 220">
<rect width="220" height="220" rx="24" fill="#102035"/>
<rect x="70" y="34" width="80" height="152" rx="16" fill="#111820" stroke="#8fb4d4" stroke-width="6"/>
<rect x="82" y="58" width="56" height="92" rx="6" fill="#24445e"/>
<circle cx="110" cy="166" r="7" fill="#d7a85d"/>
<path d="M92 92c12-11 24-11 36 0M84 78c18-18 34-18 52 0" stroke="#d7a85d" stroke-width="5" fill="none" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 483 B

View File

@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 180 180" role="img" aria-label="Photograph">
<rect width="180" height="180" rx="28" fill="#071321"/>
<rect x="34" y="36" width="112" height="108" rx="8" fill="#d9edf5"/>
<rect x="46" y="50" width="88" height="68" rx="4" fill="#18344e"/>
<circle cx="112" cy="68" r="10" fill="#d7a85d"/>
<path d="M50 116 80 88l20 18 14-13 20 23" fill="#6fa8cc"/>
<path d="M58 132h64" stroke="#20384d" stroke-width="5" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 497 B

View File

@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 220 220">
<rect width="220" height="220" rx="24" fill="#102035"/>
<path d="M38 118h144l18 24v28H20v-30z" fill="#9f2525"/>
<path d="M68 88h78l28 30H44z" fill="#be3a32"/>
<rect x="78" y="94" width="34" height="24" fill="#dce9f4" opacity=".75"/>
<rect x="118" y="94" width="28" height="24" fill="#dce9f4" opacity=".55"/>
<circle cx="58" cy="170" r="20" fill="#050914" stroke="#aabdd0" stroke-width="6"/>
<circle cx="162" cy="170" r="20" fill="#050914" stroke="#aabdd0" stroke-width="6"/>
</svg>

After

Width:  |  Height:  |  Size: 559 B

View File

@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 220 220">
<rect width="220" height="220" rx="24" fill="#102035"/>
<path d="M76 80c2-35 66-35 68 0" fill="none" stroke="#d7a85d" stroke-width="9"/>
<rect x="58" y="70" width="104" height="112" rx="20" fill="#33495d" stroke="#8aa8bf" stroke-width="5"/>
<rect x="78" y="118" width="64" height="42" rx="10" fill="#1c2c3a"/>
<path d="M80 94h60" stroke="#d7a85d" stroke-width="6" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 469 B

View File

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 180 180" role="img" aria-label="Traffic sign">
<rect width="180" height="180" rx="28" fill="#071321"/>
<path d="M90 28 144 82l-54 54-54-54z" fill="#d7a85d" stroke="#f2d28a" stroke-width="5"/>
<path d="M90 65v36M72 83h36" stroke="#20384d" stroke-width="9" stroke-linecap="round"/>
<path d="M90 136v26" stroke="#d9edf5" stroke-width="8" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 428 B

View File

@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 300">
<defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#183a5a"/><stop offset="1" stop-color="#c28b58"/></linearGradient></defs>
<rect width="300" height="300" fill="url(#g)"/>
<circle cx="150" cy="118" r="62" fill="#f0c7a4"/>
<path d="M82 132c5-70 41-94 85-82 34 9 52 36 51 82-28-30-76-34-136 0z" fill="#3b251d"/>
<path d="M58 300c14-74 50-111 92-111s78 37 92 111z" fill="#1b2638"/>
<path d="M110 157c23 22 57 22 80 0" fill="none" stroke="#8f5b48" stroke-width="8" stroke-linecap="round"/>
<circle cx="126" cy="120" r="6" fill="#1c2530"/><circle cx="174" cy="120" r="6" fill="#1c2530"/>
</svg>

After

Width:  |  Height:  |  Size: 692 B

View File

@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 300">
<defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#1d2330"/><stop offset="1" stop-color="#714033"/></linearGradient></defs>
<rect width="300" height="300" fill="url(#g)"/>
<circle cx="150" cy="116" r="59" fill="#d7ad91"/>
<path d="M91 93c12-39 41-58 76-50 27 6 47 23 54 52-42-15-84-16-130-2z" fill="#2b2423"/>
<path d="M58 300c15-72 50-108 92-108s77 36 92 108z" fill="#2a2f38"/>
<path d="M119 158c18 5 44 5 62 0" fill="none" stroke="#6f463e" stroke-width="7" stroke-linecap="round"/>
<circle cx="126" cy="120" r="6" fill="#111820"/><circle cx="174" cy="120" r="6" fill="#111820"/>
</svg>

After

Width:  |  Height:  |  Size: 690 B

View File

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

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 300">
<defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#11243b"/><stop offset="1" stop-color="#755a8c"/></linearGradient></defs>
<rect width="300" height="300" fill="url(#g)"/>
<circle cx="150" cy="120" r="60" fill="#e8bea3"/>
<path d="M78 126c-2-55 26-92 72-92s74 37 72 92c-23-38-49-52-72-52s-49 14-72 52z" fill="#d8d1c3"/>
<path d="M62 300c17-70 52-105 88-105s71 35 88 105z" fill="#473a56"/>
<path d="M118 157c19 16 45 16 64 0" fill="none" stroke="#8d5b50" stroke-width="7" stroke-linecap="round"/>
<circle cx="127" cy="121" r="5" fill="#28313d"/><circle cx="173" cy="121" r="5" fill="#28313d"/>
</svg>

After

Width:  |  Height:  |  Size: 702 B

View File

@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 300">
<defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#163d48"/><stop offset="1" stop-color="#b45d75"/></linearGradient></defs>
<rect width="300" height="300" fill="url(#g)"/>
<circle cx="150" cy="120" r="58" fill="#f1c9a8"/>
<path d="M84 118c9-52 42-80 80-70 33 9 54 36 55 75-46-28-90-29-135-5z" fill="#8a4f35"/>
<path d="M66 300c19-68 51-103 84-103s65 35 84 103z" fill="#1d4f58"/>
<path d="M119 158c19 15 43 15 62 0" fill="none" stroke="#8f5b48" stroke-width="7" stroke-linecap="round"/>
<circle cx="127" cy="121" r="5" fill="#1c2530"/><circle cx="173" cy="121" r="5" fill="#1c2530"/>
</svg>

After

Width:  |  Height:  |  Size: 692 B

View File

@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 240" role="img" aria-label="Bathroom">
<rect width="240" height="240" fill="#0b1d2d"/>
<path d="M36 72h168v124H36z" fill="#142d42"/>
<path d="M58 124h90v34c0 22-18 40-40 40H98c-22 0-40-18-40-40z" fill="#d9edf5" opacity=".92"/>
<path d="M52 116h102" stroke="#f2c46b" stroke-width="7" stroke-linecap="round"/>
<path d="M164 96h32v86h-32z" fill="#c4d8df"/>
<path d="M170 91h20" stroke="#7dc1ff" stroke-width="6" stroke-linecap="round"/>
<path d="M58 57h55v40H58z" fill="#203d55" stroke="#7dc1ff" stroke-width="3"/>
</svg>

After

Width:  |  Height:  |  Size: 590 B

View File

@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 240" role="img" aria-label="Building">
<rect width="240" height="240" fill="#081524"/>
<path d="M48 70h144v126H48z" fill="#172b3f" stroke="#6fa8cc" stroke-width="3"/>
<path d="M72 92h28v28H72zm70 0h28v28h-28zM72 140h28v28H72zm70 0h28v28h-28z" fill="#d7a85d" opacity=".68"/>
<path d="M38 70h164L120 36z" fill="#233c52"/>
<path d="M108 196v-42h24v42" fill="#0b1726"/>
</svg>

After

Width:  |  Height:  |  Size: 440 B

View File

@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 420 520">
<rect width="420" height="520" fill="#07111f"/>
<path d="M0 365c120-50 300-50 420 0v155H0z" fill="#1a2430"/>
<path d="M70 376h280" stroke="#d7a85d" stroke-width="7" stroke-dasharray="28 20" opacity=".7"/>
<path d="M82 300h252l48 52v46H42v-48z" fill="#8e2424"/>
<circle cx="114" cy="398" r="34" fill="#050914" stroke="#9ab3c7" stroke-width="7"/>
<circle cx="310" cy="398" r="34" fill="#050914" stroke="#9ab3c7" stroke-width="7"/>
<rect x="142" y="314" width="92" height="40" fill="#dce9f4" opacity=".7"/>
<rect x="250" y="316" width="62" height="38" fill="#dce9f4" opacity=".55"/>
<circle cx="58" cy="86" r="34" fill="#d7a85d" opacity=".35"/>
</svg>

After

Width:  |  Height:  |  Size: 730 B

View File

@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 420 520">
<defs><linearGradient id="sky" x1="0" y1="0" x2="0" y2="1"><stop stop-color="#10243d"/><stop offset="1" stop-color="#050914"/></linearGradient></defs>
<rect width="420" height="520" fill="url(#sky)"/>
<path d="M45 470h330V210L210 95 45 210z" fill="#1b2b3a" stroke="#596f84" stroke-width="5"/>
<path d="M80 220h260v250H80z" fill="#243648"/>
<path d="M138 470V330h72v140z" fill="#111923"/>
<g fill="#d5a65a"><rect x="104" y="250" width="48" height="58"/><rect x="268" y="250" width="48" height="58"/><rect x="218" y="350" width="52" height="64"/></g>
<path d="M0 472c95-31 190-31 420 0v48H0z" fill="#07111f"/>
<g stroke="#8fb4d4" stroke-width="2" opacity=".35"><path d="M40 30v90"/><path d="M95 10v100"/><path d="M340 22v120"/></g>
</svg>

After

Width:  |  Height:  |  Size: 816 B

View File

@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 240" role="img" aria-label="Driving test centre">
<rect width="240" height="240" fill="#071321"/>
<path d="M38 70h164v92H38z" fill="#183149" stroke="#6fa8cc" stroke-width="3"/>
<path d="M62 98h45v32H62zm70 0h45v32h-45z" fill="#0d1d2c"/>
<path d="M48 162h144v28H48z" fill="#26384a"/>
<path d="M70 190c14-20 86-20 100 0" stroke="#d7a85d" stroke-width="6" fill="none" stroke-linecap="round"/>
<path d="M82 55h76" stroke="#f2c46b" stroke-width="8" stroke-linecap="round"/>
<text x="120" y="153" text-anchor="middle" fill="#d9edf5" font-family="Arial, sans-serif" font-size="18" font-weight="700">TEST</text>
</svg>

After

Width:  |  Height:  |  Size: 682 B

View File

@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 420 520">
<rect width="420" height="520" fill="#101a2a"/>
<rect x="46" y="66" width="328" height="388" rx="18" fill="#29384a"/>
<rect x="74" y="96" width="138" height="110" fill="#c79b5a"/>
<rect x="234" y="96" width="110" height="230" fill="#172130"/>
<rect x="94" y="250" width="112" height="56" fill="#5e4234"/>
<rect x="103" y="230" width="84" height="28" fill="#f0d2a5"/>
<path d="M72 398c75-36 175-34 276 0" fill="none" stroke="#d7a85d" stroke-width="5" opacity=".45"/>
<circle cx="296" cy="388" r="34" fill="#d7a85d" opacity=".28"/>
</svg>

After

Width:  |  Height:  |  Size: 616 B

View File

@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 240" role="img" aria-label="Garage workshop">
<rect width="240" height="240" fill="#0a1420"/>
<path d="M34 72h172v116H34z" fill="#172738"/>
<path d="M54 104h132v84H54z" fill="#101b28" stroke="#6fa8cc" stroke-width="3"/>
<path d="M54 126h132M54 148h132M54 170h132" stroke="#294863" stroke-width="4"/>
<path d="M66 92h58" stroke="#d7a85d" stroke-width="7" stroke-linecap="round"/>
<path d="M144 88l26 26M170 88l-26 26" stroke="#d9edf5" stroke-width="6" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 552 B

View File

@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 240" role="img" aria-label="Kitchen">
<rect width="240" height="240" fill="#0b1726"/>
<path d="M30 66h180v126H30z" fill="#172b3b"/>
<path d="M46 86h54v90H46zm116 0h32v90h-32z" fill="#d7a85d" opacity=".65"/>
<path d="M103 86h56v90h-56z" fill="#20384c"/>
<path d="M112 98h38v30h-38z" fill="#0d1c2a" stroke="#7dc1ff" stroke-width="3"/>
<path d="M42 176h156M56 63v-20h75v20" stroke="#d9edf5" stroke-width="5" stroke-linecap="round"/>
<circle cx="177" cy="116" r="5" fill="#f2c46b"/>
</svg>

After

Width:  |  Height:  |  Size: 557 B

View File

@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 420 520">
<rect width="420" height="520" fill="#091522"/>
<rect x="50" y="82" width="320" height="354" rx="16" fill="#1c3341"/>
<circle cx="146" cy="286" r="64" fill="#0e1b25" stroke="#9db7c9" stroke-width="8"/>
<circle cx="146" cy="286" r="34" fill="#5e8fa0" opacity=".65"/>
<rect x="232" y="222" width="86" height="130" rx="8" fill="#d7a85d" opacity=".72"/>
<path d="M80 148h260" stroke="#d8e7f2" stroke-width="5" opacity=".35"/>
<path d="M96 164c42 24 86 24 132 0" stroke="#f2d8a4" stroke-width="6" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 590 B

View File

@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 240" role="img" aria-label="Road">
<defs><linearGradient id="sky" x1="0" x2="0" y1="0" y2="1"><stop stop-color="#153454"/><stop offset="1" stop-color="#071323"/></linearGradient></defs>
<rect width="240" height="240" fill="url(#sky)"/>
<path d="M0 155h240v85H0z" fill="#203142"/>
<path d="M92 240 114 112h12l24 128z" fill="#303946"/>
<path d="M120 126v26M120 174v30M120 225v15" stroke="#f2c46b" stroke-width="4" stroke-linecap="round"/>
<path d="M0 155c42-12 75-14 116-16 52-2 83-9 124-24v40c-50 14-92 18-137 19-40 1-69 5-103 16z" fill="#2f4f4f" opacity=".55"/>
<path d="M24 119h42v30H24zm145-20h39v34h-39z" fill="#101e30" stroke="#6fa8cc" stroke-width="2"/>
<path d="M31 149h171" stroke="#d7a85d" stroke-width="2" opacity=".55"/>
</svg>

After

Width:  |  Height:  |  Size: 811 B

View File

@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 420 520">
<rect width="420" height="520" fill="#07111f"/>
<path d="M64 90h292v360H64z" fill="#162333"/>
<path d="M86 424h248M108 380h226M130 336h204M152 292h182M174 248h160" stroke="#8da8bf" stroke-width="18" opacity=".55"/>
<path d="M246 80v390" stroke="#d7a85d" stroke-width="8" opacity=".7"/>
<rect x="270" y="112" width="54" height="92" fill="#c9904f" opacity=".68"/>
<path d="M0 520L420 80v440z" fill="#000" opacity=".22"/>
</svg>

After

Width:  |  Height:  |  Size: 501 B

View File

@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 240" role="img" aria-label="Vehicle interior">
<rect width="240" height="240" fill="#071321"/>
<path d="M24 76c18-24 174-24 192 0l-18 65H42z" fill="#12263b" stroke="#6fa8cc" stroke-width="3"/>
<path d="M52 88h136l-13 39H65z" fill="#203a52"/>
<path d="M28 141h184v54H28z" fill="#0e1d2c"/>
<circle cx="82" cy="165" r="25" fill="none" stroke="#d7a85d" stroke-width="7"/>
<path d="M132 155h52M132 174h38M55 113l-24 17M185 113l24 17" stroke="#7dc1ff" stroke-width="5" stroke-linecap="round"/>
<path d="M70 207h100" stroke="#304d68" stroke-width="10" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 648 B

View File

@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 240" role="img" aria-label="Waiting room">
<rect width="240" height="240" fill="#081524"/>
<path d="M32 64h176v130H32z" fill="#14283a"/>
<path d="M54 122h132v42H54z" fill="#233b50"/>
<path d="M62 96h38v68M110 96h38v68M158 96h20v68" stroke="#7dc1ff" stroke-width="7" stroke-linecap="round"/>
<circle cx="178" cy="67" r="24" fill="#101e30" stroke="#d7a85d" stroke-width="4"/>
<path d="M178 52v17l12 8" stroke="#d9edf5" stroke-width="4" stroke-linecap="round"/>
<path d="M48 194h144" stroke="#d7a85d" stroke-width="4" opacity=".7"/>
</svg>

After

Width:  |  Height:  |  Size: 608 B

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@ -0,0 +1,596 @@
# Phase 21A - Story Intelligence Visualisation Architecture Audit
## 1. Executive summary
PlotDirector already has most of the server-side ingredients needed for a durable Story Intelligence visualisation: ASP.NET Core MVC, SQL Server persistence, Dapper repositories, SignalR, a persisted background worker, canonical story entities, and a current progress page that survives browser and Word closure.
The current live experience is not yet graph-ready. SignalR messages are progress notifications, not durable events. Provisional discoveries are mostly reconstructed from saved scene-analysis JSON and transient in-memory batch decisions. Scene/chapter AI results have stable SQL identifiers, but provisional characters, locations, assets, relationships, knowledge items, confidence changes, merges and rejections do not have durable discovery records or ordered event history.
Sigma.js, Graphology and SignalR remain suitable for Phase 21 if SQL Server remains authoritative and SignalR is only a transport. The biggest prerequisite for Phase 21B is a durable visualisation snapshot/event model that can be rebuilt after refresh, replayed after reconnect, and joined to both provisional and canonical PlotDirector IDs.
No code, package, database, deployment or workflow changes were made for this audit.
## 2. Current Story Intelligence workflow
```mermaid
flowchart TD
A["Author starts onboarding import"] --> B["Website-led onboarding wizard"]
B --> C["Project and book selected or created"]
C --> D["Word Companion registers over SignalR"]
D --> E["Website asks Companion to scan document"]
E --> F["Companion reports scan preview"]
F --> G["Author reviews chapters, scenes, character candidates"]
G --> H["Onboarding builds approved chapter shells"]
H --> I["One StoryIntelligenceRun queued per approved chapter"]
I --> J["PersistedStoryIntelligenceWorker claims pending runs"]
J --> K["Chapter Structure prompt detects scene boundaries"]
K --> L["Scene Intelligence prompt analyses each suggested scene"]
L --> M["Chapter and scene results persisted in SQL"]
M --> N["SignalR progress updates current page"]
N --> O["Author reviews/imports scenes"]
O --> P["Characters, locations, assets, relationships, knowledge reviewed in sequence"]
P --> Q["Canonical PlotDirector records created or linked"]
```
Important components:
| Component | Path | Responsibility | Current state |
|---|---|---|---|
| `OnboardingController` | `PlotLine/Controllers/OnboardingController.cs` | Onboarding routes, scan review, Story Intelligence start/progress/review/import stages. | Current. Central workflow controller. |
| `WordCompanionFollowHub` | `PlotLine/Hubs/WordCompanionFollowHub.cs` | Companion presence, scan command, scan progress/completion, build marker updates. | Current. User-group scoped. |
| `ManuscriptScanPreviewStore` | `PlotLine/Services/ManuscriptScanPreviewStore.cs` | In-memory scan preview/review/build state. | Current but non-durable. Important recovery weakness. |
| `OnboardingService` | `PlotLine/Services/OnboardingService.cs` | Wizard state, scan review validation, approved chapter build. | Current. Creates chapter shells before AI run. |
| `OnboardingStoryIntelligenceService` | `PlotLine/Services/OnboardingStoryIntelligenceService.cs` | Queues per-chapter runs, builds progress/review models, orchestrates staged imports. | Current. Depends on in-memory batch store. |
| `OnboardingStoryIntelligenceBatchStore` | `PlotLine/Services/OnboardingStoryIntelligenceService.cs` | Keeps batch-to-run mapping and review decisions in a `ConcurrentDictionary`. | Current but volatile. Key gap. |
| `PersistedStoryIntelligenceWorker` | `PlotLine/Services/PersistedStoryIntelligenceWorker.cs` | Background service polling every 3 seconds for pending persisted runs. | Current. Browser-independent. |
| `PersistedStoryIntelligenceRunner` | `PlotLine/Services/PersistedStoryIntelligenceRunner.cs` | Executes chapter and scene prompts, validates, saves results, publishes progress. | Current. Per-run processing. |
| `StoryIntelligenceResultRepository` | `PlotLine/Data/StoryIntelligenceResultRepository.cs` | SQL access for runs, chapter/scene results, import commits. | Current. Core persistence path. |
| `StoryIntelligenceProgressNotifier` | `PlotLine/Services/StoryIntelligenceProgressNotifier.cs` | Broadcasts run progress to SignalR user group. | Current. Ephemeral messages. |
| `StoryIntelligenceImportCommitService` | `PlotLine/Services/StoryIntelligenceImportCommitService.cs` | Converts completed scene results into canonical chapters/scenes/metrics. | Current. Transaction is inside SQL proc. |
| Entity import services | `StoryIntelligenceCharacterImportService.cs`, `StoryIntelligenceLocationImportService.cs`, `StoryIntelligenceAssetImportService.cs`, `StoryIntelligenceRelationshipImportService.cs`, `StoryIntelligenceKnowledgeImportService.cs` | Build review candidates from committed scenes and saved AI JSON; create/link/ignore canonical entities. | Current. Decisions are partly transient. |
The older `StoryIntelligenceJobs` model still exists through `IStoryIntelligenceRepository`, `StoryIntelligenceService`, and legacy progress support, but the onboarding analysis flow now primarily uses `StoryIntelligenceRuns`.
## 3. Current background-processing architecture
`Program.cs` registers `StoryIntelligenceWorker`, `PersistedStoryIntelligenceWorker`, `EmailQueueWorker`, and `WordCompanionPresenceMonitor` as hosted services. The persisted runner claims SQL-backed pending runs via `StoryIntelligenceRun_ClaimNextPending` using `UPDLOCK, READPAST`, then processes a single run.
Reliability strengths:
- Processing is independent of the browser and Word once runs are queued.
- Runs persist status, stage, message, token totals, scene counts, failure stage, error text, cancellation timestamps, start/completion timestamps and source text.
- Cancellation is stored with `CancellationRequestedUtc` and checked by the runner.
- Failed chapter/scene JSON parse attempts can be persisted as result rows.
Weaknesses:
- No durable visualisation event stream exists.
- No explicit retry count or backoff model was found for failed Story Intelligence runs.
- No stale-running recovery was found for `StoryIntelligenceRuns` if the app dies after a run is claimed and marked `Running`.
- The active batch mapping from onboarding batch ID to run IDs is in memory only.
- Multiple app instances would need careful review: SQL claim locking helps workers, but in-memory batch state and SignalR user groups are process-local.
## 4. Current provisional discovery model
| Discovery type | Current storage before approval | Stable provisional ID? | Notes |
|---|---|---:|---|
| Chapters | Scan preview/review in memory; approved chapter shells become canonical before analysis. | Temporary scan keys only. | Chapter shells are canonical before scene analysis starts. |
| Scenes | `StoryIntelligenceSceneResults` with `SceneResultID` and temporary scene number. | Yes for scene results. | Canonical scenes created during scene commit. |
| Characters | Parsed scene JSON and review candidates derived by service. | Derived key only. | Character decisions are in in-memory batch. |
| Character aliases | Derived/imported during character review. | No durable provisional alias ID. | Alias decisions not durable. |
| Locations | Parsed scene JSON and review candidates. | Derived key only. | Uses normalisation rules, no provisional table. |
| Assets | Parsed scene JSON and review candidates. | Derived key only. | Ownership/custody inferred during import service. |
| Relationships | Parsed scene JSON and review candidates. | Derived key only. | No persisted suspected/confirmed relationship lifecycle. |
| Timeline clues | Parsed scene JSON/import notes. | No. | Imported indirectly as scene notes/timeline data. |
| Knowledge | Parsed scene JSON and review candidates. | Derived key only. | Knowledge selections/decisions live in batch. |
| Confidence | Stored inside parsed AI JSON and some review previews. | Attached to JSON only. | No confidence history. |
| Evidence/excerpts | Source text and raw/parsed prompt output are retained. | Scene result IDs help. | User-facing evidence is not normalised. |
| Rejections | In-memory batch decisions while process lives. | No durable rejection ID. | Lost on application restart unless inferred from canonical state. |
| Duplicate/merge candidates | Derived at review time. | No. | Alias/merge operations do not retain durable provisional lineage. |
The current system can answer "what chapter/scene is being analysed now?" and "what scene results exist?" reasonably well. It cannot reliably answer "in what order were characters discovered?", "when did confidence change?", "which provisional identities merged?", or "what did a reconnecting graph client miss?" without adding durable event/discovery storage.
## 5. Canonical data model relevant to the graph
Major graph-ready canonical records:
| Record | Tables / procedures | Models / repositories | Suitability |
|---|---|---|---|
| Projects, books, chapters, scenes | `Projects`, `Books`, `Chapters`, `Scenes`; `Project_*`, `Book_*`, `Chapter_*`, `Scene_*` | `ProjectRepository`, `BookRepository`, `ChapterRepository`, `SceneRepository` | Strong node base. Ordering by book/chapter/scene is significant. Soft archive applies. |
| Characters and appearances | `Characters`, `CharacterAliases`, `SceneCharacters` | `CharacterRepository` | Strong character nodes and scene edges. Alias graph needs interpretation. |
| Relationships | `CharacterRelationships`, `RelationshipEvents`, relationship categories/types | `CharacterRepository`, `RelationshipMapService` | Already feeds Cytoscape relationship map. Good edge source. |
| Character knowledge | `CharacterKnowledge`, knowledge states | `CharacterRepository` | Useful for knowledge nodes/edges, but evidence lineage limited. |
| Locations | `Locations`, `LocationAliases`, `LocationRelationships`, scene-location/cross refs | `LocationRepository` | Good location nodes; hierarchy and relationships need graph mapping. |
| Assets | `StoryAssets`, `AssetAliases`, `AssetEvents`, `AssetCustodyEvents`, dependencies | `AssetRepository` | Good asset nodes and custody/ownership edges. |
| Timeline/scene metrics | Timeline procedures, `SceneMetric*`, scene date/time fields | `TimelineRepository`, metric repositories | Good timeline layering, not a standalone graph yet. |
| Plot lines/threads | `PlotLines`, plot thread procedures/events | `PlotRepository` | Good thread nodes/edges when events are included. |
| Continuity warnings | `ContinuityWarnings`, acknowledgements | `WarningRepository`, `ContinuityWarningAcknowledgementRepository` | Useful issue nodes attached to scenes/entities. |
| Project activity | `ProjectActivity` | `ProjectActivityRepository` | Audit/activity feed, not detailed enough for graph replay. |
Existing canonical data is suitable for permanent Story Map snapshots, but the graph service should translate domain records into graph nodes/edges rather than exposing database DTOs directly.
## 6. Existing SignalR infrastructure
SignalR is registered in `Program.cs` with `MaximumReceiveMessageSize = 1 MB`.
Hubs:
- `/hubs/word-companion-follow` -> `WordCompanionFollowHub`, `[Authorize]`
- `/hubs/story-intelligence` -> `StoryIntelligenceHub`, `[Authorize]`
Group model:
- Word Companion uses `word-companion-presence:{userId}`.
- Story Intelligence uses `story-intelligence:{userId}`.
Client usage:
- `PlotLine/Views/Shared/_Layout.cshtml` includes SignalR from CDN for authenticated users.
- `PlotLine/Views/WordCompanionHost/Index.cshtml` includes SignalR from CDN.
- `PlotLine/wwwroot/js/story-intelligence-progress.js` connects to `/hubs/story-intelligence`, uses automatic reconnect, invokes watch methods after connect/reconnect, and handles `StoryIntelligenceProgressChanged` plus `StoryIntelligenceRunProgressChanged`.
Assessment:
- Suitable for progress updates and first visualisation transport.
- Current group scope is user-wide, not job/book-specific.
- Watch methods validate run ownership by `UserID`, but future graph groups should validate project/book/job access before joining.
- Messages are ephemeral. Missed recovery currently happens by reloading current run state, not by replaying events.
- No scale-out/backplane or sticky-session configuration was found. Deployment docs only cover upload storage; nginx/WebSocket settings were not present in the repo.
## 7. Existing frontend architecture
PlotLine is mostly server-rendered Razor with plain JavaScript and CSS:
- No PlotLine `package.json` was found.
- `PlotDirector.WordCompanion/package.json` exists only for the Office add-in manifest tooling.
- `bundleconfig.json` minifies selected CSS/JS files.
- Bootstrap is served from `wwwroot/lib/bootstrap`; the CSS appears to be Bootstrap 5.
- jQuery and validation libraries exist under `wwwroot/lib`.
- Theme support exists through `data-theme` / `data-bs-theme`, `site.css`, `plotline-theme.css`, and localStorage.
- Current JS is directly served/minified; no TypeScript build or module bundler is present for the MVC app.
- Existing components include Bootstrap modals, popovers, dropdowns, accordions, details-based drawers, a scene inspector drawer, and rich CSS theme audit pages.
- `prefers-reduced-motion` appears in CSS. Relationship Map disables layout animation.
Graph-related precedent:
- `RelationshipMap/Index.cshtml` loads Cytoscape from CDN and renders an interactive character relationship graph with pan/zoom, layout, edge selection, detail panel, resize handling and dark-theme styles.
- No Sigma.js or Graphology usage was found.
Sigma.js/Graphology integration options:
- Best long-term option: add an MVC frontend package/build pipeline and bundle Sigma/Graphology/worker modules with cache busting.
- Short-term prototype option: load from CDN like Cytoscape, but that conflicts with a durable production-grade graph engine and CSP/offline control.
- TypeScript is not currently established in PlotLine; Phase 21B should decide whether to introduce it with a minimal build step.
## 8. Relevant pages and navigation
Relevant routes:
- `/onboarding` -> onboarding wizard and Word scan controls.
- `/onboarding/scan-review` -> chapter/scene scan review.
- `/onboarding/story-intelligence` -> pre-analysis overview.
- `/onboarding/story-intelligence/progress?batchId=...` -> current progress page.
- `/onboarding/story-intelligence/review?batchId=...` -> scene review/import.
- `/onboarding/story-intelligence/characters|locations|assets|relationships|knowledge` -> staged entity review.
- `/onboarding/story-intelligence/book/{bookId}/continue` -> resume from book.
- `/RelationshipMap?projectId=...` -> current graph visualisation.
- `/Projects`, `/Books/Details`, `/Chapters/Details`, `/Scenes/Edit`, `/Characters`, `/Locations`, `/StoryAssets`, `/Timeline`, `/PlotThreads`, `/ContinuityExplorer`, `/Warnings`.
The current layout can host a graph, but the full-screen visualisation should probably use a minimal-chrome shell. No established full-screen graph canvas shell was found. Relationship Map has a graph workspace plus details panel that can be reused conceptually.
## 9. Security and authorisation assessment
Current strengths:
- Controllers are generally `[Authorize]`.
- `ProjectAccessFilter` globally checks project/entity access based on controller parameters and model properties.
- Project collaborators and owners are handled through `ProjectAccess_*` stored procedures.
- Story Intelligence hub methods check authenticated user ID; `WatchStoryIntelligenceRun` checks `run.UserID == userId`.
- Razor output is encoded by default.
Risks/gaps for the visualisation:
- Future graph APIs cannot rely only on `run.UserID`; collaborative project access and book-level access must be checked.
- User-group SignalR broadcasts could deliver all a user's Story Intelligence progress to every tab. A graph should use job/book groups after explicit authorisation.
- Manuscript excerpts in snapshots/evidence drawers need strict project/book/run ownership checks.
- CDN scripts complicate future CSP.
- Uploaded portrait/cover URLs should be served through existing upload path rules and not blindly embedded from AI output.
Recommended graph access:
- Snapshot endpoint: require authenticated user and `ProjectAccess_UserCanAccessEntity` for the book/run/project.
- Hub group join: `WatchStoryIntelligenceGraph(runId, afterSequence)` validates the same access, then joins `story-intelligence-run:{runId}`.
- Do not expose raw prompt internals or private reasoning; expose short manuscript evidence and structured user-facing justifications.
## 10. Stable identity assessment
Canonical IDs are stable (`project-{id}`, `book-{id}`, `chapter-{id}`, `scene-{id}`, `character-{id}`, `location-{id}`, `asset-{id}`, `relationship-{id}`, `knowledge-{id}`).
Current provisional stability:
- `StoryIntelligenceRunID`, `ChapterResultID`, and `SceneResultID` are stable.
- Temporary scan keys are stable only within the scan preview memory store.
- Review candidate keys for characters/locations/assets/relationships/knowledge are derived and not durable.
- Merges/aliases do not have a durable provisional-to-canonical lineage.
Recommended convention, pending future storage:
- `run-{runId}`
- `chapter-result-{chapterResultId}`
- `scene-result-{sceneResultId}`
- `provisional-character-{discoveryId}` -> merge/replace to `character-{characterId}`
- `provisional-location-{discoveryId}` -> `location-{locationId}`
- `provisional-asset-{discoveryId}` -> `asset-{assetId}`
- `provisional-relationship-{discoveryId}` -> `relationship-{characterRelationshipId}`
- `provisional-knowledge-{discoveryId}` -> `knowledge-{characterKnowledgeId}`
The client will need an explicit `IdentityMerged` or `NodePromoted` event to preserve visual continuity when provisional nodes become canonical.
## 11. Evidence and confidence assessment
Stored today:
- Source text on `StoryIntelligenceRuns`.
- Raw response JSON, assistant output JSON and parsed JSON for chapter/scene results.
- Validation errors/warnings counts.
- Confidence values inside parsed scene JSON for many extracted facts.
- Source scene/chapter through run, chapter result and scene result linkage.
- Canonical import commit metadata and warnings.
Not stored in a graph-ready way:
- Normalised evidence records per claim.
- Short source excerpts per discovery.
- Confidence history.
- Alternative candidates.
- Confirmation or contradictory evidence across later scenes.
- Manual override/rejection reason as durable data.
- User-facing reasoning summaries separate from raw AI output.
The future evidence drawer can be supported if Phase 21 adds durable discovery/evidence rows derived from parsed scene results, not from private model reasoning.
## 12. Event-stream assessment
Existing mechanisms:
- `ProjectActivity` and `ProjectAudit_Record`: useful general audit trail, not fine-grained Story Intelligence replay.
- SignalR progress events: ephemeral.
- Import commit rows: durable finalisation record, not incremental discovery history.
- SQL result tables: durable snapshots of prompt outputs, not ordered graph events.
Needed event model:
- Dedicated `StoryIntelligenceVisualisationEvents` table.
- Monotonic `SequenceNumber` per run/job.
- Event type, timestamp, project/book/run/scene/discovery references.
- JSON payload with a version.
- Idempotency key.
- Retention rules.
- Optional processed/published metadata if an outbox publisher is introduced.
Candidate events:
`AnalysisStarted`, `StageChanged`, `ChapterAnalysisStarted`, `SceneAnalysisStarted`, `SceneAnalysisCompleted`, `NodeDiscovered`, `NodeUpdated`, `EdgeDiscovered`, `EdgeUpdated`, `IdentityMerged`, `RelationshipSuspected`, `RelationshipConfirmed`, `TimelineEventDiscovered`, `KnowledgeThreadOpened`, `KnowledgeThreadResolved`, `ObservationCreated`, `AnalysisCompleted`, `AnalysisFailed`, `NodePromotedToCanonical`, `DiscoveryRejected`.
## 13. Performance and scale assessment
Estimated graph sizes:
| Scope | Scenes | Characters | Locations | Assets | Relationships | Knowledge/events | Nodes | Edges |
|---|---:|---:|---:|---:|---:|---:|---:|---:|
| Small test | 5-15 | 5-12 | 3-8 | 3-10 | 5-20 | 10-40 | 30-90 | 50-150 |
| Normal novel | 60-120 | 25-80 | 30-120 | 40-180 | 80-250 | 150-600 | 300-1,100 | 700-3,000 |
| Large novel | 150-250 | 80-180 | 100-250 | 150-400 | 250-700 | 500-1,500 | 1,000-3,000 | 3,000-10,000 |
| Trilogy | 300-600 | 150-350 | 200-600 | 300-1,000 | 700-2,000 | 1,500-5,000 | 3,000-9,000 | 10,000-35,000 |
| Large multi-book project | 800+ | 400+ | 800+ | 1,500+ | 3,000+ | 8,000+ | 12,000+ | 50,000+ |
Primary risks:
- JSON payload size for snapshots and replay.
- Browser layout cost for dense relationship/evidence edges.
- Portrait textures and labels.
- Replaying thousands of small events after reconnect.
- SQL DTO construction from many canonical tables.
Recommendation: Phase 21B should target one book / one active analysis batch, with filters and level-of-detail from the start. Project-wide exploration should come later.
## 14. Reconnection and recovery assessment
Current recovery:
- Progress JS reconnects and invokes watch methods to get current run state.
- Browser refresh can reload progress from SQL if the batch still exists.
- Closing Word does not stop queued analysis.
Gaps:
- In-memory scan preview and batch store do not survive application restart.
- SignalR missed events are not replayable.
- No event sequence exists.
- No graph snapshot endpoint exists.
Required Phase 21 design:
```mermaid
flowchart LR
A["Browser opens visualisation"] --> B["GET graph snapshot"]
B --> C["Snapshot includes lastSequence"]
C --> D["Join authorised SignalR run group"]
D --> E["Receive events with sequence numbers"]
E --> F["Apply idempotently to Graphology"]
F --> G["Reconnect"]
G --> H["Request events after last applied sequence"]
H --> F
H --> I["If too old, reload snapshot"]
```
## 15. Approval-to-canonical transition assessment
Scene import:
- `StoryIntelligenceImportCommitService` prepares completed run results.
- SQL commit creates canonical scenes/metrics and records `StoryIntelligenceImportCommits`.
- Pipeline state records scene import.
- Duplicate scene import is blocked by readiness checks.
Entity imports:
- Review candidates are derived from committed scenes and saved parsed JSON.
- Create/link/alias/ignore actions call existing canonical repositories.
- Pipeline state advances per book.
- Decisions and last import summaries are in the batch object, not durable review tables.
Risks:
- Provisional node IDs will disappear unless durable discovery IDs are introduced.
- Rejections/aliases are not reliably reconstructable after restart.
- Partial approval can change graph topology without an event record.
- Relationships can reference provisional characters unless promotion/merge ordering is explicit.
```mermaid
flowchart TD
A["Parsed scene result"] --> B["Provisional discovery row"]
B --> C["Graph provisional node"]
C --> D{"Author decision"}
D -->|Create| E["Canonical record created"]
D -->|Link| F["Existing canonical record linked"]
D -->|Alias/Merge| G["Discovery merged into target"]
D -->|Ignore| H["Discovery rejected"]
E --> I["NodePromotedToCanonical event"]
F --> I
G --> J["IdentityMerged event"]
H --> K["DiscoveryRejected event"]
```
## 16. Proposed Phase 21 architecture
```mermaid
flowchart TD
A["Story Intelligence analysis worker"] --> B["Persist discoveries and state"]
B --> C["SQL Server"]
C --> D["Ordered visualisation event table"]
D --> E["Story Intelligence event publisher"]
E --> F["SignalR hub"]
F --> G["Browser client"]
G --> H["Snapshot loader"]
G --> I["Event applier"]
H --> J["Graphology graph model"]
I --> J
J --> K["Sigma.js renderer"]
G --> L["Panels, feeds, filters, evidence drawer"]
```
Principles:
- SQL Server is authoritative.
- SignalR is transport only.
- Browser never analyses manuscript.
- Graph DTOs are versioned and separate from database DTOs.
- The same graph engine supports live analysis and completed Story Map.
## 17. Proposed server-side components
- `StoryIntelligenceVisualisationSnapshotService`: builds snapshot for run/book/project.
- `StoryIntelligenceVisualisationEventRepository`: persists ordered events.
- `StoryIntelligenceDiscoveryRepository`: persists provisional nodes/edges/evidence.
- `StoryIntelligenceVisualisationPublisher`: publishes stored events to SignalR after commit.
- `StoryIntelligenceGraphHub`: job/book-scoped groups with explicit access validation.
- `StoryIntelligenceGraphController` or API controller: snapshot and missed-event endpoints.
- Integration points in `PersistedStoryIntelligenceRunner` and import services to write discovery/event rows.
## 18. Proposed browser-side components
```mermaid
flowchart LR
A["Razor page"] --> B["graph bootstrap"]
B --> C["snapshot API client"]
B --> D["SignalR client"]
C --> E["graph adapter"]
D --> E
E --> F["Graphology"]
F --> G["Sigma renderer"]
E --> H["activity feed"]
E --> I["evidence drawer"]
E --> J["filters/search"]
K["layout worker"] --> F
```
Suggested modules:
- `story-graph-client`
- `story-graph-adapter`
- `story-graph-renderer`
- `story-graph-layout-worker`
- `story-graph-panels`
- `story-graph-api`
## 19. Proposed API contracts
Snapshot:
```http
GET /api/story-intelligence/runs/{runId}/graph-snapshot?scope=book
```
Response shape:
```json
{
"runId": 123,
"projectId": 10,
"bookId": 20,
"lastSequence": 456,
"nodes": [],
"edges": [],
"activeStage": "SceneIntelligence",
"permissions": { "canReview": true },
"generatedUtc": "2026-07-11T00:00:00Z"
}
```
Missed events:
```http
GET /api/story-intelligence/runs/{runId}/graph-events?afterSequence=456
```
## 20. Proposed SignalR message contracts
Hub route proposal: `/hubs/story-intelligence-graph`
Client invokes:
- `WatchRunGraph(runId, afterSequence)`
- `LeaveRunGraph(runId)`
Server sends:
```json
{
"runId": 123,
"sequence": 457,
"eventId": "01J...",
"type": "NodeDiscovered",
"occurredUtc": "2026-07-11T00:00:00Z",
"payloadVersion": 1,
"payload": {}
}
```
## 21. Proposed event categories
- Analysis lifecycle
- Progress and stage
- Scene/chapter lifecycle
- Discovery node lifecycle
- Relationship/edge lifecycle
- Evidence/confidence updates
- Merge/promotion/rejection
- Review/approval
- Failure/cancellation/recovery
## 22. Proposed implementation sequence
1. Add durable event/discovery design and SQL script.
2. Add read-only snapshot service over existing canonical plus run-result data.
3. Add event writer around current runner progress and scene result persistence.
4. Add graph hub with authorised job groups and replay-after-sequence.
5. Build browser graph adapter with mocked renderer data from real snapshots.
6. Integrate Sigma.js/Graphology and layout worker.
7. Add live progress/discovery events.
8. Add approval/promotion events for scene and entity imports.
9. Add completed Story Map route reusing the graph engine.
## 23. Database changes likely to be required
Follow existing SQL script convention in `PlotLine/Sql`, where the current latest script is `134_Phase20AV_ArchivedBookProjectPermanentDeletion.sql`.
Likely future script:
- `135_Phase21B_StoryIntelligenceVisualisationEvents.sql`
Likely tables:
- `StoryIntelligenceDiscoveries`
- `StoryIntelligenceDiscoveryEvidence`
- `StoryIntelligenceDiscoveryAliases`
- `StoryIntelligenceVisualisationEvents`
- Possibly `StoryIntelligenceGraphSnapshots` if snapshot materialisation is needed later.
Conventions to follow:
- Idempotent `IF OBJECT_ID` / `COL_LENGTH` checks.
- `CREATE OR ALTER PROCEDURE`.
- `PK_`, `FK_`, `DF_`, `CK_`, `IX_` naming.
- User/project/book isolation columns and indexes.
- Do not alter historical scripts.
## 24. Risks and mitigations
| Risk | Mitigation |
|---|---|
| Volatile batch state prevents recovery | Persist batch/run mappings and review decisions or derive them from durable pipeline rows. |
| SignalR events missed | Store ordered events and replay after sequence. |
| Graph too dense | One-book scope, filters, clustering, label level-of-detail. |
| Provisional/canonical ID churn | Add discovery IDs and explicit promotion/merge events. |
| Manuscript data leakage | Snapshot/hub access checks against project/book/run. |
| Package integration churn | Decide MVC frontend build approach before adding Sigma/Graphology. |
| CDN/CSP concerns | Prefer bundled assets for production. |
| Existing progress workflow regression | Add visualisation beside current progress page first. |
## 25. Decisions still required
- Whether Phase 21B introduces an MVC `package.json` and bundler.
- Whether to persist batch state or replace batch IDs with durable book/run pipeline IDs.
- Exact discovery/event SQL schema.
- Whether to keep Cytoscape for Relationship Map or migrate it later to the new engine.
- Retention policy for raw source excerpts and visualisation events.
- Whether snapshots are generated on demand or materialised.
## 26. Files inspected
Primary files inspected:
- `PlotLine/Program.cs`
- `PlotLine/Hubs/StoryIntelligenceHub.cs`
- `PlotLine/Hubs/WordCompanionFollowHub.cs`
- `PlotLine/Controllers/OnboardingController.cs`
- `PlotLine/Controllers/RelationshipMapController.cs`
- `PlotLine/Services/OnboardingService.cs`
- `PlotLine/Services/OnboardingStoryIntelligenceService.cs`
- `PlotLine/Services/PersistedStoryIntelligenceWorker.cs`
- `PlotLine/Services/PersistedStoryIntelligenceRunner.cs`
- `PlotLine/Services/StoryIntelligenceProgressNotifier.cs`
- `PlotLine/Services/StoryIntelligenceImportCommitService.cs`
- `PlotLine/Services/SceneImportResolver.cs`
- `PlotLine/Services/StoryIntelligenceCharacterImportService.cs`
- `PlotLine/Services/StoryIntelligenceLocationImportService.cs`
- `PlotLine/Services/ProjectAccessFilter.cs`
- `PlotLine/Services/ProjectAccessServices.cs`
- `PlotLine/Data/StoryIntelligenceResultRepository.cs`
- `PlotLine/Data/StoryIntelligenceRepository.cs`
- `PlotLine/Data/StoryIntelligencePipelineRepository.cs`
- `PlotLine/Data/Repositories.cs`
- `PlotLine/Models/StoryIntelligenceModels.cs`
- `PlotLine/Models/StoryIntelligencePersistenceModels.cs`
- `PlotLine/ViewModels/OnboardingViewModels.cs`
- `PlotLine/Views/Onboarding/StoryIntelligenceProgress.cshtml`
- `PlotLine/Views/Onboarding/StoryIntelligenceReview.cshtml`
- `PlotLine/Views/RelationshipMap/Index.cshtml`
- `PlotLine/Views/Shared/_Layout.cshtml`
- `PlotLine/wwwroot/js/story-intelligence-progress.js`
- `PlotLine/bundleconfig.json`
- `PlotDirector.WordCompanion/package.json`
- SQL scripts `001`, `004`, `005`, `006`, `007`, `020`, `033`, `039`, `042`, `046`, `088`, `093`, `111`, `114`, `115`, `117`, `121`, `123`, `124`, `127` through `134`
- `PlotLine/Docs/Features/PlotDirector_Story_Intelligence_Visualisation_Design.md`
- `PlotLine/Docs/Architecture/DeploymentConfiguration.md`
## 27. Recommended next step for Phase 21B
Phase 21B should be a narrow foundation phase:
1. Design and add durable graph discovery/event storage for one active `StoryIntelligenceRun`.
2. Add authorised snapshot and event replay endpoints.
3. Add a SignalR graph hub that joins one run group after access validation.
4. Emit lifecycle, stage, scene-start, scene-complete and basic discovery events from the existing persisted runner.
5. Do not replace the current progress page yet; add the visualisation as an optional "Watch Story Intelligence" experience backed by the same SQL state.

View File

@ -0,0 +1,123 @@
# Phase 21B - Story Intelligence Experience Prototype
## Purpose
Phase 21B creates an isolated, development-only visual prototype for the PlotDirector Story Intelligence Experience. The product specification is `PlotLine/Docs/Features/PlotDirector_Story_Intelligence_Experience_Design_v2.md`; this note records the implementation approach and limits.
## Route
- `/Development/StoryIntelligenceExperience`
- Implemented by `DevelopmentController`.
- Requires the existing `AdminOnly` policy.
- Returns `NotFound` unless `IWebHostEnvironment.IsDevelopment()` is true.
- Not linked from normal navigation.
## Files added or changed
- `PlotLine/Controllers/DevelopmentController.cs`
- `PlotLine/ViewModels/StoryIntelligenceExperiencePrototypeViewModels.cs`
- `PlotLine/Services/StoryIntelligenceExperiencePrototypeData.cs`
- `PlotLine/Views/Development/StoryIntelligenceExperience.cshtml`
- `PlotLine/wwwroot/css/story-intelligence-experience-prototype.css`
- `PlotLine/wwwroot/js/story-intelligence-experience-prototype.js`
- `PlotLine/wwwroot/images/story-intelligence/prototype/*.svg`
- `docs/phases/Phase-21B-Story-Intelligence-Experience-Prototype.md`
No existing Story Intelligence import, progress, SignalR, worker, persistence or database files were changed.
## Prototype data structure
The prototype uses `StoryIntelligenceExperiencePrototypeData.Build()` to provide simulated Alpha Flame-style scene states. Each state includes:
- Scene number, title, summary and stage
- Progress figures and estimated remaining time
- POV character
- Important and incidental characters
- One active location
- Significant assets
- Relationship changes
- Knowledge threads
- Story observations
- Timeline label/event
- Latest discoveries and activity feed
The Razor view serializes this model into a JSON script tag. The JavaScript renderer consumes that state and reconciles DOM elements by stable prototype IDs.
## Rendering approach
The prototype uses a hybrid renderer:
- HTML/CSS for the full-screen shell, panels, imagery, progress, scene ribbon and timeline.
- SVG for relationship and location connection lines.
- JavaScript for current-to-next state reconciliation and playback controls.
Cytoscape was not reused. It remains useful for the existing Relationship Map, but this prototype is not a free-form network graph. The experience needs authored composition around POV, location, assets, narrative rhythm and side-panel understanding, which is better tested here with HTML/CSS/SVG.
## Animation approach
The JavaScript updates from current scene state to next scene state and reconciles:
- Character prominence, position and role changes
- POV changes
- Location crossfades
- Asset appearance/removal/prominence
- Relationship line drawing
- Insight panel updates
- Scene ribbon movement
- Timeline growth
- Progress/stat changes
The default automatic playback interval is controlled by the prototype speed slider. Manual controls allow play/pause, previous, next and restart for development review only.
## Imagery approach
Prototype-only SVG artwork is stored under:
`PlotLine/wwwroot/images/story-intelligence/prototype/`
The artwork includes character portraits, locations and assets. It is intentionally isolated so it can later be replaced by curated canonical imagery and should not be treated as final artwork.
## Accessibility and reduced motion
The prototype provides:
- Ordinary HTML text for progress, status, discoveries and insights.
- Button elements for all controls.
- Visible focus styles.
- Semantic sections and labelled side panels.
- `prefers-reduced-motion` support that shortens transitions and disables animated line drawing.
## Responsive behaviour
The primary target is desktop/laptop. At narrower widths, the layout stacks and the page becomes scrollable so the current-story view remains usable rather than shrinking into unreadable tiles. A polished mobile version is deferred.
## Technical limitations
- Uses simulated data only.
- No SignalR or API integration.
- No database storage.
- No durable event stream.
- No real import pipeline connection.
- No post-import Story Map.
- No frontend package manager or bundler was introduced.
- The prototype view includes its own full-screen document shell rather than the standard layout.
## Items intentionally deferred
- Real Story Intelligence data binding.
- Snapshot/replay endpoints.
- Durable provisional discovery IDs.
- Event-history tables.
- Production navigation.
- Sigma.js/Graphology evaluation.
- Permanent Story Map route.
- Mobile polish.
## Recommendations for Phase 21C
Phase 21C should keep the current progress page intact while adding the first real data seam behind the prototype:
1. Define a read-only snapshot contract shaped like the prototype scene state.
2. Map existing `StoryIntelligenceRuns` and `StoryIntelligenceSceneResults` into that contract.
3. Add durable discovery/event storage only after the snapshot shape is validated.
4. Preserve the same browser-side state reconciliation API so simulated data can be replaced incrementally.

View File

@ -0,0 +1,99 @@
# Phase 21B.2 - Story Intelligence Experience Composition Refinement
## Purpose
Phase 21B.2 refines the development-only Story Intelligence Experience prototype so the Living Story View reads as one connected narrative map rather than separate character, location and asset regions.
This phase remains visual-only. It does not connect real Story Intelligence data, add SignalR, alter the import workflow, expose production navigation, create SQL, or modify the database.
## Composition Changes
- The central map container border and background were greatly softened.
- Characters, the active location and significant assets now share a single visual canvas.
- The scene title was reduced so the map has more vertical room.
- Category labels remain only as faint positional guides.
- The background now carries a subtler star/network texture behind the entities.
## Circular Location
The active location is now rendered as a circular node with the same broad visual language as character and asset nodes:
- Circular image mask
- Soft illuminated border
- Location name and secondary description below the node
- Stable `data-location-id` connection target
The existing prototype SVG location artwork is still used and masked with `object-fit: cover`, so future curated images can replace it without changing the layout contract.
## Circular Assets
Assets in the main Living Story View now render as circular image-first nodes:
- Circular image/avatar
- Label and state text underneath
- Size influenced by simulated narrative weight
- Stable `data-asset-id` connection targets
The compact discovery feed indicators were left unchanged because they serve a different scanning purpose in the left panel.
## Connection Lines
The SVG connection layer now draws multiple current-scene connection categories:
- Character relationship
- Character present at location
- Asset found at location
- Character knowledge or evidence connection to assets
- Asset-to-asset supporting evidence link
Connections use curved paths and category-specific styling. Supportive, uncertain, conflict, presence, evidence and knowledge lines are distinguishable by colour and line style rather than colour alone.
## Inline Relationship Labels
The central map now renders one or two inline relationship labels per scene on selected character-to-character connections. These labels summarize the active relationship state, while the right-hand Relationship Changes panel continues to show the latest detected changes.
Examples include:
- `trust strengthened`
- `tension increasing`
- `family`
- `questions`
- `motive exposed`
## Analysis Stage Progress
A compact analysis-stage rail was added to the header. It advances with simulated scene state and uses live-analysis stage labels:
- Reading
- Chapters
- Scenes
- Content
- Characters
- Locations
- Assets
- Relationships
- Knowledge
- Continuity
- Finalising
The real Story Intelligence pipeline classes were inspected, but not changed. This rail is prototype-only and maps the existing simulated scene `stage` and `progressPercent` values onto a compact display.
## Responsive Behaviour
The main desktop target remains 1920 x 1080 through 1366 x 768. At narrower widths the stage rail can scroll horizontally and the existing stacked layout is retained. Full mobile optimisation remains deferred.
## Limitations
- The map positions are still authored prototype positions, not graph-solved layout.
- SVG connection labels are intentionally sparse to avoid turning the experience into a dense generic graph.
- Location and asset artwork is still prototype SVG artwork, not final curated imagery.
- No historical replay/event store exists yet.
## Deferred
- Real data binding
- Durable Story Intelligence snapshot contract
- Production route/navigation
- Final image library
- Mobile-specific composition pass
- Phase 21C real-data integration

View File

@ -0,0 +1,103 @@
# Phase 21C - Story Intelligence Illustration Library and Generation Studio
## Summary
Phase 21C adds an admin-only Illustration Library for reusable Story Intelligence visual identifiers. Library illustrations are representative visual identifiers for categories such as characters, locations and assets. They are not canonical manuscript-specific artwork and must not include private manuscript text, names or excerpts in provider prompts.
## Architecture
- `IllustrationLibraryItems` stores durable library records, generation history, approval state, prompt audit data, provider metadata and file metadata.
- `IllustrationGenerationSpecification` is the structured request model. Admins work from planned specs rather than writing raw prompts.
- `IllustrationPromptBuilder` converts a spec into the versioned template `21C.1`, preserving both the prompt and original spec for audit.
- `IIllustrationImageProvider` abstracts image generation. `OpenAIIllustrationImageProvider` uses server-side configuration only.
- `IllustrationGenerationWorker` processes queued items in the background, one item at a time, so browser sessions do not need to remain open.
- `IIllustrationLibraryStorageService` writes generated WebP images and thumbnails under the persistent uploads root.
## Database
The SQL script is `PlotLine/Sql/135_Phase21C_IllustrationLibrary.sql`.
The primary table is `dbo.IllustrationLibraryItems`. It records category, stable code, display title, description, metadata JSON, generation spec JSON, file paths, dimensions, file size, MIME type, lifecycle status, approval details, provider/model/request IDs, prompt text, prompt template version, attempts, parent lineage, rejection/admin notes, usage count and timestamps.
Statuses are:
- `Planned`
- `Queued`
- `Generating`
- `Generated`
- `Approved`
- `Rejected`
- `Failed`
- `Disabled`
Stored procedures support list, summary, get, upsert planned spec, queue, claim next queued item, mark generated, mark failed, approve, reject, disable, update metadata and list approved items.
## Storage
Generated files are stored below:
`/uploads/story-intelligence-library/{characters|locations|assets}/{stable-code}/`
The service writes WebP standard images and 256px thumbnail crops. Existing character, asset, floor-plan and cover uploads are unchanged.
## Provider And Configuration
Image generation uses the existing OpenAI/Story Intelligence options pattern:
- `OpenAI:ApiKey`
- `StoryIntelligence:ImageGenerationModel`
- `StoryIntelligence:ImageGenerationSize` optional, defaulting to `1024x1024`
If configuration is missing, the app starts normally and the Studio shows a clear not-configured message. Queue items will fail individually with a stored error instead of breaking startup.
## Admin Studio
The Studio route is:
`/admin/story-intelligence/illustration-library`
It is protected by the existing `AdminOnly` policy. The page shows counts, filters, grid cards, preview image, prompt/metadata audit data, provider/model/date state, and actions for starter planning, queuing, approval, rejection, disabling and metadata notes.
Costly generation actions require confirmation in the UI. Normal Story Intelligence imports do not trigger image generation.
## Starter Definitions
The starter batch contains 28 generic reusable specs:
- 12 character identifiers
- 6 location identifiers
- 10 asset identifiers
The specs are intentionally generic. They include representative subjects such as a young observer, witness, weathered man, estate house exterior, small flat interior, folded letter, worn notebook and red classic car.
## Prototype Integration
`/Development/StoryIntelligenceExperience` now carries stable library codes for prototype characters, locations and assets. The prototype keeps its existing SVG mock imagery as fallback. If an approved library item exists for a matching code, the approved uploaded illustration is used instead.
No real Story Intelligence pipeline data is connected to the library in this phase.
## Approval And Regeneration
Approval is explicit. Generated images do not become visible to the prototype until an admin approves them.
Regeneration is represented by re-queueing eligible planned, rejected or failed records and retaining previous generation metadata. Parent lineage fields are present for fuller future regeneration history.
## Security And Privacy
- Admin route only.
- Provider calls are server-side only.
- Secrets are not stored in source, JavaScript, SQL or docs.
- Prompt template forbids text, logos, watermarks, manuscript excerpts, real names and private manuscript details.
- Tests do not make paid provider calls.
## Deferred CRUD Integration
The library is prepared for future links to real character, location and asset CRUD through stable codes and usage counts. This phase does not alter existing entity upload workflows or Story Intelligence import behaviour.
## How To Run The Starter Batch
1. Apply `PlotLine/Sql/135_Phase21C_IllustrationLibrary.sql`.
2. Open `/admin/story-intelligence/illustration-library`.
3. Choose `Create starter plan`.
4. Confirm `Queue planned items` only after image generation configuration is present and cost is expected.
5. Review generated images and approve only the reusable identifiers that meet the style and privacy requirements.

View File

@ -0,0 +1,140 @@
# Phase 21D - Story Intelligence Illustration Library and Generation Studio
## Summary
Phase 21D hardens the Illustration Library into an admin-controlled generation workflow for reusable Story Intelligence artwork. Library illustrations are representative visual identifiers selected to aid recognition and navigation. They do not define or replace the author's own image of a character, location or asset.
## SQL Script
The Phase 21D migration is:
`PlotLine/Sql/136_Phase21D_IllustrationLibraryGenerationStudio.sql`
It extends the Phase 21C table with Phase 21D names and audit fields, including `DisplayName`, `Description`, `Prompt`, `GenerationAttemptCount`, `ErrorMessage`, `RejectedUtc`, `RejectedByUserID` and `Superseded` status support. It also adds regeneration through `IllustrationLibraryItem_CreateRegeneration`.
## Data Model
`IllustrationLibraryItems` stores durable illustration records:
- Category and stable code
- Display name and description
- Structured specification JSON
- Final prompt and prompt template version
- Provider, model and request metadata
- Status and generation attempt count
- File and thumbnail paths, dimensions, MIME type and file size
- Approval, rejection and lineage fields
- Active flag and timestamps
Categories are `Character`, `Location` and `Asset`.
Statuses are `Planned`, `Queued`, `Generating`, `Generated`, `Approved`, `Rejected`, `Failed`, `Superseded` and the operational disabled state.
## Specification Model
Images are defined by `IllustrationGenerationSpecification`. It contains shared fields plus typed category fields:
- Character: apparent age band, presentation, hair colour, hair length, skin tone, features, clothing era and clothing style.
- Location: location type, era and setting.
- Asset: object type, colour and era.
Validation is centralised on the specification model and exercised by tests.
## Prompt Builder
`IllustrationPromptBuilder` uses template version `21D.1`. It applies one shared style template and category-specific composition rules. It forbids manuscript names, excerpts, visible text, watermarks, logos, anime/cartoon/Pixar-like styling and photoreal identity claims.
## Provider Abstraction
`IIllustrationImageProvider` exposes provider name, model name, configuration status, generation, metadata and failure details. The OpenAI implementation uses the existing server-side `StoryIntelligenceOptions` binding, including:
- `OpenAI:ApiKey`
- `StoryIntelligence:ImageGenerationModel`
- `StoryIntelligence:ImageGenerationSize`
No browser-side image calls are used, and tests do not call the paid Images API.
## Queue Behaviour
Generation runs through `IllustrationGenerationWorker`. It claims persisted queued items with row locks, increments generation attempts and processes one item at a time. Failures are stored per item and do not stop the batch. Attempts are capped in SQL to avoid an infinite retry loop.
## Persistent Storage
Generated images are stored under the persistent uploads root:
`/uploads/story-intelligence-library/{characters|locations|assets}/{stable-code}/`
The service writes WebP source images and 256px thumbnails using SkiaSharp. Existing user uploads for characters, assets, covers and floor plans are unchanged.
## Admin Studio
Route:
`/admin/story-intelligence/illustration-library`
The Studio is protected by the existing `AdminOnly` policy. It provides counts, search, category and status filters, preview, specification JSON, prompt, provider/model data, errors, approval and rejection information, and actions to create starter specs, queue generation, approve, reject, regenerate and disable.
## Starter Specifications
The starter set contains:
- 12 characters
- 6 locations
- 10 assets
They cover varied age bands, presentations, appearances, location types and object types. They are generic reusable specifications, not Alpha Flame-specific artwork.
## Approval And Regeneration
Only approved illustrations can be resolved outside the admin review screen. Regeneration creates a new planned child item and preserves the prior result. Approving a replacement marks any previous approved item for the same stable code as `Superseded`.
## Prototype Integration
`/Development/StoryIntelligenceExperience` resolves images by stable library code. If an approved match exists, it uses the library image. Otherwise, the existing SVG prototype artwork remains the fallback.
The real Story Intelligence pipeline is not connected to this feature in Phase 21D.
## Cost Controls
Generation is admin-only and requires confirmation. The Studio shows image counts rather than invented cost estimates. Normal imports never generate images. Missing provider configuration is shown clearly and does not block application startup.
## Tests
The test harness covers specification validation, prompt construction, category validation, status transitions, path safety and starter-batch counts. Provider calls are not made by automated tests.
## Deployment Permissions
The service account must be able to write under the configured persistent `UploadsRoot`. For local development this is the existing PlotDirector upload storage exposed at `/uploads`.
## First Three Test Images
1. Apply SQL scripts through `136_Phase21D_IllustrationLibraryGenerationStudio.sql`.
2. Open `/admin/story-intelligence/illustration-library`.
3. Choose `Create starter plan`.
4. Confirm image generation configuration is present.
5. Filter or search for one character, one location and one asset.
6. Queue those three items individually.
7. Review the generated images for style, cropping and consistency.
8. Approve, reject or regenerate each image from the Studio.
Do not approve automatically.
## Full Starter Batch
1. Complete the first three-image review successfully.
2. Return to `/admin/story-intelligence/illustration-library`.
3. Confirm `Queue planned items`.
4. Monitor queued, generated and failed counts.
5. Review generated images in the Studio.
6. Approve only images that meet the style, privacy and cropping requirements.
## Deferred CRUD Integration
Future Character, Location and Story Asset pages should support:
1. User-uploaded image
2. User-selected library illustration
3. Automatically suggested library illustration
4. Existing fallback icon
This phase does not redesign those CRUD pages and does not alter existing user-upload behaviour.

View File

@ -0,0 +1,53 @@
# Phase 21D.1 - Automatic Starter Illustration Generation
## Summary
Phase 21D.1 changes the Illustration Library starter workflow from a two-step planning and queuing process to one admin-confirmed action: `Generate Starter Library`.
The action creates any missing starter specifications, queues eligible planned or failed starter items, skips successful or already-running items, and lets the existing background worker generate images after the browser closes.
## One-Click Workflow
Admin route:
`/admin/story-intelligence/illustration-library`
The primary action is `Generate Starter Library`. It confirms the number of character, location and asset images that will be queued.
Eligible starter items are:
- Missing starter specifications
- Existing `Planned` starter items
- Existing `Failed` starter items
Skipped starter items are:
- `Queued`
- `Generating`
- `Generated`
- `Approved`
This prevents accidental duplicate submissions and avoids regenerating successful artwork unnecessarily.
## Prototype Activation
The development Story Intelligence prototype resolves starter illustrations in this order:
1. Approved starter illustration
2. Generated starter illustration
3. Existing SVG fallback
This is limited to the development prototype route and does not weaken future production approval workflows.
## Configuration Diagnostics
The OpenAI image provider now reports specific missing settings without exposing secrets:
- OpenAI API key
- image-generation model
The local development service has `OpenAI__ApiKey` configured through `/etc/plotdirector/plotdirector.env`, but no image-generation model setting is currently present.
## Tests
The test harness now covers starter generation planning, duplicate protection for successful starter items, failed-item retry eligibility and provider missing-configuration diagnostics.

Some files were not shown because too many files have changed in this diff Show More