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 GetRunAsync(int runId); Task GetChapterResultAsync(int runId); Task> ListSceneResultsAsync(int runId); } 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.SourceType, request.SourceLabel, SourceText = request.ChapterText, request.SourceWordCount, request.SourceCharacterCount, 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 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(); } }