PlotDirector/PlotLine/Services/StoryIntelligenceResultPersistenceService.cs
2026-07-04 21:04:40 +01:00

188 lines
8.4 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<StoryIntelligenceSavedRunsViewModel> ListRunsAsync();
Task<StoryIntelligenceSavedRunDetailViewModel?> GetRunDetailAsync(int runId);
}
public sealed class StoryIntelligenceResultPersistenceService(
IStoryIntelligenceResultRepository repository,
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<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 status = !string.IsNullOrWhiteSpace(result.ErrorMessage) || result.PipelineStopped
? StoryIntelligenceRunStatuses.Failed
: errorCount > 0 || warningCount > 0 || result.SceneBlocks.Any(block => !block.Success && block.SplitValid)
? StoryIntelligenceRunStatuses.CompletedWithIssues
: StoryIntelligenceRunStatuses.Completed;
return new StoryIntelligenceRunSaveRequest
{
UserID = userId,
ProjectID = form.ProjectID,
BookID = form.BookID,
Status = status,
SourceType = "AdminDryRun",
PromptVersion = $"Chapter={result.ChapterPromptVersion}; Scene={result.ScenePromptVersion}",
Model = result.Model,
StartedUtc = startedUtc,
CompletedUtc = completedUtc,
TotalInputTokens = result.TotalInputTokens,
TotalOutputTokens = result.TotalOutputTokens,
TotalTokens = SumTokens(result.TotalInputTokens, result.TotalOutputTokens),
TotalDurationMs = result.TotalDurationMs,
ErrorMessage = FirstError(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 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;
}