732 lines
29 KiB
C#
732 lines
29 KiB
C#
using System.Diagnostics;
|
|
using System.Net;
|
|
using System.Net.Http.Headers;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using System.Text.Json.Serialization;
|
|
using Microsoft.Extensions.Caching.Memory;
|
|
using Microsoft.Extensions.Options;
|
|
using PlotLine.Models;
|
|
using PlotLine.ViewModels;
|
|
|
|
namespace PlotLine.Services;
|
|
|
|
public interface IStoryPromptRepository
|
|
{
|
|
Task<string> LoadPromptAsync(string fileName, CancellationToken cancellationToken);
|
|
}
|
|
|
|
public interface IStoryPromptBuilder
|
|
{
|
|
string BuildPrompt(string promptTemplate, string sceneContextJson, string sceneText);
|
|
}
|
|
|
|
public interface IStoryPromptVersionService
|
|
{
|
|
string GetPromptVersion(string fileName);
|
|
}
|
|
|
|
public interface IStoryIntelligenceClient
|
|
{
|
|
Task<StoryIntelligenceClientResult> ExecutePromptAsync(
|
|
string completedPrompt,
|
|
string promptVersion,
|
|
CancellationToken cancellationToken,
|
|
string? modelOverride = null,
|
|
int? maxOutputTokensOverride = null);
|
|
StoryIntelligenceClientConfigurationStatus GetConfigurationStatus();
|
|
}
|
|
|
|
public interface IStoryIntelligenceDiagnosticsService
|
|
{
|
|
Task<StoryIntelligenceDiagnosticsViewModel> GetDiagnosticsAsync(CancellationToken cancellationToken);
|
|
}
|
|
|
|
public interface IStoryIntelligenceDryRunService
|
|
{
|
|
StoryIntelligenceDryRunViewModel GetDefault();
|
|
Task<StoryIntelligenceDryRunViewModel> ExecuteAsync(StoryIntelligenceDryRunForm form, CancellationToken cancellationToken);
|
|
}
|
|
|
|
public interface IChapterStructureDryRunService
|
|
{
|
|
ChapterStructureDryRunViewModel GetDefault();
|
|
Task<ChapterStructureDryRunViewModel> ExecuteAsync(ChapterStructureDryRunForm form, CancellationToken cancellationToken);
|
|
}
|
|
|
|
public sealed class StoryPromptRepository(
|
|
IWebHostEnvironment environment,
|
|
IMemoryCache cache,
|
|
ILogger<StoryPromptRepository> logger) : IStoryPromptRepository
|
|
{
|
|
private const string PromptFolder = "Docs/AI";
|
|
|
|
public async Task<string> LoadPromptAsync(string fileName, CancellationToken cancellationToken)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(fileName)
|
|
|| fileName.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0
|
|
|| fileName.Contains(Path.DirectorySeparatorChar)
|
|
|| fileName.Contains(Path.AltDirectorySeparatorChar))
|
|
{
|
|
throw new InvalidOperationException("Prompt filename is invalid.");
|
|
}
|
|
|
|
var cacheKey = $"story-prompt:{fileName}";
|
|
if (cache.TryGetValue(cacheKey, out string? cachedPrompt) && !string.IsNullOrWhiteSpace(cachedPrompt))
|
|
{
|
|
return cachedPrompt;
|
|
}
|
|
|
|
var promptPath = Path.Combine(environment.ContentRootPath, PromptFolder, fileName);
|
|
if (!File.Exists(promptPath))
|
|
{
|
|
throw new FileNotFoundException($"Story Intelligence prompt file '{fileName}' was not found in /Docs/AI/.", promptPath);
|
|
}
|
|
|
|
var prompt = await File.ReadAllTextAsync(promptPath, cancellationToken);
|
|
if (string.IsNullOrWhiteSpace(prompt))
|
|
{
|
|
throw new InvalidOperationException($"Story Intelligence prompt file '{fileName}' is empty.");
|
|
}
|
|
|
|
cache.Set(cacheKey, prompt, TimeSpan.FromMinutes(15));
|
|
logger.LogInformation("Loaded Story Intelligence prompt {PromptFile} from {PromptPath}.", fileName, promptPath);
|
|
return prompt;
|
|
}
|
|
}
|
|
|
|
public sealed class StoryPromptBuilder : IStoryPromptBuilder
|
|
{
|
|
public string BuildPrompt(string promptTemplate, string sceneContextJson, string sceneText)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(promptTemplate))
|
|
{
|
|
throw new InvalidOperationException("Prompt template is empty.");
|
|
}
|
|
|
|
return promptTemplate
|
|
.Replace("{{SCENE_CONTEXT_JSON}}", sceneContextJson ?? string.Empty, StringComparison.Ordinal)
|
|
.Replace("{{SCENE_TEXT}}", sceneText ?? string.Empty, StringComparison.Ordinal);
|
|
}
|
|
}
|
|
|
|
public sealed class StoryPromptVersionService : IStoryPromptVersionService
|
|
{
|
|
public string GetPromptVersion(string fileName)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(fileName))
|
|
{
|
|
throw new InvalidOperationException("Prompt filename is required.");
|
|
}
|
|
|
|
var safeName = Path.GetFileName(fileName);
|
|
return Path.GetFileNameWithoutExtension(safeName);
|
|
}
|
|
}
|
|
|
|
public sealed class StoryIntelligenceClient(
|
|
HttpClient httpClient,
|
|
IOptions<StoryIntelligenceOptions> options,
|
|
ILogger<StoryIntelligenceClient> logger) : IStoryIntelligenceClient
|
|
{
|
|
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
|
private readonly StoryIntelligenceOptions settings = options.Value;
|
|
|
|
public StoryIntelligenceClientConfigurationStatus GetConfigurationStatus()
|
|
=> new()
|
|
{
|
|
ApiKeyConfigured = !string.IsNullOrWhiteSpace(settings.ApiKey),
|
|
Model = settings.EffectiveModel,
|
|
ChapterStructureModel = settings.EffectiveChapterStructureModel,
|
|
SceneIntelligenceModel = settings.EffectiveSceneIntelligenceModel,
|
|
MaxOutputTokens = settings.MaxOutputTokens,
|
|
TimeoutSeconds = settings.TimeoutSeconds,
|
|
ClientConstructed = true,
|
|
ConnectionStatus = "Not checked on diagnostics page. Only the admin dry-run page makes a live OpenAI request."
|
|
};
|
|
|
|
public async Task<StoryIntelligenceClientResult> ExecutePromptAsync(
|
|
string completedPrompt,
|
|
string promptVersion,
|
|
CancellationToken cancellationToken,
|
|
string? modelOverride = null,
|
|
int? maxOutputTokensOverride = null)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(settings.ApiKey))
|
|
{
|
|
throw new InvalidOperationException("OpenAI API key is not configured. Add OpenAI:ApiKey to User Secrets or environment variables.");
|
|
}
|
|
|
|
var model = string.IsNullOrWhiteSpace(modelOverride)
|
|
? settings.EffectiveModel
|
|
: modelOverride.Trim();
|
|
|
|
if (string.IsNullOrWhiteSpace(model))
|
|
{
|
|
throw new InvalidOperationException("OpenAI model is not configured. Set OpenAI:Model or StoryIntelligence stage models in configuration.");
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(completedPrompt))
|
|
{
|
|
throw new InvalidOperationException("Completed prompt is empty.");
|
|
}
|
|
|
|
var maxOutputTokens = maxOutputTokensOverride.GetValueOrDefault(settings.MaxOutputTokens);
|
|
if (maxOutputTokens <= 0)
|
|
{
|
|
maxOutputTokens = settings.MaxOutputTokens;
|
|
}
|
|
|
|
var requestBody = JsonSerializer.Serialize(new
|
|
{
|
|
model = model,
|
|
input = completedPrompt,
|
|
max_output_tokens = maxOutputTokens,
|
|
store = false,
|
|
reasoning = new
|
|
{
|
|
effort = "minimal"
|
|
},
|
|
text = new
|
|
{
|
|
verbosity = "low"
|
|
}
|
|
}, JsonOptions);
|
|
|
|
var stopwatch = Stopwatch.StartNew();
|
|
try
|
|
{
|
|
var (responseText, retryCount) = await SendWithRetryAsync(requestBody, cancellationToken);
|
|
stopwatch.Stop();
|
|
logger.LogInformation(
|
|
"Story Intelligence prompt executed. PromptVersion={PromptVersion} Model={Model} DurationMs={DurationMs} RetryCount={RetryCount} InputTokens={InputTokens} OutputTokens={OutputTokens} EstimatedCost={EstimatedCost} Success={Success}",
|
|
promptVersion,
|
|
model,
|
|
stopwatch.ElapsedMilliseconds,
|
|
retryCount,
|
|
null,
|
|
null,
|
|
null,
|
|
true);
|
|
|
|
return new StoryIntelligenceClientResult
|
|
{
|
|
RawResponseText = responseText,
|
|
Model = model,
|
|
Duration = stopwatch.Elapsed,
|
|
RetryCount = retryCount,
|
|
InputTokens = GetTokenUsage(responseText).InputTokens,
|
|
OutputTokens = GetTokenUsage(responseText).OutputTokens
|
|
};
|
|
}
|
|
catch (Exception ex) when (ex is not OperationCanceledException)
|
|
{
|
|
stopwatch.Stop();
|
|
logger.LogError(
|
|
ex,
|
|
"Story Intelligence prompt failed. PromptVersion={PromptVersion} Model={Model} DurationMs={DurationMs} RetryCount={RetryCount} InputTokens={InputTokens} OutputTokens={OutputTokens} EstimatedCost={EstimatedCost} Success={Success}",
|
|
promptVersion,
|
|
model,
|
|
stopwatch.ElapsedMilliseconds,
|
|
null,
|
|
null,
|
|
null,
|
|
null,
|
|
false);
|
|
throw;
|
|
}
|
|
}
|
|
|
|
private async Task<(string ResponseText, int RetryCount)> SendWithRetryAsync(string requestBody, CancellationToken cancellationToken)
|
|
{
|
|
const int maxAttempts = 3;
|
|
var retryCount = 0;
|
|
for (var attempt = 1; attempt <= maxAttempts; attempt++)
|
|
{
|
|
using var request = new HttpRequestMessage(HttpMethod.Post, "https://api.openai.com/v1/responses")
|
|
{
|
|
Content = new StringContent(requestBody, Encoding.UTF8, "application/json")
|
|
};
|
|
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", settings.ApiKey);
|
|
|
|
try
|
|
{
|
|
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
|
timeout.CancelAfter(TimeSpan.FromSeconds(Math.Max(1, settings.TimeoutSeconds)));
|
|
using var response = await httpClient.SendAsync(request, timeout.Token);
|
|
var responseText = await response.Content.ReadAsStringAsync(cancellationToken);
|
|
|
|
if (response.IsSuccessStatusCode)
|
|
{
|
|
return (responseText, retryCount);
|
|
}
|
|
|
|
if (!ShouldRetry(response.StatusCode) || attempt == maxAttempts)
|
|
{
|
|
throw new InvalidOperationException($"OpenAI request failed with {(int)response.StatusCode} {response.StatusCode}: {TrimForLog(responseText)}");
|
|
}
|
|
|
|
retryCount++;
|
|
}
|
|
catch (TaskCanceledException) when (!cancellationToken.IsCancellationRequested)
|
|
{
|
|
if (attempt == maxAttempts)
|
|
{
|
|
throw new InvalidOperationException($"OpenAI request timed out after {settings.TimeoutSeconds} seconds.");
|
|
}
|
|
|
|
retryCount++;
|
|
await DelayForRetryAsync(attempt, cancellationToken);
|
|
continue;
|
|
}
|
|
catch (HttpRequestException) when (attempt < maxAttempts)
|
|
{
|
|
retryCount++;
|
|
await DelayForRetryAsync(attempt, cancellationToken);
|
|
continue;
|
|
}
|
|
|
|
await DelayForRetryAsync(attempt, cancellationToken);
|
|
}
|
|
|
|
throw new InvalidOperationException("OpenAI request failed after retry attempts.");
|
|
}
|
|
|
|
private static bool ShouldRetry(HttpStatusCode statusCode)
|
|
=> statusCode is HttpStatusCode.RequestTimeout
|
|
or HttpStatusCode.TooManyRequests
|
|
or HttpStatusCode.InternalServerError
|
|
or HttpStatusCode.BadGateway
|
|
or HttpStatusCode.ServiceUnavailable
|
|
or HttpStatusCode.GatewayTimeout;
|
|
|
|
private static Task DelayForRetryAsync(int attempt, CancellationToken cancellationToken)
|
|
=> Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, attempt - 1)), cancellationToken);
|
|
|
|
private static string TrimForLog(string value)
|
|
=> string.IsNullOrWhiteSpace(value) || value.Length <= 500 ? value : value[..500];
|
|
|
|
private static (int? InputTokens, int? OutputTokens) GetTokenUsage(string responseText)
|
|
{
|
|
try
|
|
{
|
|
var response = JsonSerializer.Deserialize<OpenAIResponseEnvelope>(responseText, JsonOptions);
|
|
return (response?.Usage?.InputTokens, response?.Usage?.OutputTokens);
|
|
}
|
|
catch (JsonException)
|
|
{
|
|
return (null, null);
|
|
}
|
|
}
|
|
}
|
|
|
|
public sealed class StoryIntelligenceDiagnosticsService(
|
|
IStoryPromptRepository prompts,
|
|
IStoryPromptVersionService versions,
|
|
IStoryPromptBuilder builder,
|
|
IStoryIntelligenceClient client) : IStoryIntelligenceDiagnosticsService
|
|
{
|
|
private const string ScenePromptFile = "Scene-Prompt-V1.md";
|
|
|
|
public async Task<StoryIntelligenceDiagnosticsViewModel> GetDiagnosticsAsync(CancellationToken cancellationToken)
|
|
{
|
|
var clientStatus = client.GetConfigurationStatus();
|
|
var model = new StoryIntelligenceDiagnosticsViewModel
|
|
{
|
|
Model = clientStatus.Model,
|
|
ChapterStructureModel = clientStatus.ChapterStructureModel,
|
|
SceneIntelligenceModel = clientStatus.SceneIntelligenceModel,
|
|
ApiKeyConfigured = clientStatus.ApiKeyConfigured,
|
|
ClientConstructed = clientStatus.ClientConstructed,
|
|
ConnectionStatus = clientStatus.ConnectionStatus,
|
|
PromptFileName = ScenePromptFile,
|
|
PromptVersion = versions.GetPromptVersion(ScenePromptFile),
|
|
MaxOutputTokens = clientStatus.MaxOutputTokens,
|
|
TimeoutSeconds = clientStatus.TimeoutSeconds
|
|
};
|
|
|
|
try
|
|
{
|
|
var prompt = await prompts.LoadPromptAsync(ScenePromptFile, cancellationToken);
|
|
model.PromptRepositoryHealthy = true;
|
|
model.ScenePromptLoaded = true;
|
|
model.ScenePromptLength = prompt.Length;
|
|
var completed = builder.BuildPrompt(prompt, "{\"sceneId\":0}", "Diagnostic scene text.");
|
|
model.RuntimeSubstitutionWorks = completed.Contains("\"sceneId\":0", StringComparison.Ordinal)
|
|
&& completed.Contains("Diagnostic scene text.", StringComparison.Ordinal)
|
|
&& !completed.Contains("{{SCENE_CONTEXT_JSON}}", StringComparison.Ordinal)
|
|
&& !completed.Contains("{{SCENE_TEXT}}", StringComparison.Ordinal);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
model.PromptRepositoryHealthy = false;
|
|
model.ScenePromptLoaded = false;
|
|
model.ErrorMessage = ex.Message;
|
|
}
|
|
|
|
return model;
|
|
}
|
|
}
|
|
|
|
public sealed class StoryIntelligenceDryRunService(
|
|
IStoryPromptRepository prompts,
|
|
IStoryPromptVersionService versions,
|
|
IStoryPromptBuilder builder,
|
|
IStoryIntelligenceClient client,
|
|
IStorySceneValidator validator,
|
|
ILogger<StoryIntelligenceDryRunService> logger) : IStoryIntelligenceDryRunService
|
|
{
|
|
private const string ScenePromptFile = "Scene-Prompt-V1.md";
|
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
|
{
|
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
|
PropertyNameCaseInsensitive = true,
|
|
WriteIndented = true
|
|
};
|
|
|
|
public StoryIntelligenceDryRunViewModel GetDefault()
|
|
=> new();
|
|
|
|
public async Task<StoryIntelligenceDryRunViewModel> ExecuteAsync(StoryIntelligenceDryRunForm form, CancellationToken cancellationToken)
|
|
{
|
|
var model = new StoryIntelligenceDryRunViewModel { Form = form };
|
|
var promptVersion = versions.GetPromptVersion(ScenePromptFile);
|
|
var clientStatus = client.GetConfigurationStatus();
|
|
var contextJson = BuildSceneContextJson(form);
|
|
|
|
try
|
|
{
|
|
if (string.IsNullOrWhiteSpace(form.SceneText))
|
|
{
|
|
throw new InvalidOperationException("Enter short fictional scene text before running the dry run.");
|
|
}
|
|
|
|
var template = await prompts.LoadPromptAsync(ScenePromptFile, cancellationToken);
|
|
var completedPrompt = builder.BuildPrompt(template, contextJson, form.SceneText);
|
|
var result = await client.ExecutePromptAsync(
|
|
completedPrompt,
|
|
promptVersion,
|
|
cancellationToken,
|
|
clientStatus.SceneIntelligenceModel);
|
|
var sceneJson = ExtractSceneJson(result.RawResponseText);
|
|
var parsedScene = DeserializeScene(sceneJson);
|
|
var validation = validator.Validate(parsedScene);
|
|
|
|
model.Result = new StoryIntelligenceDryRunResultViewModel
|
|
{
|
|
Success = true,
|
|
PromptVersion = promptVersion,
|
|
Model = result.Model,
|
|
SceneContextJson = contextJson,
|
|
Duration = FormatDuration(result.Duration),
|
|
RetryCount = result.RetryCount,
|
|
InputTokens = result.InputTokens,
|
|
OutputTokens = result.OutputTokens,
|
|
RawResponseText = result.RawResponseText,
|
|
SceneJsonText = sceneJson,
|
|
ParsedScene = parsedScene,
|
|
Validation = validation
|
|
};
|
|
|
|
logger.LogInformation(
|
|
"Admin Story Intelligence dry run completed. PromptVersion={PromptVersion} Model={Model} DurationMs={DurationMs} RetryCount={RetryCount} InputTokens={InputTokens} OutputTokens={OutputTokens} ValidationPassed={ValidationPassed} ValidationWarnings={ValidationWarnings} ValidationErrors={ValidationErrors} Success={Success}",
|
|
promptVersion,
|
|
result.Model,
|
|
result.Duration.TotalMilliseconds,
|
|
result.RetryCount,
|
|
result.InputTokens,
|
|
result.OutputTokens,
|
|
validation.IsValid,
|
|
validation.Warnings.Count,
|
|
validation.Errors.Count,
|
|
true);
|
|
}
|
|
catch (JsonException ex)
|
|
{
|
|
model.Result = new StoryIntelligenceDryRunResultViewModel
|
|
{
|
|
Success = false,
|
|
PromptVersion = promptVersion,
|
|
Model = clientStatus.SceneIntelligenceModel,
|
|
SceneContextJson = contextJson,
|
|
ErrorMessage = $"Scene Intelligence JSON could not be deserialised: {ex.Message}",
|
|
Validation = validator.Validate(null)
|
|
};
|
|
|
|
logger.LogWarning(
|
|
ex,
|
|
"Admin Story Intelligence dry run deserialisation failed. PromptVersion={PromptVersion} Model={Model} Success={Success}",
|
|
promptVersion,
|
|
clientStatus.SceneIntelligenceModel,
|
|
false);
|
|
}
|
|
catch (Exception ex) when (ex is not OperationCanceledException)
|
|
{
|
|
model.Result = new StoryIntelligenceDryRunResultViewModel
|
|
{
|
|
Success = false,
|
|
PromptVersion = promptVersion,
|
|
Model = clientStatus.SceneIntelligenceModel,
|
|
SceneContextJson = contextJson,
|
|
ErrorMessage = ex.Message
|
|
};
|
|
|
|
logger.LogWarning(
|
|
ex,
|
|
"Admin Story Intelligence dry run failed. PromptVersion={PromptVersion} Model={Model} Success={Success}",
|
|
promptVersion,
|
|
clientStatus.SceneIntelligenceModel,
|
|
false);
|
|
}
|
|
|
|
return model;
|
|
}
|
|
|
|
private static string BuildSceneContextJson(StoryIntelligenceDryRunForm form)
|
|
=> JsonSerializer.Serialize(new
|
|
{
|
|
projectId = form.ProjectID,
|
|
bookId = form.BookID,
|
|
chapterId = form.ChapterID,
|
|
sceneId = form.SceneID,
|
|
chapterNumber = form.ChapterNumber,
|
|
sceneNumber = form.SceneNumber,
|
|
sourceLabel = string.IsNullOrWhiteSpace(form.SourceLabel) ? "Admin test scene" : form.SourceLabel.Trim()
|
|
}, JsonOptions);
|
|
|
|
private static string FormatDuration(TimeSpan duration)
|
|
=> duration.TotalSeconds < 1
|
|
? $"{duration.TotalMilliseconds:0} ms"
|
|
: $"{duration.TotalSeconds:0.00} sec";
|
|
|
|
private static string ExtractSceneJson(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 SceneIntelligenceScene? DeserializeScene(string sceneJson)
|
|
=> JsonSerializer.Deserialize<SceneIntelligenceScene>(sceneJson, JsonOptions);
|
|
}
|
|
|
|
public sealed class ChapterStructureDryRunService(
|
|
IStoryPromptRepository prompts,
|
|
IStoryPromptVersionService versions,
|
|
IStoryIntelligenceClient client,
|
|
IChapterStructureValidator validator,
|
|
ILogger<ChapterStructureDryRunService> logger) : IChapterStructureDryRunService
|
|
{
|
|
private const string ChapterPromptFile = "Chapter-Structure-Prompt-V1.md";
|
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
|
{
|
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
|
PropertyNameCaseInsensitive = true,
|
|
WriteIndented = true
|
|
};
|
|
|
|
public ChapterStructureDryRunViewModel GetDefault()
|
|
=> new();
|
|
|
|
public async Task<ChapterStructureDryRunViewModel> ExecuteAsync(ChapterStructureDryRunForm form, CancellationToken cancellationToken)
|
|
{
|
|
var model = new ChapterStructureDryRunViewModel { Form = form };
|
|
var promptVersion = versions.GetPromptVersion(ChapterPromptFile);
|
|
var clientStatus = client.GetConfigurationStatus();
|
|
var contextJson = BuildChapterContextJson(form);
|
|
var paragraphs = StoryIntelligenceParagraphs.Split(form.ChapterText);
|
|
var paragraphCount = paragraphs.Count;
|
|
|
|
try
|
|
{
|
|
if (string.IsNullOrWhiteSpace(form.ChapterText))
|
|
{
|
|
throw new InvalidOperationException("Enter fictional chapter text before running the chapter structure test.");
|
|
}
|
|
|
|
var template = await prompts.LoadPromptAsync(ChapterPromptFile, cancellationToken);
|
|
var numberedChapterText = StoryIntelligenceParagraphs.Number(paragraphs);
|
|
var completedPrompt = BuildChapterPrompt(template, contextJson, numberedChapterText);
|
|
var result = await client.ExecutePromptAsync(
|
|
completedPrompt,
|
|
promptVersion,
|
|
cancellationToken,
|
|
clientStatus.ChapterStructureModel);
|
|
var chapterJson = ExtractOutputText(result.RawResponseText);
|
|
var parsedChapter = DeserializeChapter(chapterJson);
|
|
var chapterNormalisation = ChapterStructureBoundaryNormaliser.Normalise(parsedChapter, paragraphCount);
|
|
var chapterForValidation = chapterNormalisation.ChapterStructure;
|
|
var validation = validator.Validate(chapterForValidation, paragraphCount);
|
|
ChapterStructureBoundaryNormaliser.AddIssuesTo(validation, chapterNormalisation);
|
|
|
|
model.Result = new ChapterStructureDryRunResultViewModel
|
|
{
|
|
Success = true,
|
|
PromptVersion = promptVersion,
|
|
Model = result.Model,
|
|
ChapterContextJson = contextJson,
|
|
ParagraphCount = paragraphCount,
|
|
Duration = FormatDuration(result.Duration),
|
|
RetryCount = result.RetryCount,
|
|
InputTokens = result.InputTokens,
|
|
OutputTokens = result.OutputTokens,
|
|
RawResponseText = result.RawResponseText,
|
|
ChapterJsonText = chapterJson,
|
|
ParsedChapter = chapterForValidation,
|
|
Validation = validation
|
|
};
|
|
|
|
logger.LogInformation(
|
|
"Admin Chapter Structure dry run completed. PromptVersion={PromptVersion} Model={Model} DurationMs={DurationMs} RetryCount={RetryCount} InputTokens={InputTokens} OutputTokens={OutputTokens} ValidationPassed={ValidationPassed} ValidationWarnings={ValidationWarnings} ValidationErrors={ValidationErrors} Success={Success}",
|
|
promptVersion,
|
|
result.Model,
|
|
result.Duration.TotalMilliseconds,
|
|
result.RetryCount,
|
|
result.InputTokens,
|
|
result.OutputTokens,
|
|
validation.IsValid,
|
|
validation.Warnings.Count,
|
|
validation.Errors.Count,
|
|
true);
|
|
}
|
|
catch (JsonException ex)
|
|
{
|
|
model.Result = new ChapterStructureDryRunResultViewModel
|
|
{
|
|
Success = false,
|
|
PromptVersion = promptVersion,
|
|
Model = clientStatus.ChapterStructureModel,
|
|
ChapterContextJson = contextJson,
|
|
ParagraphCount = paragraphCount,
|
|
ErrorMessage = $"Chapter Structure JSON could not be deserialised: {ex.Message}",
|
|
Validation = validator.Validate(null, paragraphCount)
|
|
};
|
|
|
|
logger.LogWarning(
|
|
ex,
|
|
"Admin Chapter Structure dry run deserialisation failed. PromptVersion={PromptVersion} Model={Model} Success={Success}",
|
|
promptVersion,
|
|
clientStatus.ChapterStructureModel,
|
|
false);
|
|
}
|
|
catch (Exception ex) when (ex is not OperationCanceledException)
|
|
{
|
|
model.Result = new ChapterStructureDryRunResultViewModel
|
|
{
|
|
Success = false,
|
|
PromptVersion = promptVersion,
|
|
Model = clientStatus.ChapterStructureModel,
|
|
ChapterContextJson = contextJson,
|
|
ParagraphCount = paragraphCount,
|
|
ErrorMessage = ex.Message
|
|
};
|
|
|
|
logger.LogWarning(
|
|
ex,
|
|
"Admin Chapter Structure dry run failed. PromptVersion={PromptVersion} Model={Model} Success={Success}",
|
|
promptVersion,
|
|
clientStatus.ChapterStructureModel,
|
|
false);
|
|
}
|
|
|
|
return model;
|
|
}
|
|
|
|
private static string BuildChapterContextJson(ChapterStructureDryRunForm form)
|
|
=> JsonSerializer.Serialize(new
|
|
{
|
|
projectId = form.ProjectID,
|
|
bookId = form.BookID,
|
|
chapterId = form.ChapterID,
|
|
chapterNumber = form.ChapterNumber,
|
|
sourceLabel = string.IsNullOrWhiteSpace(form.SourceLabel) ? "Admin test chapter" : form.SourceLabel.Trim()
|
|
}, 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 ChapterStructureModel? DeserializeChapter(string chapterJson)
|
|
=> JsonSerializer.Deserialize<ChapterStructureModel>(chapterJson, JsonOptions);
|
|
|
|
private static string FormatDuration(TimeSpan duration)
|
|
=> duration.TotalSeconds < 1
|
|
? $"{duration.TotalMilliseconds:0} ms"
|
|
: $"{duration.TotalSeconds:0.00} sec";
|
|
}
|
|
|
|
internal sealed class OpenAIResponseEnvelope
|
|
{
|
|
public List<OpenAIResponseOutputItem>? Output { get; init; }
|
|
public OpenAIResponseUsage? Usage { get; init; }
|
|
}
|
|
|
|
internal sealed class OpenAIResponseOutputItem
|
|
{
|
|
public List<OpenAIResponseContentItem>? Content { get; init; }
|
|
}
|
|
|
|
internal sealed class OpenAIResponseContentItem
|
|
{
|
|
public string? Type { get; init; }
|
|
public string? Text { get; init; }
|
|
}
|
|
|
|
internal sealed class OpenAIResponseUsage
|
|
{
|
|
[JsonPropertyName("input_tokens")]
|
|
public int? InputTokens { get; init; }
|
|
|
|
[JsonPropertyName("output_tokens")]
|
|
public int? OutputTokens { get; init; }
|
|
}
|
|
|
|
public sealed class StoryIntelligenceClientConfigurationStatus
|
|
{
|
|
public bool ApiKeyConfigured { get; init; }
|
|
public string Model { get; init; } = string.Empty;
|
|
public string ChapterStructureModel { get; init; } = string.Empty;
|
|
public string SceneIntelligenceModel { get; init; } = string.Empty;
|
|
public int MaxOutputTokens { get; init; }
|
|
public int TimeoutSeconds { get; init; }
|
|
public bool ClientConstructed { get; init; }
|
|
public string ConnectionStatus { get; init; } = string.Empty;
|
|
}
|