SET ANSI_NULLS ON; GO SET QUOTED_IDENTIFIER ON; GO IF OBJECT_ID(N'dbo.CharacterEnrichmentRuns', N'U') IS NULL BEGIN CREATE TABLE dbo.CharacterEnrichmentRuns ( CharacterEnrichmentRunID int IDENTITY(1,1) NOT NULL CONSTRAINT PK_CharacterEnrichmentRuns PRIMARY KEY, ProjectID int NOT NULL, BookID int NOT NULL, UserID int NOT NULL, StoryIntelligenceBookPipelineID int NULL, CharacterResolutionVersion nvarchar(120) NOT NULL, Status nvarchar(30) NOT NULL CONSTRAINT DF_CharacterEnrichmentRuns_Status DEFAULT N'Queued', TotalCharacters int NOT NULL CONSTRAINT DF_CharacterEnrichmentRuns_TotalCharacters DEFAULT 0, ProcessedCharacters int NOT NULL CONSTRAINT DF_CharacterEnrichmentRuns_ProcessedCharacters DEFAULT 0, CurrentStage nvarchar(120) NULL, CurrentMessage nvarchar(300) NULL, QueuedUtc datetime2 NOT NULL CONSTRAINT DF_CharacterEnrichmentRuns_QueuedUtc DEFAULT SYSUTCDATETIME(), StartedUtc datetime2 NULL, CompletedUtc datetime2 NULL, FailedUtc datetime2 NULL, AttemptCount int NOT NULL CONSTRAINT DF_CharacterEnrichmentRuns_AttemptCount DEFAULT 0, Model nvarchar(100) NULL, InputTokens int NULL, OutputTokens int NULL, TotalTokens int NULL, DurationMs bigint NULL, ErrorMessage nvarchar(max) NULL, ErrorDetail nvarchar(max) NULL, IsCurrent bit NOT NULL CONSTRAINT DF_CharacterEnrichmentRuns_IsCurrent DEFAULT 1, UpdatedUtc datetime2 NOT NULL CONSTRAINT DF_CharacterEnrichmentRuns_UpdatedUtc DEFAULT SYSUTCDATETIME(), CONSTRAINT FK_CharacterEnrichmentRuns_Projects FOREIGN KEY (ProjectID) REFERENCES dbo.Projects(ProjectID), CONSTRAINT FK_CharacterEnrichmentRuns_Books FOREIGN KEY (BookID) REFERENCES dbo.Books(BookID) ON DELETE CASCADE, CONSTRAINT FK_CharacterEnrichmentRuns_AppUser FOREIGN KEY (UserID) REFERENCES dbo.AppUser(UserID), CONSTRAINT FK_CharacterEnrichmentRuns_Pipeline FOREIGN KEY (StoryIntelligenceBookPipelineID) REFERENCES dbo.StoryIntelligenceBookPipelines(StoryIntelligenceBookPipelineID) ); END; GO IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'IX_CharacterEnrichmentRuns_Queue' AND object_id = OBJECT_ID(N'dbo.CharacterEnrichmentRuns')) CREATE INDEX IX_CharacterEnrichmentRuns_Queue ON dbo.CharacterEnrichmentRuns(Status, QueuedUtc) INCLUDE (BookID, UserID); GO IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'UX_CharacterEnrichmentRuns_CurrentVersion' AND object_id = OBJECT_ID(N'dbo.CharacterEnrichmentRuns')) CREATE UNIQUE INDEX UX_CharacterEnrichmentRuns_CurrentVersion ON dbo.CharacterEnrichmentRuns(BookID, CharacterResolutionVersion) WHERE IsCurrent = 1 AND Status IN (N'Queued', N'Running', N'Completed'); GO IF OBJECT_ID(N'dbo.CharacterEnrichmentEvidence', N'U') IS NULL BEGIN CREATE TABLE dbo.CharacterEnrichmentEvidence ( CharacterEnrichmentEvidenceID int IDENTITY(1,1) NOT NULL CONSTRAINT PK_CharacterEnrichmentEvidence PRIMARY KEY, CharacterEnrichmentRunID int NOT NULL, CharacterID int NOT NULL, SceneID int NULL, FieldName nvarchar(80) NOT NULL, Value nvarchar(500) NULL, Evidence nvarchar(700) NULL, Confidence decimal(5,4) NOT NULL CONSTRAINT DF_CharacterEnrichmentEvidence_Confidence DEFAULT 0, Source nvarchar(80) NOT NULL CONSTRAINT DF_CharacterEnrichmentEvidence_Source DEFAULT N'Character enrichment', CreatedUtc datetime2 NOT NULL CONSTRAINT DF_CharacterEnrichmentEvidence_CreatedUtc DEFAULT SYSUTCDATETIME(), CONSTRAINT FK_CharacterEnrichmentEvidence_Run FOREIGN KEY (CharacterEnrichmentRunID) REFERENCES dbo.CharacterEnrichmentRuns(CharacterEnrichmentRunID) ON DELETE CASCADE, CONSTRAINT FK_CharacterEnrichmentEvidence_Character FOREIGN KEY (CharacterID) REFERENCES dbo.Characters(CharacterID), CONSTRAINT FK_CharacterEnrichmentEvidence_Scene FOREIGN KEY (SceneID) REFERENCES dbo.Scenes(SceneID) ); END; GO IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'IX_CharacterEnrichmentEvidence_Character' AND object_id = OBJECT_ID(N'dbo.CharacterEnrichmentEvidence')) CREATE INDEX IX_CharacterEnrichmentEvidence_Character ON dbo.CharacterEnrichmentEvidence(CharacterID, CharacterEnrichmentRunID, FieldName); GO CREATE OR ALTER PROCEDURE dbo.CharacterEnrichmentRun_Queue @ProjectID int, @BookID int, @UserID int, @StoryIntelligenceBookPipelineID int = NULL, @CharacterResolutionVersion nvarchar(120) AS BEGIN SET NOCOUNT ON; SET XACT_ABORT ON; DECLARE @ExistingID int, @TotalCharacters int; SELECT @TotalCharacters = COUNT(DISTINCT c.CharacterID) FROM dbo.Characters c INNER JOIN dbo.SceneCharacters sc ON sc.CharacterID = c.CharacterID INNER JOIN dbo.Scenes s ON s.SceneID = sc.SceneID AND s.IsArchived = 0 INNER JOIN dbo.Chapters ch ON ch.ChapterID = s.ChapterID AND ch.IsArchived = 0 INNER JOIN dbo.SceneManuscriptSources sms ON sms.SceneID = s.SceneID WHERE c.ProjectID = @ProjectID AND c.IsArchived = 0 AND ch.BookID = @BookID; SELECT TOP (1) @ExistingID = CharacterEnrichmentRunID FROM dbo.CharacterEnrichmentRuns WHERE BookID = @BookID AND CharacterResolutionVersion = @CharacterResolutionVersion AND IsCurrent = 1 AND Status IN (N'Queued', N'Running', N'Completed') ORDER BY CharacterEnrichmentRunID DESC; IF @ExistingID IS NULL BEGIN INSERT dbo.CharacterEnrichmentRuns (ProjectID, BookID, UserID, StoryIntelligenceBookPipelineID, CharacterResolutionVersion, Status, TotalCharacters, CurrentStage, CurrentMessage) VALUES (@ProjectID, @BookID, @UserID, @StoryIntelligenceBookPipelineID, @CharacterResolutionVersion, N'Queued', @TotalCharacters, N'Preparing character analysis', N'Preparing character analysis...'); SET @ExistingID = CAST(SCOPE_IDENTITY() AS int); END; SELECT * FROM dbo.CharacterEnrichmentRuns WHERE CharacterEnrichmentRunID = @ExistingID; END; GO CREATE OR ALTER PROCEDURE dbo.CharacterEnrichmentRun_Retry @BookID int, @UserID int AS BEGIN SET NOCOUNT ON; SET XACT_ABORT ON; DECLARE @PreviousID int, @ProjectID int, @PipelineID int, @Version nvarchar(120); SELECT TOP (1) @PreviousID = CharacterEnrichmentRunID, @ProjectID = ProjectID, @PipelineID = StoryIntelligenceBookPipelineID, @Version = CharacterResolutionVersion FROM dbo.CharacterEnrichmentRuns WHERE BookID = @BookID AND UserID = @UserID AND IsCurrent = 1 AND Status = N'Failed' ORDER BY CharacterEnrichmentRunID DESC; IF @PreviousID IS NULL BEGIN SELECT TOP (0) * FROM dbo.CharacterEnrichmentRuns; RETURN; END; UPDATE dbo.CharacterEnrichmentRuns SET IsCurrent = 0, Status = N'Superseded', UpdatedUtc = SYSUTCDATETIME() WHERE CharacterEnrichmentRunID = @PreviousID; EXEC dbo.CharacterEnrichmentRun_Queue @ProjectID = @ProjectID, @BookID = @BookID, @UserID = @UserID, @StoryIntelligenceBookPipelineID = @PipelineID, @CharacterResolutionVersion = @Version; END; GO CREATE OR ALTER PROCEDURE dbo.CharacterEnrichmentRun_ClaimNext @LeaseMinutes int = 90 AS BEGIN SET NOCOUNT ON; SET XACT_ABORT ON; DECLARE @RunID int; SELECT TOP (1) @RunID = CharacterEnrichmentRunID FROM dbo.CharacterEnrichmentRuns WITH (UPDLOCK, READPAST) WHERE IsCurrent = 1 AND ( Status = N'Queued' OR (Status = N'Running' AND StartedUtc < DATEADD(minute, -ABS(@LeaseMinutes), SYSUTCDATETIME())) ) ORDER BY QueuedUtc, CharacterEnrichmentRunID; IF @RunID IS NULL BEGIN SELECT TOP (0) * FROM dbo.CharacterEnrichmentRuns; RETURN; END; UPDATE dbo.CharacterEnrichmentRuns SET Status = N'Running', StartedUtc = COALESCE(StartedUtc, SYSUTCDATETIME()), AttemptCount = AttemptCount + 1, CurrentStage = N'Preparing character analysis', CurrentMessage = N'Preparing character analysis...', UpdatedUtc = SYSUTCDATETIME() WHERE CharacterEnrichmentRunID = @RunID; SELECT * FROM dbo.CharacterEnrichmentRuns WHERE CharacterEnrichmentRunID = @RunID; END; GO CREATE OR ALTER PROCEDURE dbo.CharacterEnrichmentRun_GetCurrentByBook @BookID int AS BEGIN SET NOCOUNT ON; SELECT TOP (1) * FROM dbo.CharacterEnrichmentRuns WHERE BookID = @BookID AND IsCurrent = 1 ORDER BY CharacterEnrichmentRunID DESC; END; GO CREATE OR ALTER PROCEDURE dbo.CharacterEnrichmentRun_UpdateProgress @CharacterEnrichmentRunID int, @TotalCharacters int, @ProcessedCharacters int, @CurrentStage nvarchar(120), @CurrentMessage nvarchar(300) AS BEGIN SET NOCOUNT ON; UPDATE dbo.CharacterEnrichmentRuns SET TotalCharacters = @TotalCharacters, ProcessedCharacters = @ProcessedCharacters, CurrentStage = @CurrentStage, CurrentMessage = @CurrentMessage, UpdatedUtc = SYSUTCDATETIME() WHERE CharacterEnrichmentRunID = @CharacterEnrichmentRunID; END; GO CREATE OR ALTER PROCEDURE dbo.CharacterEnrichment_ContextGet @ProjectID int, @BookID int AS BEGIN SET NOCOUNT ON; SELECT BookTitle = CASE WHEN NULLIF(LTRIM(RTRIM(b.Subtitle)), N'') IS NULL THEN b.BookTitle ELSE CONCAT(b.BookTitle, N': ', b.Subtitle) END, b.StoryEra, SeriesStartDate = CAST(NULL AS date) FROM dbo.Books b WHERE b.BookID = @BookID AND b.ProjectID = @ProjectID AND b.IsArchived = 0; SELECT DISTINCT c.CharacterID, c.CharacterName, c.BirthDate, c.AgeAtSeriesStart, c.Height, c.EyeColour, c.DefaultDescription FROM dbo.Characters c INNER JOIN dbo.SceneCharacters sc ON sc.CharacterID = c.CharacterID INNER JOIN dbo.Scenes s ON s.SceneID = sc.SceneID AND s.IsArchived = 0 INNER JOIN dbo.Chapters ch ON ch.ChapterID = s.ChapterID AND ch.IsArchived = 0 INNER JOIN dbo.SceneManuscriptSources sms ON sms.SceneID = s.SceneID WHERE c.ProjectID = @ProjectID AND c.IsArchived = 0 AND ch.BookID = @BookID ORDER BY c.CharacterName; SELECT alias.CharacterID, alias.Alias FROM dbo.CharacterAliases alias INNER JOIN dbo.Characters c ON c.CharacterID = alias.CharacterID WHERE c.ProjectID = @ProjectID AND c.IsArchived = 0 ORDER BY alias.CharacterID, alias.SortOrder, alias.Alias; SELECT sc.CharacterID, s.SceneID, ch.ChapterID, ch.ChapterNumber, ch.ChapterTitle, s.SceneNumber, s.SceneTitle, sms.SourceText FROM dbo.SceneCharacters sc INNER JOIN dbo.Characters c ON c.CharacterID = sc.CharacterID AND c.ProjectID = @ProjectID AND c.IsArchived = 0 INNER JOIN dbo.Scenes s ON s.SceneID = sc.SceneID AND s.IsArchived = 0 INNER JOIN dbo.Chapters ch ON ch.ChapterID = s.ChapterID AND ch.BookID = @BookID AND ch.IsArchived = 0 INNER JOIN dbo.SceneManuscriptSources sms ON sms.SceneID = s.SceneID ORDER BY sc.CharacterID, ch.SortOrder, ch.ChapterNumber, s.SortOrder, s.SceneNumber, s.SceneID; END; GO CREATE OR ALTER PROCEDURE dbo.CharacterEnrichmentRun_Complete @CharacterEnrichmentRunID int, @Model nvarchar(100), @InputTokens int = NULL, @OutputTokens int = NULL, @TotalTokens int = NULL, @DurationMs bigint = NULL, @ResultsJson nvarchar(max) AS BEGIN SET NOCOUNT ON; SET XACT_ABORT ON; DECLARE @ProjectID int, @BookID int, @PipelineID int; SELECT @ProjectID = ProjectID, @BookID = BookID, @PipelineID = StoryIntelligenceBookPipelineID FROM dbo.CharacterEnrichmentRuns WHERE CharacterEnrichmentRunID = @CharacterEnrichmentRunID; DECLARE @Results TABLE ( CharacterID int NOT NULL PRIMARY KEY, Summary nvarchar(max) NULL, Confidence decimal(5,4) NOT NULL, DateOfBirthValue nvarchar(120) NULL, DateOfBirthConfidence decimal(5,4) NULL, DateOfBirthEvidence nvarchar(700) NULL, DateOfBirthSceneID int NULL, AgeValue nvarchar(120) NULL, AgeConfidence decimal(5,4) NULL, AgeEvidence nvarchar(700) NULL, AgeSceneID int NULL, HeightValue nvarchar(120) NULL, HeightConfidence decimal(5,4) NULL, HeightEvidence nvarchar(700) NULL, HeightSceneID int NULL, EyeColourValue nvarchar(120) NULL, EyeColourConfidence decimal(5,4) NULL, EyeColourEvidence nvarchar(700) NULL, EyeColourSceneID int NULL ); INSERT @Results SELECT CharacterID, NULLIF(LTRIM(RTRIM(Summary)), N''), Confidence, NULLIF(LTRIM(RTRIM(DateOfBirthValue)), N''), DateOfBirthConfidence, NULLIF(LTRIM(RTRIM(DateOfBirthEvidence)), N''), DateOfBirthSceneID, NULLIF(LTRIM(RTRIM(AgeValue)), N''), AgeConfidence, NULLIF(LTRIM(RTRIM(AgeEvidence)), N''), AgeSceneID, NULLIF(LTRIM(RTRIM(HeightValue)), N''), HeightConfidence, NULLIF(LTRIM(RTRIM(HeightEvidence)), N''), HeightSceneID, NULLIF(LTRIM(RTRIM(EyeColourValue)), N''), EyeColourConfidence, NULLIF(LTRIM(RTRIM(EyeColourEvidence)), N''), EyeColourSceneID FROM OPENJSON(@ResultsJson) WITH ( CharacterID int '$.characterID', Summary nvarchar(max) '$.summary', Confidence decimal(5,4) '$.confidence', DateOfBirthValue nvarchar(120) '$.dateOfBirth.value', DateOfBirthConfidence decimal(5,4) '$.dateOfBirth.confidence', DateOfBirthEvidence nvarchar(700) '$.dateOfBirth.evidence', DateOfBirthSceneID int '$.dateOfBirth.sceneID', AgeValue nvarchar(120) '$.ageAtStartOfSeries.value', AgeConfidence decimal(5,4) '$.ageAtStartOfSeries.confidence', AgeEvidence nvarchar(700) '$.ageAtStartOfSeries.evidence', AgeSceneID int '$.ageAtStartOfSeries.sceneID', HeightValue nvarchar(120) '$.height.value', HeightConfidence decimal(5,4) '$.height.confidence', HeightEvidence nvarchar(700) '$.height.evidence', HeightSceneID int '$.height.sceneID', EyeColourValue nvarchar(120) '$.eyeColour.value', EyeColourConfidence decimal(5,4) '$.eyeColour.confidence', EyeColourEvidence nvarchar(700) '$.eyeColour.evidence', EyeColourSceneID int '$.eyeColour.sceneID' ) WHERE CharacterID IS NOT NULL; UPDATE c SET DefaultDescription = CASE WHEN NULLIF(LTRIM(RTRIM(c.DefaultDescription)), N'') IS NULL THEN r.Summary ELSE c.DefaultDescription END, BirthDate = CASE WHEN c.BirthDate IS NULL THEN TRY_CONVERT(date, r.DateOfBirthValue) ELSE c.BirthDate END, AgeAtSeriesStart = CASE WHEN c.AgeAtSeriesStart IS NULL THEN TRY_CONVERT(int, r.AgeValue) ELSE c.AgeAtSeriesStart END, Height = CASE WHEN NULLIF(LTRIM(RTRIM(c.Height)), N'') IS NULL THEN r.HeightValue ELSE c.Height END, EyeColour = CASE WHEN NULLIF(LTRIM(RTRIM(c.EyeColour)), N'') IS NULL THEN r.EyeColourValue ELSE c.EyeColour END, UpdatedDate = SYSUTCDATETIME() FROM dbo.Characters c INNER JOIN @Results r ON r.CharacterID = c.CharacterID WHERE c.ProjectID = @ProjectID; MERGE dbo.CharacterIntelligenceProfiles WITH (HOLDLOCK) AS target USING ( SELECT ImportSessionID = @PipelineID, ProjectID = @ProjectID, BookID = @BookID, CanonicalIdentityKey = CONCAT(N'character-enrichment-', r.CharacterID), CanonicalName = c.CharacterName, ProfileJson = (SELECT r.* FOR JSON PATH, WITHOUT_ARRAY_WRAPPER), Confidence = r.Confidence, NarrativeImportance = CAST(NULL AS nvarchar(60)) FROM @Results r INNER JOIN dbo.Characters c ON c.CharacterID = r.CharacterID WHERE @PipelineID IS NOT NULL ) AS source ON target.ImportSessionID = source.ImportSessionID AND target.CanonicalIdentityKey = source.CanonicalIdentityKey WHEN MATCHED THEN UPDATE SET CanonicalName = source.CanonicalName, ProfileJson = source.ProfileJson, Confidence = source.Confidence, EvidenceVersion = N'CharacterEnrichmentV1', UpdatedUtc = SYSUTCDATETIME() WHEN NOT MATCHED THEN INSERT (ImportSessionID, ProjectID, BookID, CanonicalIdentityKey, CanonicalName, ProfileJson, Confidence, NarrativeImportance, IsRecurring, EvidenceVersion, LastUpdatedSceneResultID) VALUES (source.ImportSessionID, source.ProjectID, source.BookID, source.CanonicalIdentityKey, source.CanonicalName, source.ProfileJson, source.Confidence, source.NarrativeImportance, 1, N'CharacterEnrichmentV1', NULL); INSERT dbo.CharacterEnrichmentEvidence (CharacterEnrichmentRunID, CharacterID, SceneID, FieldName, Value, Evidence, Confidence) SELECT @CharacterEnrichmentRunID, CharacterID, NULL, N'Summary', Summary, NULL, Confidence FROM @Results WHERE Summary IS NOT NULL UNION ALL SELECT @CharacterEnrichmentRunID, CharacterID, DateOfBirthSceneID, N'DateOfBirth', DateOfBirthValue, DateOfBirthEvidence, COALESCE(DateOfBirthConfidence, 0) FROM @Results WHERE DateOfBirthValue IS NOT NULL AND DateOfBirthSceneID IS NOT NULL UNION ALL SELECT @CharacterEnrichmentRunID, CharacterID, AgeSceneID, N'AgeAtStartOfSeries', AgeValue, AgeEvidence, COALESCE(AgeConfidence, 0) FROM @Results WHERE AgeValue IS NOT NULL AND AgeSceneID IS NOT NULL UNION ALL SELECT @CharacterEnrichmentRunID, CharacterID, HeightSceneID, N'Height', HeightValue, HeightEvidence, COALESCE(HeightConfidence, 0) FROM @Results WHERE HeightValue IS NOT NULL AND HeightSceneID IS NOT NULL UNION ALL SELECT @CharacterEnrichmentRunID, CharacterID, EyeColourSceneID, N'EyeColour', EyeColourValue, EyeColourEvidence, COALESCE(EyeColourConfidence, 0) FROM @Results WHERE EyeColourValue IS NOT NULL AND EyeColourSceneID IS NOT NULL; UPDATE dbo.CharacterEnrichmentRuns SET Status = N'Completed', ProcessedCharacters = TotalCharacters, CurrentStage = N'Complete', CurrentMessage = N'Character summaries are ready.', CompletedUtc = SYSUTCDATETIME(), Model = @Model, InputTokens = @InputTokens, OutputTokens = @OutputTokens, TotalTokens = @TotalTokens, DurationMs = @DurationMs, ErrorMessage = NULL, ErrorDetail = NULL, UpdatedUtc = SYSUTCDATETIME() WHERE CharacterEnrichmentRunID = @CharacterEnrichmentRunID; END; GO CREATE OR ALTER PROCEDURE dbo.CharacterEnrichmentRun_Fail @CharacterEnrichmentRunID int, @ErrorMessage nvarchar(max), @ErrorDetail nvarchar(max) = NULL, @DurationMs bigint = NULL AS BEGIN SET NOCOUNT ON; UPDATE dbo.CharacterEnrichmentRuns SET Status = N'Failed', FailedUtc = SYSUTCDATETIME(), CurrentStage = N'Failed', CurrentMessage = N'Character enrichment failed.', ErrorMessage = @ErrorMessage, ErrorDetail = @ErrorDetail, DurationMs = @DurationMs, UpdatedUtc = SYSUTCDATETIME() WHERE CharacterEnrichmentRunID = @CharacterEnrichmentRunID; END; GO