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

352 lines
16 KiB
C#

using System.Diagnostics;
using System.Text.Json;
using PlotLine.Models;
using PlotLine.ViewModels;
namespace PlotLine.Services;
public interface IChapterStoryIntelligenceDryRunService
{
ChapterStoryIntelligenceDryRunViewModel GetDefault();
Task<ChapterStoryIntelligenceDryRunViewModel> ExecuteAsync(ChapterStoryIntelligenceDryRunForm form, CancellationToken cancellationToken);
}
public sealed class ChapterStoryIntelligenceDryRunService(
IStoryPromptRepository prompts,
IStoryPromptVersionService versions,
IStoryPromptBuilder scenePromptBuilder,
IStoryIntelligenceClient client,
IChapterStructureValidator chapterValidator,
IStorySceneValidator sceneValidator,
ILogger<ChapterStoryIntelligenceDryRunService> logger) : IChapterStoryIntelligenceDryRunService
{
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
};
public ChapterStoryIntelligenceDryRunViewModel GetDefault()
=> new();
public async Task<ChapterStoryIntelligenceDryRunViewModel> ExecuteAsync(ChapterStoryIntelligenceDryRunForm form, CancellationToken cancellationToken)
{
var model = new ChapterStoryIntelligenceDryRunViewModel { Form = form };
var totalStopwatch = Stopwatch.StartNew();
var chapterPromptVersion = versions.GetPromptVersion(ChapterPromptFile);
var scenePromptVersion = versions.GetPromptVersion(ScenePromptFile);
var clientStatus = client.GetConfigurationStatus();
var paragraphs = StoryIntelligenceParagraphs.Split(form.ChapterText);
var chapterContextJson = BuildChapterContextJson(form);
try
{
if (paragraphs.Count == 0)
{
throw new InvalidOperationException("Enter fictional chapter text before running the combined pipeline test.");
}
var chapterTemplate = await prompts.LoadPromptAsync(ChapterPromptFile, cancellationToken);
var numberedChapterText = StoryIntelligenceParagraphs.Number(paragraphs);
var chapterPrompt = BuildChapterPrompt(chapterTemplate, chapterContextJson, numberedChapterText);
var chapterResult = await client.ExecutePromptAsync(chapterPrompt, chapterPromptVersion, cancellationToken);
var chapterJson = ExtractOutputText(chapterResult.RawResponseText);
var parsedChapter = JsonSerializer.Deserialize<ChapterStructureModel>(chapterJson, JsonOptions);
var chapterValidation = chapterValidator.Validate(parsedChapter, paragraphs.Count);
var sceneBlocks = new List<ChapterStorySceneBlockViewModel>();
if (!chapterValidation.IsValid || parsedChapter?.SceneBoundaries is null)
{
totalStopwatch.Stop();
var stoppedResult = new ChapterStoryIntelligenceDryRunResultViewModel
{
Success = false,
PipelineStopped = true,
StopReason = "Chapter Structure validation failed. Scene Intelligence was not run.",
ChapterPromptVersion = chapterPromptVersion,
ScenePromptVersion = scenePromptVersion,
Model = chapterResult.Model,
ChapterContextJson = chapterContextJson,
ParagraphCount = paragraphs.Count,
TotalDuration = FormatDuration(totalStopwatch.Elapsed),
TotalDurationMs = totalStopwatch.ElapsedMilliseconds,
TotalInputTokens = chapterResult.InputTokens,
TotalOutputTokens = chapterResult.OutputTokens,
ChapterDuration = FormatDuration(chapterResult.Duration),
ChapterDurationMs = Convert.ToInt64(chapterResult.Duration.TotalMilliseconds),
ChapterRetryCount = chapterResult.RetryCount,
ChapterInputTokens = chapterResult.InputTokens,
ChapterOutputTokens = chapterResult.OutputTokens,
ChapterRawResponseText = chapterResult.RawResponseText,
ChapterJsonText = chapterJson,
ParsedChapter = parsedChapter,
ChapterValidation = chapterValidation,
SceneBlocks = sceneBlocks
};
LogPipelineResult(stoppedResult);
model.Result = stoppedResult;
return model;
}
var sceneTemplate = await prompts.LoadPromptAsync(ScenePromptFile, cancellationToken);
foreach (var boundary in parsedChapter.SceneBoundaries)
{
var block = BuildSceneBlock(form, paragraphs, boundary);
if (!block.SplitValid)
{
sceneBlocks.Add(block);
continue;
}
var sceneContextJson = BuildSceneContextJson(form, boundary);
var completedScenePrompt = scenePromptBuilder.BuildPrompt(sceneTemplate, sceneContextJson, block.SceneText);
sceneBlocks.Add(await ExecuteSceneAsync(block, sceneContextJson, completedScenePrompt, scenePromptVersion, cancellationToken));
}
totalStopwatch.Stop();
var finalResult = new ChapterStoryIntelligenceDryRunResultViewModel
{
Success = sceneBlocks.All(block => block.Success || !block.SplitValid),
PipelineStopped = false,
ChapterPromptVersion = chapterPromptVersion,
ScenePromptVersion = scenePromptVersion,
Model = chapterResult.Model,
ChapterContextJson = chapterContextJson,
ParagraphCount = paragraphs.Count,
TotalDuration = FormatDuration(totalStopwatch.Elapsed),
TotalDurationMs = totalStopwatch.ElapsedMilliseconds,
TotalInputTokens = SumTokens(chapterResult.InputTokens, sceneBlocks.Select(block => block.InputTokens)),
TotalOutputTokens = SumTokens(chapterResult.OutputTokens, sceneBlocks.Select(block => block.OutputTokens)),
ChapterDuration = FormatDuration(chapterResult.Duration),
ChapterDurationMs = Convert.ToInt64(chapterResult.Duration.TotalMilliseconds),
ChapterRetryCount = chapterResult.RetryCount,
ChapterInputTokens = chapterResult.InputTokens,
ChapterOutputTokens = chapterResult.OutputTokens,
ChapterRawResponseText = chapterResult.RawResponseText,
ChapterJsonText = chapterJson,
ParsedChapter = parsedChapter,
ChapterValidation = chapterValidation,
SceneBlocks = sceneBlocks
};
LogPipelineResult(finalResult);
model.Result = finalResult;
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
totalStopwatch.Stop();
model.Result = new ChapterStoryIntelligenceDryRunResultViewModel
{
Success = false,
PipelineStopped = true,
StopReason = "The combined pipeline could not complete.",
ChapterPromptVersion = chapterPromptVersion,
ScenePromptVersion = scenePromptVersion,
Model = clientStatus.Model,
ChapterContextJson = chapterContextJson,
ParagraphCount = paragraphs.Count,
TotalDuration = FormatDuration(totalStopwatch.Elapsed),
TotalDurationMs = totalStopwatch.ElapsedMilliseconds,
ErrorMessage = ex.Message
};
logger.LogWarning(
ex,
"Admin Chapter Story Intelligence pipeline failed. ChapterPromptVersion={ChapterPromptVersion} ScenePromptVersion={ScenePromptVersion} Model={Model} TotalDurationMs={TotalDurationMs} Success={Success}",
chapterPromptVersion,
scenePromptVersion,
clientStatus.Model,
totalStopwatch.ElapsedMilliseconds,
false);
}
return model;
}
private async Task<ChapterStorySceneBlockViewModel> ExecuteSceneAsync(
ChapterStorySceneBlockViewModel block,
string sceneContextJson,
string completedScenePrompt,
string scenePromptVersion,
CancellationToken cancellationToken)
{
try
{
var result = await client.ExecutePromptAsync(completedScenePrompt, scenePromptVersion, cancellationToken);
var sceneJson = ExtractOutputText(result.RawResponseText);
var parsedScene = JsonSerializer.Deserialize<SceneIntelligenceScene>(sceneJson, JsonOptions);
var validation = sceneValidator.Validate(parsedScene);
logger.LogInformation(
"Admin Chapter Story Intelligence scene completed. TemporarySceneNumber={TemporarySceneNumber} DurationMs={DurationMs} InputTokens={InputTokens} OutputTokens={OutputTokens} ValidationPassed={ValidationPassed} ValidationWarnings={ValidationWarnings} ValidationErrors={ValidationErrors} Success={Success}",
block.TemporarySceneNumber,
result.Duration.TotalMilliseconds,
result.InputTokens,
result.OutputTokens,
validation.IsValid,
validation.Warnings.Count,
validation.Errors.Count,
true);
return block with
{
Success = true,
SceneContextJson = sceneContextJson,
Duration = FormatDuration(result.Duration),
DurationMs = Convert.ToInt64(result.Duration.TotalMilliseconds),
RetryCount = result.RetryCount,
InputTokens = result.InputTokens,
OutputTokens = result.OutputTokens,
RawResponseText = result.RawResponseText,
SceneJsonText = sceneJson,
ParsedScene = parsedScene,
Validation = validation
};
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
logger.LogWarning(
ex,
"Admin Chapter Story Intelligence scene failed. TemporarySceneNumber={TemporarySceneNumber} Success={Success}",
block.TemporarySceneNumber,
false);
return block with
{
Success = false,
SceneContextJson = sceneContextJson,
ErrorMessage = ex.Message
};
}
}
private static ChapterStorySceneBlockViewModel BuildSceneBlock(
ChapterStoryIntelligenceDryRunForm form,
IReadOnlyList<string> paragraphs,
ChapterSceneBoundary boundary)
{
var sceneNumber = boundary.SceneNumber ?? 0;
var start = boundary.StartParagraph ?? 0;
var end = boundary.EndParagraph ?? 0;
if (sceneNumber <= 0 || start <= 0 || end <= 0 || start > end || end > paragraphs.Count)
{
return new ChapterStorySceneBlockViewModel
{
TemporarySceneNumber = sceneNumber,
StartParagraph = start,
EndParagraph = end,
BoundaryReason = boundary.Reason ?? string.Empty,
BoundaryConfidence = boundary.Confidence,
SplitValid = false,
SplitErrorMessage = "Scene boundary range is invalid for the submitted chapter paragraphs."
};
}
var sceneParagraphs = paragraphs.Skip(start - 1).Take(end - start + 1).ToList();
var sceneText = string.Join(Environment.NewLine + Environment.NewLine, sceneParagraphs);
return new ChapterStorySceneBlockViewModel
{
TemporarySceneNumber = sceneNumber,
StartParagraph = start,
EndParagraph = end,
BoundaryReason = boundary.Reason ?? string.Empty,
BoundaryConfidence = boundary.Confidence,
SceneText = sceneText,
SceneTextPreview = Preview(sceneText),
SplitValid = true,
SceneContextJson = BuildSceneContextJson(form, boundary)
};
}
private static string BuildChapterContextJson(ChapterStoryIntelligenceDryRunForm form)
=> JsonSerializer.Serialize(new
{
projectId = form.ProjectID,
bookId = form.BookID,
chapterId = form.ChapterID,
chapterNumber = form.ChapterNumber,
sourceLabel = string.IsNullOrWhiteSpace(form.SourceLabel) ? "Admin pipeline test chapter" : form.SourceLabel.Trim()
}, JsonOptions);
private static string BuildSceneContextJson(ChapterStoryIntelligenceDryRunForm form, ChapterSceneBoundary boundary)
=> JsonSerializer.Serialize(new
{
projectId = form.ProjectID,
bookId = form.BookID,
chapterId = form.ChapterID,
sceneId = (int?)null,
chapterNumber = form.ChapterNumber,
sceneNumber = boundary.SceneNumber,
sourceLabel = $"{(string.IsNullOrWhiteSpace(form.SourceLabel) ? "Admin pipeline test chapter" : form.SourceLabel.Trim())}, suggested scene {boundary.SceneNumber}"
}, JsonOptions);
private static string BuildChapterPrompt(string promptTemplate, string chapterContextJson, string chapterText)
{
if (string.IsNullOrWhiteSpace(promptTemplate))
{
throw new InvalidOperationException("Prompt template is empty.");
}
return 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;
if (string.IsNullOrWhiteSpace(outputText))
{
throw new JsonException("OpenAI response did not contain output_text content.");
}
return outputText.Trim();
}
private static string Preview(string value)
{
var cleaned = value.ReplaceLineEndings(" ").Trim();
return cleaned.Length <= 260 ? cleaned : $"{cleaned[..260]}...";
}
private static int? SumTokens(int? first, IEnumerable<int?> rest)
{
var values = new[] { first }.Concat(rest).ToList();
return values.Any(value => value.HasValue) ? values.Sum(value => value ?? 0) : null;
}
private static string FormatDuration(TimeSpan duration)
=> duration.TotalSeconds < 1
? $"{duration.TotalMilliseconds:0} ms"
: $"{duration.TotalSeconds:0.00} sec";
private void LogPipelineResult(ChapterStoryIntelligenceDryRunResultViewModel result)
{
logger.LogInformation(
"Admin Chapter Story Intelligence pipeline completed. ChapterPromptVersion={ChapterPromptVersion} ScenePromptVersion={ScenePromptVersion} Model={Model} TotalDuration={TotalDuration} TotalInputTokens={TotalInputTokens} TotalOutputTokens={TotalOutputTokens} ChapterValidationErrors={ChapterValidationErrors} ChapterValidationWarnings={ChapterValidationWarnings} SceneCount={SceneCount} SceneValidationErrors={SceneValidationErrors} SceneValidationWarnings={SceneValidationWarnings} Success={Success}",
result.ChapterPromptVersion,
result.ScenePromptVersion,
result.Model,
result.TotalDuration,
result.TotalInputTokens,
result.TotalOutputTokens,
result.ChapterValidation?.Errors.Count,
result.ChapterValidation?.Warnings.Count,
result.SceneBlocks.Count,
result.SceneBlocks.Sum(block => block.Validation?.Errors.Count ?? 0),
result.SceneBlocks.Sum(block => block.Validation?.Warnings.Count ?? 0),
result.Success);
}
}