PlotDirector/PlotLine/Services/PersistedStoryIntelligenceRunner.cs

1076 lines
48 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<StoryIntelligenceOptions> options,
IOptions<StoryIntelligencePricingOptions> pricingOptions,
IStoryIntelligenceProgressNotifier notifier,
ILogger<PersistedStoryIntelligenceRunner> logger) : IPersistedStoryIntelligenceRunner
{
private const string ChapterPromptFile = "Chapter-Structure-Prompt-V2.md";
private const string ScenePromptFile = "Scene-Prompt-V2.md";
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
WriteIndented = true
};
private readonly StoryIntelligenceOptions settings = options.Value;
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);
var stageModels = ResolveStageModels(run);
int? chapterResultId = null;
var failedScenes = 0;
var completedScenes = 0;
var hasWarnings = false;
var currentFailureStage = StoryIntelligenceFailureStages.DocumentRead;
try
{
await PublishAsync(
run,
"Import started",
StoryIntelligenceRunStatuses.Running,
StoryIntelligenceFailureStages.DocumentRead,
"Story Intelligence has started reading this chapter.",
stopwatch.ElapsedMilliseconds);
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);
await PublishAsync(
run,
"Chapter started",
StoryIntelligenceRunStatuses.Running,
StoryIntelligenceFailureStages.ChapterStructure,
"PlotDirector is finding the scene boundaries in this chapter.",
stopwatch.ElapsedMilliseconds);
var chapterClientResult = await client.ExecutePromptAsync(
chapterPrompt,
chapterPromptVersion,
cancellationToken,
stageModels.ChapterStructureModel);
totals.Add(chapterClientResult, pricing);
AiJsonParseResult<ChapterStructureModel> chapterAttempt;
try
{
chapterAttempt = await ParseAiJsonWithRetryAsync<ChapterStructureModel>(
chapterPrompt,
chapterPromptVersion,
stageModels.ChapterStructureModel,
chapterClientResult,
totals,
"Chapter Structure",
cancellationToken);
}
catch (AiJsonParseException ex)
{
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 = stageModels.ChapterStructureModel,
RawResponseJson = ex.RawResponseJson,
OutputTextJson = ex.RawAssistantOutput,
ParsedJson = SerialiseFailedAiJsonParse(ex),
ValidationErrorsCount = 1,
ValidationWarningsCount = ex.RetryAttempted ? 1 : 0,
InputTokens = ex.InputTokens,
OutputTokens = ex.OutputTokens,
TotalTokens = SumTokens(ex.InputTokens, ex.OutputTokens),
DurationMs = Convert.ToInt64(ex.Duration.TotalMilliseconds)
});
throw new StoryIntelligenceRunFailureException(
StoryIntelligenceFailureStages.Validation,
"Chapter Structure JSON could not be parsed after one retry.",
ex.ToString());
}
var parsedChapter = chapterAttempt.Parsed;
var chapterNormalisation = ChapterStructureBoundaryNormaliser.Normalise(parsedChapter, paragraphs.Count);
var chapterForProcessing = chapterNormalisation.ChapterStructure;
var chapterValidation = chapterValidator.Validate(chapterForProcessing, paragraphs.Count);
foreach (var warning in chapterAttempt.Warnings)
{
chapterValidation.Warnings.Add(new PlotLine.Models.StoryIntelligence.ValidationIssue
{
Severity = "Warning",
Path = "$",
Message = warning,
SuggestedFix = "Review the raw and repaired Chapter Structure JSON."
});
}
ChapterStructureBoundaryNormaliser.AddIssuesTo(chapterValidation, chapterNormalisation);
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 = chapterAttempt.Model,
RawResponseJson = chapterAttempt.RawResponseJson,
OutputTextJson = chapterAttempt.RawAssistantOutput,
ParsedJson = SerialiseChapterParseAudit(chapterForProcessing, chapterNormalisation, chapterAttempt),
ValidationErrorsCount = chapterValidation.Errors.Count,
ValidationWarningsCount = chapterValidation.Warnings.Count,
InputTokens = chapterAttempt.InputTokens,
OutputTokens = chapterAttempt.OutputTokens,
TotalTokens = SumTokens(chapterAttempt.InputTokens, chapterAttempt.OutputTokens),
DurationMs = Convert.ToInt64(chapterAttempt.Duration.TotalMilliseconds)
});
if (!chapterValidation.IsValid || chapterForProcessing?.SceneBoundaries is null)
{
throw new StoryIntelligenceRunFailureException(
StoryIntelligenceFailureStages.ChapterStructure,
"Chapter Structure validation failed.",
BuildValidationDetail(chapterValidation));
}
var sceneBlocks = chapterForProcessing.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: totals.EstimatedCostUSD,
estimatedCostGBP: totals.EstimatedCostGBP);
await PublishAsync(
run,
"Scene boundaries detected",
StoryIntelligenceRunStatuses.Running,
StoryIntelligenceFailureStages.SceneSplit,
$"Detected {sceneBlocks.Count:N0} suggested scene(s).",
stopwatch.ElapsedMilliseconds,
totalDetectedScenes: sceneBlocks.Count,
completedScenes: completedScenes,
failedScenes: failedScenes,
totalTokens: totals.TotalTokens);
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.");
await PublishAsync(
run,
"Scene analysis failed",
StoryIntelligenceRunStatuses.Running,
StoryIntelligenceFailureStages.SceneIntelligence,
$"Suggested scene {block.TemporarySceneNumber:N0} could not be analysed because its paragraph range was invalid.",
stopwatch.ElapsedMilliseconds,
totalDetectedScenes: sceneBlocks.Count,
completedScenes: completedScenes,
failedScenes: failedScenes,
totalTokens: totals.TotalTokens);
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);
await PublishAsync(
run,
"Scene analysis started",
StoryIntelligenceRunStatuses.Running,
StoryIntelligenceFailureStages.SceneIntelligence,
$"Reading scene {block.TemporarySceneNumber:N0}.",
stopwatch.ElapsedMilliseconds,
totalDetectedScenes: sceneBlocks.Count,
completedScenes: completedScenes,
failedScenes: failedScenes,
totalTokens: totals.TotalTokens);
try
{
currentFailureStage = StoryIntelligenceFailureStages.SceneIntelligence;
var completedScenePrompt = scenePromptBuilder.BuildPrompt(sceneTemplate, block.SceneContextJson, block.SceneText);
var sceneClientResult = await client.ExecutePromptAsync(
completedScenePrompt,
scenePromptVersion,
cancellationToken,
stageModels.SceneIntelligenceModel,
SceneIntelligenceMaxOutputTokens());
totals.Add(sceneClientResult, pricing);
var sceneAttempt = await ParseAiJsonWithRetryAsync<SceneIntelligenceScene>(
completedScenePrompt,
scenePromptVersion,
stageModels.SceneIntelligenceModel,
sceneClientResult,
totals,
"Scene Intelligence",
cancellationToken);
var validation = sceneValidator.Validate(sceneAttempt.Parsed);
foreach (var warning in sceneAttempt.Warnings)
{
validation.Warnings.Add(new PlotLine.Models.StoryIntelligence.ValidationIssue
{
Severity = "Warning",
Path = "$",
Message = warning,
SuggestedFix = "Review the raw and repaired Scene Intelligence JSON."
});
}
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 = sceneAttempt.RawResponseJson,
OutputTextJson = sceneAttempt.RawAssistantOutput,
ParsedJson = SerialiseSceneParseAudit(sceneAttempt, validation.IsValid),
ValidationErrorsCount = validation.Errors.Count,
ValidationWarningsCount = validation.Warnings.Count,
InputTokens = sceneAttempt.InputTokens,
OutputTokens = sceneAttempt.OutputTokens,
TotalTokens = SumTokens(sceneAttempt.InputTokens, sceneAttempt.OutputTokens),
DurationMs = Convert.ToInt64(sceneAttempt.Duration.TotalMilliseconds)
});
completedScenes++;
await PublishAsync(
run,
"Scene analysis completed",
StoryIntelligenceRunStatuses.Running,
StoryIntelligenceFailureStages.SceneIntelligence,
$"Scene {block.TemporarySceneNumber:N0} analysed.",
stopwatch.ElapsedMilliseconds,
totalDetectedScenes: sceneBlocks.Count,
completedScenes: completedScenes,
failedScenes: failedScenes,
totalTokens: totals.TotalTokens,
latestSceneSummary: TrimOptional(sceneAttempt.Parsed?.Summary?.Short, 220),
recentDiscoveries: BuildDiscoveries(sceneAttempt.Parsed));
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, ex as AiJsonParseException);
await PublishAsync(
run,
"Scene analysis failed",
StoryIntelligenceRunStatuses.Running,
StoryIntelligenceFailureStages.SceneIntelligence,
$"Scene {block.TemporarySceneNumber:N0} needs attention.",
stopwatch.ElapsedMilliseconds,
totalDetectedScenes: sceneBlocks.Count,
completedScenes: completedScenes,
failedScenes: failedScenes,
totalTokens: totals.TotalTokens,
errorMessage: 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: totals.EstimatedCostUSD,
estimatedCostGBP: totals.EstimatedCostGBP);
}
var finalStatus = failedScenes > 0 || hasWarnings
? StoryIntelligenceRunStatuses.CompletedWithWarnings
: StoryIntelligenceRunStatuses.Completed;
await repository.CompleteRunAsync(
run.StoryIntelligenceRunID,
finalStatus,
totals.InputTokens,
totals.OutputTokens,
totals.TotalTokens,
stopwatch.ElapsedMilliseconds,
totals.EstimatedCostGBP,
totals.EstimatedCostUSD);
await PublishAsync(
run,
finalStatus == StoryIntelligenceRunStatuses.Completed ? "Import completed" : "Chapter completed",
finalStatus,
StoryIntelligenceRunStatuses.Completed,
finalStatus == StoryIntelligenceRunStatuses.Completed
? "Chapter analysis complete."
: "Chapter analysis complete with notes to review.",
stopwatch.ElapsedMilliseconds,
totalDetectedScenes: sceneBlocks.Count,
completedScenes: completedScenes,
failedScenes: failedScenes,
totalTokens: totals.TotalTokens);
}
catch (StoryIntelligenceRunCancelledException)
{
await repository.CancelRunAsync(run.StoryIntelligenceRunID, stopwatch.ElapsedMilliseconds);
await PublishAsync(
run,
"Import cancelled",
StoryIntelligenceRunStatuses.Cancelled,
currentFailureStage,
"Analysis was cancelled.",
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);
await PublishAsync(
run,
"Import failed",
StoryIntelligenceRunStatuses.Failed,
currentFailureStage,
"Analysis could not finish.",
stopwatch.ElapsedMilliseconds,
errorMessage: ex.Message);
}
catch (StoryIntelligenceRunFailureException ex)
{
await repository.FailRunAsync(run.StoryIntelligenceRunID, ex.FailureStage, ex.Message, ex.Detail, stopwatch.ElapsedMilliseconds);
await PublishAsync(
run,
"Import failed",
StoryIntelligenceRunStatuses.Failed,
ex.FailureStage,
"Analysis could not finish.",
stopwatch.ElapsedMilliseconds,
errorMessage: ex.Message);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
await repository.FailRunAsync(
run.StoryIntelligenceRunID,
ClassifyFailureStage(ex),
ex.Message,
ex.ToString(),
stopwatch.ElapsedMilliseconds);
await PublishAsync(
run,
"Import failed",
StoryIntelligenceRunStatuses.Failed,
ClassifyFailureStage(ex),
"Analysis could not finish.",
stopwatch.ElapsedMilliseconds,
errorMessage: ex.Message);
}
}
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 Task PublishAsync(
StoryIntelligenceQueuedRun run,
string eventType,
string status,
string currentStage,
string currentMessage,
long elapsedMs,
int? totalDetectedScenes = null,
int? completedScenes = null,
int? failedScenes = null,
int? totalTokens = null,
string? latestSceneSummary = null,
IReadOnlyList<string>? recentDiscoveries = null,
string? errorMessage = null)
=> notifier.PublishAsync(new StoryIntelligenceRunProgressEvent
{
RunID = run.StoryIntelligenceRunID,
UserID = run.UserID,
ProjectID = run.ProjectID,
BookID = run.BookID,
ChapterID = run.ChapterID,
ChapterNumber = run.ChapterNumber,
Status = status,
CurrentStage = currentStage,
CurrentMessage = currentMessage,
EventType = eventType,
TotalDetectedScenes = totalDetectedScenes,
CompletedScenes = completedScenes,
FailedScenes = failedScenes,
TotalTokens = totalTokens,
TotalDurationMs = elapsedMs,
ElapsedTime = FormatDuration(elapsedMs),
EstimatedRemaining = EstimateRemaining(elapsedMs, completedScenes ?? 0, totalDetectedScenes ?? 0),
LatestSceneSummary = latestSceneSummary,
RecentDiscoveries = recentDiscoveries ?? [],
ErrorMessage = errorMessage,
UpdatedUtc = DateTime.UtcNow,
IsActive = status is StoryIntelligenceRunStatuses.Pending or StoryIntelligenceRunStatuses.Running,
IsCompleted = status is StoryIntelligenceRunStatuses.Completed or StoryIntelligenceRunStatuses.CompletedWithWarnings
});
private static string FormatDuration(long? milliseconds)
{
var elapsed = TimeSpan.FromMilliseconds(Math.Max(0, milliseconds ?? 0));
if (elapsed.TotalMinutes >= 1)
{
return $"{Math.Max(1, (int)Math.Round(elapsed.TotalMinutes)):N0} min";
}
return $"{Math.Max(0, (int)Math.Round(elapsed.TotalSeconds)):N0} sec";
}
private static string? EstimateRemaining(long elapsedMs, int completedScenes, int totalScenes)
{
if (totalScenes > 0 && completedScenes >= totalScenes)
{
return "Nearly finished";
}
if (completedScenes <= 0 || totalScenes <= 0)
{
return "Calculating";
}
var remainingScenes = Math.Max(0, totalScenes - completedScenes);
var averageSceneMs = elapsedMs / Math.Max(1, completedScenes);
return FormatDuration(averageSceneMs * remainingScenes);
}
private static IReadOnlyList<string> BuildDiscoveries(SceneIntelligenceScene? scene)
{
if (scene is null)
{
return [];
}
var discoveries = new List<string>();
discoveries.AddRange((scene.Characters ?? [])
.Select(character => TrimOptional(character.Name, 120))
.Where(name => !string.IsNullOrWhiteSpace(name))
.Distinct(StringComparer.OrdinalIgnoreCase)
.Take(3)
.Select(name => $"Character detected: {name}"));
discoveries.AddRange((scene.Locations ?? [])
.Select(location => TrimOptional(location.Name, 120))
.Where(name => !string.IsNullOrWhiteSpace(name))
.Distinct(StringComparer.OrdinalIgnoreCase)
.Take(3)
.Select(name => $"Location detected: {name}"));
discoveries.AddRange((scene.Assets ?? [])
.Select(asset => TrimOptional(asset.Name, 120))
.Where(name => !string.IsNullOrWhiteSpace(name))
.Distinct(StringComparer.OrdinalIgnoreCase)
.Take(2)
.Select(name => $"Asset detected: {name}"));
discoveries.AddRange((scene.Relationships ?? [])
.Select(relationship => BuildRelationshipDiscovery(relationship))
.Where(text => !string.IsNullOrWhiteSpace(text))
.OfType<string>()
.Take(2));
discoveries.AddRange((scene.TimelineClues ?? [])
.Select(clue => TrimOptional(clue.Clue, 160))
.Where(text => !string.IsNullOrWhiteSpace(text))
.Take(2)
.Select(text => $"Timeline clue detected: {text}"));
return discoveries
.Distinct(StringComparer.OrdinalIgnoreCase)
.Take(8)
.ToList();
}
private static string? BuildRelationshipDiscovery(SceneIntelligenceRelationship relationship)
{
var characterA = TrimOptional(relationship.CharacterA, 80);
var characterB = TrimOptional(relationship.CharacterB, 80);
var signal = TrimOptional(relationship.RelationshipSignal, 120);
if (string.IsNullOrWhiteSpace(characterA) || string.IsNullOrWhiteSpace(characterB))
{
return null;
}
return string.IsNullOrWhiteSpace(signal)
? $"Relationship detected: {characterA} and {characterB}"
: $"Relationship detected: {characterA} and {characterB} - {signal}";
}
private static string? TrimOptional(string? value, int maxLength)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
var trimmed = value.Trim();
return trimmed.Length <= maxLength ? trimmed : trimmed[..maxLength].TrimEnd();
}
private async Task<AiJsonParseResult<T>> ParseAiJsonWithRetryAsync<T>(
string completedPrompt,
string promptVersion,
string model,
StoryIntelligenceClientResult initialClientResult,
TokenTotals totals,
string label,
CancellationToken cancellationToken)
{
var initialRawOutput = TryExtractOutputText(initialClientResult.RawResponseText, out var initialExtractError);
var initialParse = StoryIntelligenceAiJsonParser.Parse<T>(initialRawOutput, JsonOptions);
if (initialParse.Success)
{
return AiJsonParseResult<T>.Success(
initialClientResult.Model,
initialClientResult.RawResponseText,
initialRawOutput,
initialParse.RawJson,
initialParse.RepairedJson,
initialParse.Parsed,
initialClientResult.InputTokens,
initialClientResult.OutputTokens,
initialClientResult.Duration,
retryAttempted: false,
warnings: initialParse.Warnings);
}
var initialError = initialExtractError ?? initialParse.ErrorMessage ?? $"{label} JSON could not be parsed.";
var retryPrompt = BuildJsonRetryPrompt(completedPrompt, label, initialError, initialRawOutput);
var retryClientResult = await client.ExecutePromptAsync(
retryPrompt,
promptVersion,
cancellationToken,
model,
MaxOutputTokensForLabel(label));
totals.Add(retryClientResult, pricing);
var retryRawOutput = TryExtractOutputText(retryClientResult.RawResponseText, out var retryExtractError);
var retryParse = StoryIntelligenceAiJsonParser.Parse<T>(retryRawOutput, JsonOptions);
if (retryParse.Success)
{
var warnings = new List<string> { $"{label} JSON parse failed once and succeeded after one retry." };
warnings.AddRange(initialParse.Warnings);
warnings.AddRange(retryParse.Warnings);
return AiJsonParseResult<T>.Success(
retryClientResult.Model,
CombineRawResponses(initialClientResult.RawResponseText, retryClientResult.RawResponseText),
CombineAssistantOutputs(initialRawOutput, retryRawOutput),
retryParse.RawJson,
retryParse.RepairedJson,
retryParse.Parsed,
SumTokens(initialClientResult.InputTokens, retryClientResult.InputTokens),
SumTokens(initialClientResult.OutputTokens, retryClientResult.OutputTokens),
initialClientResult.Duration + retryClientResult.Duration,
retryAttempted: true,
warnings: warnings,
initialError: initialError);
}
var retryError = retryExtractError ?? retryParse.ErrorMessage ?? $"{label} retry JSON could not be parsed.";
throw new AiJsonParseException(
$"{label} JSON could not be parsed after one retry.",
CombineRawResponses(initialClientResult.RawResponseText, retryClientResult.RawResponseText),
CombineAssistantOutputs(initialRawOutput, retryRawOutput),
initialError,
retryAttempted: true,
retryError: retryError,
inputTokens: SumTokens(initialClientResult.InputTokens, retryClientResult.InputTokens),
outputTokens: SumTokens(initialClientResult.OutputTokens, retryClientResult.OutputTokens),
duration: initialClientResult.Duration + retryClientResult.Duration);
}
private static string TryExtractOutputText(string rawResponseText, out string? error)
{
try
{
error = null;
return ExtractOutputText(rawResponseText);
}
catch (JsonException ex)
{
error = ex.Message;
return string.Empty;
}
}
private async Task PersistFailedSceneAsync(
StoryIntelligenceQueuedRun run,
int? chapterResultId,
StorySceneTextBlock block,
string scenePromptVersion,
string message,
AiJsonParseException? parseException = null)
{
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 = ResolveStageModels(run).SceneIntelligenceModel,
RawResponseJson = parseException?.RawResponseJson ?? string.Empty,
OutputTextJson = parseException?.RawAssistantOutput ?? string.Empty,
ParsedJson = JsonSerializer.Serialize(new
{
error = message,
parseError = parseException?.InitialError,
retryAttempted = parseException?.RetryAttempted ?? false,
retryError = parseException?.RetryError
}, JsonOptions),
ValidationErrorsCount = 1,
ValidationWarningsCount = parseException?.RetryAttempted == true ? 1 : 0,
InputTokens = parseException?.InputTokens,
OutputTokens = parseException?.OutputTokens,
TotalTokens = SumTokens(parseException?.InputTokens, parseException?.OutputTokens),
DurationMs = parseException is null ? null : Convert.ToInt64(parseException.Duration.TotalMilliseconds)
});
}
private StoryIntelligenceStageModels ResolveStageModels(StoryIntelligenceQueuedRun run)
=> StoryIntelligenceOptions.ParseModelSummary(
run.Model,
string.IsNullOrWhiteSpace(settings.EffectiveChapterStructureModel) ? settings.EffectiveModel : settings.EffectiveChapterStructureModel,
string.IsNullOrWhiteSpace(settings.EffectiveSceneIntelligenceModel) ? settings.EffectiveModel : settings.EffectiveSceneIntelligenceModel);
private int SceneIntelligenceMaxOutputTokens()
=> settings.SceneIntelligenceMaxOutputTokens.GetValueOrDefault(settings.MaxOutputTokens) > 0
? settings.SceneIntelligenceMaxOutputTokens.GetValueOrDefault(settings.MaxOutputTokens)
: settings.MaxOutputTokens;
private int MaxOutputTokensForLabel(string label)
=> label.Contains("Scene", StringComparison.OrdinalIgnoreCase)
? SceneIntelligenceMaxOutputTokens()
: settings.MaxOutputTokens;
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 string SerialiseChapterParseAudit(
ChapterStructureModel? chapterStructure,
ChapterStructureBoundaryNormalisationResult normalisation,
AiJsonParseResult<ChapterStructureModel> parseResult)
{
if (!parseResult.RepairApplied && !parseResult.RetryAttempted && normalisation.Issues.Count == 0)
{
return SerializeParsed(chapterStructure);
}
return JsonSerializer.Serialize(new
{
chapterStructure?.SchemaVersion,
chapterStructure?.ChapterSummary,
chapterStructure?.SceneBoundaries,
repairedJson = parseResult.RepairApplied ? parseResult.RepairedJson : null,
repairApplied = parseResult.RepairApplied,
retryAttempted = parseResult.RetryAttempted,
initialParseError = parseResult.InitialError,
jsonWarnings = parseResult.Warnings,
normalisation = normalisation.Issues.Select(issue => new
{
issue.Severity,
issue.Path,
issue.Message,
issue.SuggestedFix
})
}, JsonOptions);
}
private static string SerialiseSceneParseAudit(AiJsonParseResult<SceneIntelligenceScene> parseResult, bool validationPassed)
{
if (!parseResult.RepairApplied && !parseResult.RetryAttempted)
{
return SerializeParsed(parseResult.Parsed);
}
return JsonSerializer.Serialize(new
{
parsedScene = parseResult.Parsed,
repairedJson = parseResult.RepairApplied ? parseResult.RepairedJson : null,
repairApplied = parseResult.RepairApplied,
retryAttempted = parseResult.RetryAttempted,
initialParseError = parseResult.InitialError,
validationPassed,
warnings = parseResult.Warnings
}, JsonOptions);
}
private static string SerialiseFailedAiJsonParse(AiJsonParseException parseException)
=> JsonSerializer.Serialize(new
{
error = parseException.Message,
parseError = parseException.InitialError,
retryAttempted = parseException.RetryAttempted,
retryError = parseException.RetryError
}, JsonOptions);
private static string BuildJsonRetryPrompt(string originalPrompt, string label, string parseError, string malformedJson)
=> string.Concat(
originalPrompt,
Environment.NewLine,
Environment.NewLine,
"## JSON Repair Retry",
Environment.NewLine,
$"Your previous response was not valid JSON. Return the same {label} object as valid JSON only. Do not include markdown. Do not truncate. All numeric confidence values must be valid JSON numbers such as 0.9 or 0.0.",
Environment.NewLine,
"Parse error:",
Environment.NewLine,
parseError,
Environment.NewLine,
"Malformed JSON returned previously:",
Environment.NewLine,
"```json",
Environment.NewLine,
malformedJson,
Environment.NewLine,
"```");
private static string CombineRawResponses(string initialRawResponse, string retryRawResponse)
=> JsonSerializer.Serialize(new
{
initialRawResponseJson = initialRawResponse,
retryRawResponseJson = retryRawResponse
}, JsonOptions);
private static string CombineAssistantOutputs(string initialOutput, string retryOutput)
=> JsonSerializer.Serialize(new
{
initialAssistantOutput = initialOutput,
retryAssistantOutput = retryOutput
}, 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 AiJsonParseException(
string message,
string rawResponseJson,
string rawAssistantOutput,
string initialError,
bool retryAttempted,
string? retryError,
int? inputTokens,
int? outputTokens,
TimeSpan duration) : Exception(message)
{
public string RawResponseJson { get; } = rawResponseJson;
public string RawAssistantOutput { get; } = rawAssistantOutput;
public string InitialError { get; } = initialError;
public bool RetryAttempted { get; } = retryAttempted;
public string? RetryError { get; } = retryError;
public int? InputTokens { get; } = inputTokens;
public int? OutputTokens { get; } = outputTokens;
public TimeSpan Duration { get; } = duration;
}
private sealed record AiJsonParseResult<T>(
string Model,
string RawResponseJson,
string RawAssistantOutput,
string RawJson,
string? RepairedJson,
T? Parsed,
int? InputTokens,
int? OutputTokens,
TimeSpan Duration,
bool RetryAttempted,
IReadOnlyList<string> Warnings,
string? InitialError)
{
public bool RepairApplied => Warnings.Any(warning =>
warning.Contains("markdown", StringComparison.OrdinalIgnoreCase)
|| warning.Contains("Normalised", StringComparison.OrdinalIgnoreCase)
|| warning.Contains("Escaped", StringComparison.OrdinalIgnoreCase)
|| warning.Contains("Trimmed", StringComparison.OrdinalIgnoreCase));
public static AiJsonParseResult<T> Success(
string model,
string rawResponseJson,
string rawAssistantOutput,
string rawJson,
string? repairedJson,
T? parsed,
int? inputTokens,
int? outputTokens,
TimeSpan duration,
bool retryAttempted,
IReadOnlyList<string> warnings,
string? initialError = null)
=> new(
model,
rawResponseJson,
rawAssistantOutput,
rawJson,
repairedJson,
parsed,
inputTokens,
outputTokens,
duration,
retryAttempted,
warnings,
initialError);
}
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 decimal? EstimatedCostUSD { get; private set; }
public decimal? EstimatedCostGBP { get; private set; }
public void Add(StoryIntelligenceClientResult result, StoryIntelligencePricingOptions pricing)
{
InputTokens = SumNullable(InputTokens, result.InputTokens);
OutputTokens = SumNullable(OutputTokens, result.OutputTokens);
AddCost(result, pricing);
}
private static int? SumNullable(int? current, int? next)
=> current.HasValue || next.HasValue ? (current ?? 0) + (next ?? 0) : null;
private void AddCost(StoryIntelligenceClientResult result, StoryIntelligencePricingOptions pricing)
{
var modelPricing = pricing.GetPricingForModel(result.Model);
if (modelPricing?.InputPerMillionUsd is not decimal inputPrice
|| modelPricing.OutputPerMillionUsd is not decimal outputPrice
|| result.InputTokens is not int inputTokens
|| result.OutputTokens is not int outputTokens)
{
EstimatedCostUSD = null;
EstimatedCostGBP = null;
return;
}
var requestUsd = (inputTokens / 1_000_000m * inputPrice)
+ (outputTokens / 1_000_000m * outputPrice);
EstimatedCostUSD = (EstimatedCostUSD ?? 0m) + requestUsd;
EstimatedCostGBP = pricing.UsdToGbpRate.HasValue
? (EstimatedCostGBP ?? 0m) + (requestUsd * pricing.UsdToGbpRate.Value)
: null;
}
}
}