PlotDirector/PlotLine/Data/WholeBookPlotIntelligenceRepository.cs

366 lines
16 KiB
C#

using System.Data;
using System.Text.Json;
using Dapper;
using PlotLine.Models;
namespace PlotLine.Data;
public interface IWholeBookPlotIntelligenceRepository
{
Task SaveAuthorSeedsAsync(AuthorPlotSeedSaveRequest request);
Task<IReadOnlyList<AuthorPlotSeed>> ListAuthorSeedsAsync(int bookId);
Task<WholeBookPlotSynthesisRun> QueueAsync(WholeBookPlotSynthesisQueueRequest request);
Task<WholeBookPlotSynthesisRun?> RetryAsync(int bookId, int userId);
Task<WholeBookPlotSynthesisRun?> ClaimNextAsync(int leaseMinutes);
Task<WholeBookPlotSynthesisRun?> GetCurrentAsync(int bookId);
Task CompleteAsync(WholeBookPlotSynthesisCompletionRequest request, WholeBookPlotSynthesisResult result);
Task FailAsync(int runId, string errorMessage, string? errorDetail, long? durationMs);
Task<WholeBookPlotSynthesisContext> 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<IReadOnlyList<AuthorPlotSeed>> ListAuthorSeedsAsync(int bookId)
{
using var connection = connectionFactory.CreateConnection();
var rows = await connection.QueryAsync<AuthorPlotSeed>(
"dbo.AuthorPlotSeed_ListByBook",
new { BookID = bookId },
commandType: CommandType.StoredProcedure);
return rows.ToList();
}
public async Task<WholeBookPlotSynthesisRun> QueueAsync(WholeBookPlotSynthesisQueueRequest request)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleAsync<WholeBookPlotSynthesisRun>(
"dbo.WholeBookPlotSynthesisRun_Queue",
new
{
request.ProjectID,
request.BookID,
request.UserID,
request.SourceStoryIntelligenceRunID,
request.CharacterResolutionVersion
},
commandType: CommandType.StoredProcedure);
}
public async Task<WholeBookPlotSynthesisRun?> RetryAsync(int bookId, int userId)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleOrDefaultAsync<WholeBookPlotSynthesisRun>(
"dbo.WholeBookPlotSynthesisRun_Retry",
new { BookID = bookId, UserID = userId },
commandType: CommandType.StoredProcedure);
}
public async Task<WholeBookPlotSynthesisRun?> ClaimNextAsync(int leaseMinutes)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleOrDefaultAsync<WholeBookPlotSynthesisRun>(
"dbo.WholeBookPlotSynthesisRun_ClaimNext",
new { LeaseMinutes = leaseMinutes },
commandType: CommandType.StoredProcedure);
}
public async Task<WholeBookPlotSynthesisRun?> GetCurrentAsync(int bookId)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleOrDefaultAsync<WholeBookPlotSynthesisRun>(
"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<int>(
"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<int>(
"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<WholeBookPlotSynthesisContext> 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<ContextBookRow>();
var sourceRows = (await result.ReadAsync<ContextSceneRow>()).ToList();
var characters = (await result.ReadAsync<WholeBookPlotSynthesisCharacter>()).ToList();
var aliases = (await result.ReadAsync<ContextCharacterAliasRow>()).ToList();
var plotLines = (await result.ReadAsync<WholeBookPlotSynthesisExistingPlotLine>()).ToList();
var threads = (await result.ReadAsync<WholeBookPlotSynthesisExistingThreadRow>()).ToList();
var seeds = (await result.ReadAsync<AuthorPlotSeed>()).ToList();
var audit = await result.ReadSingleAsync<WholeBookPlotSynthesisSourceAudit>();
var aliasesByCharacter = aliases
.GroupBy(alias => alias.CharacterID)
.ToDictionary(group => group.Key, group => (IReadOnlyList<string>)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<WholeBookPlotSynthesisExistingThread>)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; }
}
}