443 lines
20 KiB
C#
443 lines
20 KiB
C#
using System.Diagnostics;
|
|
using System.Text.Json;
|
|
using Microsoft.Extensions.Options;
|
|
using PlotLine.Data;
|
|
using PlotLine.Models;
|
|
|
|
namespace PlotLine.Services;
|
|
|
|
public interface IPersistedStoryIntelligenceRunner
|
|
{
|
|
Task ProcessNextAsync(CancellationToken cancellationToken);
|
|
}
|
|
|
|
public sealed class PersistedStoryIntelligenceRunner(
|
|
IStoryIntelligenceResultRepository repository,
|
|
IStoryPromptRepository prompts,
|
|
IStoryPromptVersionService versions,
|
|
IStoryPromptBuilder scenePromptBuilder,
|
|
IStoryIntelligenceClient client,
|
|
IChapterStructureValidator chapterValidator,
|
|
IStorySceneValidator sceneValidator,
|
|
IOptions<StoryIntelligencePricingOptions> pricingOptions,
|
|
ILogger<PersistedStoryIntelligenceRunner> logger) : IPersistedStoryIntelligenceRunner
|
|
{
|
|
private const string ChapterPromptFile = "Chapter-Structure-Prompt-V1.md";
|
|
private const string ScenePromptFile = "Scene-Prompt-V1.md";
|
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
|
{
|
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
|
PropertyNameCaseInsensitive = true,
|
|
WriteIndented = true
|
|
};
|
|
|
|
private readonly StoryIntelligencePricingOptions pricing = pricingOptions.Value;
|
|
|
|
public async Task ProcessNextAsync(CancellationToken cancellationToken)
|
|
{
|
|
var run = await repository.ClaimNextPendingAsync();
|
|
if (run is null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
await ProcessAsync(run, cancellationToken);
|
|
}
|
|
|
|
private async Task ProcessAsync(StoryIntelligenceQueuedRun run, CancellationToken cancellationToken)
|
|
{
|
|
var stopwatch = Stopwatch.StartNew();
|
|
var totals = new TokenTotals();
|
|
var chapterPromptVersion = versions.GetPromptVersion(ChapterPromptFile);
|
|
var scenePromptVersion = versions.GetPromptVersion(ScenePromptFile);
|
|
int? chapterResultId = null;
|
|
var failedScenes = 0;
|
|
var completedScenes = 0;
|
|
var hasWarnings = false;
|
|
|
|
try
|
|
{
|
|
if (string.IsNullOrWhiteSpace(run.SourceText))
|
|
{
|
|
throw new StoryIntelligenceRunFailureException(
|
|
StoryIntelligenceFailureStages.DocumentRead,
|
|
"Story Intelligence run has no source text.");
|
|
}
|
|
|
|
await ThrowIfCancellationRequestedAsync(run.StoryIntelligenceRunID, stopwatch.ElapsedMilliseconds, cancellationToken);
|
|
var paragraphs = SplitParagraphs(run.SourceText);
|
|
if (paragraphs.Count == 0)
|
|
{
|
|
throw new StoryIntelligenceRunFailureException(
|
|
StoryIntelligenceFailureStages.DocumentRead,
|
|
"Story Intelligence run source text does not contain readable paragraphs.");
|
|
}
|
|
|
|
var chapterContextJson = BuildChapterContextJson(run);
|
|
var chapterTemplate = await prompts.LoadPromptAsync(ChapterPromptFile, cancellationToken);
|
|
var chapterPrompt = BuildChapterPrompt(chapterTemplate, chapterContextJson, run.SourceText);
|
|
|
|
await repository.UpdateProgressAsync(
|
|
run.StoryIntelligenceRunID,
|
|
currentStage: StoryIntelligenceFailureStages.ChapterStructure,
|
|
currentMessage: "Running Chapter Structure analysis.",
|
|
totalDurationMs: stopwatch.ElapsedMilliseconds);
|
|
|
|
var chapterClientResult = await client.ExecutePromptAsync(chapterPrompt, chapterPromptVersion, cancellationToken);
|
|
totals.Add(chapterClientResult);
|
|
var chapterJson = ExtractOutputText(chapterClientResult.RawResponseText);
|
|
var parsedChapter = JsonSerializer.Deserialize<ChapterStructureModel>(chapterJson, JsonOptions);
|
|
var chapterValidation = chapterValidator.Validate(parsedChapter, paragraphs.Count);
|
|
hasWarnings = chapterValidation.Warnings.Count > 0;
|
|
chapterResultId = await repository.SaveChapterResultAsync(
|
|
run.StoryIntelligenceRunID,
|
|
new StoryIntelligenceChapterResultSaveRequest
|
|
{
|
|
ProjectID = run.ProjectID,
|
|
BookID = run.BookID,
|
|
ChapterID = run.ChapterID,
|
|
ChapterNumber = ChapterNumber(run),
|
|
SourceLabel = SourceLabel(run),
|
|
PromptVersion = chapterPromptVersion,
|
|
Model = chapterClientResult.Model,
|
|
RawResponseJson = chapterClientResult.RawResponseText,
|
|
OutputTextJson = chapterJson,
|
|
ParsedJson = SerializeParsed(parsedChapter),
|
|
ValidationErrorsCount = chapterValidation.Errors.Count,
|
|
ValidationWarningsCount = chapterValidation.Warnings.Count,
|
|
InputTokens = chapterClientResult.InputTokens,
|
|
OutputTokens = chapterClientResult.OutputTokens,
|
|
TotalTokens = SumTokens(chapterClientResult.InputTokens, chapterClientResult.OutputTokens),
|
|
DurationMs = Convert.ToInt64(chapterClientResult.Duration.TotalMilliseconds)
|
|
});
|
|
|
|
if (!chapterValidation.IsValid || parsedChapter?.SceneBoundaries is null)
|
|
{
|
|
throw new StoryIntelligenceRunFailureException(
|
|
StoryIntelligenceFailureStages.ChapterStructure,
|
|
"Chapter Structure validation failed.",
|
|
BuildValidationDetail(chapterValidation));
|
|
}
|
|
|
|
var sceneBlocks = parsedChapter.SceneBoundaries
|
|
.Select(boundary => BuildSceneBlock(run, paragraphs, boundary))
|
|
.ToList();
|
|
failedScenes += sceneBlocks.Count(block => !block.SplitValid);
|
|
|
|
await repository.UpdateProgressAsync(
|
|
run.StoryIntelligenceRunID,
|
|
currentStage: StoryIntelligenceFailureStages.SceneSplit,
|
|
currentMessage: $"Detected {sceneBlocks.Count:N0} suggested scene(s).",
|
|
totalDetectedScenes: sceneBlocks.Count,
|
|
failedScenes: failedScenes,
|
|
totalInputTokens: totals.InputTokens,
|
|
totalOutputTokens: totals.OutputTokens,
|
|
totalTokens: totals.TotalTokens,
|
|
totalDurationMs: stopwatch.ElapsedMilliseconds,
|
|
estimatedCostUSD: EstimateUsd(totals),
|
|
estimatedCostGBP: EstimateGbp(totals));
|
|
|
|
var sceneTemplate = await prompts.LoadPromptAsync(ScenePromptFile, cancellationToken);
|
|
foreach (var block in sceneBlocks)
|
|
{
|
|
await ThrowIfCancellationRequestedAsync(run.StoryIntelligenceRunID, stopwatch.ElapsedMilliseconds, cancellationToken);
|
|
if (!block.SplitValid)
|
|
{
|
|
await PersistFailedSceneAsync(run, chapterResultId, block, scenePromptVersion, "Scene boundary range is invalid for the submitted chapter paragraphs.");
|
|
continue;
|
|
}
|
|
|
|
await repository.UpdateProgressAsync(
|
|
run.StoryIntelligenceRunID,
|
|
currentStage: StoryIntelligenceFailureStages.SceneIntelligence,
|
|
currentMessage: $"Running Scene Intelligence for suggested scene {block.TemporarySceneNumber:N0}.",
|
|
completedScenes: completedScenes,
|
|
failedScenes: failedScenes,
|
|
totalDurationMs: stopwatch.ElapsedMilliseconds);
|
|
|
|
try
|
|
{
|
|
var completedScenePrompt = scenePromptBuilder.BuildPrompt(sceneTemplate, block.SceneContextJson, block.SceneText);
|
|
var sceneClientResult = await client.ExecutePromptAsync(completedScenePrompt, scenePromptVersion, cancellationToken);
|
|
totals.Add(sceneClientResult);
|
|
var sceneJson = ExtractOutputText(sceneClientResult.RawResponseText);
|
|
var parsedScene = JsonSerializer.Deserialize<SceneIntelligenceScene>(sceneJson, JsonOptions);
|
|
var validation = sceneValidator.Validate(parsedScene);
|
|
hasWarnings = hasWarnings || validation.Warnings.Count > 0;
|
|
|
|
await repository.SaveSceneResultAsync(
|
|
run.StoryIntelligenceRunID,
|
|
chapterResultId,
|
|
new StoryIntelligenceSceneResultSaveRequest
|
|
{
|
|
ProjectID = run.ProjectID,
|
|
BookID = run.BookID,
|
|
ChapterID = run.ChapterID,
|
|
SceneID = null,
|
|
TemporarySceneNumber = block.TemporarySceneNumber,
|
|
StartParagraph = block.StartParagraph,
|
|
EndParagraph = block.EndParagraph,
|
|
SourceLabel = block.SourceLabel,
|
|
PromptVersion = scenePromptVersion,
|
|
Model = sceneClientResult.Model,
|
|
RawResponseJson = sceneClientResult.RawResponseText,
|
|
OutputTextJson = sceneJson,
|
|
ParsedJson = SerializeParsed(parsedScene),
|
|
ValidationErrorsCount = validation.Errors.Count,
|
|
ValidationWarningsCount = validation.Warnings.Count,
|
|
InputTokens = sceneClientResult.InputTokens,
|
|
OutputTokens = sceneClientResult.OutputTokens,
|
|
TotalTokens = SumTokens(sceneClientResult.InputTokens, sceneClientResult.OutputTokens),
|
|
DurationMs = Convert.ToInt64(sceneClientResult.Duration.TotalMilliseconds)
|
|
});
|
|
|
|
completedScenes++;
|
|
if (!validation.IsValid || validation.Warnings.Count > 0)
|
|
{
|
|
failedScenes += validation.Errors.Count > 0 ? 1 : 0;
|
|
}
|
|
}
|
|
catch (Exception ex) when (ex is not OperationCanceledException && !IsFatal(ex))
|
|
{
|
|
failedScenes++;
|
|
await PersistFailedSceneAsync(run, chapterResultId, block, scenePromptVersion, ex.Message);
|
|
logger.LogWarning(ex, "Story Intelligence scene failed for run {StoryIntelligenceRunID}, scene {TemporarySceneNumber}.", run.StoryIntelligenceRunID, block.TemporarySceneNumber);
|
|
}
|
|
|
|
await repository.UpdateProgressAsync(
|
|
run.StoryIntelligenceRunID,
|
|
completedScenes: completedScenes,
|
|
failedScenes: failedScenes,
|
|
totalInputTokens: totals.InputTokens,
|
|
totalOutputTokens: totals.OutputTokens,
|
|
totalTokens: totals.TotalTokens,
|
|
totalDurationMs: stopwatch.ElapsedMilliseconds,
|
|
estimatedCostUSD: EstimateUsd(totals),
|
|
estimatedCostGBP: EstimateGbp(totals));
|
|
}
|
|
|
|
var finalStatus = failedScenes > 0 || hasWarnings
|
|
? StoryIntelligenceRunStatuses.CompletedWithWarnings
|
|
: StoryIntelligenceRunStatuses.Completed;
|
|
|
|
await repository.CompleteRunAsync(
|
|
run.StoryIntelligenceRunID,
|
|
finalStatus,
|
|
totals.InputTokens,
|
|
totals.OutputTokens,
|
|
totals.TotalTokens,
|
|
stopwatch.ElapsedMilliseconds,
|
|
EstimateGbp(totals),
|
|
EstimateUsd(totals));
|
|
}
|
|
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
|
{
|
|
await repository.CancelRunAsync(run.StoryIntelligenceRunID, stopwatch.ElapsedMilliseconds);
|
|
}
|
|
catch (StoryIntelligenceRunFailureException ex)
|
|
{
|
|
await repository.FailRunAsync(run.StoryIntelligenceRunID, ex.FailureStage, ex.Message, ex.Detail, stopwatch.ElapsedMilliseconds);
|
|
}
|
|
catch (Exception ex) when (ex is not OperationCanceledException)
|
|
{
|
|
await repository.FailRunAsync(
|
|
run.StoryIntelligenceRunID,
|
|
ClassifyFailureStage(ex),
|
|
ex.Message,
|
|
ex.ToString(),
|
|
stopwatch.ElapsedMilliseconds);
|
|
}
|
|
}
|
|
|
|
private async Task ThrowIfCancellationRequestedAsync(int runId, long elapsedMs, CancellationToken cancellationToken)
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
var latest = await repository.GetRunAsync(runId);
|
|
if (latest?.CancellationRequestedUtc.HasValue == true || string.Equals(latest?.Status, StoryIntelligenceRunStatuses.Cancelled, StringComparison.Ordinal))
|
|
{
|
|
await repository.CancelRunAsync(runId, elapsedMs);
|
|
throw new OperationCanceledException();
|
|
}
|
|
}
|
|
|
|
private async Task PersistFailedSceneAsync(
|
|
StoryIntelligenceQueuedRun run,
|
|
int? chapterResultId,
|
|
StorySceneTextBlock block,
|
|
string scenePromptVersion,
|
|
string message)
|
|
{
|
|
await repository.SaveSceneResultAsync(
|
|
run.StoryIntelligenceRunID,
|
|
chapterResultId,
|
|
new StoryIntelligenceSceneResultSaveRequest
|
|
{
|
|
ProjectID = run.ProjectID,
|
|
BookID = run.BookID,
|
|
ChapterID = run.ChapterID,
|
|
SceneID = null,
|
|
TemporarySceneNumber = block.TemporarySceneNumber,
|
|
StartParagraph = block.StartParagraph,
|
|
EndParagraph = block.EndParagraph,
|
|
SourceLabel = block.SourceLabel,
|
|
PromptVersion = scenePromptVersion,
|
|
Model = run.Model,
|
|
RawResponseJson = string.Empty,
|
|
OutputTextJson = string.Empty,
|
|
ParsedJson = JsonSerializer.Serialize(new { error = message }, JsonOptions),
|
|
ValidationErrorsCount = 1,
|
|
ValidationWarningsCount = 0
|
|
});
|
|
}
|
|
|
|
private decimal? EstimateUsd(TokenTotals totals)
|
|
{
|
|
if (!pricing.InputPerMillionUsd.HasValue || !pricing.OutputPerMillionUsd.HasValue)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var inputCost = (totals.InputTokens ?? 0) / 1_000_000m * pricing.InputPerMillionUsd.Value;
|
|
var outputCost = (totals.OutputTokens ?? 0) / 1_000_000m * pricing.OutputPerMillionUsd.Value;
|
|
return inputCost + outputCost;
|
|
}
|
|
|
|
private decimal? EstimateGbp(TokenTotals totals)
|
|
{
|
|
var usd = EstimateUsd(totals);
|
|
return usd.HasValue && pricing.UsdToGbpRate.HasValue ? usd.Value * pricing.UsdToGbpRate.Value : null;
|
|
}
|
|
|
|
private static StorySceneTextBlock BuildSceneBlock(StoryIntelligenceQueuedRun run, IReadOnlyList<string> paragraphs, ChapterSceneBoundary boundary)
|
|
{
|
|
var sceneNumber = boundary.SceneNumber ?? 0;
|
|
var start = boundary.StartParagraph ?? 0;
|
|
var end = boundary.EndParagraph ?? 0;
|
|
var sourceLabel = $"{SourceLabel(run)}, suggested scene {sceneNumber}";
|
|
|
|
if (sceneNumber <= 0 || start <= 0 || end <= 0 || start > end || end > paragraphs.Count)
|
|
{
|
|
return new StorySceneTextBlock(sceneNumber, start, end, sourceLabel, string.Empty, string.Empty, false);
|
|
}
|
|
|
|
var sceneText = string.Join(Environment.NewLine + Environment.NewLine, paragraphs.Skip(start - 1).Take(end - start + 1));
|
|
return new StorySceneTextBlock(
|
|
sceneNumber,
|
|
start,
|
|
end,
|
|
sourceLabel,
|
|
sceneText,
|
|
BuildSceneContextJson(run, boundary),
|
|
true);
|
|
}
|
|
|
|
private static string BuildChapterContextJson(StoryIntelligenceQueuedRun run)
|
|
=> JsonSerializer.Serialize(new
|
|
{
|
|
projectId = run.ProjectID,
|
|
bookId = run.BookID,
|
|
chapterId = run.ChapterID,
|
|
chapterNumber = ChapterNumber(run),
|
|
sourceLabel = SourceLabel(run)
|
|
}, JsonOptions);
|
|
|
|
private static string BuildSceneContextJson(StoryIntelligenceQueuedRun run, ChapterSceneBoundary boundary)
|
|
=> JsonSerializer.Serialize(new
|
|
{
|
|
projectId = run.ProjectID,
|
|
bookId = run.BookID,
|
|
chapterId = run.ChapterID,
|
|
sceneId = (int?)null,
|
|
chapterNumber = ChapterNumber(run),
|
|
sceneNumber = boundary.SceneNumber,
|
|
sourceLabel = $"{SourceLabel(run)}, suggested scene {boundary.SceneNumber}"
|
|
}, JsonOptions);
|
|
|
|
private static string BuildChapterPrompt(string promptTemplate, string chapterContextJson, string chapterText)
|
|
=> promptTemplate
|
|
.Replace("{{CHAPTER_CONTEXT_JSON}}", chapterContextJson ?? string.Empty, StringComparison.Ordinal)
|
|
.Replace("{{CHAPTER_TEXT}}", chapterText ?? string.Empty, StringComparison.Ordinal);
|
|
|
|
private static string ExtractOutputText(string rawResponseText)
|
|
{
|
|
var response = JsonSerializer.Deserialize<OpenAIResponseEnvelope>(rawResponseText, JsonOptions)
|
|
?? throw new JsonException("OpenAI response envelope was empty.");
|
|
var outputText = response.Output?
|
|
.SelectMany(item => item.Content ?? [])
|
|
.FirstOrDefault(content => string.Equals(content.Type, "output_text", StringComparison.OrdinalIgnoreCase)
|
|
&& !string.IsNullOrWhiteSpace(content.Text))
|
|
?.Text;
|
|
|
|
return string.IsNullOrWhiteSpace(outputText)
|
|
? throw new JsonException("OpenAI response did not contain output_text content.")
|
|
: outputText.Trim();
|
|
}
|
|
|
|
private static List<string> SplitParagraphs(string chapterText)
|
|
=> chapterText.Split(["\r\n\r\n", "\n\n", "\r\r"], StringSplitOptions.RemoveEmptyEntries)
|
|
.Select(paragraph => paragraph.Trim())
|
|
.Where(paragraph => !string.IsNullOrWhiteSpace(paragraph))
|
|
.ToList();
|
|
|
|
private static string SerializeParsed<T>(T? value)
|
|
=> value is null ? string.Empty : JsonSerializer.Serialize(value, JsonOptions);
|
|
|
|
private static int? SumTokens(int? inputTokens, int? outputTokens)
|
|
=> inputTokens.HasValue || outputTokens.HasValue ? (inputTokens ?? 0) + (outputTokens ?? 0) : null;
|
|
|
|
private static string SourceLabel(StoryIntelligenceQueuedRun run)
|
|
=> string.IsNullOrWhiteSpace(run.SourceLabel) ? "Admin persisted Story Intelligence chapter" : run.SourceLabel.Trim();
|
|
|
|
private static decimal ChapterNumber(StoryIntelligenceQueuedRun run)
|
|
=> run.ChapterNumber ?? 1;
|
|
|
|
private static string BuildValidationDetail(PlotLine.Models.StoryIntelligence.ValidationResult validation)
|
|
=> string.Join(
|
|
Environment.NewLine,
|
|
validation.Errors.Concat(validation.Warnings).Select(issue => $"{issue.Severity} {issue.Path}: {issue.Message}"));
|
|
|
|
private static bool IsFatal(Exception ex)
|
|
=> ex is InvalidOperationException invalid
|
|
&& (invalid.Message.Contains("API key", StringComparison.OrdinalIgnoreCase)
|
|
|| invalid.Message.Contains("model is not configured", StringComparison.OrdinalIgnoreCase)
|
|
|| invalid.Message.Contains("prompt", StringComparison.OrdinalIgnoreCase));
|
|
|
|
private static string ClassifyFailureStage(Exception ex)
|
|
=> ex is FileNotFoundException
|
|
? StoryIntelligenceFailureStages.ChapterStructure
|
|
: ex is JsonException
|
|
? StoryIntelligenceFailureStages.Validation
|
|
: StoryIntelligenceFailureStages.Persistence;
|
|
|
|
private sealed class StoryIntelligenceRunFailureException(string failureStage, string message, string? detail = null) : Exception(message)
|
|
{
|
|
public string FailureStage { get; } = failureStage;
|
|
public string? Detail { get; } = detail;
|
|
}
|
|
|
|
private sealed record StorySceneTextBlock(
|
|
int TemporarySceneNumber,
|
|
int StartParagraph,
|
|
int EndParagraph,
|
|
string SourceLabel,
|
|
string SceneText,
|
|
string SceneContextJson,
|
|
bool SplitValid);
|
|
|
|
private sealed class TokenTotals
|
|
{
|
|
public int? InputTokens { get; private set; }
|
|
public int? OutputTokens { get; private set; }
|
|
public int? TotalTokens => InputTokens.HasValue || OutputTokens.HasValue ? (InputTokens ?? 0) + (OutputTokens ?? 0) : null;
|
|
|
|
public void Add(StoryIntelligenceClientResult result)
|
|
{
|
|
InputTokens = SumNullable(InputTokens, result.InputTokens);
|
|
OutputTokens = SumNullable(OutputTokens, result.OutputTokens);
|
|
}
|
|
|
|
private static int? SumNullable(int? current, int? next)
|
|
=> current.HasValue || next.HasValue ? (current ?? 0) + (next ?? 0) : null;
|
|
}
|
|
}
|