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

455 lines
21 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;
var currentFailureStage = StoryIntelligenceFailureStages.DocumentRead;
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 = StoryIntelligenceParagraphs.Split(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 numberedChapterText = StoryIntelligenceParagraphs.Number(paragraphs);
var chapterPrompt = BuildChapterPrompt(chapterTemplate, chapterContextJson, numberedChapterText);
currentFailureStage = StoryIntelligenceFailureStages.ChapterStructure;
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);
currentFailureStage = StoryIntelligenceFailureStages.SceneSplit;
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
{
currentFailureStage = StoryIntelligenceFailureStages.SceneIntelligence;
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 (StoryIntelligenceRunCancelledException)
{
await repository.CancelRunAsync(run.StoryIntelligenceRunID, stopwatch.ElapsedMilliseconds);
}
catch (OperationCanceledException ex) when (!cancellationToken.IsCancellationRequested)
{
await repository.FailRunAsync(
run.StoryIntelligenceRunID,
currentFailureStage,
"Story Intelligence request timed out or was cancelled before the run completed.",
ex.ToString(),
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 StoryIntelligenceRunCancelledException();
}
}
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 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 class StoryIntelligenceRunCancelledException : Exception
{
}
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;
}
}