Compare commits

..

54 Commits

Author SHA1 Message Date
72d2438314 Improve onboarding import wizard UX 2026-08-13 12:56:43 +00:00
c3043b994a Wire scan aliases into story intelligence 2026-08-13 09:38:04 +00:00
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
114 changed files with 21069 additions and 93 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.

File diff suppressed because it is too large Load Diff

View File

@ -13,13 +13,15 @@ public sealed class AdminController(
IFeatureRequestService featureRequests,
IEmailService emails,
IStoryIntelligenceDiagnosticsService storyIntelligenceDiagnostics,
IStoryIntelligenceDevelopmentResetService storyIntelligenceDevelopmentReset,
IStoryIntelligenceDryRunService storyIntelligenceDryRun,
IChapterStructureDryRunService chapterStructureDryRun,
IChapterStoryIntelligenceDryRunService chapterStoryIntelligenceDryRun,
IStoryIntelligenceResultPersistenceService storyIntelligenceResults,
IStoryIntelligenceExistingChapterQueueService existingChapterQueue,
IStoryIntelligenceFullChapterTestService fullChapterTest,
IStoryIntelligenceImportCommitService storyIntelligenceImportCommit) : Controller
IStoryIntelligenceImportCommitService storyIntelligenceImportCommit,
IIllustrationLibraryService illustrationLibrary) : Controller
{
[HttpGet("")]
[HttpGet("index")]
@ -37,12 +39,141 @@ public sealed class AdminController(
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")]
public async Task<IActionResult> StoryIntelligenceDiagnostics(CancellationToken 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")]
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")]
[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)
{
TempData["OnboardingStoryIntelligenceError"] = "Review the manuscript scan before starting Story Intelligence.";
@ -490,6 +494,8 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
}
[HttpPost("scan-review")]
[RequestSizeLimit(25 * 1024 * 1024)]
[RequestFormLimits(ValueCountLimit = 20000, KeyLengthLimit = 4096, ValueLengthLimit = 1024 * 1024)]
[ValidateAntiForgeryToken]
public async Task<IActionResult> SaveScanReview(ManuscriptScanReviewForm form, string intent = "save")
{

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,73 @@
using Dapper;
using PlotLine.Models;
namespace PlotLine.Data;
public interface ICharacterIntelligenceRepository
{
Task<IReadOnlyList<CharacterIntelligenceProfile>> ListProfilesAsync(int importSessionId);
Task<CharacterIntelligenceProfile> UpsertProfileAsync(CharacterIntelligenceProfileSave request);
Task AddEvidenceAsync(CharacterIntelligenceEvidenceSave request);
}
public sealed class CharacterIntelligenceRepository(ISqlConnectionFactory connectionFactory) : ICharacterIntelligenceRepository
{
public async Task<IReadOnlyList<CharacterIntelligenceProfile>> ListProfilesAsync(int importSessionId)
{
using var connection = connectionFactory.CreateConnection();
var rows = await connection.QueryAsync<CharacterIntelligenceProfile>(
"""
SELECT *
FROM dbo.CharacterIntelligenceProfiles
WHERE ImportSessionID = @ImportSessionID
ORDER BY IsRecurring DESC, Confidence DESC, CanonicalName;
""",
new { ImportSessionID = importSessionId });
return rows.ToList();
}
public async Task<CharacterIntelligenceProfile> UpsertProfileAsync(CharacterIntelligenceProfileSave request)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleAsync<CharacterIntelligenceProfile>(
"""
MERGE dbo.CharacterIntelligenceProfiles 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
CanonicalName = @CanonicalName,
ProfileJson = @ProfileJson,
Confidence = @Confidence,
NarrativeImportance = @NarrativeImportance,
IsRecurring = CASE WHEN @IsRecurring = 1 THEN 1 ELSE target.IsRecurring END,
EvidenceVersion = @EvidenceVersion,
LastUpdatedSceneResultID = @SceneResultID,
UpdatedUtc = SYSUTCDATETIME()
WHEN NOT MATCHED THEN
INSERT (ImportSessionID, ProjectID, BookID, CanonicalIdentityKey, CanonicalName, ProfileJson, Confidence, NarrativeImportance, IsRecurring, EvidenceVersion, LastUpdatedSceneResultID)
VALUES (@ImportSessionID, @ProjectID, @BookID, @CanonicalIdentityKey, @CanonicalName, @ProfileJson, @Confidence, @NarrativeImportance, @IsRecurring, @EvidenceVersion, @SceneResultID);
SELECT *
FROM dbo.CharacterIntelligenceProfiles
WHERE ImportSessionID = @ImportSessionID
AND CanonicalIdentityKey = @CanonicalIdentityKey;
""",
request);
}
public async Task AddEvidenceAsync(CharacterIntelligenceEvidenceSave request)
{
using var connection = connectionFactory.CreateConnection();
await connection.ExecuteAsync(
"""
INSERT dbo.CharacterIntelligenceEvidenceEvents
(CharacterIntelligenceProfileID, SceneResultID, FieldName, PreviousValue, NewValue, Evidence, Confidence, IsContradiction, Source)
VALUES
(@CharacterIntelligenceProfileID, @SceneResultID, @FieldName, @PreviousValue, @NewValue, @Evidence, @Confidence, @IsContradiction, @Source);
""",
request);
}
}

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
{
Task<StoryIntelligenceBookPipelineState?> GetByIdForUserAsync(int importSessionId, int userId);
Task<StoryIntelligenceBookPipelineState?> GetByBookAsync(int bookId);
Task<StoryIntelligenceBookPipelineState?> GetByBookForUserAsync(int bookId, int userId);
Task<IReadOnlyList<StoryIntelligenceBookPipelineState>> ListForUserAsync(int userId);
@ -15,6 +16,15 @@ public interface 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)
{
using var connection = connectionFactory.CreateConnection();

View File

@ -30,6 +30,7 @@ public interface IStoryIntelligenceResultRepository
Task<int> SaveChapterResultAsync(int runId, StoryIntelligenceChapterResultSaveRequest chapter);
Task<int> SaveSceneResultAsync(int runId, int? chapterResultId, StoryIntelligenceSceneResultSaveRequest scene);
Task<IReadOnlyList<StoryIntelligenceSavedRunListItem>> ListRunsAsync();
Task<IReadOnlyList<StoryIntelligenceSavedRun>> ListRunsByBookForUserAsync(int bookId, int userId);
Task<StoryIntelligenceSavedRun?> GetRunAsync(int runId);
Task<StoryIntelligenceSavedChapterResult?> GetChapterResultAsync(int runId);
Task<IReadOnlyList<StoryIntelligenceSavedSceneResult>> ListSceneResultsAsync(int runId);
@ -178,7 +179,8 @@ public sealed class StoryIntelligenceResultRepository(ISqlConnectionFactory conn
request.SourceParagraphCount,
request.SourceChapterCount,
request.Model,
request.PromptVersionsSummary
request.PromptVersionsSummary,
request.KnownCharactersJson
},
commandType: CommandType.StoredProcedure);
}
@ -350,6 +352,16 @@ public sealed class StoryIntelligenceResultRepository(ISqlConnectionFactory conn
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)
{
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

@ -0,0 +1,100 @@
# Character Profile Update Prompt V1
You are PlotDirector's Character Intelligence updater.
You do not rewrite profiles from scratch.
You only report updates that should be applied to the existing canonical profiles.
Use only:
- CURRENT_CHARACTER_PROFILES_JSON
- CURRENT_SCENE_INTELLIGENCE_JSON
- CURRENT_STORY_MEMORY_JSON
Do not use raw manuscript text.
Do not infer appearance independently with pattern matching.
Use the structured scene intelligence and accumulated memory.
For each recurring or potentially recurring character in the current scene, decide:
- has anything new been learned?
- has any existing fact become more certain?
- has anything been contradicted?
- has this character possibly been confused with another profile?
- should aliases be added?
Return JSON only. Do not include markdown.
The response must match this shape:
{
"schemaVersion": "22",
"updates": [
{
"canonicalName": "Maggie",
"aliases": ["Miss Jones"],
"recurrenceSignal": "Appears again with the same role and appearance evidence.",
"confidence": 0.82,
"fieldUpdates": [
{
"fieldName": "HairColour",
"value": "Red",
"evidence": "Structured scene intelligence reports long natural red hair.",
"confidence": 0.91,
"isNewInformation": true,
"increasesCertainty": true
}
],
"contradictions": [
{
"fieldName": "EstimatedAgeBand",
"existingValue": "Teen",
"newValue": "Adult",
"evidence": "Scene intelligence now identifies the character as an adult.",
"confidence": 0.78
}
]
}
]
}
Allowed fieldName values:
- RoleInStory
- NarrativeImportance
- Presentation
- EstimatedAge
- EstimatedAgeBand
- HairColour
- HairLength
- HairStyle
- EyeColour
- SkinTone
- Height
- Build
- DistinctiveFeatures
- TypicalClothing
- Occupation
- Relationships
- CurrentStatus
Guidelines:
- Prefer stable accumulated knowledge over one-scene uncertainty.
- Do not downgrade an existing fact unless the current scene clearly contradicts it.
- If the current scene merely repeats known information, return the update only when it increases confidence.
- Preserve ambiguity by using contradictions instead of overwriting uncertain conflicts.
- Keep evidence summaries short and do not quote manuscript prose.
CURRENT_CHARACTER_PROFILES_JSON:
{{CURRENT_CHARACTER_PROFILES_JSON}}
CURRENT_SCENE_INTELLIGENCE_JSON:
{{CURRENT_SCENE_INTELLIGENCE_JSON}}
CURRENT_STORY_MEMORY_JSON:
{{CURRENT_STORY_MEMORY_JSON}}
SCENE_RESULT_ID:
{{SCENE_RESULT_ID}}

View File

@ -50,7 +50,8 @@ It should not:
7. Treat assets as meaningful story objects, not ordinary props.
8. Treat relationships as scene-local signals, not final records.
9. Treat metrics as descriptive intensity scores, not quality scores.
10. Return JSON only.
10. Extract explicit visual character appearance separately from story understanding.
11. Return JSON only.
## Strict Behavioural Rules
@ -76,6 +77,13 @@ It should not:
Use only the scene text and supplied context.
When `knownCharacters` is supplied in the scene context, use it only as identity context for names already detected before analysis:
- match scene character names against `canonicalName` and `aliases`;
- when the scene uses a supplied alias for the same identity, use the supplied `canonicalName` and include the scene-used name in `aliases`;
- do not create a character solely because it appears in `knownCharacters`;
- do not override explicit scene evidence with scan context.
If something is not stated or strongly implied:
- use `null` for scalar values;
@ -211,6 +219,7 @@ Report characters who are:
For each character:
- use the manuscript's name or label;
- when supplied `knownCharacters` identify that name as an alias, use the supplied canonical name and place the alias used in the scene in `aliases`;
- include exactly these properties: `name`, `roleInScene`, `mentionedOnly`, `aliases`, `actions`, `confidence`, `notes`;
- set `mentionedOnly` to `false` when active in the scene;
- set `mentionedOnly` to `true` when absent and only referred to;
@ -229,6 +238,26 @@ For each character:
Mentioned-only characters matter because later stages need to know who is referenced without assuming they were present.
## Character Appearance Rules
Scene understanding is not enough for illustration matching. Report explicit or strongly supported visual facts in `characterAppearance`.
For every named or recurring character with supported appearance evidence, include exactly these properties: `canonicalName`, `aliases`, `title`, `presentation`, `explicitAge`, `inferredAgeBand`, `hairColour`, `hairLength`, `hairStyle`, `facialHair`, `glasses`, `build`, `distinctiveFeatures`, `clothing`, `roleOrOccupation`, `relationshipAgeEvidence`, `pronounEvidence`, `evidence`, `confidence`, `evidenceKind`.
Rules:
- Use the manuscript as the authority.
- Keep unknown visual fields as `null`.
- Use `evidenceKind: "explicit"` for directly stated facts and `evidenceKind: "inferred"` for title, role, relationship or pronoun evidence.
- Keep `evidence` to a short exact fragment from the scene.
- Do not infer hair colour from nearby characters.
- Do not inherit appearance from the point of view character.
- Do not copy appearance evidence between identities.
- If the scene says `Miss Davies, but you can call me Zoe`, use canonicalName `Zoe Davies` and aliases `["Miss Davies", "Zoe", "Miss Davies (Zoe)"]`.
- Unknown age must not be marked as child.
- Adult titles and occupations such as Mr, Mrs, Miss, Ms, teacher, driving examiner, driving instructor, police officer, detective, solicitor, landlord, manager, nurse, receptionist, mother, father, aunt and uncle are adult evidence.
- Words like `girl`, `lad` or `young` alone are presentation/voice clues, not child-age evidence.
## Location Reporting Rules
Named locations:
@ -256,6 +285,8 @@ Use the `name` property for both named locations and generic labels. For an unna
Use `parentLocationHint` only when the scene supports a containing place. Do not invent location hierarchy.
Prefer the active scene location over nearby route or address wording. If the action happens in a house, shop, office, garage, waiting room, test centre, car park, vehicle interior or other functional place that is described by reference to a road, report the functional place as the present location and put the road/address wording in `parentLocationHint` or `notes` only when the scene supports it. Report a road, lane or street as the present location only when the action space is actually the road, lane or street.
Do not infer a character's current location from another character thinking about them.
Example: if Beth thinks `closer, perhaps, to Maggie`, do not create `Maggie at Ashdown Trust`, `Maggie's location nearby` or `Beth leaves Maggie's location`. Instead, use a question or observation such as `Beth speculates that the Ashdown Trust may bring her closer to Maggie.`
@ -543,6 +574,30 @@ Return exactly one JSON object matching this shape:
"notes": null
}
],
"characterAppearance": [
{
"canonicalName": "",
"aliases": [],
"title": null,
"presentation": null,
"explicitAge": null,
"inferredAgeBand": null,
"hairColour": null,
"hairLength": null,
"hairStyle": null,
"facialHair": null,
"glasses": null,
"build": null,
"distinctiveFeatures": [],
"clothing": null,
"roleOrOccupation": null,
"relationshipAgeEvidence": null,
"pronounEvidence": null,
"evidence": "",
"confidence": 0.0,
"evidenceKind": "explicit"
}
],
"locations": [
{
"name": "",

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,138 @@
using System.Text.Json.Serialization;
namespace PlotLine.Models;
public sealed class CharacterIntelligenceProfile
{
public int CharacterIntelligenceProfileID { 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 CanonicalName { get; init; } = string.Empty;
public string ProfileJson { get; init; } = string.Empty;
public decimal Confidence { get; init; }
public string? NarrativeImportance { get; init; }
public bool IsRecurring { get; init; }
public string EvidenceVersion { get; init; } = CharacterIntelligenceVersions.Current;
public int? LastUpdatedSceneResultID { get; init; }
public DateTime CreatedUtc { get; init; }
public DateTime UpdatedUtc { get; init; }
}
public static class CharacterIntelligenceVersions
{
public const string Current = "22";
}
public sealed class CharacterIntelligenceProfileDocument
{
public string CanonicalName { get; set; } = string.Empty;
public List<string> Aliases { get; set; } = [];
public string? RoleInStory { get; set; }
public string? NarrativeImportance { get; set; }
public string? Presentation { get; set; }
public string? EstimatedAge { get; set; }
public string? EstimatedAgeBand { get; set; }
public string? HairColour { get; set; }
public string? HairLength { get; set; }
public string? HairStyle { get; set; }
public string? EyeColour { get; set; }
public string? SkinTone { get; set; }
public string? Height { get; set; }
public string? Build { get; set; }
public List<string> DistinctiveFeatures { get; set; } = [];
public string? TypicalClothing { get; set; }
public string? Occupation { get; set; }
public List<string> Relationships { get; set; } = [];
public string? CurrentStatus { get; set; }
public decimal Confidence { get; set; }
public List<CharacterIntelligenceEvidenceItem> EvidenceTimeline { get; set; } = [];
public List<CharacterIntelligenceContradiction> Contradictions { get; set; } = [];
public DateTime LastUpdatedUtc { get; set; }
}
public sealed class CharacterIntelligenceEvidenceItem
{
public int? SceneResultID { get; set; }
public decimal? ChapterNumber { get; set; }
public int? SceneNumber { get; set; }
public string FieldName { get; set; } = string.Empty;
public string? Value { get; set; }
public string Evidence { get; set; } = string.Empty;
public decimal Confidence { get; set; }
public DateTime RecordedUtc { get; set; } = DateTime.UtcNow;
}
public sealed class CharacterIntelligenceContradiction
{
public string FieldName { get; set; } = string.Empty;
public string? ExistingValue { get; set; }
public string? NewValue { get; set; }
public string Evidence { get; set; } = string.Empty;
public decimal Confidence { get; set; }
public int? SceneResultID { get; set; }
}
public sealed record CharacterIntelligenceProfileSave(
int ImportSessionID,
int ProjectID,
int BookID,
string CanonicalIdentityKey,
string CanonicalName,
string ProfileJson,
decimal Confidence,
string? NarrativeImportance,
bool IsRecurring,
string EvidenceVersion,
int SceneResultID);
public sealed record CharacterIntelligenceEvidenceSave(
int CharacterIntelligenceProfileID,
int SceneResultID,
string FieldName,
string? PreviousValue,
string? NewValue,
string Evidence,
decimal Confidence,
bool IsContradiction,
string Source);
public sealed class CharacterIntelligenceProfileUpdateResponse
{
public string SchemaVersion { get; init; } = CharacterIntelligenceVersions.Current;
public List<CharacterIntelligenceProfileUpdate> Updates { get; init; } = [];
}
public sealed class CharacterIntelligenceProfileUpdate
{
public string CanonicalName { get; init; } = string.Empty;
public List<string> Aliases { get; init; } = [];
public string? RecurrenceSignal { get; init; }
public decimal Confidence { get; init; }
public List<CharacterIntelligenceFieldUpdate> FieldUpdates { get; init; } = [];
public List<CharacterIntelligenceAiContradiction> Contradictions { get; init; } = [];
[JsonExtensionData]
public Dictionary<string, System.Text.Json.JsonElement>? ExtensionData { get; init; }
}
public sealed class CharacterIntelligenceFieldUpdate
{
public string FieldName { get; init; } = string.Empty;
public string? Value { get; init; }
public string Evidence { get; init; } = string.Empty;
public decimal Confidence { get; init; }
public bool IsNewInformation { get; init; }
public bool IncreasesCertainty { get; init; }
}
public sealed class CharacterIntelligenceAiContradiction
{
public string FieldName { get; init; } = string.Empty;
public string? ExistingValue { get; init; }
public string? NewValue { get; init; }
public string Evidence { get; init; } = string.Empty;
public decimal Confidence { get; init; }
}

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

@ -153,6 +153,7 @@ public sealed class ManuscriptScanCharacterReviewDecision
public string Name { get; init; } = string.Empty;
public string Category { get; init; } = "PossibleCharacter";
public int? ExistingCharacterID { get; init; }
public string? AliasTargetTemporaryCharacterKey { get; init; }
}
public sealed class OnboardingManuscriptBuildResult

View File

@ -12,6 +12,7 @@ public sealed class SceneIntelligenceScene
public SceneIntelligencePointOfView? PointOfView { get; init; }
public SceneIntelligenceSetting? Setting { get; init; }
public List<SceneIntelligenceCharacter>? Characters { get; init; }
public List<SceneIntelligenceCharacterAppearance>? CharacterAppearance { get; init; }
public List<SceneIntelligenceLocation>? Locations { get; init; }
public List<SceneIntelligenceAsset>? Assets { get; init; }
public List<SceneIntelligenceRelationship>? Relationships { get; init; }
@ -99,6 +100,35 @@ public sealed class SceneIntelligenceCharacter
public Dictionary<string, JsonElement>? ExtensionData { get; init; }
}
public sealed class SceneIntelligenceCharacterAppearance
{
public string? CanonicalName { get; init; }
public List<string>? Aliases { get; init; }
public string? Title { get; init; }
public string? Presentation { get; init; }
public string? ExplicitAge { get; init; }
public string? InferredAgeBand { get; init; }
public string? HairColour { get; init; }
public string? HairLength { get; init; }
public string? HairStyle { get; init; }
public string? EyeColour { get; init; }
public string? FacialHair { get; init; }
public string? Glasses { get; init; }
public string? Height { get; init; }
public string? Build { get; init; }
public List<string>? DistinctiveFeatures { get; init; }
public string? Clothing { get; init; }
public string? RoleOrOccupation { get; init; }
public string? RelationshipAgeEvidence { get; init; }
public string? PronounEvidence { get; init; }
public string? Evidence { get; init; }
public decimal? Confidence { get; init; }
public string? EvidenceKind { get; init; }
[JsonExtensionData]
public Dictionary<string, JsonElement>? ExtensionData { get; init; }
}
public sealed class SceneIntelligenceLocation
{
public string? Name { get; init; }

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 ChapterStructureModel { 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? SceneIntelligenceMaxOutputTokens { get; init; }
public int TimeoutSeconds { get; init; } = 120;

View File

@ -149,6 +149,18 @@ public sealed class StoryIntelligenceRunQueueRequest
public int? SourceChapterCount { get; init; }
public string Model { get; init; } = string.Empty;
public string PromptVersionsSummary { get; init; } = string.Empty;
public string? KnownCharactersJson { get; init; }
}
public sealed class StoryIntelligenceKnownCharacterContext
{
public string CanonicalName { get; init; } = string.Empty;
public IReadOnlyList<string> Aliases { get; init; } = [];
public int? ExistingCharacterID { get; init; }
public IReadOnlyList<string> CandidateKeys { get; init; } = [];
public int MentionCount { get; init; }
public decimal Confidence { get; init; }
public string Source { get; init; } = "InitialManuscriptScan";
}
public sealed class StoryIntelligenceChapterResultSaveRequest
@ -283,6 +295,7 @@ public sealed class StoryIntelligenceQueuedRun
public int? SourceChapterCount { get; init; }
public string PromptVersion { get; init; } = string.Empty;
public string? PromptVersionsSummary { get; init; }
public string? KnownCharactersJson { get; init; }
public string Model { get; init; } = string.Empty;
public DateTime StartedUtc { get; init; }
public DateTime? CompletedUtc { get; init; }

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

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));
}
}

View File

@ -0,0 +1,457 @@
using System.Text.Json;
using PlotLine.Data;
using PlotLine.Models;
namespace PlotLine.Services;
public interface ICharacterIntelligenceService
{
Task UpdateProfilesForSceneAsync(
StoryIntelligenceBookPipelineState importSession,
int sceneResultId,
SceneIntelligenceScene scene,
CancellationToken cancellationToken = default);
}
public sealed class CharacterIntelligenceService(
ICharacterIntelligenceRepository profiles,
IStoryMemoryRepository storyMemory,
IStoryPromptRepository prompts,
IStoryPromptVersionService versions,
IStoryIntelligenceClient client,
ILogger<CharacterIntelligenceService> logger) : ICharacterIntelligenceService
{
private const string PromptFile = "Character-Profile-Update-Prompt-V1.md";
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
WriteIndented = false
};
public async Task UpdateProfilesForSceneAsync(
StoryIntelligenceBookPipelineState importSession,
int sceneResultId,
SceneIntelligenceScene scene,
CancellationToken cancellationToken = default)
{
var characters = SceneCharacterNames(scene).ToList();
if (characters.Count == 0)
{
return;
}
var existingProfiles = await profiles.ListProfilesAsync(importSession.StoryIntelligenceBookPipelineID);
var response = await TryGetAiUpdatesAsync(importSession, sceneResultId, scene, existingProfiles, cancellationToken)
?? BuildStructuredFallbackUpdates(scene);
foreach (var update in response.Updates.Where(item => !string.IsNullOrWhiteSpace(item.CanonicalName)))
{
cancellationToken.ThrowIfCancellationRequested();
var key = CharacterKey(update.CanonicalName, update.Aliases);
var existing = existingProfiles.FirstOrDefault(item => string.Equals(item.CanonicalIdentityKey, key, StringComparison.OrdinalIgnoreCase));
var document = ReadProfile(existing) ?? NewProfile(update.CanonicalName, update.Aliases);
var events = ApplyUpdate(document, update, sceneResultId, scene);
var saved = await profiles.UpsertProfileAsync(new CharacterIntelligenceProfileSave(
importSession.StoryIntelligenceBookPipelineID,
importSession.ProjectID,
importSession.BookID,
key,
document.CanonicalName,
JsonSerializer.Serialize(document, JsonOptions),
document.Confidence,
document.NarrativeImportance,
IsRecurring(document, scene),
CharacterIntelligenceVersions.Current,
sceneResultId));
foreach (var item in events)
{
await profiles.AddEvidenceAsync(item with { CharacterIntelligenceProfileID = saved.CharacterIntelligenceProfileID });
}
}
}
private async Task<CharacterIntelligenceProfileUpdateResponse?> TryGetAiUpdatesAsync(
StoryIntelligenceBookPipelineState importSession,
int sceneResultId,
SceneIntelligenceScene scene,
IReadOnlyList<CharacterIntelligenceProfile> existingProfiles,
CancellationToken cancellationToken)
{
try
{
var memoryCharacters = await storyMemory.ListCharactersAsync(importSession.StoryIntelligenceBookPipelineID);
var memoryAttributes = await storyMemory.ListCharacterAttributesAsync(importSession.StoryIntelligenceBookPipelineID);
var prompt = await prompts.LoadPromptAsync(PromptFile, cancellationToken);
var completed = prompt
.Replace("{{CURRENT_CHARACTER_PROFILES_JSON}}", BuildCurrentProfilesJson(existingProfiles), StringComparison.Ordinal)
.Replace("{{CURRENT_SCENE_INTELLIGENCE_JSON}}", JsonSerializer.Serialize(scene, JsonOptions), StringComparison.Ordinal)
.Replace("{{CURRENT_STORY_MEMORY_JSON}}", BuildStoryMemoryJson(memoryCharacters, memoryAttributes), StringComparison.Ordinal)
.Replace("{{SCENE_RESULT_ID}}", sceneResultId.ToString("N0"), StringComparison.Ordinal);
var clientResult = await client.ExecutePromptAsync(
completed,
versions.GetPromptVersion(PromptFile),
cancellationToken,
client.GetConfigurationStatus().SceneIntelligenceModel);
var output = ExtractOutputText(clientResult.RawResponseText);
var parse = StoryIntelligenceAiJsonParser.Parse<CharacterIntelligenceProfileUpdateResponse>(output, JsonOptions);
if (parse.Success && parse.Parsed is not null)
{
return parse.Parsed;
}
logger.LogWarning(
"Character Intelligence profile update JSON could not be parsed for import session {ImportSessionId}, scene result {SceneResultId}: {ErrorMessage}",
importSession.StoryIntelligenceBookPipelineID,
sceneResultId,
parse.ErrorMessage);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
logger.LogWarning(
ex,
"Character Intelligence AI profile update failed for import session {ImportSessionId}, scene result {SceneResultId}. Structured scene output fallback will be used.",
importSession.StoryIntelligenceBookPipelineID,
sceneResultId);
}
return null;
}
private static CharacterIntelligenceProfileDocument? ReadProfile(CharacterIntelligenceProfile? profile)
{
if (profile is null || string.IsNullOrWhiteSpace(profile.ProfileJson))
{
return null;
}
try
{
return JsonSerializer.Deserialize<CharacterIntelligenceProfileDocument>(profile.ProfileJson, JsonOptions);
}
catch (JsonException)
{
return null;
}
}
private static CharacterIntelligenceProfileDocument NewProfile(string canonicalName, IEnumerable<string>? aliases)
=> new()
{
CanonicalName = Clean(canonicalName),
Aliases = CleanList(aliases),
Confidence = 0.25m,
LastUpdatedUtc = DateTime.UtcNow
};
private static IReadOnlyList<CharacterIntelligenceEvidenceSave> ApplyUpdate(
CharacterIntelligenceProfileDocument profile,
CharacterIntelligenceProfileUpdate update,
int sceneResultId,
SceneIntelligenceScene scene)
{
var events = new List<CharacterIntelligenceEvidenceSave>();
profile.CanonicalName = FirstConfigured(profile.CanonicalName, update.CanonicalName);
MergeList(profile.Aliases, update.Aliases);
profile.Confidence = Math.Max(profile.Confidence, ClampConfidence(update.Confidence));
foreach (var field in update.FieldUpdates.Where(item => !string.IsNullOrWhiteSpace(item.FieldName) && !string.IsNullOrWhiteSpace(item.Value)))
{
var previous = GetField(profile, field.FieldName);
if (!ShouldApply(previous, field))
{
continue;
}
SetField(profile, field.FieldName, field.Value);
var confidence = ClampConfidence(field.Confidence);
profile.Confidence = Math.Max(profile.Confidence, confidence);
profile.EvidenceTimeline.Add(new CharacterIntelligenceEvidenceItem
{
SceneResultID = sceneResultId,
ChapterNumber = scene.SceneReference?.ChapterNumber,
SceneNumber = scene.SceneReference?.SceneNumber is decimal number ? Convert.ToInt32(number) : null,
FieldName = field.FieldName,
Value = field.Value,
Evidence = Clean(field.Evidence),
Confidence = confidence
});
events.Add(NewEvidence(sceneResultId, field.FieldName, previous, field.Value, field.Evidence, confidence, false, "AI profile update"));
}
foreach (var contradiction in update.Contradictions.Where(item => !string.IsNullOrWhiteSpace(item.FieldName)))
{
profile.Contradictions.Add(new CharacterIntelligenceContradiction
{
FieldName = contradiction.FieldName,
ExistingValue = contradiction.ExistingValue,
NewValue = contradiction.NewValue,
Evidence = Clean(contradiction.Evidence),
Confidence = ClampConfidence(contradiction.Confidence),
SceneResultID = sceneResultId
});
events.Add(NewEvidence(sceneResultId, contradiction.FieldName, contradiction.ExistingValue, contradiction.NewValue, contradiction.Evidence, ClampConfidence(contradiction.Confidence), true, "AI profile update"));
}
profile.EvidenceTimeline = profile.EvidenceTimeline.TakeLast(80).ToList();
profile.Contradictions = profile.Contradictions.TakeLast(40).ToList();
profile.LastUpdatedUtc = DateTime.UtcNow;
return events;
}
private static CharacterIntelligenceEvidenceSave NewEvidence(
int sceneResultId,
string fieldName,
string? previous,
string? value,
string evidence,
decimal confidence,
bool contradiction,
string source)
=> new(0, sceneResultId, fieldName, previous, value, Clean(evidence), confidence, contradiction, source);
private static CharacterIntelligenceProfileUpdateResponse BuildStructuredFallbackUpdates(SceneIntelligenceScene scene)
{
var updates = new List<CharacterIntelligenceProfileUpdate>();
foreach (var group in (scene.Characters ?? [])
.Where(item => !string.IsNullOrWhiteSpace(item.Name))
.GroupBy(item => Clean(item.Name), StringComparer.OrdinalIgnoreCase))
{
var character = group.OrderByDescending(item => item.Confidence ?? 0).First();
var appearance = (scene.CharacterAppearance ?? [])
.Where(item => !string.IsNullOrWhiteSpace(item.CanonicalName))
.FirstOrDefault(item => SameCharacter(item.CanonicalName, character.Name, item.Aliases));
var fields = new List<CharacterIntelligenceFieldUpdate>
{
Field("RoleInStory", character.RoleInScene, character.Notes, character.Confidence),
Field("Presentation", appearance?.Presentation ?? appearance?.PronounEvidence, appearance?.Evidence, appearance?.Confidence),
Field("EstimatedAge", appearance?.ExplicitAge, appearance?.Evidence, appearance?.Confidence),
Field("EstimatedAgeBand", appearance?.InferredAgeBand, appearance?.Evidence, appearance?.Confidence),
Field("HairColour", appearance?.HairColour, appearance?.Evidence, appearance?.Confidence),
Field("HairLength", appearance?.HairLength, appearance?.Evidence, appearance?.Confidence),
Field("HairStyle", appearance?.HairStyle, appearance?.Evidence, appearance?.Confidence),
Field("EyeColour", appearance?.EyeColour, appearance?.Evidence, appearance?.Confidence),
Field("Height", appearance?.Height, appearance?.Evidence, appearance?.Confidence),
Field("Build", appearance?.Build, appearance?.Evidence, appearance?.Confidence),
Field("TypicalClothing", appearance?.Clothing, appearance?.Evidence, appearance?.Confidence),
Field("Occupation", appearance?.RoleOrOccupation, appearance?.Evidence, appearance?.Confidence),
Field("CurrentStatus", character.MentionedOnly == true ? "Mentioned" : "Present in scene", character.Notes, character.Confidence)
};
updates.Add(new CharacterIntelligenceProfileUpdate
{
CanonicalName = group.Key,
Aliases = CleanList(character.Aliases).Concat(CleanList(appearance?.Aliases)).Distinct(StringComparer.OrdinalIgnoreCase).ToList(),
Confidence = character.Confidence ?? appearance?.Confidence ?? 0.55m,
FieldUpdates = fields.Where(item => !string.IsNullOrWhiteSpace(item.Value)).ToList()
});
}
return new CharacterIntelligenceProfileUpdateResponse { Updates = updates };
}
private static CharacterIntelligenceFieldUpdate Field(string name, string? value, string? evidence, decimal? confidence)
=> new()
{
FieldName = name,
Value = Clean(value),
Evidence = Clean(evidence),
Confidence = confidence ?? 0.55m,
IsNewInformation = true,
IncreasesCertainty = true
};
private static bool ShouldApply(string? previous, CharacterIntelligenceFieldUpdate update)
{
if (string.IsNullOrWhiteSpace(update.Value))
{
return false;
}
if (string.IsNullOrWhiteSpace(previous) || string.Equals(previous, "Unknown", StringComparison.OrdinalIgnoreCase))
{
return true;
}
return update.IncreasesCertainty && ClampConfidence(update.Confidence) >= 0.75m && !string.Equals(previous, update.Value, StringComparison.OrdinalIgnoreCase);
}
private static string? GetField(CharacterIntelligenceProfileDocument profile, string fieldName)
=> NormaliseField(fieldName) switch
{
"roleinstory" => profile.RoleInStory,
"narrativeimportance" => profile.NarrativeImportance,
"presentation" => profile.Presentation,
"estimatedage" => profile.EstimatedAge,
"estimatedageband" => profile.EstimatedAgeBand,
"haircolour" => profile.HairColour,
"hairlength" => profile.HairLength,
"hairstyle" => profile.HairStyle,
"eyecolour" => profile.EyeColour,
"skintone" => profile.SkinTone,
"height" => profile.Height,
"build" => profile.Build,
"typicalclothing" => profile.TypicalClothing,
"occupation" => profile.Occupation,
"currentstatus" => profile.CurrentStatus,
_ => null
};
private static void SetField(CharacterIntelligenceProfileDocument profile, string fieldName, string? value)
{
var clean = Clean(value);
switch (NormaliseField(fieldName))
{
case "roleinstory": profile.RoleInStory = clean; break;
case "narrativeimportance": profile.NarrativeImportance = clean; break;
case "presentation": profile.Presentation = clean; break;
case "estimatedage": profile.EstimatedAge = clean; break;
case "estimatedageband": profile.EstimatedAgeBand = clean; break;
case "haircolour": profile.HairColour = clean; break;
case "hairlength": profile.HairLength = clean; break;
case "hairstyle": profile.HairStyle = clean; break;
case "eyecolour": profile.EyeColour = clean; break;
case "skintone": profile.SkinTone = clean; break;
case "height": profile.Height = clean; break;
case "build": profile.Build = clean; break;
case "typicalclothing": profile.TypicalClothing = clean; break;
case "occupation": profile.Occupation = clean; break;
case "currentstatus": profile.CurrentStatus = clean; break;
case "distinctivefeatures": AddUnique(profile.DistinctiveFeatures, clean); break;
case "relationships": AddUnique(profile.Relationships, clean); break;
}
}
private static bool IsRecurring(CharacterIntelligenceProfileDocument profile, SceneIntelligenceScene scene)
=> profile.EvidenceTimeline.Select(item => item.SceneResultID).Distinct().Count() > 1
|| (scene.Relationships ?? []).Any(item => string.Equals(Clean(item.CharacterA), profile.CanonicalName, StringComparison.OrdinalIgnoreCase)
|| string.Equals(Clean(item.CharacterB), profile.CanonicalName, StringComparison.OrdinalIgnoreCase))
|| profile.Confidence >= 0.7m;
private static IEnumerable<string> SceneCharacterNames(SceneIntelligenceScene scene)
=> (scene.Characters ?? []).Select(item => item.Name)
.Concat((scene.CharacterAppearance ?? []).Select(item => item.CanonicalName))
.Where(item => !string.IsNullOrWhiteSpace(item))
.Select(item => Clean(item)!);
private static string BuildCurrentProfilesJson(IReadOnlyList<CharacterIntelligenceProfile> existingProfiles)
=> JsonSerializer.Serialize(existingProfiles.Select(item => new
{
item.CanonicalIdentityKey,
item.CanonicalName,
item.Confidence,
item.NarrativeImportance,
item.IsRecurring,
profile = ReadProfile(item)
}), JsonOptions);
private static string BuildStoryMemoryJson(
IReadOnlyList<StoryMemoryCharacter> characters,
IReadOnlyList<StoryMemoryCharacterAttribute> attributes)
=> JsonSerializer.Serialize(new
{
characters = characters.Select(item => new
{
item.CanonicalIdentityKey,
item.DisplayName,
item.IsNamed,
item.IsGroupEntity,
item.IsNarrator,
item.IsPrimaryPOV,
item.Significance,
attributes = attributes
.Where(attribute => attribute.StoryMemoryCharacterID == item.StoryMemoryCharacterID)
.Select(attribute => new
{
attribute.AttributeType,
attribute.NormalisedValue,
attribute.Confidence,
attribute.IsExplicit,
attribute.EvidenceSummary
})
})
}, JsonOptions);
private static string ExtractOutputText(string rawResponseText)
{
using var response = JsonDocument.Parse(rawResponseText);
foreach (var output in response.RootElement.GetProperty("output").EnumerateArray())
{
if (!output.TryGetProperty("content", out var content))
{
continue;
}
foreach (var item in content.EnumerateArray())
{
if (item.TryGetProperty("type", out var type)
&& string.Equals(type.GetString(), "output_text", StringComparison.OrdinalIgnoreCase)
&& item.TryGetProperty("text", out var text)
&& !string.IsNullOrWhiteSpace(text.GetString()))
{
return text.GetString()!.Trim();
}
}
}
return string.Empty;
}
private static bool SameCharacter(string? value, string? name, IEnumerable<string>? aliases)
{
var clean = Clean(value);
return string.Equals(clean, Clean(name), StringComparison.OrdinalIgnoreCase)
|| CleanList(aliases).Any(alias => string.Equals(alias, clean, StringComparison.OrdinalIgnoreCase));
}
private static string CharacterKey(string name, IEnumerable<string>? aliases)
{
var candidates = CleanList(aliases).Prepend(Clean(name)).Where(item => !string.IsNullOrWhiteSpace(item)).ToList();
var selected = candidates.OrderBy(item => item.Length).FirstOrDefault() ?? "unknown";
return $"character-intelligence-{StableKey(selected)}";
}
private static string StableKey(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));
}
private static string NormaliseField(string value)
=> new(value.Where(char.IsLetterOrDigit).Select(char.ToLowerInvariant).ToArray());
private static void MergeList(List<string> target, IEnumerable<string>? values)
{
foreach (var value in CleanList(values))
{
AddUnique(target, value);
}
}
private static void AddUnique(List<string> target, string? value)
{
var clean = Clean(value);
if (!string.IsNullOrWhiteSpace(clean) && !target.Contains(clean, StringComparer.OrdinalIgnoreCase))
{
target.Add(clean);
}
}
private static List<string> CleanList(IEnumerable<string>? values)
=> (values ?? [])
.Select(Clean)
.Where(item => !string.IsNullOrWhiteSpace(item))
.Select(item => item!)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
private static string Clean(string? value)
=> string.Join(' ', (value ?? string.Empty).Trim().Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries));
private static string FirstConfigured(params string?[] values)
=> values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))?.Trim() ?? string.Empty;
private static decimal ClampConfidence(decimal confidence)
=> Math.Clamp(confidence <= 0 ? 0.55m : confidence, 0m, 1m);
}

File diff suppressed because it is too large Load Diff

View File

@ -163,16 +163,24 @@ public sealed class OnboardingService(
.Select(item =>
{
var cleanName = Clean(item.Name);
var matchedExistingId = item.ExistingCharacterID.HasValue && existingCharacterIds.Contains(item.ExistingCharacterID.Value)
var aliasTargetKey = CleanOptional(item.AliasTargetTemporaryCharacterKey);
if (!string.IsNullOrWhiteSpace(aliasTargetKey)
&& (!candidateLookup.ContainsKey(aliasTargetKey) || string.Equals(aliasTargetKey, item.TemporaryCharacterKey, StringComparison.Ordinal)))
{
aliasTargetKey = null;
}
var matchedExistingId = string.IsNullOrWhiteSpace(aliasTargetKey) && item.ExistingCharacterID.HasValue && existingCharacterIds.Contains(item.ExistingCharacterID.Value)
? item.ExistingCharacterID
: MatchExistingCharacter(cleanName, existingCharacters)?.CharacterID;
: string.IsNullOrWhiteSpace(aliasTargetKey) ? MatchExistingCharacter(cleanName, existingCharacters)?.CharacterID : null;
return new ManuscriptScanCharacterReviewDecision
{
TemporaryCharacterKey = item.TemporaryCharacterKey,
Include = item.Include,
Name = cleanName,
Category = item.Category,
ExistingCharacterID = matchedExistingId
ExistingCharacterID = matchedExistingId,
AliasTargetTemporaryCharacterKey = aliasTargetKey
};
})
.ToList();
@ -211,6 +219,11 @@ public sealed class OnboardingService(
var chapterDecisions = review.Chapters.Where(chapter => chapter.Include).OrderBy(chapter => chapter.ChapterNumber).ToList();
await PublishBuildProgress(userId, preview.PreviewID, "Preparing chapters for analysis...", 35, progress);
var characterDecisions = review.Characters
.Where(IsIncludedCanonicalCharacter)
.OrderBy(character => character.Name)
.ThenBy(character => character.TemporaryCharacterKey)
.ToList();
var request = new OnboardingManuscriptBuildRequest
{
PreviewID = preview.PreviewID,
@ -225,7 +238,12 @@ public sealed class OnboardingService(
WordCount = preview.Chapters.FirstOrDefault(item => item.TemporaryChapterKey == chapter.TemporaryChapterKey)?.WordCount ?? 0
}).ToList(),
Scenes = [],
Characters = []
Characters = characterDecisions.Select(character => new OnboardingBuildCharacterRequest
{
TemporaryCharacterKey = character.TemporaryCharacterKey,
Name = character.Name,
ExistingCharacterID = character.ExistingCharacterID
}).ToList()
};
await PublishBuildProgress(userId, preview.PreviewID, "Saving chapters...", 60, progress);
@ -252,6 +270,7 @@ public sealed class OnboardingService(
CharacterMappings = result.CharacterMappings,
MarkerWarning = result.MarkerWarning
};
await AddScanCharacterAliasesAsync(review, result);
await scanPreviews.SaveBuildResultAsync(userId, result);
await PublishBuildProgress(userId, preview.PreviewID, "Finalising project...", 90, progress);
await PublishBuildProgress(userId, preview.PreviewID, result.Message, 100, progress, result);
@ -385,7 +404,12 @@ public sealed class OnboardingService(
Category = candidate.Category,
Reason = candidate.Reason,
ExistingCharacterID = existing?.CharacterID,
ExistingCharacterName = existing?.CharacterName
ExistingCharacterName = existing?.CharacterName,
AliasTargetTemporaryCharacterKey = decision?.AliasTargetTemporaryCharacterKey,
AliasTargetName = !string.IsNullOrWhiteSpace(decision?.AliasTargetTemporaryCharacterKey)
&& characterReview.TryGetValue(decision.AliasTargetTemporaryCharacterKey, out var targetDecision)
? targetDecision.Name
: null
};
})
.ToList();
@ -406,7 +430,7 @@ public sealed class OnboardingService(
CharacterCandidateCount = preview.CharacterCandidateCount,
SelectedChapterCount = selectedChapterKeys.Count,
SelectedSceneCount = selectedScenes.Count,
SelectedCharacterCount = characters.Count(character => character.Include && !string.Equals(character.Category, "Excluded", StringComparison.OrdinalIgnoreCase)),
SelectedCharacterCount = characters.Count(character => character.Include && !character.IsAlias && !string.Equals(character.Category, "Excluded", StringComparison.OrdinalIgnoreCase)),
SelectedWordCount = selectedScenes.Sum(scene => scene.WordCount),
Chapters = chapters,
CharacterCandidates = characters,
@ -466,7 +490,7 @@ public sealed class OnboardingService(
}
var duplicateName = review.Characters
.Where(character => character.Include && !string.Equals(character.Category, "Excluded", StringComparison.OrdinalIgnoreCase))
.Where(IsIncludedCanonicalCharacter)
.Select(character => Clean(character.Name))
.Where(name => !string.IsNullOrWhiteSpace(name))
.GroupBy(name => name, StringComparer.OrdinalIgnoreCase)
@ -480,8 +504,56 @@ public sealed class OnboardingService(
{
throw new InvalidOperationException("Every included character needs a display name.");
}
var characterLookup = review.Characters.ToDictionary(character => character.TemporaryCharacterKey, StringComparer.Ordinal);
foreach (var character in review.Characters.Where(character => character.Include && !string.IsNullOrWhiteSpace(character.AliasTargetTemporaryCharacterKey)))
{
if (string.Equals(character.TemporaryCharacterKey, character.AliasTargetTemporaryCharacterKey, StringComparison.Ordinal))
{
throw new InvalidOperationException("A character candidate cannot be an alias of itself.");
}
if (!characterLookup.TryGetValue(character.AliasTargetTemporaryCharacterKey!, out var target)
|| !IsIncludedCanonicalCharacter(target))
{
throw new InvalidOperationException($"Choose an included primary character for the alias \"{character.Name}\".");
}
}
}
private async Task AddScanCharacterAliasesAsync(ManuscriptScanReviewDecision review, OnboardingManuscriptBuildResult result)
{
var characterIds = result.CharacterMappings.ToDictionary(item => item.TemporaryCharacterKey, item => item.CharacterID, StringComparer.Ordinal);
var decisions = review.Characters.ToDictionary(item => item.TemporaryCharacterKey, StringComparer.Ordinal);
foreach (var alias in review.Characters.Where(character => character.Include && !string.IsNullOrWhiteSpace(character.AliasTargetTemporaryCharacterKey)))
{
if (!decisions.TryGetValue(alias.AliasTargetTemporaryCharacterKey!, out var target)
|| !characterIds.TryGetValue(target.TemporaryCharacterKey, out var characterId))
{
continue;
}
var cleanAlias = Clean(alias.Name);
if (string.IsNullOrWhiteSpace(cleanAlias) || string.Equals(cleanAlias, target.Name, StringComparison.OrdinalIgnoreCase))
{
continue;
}
var existingAliases = await characters.ListAliasesAsync(characterId);
if (existingAliases.Any(item => string.Equals(item.Alias, cleanAlias, StringComparison.OrdinalIgnoreCase)))
{
continue;
}
await characters.AddAliasAsync(characterId, cleanAlias, null);
}
}
private static bool IsIncludedCanonicalCharacter(ManuscriptScanCharacterReviewDecision character)
=> character.Include
&& string.IsNullOrWhiteSpace(character.AliasTargetTemporaryCharacterKey)
&& !string.Equals(character.Category, "Excluded", StringComparison.OrdinalIgnoreCase);
private static Character? MatchExistingCharacter(string? name, IReadOnlyList<Character> existingCharacters)
{
var clean = Clean(name);

View File

@ -9,7 +9,7 @@ namespace PlotLine.Services;
public interface IOnboardingStoryIntelligenceService
{
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?> CancelAsync(Guid batchId);
Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> CommitAsync(Guid batchId, int runId);
@ -40,6 +40,7 @@ public sealed class OnboardingStoryIntelligenceService(
IStoryIntelligenceRelationshipImportService relationshipImport,
IStoryIntelligenceKnowledgeImportService knowledgeImport,
IStoryIntelligencePipelineStateService pipelineState,
IStoryMemoryRepository storyMemory,
IStoryIntelligenceClient client,
IOnboardingStoryIntelligenceBatchStore batchStore,
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 context = await GetContextAsync(userId);
@ -119,6 +120,7 @@ public sealed class OnboardingStoryIntelligenceService(
clientStatus.ChapterStructureModel,
clientStatus.SceneIntelligenceModel);
var batchItems = new List<OnboardingStoryIntelligenceBatchItem>();
var knownCharactersJson = BuildKnownCharacterContextJson(preview, review);
foreach (var chapter in included)
{
@ -150,7 +152,8 @@ public sealed class OnboardingStoryIntelligenceService(
SourceParagraphCount = paragraphs.Count,
SourceChapterCount = 1,
Model = modelSummary,
PromptVersionsSummary = PromptVersionsSummary
PromptVersionsSummary = PromptVersionsSummary,
KnownCharactersJson = knownCharactersJson
});
batchItems.Add(new OnboardingStoryIntelligenceBatchItem
@ -161,6 +164,7 @@ public sealed class OnboardingStoryIntelligenceService(
ChapterID = chapterId,
RunID = runId
});
await pipelineState.RecordChapterAnalysisQueuedAsync(preview.ProjectID, preview.BookID, runId);
await notifier.PublishAsync(new StoryIntelligenceRunProgressEvent
{
@ -189,9 +193,23 @@ public sealed class OnboardingStoryIntelligenceService(
BookID = preview.BookID,
ProjectName = wizard.SelectedProjectName,
BookTitle = wizard.SelectedBookTitle,
AppearancePreference = NormaliseAppearancePreference(appearancePreference),
PrimaryAgeGroup = CleanContext(primaryAgeGroup),
StoryEra = CleanContext(storyEra),
StoryLocation = CleanContext(storyLocation),
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);
logger.LogInformation(
"Queued onboarding Story Intelligence batch {BatchID}. UserID={UserID} ProjectID={ProjectID} BookID={BookID} Chapters={ChapterCount}",
@ -204,6 +222,100 @@ public sealed class OnboardingStoryIntelligenceService(
return batch;
}
private static string NormaliseAppearancePreference(string? value)
=> value?.Trim() switch
{
StoryMemoryAppearancePreferences.ExplicitEvidenceOnly => StoryMemoryAppearancePreferences.ExplicitEvidenceOnly,
StoryMemoryAppearancePreferences.Balanced => StoryMemoryAppearancePreferences.Balanced,
StoryMemoryAppearancePreferences.PredominantlyLight => StoryMemoryAppearancePreferences.PredominantlyLight,
StoryMemoryAppearancePreferences.PredominantlyMedium => StoryMemoryAppearancePreferences.PredominantlyMedium,
StoryMemoryAppearancePreferences.PredominantlyDark => StoryMemoryAppearancePreferences.PredominantlyDark,
StoryMemoryAppearancePreferences.CustomMix => StoryMemoryAppearancePreferences.CustomMix,
_ => StoryMemoryAppearancePreferences.Balanced
};
private static string CleanContext(string? value)
{
var clean = value?.Trim();
return string.IsNullOrWhiteSpace(clean) ? string.Empty : clean[..Math.Min(clean.Length, 160)];
}
public static string? BuildKnownCharacterContextJson(ManuscriptScanPreview preview, ManuscriptScanReviewDecision review)
{
var candidates = preview.CharacterCandidates.ToDictionary(candidate => candidate.TemporaryCharacterKey, StringComparer.Ordinal);
var decisions = review.Characters
.Where(character => character.Include && !string.Equals(character.Category, "Excluded", StringComparison.OrdinalIgnoreCase))
.ToDictionary(character => character.TemporaryCharacterKey, StringComparer.Ordinal);
var roots = decisions.Values
.Where(character => string.IsNullOrWhiteSpace(character.AliasTargetTemporaryCharacterKey))
.OrderBy(character => character.Name)
.ThenBy(character => character.TemporaryCharacterKey)
.ToList();
var context = new List<StoryIntelligenceKnownCharacterContext>();
foreach (var root in roots)
{
var aliasDecisions = decisions.Values
.Where(character => string.Equals(character.AliasTargetTemporaryCharacterKey, root.TemporaryCharacterKey, StringComparison.Ordinal))
.OrderBy(character => character.Name)
.ToList();
var names = new List<string>();
AddName(names, root.Name);
if (candidates.TryGetValue(root.TemporaryCharacterKey, out var rootCandidate))
{
AddName(names, rootCandidate.Name);
}
foreach (var alias in aliasDecisions)
{
AddName(names, alias.Name);
if (candidates.TryGetValue(alias.TemporaryCharacterKey, out var aliasCandidate))
{
AddName(names, aliasCandidate.Name);
}
}
var aliases = names
.Where(name => !string.Equals(name, root.Name, StringComparison.OrdinalIgnoreCase))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(name => name)
.ToList();
var allKeys = new[] { root.TemporaryCharacterKey }
.Concat(aliasDecisions.Select(alias => alias.TemporaryCharacterKey))
.ToList();
var mentionCount = allKeys
.Select(key => candidates.TryGetValue(key, out var candidate) ? candidate.MentionCount : 0)
.Sum();
var confidence = allKeys
.Select(key => candidates.TryGetValue(key, out var candidate) ? Math.Clamp(candidate.QualityScore / 100m, 0.1m, 1m) : 0.5m)
.DefaultIfEmpty(0.5m)
.Max();
context.Add(new StoryIntelligenceKnownCharacterContext
{
CanonicalName = root.Name,
Aliases = aliases,
ExistingCharacterID = root.ExistingCharacterID,
CandidateKeys = allKeys,
MentionCount = mentionCount,
Confidence = confidence
});
}
return context.Count == 0
? null
: JsonSerializer.Serialize(context, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
}
private static void AddName(List<string> names, string? value)
{
var clean = CleanContext(value);
if (!string.IsNullOrWhiteSpace(clean) && !names.Contains(clean, StringComparer.OrdinalIgnoreCase))
{
names.Add(clean);
}
}
public async Task<StoryIntelligenceProgressViewModel?> GetProgressAsync(Guid batchId)
{
var batch = await batchStore.GetAsync(RequireUserId(), batchId);
@ -253,6 +365,7 @@ public sealed class OnboardingStoryIntelligenceService(
SourceWordCount = run.SourceWordCount,
TotalTokens = run.TotalTokens,
TotalDurationMs = run.TotalDurationMs,
UpdatedUtc = run.UpdatedUtc,
EstimatedCostUSD = run.EstimatedCostUSD,
EstimatedCostGBP = run.EstimatedCostGBP,
FailureStage = run.FailureStage,
@ -275,10 +388,12 @@ public sealed class OnboardingStoryIntelligenceService(
var assetReview = await assetImport.BuildReviewAsync(batch);
var relationshipReview = await relationshipImport.BuildReviewAsync(batch);
var knowledgeReview = await knowledgeImport.BuildReviewAsync(batch);
var pipeline = await pipelineState.GetForBookAsync(batch.BookID, batch.UserID);
return new StoryIntelligenceProgressViewModel
{
BatchID = batch.BatchID,
PreviewID = batch.PreviewID,
ImportSessionID = pipeline?.StoryIntelligenceBookPipelineID,
ProjectID = batch.ProjectID,
BookID = batch.BookID,
ProjectName = batch.ProjectName,
@ -579,8 +694,35 @@ public sealed class OnboardingStoryIntelligenceService(
return null;
}
var committedRuns = await pipelineState.ListCommittedRunsByBookAsync(bookId, userId);
if (committedRuns.Count == 0)
var committedRuns = (await pipelineState.ListCommittedRunsByBookAsync(bookId, userId))
.OrderBy(run => run.ChapterNumber)
.ThenBy(run => run.StoryIntelligenceRunID)
.Select(run => new OnboardingStoryIntelligenceBatchItem
{
TemporaryChapterKey = $"book-{state.BookID}-chapter-{run.ChapterID}",
ChapterNumber = Convert.ToInt32(run.ChapterNumber),
ChapterTitle = run.ChapterTitle,
ChapterID = run.ChapterID,
RunID = run.StoryIntelligenceRunID
})
.ToList();
var activeRuns = committedRuns.Count > 0
? []
: (await runs.ListRunsByBookForUserAsync(bookId, userId))
.Where(run => run.BookID == state.BookID)
.OrderBy(run => run.ChapterNumber ?? decimal.MaxValue)
.ThenBy(run => run.StoryIntelligenceRunID)
.Select(run => new OnboardingStoryIntelligenceBatchItem
{
TemporaryChapterKey = $"book-{state.BookID}-chapter-{run.ChapterID ?? run.StoryIntelligenceRunID}",
ChapterNumber = Convert.ToInt32(run.ChapterNumber ?? 0),
ChapterTitle = FirstConfigured(run.SourceFileName, run.CurrentMessage, run.ChapterNumber.HasValue ? $"Chapter {run.ChapterNumber:0.##}" : $"Run {run.StoryIntelligenceRunID:N0}")!,
ChapterID = run.ChapterID ?? 0,
RunID = run.StoryIntelligenceRunID
})
.ToList();
var batchItems = committedRuns.Count > 0 ? committedRuns : activeRuns;
if (batchItems.Count == 0)
{
return null;
}
@ -594,18 +736,7 @@ public sealed class OnboardingStoryIntelligenceService(
BookID = state.BookID,
ProjectName = state.ProjectName,
BookTitle = state.BookDisplayTitle,
Items = committedRuns
.OrderBy(run => run.ChapterNumber)
.ThenBy(run => run.StoryIntelligenceRunID)
.Select(run => new OnboardingStoryIntelligenceBatchItem
{
TemporaryChapterKey = $"book-{state.BookID}-chapter-{run.ChapterID}",
ChapterNumber = Convert.ToInt32(run.ChapterNumber),
ChapterTitle = run.ChapterTitle,
ChapterID = run.ChapterID,
RunID = run.StoryIntelligenceRunID
})
.ToList()
Items = batchItems
};
var characterStageComplete = HasCompletedStage(state, StoryIntelligencePipelineStages.CharacterImport)
@ -900,6 +1031,10 @@ public sealed class OnboardingStoryIntelligenceBatch
public int BookID { get; init; }
public string? ProjectName { get; init; }
public string? BookTitle { get; init; }
public string AppearancePreference { get; init; } = StoryMemoryAppearancePreferences.Balanced;
public 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 IReadOnlyList<OnboardingStoryIntelligenceBatchItem> Items { get; init; } = [];
public List<OnboardingStoryIntelligenceCharacterDecision> CharacterDecisions { get; } = [];

View File

@ -13,6 +13,7 @@ public interface IPersistedStoryIntelligenceRunner
public sealed class PersistedStoryIntelligenceRunner(
IStoryIntelligenceResultRepository repository,
IStoryIntelligencePipelineRepository pipelines,
IStoryPromptRepository prompts,
IStoryPromptVersionService versions,
IStoryPromptBuilder scenePromptBuilder,
@ -22,6 +23,8 @@ public sealed class PersistedStoryIntelligenceRunner(
IOptions<StoryIntelligenceOptions> options,
IOptions<StoryIntelligencePricingOptions> pricingOptions,
IStoryIntelligenceProgressNotifier notifier,
IStoryMemoryService storyMemory,
ICharacterIntelligenceService characterIntelligence,
ILogger<PersistedStoryIntelligenceRunner> logger) : IPersistedStoryIntelligenceRunner
{
private const string ChapterPromptFile = "Chapter-Structure-Prompt-V2.md";
@ -304,7 +307,7 @@ public sealed class PersistedStoryIntelligenceRunner(
hasWarnings = hasWarnings || validation.Warnings.Count > 0;
await repository.SaveSceneResultAsync(
var sceneResultId = await repository.SaveSceneResultAsync(
run.StoryIntelligenceRunID,
chapterResultId,
new StoryIntelligenceSceneResultSaveRequest
@ -329,6 +332,47 @@ public sealed class PersistedStoryIntelligenceRunner(
TotalTokens = SumTokens(sceneAttempt.InputTokens, sceneAttempt.OutputTokens),
DurationMs = Convert.ToInt64(sceneAttempt.Duration.TotalMilliseconds)
});
var importSessionId = run.BookID.HasValue ? (await pipelines.GetByBookAsync(run.BookID.Value))?.StoryIntelligenceBookPipelineID : null;
logger.LogInformation(
"Story Intelligence importer synchronisation: import session {ImportSessionId}; run {RunId}; current chapter {ChapterNumber}; current scene {SceneNumber}; scene result {SceneResultId}; snapshot version ImportPipeline; change token n/a; status analysed.",
importSessionId,
run.StoryIntelligenceRunID,
run.ChapterNumber,
block.TemporarySceneNumber,
sceneResultId);
if (importSessionId.HasValue)
{
try
{
await storyMemory.ProcessSceneResultAsync(
importSessionId.Value,
run.UserID,
sceneResultId,
new StoryMemoryResolveOptions { QueueDemand = 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);
}
try
{
var importSession = await pipelines.GetByIdForUserAsync(importSessionId.Value, run.UserID);
if (importSession is not null && sceneAttempt.Parsed is not null)
{
await characterIntelligence.UpdateProfilesForSceneAsync(
importSession,
sceneResultId,
sceneAttempt.Parsed,
cancellationToken);
}
}
catch (Exception ex) when (ex is not OperationCanceledException && !IsFatal(ex))
{
logger.LogError(ex, "Character Intelligence profile update failed for import session {ImportSessionId}, scene result {SceneResultId}.", importSessionId, sceneResultId);
}
}
completedScenes++;
await PublishAsync(
@ -721,7 +765,7 @@ public sealed class PersistedStoryIntelligenceRunner(
string message,
AiJsonParseException? parseException = null)
{
await repository.SaveSceneResultAsync(
var sceneResultId = await repository.SaveSceneResultAsync(
run.StoryIntelligenceRunID,
chapterResultId,
new StoryIntelligenceSceneResultSaveRequest
@ -752,6 +796,14 @@ public sealed class PersistedStoryIntelligenceRunner(
TotalTokens = SumTokens(parseException?.InputTokens, parseException?.OutputTokens),
DurationMs = parseException is null ? null : Convert.ToInt64(parseException.Duration.TotalMilliseconds)
});
var importSessionId = run.BookID.HasValue ? (await pipelines.GetByBookAsync(run.BookID.Value))?.StoryIntelligenceBookPipelineID : null;
logger.LogInformation(
"Story Intelligence importer synchronisation: import session {ImportSessionId}; run {RunId}; current chapter {ChapterNumber}; current scene {SceneNumber}; scene result {SceneResultId}; snapshot version ImportPipeline; change token n/a; status failed.",
importSessionId,
run.StoryIntelligenceRunID,
run.ChapterNumber,
block.TemporarySceneNumber,
sceneResultId);
}
private StoryIntelligenceStageModels ResolveStageModels(StoryIntelligenceQueuedRun run)
@ -800,7 +852,8 @@ public sealed class PersistedStoryIntelligenceRunner(
bookId = run.BookID,
chapterId = run.ChapterID,
chapterNumber = ChapterNumber(run),
sourceLabel = SourceLabel(run)
sourceLabel = SourceLabel(run),
knownCharacters = KnownCharacters(run)
}, JsonOptions);
private static string BuildSceneContextJson(StoryIntelligenceQueuedRun run, ChapterSceneBoundary boundary)
@ -812,9 +865,27 @@ public sealed class PersistedStoryIntelligenceRunner(
sceneId = (int?)null,
chapterNumber = ChapterNumber(run),
sceneNumber = boundary.SceneNumber,
sourceLabel = $"{SourceLabel(run)}, suggested scene {boundary.SceneNumber}"
sourceLabel = $"{SourceLabel(run)}, suggested scene {boundary.SceneNumber}",
knownCharacters = KnownCharacters(run)
}, JsonOptions);
private static IReadOnlyList<StoryIntelligenceKnownCharacterContext> KnownCharacters(StoryIntelligenceQueuedRun run)
{
if (string.IsNullOrWhiteSpace(run.KnownCharactersJson))
{
return [];
}
try
{
return JsonSerializer.Deserialize<IReadOnlyList<StoryIntelligenceKnownCharacterContext>>(run.KnownCharactersJson, JsonOptions) ?? [];
}
catch (JsonException)
{
return [];
}
}
private static string BuildChapterPrompt(string promptTemplate, string chapterContextJson, string chapterText)
=> promptTemplate
.Replace("{{CHAPTER_CONTEXT_JSON}}", chapterContextJson ?? string.Empty, StringComparison.Ordinal)

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,
IStoryIntelligenceResultRepository runs,
IStoryIntelligenceClient client,
IStoryIntelligencePipelineStateService pipelines,
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.";
@ -77,6 +78,7 @@ public sealed class StoryIntelligenceExistingChapterQueueService(
clientStatus.SceneIntelligenceModel),
PromptVersionsSummary = "Chapter=Chapter-Structure-Prompt-V2; Scene=Scene-Prompt-V2"
});
await pipelines.RecordChapterAnalysisQueuedAsync(selected.ProjectID, selected.BookID, runId);
logger.LogInformation(
"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(
IStoryIntelligenceResultRepository runs,
IStoryIntelligencePipelineStateService pipelines,
IOptions<StoryIntelligenceOptions> options,
IOptions<StoryIntelligencePricingOptions> pricingOptions,
ILogger<StoryIntelligenceFullChapterTestService> logger) : IStoryIntelligenceFullChapterTestService
@ -104,6 +105,10 @@ public sealed class StoryIntelligenceFullChapterTestService(
Model = StoryIntelligenceOptions.BuildModelSummary(modelSelection.ChapterStructureModel, modelSelection.SceneIntelligenceModel),
PromptVersionsSummary = PromptVersionsSummary
});
if (form.ProjectID is int projectId && form.BookID is int bookId)
{
await pipelines.RecordChapterAnalysisQueuedAsync(projectId, bookId, runId);
}
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}",

View File

@ -0,0 +1,820 @@
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, "driving test centre", "driving test center", "test centre", "test center")) return "DrivingTestCentre";
if (ContainsAny(text, "police station")) return "PoliceStation";
if (ContainsAny(text, "derelict hospital", "abandoned hospital")) return "DerelictHospital";
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, "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, "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 (text.Contains("shop", StringComparison.OrdinalIgnoreCase)) return "Shop";
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);
}
if (IsInsufficientQuota(responseText))
{
throw new InvalidOperationException($"OpenAI billing quota exhausted; request was not retried: {TrimForLog(responseText)}");
}
if (!ShouldRetry(response.StatusCode) || attempt == maxAttempts)
{
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.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)
=> 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<IReadOnlyList<StoryIntelligenceBookPipelineState>> ListForUserAsync(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 RecordCharacterImportAsync(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)
{
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,61 @@
SET ANSI_NULLS ON;
SET QUOTED_IDENTIFIER ON;
GO
IF OBJECT_ID(N'dbo.CharacterIntelligenceProfiles', N'U') IS NULL
BEGIN
CREATE TABLE dbo.CharacterIntelligenceProfiles
(
CharacterIntelligenceProfileID int IDENTITY(1,1) NOT NULL CONSTRAINT PK_CharacterIntelligenceProfiles PRIMARY KEY,
ImportSessionID int NOT NULL,
ProjectID int NOT NULL,
BookID int NOT NULL,
CanonicalIdentityKey nvarchar(180) NOT NULL,
CanonicalName nvarchar(220) NOT NULL,
ProfileJson nvarchar(max) NOT NULL,
Confidence decimal(5,2) NOT NULL CONSTRAINT DF_CharacterIntelligenceProfiles_Confidence DEFAULT 0,
NarrativeImportance nvarchar(80) NULL,
IsRecurring bit NOT NULL CONSTRAINT DF_CharacterIntelligenceProfiles_IsRecurring DEFAULT 0,
EvidenceVersion nvarchar(40) NOT NULL CONSTRAINT DF_CharacterIntelligenceProfiles_EvidenceVersion DEFAULT N'22',
LastUpdatedSceneResultID int NULL,
CreatedUtc datetime2(0) NOT NULL CONSTRAINT DF_CharacterIntelligenceProfiles_CreatedUtc DEFAULT SYSUTCDATETIME(),
UpdatedUtc datetime2(0) NOT NULL CONSTRAINT DF_CharacterIntelligenceProfiles_UpdatedUtc DEFAULT SYSUTCDATETIME(),
CONSTRAINT FK_CharacterIntelligenceProfiles_ImportSession FOREIGN KEY (ImportSessionID) REFERENCES dbo.StoryIntelligenceBookPipelines(StoryIntelligenceBookPipelineID),
CONSTRAINT FK_CharacterIntelligenceProfiles_LastSceneResult FOREIGN KEY (LastUpdatedSceneResultID) REFERENCES dbo.StoryIntelligenceSceneResults(SceneResultID)
);
END;
GO
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'UX_CharacterIntelligenceProfiles_ImportCanonical' AND object_id = OBJECT_ID(N'dbo.CharacterIntelligenceProfiles'))
CREATE UNIQUE INDEX UX_CharacterIntelligenceProfiles_ImportCanonical ON dbo.CharacterIntelligenceProfiles(ImportSessionID, CanonicalIdentityKey);
GO
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'IX_CharacterIntelligenceProfiles_ImportRecurring' AND object_id = OBJECT_ID(N'dbo.CharacterIntelligenceProfiles'))
CREATE INDEX IX_CharacterIntelligenceProfiles_ImportRecurring ON dbo.CharacterIntelligenceProfiles(ImportSessionID, IsRecurring DESC, Confidence DESC, CanonicalName);
GO
IF OBJECT_ID(N'dbo.CharacterIntelligenceEvidenceEvents', N'U') IS NULL
BEGIN
CREATE TABLE dbo.CharacterIntelligenceEvidenceEvents
(
CharacterIntelligenceEvidenceEventID int IDENTITY(1,1) NOT NULL CONSTRAINT PK_CharacterIntelligenceEvidenceEvents PRIMARY KEY,
CharacterIntelligenceProfileID int NOT NULL,
SceneResultID int NOT NULL,
FieldName nvarchar(80) NOT NULL,
PreviousValue nvarchar(500) NULL,
NewValue nvarchar(500) NULL,
Evidence nvarchar(700) NULL,
Confidence decimal(5,2) NOT NULL CONSTRAINT DF_CharacterIntelligenceEvidenceEvents_Confidence DEFAULT 0,
IsContradiction bit NOT NULL CONSTRAINT DF_CharacterIntelligenceEvidenceEvents_IsContradiction DEFAULT 0,
Source nvarchar(80) NOT NULL CONSTRAINT DF_CharacterIntelligenceEvidenceEvents_Source DEFAULT N'AI profile update',
CreatedUtc datetime2(0) NOT NULL CONSTRAINT DF_CharacterIntelligenceEvidenceEvents_CreatedUtc DEFAULT SYSUTCDATETIME(),
CONSTRAINT FK_CharacterIntelligenceEvidenceEvents_Profile FOREIGN KEY (CharacterIntelligenceProfileID) REFERENCES dbo.CharacterIntelligenceProfiles(CharacterIntelligenceProfileID),
CONSTRAINT FK_CharacterIntelligenceEvidenceEvents_SceneResult FOREIGN KEY (SceneResultID) REFERENCES dbo.StoryIntelligenceSceneResults(SceneResultID)
);
END;
GO
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'IX_CharacterIntelligenceEvidenceEvents_ProfileScene' AND object_id = OBJECT_ID(N'dbo.CharacterIntelligenceEvidenceEvents'))
CREATE INDEX IX_CharacterIntelligenceEvidenceEvents_ProfileScene ON dbo.CharacterIntelligenceEvidenceEvents(CharacterIntelligenceProfileID, SceneResultID, CreatedUtc);
GO

View File

@ -0,0 +1,98 @@
SET ANSI_NULLS ON;
GO
SET QUOTED_IDENTIFIER ON;
GO
IF COL_LENGTH(N'dbo.StoryIntelligenceRuns', N'KnownCharactersJson') IS NULL
BEGIN
ALTER TABLE dbo.StoryIntelligenceRuns ADD KnownCharactersJson nvarchar(max) NULL;
END;
GO
CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceRun_QueueAdminText
@UserID int,
@ProjectID int = NULL,
@BookID int = NULL,
@ChapterID int = NULL,
@ChapterNumber decimal(9, 2) = NULL,
@SourceType nvarchar(50),
@SourceLabel nvarchar(300),
@SourceText nvarchar(max),
@SourceFileName nvarchar(260) = NULL,
@SourceFileSizeBytes bigint = NULL,
@SourceWordCount int = NULL,
@SourceCharacterCount int = NULL,
@SourceParagraphCount int = NULL,
@SourceChapterCount int = NULL,
@Model nvarchar(100),
@PromptVersionsSummary nvarchar(500),
@KnownCharactersJson nvarchar(max) = NULL
AS
BEGIN
SET NOCOUNT ON;
INSERT dbo.StoryIntelligenceRuns
(
UserID, ProjectID, BookID, ChapterID, ChapterNumber, Status, SourceType, SourceLabel, SourceText,
SourceFileName, SourceFileSizeBytes, SourceWordCount, SourceCharacterCount, SourceParagraphCount,
SourceChapterCount, Model, PromptVersion, PromptVersionsSummary, KnownCharactersJson, StartedUtc,
CurrentStage, CurrentMessage, CompletedScenes, FailedScenes
)
VALUES
(
@UserID, @ProjectID, @BookID, @ChapterID, @ChapterNumber, N'Pending', @SourceType, @SourceLabel, @SourceText,
@SourceFileName, @SourceFileSizeBytes, @SourceWordCount, @SourceCharacterCount, @SourceParagraphCount,
@SourceChapterCount, @Model, @PromptVersionsSummary, @PromptVersionsSummary, @KnownCharactersJson,
SYSUTCDATETIME(), N'Pending', N'Queued for Story Intelligence processing.', 0, 0
);
SELECT CAST(SCOPE_IDENTITY() AS int) AS StoryIntelligenceRunID;
END;
GO
CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceRun_ClaimNextPending
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON;
DECLARE @StoryIntelligenceRunID int;
SELECT TOP (1) @StoryIntelligenceRunID = StoryIntelligenceRunID
FROM dbo.StoryIntelligenceRuns WITH (UPDLOCK, READPAST)
WHERE Status = N'Pending'
ORDER BY CreatedUtc;
IF @StoryIntelligenceRunID IS NULL
BEGIN
SELECT TOP (0)
StoryIntelligenceRunID, UserID, ProjectID, BookID, ChapterID, ChapterNumber, Status, SourceType, SourceLabel,
SourceText, SourceWordCount, SourceCharacterCount, SourceParagraphCount, SourceChapterCount, PromptVersion,
PromptVersionsSummary, KnownCharactersJson, Model, StartedUtc, CompletedUtc, FailureStage, TotalInputTokens,
TotalOutputTokens, TotalTokens, TotalDurationMs, EstimatedCostGBP, EstimatedCostUSD,
ErrorMessage, ErrorDetail, CurrentStage, CurrentMessage, TotalDetectedScenes,
CompletedScenes, FailedScenes, CancellationRequestedUtc, CancelledUtc, CreatedUtc, UpdatedUtc
FROM dbo.StoryIntelligenceRuns;
RETURN;
END;
UPDATE dbo.StoryIntelligenceRuns
SET Status = N'Running',
StartedUtc = SYSUTCDATETIME(),
CurrentStage = N'ChapterStructure',
CurrentMessage = N'Running Chapter Structure analysis.',
UpdatedUtc = SYSUTCDATETIME()
WHERE StoryIntelligenceRunID = @StoryIntelligenceRunID
AND Status = N'Pending';
SELECT
StoryIntelligenceRunID, UserID, ProjectID, BookID, ChapterID, ChapterNumber, Status, SourceType, SourceLabel,
SourceText, SourceWordCount, SourceCharacterCount, SourceParagraphCount, SourceChapterCount, PromptVersion,
PromptVersionsSummary, KnownCharactersJson, Model, StartedUtc, CompletedUtc, FailureStage, TotalInputTokens,
TotalOutputTokens, TotalTokens, TotalDurationMs, EstimatedCostGBP, EstimatedCostUSD,
ErrorMessage, ErrorDetail, CurrentStage, CurrentMessage, TotalDetectedScenes,
CompletedScenes, FailedScenes, CancellationRequestedUtc, CancelledUtc, CreatedUtc, UpdatedUtc
FROM dbo.StoryIntelligenceRuns
WHERE StoryIntelligenceRunID = @StoryIntelligenceRunID;
END;
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 StoryIntelligenceJobProgress? ExistingJob { get; init; }
public bool CanStart { get; init; }
public string AppearancePreference { get; init; } = StoryMemoryAppearancePreferences.Balanced;
}
public sealed class StoryIntelligenceProgressViewModel
@ -138,6 +139,7 @@ public sealed class StoryIntelligenceProgressViewModel
public StoryIntelligenceJobProgress Job { get; init; } = new();
public Guid? BatchID { get; init; }
public Guid? PreviewID { get; init; }
public int? ImportSessionID { get; init; }
public int ProjectID { get; init; }
public int BookID { get; init; }
public string? ProjectName { get; init; }
@ -531,6 +533,7 @@ public sealed class StoryIntelligenceOnboardingChapterViewModel
public int? SourceWordCount { get; init; }
public int? TotalTokens { get; init; }
public long? TotalDurationMs { get; init; }
public DateTime? UpdatedUtc { get; init; }
public decimal? EstimatedCostUSD { get; init; }
public decimal? EstimatedCostGBP { get; init; }
public string? FailureStage { get; init; }
@ -665,6 +668,9 @@ public sealed class ManuscriptScanReviewCharacterViewModel
public int? ExistingCharacterID { get; init; }
public string? ExistingCharacterName { get; init; }
public bool IsExistingCharacterMatch => ExistingCharacterID.HasValue;
public string? AliasTargetTemporaryCharacterKey { get; init; }
public string? AliasTargetName { get; init; }
public bool IsAlias => !string.IsNullOrWhiteSpace(AliasTargetTemporaryCharacterKey);
}
public sealed class ManuscriptScanExistingCharacterOptionViewModel
@ -706,4 +712,5 @@ public sealed class ManuscriptScanCharacterReviewFormItem
public string Name { get; set; } = string.Empty;
public string Category { get; set; } = "PossibleCharacter";
public int? ExistingCharacterID { get; set; }
public string? AliasTargetTemporaryCharacterKey { get; set; }
}

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="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="StoryIntelligenceIllustrationLibrary">Illustration library</a>
</div>
</article>
<article class="project-card">

View File

@ -23,6 +23,16 @@
<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">
<h2>OpenAI configuration</h2>
<div class="row g-3">
@ -59,6 +69,14 @@
</dl>
</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">
<h2>Prompt repository</h2>
<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

@ -13,6 +13,11 @@
var excludedCharacters = Model.CharacterCandidates
.Where(candidate => string.Equals(candidate.Category, "Excluded", StringComparison.OrdinalIgnoreCase))
.ToList();
var detectedAliasTargets = Model.CharacterCandidates
.Where(candidate => !string.Equals(candidate.Category, "Excluded", StringComparison.OrdinalIgnoreCase))
.OrderBy(candidate => candidate.ReviewName)
.ToList();
var visibleCharacterCount = probableCharacters.Count + possibleCharacters.Count + relationshipTitles.Count;
}
<section class="onboarding-shell" aria-labelledby="scan-review-title">
@ -23,9 +28,8 @@
<div class="onboarding-copy">
<p class="eyebrow">Step 7 of 9</p>
<h1 id="scan-review-title">Review Chapters</h1>
<p>Review the chapters PlotDirector found before Story Intelligence begins.</p>
<p>Scene and character scan details are shown for context only in this phase.</p>
<h1 id="scan-review-title">Review Manuscript Scan</h1>
<p>Confirm the chapter structure first, then review the detected character names and aliases before Story Intelligence begins.</p>
</div>
@if (TempData["OnboardingReviewMessage"] is string reviewMessage)
@ -61,8 +65,13 @@
<button class="btn btn-sm btn-outline-secondary" type="reset">Restore scan defaults</button>
</div>
<section class="onboarding-review-grid" aria-label="Scan preview">
<div class="onboarding-review-section">
<section class="onboarding-review-workflow" aria-label="Scan preview" data-scan-review-tabs>
<div class="onboarding-review-tabs" role="tablist" aria-label="Scan review sections">
<button class="is-active" type="button" role="tab" aria-selected="true" data-scan-review-tab="structure">1. Chapters and scenes</button>
<button type="button" role="tab" aria-selected="false" data-scan-review-tab="characters">2. Characters</button>
</div>
<div class="onboarding-review-section" data-scan-review-panel="structure">
<h2>Chapters and scenes</h2>
@if (!Model.Chapters.Any())
{
@ -140,10 +149,19 @@
}
</div>
}
<div class="onboarding-actions onboarding-actions--inline">
<button class="btn btn-primary" type="button" data-scan-review-next="characters">Continue to characters</button>
</div>
</div>
<aside class="onboarding-review-section">
<aside class="onboarding-review-section" data-scan-review-panel="characters" hidden>
<div class="onboarding-review-section-heading">
<div>
<h2>Character candidates</h2>
<p>@visibleCharacterCount.ToString("N0") detected candidate@(visibleCharacterCount == 1 ? string.Empty : "s") before excluded names.</p>
</div>
<button class="btn btn-outline-secondary btn-sm" type="button" data-scan-review-next="structure">Back to chapters</button>
</div>
@if (!probableCharacters.Any() && !possibleCharacters.Any() && !relationshipTitles.Any())
{
<p>No repeated character names were found yet. You can still prepare chapters and add characters later.</p>
@ -155,6 +173,11 @@
Find a candidate
<input class="form-control" type="search" placeholder="Search names or notes" data-character-filter />
</label>
<div class="onboarding-character-pager" data-character-pager hidden>
<button class="btn btn-outline-secondary btn-sm" type="button" data-character-page-prev>Previous</button>
<span data-character-page-label></span>
<button class="btn btn-outline-secondary btn-sm" type="button" data-character-page-next>Next</button>
</div>
<p class="onboarding-empty-state" data-character-empty hidden>No matching candidates.</p>
<div class="onboarding-character-groups">
@foreach (var group in new[]
@ -174,7 +197,7 @@
<ul class="onboarding-character-candidates">
@foreach (var candidate in group.Candidates)
{
<li data-character-name="@candidate.ReviewName" data-character-reason="@candidate.Reason">
<li data-character-name="@candidate.ReviewName" data-character-reason="@candidate.Reason" data-character-page-item>
<input type="hidden" name="Characters[@characterIndex].TemporaryCharacterKey" value="@candidate.TemporaryCharacterKey" />
<input type="hidden" name="Characters[@characterIndex].Category" value="@candidate.Category" />
<label class="onboarding-review-toggle">
@ -196,7 +219,21 @@
}
</select>
</label>
<label class="onboarding-review-field">
Alias of detected character
<select class="form-select" name="Characters[@characterIndex].AliasTargetTemporaryCharacterKey">
<option value="">Not an alias</option>
@foreach (var target in detectedAliasTargets.Where(target => !string.Equals(target.TemporaryCharacterKey, candidate.TemporaryCharacterKey, StringComparison.Ordinal)))
{
<option value="@target.TemporaryCharacterKey" selected="@(string.Equals(candidate.AliasTargetTemporaryCharacterKey, target.TemporaryCharacterKey, StringComparison.Ordinal))">@target.ReviewName</option>
}
</select>
</label>
<span>@candidate.MentionCount mention@(candidate.MentionCount == 1 ? string.Empty : "s") / score @candidate.QualityScore</span>
@if (!string.IsNullOrWhiteSpace(candidate.AliasTargetName))
{
<small>Alias of detected character: @candidate.AliasTargetName</small>
}
@if (!string.IsNullOrWhiteSpace(candidate.ExistingCharacterName))
{
<small>Matches existing character: @candidate.ExistingCharacterName</small>
@ -220,7 +257,7 @@
<ul class="onboarding-character-candidates">
@foreach (var candidate in excludedCharacters)
{
<li data-character-name="@candidate.ReviewName" data-character-reason="@candidate.Reason">
<li data-character-name="@candidate.ReviewName" data-character-reason="@candidate.Reason" data-character-page-item>
<input type="hidden" name="Characters[@characterIndex].TemporaryCharacterKey" value="@candidate.TemporaryCharacterKey" />
<input type="hidden" name="Characters[@characterIndex].Category" value="@candidate.Category" />
<label class="onboarding-review-toggle">
@ -242,7 +279,21 @@
}
</select>
</label>
<label class="onboarding-review-field">
Alias of detected character
<select class="form-select" name="Characters[@characterIndex].AliasTargetTemporaryCharacterKey">
<option value="">Not an alias</option>
@foreach (var target in detectedAliasTargets.Where(target => !string.Equals(target.TemporaryCharacterKey, candidate.TemporaryCharacterKey, StringComparison.Ordinal)))
{
<option value="@target.TemporaryCharacterKey" selected="@(string.Equals(candidate.AliasTargetTemporaryCharacterKey, target.TemporaryCharacterKey, StringComparison.Ordinal))">@target.ReviewName</option>
}
</select>
</label>
<span>@candidate.MentionCount mention@(candidate.MentionCount == 1 ? string.Empty : "s") / score @candidate.QualityScore</span>
@if (!string.IsNullOrWhiteSpace(candidate.AliasTargetName))
{
<small>Alias of detected character: @candidate.AliasTargetName</small>
}
@if (!string.IsNullOrWhiteSpace(candidate.Reason))
{
<small>@candidate.Reason</small>
@ -267,6 +318,33 @@
</section>
<script>
const scanReviewRoot = document.querySelector("[data-scan-review-tabs]");
const showScanPanel = (panelName) => {
if (!scanReviewRoot) {
return;
}
scanReviewRoot.querySelectorAll("[data-scan-review-tab]").forEach((tab) => {
const isActive = tab.dataset.scanReviewTab === panelName;
tab.classList.toggle("is-active", isActive);
tab.setAttribute("aria-selected", String(isActive));
});
scanReviewRoot.querySelectorAll("[data-scan-review-panel]").forEach((panel) => {
panel.hidden = panel.dataset.scanReviewPanel !== panelName;
});
};
scanReviewRoot?.addEventListener("click", (event) => {
const tab = event.target.closest("[data-scan-review-tab]");
if (tab) {
showScanPanel(tab.dataset.scanReviewTab);
}
const next = event.target.closest("[data-scan-review-next]");
if (next) {
showScanPanel(next.dataset.scanReviewNext);
}
});
const reviewTools = document.querySelector("[data-review-tools]");
reviewTools?.addEventListener("click", (event) => {
const button = event.target.closest("[data-bulk-target]");
@ -282,26 +360,79 @@
});
const characterFilter = document.querySelector("[data-character-filter]");
characterFilter?.addEventListener("input", () => {
const query = characterFilter.value.trim().toLowerCase();
let visibleCount = 0;
document.querySelectorAll("[data-character-name]").forEach((item) => {
const text = `${item.dataset.characterName || ""} ${item.dataset.characterReason || ""}`.toLowerCase();
const isVisible = query.length === 0 || text.includes(query);
item.hidden = !isVisible;
if (isVisible) {
visibleCount++;
}
});
const characterPager = document.querySelector("[data-character-pager]");
const characterPageLabel = document.querySelector("[data-character-page-label]");
const characterPagePrev = document.querySelector("[data-character-page-prev]");
const characterPageNext = document.querySelector("[data-character-page-next]");
const characterPageSize = 20;
let characterPage = 1;
const characterItems = () => [...document.querySelectorAll("[data-character-page-item]")];
const matchingCharacterItems = () => {
const query = characterFilter?.value.trim().toLowerCase() || "";
return characterItems().filter((item) => {
const text = `${item.dataset.characterName || ""} ${item.dataset.characterReason || ""}`.toLowerCase();
return query.length === 0 || text.includes(query);
});
};
const refreshCharacterSections = () => {
document.querySelectorAll(".onboarding-character-groups section").forEach((section) => {
const hasVisibleItems = [...section.querySelectorAll("[data-character-name]")].some((item) => !item.hidden);
const hasVisibleItems = [...section.querySelectorAll("[data-character-page-item]")].some((item) => !item.hidden);
section.hidden = !hasVisibleItems;
});
};
const renderCharacterPage = () => {
const matches = matchingCharacterItems();
const pageCount = Math.max(1, Math.ceil(matches.length / characterPageSize));
characterPage = Math.min(Math.max(characterPage, 1), pageCount);
const start = (characterPage - 1) * characterPageSize;
const visible = new Set(matches.slice(start, start + characterPageSize));
characterItems().forEach((item) => {
item.hidden = !visible.has(item);
});
if (characterPager) {
characterPager.hidden = matches.length <= characterPageSize;
}
if (characterPageLabel) {
characterPageLabel.textContent = matches.length === 0
? "No candidates"
: `Page ${characterPage} of ${pageCount} / ${matches.length.toLocaleString()} candidate${matches.length === 1 ? "" : "s"}`;
}
if (characterPagePrev) {
characterPagePrev.disabled = characterPage <= 1;
}
if (characterPageNext) {
characterPageNext.disabled = characterPage >= pageCount;
}
refreshCharacterSections();
const emptyState = document.querySelector("[data-character-empty]");
if (emptyState) {
emptyState.hidden = visibleCount > 0;
emptyState.hidden = matches.length > 0;
}
};
characterFilter?.addEventListener("input", () => {
characterPage = 1;
renderCharacterPage();
});
characterPagePrev?.addEventListener("click", () => {
characterPage -= 1;
renderCharacterPage();
});
characterPageNext?.addEventListener("click", () => {
characterPage += 1;
renderCharacterPage();
});
renderCharacterPage();
</script>

View File

@ -4,12 +4,12 @@
}
<section class="onboarding-shell" aria-labelledby="story-intelligence-title">
<div class="onboarding-panel">
<partial name="_StoryIntelligencePipelineHeader" model="@(new StoryIntelligencePipelineHeaderViewModel { CurrentStage = "Analyse Manuscript" })" />
<div class="onboarding-panel onboarding-analysis-panel">
<partial name="_OnboardingJourneyHeader" model="@(new OnboardingJourneyHeaderViewModel { CurrentStep = 8 })" />
<div class="onboarding-copy">
<p class="eyebrow">Analyse manuscript</p>
<p class="eyebrow">Step 8 of 9</p>
<h1 id="story-intelligence-title">Analyse Manuscript</h1>
<p>PlotDirector will analyse approved chapters in the background and prepare scene suggestions for review.</p>
<p>Set the analysis context, then PlotDirector will read the approved chapters and prepare scenes, characters, locations, assets, relationships and knowledge for review.</p>
</div>
@if (TempData["OnboardingStoryIntelligenceError"] is string error)
@ -59,14 +59,9 @@
</div>
</div>
<section class="onboarding-form-section">
<p>Nothing will be created yet. PlotDirector will prepare scene suggestions for review.</p>
<p>Scenes are reviewed first. Character review follows after scenes are created, with locations, assets, relationships and knowledge shown as upcoming Story Intelligence stages.</p>
</section>
@if (!string.IsNullOrWhiteSpace(Model.Message))
{
<section class="onboarding-form-section">
<section class="onboarding-form-section onboarding-form-section--quiet">
<p>@Model.Message</p>
@if (Model.MissingChapterTextCount > 0)
{
@ -75,14 +70,88 @@
</section>
}
<form asp-action="StartStoryIntelligence" method="post" class="onboarding-analysis-setup">
<div class="onboarding-analysis-setup__header">
<div>
<p class="eyebrow">Analysis setup</p>
<h2>Story and portrait context</h2>
<p>Explicit manuscript evidence always wins. These choices guide unresolved generated portraits and broad story context.</p>
</div>
<strong>Required before analysis</strong>
</div>
<div class="onboarding-analysis-controls">
<label>
Appearance baseline
<select class="form-select" 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>
<label>
Primary character age group
<select class="form-select" 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-select" 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-select" 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>
</div>
<div class="onboarding-analysis-flow">
<div><span>1</span><strong>Analyse chapters</strong></div>
<div><span>2</span><strong>Review scenes</strong></div>
<div><span>3</span><strong>Review story entities</strong></div>
<div><span>4</span><strong>Build database</strong></div>
</div>
<div class="onboarding-actions">
<a class="btn btn-outline-secondary" asp-action="ScanReview" asp-route-previewId="@Model.PreviewID">Back to chapter review</a>
<a class="btn btn-outline-secondary" asp-action="ScanReview" asp-route-previewId="@Model.PreviewID">Back to scan review</a>
<button class="btn btn-primary" type="submit" disabled="@(!Model.CanStart)">Analyse manuscript</button>
</div>
</form>
<div class="onboarding-actions onboarding-actions--secondary">
<form asp-action="SkipStoryIntelligence" method="post">
<button class="btn btn-outline-secondary" type="submit">Skip for now</button>
</form>
<form asp-action="StartStoryIntelligence" method="post">
<button class="btn btn-primary" type="submit" disabled="@(!Model.CanStart)">Analyse manuscript</button>
</form>
</div>
}
</div>

View File

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

View File

@ -222,6 +222,15 @@
flex-wrap: wrap;
}
.onboarding-actions--inline {
margin-top: .75rem;
}
.onboarding-actions--secondary {
justify-content: flex-start;
margin-top: .9rem;
}
.onboarding-form-section {
display: grid;
gap: .75rem;
@ -232,6 +241,10 @@
background: rgba(255, 255, 255, .45);
}
.onboarding-form-section--quiet {
background: rgba(255, 255, 255, .58);
}
.onboarding-form-section h2 {
margin: 0;
font-size: 1.05rem;
@ -464,10 +477,46 @@
gap: 1rem;
}
.onboarding-review-workflow {
display: grid;
gap: 1rem;
}
.onboarding-review-tabs {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: .35rem;
border: 1px solid rgba(31, 42, 68, .14);
border-radius: 8px;
padding: .35rem;
background: rgba(255, 255, 255, .58);
}
.onboarding-review-tabs button {
min-height: 2.7rem;
border: 1px solid transparent;
border-radius: 6px;
background: transparent;
color: var(--bs-secondary-color);
font: inherit;
font-weight: 800;
}
.onboarding-review-tabs button.is-active {
border-color: rgba(47, 111, 99, .34);
background: rgba(47, 111, 99, .11);
color: var(--bs-body-color);
box-shadow: inset 0 0 0 1px rgba(47, 111, 99, .08);
}
.onboarding-review-section {
display: grid;
align-content: start;
gap: .8rem;
border: 1px solid rgba(31, 42, 68, .1);
border-radius: 8px;
padding: 1rem;
background: rgba(255, 255, 255, .42);
}
.onboarding-review-section h2 {
@ -475,6 +524,18 @@
font-size: 1.2rem;
}
.onboarding-review-section-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}
.onboarding-review-section-heading p {
margin: .25rem 0 0;
color: var(--bs-secondary-color);
}
.onboarding-character-filter {
display: grid;
gap: .35rem;
@ -1285,6 +1346,23 @@ summary.story-review-chapter-heading {
font-size: 1rem;
}
.onboarding-character-pager {
display: flex;
align-items: center;
justify-content: space-between;
gap: .75rem;
border: 1px solid rgba(31, 42, 68, .1);
border-radius: 8px;
padding: .6rem;
background: rgba(255, 255, 255, .62);
}
.onboarding-character-pager span {
color: var(--bs-secondary-color);
font-weight: 800;
text-align: center;
}
.onboarding-excluded-candidates {
border-top: 1px solid rgba(31, 42, 68, .12);
padding-top: .65rem;
@ -1419,6 +1497,105 @@ summary.story-review-chapter-heading {
background: rgba(47, 111, 99, .07);
}
.onboarding-analysis-panel {
width: min(1040px, 100%);
}
.onboarding-analysis-setup {
display: grid;
gap: 1rem;
margin-top: 1rem;
border: 1px solid rgba(47, 111, 99, .28);
border-radius: 8px;
padding: clamp(1rem, 2vw, 1.35rem);
background: linear-gradient(135deg, rgba(47, 111, 99, .1), rgba(255, 255, 255, .72) 52%, rgba(182, 138, 70, .12));
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .54);
}
.onboarding-analysis-setup__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}
.onboarding-analysis-setup__header h2 {
margin: 0 0 .35rem;
font-size: 1.35rem;
}
.onboarding-analysis-setup__header p:not(.eyebrow) {
margin: 0;
color: var(--bs-secondary-color);
}
.onboarding-analysis-setup__header > strong {
flex: 0 0 auto;
border: 1px solid rgba(47, 111, 99, .22);
border-radius: 999px;
padding: .35rem .7rem;
background: rgba(255, 255, 255, .72);
color: #2f6f63;
font-size: .82rem;
white-space: nowrap;
}
.onboarding-analysis-controls {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: .8rem;
}
.onboarding-analysis-controls label {
display: grid;
gap: .35rem;
min-width: 0;
color: var(--bs-body-color);
font-weight: 800;
}
.onboarding-analysis-controls select {
min-height: 3rem;
border-color: rgba(31, 42, 68, .22);
background-color: #fff;
font-weight: 700;
}
.onboarding-analysis-flow {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: .65rem;
}
.onboarding-analysis-flow div {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
gap: .55rem;
min-width: 0;
border: 1px solid rgba(31, 42, 68, .1);
border-radius: 8px;
padding: .7rem;
background: rgba(255, 255, 255, .64);
}
.onboarding-analysis-flow span {
display: grid;
place-items: center;
width: 1.45rem;
aspect-ratio: 1;
border-radius: 50%;
background: #2f6f63;
color: #fff;
font-size: .8rem;
font-weight: 900;
}
.onboarding-analysis-flow strong {
min-width: 0;
font-size: .9rem;
}
.story-intelligence-dashboard-card {
display: flex;
align-items: center;
@ -1585,6 +1762,8 @@ summary.story-review-chapter-heading {
.onboarding-dashboard-nudge,
.onboarding-companion-card,
.onboarding-review-grid,
.onboarding-analysis-controls,
.onboarding-analysis-flow,
.onboarding-summary div,
.story-intelligence-dashboard-card {
align-items: stretch;
@ -1592,6 +1771,15 @@ summary.story-review-chapter-heading {
flex-direction: column;
}
.onboarding-analysis-setup__header,
.onboarding-review-section-heading {
flex-direction: column;
}
.onboarding-review-tabs {
grid-template-columns: 1fr;
}
.onboarding-story-facts {
grid-template-columns: 1fr;
}

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

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