PlotDirector/PlotLine/Services/StoryIntelligenceResultPersistenceService.cs
2026-07-04 22:32:59 +01:00

288 lines
13 KiB
C#

using System.Text.Json;
using PlotLine.Data;
using PlotLine.Models;
using PlotLine.ViewModels;
namespace PlotLine.Services;
public interface IStoryIntelligenceResultPersistenceService
{
Task<int> SaveDisplayedRunAsync(int userId, string payload);
Task<int> QueueAdminTextRunAsync(int userId, ChapterStoryIntelligenceDryRunForm form);
Task CancelRunAsync(int runId);
Task<StoryIntelligenceSavedRunsViewModel> ListRunsAsync();
Task<StoryIntelligenceSavedRunDetailViewModel?> GetRunDetailAsync(int runId);
}
public sealed class StoryIntelligenceResultPersistenceService(
IStoryIntelligenceResultRepository repository,
IStoryIntelligenceClient client,
ILogger<StoryIntelligenceResultPersistenceService> logger) : IStoryIntelligenceResultPersistenceService
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
WriteIndented = true
};
public async Task<int> SaveDisplayedRunAsync(int userId, string payload)
{
if (string.IsNullOrWhiteSpace(payload))
{
throw new InvalidOperationException("No dry-run result was submitted for saving.");
}
var dryRun = JsonSerializer.Deserialize<ChapterStoryIntelligenceDryRunViewModel>(payload, JsonOptions)
?? throw new InvalidOperationException("The submitted dry-run result could not be read.");
if (dryRun.Result is null)
{
throw new InvalidOperationException("The submitted dry-run result is empty.");
}
var request = BuildSaveRequest(userId, dryRun);
var runId = await repository.SaveAsync(request);
logger.LogInformation(
"Saved Story Intelligence admin dry-run. StoryIntelligenceRunID={StoryIntelligenceRunID} UserID={UserID} Status={Status} ChapterPromptVersion={ChapterPromptVersion} ScenePromptVersion={ScenePromptVersion} Model={Model} SceneCount={SceneCount} ValidationErrors={ValidationErrors} ValidationWarnings={ValidationWarnings} TotalDurationMs={TotalDurationMs} TotalTokens={TotalTokens}",
runId,
userId,
request.Status,
dryRun.Result.ChapterPromptVersion,
dryRun.Result.ScenePromptVersion,
dryRun.Result.Model,
request.SceneResults.Count,
CountErrors(dryRun.Result),
CountWarnings(dryRun.Result),
request.TotalDurationMs,
request.TotalTokens);
return runId;
}
public async Task<int> QueueAdminTextRunAsync(int userId, ChapterStoryIntelligenceDryRunForm form)
{
if (string.IsNullOrWhiteSpace(form.ChapterText))
{
throw new InvalidOperationException("Enter chapter text before queueing a persisted Story Intelligence run.");
}
var promptVersionsSummary = "Chapter=Chapter-Structure-Prompt-V1; Scene=Scene-Prompt-V1";
var clientStatus = client.GetConfigurationStatus();
return await repository.QueueAdminTextAsync(new StoryIntelligenceRunQueueRequest
{
UserID = userId,
ProjectID = form.ProjectID,
BookID = form.BookID,
ChapterID = form.ChapterID,
ChapterNumber = form.ChapterNumber,
SourceType = "AdminText",
SourceLabel = CleanSourceLabel(form.SourceLabel),
ChapterText = form.ChapterText,
SourceWordCount = CountWords(form.ChapterText),
SourceCharacterCount = form.ChapterText.Length,
SourceParagraphCount = StoryIntelligenceParagraphs.Split(form.ChapterText).Count,
SourceChapterCount = 1,
Model = clientStatus.Model,
PromptVersionsSummary = promptVersionsSummary
});
}
public Task CancelRunAsync(int runId)
=> repository.RequestCancelAsync(runId);
public async Task<StoryIntelligenceSavedRunsViewModel> ListRunsAsync()
=> new() { Runs = await repository.ListRunsAsync() };
public async Task<StoryIntelligenceSavedRunDetailViewModel?> GetRunDetailAsync(int runId)
{
var run = await repository.GetRunAsync(runId);
if (run is null)
{
return null;
}
return new StoryIntelligenceSavedRunDetailViewModel
{
Run = run,
ChapterResult = await repository.GetChapterResultAsync(runId),
SceneResults = await repository.ListSceneResultsAsync(runId)
};
}
private static StoryIntelligenceRunSaveRequest BuildSaveRequest(int userId, ChapterStoryIntelligenceDryRunViewModel dryRun)
{
var form = dryRun.Form;
var result = dryRun.Result!;
var completedUtc = DateTime.UtcNow;
var startedUtc = result.TotalDurationMs.HasValue
? completedUtc.AddMilliseconds(-result.TotalDurationMs.Value)
: completedUtc;
var errorCount = CountErrors(result);
var warningCount = CountWarnings(result);
var sceneFailed = result.SceneBlocks.Any(block => !block.Success && block.SplitValid);
var status = !string.IsNullOrWhiteSpace(result.ErrorMessage) || result.PipelineStopped || errorCount > 0 || sceneFailed
? StoryIntelligenceRunStatuses.Failed
: warningCount > 0
? StoryIntelligenceRunStatuses.CompletedWithWarnings
: StoryIntelligenceRunStatuses.Completed;
var promptVersionsSummary = $"Chapter={result.ChapterPromptVersion}; Scene={result.ScenePromptVersion}";
return new StoryIntelligenceRunSaveRequest
{
UserID = userId,
ProjectID = form.ProjectID,
BookID = form.BookID,
Status = status,
SourceType = "AdminDryRun",
SourceWordCount = CountWords(form.ChapterText),
SourceCharacterCount = string.IsNullOrEmpty(form.ChapterText) ? null : form.ChapterText.Length,
SourceChapterCount = 1,
PromptVersion = promptVersionsSummary,
PromptVersionsSummary = promptVersionsSummary,
Model = result.Model,
StartedUtc = startedUtc,
CompletedUtc = completedUtc,
FailureStage = DetermineFailureStage(result),
TotalInputTokens = result.TotalInputTokens,
TotalOutputTokens = result.TotalOutputTokens,
TotalTokens = SumTokens(result.TotalInputTokens, result.TotalOutputTokens),
TotalDurationMs = result.TotalDurationMs,
ErrorMessage = FirstError(result),
ErrorDetail = BuildErrorDetail(result),
ChapterResult = BuildChapterResult(form, result),
SceneResults = result.SceneBlocks.Select(block => BuildSceneResult(form, result, block)).ToList()
};
}
private static StoryIntelligenceChapterResultSaveRequest BuildChapterResult(
ChapterStoryIntelligenceDryRunForm form,
ChapterStoryIntelligenceDryRunResultViewModel result)
=> new()
{
ProjectID = form.ProjectID,
BookID = form.BookID,
ChapterID = form.ChapterID,
ChapterNumber = form.ChapterNumber,
SourceLabel = CleanSourceLabel(form.SourceLabel),
PromptVersion = result.ChapterPromptVersion,
Model = result.Model,
RawResponseJson = result.ChapterRawResponseText,
OutputTextJson = result.ChapterJsonText,
ParsedJson = SerializeParsed(result.ParsedChapter),
ValidationErrorsCount = result.ChapterValidation?.Errors.Count ?? 0,
ValidationWarningsCount = result.ChapterValidation?.Warnings.Count ?? 0,
InputTokens = result.ChapterInputTokens,
OutputTokens = result.ChapterOutputTokens,
TotalTokens = SumTokens(result.ChapterInputTokens, result.ChapterOutputTokens),
DurationMs = result.ChapterDurationMs
};
private static StoryIntelligenceSceneResultSaveRequest BuildSceneResult(
ChapterStoryIntelligenceDryRunForm form,
ChapterStoryIntelligenceDryRunResultViewModel result,
ChapterStorySceneBlockViewModel block)
=> new()
{
ProjectID = form.ProjectID,
BookID = form.BookID,
ChapterID = form.ChapterID,
SceneID = null,
TemporarySceneNumber = block.TemporarySceneNumber,
StartParagraph = block.StartParagraph > 0 ? block.StartParagraph : null,
EndParagraph = block.EndParagraph > 0 ? block.EndParagraph : null,
SourceLabel = $"{CleanSourceLabel(form.SourceLabel)}, suggested scene {block.TemporarySceneNumber}",
PromptVersion = result.ScenePromptVersion,
Model = result.Model,
RawResponseJson = block.RawResponseText,
OutputTextJson = block.SceneJsonText,
ParsedJson = SerializeParsed(block.ParsedScene),
ValidationErrorsCount = block.Validation?.Errors.Count ?? 0,
ValidationWarningsCount = block.Validation?.Warnings.Count ?? 0,
InputTokens = block.InputTokens,
OutputTokens = block.OutputTokens,
TotalTokens = SumTokens(block.InputTokens, block.OutputTokens),
DurationMs = block.DurationMs
};
private static string SerializeParsed<T>(T? value)
=> value is null ? string.Empty : JsonSerializer.Serialize(value, JsonOptions);
private static string CleanSourceLabel(string? value)
=> string.IsNullOrWhiteSpace(value) ? "Admin pipeline test chapter" : value.Trim();
private static int? SumTokens(int? inputTokens, int? outputTokens)
=> inputTokens.HasValue || outputTokens.HasValue ? (inputTokens ?? 0) + (outputTokens ?? 0) : null;
private static int? CountWords(string? value)
=> string.IsNullOrWhiteSpace(value)
? null
: value.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries).Length;
private static int CountErrors(ChapterStoryIntelligenceDryRunResultViewModel result)
=> (result.ChapterValidation?.Errors.Count ?? 0)
+ result.SceneBlocks.Sum(block => block.Validation?.Errors.Count ?? 0);
private static int CountWarnings(ChapterStoryIntelligenceDryRunResultViewModel result)
=> (result.ChapterValidation?.Warnings.Count ?? 0)
+ result.SceneBlocks.Sum(block => block.Validation?.Warnings.Count ?? 0);
private static string? FirstError(ChapterStoryIntelligenceDryRunResultViewModel result)
=> !string.IsNullOrWhiteSpace(result.ErrorMessage)
? result.ErrorMessage
: result.SceneBlocks.FirstOrDefault(block => !string.IsNullOrWhiteSpace(block.ErrorMessage))?.ErrorMessage;
private static string? DetermineFailureStage(ChapterStoryIntelligenceDryRunResultViewModel result)
{
if (string.IsNullOrWhiteSpace(result.ErrorMessage)
&& !result.PipelineStopped
&& result.ChapterValidation?.Errors.Count is not > 0
&& result.SceneBlocks.All(block => block.Success || !block.SplitValid)
&& result.SceneBlocks.All(block => block.Validation?.Errors.Count is not > 0))
{
return null;
}
if (result.ChapterValidation?.Errors.Count is > 0 || result.StopReason.Contains("validation", StringComparison.OrdinalIgnoreCase))
{
return StoryIntelligenceFailureStages.Validation;
}
if (result.SceneBlocks.Any(block => !block.SplitValid))
{
return StoryIntelligenceFailureStages.SceneSplit;
}
if (result.SceneBlocks.Any(block => !block.Success || !string.IsNullOrWhiteSpace(block.ErrorMessage)))
{
return StoryIntelligenceFailureStages.SceneIntelligence;
}
return StoryIntelligenceFailureStages.ChapterStructure;
}
private static string? BuildErrorDetail(ChapterStoryIntelligenceDryRunResultViewModel result)
{
var details = new List<string>();
if (!string.IsNullOrWhiteSpace(result.StopReason))
{
details.Add(result.StopReason);
}
if (!string.IsNullOrWhiteSpace(result.ErrorMessage))
{
details.Add(result.ErrorMessage);
}
details.AddRange(result.ChapterValidation?.Errors.Select(issue => $"{issue.Path}: {issue.Message}") ?? []);
details.AddRange(result.SceneBlocks
.Where(block => !string.IsNullOrWhiteSpace(block.ErrorMessage))
.Select(block => $"Scene {block.TemporarySceneNumber}: {block.ErrorMessage}"));
details.AddRange(result.SceneBlocks
.Where(block => block.Validation is not null)
.SelectMany(block => block.Validation!.Errors.Select(issue => $"Scene {block.TemporarySceneNumber} {issue.Path}: {issue.Message}")));
return details.Count == 0 ? null : string.Join(Environment.NewLine, details);
}
}