288 lines
13 KiB
C#
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);
|
|
}
|
|
}
|