PlotDirector/PlotLine/Data/StoryIntelligenceResultRepository.cs

909 lines
39 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<StoryIntelligenceQueuedRun?> ClaimNextPendingFairAsync(int maxConcurrentRunsPerBook, int maxConcurrentRunsPerUser, int claimLeaseMinutes);
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<IReadOnlyList<StoryIntelligenceActiveBookRunSummary>> ListActiveBookRunSummariesForUserAsync(int userId);
Task<StoryIntelligenceSavedRun?> GetRunAsync(int runId);
Task<StoryIntelligenceSavedChapterResult?> GetChapterResultAsync(int runId);
Task<IReadOnlyList<StoryIntelligenceSavedSceneResult>> ListSceneResultsAsync(int runId);
Task<IReadOnlyList<Scene>> ListChapterScenesAsync(int chapterId);
Task<StoryIntelligenceImportCommit?> GetImportCommitAsync(int runId);
Task<StoryIntelligenceChapterImportReadiness> GetChapterImportReadinessAsync(int chapterId);
Task<StoryIntelligenceImportCommitResult> CommitImportAsync(StoryIntelligenceImportCommitRequest request);
}
public sealed class StoryIntelligenceResultRepository(ISqlConnectionFactory connectionFactory) : IStoryIntelligenceResultRepository
{
private static readonly SemaphoreSlim QueueSupportLock = new(1, 1);
private static bool queueSupportEnsured;
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();
await EnsureKnownCharacterQueueSupportAsync(connection);
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,
request.KnownCharactersJson
},
commandType: CommandType.StoredProcedure);
}
private static async Task EnsureKnownCharacterQueueSupportAsync(IDbConnection connection)
{
if (queueSupportEnsured)
{
return;
}
await QueueSupportLock.WaitAsync();
try
{
if (queueSupportEnsured)
{
return;
}
await connection.ExecuteAsync(
"""
IF COL_LENGTH(N'dbo.StoryIntelligenceRuns', N'KnownCharactersJson') IS NULL
BEGIN
ALTER TABLE dbo.StoryIntelligenceRuns ADD KnownCharactersJson nvarchar(max) NULL;
END;
""");
await connection.ExecuteAsync(
"""
CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceRun_QueueAdminText
@UserID int,
@ProjectID int = NULL,
@BookID int = NULL,
@ChapterID int = NULL,
@ChapterNumber decimal(9, 2) = NULL,
@SourceType nvarchar(50),
@SourceLabel nvarchar(300),
@SourceText nvarchar(max),
@SourceFileName nvarchar(260) = NULL,
@SourceFileSizeBytes bigint = NULL,
@SourceWordCount int = NULL,
@SourceCharacterCount int = NULL,
@SourceParagraphCount int = NULL,
@SourceChapterCount int = NULL,
@Model nvarchar(100),
@PromptVersionsSummary nvarchar(500),
@KnownCharactersJson nvarchar(max) = NULL
AS
BEGIN
SET NOCOUNT ON;
INSERT dbo.StoryIntelligenceRuns
(
UserID, ProjectID, BookID, ChapterID, ChapterNumber, Status, SourceType, SourceLabel, SourceText,
SourceFileName, SourceFileSizeBytes, SourceWordCount, SourceCharacterCount, SourceParagraphCount,
SourceChapterCount, Model, PromptVersion, PromptVersionsSummary, KnownCharactersJson, StartedUtc,
CurrentStage, CurrentMessage, CompletedScenes, FailedScenes
)
VALUES
(
@UserID, @ProjectID, @BookID, @ChapterID, @ChapterNumber, N'Pending', @SourceType, @SourceLabel, @SourceText,
@SourceFileName, @SourceFileSizeBytes, @SourceWordCount, @SourceCharacterCount, @SourceParagraphCount,
@SourceChapterCount, @Model, @PromptVersionsSummary, @PromptVersionsSummary, @KnownCharactersJson,
SYSUTCDATETIME(), N'Pending', N'Queued for Story Intelligence processing.', 0, 0
);
SELECT CAST(SCOPE_IDENTITY() AS int) AS StoryIntelligenceRunID;
END;
""");
await connection.ExecuteAsync(
"""
CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceRun_ClaimNextPending
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON;
DECLARE @StoryIntelligenceRunID int;
SELECT TOP (1) @StoryIntelligenceRunID = StoryIntelligenceRunID
FROM dbo.StoryIntelligenceRuns WITH (UPDLOCK, READPAST)
WHERE Status = N'Pending'
ORDER BY CreatedUtc;
IF @StoryIntelligenceRunID IS NULL
BEGIN
SELECT TOP (0)
StoryIntelligenceRunID, UserID, ProjectID, BookID, ChapterID, ChapterNumber, Status, SourceType, SourceLabel,
SourceText, SourceWordCount, SourceCharacterCount, SourceParagraphCount, SourceChapterCount, PromptVersion,
PromptVersionsSummary, KnownCharactersJson, Model, StartedUtc, CompletedUtc, FailureStage, TotalInputTokens,
TotalOutputTokens, TotalTokens, TotalDurationMs, EstimatedCostGBP, EstimatedCostUSD,
ErrorMessage, ErrorDetail, CurrentStage, CurrentMessage, TotalDetectedScenes,
CompletedScenes, FailedScenes, CancellationRequestedUtc, CancelledUtc, CreatedUtc, UpdatedUtc
FROM dbo.StoryIntelligenceRuns;
RETURN;
END;
UPDATE dbo.StoryIntelligenceRuns
SET Status = N'Running',
StartedUtc = SYSUTCDATETIME(),
CurrentStage = N'ChapterStructure',
CurrentMessage = N'Running Chapter Structure analysis.',
UpdatedUtc = SYSUTCDATETIME()
WHERE StoryIntelligenceRunID = @StoryIntelligenceRunID
AND Status = N'Pending';
SELECT
StoryIntelligenceRunID, UserID, ProjectID, BookID, ChapterID, ChapterNumber, Status, SourceType, SourceLabel,
SourceText, SourceWordCount, SourceCharacterCount, SourceParagraphCount, SourceChapterCount, PromptVersion,
PromptVersionsSummary, KnownCharactersJson, Model, StartedUtc, CompletedUtc, FailureStage, TotalInputTokens,
TotalOutputTokens, TotalTokens, TotalDurationMs, EstimatedCostGBP, EstimatedCostUSD,
ErrorMessage, ErrorDetail, CurrentStage, CurrentMessage, TotalDetectedScenes,
CompletedScenes, FailedScenes, CancellationRequestedUtc, CancelledUtc, CreatedUtc, UpdatedUtc
FROM dbo.StoryIntelligenceRuns
WHERE StoryIntelligenceRunID = @StoryIntelligenceRunID;
END;
""");
queueSupportEnsured = true;
}
finally
{
QueueSupportLock.Release();
}
}
public async Task<StoryIntelligenceQueuedRun?> ClaimNextPendingAsync()
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleOrDefaultAsync<StoryIntelligenceQueuedRun>(
"dbo.StoryIntelligenceRun_ClaimNextPending",
commandType: CommandType.StoredProcedure);
}
public async Task<StoryIntelligenceQueuedRun?> ClaimNextPendingFairAsync(
int maxConcurrentRunsPerBook,
int maxConcurrentRunsPerUser,
int claimLeaseMinutes)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleOrDefaultAsync<StoryIntelligenceQueuedRun>(
"dbo.StoryIntelligenceRun_ClaimNextPendingFair",
new
{
MaxConcurrentRunsPerBook = Math.Max(1, maxConcurrentRunsPerBook),
MaxConcurrentRunsPerUser = Math.Max(1, maxConcurrentRunsPerUser),
ClaimLeaseMinutes = Math.Max(15, claimLeaseMinutes)
},
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<IReadOnlyList<StoryIntelligenceActiveBookRunSummary>> ListActiveBookRunSummariesForUserAsync(int userId)
{
using var connection = connectionFactory.CreateConnection();
var rows = await connection.QueryAsync<StoryIntelligenceActiveBookRunSummary>(
"dbo.StoryIntelligenceRun_ListActiveBookSummaryForUser",
new { 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<IReadOnlyList<Scene>> ListChapterScenesAsync(int chapterId)
{
using var connection = connectionFactory.CreateConnection();
var rows = await connection.QueryAsync<Scene>(
"dbo.Scene_ListByChapter",
new { ChapterID = chapterId },
commandType: CommandType.StoredProcedure);
return rows.Where(scene => !scene.IsArchived)
.OrderBy(scene => scene.SortOrder)
.ThenBy(scene => scene.SceneNumber)
.ThenBy(scene => scene.SceneID)
.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);
var existingScenes = new List<Scene>();
var orderedScenes = request.Scenes.OrderBy(scene => scene.TemporarySceneNumber).ToList();
if (!readiness.CanCommit)
{
existingScenes = (await connection.QueryAsync<Scene>(
"dbo.Scene_ListByChapter",
new { request.ChapterID },
transaction,
commandType: CommandType.StoredProcedure))
.Where(scene => !scene.IsArchived)
.OrderBy(scene => scene.SortOrder)
.ThenBy(scene => scene.SceneNumber)
.ThenBy(scene => scene.SceneID)
.ToList();
if (existingScenes.Count != orderedScenes.Count)
{
transaction.Rollback();
return new StoryIntelligenceImportCommitResult
{
Success = false,
Message = $"This chapter contains {existingScenes.Count:N0} existing scene(s), but Story Intelligence produced {orderedScenes.Count:N0} scene result(s)."
};
}
}
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);
}
for (var index = 0; index < orderedScenes.Count; index++)
{
var item = orderedScenes[index];
var existingScene = item.SceneID.HasValue
? existingScenes.FirstOrDefault(scene => scene.SceneID == item.SceneID.Value)
: existingScenes.Count > index ? existingScenes[index] : null;
var preserveExistingChronology = HasExistingChronology(existingScene);
var operation = "preparing scene";
try
{
operation = "saving scene";
var relativeTimeText = TrimOptionalText(
preserveExistingChronology
? existingScene?.RelativeTimeText
: item.RelativeTimeText ?? 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 = item.SceneID ?? existingScene?.SceneID,
ChapterID = request.ChapterID,
SceneNumber = Convert.ToDecimal(item.TemporarySceneNumber),
SceneTitle = sceneTitle,
Summary = item.Summary,
TimeModeID = preserveExistingChronology ? existingScene!.TimeModeID : item.TimeModeID,
StartDateTime = preserveExistingChronology ? existingScene!.StartDateTime : item.StartDateTime,
EndDateTime = preserveExistingChronology ? existingScene!.EndDateTime : item.EndDateTime,
DurationAmount = preserveExistingChronology ? existingScene!.DurationAmount : item.DurationAmount,
DurationUnitID = preserveExistingChronology ? existingScene!.DurationUnitID : item.DurationUnitID,
RelativeTimeText = relativeTimeText,
TimeConfidenceID = preserveExistingChronology ? existingScene!.TimeConfidenceID : item.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(existingScenes.Count > 0 ? "Enriched existing structural scenes." : "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 = existingScenes.Count > 0
? $"Enriched {scenesCreated:N0} existing scene(s)."
: $"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 bool HasExistingChronology(Scene? scene)
=> scene is not null
&& (scene.StartDateTime.HasValue
|| scene.EndDateTime.HasValue
|| scene.DurationAmount.HasValue
|| scene.DurationUnitID.HasValue
|| !string.IsNullOrWhiteSpace(scene.RelativeTimeText));
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();
}
}