using System.Data; using System.Text.Json; using Dapper; using PlotLine.Models; namespace PlotLine.Data; public interface ICharacterEnrichmentRepository { Task QueueAsync(CharacterEnrichmentQueueRequest request); Task QueueCharacterAsync(CharacterEnrichmentCharacterQueueRequest request); Task RetryAsync(int bookId, int userId); Task ClaimNextAsync(int leaseMinutes); Task GetCurrentAsync(int bookId); Task GetCurrentForCharacterAsync(int characterId, int userId); Task GetCharacterStatusAsync(int characterId, int userId); Task UpdateProgressAsync(CharacterEnrichmentProgressUpdate update); Task CompleteAsync(CharacterEnrichmentCompletionRequest request, IReadOnlyList results); Task FailAsync(int runId, string errorMessage, string? errorDetail, long? durationMs); Task BuildContextAsync(int projectId, int bookId); Task BuildContextAsync(int projectId, int bookId, int? characterId); } public sealed class CharacterEnrichmentRepository(ISqlConnectionFactory connectionFactory) : ICharacterEnrichmentRepository { private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); public async Task QueueAsync(CharacterEnrichmentQueueRequest request) { using var connection = connectionFactory.CreateConnection(); return await connection.QuerySingleAsync( "dbo.CharacterEnrichmentRun_Queue", request, commandType: CommandType.StoredProcedure); } public async Task QueueCharacterAsync(CharacterEnrichmentCharacterQueueRequest request) { using var connection = connectionFactory.CreateConnection(); return await connection.QuerySingleAsync( "dbo.CharacterEnrichmentRun_QueueCharacter", request, commandType: CommandType.StoredProcedure); } public async Task RetryAsync(int bookId, int userId) { using var connection = connectionFactory.CreateConnection(); return await connection.QuerySingleOrDefaultAsync( "dbo.CharacterEnrichmentRun_Retry", new { BookID = bookId, UserID = userId }, commandType: CommandType.StoredProcedure); } public async Task ClaimNextAsync(int leaseMinutes) { using var connection = connectionFactory.CreateConnection(); return await connection.QuerySingleOrDefaultAsync( "dbo.CharacterEnrichmentRun_ClaimNext", new { LeaseMinutes = leaseMinutes }, commandType: CommandType.StoredProcedure); } public async Task GetCurrentAsync(int bookId) { using var connection = connectionFactory.CreateConnection(); return await connection.QuerySingleOrDefaultAsync( "dbo.CharacterEnrichmentRun_GetCurrentByBook", new { BookID = bookId }, commandType: CommandType.StoredProcedure); } public async Task GetCurrentForCharacterAsync(int characterId, int userId) { using var connection = connectionFactory.CreateConnection(); return await connection.QuerySingleOrDefaultAsync( "dbo.CharacterEnrichmentRun_GetCurrentByCharacter", new { CharacterID = characterId, UserID = userId }, commandType: CommandType.StoredProcedure); } public async Task GetCharacterStatusAsync(int characterId, int userId) { using var connection = connectionFactory.CreateConnection(); return await connection.QuerySingleOrDefaultAsync( "dbo.CharacterEnrichmentCharacterStatus_Get", new { CharacterID = characterId, UserID = userId }, commandType: CommandType.StoredProcedure); } public async Task UpdateProgressAsync(CharacterEnrichmentProgressUpdate update) { using var connection = connectionFactory.CreateConnection(); await connection.ExecuteAsync( "dbo.CharacterEnrichmentRun_UpdateProgress", update, commandType: CommandType.StoredProcedure); } public async Task CompleteAsync(CharacterEnrichmentCompletionRequest request, IReadOnlyList results) { using var connection = connectionFactory.CreateConnection(); await connection.ExecuteAsync( "dbo.CharacterEnrichmentRun_Complete", new { request.CharacterEnrichmentRunID, request.Model, request.InputTokens, request.OutputTokens, request.TotalTokens, request.DurationMs, ResultsJson = JsonSerializer.Serialize(results, JsonOptions) }, commandType: CommandType.StoredProcedure); } public async Task FailAsync(int runId, string errorMessage, string? errorDetail, long? durationMs) { using var connection = connectionFactory.CreateConnection(); await connection.ExecuteAsync( "dbo.CharacterEnrichmentRun_Fail", new { CharacterEnrichmentRunID = runId, ErrorMessage = errorMessage, ErrorDetail = errorDetail, DurationMs = durationMs }, commandType: CommandType.StoredProcedure); } public async Task BuildContextAsync(int projectId, int bookId) => await BuildContextAsync(projectId, bookId, null); public async Task BuildContextAsync(int projectId, int bookId, int? characterId) { using var connection = connectionFactory.CreateConnection(); using var result = await connection.QueryMultipleAsync( "dbo.CharacterEnrichment_ContextGet", new { ProjectID = projectId, BookID = bookId, CharacterID = characterId }, commandType: CommandType.StoredProcedure); var book = await result.ReadSingleAsync(); var characters = (await result.ReadAsync()).ToList(); var aliases = (await result.ReadAsync()).ToList(); var scenes = (await result.ReadAsync()).ToList(); var aliasesByCharacter = aliases .GroupBy(alias => alias.CharacterID) .ToDictionary(group => group.Key, group => (IReadOnlyList)group.Select(alias => alias.Alias).ToList()); var scenesByCharacter = scenes .GroupBy(scene => scene.CharacterID) .ToDictionary( group => group.Key, group => (IReadOnlyList)group.Select(scene => new CharacterEnrichmentSceneContext { SceneID = scene.SceneID, ChapterID = scene.ChapterID, ChapterNumber = scene.ChapterNumber, ChapterTitle = scene.ChapterTitle, SceneNumber = scene.SceneNumber, SceneTitle = scene.SceneTitle, SourceText = scene.SourceText }).ToList()); return new CharacterEnrichmentContext { ProjectID = projectId, BookID = bookId, BookTitle = book.BookTitle, StoryEra = book.StoryEra, SeriesStartDate = book.SeriesStartDate, Characters = characters.Select(character => new CharacterEnrichmentCharacterContext { CharacterID = character.CharacterID, CharacterName = character.CharacterName, BirthDate = character.BirthDate, AgeAtSeriesStart = character.AgeAtSeriesStart, Height = character.Height, EyeColour = character.EyeColour, DefaultDescription = character.DefaultDescription, Aliases = aliasesByCharacter.GetValueOrDefault(character.CharacterID) ?? [], Scenes = scenesByCharacter.GetValueOrDefault(character.CharacterID) ?? [] }).ToList() }; } private sealed class ContextBookRow { public string BookTitle { get; init; } = string.Empty; public string? StoryEra { get; init; } public DateTime? SeriesStartDate { get; init; } } private sealed class ContextAliasRow { public int CharacterID { get; init; } public string Alias { get; init; } = string.Empty; } private sealed class ContextSceneRow { public int CharacterID { get; init; } public int SceneID { get; init; } public int ChapterID { get; init; } public decimal ChapterNumber { get; init; } public string ChapterTitle { get; init; } = string.Empty; public decimal SceneNumber { get; init; } public string SceneTitle { get; init; } = string.Empty; public string SourceText { get; init; } = string.Empty; } }