using System.Data; using System.Text.Json; using Dapper; using PlotLine.Models; namespace PlotLine.Data; public interface IWholeBookPlotIntelligenceRepository { Task SaveAuthorSeedsAsync(AuthorPlotSeedSaveRequest request); Task> ListAuthorSeedsAsync(int bookId); Task QueueAsync(WholeBookPlotSynthesisQueueRequest request); Task RetryAsync(int bookId, int userId); Task ClaimNextAsync(int leaseMinutes); Task GetCurrentAsync(int bookId); Task CompleteAsync(WholeBookPlotSynthesisCompletionRequest request, WholeBookPlotSynthesisResult result); Task FailAsync(int runId, string errorMessage, string? errorDetail, long? durationMs); Task BuildContextAsync(int projectId, int bookId); } public sealed class WholeBookPlotIntelligenceRepository(ISqlConnectionFactory connectionFactory) : IWholeBookPlotIntelligenceRepository { private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); public async Task SaveAuthorSeedsAsync(AuthorPlotSeedSaveRequest request) { using var connection = connectionFactory.CreateConnection(); await connection.ExecuteAsync( "dbo.AuthorPlotSeed_SaveForBook", new { request.ProjectID, request.BookID, SeedsJson = JsonSerializer.Serialize( request.Descriptions .Where(description => !string.IsNullOrWhiteSpace(description)) .Select(description => new { description = description.Trim() }) .ToList(), JsonOptions) }, commandType: CommandType.StoredProcedure); } public async Task> ListAuthorSeedsAsync(int bookId) { using var connection = connectionFactory.CreateConnection(); var rows = await connection.QueryAsync( "dbo.AuthorPlotSeed_ListByBook", new { BookID = bookId }, commandType: CommandType.StoredProcedure); return rows.ToList(); } public async Task QueueAsync(WholeBookPlotSynthesisQueueRequest request) { using var connection = connectionFactory.CreateConnection(); return await connection.QuerySingleAsync( "dbo.WholeBookPlotSynthesisRun_Queue", new { request.ProjectID, request.BookID, request.UserID, request.SourceStoryIntelligenceRunID, request.CharacterResolutionVersion }, commandType: CommandType.StoredProcedure); } public async Task RetryAsync(int bookId, int userId) { using var connection = connectionFactory.CreateConnection(); return await connection.QuerySingleOrDefaultAsync( "dbo.WholeBookPlotSynthesisRun_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.WholeBookPlotSynthesisRun_ClaimNext", new { LeaseMinutes = leaseMinutes }, commandType: CommandType.StoredProcedure); } public async Task GetCurrentAsync(int bookId) { using var connection = connectionFactory.CreateConnection(); return await connection.QuerySingleOrDefaultAsync( "dbo.WholeBookPlotSynthesisRun_GetCurrentByBook", new { BookID = bookId }, commandType: CommandType.StoredProcedure); } public async Task CompleteAsync(WholeBookPlotSynthesisCompletionRequest request, WholeBookPlotSynthesisResult result) { using var connection = connectionFactory.CreateConnection(); connection.Open(); using var transaction = connection.BeginTransaction(); try { await connection.ExecuteAsync( "dbo.WholeBookPlotSynthesisRun_Complete", new { request.WholeBookPlotSynthesisRunID, request.Model, request.OpenAIResponseID, request.InputTokens, request.OutputTokens, request.CachedInputTokens, request.TotalTokens, request.DurationMs, request.ResultJson }, transaction, commandType: CommandType.StoredProcedure); foreach (var plotLine in result.PlotLines) { var candidateId = await connection.QuerySingleAsync( "dbo.WholeBookPlotSynthesisCandidate_Save", new { request.WholeBookPlotSynthesisRunID, CandidateType = StoryIntelligenceNarrativeCandidateTypes.PlotLine, CandidateKey = CandidateKey(StoryIntelligenceNarrativeCandidateTypes.PlotLine, plotLine.Key, plotLine.ProposedTitle), plotLine.ProposedTitle, ProposedDescription = plotLine.Description, Category = "Plot Line", InferredState = plotLine.BookStatus, ChangeType = plotLine.BookStatus, plotLine.Significance, plotLine.ExistingPlotLineID, ExistingPlotThreadID = (int?)null, RelatedPlotLineID = (int?)null, ParentCandidateKey = (string?)null, ParentNarrativeCandidateID = (int?)null, plotLine.AuthorPlotSeedID, plotLine.ExistingPlotLineMatchConfidence, plotLine.ExistingPlotLineMatchReason, plotLine.SeedMatchConfidence, plotLine.SeedMatchReason, plotLine.Confidence }, transaction, commandType: CommandType.StoredProcedure); var sortOrder = 0; foreach (var thread in plotLine.Threads) { var threadCandidateId = await connection.QuerySingleAsync( "dbo.WholeBookPlotSynthesisCandidate_Save", new { request.WholeBookPlotSynthesisRunID, CandidateType = StoryIntelligenceNarrativeCandidateTypes.Thread, CandidateKey = CandidateKey(StoryIntelligenceNarrativeCandidateTypes.Thread, thread.Key, thread.ProposedTitle), thread.ProposedTitle, ProposedDescription = thread.Description, Category = "Thread", InferredState = thread.Status, ChangeType = thread.Status, Significance = plotLine.Significance, ExistingPlotLineID = plotLine.ExistingPlotLineID, thread.ExistingPlotThreadID, RelatedPlotLineID = plotLine.ExistingPlotLineID, ParentCandidateKey = CandidateKey(StoryIntelligenceNarrativeCandidateTypes.PlotLine, plotLine.Key, plotLine.ProposedTitle), ParentNarrativeCandidateID = candidateId, plotLine.AuthorPlotSeedID, ExistingPlotLineMatchConfidence = thread.ExistingThreadMatchConfidence, ExistingPlotLineMatchReason = thread.MatchReason, SeedMatchConfidence = plotLine.SeedMatchConfidence, SeedMatchReason = plotLine.SeedMatchReason, thread.Confidence }, transaction, commandType: CommandType.StoredProcedure); foreach (var item in thread.Events.Where(item => item.SceneID > 0)) { await connection.ExecuteAsync( "dbo.WholeBookPlotSynthesisCandidateEvent_Save", new { NarrativeCandidateID = threadCandidateId, SynthesisRunID = request.WholeBookPlotSynthesisRunID, EventKey = string.IsNullOrWhiteSpace(item.Key) ? $"{thread.Key}:{item.SceneID}:{item.EventType}:{sortOrder}" : item.Key, item.SceneID, item.EventType, EventTitle = item.Title, EventDescription = item.Description, item.Evidence, item.Confidence, SortOrder = sortOrder++ }, transaction, commandType: CommandType.StoredProcedure); await connection.ExecuteAsync( "dbo.StoryIntelligenceNarrativeCandidateEvidence_Save", new { NarrativeCandidateID = threadCandidateId, item.SceneID, ContributionType = item.EventType, Evidence = item.Evidence, SortOrder = sortOrder }, transaction, commandType: CommandType.StoredProcedure); } } } transaction.Commit(); } catch { transaction.Rollback(); throw; } } public async Task FailAsync(int runId, string errorMessage, string? errorDetail, long? durationMs) { using var connection = connectionFactory.CreateConnection(); await connection.ExecuteAsync( "dbo.WholeBookPlotSynthesisRun_Fail", new { WholeBookPlotSynthesisRunID = runId, ErrorMessage = errorMessage, ErrorDetail = errorDetail, DurationMs = durationMs }, commandType: CommandType.StoredProcedure); } public async Task BuildContextAsync(int projectId, int bookId) { using var connection = connectionFactory.CreateConnection(); using var result = await connection.QueryMultipleAsync( "dbo.WholeBookPlotSynthesis_ContextGet", new { ProjectID = projectId, BookID = bookId }, commandType: CommandType.StoredProcedure); var book = await result.ReadSingleAsync(); var sourceRows = (await result.ReadAsync()).ToList(); var characters = (await result.ReadAsync()).ToList(); var aliases = (await result.ReadAsync()).ToList(); var plotLines = (await result.ReadAsync()).ToList(); var threads = (await result.ReadAsync()).ToList(); var seeds = (await result.ReadAsync()).ToList(); var audit = await result.ReadSingleAsync(); var aliasesByCharacter = aliases .GroupBy(alias => alias.CharacterID) .ToDictionary(group => group.Key, group => (IReadOnlyList)group.Select(alias => alias.Alias).ToList()); characters = characters .Select(character => new WholeBookPlotSynthesisCharacter { CharacterID = character.CharacterID, CanonicalName = character.CanonicalName, Aliases = aliasesByCharacter.GetValueOrDefault(character.CharacterID) ?? [] }) .ToList(); var threadsByPlotLine = threads .GroupBy(thread => thread.PlotLineID) .ToDictionary( group => group.Key, group => (IReadOnlyList)group.Select(thread => new WholeBookPlotSynthesisExistingThread { PlotThreadID = thread.PlotThreadID, Title = thread.Title, Summary = thread.Summary, Status = thread.Status }).ToList()); plotLines = plotLines .Select(plotLine => new WholeBookPlotSynthesisExistingPlotLine { PlotLineID = plotLine.PlotLineID, Name = plotLine.Name, Description = plotLine.Description, Importance = plotLine.Importance, BookID = plotLine.BookID, Threads = threadsByPlotLine.GetValueOrDefault(plotLine.PlotLineID) ?? [] }) .ToList(); var chapters = sourceRows .GroupBy(row => new { row.ChapterID, row.ChapterNumber, row.ChapterTitle }) .Select(group => new WholeBookPlotSynthesisChapter { ChapterID = group.Key.ChapterID, ChapterNumber = group.Key.ChapterNumber, ChapterTitle = group.Key.ChapterTitle, Scenes = group.Select(row => new WholeBookPlotSynthesisScene { SceneID = row.SceneID, SceneNumber = row.SceneNumber, SceneTitle = row.SceneTitle, StructuralSummary = row.StructuralSummary, StructuralSetting = row.StructuralSetting, PovCharacterName = row.PovCharacterName, SourceText = row.SourceText }).ToList() }) .ToList(); return new WholeBookPlotSynthesisContext { ProjectID = projectId, BookID = bookId, BookTitle = book.BookTitle, Chapters = chapters, Characters = characters, ExistingPlotLines = plotLines, AuthorSeeds = seeds, SourceAudit = audit }; } private static string CandidateKey(string type, string key, string title) => $"{type}:{(string.IsNullOrWhiteSpace(key) ? title : key).Trim().ToLowerInvariant()}"; private sealed class ContextBookRow { public string BookTitle { get; init; } = string.Empty; } private sealed class ContextSceneRow { public int ChapterID { get; init; } public decimal ChapterNumber { get; init; } public string ChapterTitle { get; init; } = string.Empty; public int SceneID { get; init; } public decimal SceneNumber { get; init; } public string SceneTitle { get; init; } = string.Empty; public string? StructuralSummary { get; init; } public string? StructuralSetting { get; init; } public string? PovCharacterName { get; init; } public string SourceText { get; init; } = string.Empty; } private sealed class ContextCharacterAliasRow { public int CharacterID { get; init; } public string Alias { get; init; } = string.Empty; } private sealed class WholeBookPlotSynthesisExistingThreadRow { public int PlotThreadID { get; init; } public int PlotLineID { get; init; } public string Title { get; init; } = string.Empty; public string? Summary { get; init; } public string? Status { get; init; } } }