366 lines
16 KiB
C#
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; }
|
|
}
|
|
}
|