using System.Data; using Dapper; using PlotLine.Models; namespace PlotLine.Data; public interface IStoryIntelligenceResultRepository { Task SaveAsync(StoryIntelligenceRunSaveRequest request); Task QueueAdminTextAsync(StoryIntelligenceRunQueueRequest request); Task ClaimNextPendingAsync(); Task UpdateProgressAsync( int runId, string? status = null, string? currentStage = null, string? currentMessage = null, int? totalDetectedScenes = null, int? completedScenes = null, int? failedScenes = null, int? totalInputTokens = null, int? totalOutputTokens = null, int? totalTokens = null, long? totalDurationMs = null, decimal? estimatedCostGBP = null, decimal? estimatedCostUSD = null); Task CompleteRunAsync(int runId, string status, int? totalInputTokens, int? totalOutputTokens, int? totalTokens, long? totalDurationMs, decimal? estimatedCostGBP, decimal? estimatedCostUSD); Task FailRunAsync(int runId, string failureStage, string errorMessage, string? errorDetail, long? totalDurationMs); Task RequestCancelAsync(int runId); Task CancelRunAsync(int runId, long? totalDurationMs); Task SaveChapterResultAsync(int runId, StoryIntelligenceChapterResultSaveRequest chapter); Task SaveSceneResultAsync(int runId, int? chapterResultId, StoryIntelligenceSceneResultSaveRequest scene); Task> ListRunsAsync(); Task> ListRunsByBookForUserAsync(int bookId, int userId); Task GetRunAsync(int runId); Task GetChapterResultAsync(int runId); Task> ListSceneResultsAsync(int runId); Task> ListChapterScenesAsync(int chapterId); Task GetImportCommitAsync(int runId); Task GetChapterImportReadinessAsync(int chapterId); Task CommitImportAsync(StoryIntelligenceImportCommitRequest request); } public sealed class StoryIntelligenceResultRepository(ISqlConnectionFactory connectionFactory) : IStoryIntelligenceResultRepository { private static readonly SemaphoreSlim QueueSupportLock = new(1, 1); private static bool queueSupportEnsured; public async Task SaveAsync(StoryIntelligenceRunSaveRequest request) { using var connection = connectionFactory.CreateConnection(); connection.Open(); using var transaction = connection.BeginTransaction(); try { var runId = await connection.QuerySingleAsync( "dbo.StoryIntelligenceRun_Save", new { request.UserID, request.ProjectID, request.BookID, request.Status, request.SourceType, request.SourceFileName, request.SourceFileSizeBytes, request.SourceWordCount, request.SourceCharacterCount, request.SourceChapterCount, request.SourceDetectedImagesCount, request.SourceDetectedTablesCount, request.SourceDetectedFootnotesCount, request.SourceDetectedCommentsCount, request.PromptVersion, request.Model, request.StartedUtc, request.CompletedUtc, request.FailureStage, request.TotalInputTokens, request.TotalOutputTokens, request.TotalTokens, request.TotalDurationMs, request.EstimatedCostGBP, request.EstimatedCostUSD, request.PromptVersionsSummary, request.ErrorMessage, request.ErrorDetail }, transaction, commandType: CommandType.StoredProcedure); int? chapterResultId = null; if (request.ChapterResult is not null) { var chapter = request.ChapterResult; chapterResultId = await connection.QuerySingleAsync( "dbo.StoryIntelligenceChapterResult_Save", new { StoryIntelligenceRunID = runId, chapter.ProjectID, chapter.BookID, chapter.ChapterID, chapter.ChapterNumber, chapter.SourceLabel, chapter.PromptVersion, chapter.Model, chapter.RawResponseJson, chapter.OutputTextJson, chapter.ParsedJson, chapter.ValidationErrorsCount, chapter.ValidationWarningsCount, chapter.InputTokens, chapter.OutputTokens, chapter.TotalTokens, chapter.DurationMs }, transaction, commandType: CommandType.StoredProcedure); } foreach (var scene in request.SceneResults) { await connection.ExecuteAsync( "dbo.StoryIntelligenceSceneResult_Save", new { StoryIntelligenceRunID = runId, ChapterResultID = chapterResultId, scene.ProjectID, scene.BookID, scene.ChapterID, scene.SceneID, scene.TemporarySceneNumber, scene.StartParagraph, scene.EndParagraph, scene.SourceLabel, scene.PromptVersion, scene.Model, scene.RawResponseJson, scene.OutputTextJson, scene.ParsedJson, scene.ValidationErrorsCount, scene.ValidationWarningsCount, scene.InputTokens, scene.OutputTokens, scene.TotalTokens, scene.DurationMs }, transaction, commandType: CommandType.StoredProcedure); } transaction.Commit(); return runId; } catch { transaction.Rollback(); throw; } } public async Task QueueAdminTextAsync(StoryIntelligenceRunQueueRequest request) { using var connection = connectionFactory.CreateConnection(); await EnsureKnownCharacterQueueSupportAsync(connection); return await connection.QuerySingleAsync( "dbo.StoryIntelligenceRun_QueueAdminText", new { request.UserID, request.ProjectID, request.BookID, request.ChapterID, request.ChapterNumber, request.SourceType, request.SourceLabel, SourceText = request.ChapterText, request.SourceFileName, request.SourceFileSizeBytes, request.SourceWordCount, request.SourceCharacterCount, request.SourceParagraphCount, request.SourceChapterCount, request.Model, request.PromptVersionsSummary, request.KnownCharactersJson }, commandType: CommandType.StoredProcedure); } private static async Task EnsureKnownCharacterQueueSupportAsync(IDbConnection connection) { if (queueSupportEnsured) { return; } await QueueSupportLock.WaitAsync(); try { if (queueSupportEnsured) { return; } await connection.ExecuteAsync( """ IF COL_LENGTH(N'dbo.StoryIntelligenceRuns', N'KnownCharactersJson') IS NULL BEGIN ALTER TABLE dbo.StoryIntelligenceRuns ADD KnownCharactersJson nvarchar(max) NULL; END; """); await connection.ExecuteAsync( """ 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; """); await connection.ExecuteAsync( """ 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; """); queueSupportEnsured = true; } finally { QueueSupportLock.Release(); } } public async Task ClaimNextPendingAsync() { using var connection = connectionFactory.CreateConnection(); return await connection.QuerySingleOrDefaultAsync( "dbo.StoryIntelligenceRun_ClaimNextPending", commandType: CommandType.StoredProcedure); } public async Task UpdateProgressAsync( int runId, string? status = null, string? currentStage = null, string? currentMessage = null, int? totalDetectedScenes = null, int? completedScenes = null, int? failedScenes = null, int? totalInputTokens = null, int? totalOutputTokens = null, int? totalTokens = null, long? totalDurationMs = null, decimal? estimatedCostGBP = null, decimal? estimatedCostUSD = null) { using var connection = connectionFactory.CreateConnection(); await connection.ExecuteAsync( "dbo.StoryIntelligenceRun_UpdateProgress", new { StoryIntelligenceRunID = runId, Status = status, CurrentStage = currentStage, CurrentMessage = currentMessage, TotalDetectedScenes = totalDetectedScenes, CompletedScenes = completedScenes, FailedScenes = failedScenes, TotalInputTokens = totalInputTokens, TotalOutputTokens = totalOutputTokens, TotalTokens = totalTokens, TotalDurationMs = totalDurationMs, EstimatedCostGBP = estimatedCostGBP, EstimatedCostUSD = estimatedCostUSD }, commandType: CommandType.StoredProcedure); } public async Task CompleteRunAsync(int runId, string status, int? totalInputTokens, int? totalOutputTokens, int? totalTokens, long? totalDurationMs, decimal? estimatedCostGBP, decimal? estimatedCostUSD) { using var connection = connectionFactory.CreateConnection(); await connection.ExecuteAsync( "dbo.StoryIntelligenceRun_Complete", new { StoryIntelligenceRunID = runId, Status = status, TotalInputTokens = totalInputTokens, TotalOutputTokens = totalOutputTokens, TotalTokens = totalTokens, TotalDurationMs = totalDurationMs, EstimatedCostGBP = estimatedCostGBP, EstimatedCostUSD = estimatedCostUSD }, commandType: CommandType.StoredProcedure); } public async Task FailRunAsync(int runId, string failureStage, string errorMessage, string? errorDetail, long? totalDurationMs) { using var connection = connectionFactory.CreateConnection(); await connection.ExecuteAsync( "dbo.StoryIntelligenceRun_Fail", new { StoryIntelligenceRunID = runId, FailureStage = failureStage, ErrorMessage = errorMessage, ErrorDetail = errorDetail, TotalDurationMs = totalDurationMs }, commandType: CommandType.StoredProcedure); } public async Task RequestCancelAsync(int runId) { using var connection = connectionFactory.CreateConnection(); await connection.ExecuteAsync( "dbo.StoryIntelligenceRun_RequestCancel", new { StoryIntelligenceRunID = runId }, commandType: CommandType.StoredProcedure); } public async Task CancelRunAsync(int runId, long? totalDurationMs) { using var connection = connectionFactory.CreateConnection(); await connection.ExecuteAsync( "dbo.StoryIntelligenceRun_Cancel", new { StoryIntelligenceRunID = runId, TotalDurationMs = totalDurationMs }, commandType: CommandType.StoredProcedure); } public async Task SaveChapterResultAsync(int runId, StoryIntelligenceChapterResultSaveRequest chapter) { using var connection = connectionFactory.CreateConnection(); return await connection.QuerySingleAsync( "dbo.StoryIntelligenceChapterResult_Save", new { StoryIntelligenceRunID = runId, chapter.ProjectID, chapter.BookID, chapter.ChapterID, chapter.ChapterNumber, chapter.SourceLabel, chapter.PromptVersion, chapter.Model, chapter.RawResponseJson, chapter.OutputTextJson, chapter.ParsedJson, chapter.ValidationErrorsCount, chapter.ValidationWarningsCount, chapter.InputTokens, chapter.OutputTokens, chapter.TotalTokens, chapter.DurationMs }, commandType: CommandType.StoredProcedure); } public async Task SaveSceneResultAsync(int runId, int? chapterResultId, StoryIntelligenceSceneResultSaveRequest scene) { using var connection = connectionFactory.CreateConnection(); return await connection.QuerySingleAsync( "dbo.StoryIntelligenceSceneResult_Save", new { StoryIntelligenceRunID = runId, ChapterResultID = chapterResultId, scene.ProjectID, scene.BookID, scene.ChapterID, scene.SceneID, scene.TemporarySceneNumber, scene.StartParagraph, scene.EndParagraph, scene.SourceLabel, scene.PromptVersion, scene.Model, scene.RawResponseJson, scene.OutputTextJson, scene.ParsedJson, scene.ValidationErrorsCount, scene.ValidationWarningsCount, scene.InputTokens, scene.OutputTokens, scene.TotalTokens, scene.DurationMs }, commandType: CommandType.StoredProcedure); } public async Task> ListRunsAsync() { using var connection = connectionFactory.CreateConnection(); var rows = await connection.QueryAsync( "dbo.StoryIntelligenceRun_ListAdmin", commandType: CommandType.StoredProcedure); return rows.ToList(); } public async Task> ListRunsByBookForUserAsync(int bookId, int userId) { using var connection = connectionFactory.CreateConnection(); var rows = await connection.QueryAsync( "dbo.StoryIntelligenceRun_ListByBookForUser", new { BookID = bookId, UserID = userId }, commandType: CommandType.StoredProcedure); return rows.ToList(); } public async Task GetRunAsync(int runId) { using var connection = connectionFactory.CreateConnection(); return await connection.QuerySingleOrDefaultAsync( "dbo.StoryIntelligenceRun_GetAdmin", new { StoryIntelligenceRunID = runId }, commandType: CommandType.StoredProcedure); } public async Task GetChapterResultAsync(int runId) { using var connection = connectionFactory.CreateConnection(); return await connection.QuerySingleOrDefaultAsync( "dbo.StoryIntelligenceChapterResult_GetByRun", new { StoryIntelligenceRunID = runId }, commandType: CommandType.StoredProcedure); } public async Task> ListSceneResultsAsync(int runId) { using var connection = connectionFactory.CreateConnection(); var rows = await connection.QueryAsync( "dbo.StoryIntelligenceSceneResult_ListByRun", new { StoryIntelligenceRunID = runId }, commandType: CommandType.StoredProcedure); return rows.ToList(); } public async Task> ListChapterScenesAsync(int chapterId) { using var connection = connectionFactory.CreateConnection(); var rows = await connection.QueryAsync( "dbo.Scene_ListByChapter", new { ChapterID = chapterId }, commandType: CommandType.StoredProcedure); return rows.Where(scene => !scene.IsArchived) .OrderBy(scene => scene.SortOrder) .ThenBy(scene => scene.SceneNumber) .ThenBy(scene => scene.SceneID) .ToList(); } public async Task GetImportCommitAsync(int runId) { using var connection = connectionFactory.CreateConnection(); return await connection.QuerySingleOrDefaultAsync( "dbo.StoryIntelligenceImportCommit_GetByRun", new { StoryIntelligenceRunID = runId }, commandType: CommandType.StoredProcedure); } public async Task GetChapterImportReadinessAsync(int chapterId) { using var connection = connectionFactory.CreateConnection(); return await connection.QuerySingleAsync( "dbo.StoryIntelligenceImportCommit_GetChapterReadiness", new { ChapterID = chapterId }, commandType: CommandType.StoredProcedure); } public async Task CommitImportAsync(StoryIntelligenceImportCommitRequest request) { using var connection = connectionFactory.CreateConnection(); connection.Open(); using var transaction = connection.BeginTransaction(); var scenesCreated = 0; var metricsCreated = 0; var commitWarnings = new List(); try { var alreadyCommitted = await connection.QuerySingleAsync( "dbo.StoryIntelligenceImportCommit_HasCompleted", new { request.StoryIntelligenceRunID }, transaction, commandType: CommandType.StoredProcedure); if (alreadyCommitted) { transaction.Rollback(); return new StoryIntelligenceImportCommitResult { Success = false, Message = "This Story Intelligence run has already been committed." }; } var readiness = await connection.QuerySingleAsync( "dbo.StoryIntelligenceImportCommit_GetChapterReadiness", new { request.ChapterID }, transaction, commandType: CommandType.StoredProcedure); var existingScenes = new List(); var orderedScenes = request.Scenes.OrderBy(scene => scene.TemporarySceneNumber).ToList(); if (!readiness.CanCommit) { existingScenes = (await connection.QueryAsync( "dbo.Scene_ListByChapter", new { request.ChapterID }, transaction, commandType: CommandType.StoredProcedure)) .Where(scene => !scene.IsArchived) .OrderBy(scene => scene.SortOrder) .ThenBy(scene => scene.SceneNumber) .ThenBy(scene => scene.SceneID) .ToList(); if (existingScenes.Count != orderedScenes.Count) { transaction.Rollback(); return new StoryIntelligenceImportCommitResult { Success = false, Message = $"This chapter contains {existingScenes.Count:N0} existing scene(s), but Story Intelligence produced {orderedScenes.Count:N0} scene result(s)." }; } } if (!string.IsNullOrWhiteSpace(request.ChapterSummary) || request.ChapterPurposeID.HasValue) { await connection.ExecuteAsync( "dbo.StoryIntelligenceImportCommit_UpdateChapterPlanning", new { request.ChapterID, Summary = request.ChapterSummary, request.ChapterPurposeID }, transaction, commandType: CommandType.StoredProcedure); } for (var index = 0; index < orderedScenes.Count; index++) { var item = orderedScenes[index]; var operation = "preparing scene"; try { operation = "saving scene"; var relativeTimeText = TrimOptionalText( item.ParsedScene.Setting?.DateOrTimeReference ?? item.ParsedScene.Setting?.TimeOfDay, 200, $"Scene {item.TemporarySceneNumber:N0} relative time", commitWarnings); var sceneTitle = TrimOptionalText(item.SceneTitle, 200, $"Scene {item.TemporarySceneNumber:N0} title", commitWarnings) ?? $"Scene {item.TemporarySceneNumber:N0}"; var sceneId = await connection.QuerySingleAsync( "dbo.Scene_Save", new { SceneID = item.SceneID ?? (existingScenes.Count > index ? existingScenes[index].SceneID : (int?)null), ChapterID = request.ChapterID, SceneNumber = Convert.ToDecimal(item.TemporarySceneNumber), SceneTitle = sceneTitle, Summary = item.Summary, TimeModeID = request.TimeModeID, StartDateTime = (DateTime?)null, EndDateTime = (DateTime?)null, DurationAmount = (decimal?)null, DurationUnitID = (int?)null, RelativeTimeText = relativeTimeText, TimeConfidenceID = request.TimeConfidenceID, ScenePurposeNotes = item.PurposeNotes, SceneOutcomeNotes = item.OutcomeNotes, RevisionStatusID = request.RevisionStatusID, PrimaryLocationID = (int?)null, FloorPlanID = (int?)null, InitialFloorPlanFloorID = (int?)null }, transaction, commandType: CommandType.StoredProcedure); operation = "saving source tracking"; await connection.ExecuteAsync( "dbo.StoryIntelligenceSceneSource_Save", new { SceneID = sceneId, ImportSource = "Story Intelligence", ImportRunID = request.StoryIntelligenceRunID, SourceStartParagraph = item.StartParagraph, SourceEndParagraph = item.EndParagraph }, transaction, commandType: CommandType.StoredProcedure); if (item.PovCharacterID.HasValue) { operation = "linking POV character"; await TryOptionalSceneOperationAsync( connection, transaction, item.TemporarySceneNumber, operation, commitWarnings, () => connection.ExecuteAsync( "dbo.Scene_UpdatePovCharacter", new { SceneID = sceneId, POVCharacterID = item.PovCharacterID }, transaction, commandType: CommandType.StoredProcedure)); } if (item.PurposeTypeIDs.Count > 0) { operation = "linking purpose labels"; await TryOptionalSceneOperationAsync( connection, transaction, item.TemporarySceneNumber, operation, commitWarnings, () => connection.ExecuteAsync( "dbo.Scene_SavePurposes", new { SceneID = sceneId, PurposeIds = string.Join(',', item.PurposeTypeIDs.Distinct()) }, transaction, commandType: CommandType.StoredProcedure)); } foreach (var metric in item.Metrics) { operation = $"saving metric '{metric.MetricName}'"; var saved = await TryOptionalSceneOperationAsync( connection, transaction, item.TemporarySceneNumber, operation, commitWarnings, () => connection.ExecuteAsync( "dbo.SceneMetric_SaveValue", new { SceneID = sceneId, metric.MetricTypeID, metric.Value, metric.Notes }, transaction, commandType: CommandType.StoredProcedure)); if (saved) { metricsCreated++; } } if (request.TimelineNoteTypeID.HasValue && !string.IsNullOrWhiteSpace(item.ImportNoteText)) { operation = "saving import note"; await TryOptionalSceneOperationAsync( connection, transaction, item.TemporarySceneNumber, operation, commitWarnings, () => connection.ExecuteAsync( "dbo.SceneNote_Save", new { SceneNoteID = (int?)null, SceneID = sceneId, SceneNoteTypeID = request.TimelineNoteTypeID.Value, NoteTitle = "Story Intelligence import notes", NoteText = item.ImportNoteText, SortOrder = 10, IsPinned = false, IsResolved = false }, transaction, commandType: CommandType.StoredProcedure)); } scenesCreated++; } catch (Exception ex) { throw new InvalidOperationException( $"Scene {item.TemporarySceneNumber:N0} failed during {operation}: {ex.Message}", ex); } } var notes = new List(); notes.Add(existingScenes.Count > 0 ? "Enriched existing structural scenes." : "Committed into empty chapter."); notes.AddRange(request.Warnings.Where(warning => !string.IsNullOrWhiteSpace(warning))); notes.AddRange(commitWarnings.Where(warning => !string.IsNullOrWhiteSpace(warning))); var commitId = await connection.QuerySingleAsync( "dbo.StoryIntelligenceImportCommit_RecordCompleted", new { request.StoryIntelligenceRunID, request.ProjectID, request.BookID, request.ChapterID, CommittedByUserID = request.UserID, ScenesCreated = scenesCreated, MetricsCreated = metricsCreated, Notes = string.Join(Environment.NewLine, notes) }, transaction, commandType: CommandType.StoredProcedure); transaction.Commit(); return new StoryIntelligenceImportCommitResult { Success = true, CommitID = commitId, ScenesCreated = scenesCreated, MetricsCreated = metricsCreated, Message = existingScenes.Count > 0 ? $"Enriched {scenesCreated:N0} existing scene(s)." : $"Created {scenesCreated:N0} scene(s)." }; } catch (Exception ex) { transaction.Rollback(); await RecordFailedCommitAsync(request, 0, 0, ex); throw; } } private async Task RecordFailedCommitAsync(StoryIntelligenceImportCommitRequest request, int scenesCreated, int metricsCreated, Exception ex) { using var connection = connectionFactory.CreateConnection(); await connection.ExecuteAsync( "dbo.StoryIntelligenceImportCommit_RecordFailed", new { request.StoryIntelligenceRunID, request.ProjectID, request.BookID, request.ChapterID, CommittedByUserID = request.UserID, ScenesCreated = scenesCreated, MetricsCreated = metricsCreated, Notes = ex.Message }, commandType: CommandType.StoredProcedure); } private static async Task TryOptionalSceneOperationAsync( IDbConnection connection, IDbTransaction transaction, int sceneNumber, string operation, ICollection warnings, Func> action) { try { await action(); return true; } catch (Exception ex) { warnings.Add($"Scene {sceneNumber:N0}: skipped optional {operation} because the database rejected it: {ex.Message}"); return false; } } private static string? TrimOptionalText(string? value, int maxLength, string label, ICollection warnings) { if (string.IsNullOrWhiteSpace(value)) { return null; } var trimmed = value.Trim(); if (trimmed.Length <= maxLength) { return trimmed; } warnings.Add($"{label} was truncated to {maxLength:N0} characters for the database field limit."); return trimmed[..maxLength].TrimEnd(); } }