PlotDirector/PlotLine/Data/StoryIntelligenceResultRepository.cs

706 lines
29 KiB
C#

using System.Data;
using Dapper;
using PlotLine.Models;
namespace PlotLine.Data;
public interface IStoryIntelligenceResultRepository
{
Task<int> SaveAsync(StoryIntelligenceRunSaveRequest request);
Task<int> QueueAdminTextAsync(StoryIntelligenceRunQueueRequest request);
Task<StoryIntelligenceQueuedRun?> 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<int> SaveChapterResultAsync(int runId, StoryIntelligenceChapterResultSaveRequest chapter);
Task<int> SaveSceneResultAsync(int runId, int? chapterResultId, StoryIntelligenceSceneResultSaveRequest scene);
Task<IReadOnlyList<StoryIntelligenceSavedRunListItem>> ListRunsAsync();
Task<IReadOnlyList<StoryIntelligenceSavedRun>> ListRunsByBookForUserAsync(int bookId, int userId);
Task<StoryIntelligenceSavedRun?> GetRunAsync(int runId);
Task<StoryIntelligenceSavedChapterResult?> GetChapterResultAsync(int runId);
Task<IReadOnlyList<StoryIntelligenceSavedSceneResult>> ListSceneResultsAsync(int runId);
Task<StoryIntelligenceImportCommit?> GetImportCommitAsync(int runId);
Task<StoryIntelligenceChapterImportReadiness> GetChapterImportReadinessAsync(int chapterId);
Task<StoryIntelligenceImportCommitResult> CommitImportAsync(StoryIntelligenceImportCommitRequest request);
}
public sealed class StoryIntelligenceResultRepository(ISqlConnectionFactory connectionFactory) : IStoryIntelligenceResultRepository
{
public async Task<int> SaveAsync(StoryIntelligenceRunSaveRequest request)
{
using var connection = connectionFactory.CreateConnection();
connection.Open();
using var transaction = connection.BeginTransaction();
try
{
var runId = await connection.QuerySingleAsync<int>(
"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<int>(
"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<int> QueueAdminTextAsync(StoryIntelligenceRunQueueRequest request)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleAsync<int>(
"dbo.StoryIntelligenceRun_QueueAdminText",
new
{
request.UserID,
request.ProjectID,
request.BookID,
request.ChapterID,
request.ChapterNumber,
request.SourceType,
request.SourceLabel,
SourceText = request.ChapterText,
request.SourceFileName,
request.SourceFileSizeBytes,
request.SourceWordCount,
request.SourceCharacterCount,
request.SourceParagraphCount,
request.SourceChapterCount,
request.Model,
request.PromptVersionsSummary
},
commandType: CommandType.StoredProcedure);
}
public async Task<StoryIntelligenceQueuedRun?> ClaimNextPendingAsync()
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleOrDefaultAsync<StoryIntelligenceQueuedRun>(
"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<int> SaveChapterResultAsync(int runId, StoryIntelligenceChapterResultSaveRequest chapter)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleAsync<int>(
"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<int> SaveSceneResultAsync(int runId, int? chapterResultId, StoryIntelligenceSceneResultSaveRequest scene)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleAsync<int>(
"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<IReadOnlyList<StoryIntelligenceSavedRunListItem>> ListRunsAsync()
{
using var connection = connectionFactory.CreateConnection();
var rows = await connection.QueryAsync<StoryIntelligenceSavedRunListItem>(
"dbo.StoryIntelligenceRun_ListAdmin",
commandType: CommandType.StoredProcedure);
return rows.ToList();
}
public async Task<IReadOnlyList<StoryIntelligenceSavedRun>> ListRunsByBookForUserAsync(int bookId, int userId)
{
using var connection = connectionFactory.CreateConnection();
var rows = await connection.QueryAsync<StoryIntelligenceSavedRun>(
"dbo.StoryIntelligenceRun_ListByBookForUser",
new { BookID = bookId, UserID = userId },
commandType: CommandType.StoredProcedure);
return rows.ToList();
}
public async Task<StoryIntelligenceSavedRun?> GetRunAsync(int runId)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleOrDefaultAsync<StoryIntelligenceSavedRun>(
"dbo.StoryIntelligenceRun_GetAdmin",
new { StoryIntelligenceRunID = runId },
commandType: CommandType.StoredProcedure);
}
public async Task<StoryIntelligenceSavedChapterResult?> GetChapterResultAsync(int runId)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleOrDefaultAsync<StoryIntelligenceSavedChapterResult>(
"dbo.StoryIntelligenceChapterResult_GetByRun",
new { StoryIntelligenceRunID = runId },
commandType: CommandType.StoredProcedure);
}
public async Task<IReadOnlyList<StoryIntelligenceSavedSceneResult>> ListSceneResultsAsync(int runId)
{
using var connection = connectionFactory.CreateConnection();
var rows = await connection.QueryAsync<StoryIntelligenceSavedSceneResult>(
"dbo.StoryIntelligenceSceneResult_ListByRun",
new { StoryIntelligenceRunID = runId },
commandType: CommandType.StoredProcedure);
return rows.ToList();
}
public async Task<StoryIntelligenceImportCommit?> GetImportCommitAsync(int runId)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleOrDefaultAsync<StoryIntelligenceImportCommit>(
"dbo.StoryIntelligenceImportCommit_GetByRun",
new { StoryIntelligenceRunID = runId },
commandType: CommandType.StoredProcedure);
}
public async Task<StoryIntelligenceChapterImportReadiness> GetChapterImportReadinessAsync(int chapterId)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleAsync<StoryIntelligenceChapterImportReadiness>(
"dbo.StoryIntelligenceImportCommit_GetChapterReadiness",
new { ChapterID = chapterId },
commandType: CommandType.StoredProcedure);
}
public async Task<StoryIntelligenceImportCommitResult> CommitImportAsync(StoryIntelligenceImportCommitRequest request)
{
using var connection = connectionFactory.CreateConnection();
connection.Open();
using var transaction = connection.BeginTransaction();
var scenesCreated = 0;
var metricsCreated = 0;
var commitWarnings = new List<string>();
try
{
var alreadyCommitted = await connection.QuerySingleAsync<bool>(
"dbo.StoryIntelligenceImportCommit_HasCompleted",
new { request.StoryIntelligenceRunID },
transaction,
commandType: CommandType.StoredProcedure);
if (alreadyCommitted)
{
transaction.Rollback();
return new StoryIntelligenceImportCommitResult
{
Success = false,
Message = "This Story Intelligence run has already been committed."
};
}
var readiness = await connection.QuerySingleAsync<StoryIntelligenceChapterImportReadiness>(
"dbo.StoryIntelligenceImportCommit_GetChapterReadiness",
new { request.ChapterID },
transaction,
commandType: CommandType.StoredProcedure);
if (!readiness.CanCommit)
{
transaction.Rollback();
return new StoryIntelligenceImportCommitResult
{
Success = false,
Message = readiness.BlockReason ?? "This chapter already contains scenes."
};
}
if (!string.IsNullOrWhiteSpace(request.ChapterSummary) || request.ChapterPurposeID.HasValue)
{
await connection.ExecuteAsync(
"dbo.StoryIntelligenceImportCommit_UpdateChapterPlanning",
new
{
request.ChapterID,
Summary = request.ChapterSummary,
request.ChapterPurposeID
},
transaction,
commandType: CommandType.StoredProcedure);
}
var orderedScenes = request.Scenes.OrderBy(scene => scene.TemporarySceneNumber).ToList();
for (var index = 0; index < orderedScenes.Count; index++)
{
var item = orderedScenes[index];
var operation = "preparing scene";
try
{
operation = "saving scene";
var relativeTimeText = TrimOptionalText(
item.ParsedScene.Setting?.DateOrTimeReference ?? item.ParsedScene.Setting?.TimeOfDay,
200,
$"Scene {item.TemporarySceneNumber:N0} relative time",
commitWarnings);
var sceneTitle = TrimOptionalText(item.SceneTitle, 200, $"Scene {item.TemporarySceneNumber:N0} title", commitWarnings)
?? $"Scene {item.TemporarySceneNumber:N0}";
var sceneId = await connection.QuerySingleAsync<int>(
"dbo.Scene_Save",
new
{
SceneID = (int?)null,
ChapterID = request.ChapterID,
SceneNumber = Convert.ToDecimal(item.TemporarySceneNumber),
SceneTitle = sceneTitle,
Summary = item.Summary,
TimeModeID = request.TimeModeID,
StartDateTime = (DateTime?)null,
EndDateTime = (DateTime?)null,
DurationAmount = (decimal?)null,
DurationUnitID = (int?)null,
RelativeTimeText = relativeTimeText,
TimeConfidenceID = request.TimeConfidenceID,
ScenePurposeNotes = item.PurposeNotes,
SceneOutcomeNotes = item.OutcomeNotes,
RevisionStatusID = request.RevisionStatusID,
PrimaryLocationID = (int?)null,
FloorPlanID = (int?)null,
InitialFloorPlanFloorID = (int?)null
},
transaction,
commandType: CommandType.StoredProcedure);
operation = "saving source tracking";
await connection.ExecuteAsync(
"dbo.StoryIntelligenceSceneSource_Save",
new
{
SceneID = sceneId,
ImportSource = "Story Intelligence",
ImportRunID = request.StoryIntelligenceRunID,
SourceStartParagraph = item.StartParagraph,
SourceEndParagraph = item.EndParagraph
},
transaction,
commandType: CommandType.StoredProcedure);
if (item.PovCharacterID.HasValue)
{
operation = "linking POV character";
await TryOptionalSceneOperationAsync(
connection,
transaction,
item.TemporarySceneNumber,
operation,
commitWarnings,
() => connection.ExecuteAsync(
"dbo.Scene_UpdatePovCharacter",
new { SceneID = sceneId, POVCharacterID = item.PovCharacterID },
transaction,
commandType: CommandType.StoredProcedure));
}
if (item.PurposeTypeIDs.Count > 0)
{
operation = "linking purpose labels";
await TryOptionalSceneOperationAsync(
connection,
transaction,
item.TemporarySceneNumber,
operation,
commitWarnings,
() => connection.ExecuteAsync(
"dbo.Scene_SavePurposes",
new { SceneID = sceneId, PurposeIds = string.Join(',', item.PurposeTypeIDs.Distinct()) },
transaction,
commandType: CommandType.StoredProcedure));
}
foreach (var metric in item.Metrics)
{
operation = $"saving metric '{metric.MetricName}'";
var saved = await TryOptionalSceneOperationAsync(
connection,
transaction,
item.TemporarySceneNumber,
operation,
commitWarnings,
() => connection.ExecuteAsync(
"dbo.SceneMetric_SaveValue",
new { SceneID = sceneId, metric.MetricTypeID, metric.Value, metric.Notes },
transaction,
commandType: CommandType.StoredProcedure));
if (saved)
{
metricsCreated++;
}
}
if (request.TimelineNoteTypeID.HasValue && !string.IsNullOrWhiteSpace(item.ImportNoteText))
{
operation = "saving import note";
await TryOptionalSceneOperationAsync(
connection,
transaction,
item.TemporarySceneNumber,
operation,
commitWarnings,
() => connection.ExecuteAsync(
"dbo.SceneNote_Save",
new
{
SceneNoteID = (int?)null,
SceneID = sceneId,
SceneNoteTypeID = request.TimelineNoteTypeID.Value,
NoteTitle = "Story Intelligence import notes",
NoteText = item.ImportNoteText,
SortOrder = 10,
IsPinned = false,
IsResolved = false
},
transaction,
commandType: CommandType.StoredProcedure));
}
scenesCreated++;
}
catch (Exception ex)
{
throw new InvalidOperationException(
$"Scene {item.TemporarySceneNumber:N0} failed during {operation}: {ex.Message}",
ex);
}
}
var notes = new List<string>();
notes.Add("Committed into empty chapter.");
notes.AddRange(request.Warnings.Where(warning => !string.IsNullOrWhiteSpace(warning)));
notes.AddRange(commitWarnings.Where(warning => !string.IsNullOrWhiteSpace(warning)));
var commitId = await connection.QuerySingleAsync<int>(
"dbo.StoryIntelligenceImportCommit_RecordCompleted",
new
{
request.StoryIntelligenceRunID,
request.ProjectID,
request.BookID,
request.ChapterID,
CommittedByUserID = request.UserID,
ScenesCreated = scenesCreated,
MetricsCreated = metricsCreated,
Notes = string.Join(Environment.NewLine, notes)
},
transaction,
commandType: CommandType.StoredProcedure);
transaction.Commit();
return new StoryIntelligenceImportCommitResult
{
Success = true,
CommitID = commitId,
ScenesCreated = scenesCreated,
MetricsCreated = metricsCreated,
Message = $"Created {scenesCreated:N0} scene(s)."
};
}
catch (Exception ex)
{
transaction.Rollback();
await RecordFailedCommitAsync(request, 0, 0, ex);
throw;
}
}
private async Task RecordFailedCommitAsync(StoryIntelligenceImportCommitRequest request, int scenesCreated, int metricsCreated, Exception ex)
{
using var connection = connectionFactory.CreateConnection();
await connection.ExecuteAsync(
"dbo.StoryIntelligenceImportCommit_RecordFailed",
new
{
request.StoryIntelligenceRunID,
request.ProjectID,
request.BookID,
request.ChapterID,
CommittedByUserID = request.UserID,
ScenesCreated = scenesCreated,
MetricsCreated = metricsCreated,
Notes = ex.Message
},
commandType: CommandType.StoredProcedure);
}
private static async Task<bool> TryOptionalSceneOperationAsync(
IDbConnection connection,
IDbTransaction transaction,
int sceneNumber,
string operation,
ICollection<string> warnings,
Func<Task<int>> action)
{
try
{
await action();
return true;
}
catch (Exception ex)
{
warnings.Add($"Scene {sceneNumber:N0}: skipped optional {operation} because the database rejected it: {ex.Message}");
return false;
}
}
private static string? TrimOptionalText(string? value, int maxLength, string label, ICollection<string> warnings)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
var trimmed = value.Trim();
if (trimmed.Length <= maxLength)
{
return trimmed;
}
warnings.Add($"{label} was truncated to {maxLength:N0} characters for the database field limit.");
return trimmed[..maxLength].TrimEnd();
}
}