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 GetImportCommitAsync(int runId); Task GetChapterImportReadinessAsync(int chapterId); Task CommitImportAsync(StoryIntelligenceImportCommitRequest request); } public sealed class StoryIntelligenceResultRepository(ISqlConnectionFactory connectionFactory) : IStoryIntelligenceResultRepository { 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(); 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 }, commandType: CommandType.StoredProcedure); } 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 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); if (!readiness.CanCommit) { transaction.Rollback(); return new StoryIntelligenceImportCommitResult { Success = false, Message = readiness.BlockReason ?? "This chapter already contains scenes." }; } 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); } var orderedScenes = request.Scenes.OrderBy(scene => scene.TemporarySceneNumber).ToList(); 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 = (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("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 = $"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(); } }