378 lines
18 KiB
C#
378 lines
18 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,
|
|
clientStatus.ChapterStructureModel);
|
|
var chapterJson = ExtractOutputText(chapterResult.RawResponseText);
|
|
var parsedChapter = JsonSerializer.Deserialize<ChapterStructureModel>(chapterJson, JsonOptions);
|
|
var chapterNormalisation = ChapterStructureBoundaryNormaliser.Normalise(parsedChapter, paragraphs.Count);
|
|
var chapterForProcessing = chapterNormalisation.ChapterStructure;
|
|
var chapterValidation = chapterValidator.Validate(chapterForProcessing, paragraphs.Count);
|
|
ChapterStructureBoundaryNormaliser.AddIssuesTo(chapterValidation, chapterNormalisation);
|
|
var sceneBlocks = new List<ChapterStorySceneBlockViewModel>();
|
|
|
|
if (!chapterValidation.IsValid || chapterForProcessing?.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 = StoryIntelligenceOptions.BuildModelSummary(
|
|
chapterResult.Model,
|
|
clientStatus.SceneIntelligenceModel),
|
|
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 = chapterForProcessing,
|
|
ChapterValidation = chapterValidation,
|
|
SceneBlocks = sceneBlocks
|
|
};
|
|
LogPipelineResult(stoppedResult);
|
|
model.Result = stoppedResult;
|
|
return model;
|
|
}
|
|
|
|
var sceneTemplate = await prompts.LoadPromptAsync(ScenePromptFile, cancellationToken);
|
|
foreach (var boundary in chapterForProcessing.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,
|
|
clientStatus.SceneIntelligenceModel,
|
|
cancellationToken));
|
|
}
|
|
|
|
totalStopwatch.Stop();
|
|
var finalResult = new ChapterStoryIntelligenceDryRunResultViewModel
|
|
{
|
|
Success = sceneBlocks.All(block => block.Success || !block.SplitValid),
|
|
PipelineStopped = false,
|
|
ChapterPromptVersion = chapterPromptVersion,
|
|
ScenePromptVersion = scenePromptVersion,
|
|
Model = StoryIntelligenceOptions.BuildModelSummary(
|
|
chapterResult.Model,
|
|
clientStatus.SceneIntelligenceModel),
|
|
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 = chapterForProcessing,
|
|
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 = StoryIntelligenceOptions.BuildModelSummary(
|
|
clientStatus.ChapterStructureModel,
|
|
clientStatus.SceneIntelligenceModel),
|
|
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,
|
|
StoryIntelligenceOptions.BuildModelSummary(
|
|
clientStatus.ChapterStructureModel,
|
|
clientStatus.SceneIntelligenceModel),
|
|
totalStopwatch.ElapsedMilliseconds,
|
|
false);
|
|
}
|
|
|
|
return model;
|
|
}
|
|
|
|
private async Task<ChapterStorySceneBlockViewModel> ExecuteSceneAsync(
|
|
ChapterStorySceneBlockViewModel block,
|
|
string sceneContextJson,
|
|
string completedScenePrompt,
|
|
string scenePromptVersion,
|
|
string sceneModel,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
try
|
|
{
|
|
var result = await client.ExecutePromptAsync(
|
|
completedScenePrompt,
|
|
scenePromptVersion,
|
|
cancellationToken,
|
|
sceneModel);
|
|
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);
|
|
}
|
|
}
|