PlotDirector/PlotLine/Services/StoryIntelligenceOpenAIInfrastructure.cs
2026-07-04 17:06:59 +01:00

310 lines
12 KiB
C#

using System.Diagnostics;
using System.Net;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
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);
StoryIntelligenceClientConfigurationStatus GetConfigurationStatus();
}
public interface IStoryIntelligenceDiagnosticsService
{
Task<StoryIntelligenceDiagnosticsViewModel> GetDiagnosticsAsync(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.Model,
Temperature = settings.Temperature,
MaxOutputTokens = settings.MaxOutputTokens,
TimeoutSeconds = settings.TimeoutSeconds,
ClientConstructed = true,
ConnectionStatus = "Not checked. Diagnostics do not call OpenAI."
};
public async Task<StoryIntelligenceClientResult> ExecutePromptAsync(string completedPrompt, string promptVersion, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(settings.ApiKey))
{
throw new InvalidOperationException("OpenAI API key is not configured. Add OpenAI:ApiKey to User Secrets or environment variables.");
}
if (string.IsNullOrWhiteSpace(settings.Model))
{
throw new InvalidOperationException("OpenAI model is not configured. Set OpenAI:Model in configuration.");
}
if (string.IsNullOrWhiteSpace(completedPrompt))
{
throw new InvalidOperationException("Completed prompt is empty.");
}
var requestBody = JsonSerializer.Serialize(new
{
model = settings.Model,
input = completedPrompt,
temperature = settings.Temperature,
max_output_tokens = settings.MaxOutputTokens
}, JsonOptions);
var stopwatch = Stopwatch.StartNew();
try
{
var responseText = await SendWithRetryAsync(requestBody, cancellationToken);
stopwatch.Stop();
logger.LogInformation(
"Story Intelligence prompt executed. PromptVersion={PromptVersion} Model={Model} DurationMs={DurationMs} InputTokens={InputTokens} OutputTokens={OutputTokens} EstimatedCost={EstimatedCost} Success={Success}",
promptVersion,
settings.Model,
stopwatch.ElapsedMilliseconds,
null,
null,
null,
true);
return new StoryIntelligenceClientResult
{
RawResponseText = responseText,
Model = settings.Model,
Duration = stopwatch.Elapsed
};
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
stopwatch.Stop();
logger.LogError(
ex,
"Story Intelligence prompt failed. PromptVersion={PromptVersion} Model={Model} DurationMs={DurationMs} InputTokens={InputTokens} OutputTokens={OutputTokens} EstimatedCost={EstimatedCost} Success={Success}",
promptVersion,
settings.Model,
stopwatch.ElapsedMilliseconds,
null,
null,
null,
false);
throw;
}
}
private async Task<string> SendWithRetryAsync(string requestBody, CancellationToken cancellationToken)
{
const int maxAttempts = 3;
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;
}
if (!ShouldRetry(response.StatusCode) || attempt == maxAttempts)
{
throw new InvalidOperationException($"OpenAI request failed with {(int)response.StatusCode} {response.StatusCode}: {TrimForLog(responseText)}");
}
}
catch (TaskCanceledException) when (!cancellationToken.IsCancellationRequested && attempt < maxAttempts)
{
await DelayForRetryAsync(attempt, cancellationToken);
continue;
}
catch (HttpRequestException) when (attempt < maxAttempts)
{
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];
}
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,
ApiKeyConfigured = clientStatus.ApiKeyConfigured,
ClientConstructed = clientStatus.ClientConstructed,
ConnectionStatus = clientStatus.ConnectionStatus,
PromptFileName = ScenePromptFile,
PromptVersion = versions.GetPromptVersion(ScenePromptFile),
Temperature = clientStatus.Temperature,
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 StoryIntelligenceClientConfigurationStatus
{
public bool ApiKeyConfigured { get; init; }
public string Model { get; init; } = string.Empty;
public decimal Temperature { get; init; }
public int MaxOutputTokens { get; init; }
public int TimeoutSeconds { get; init; }
public bool ClientConstructed { get; init; }
public string ConnectionStatus { get; init; } = string.Empty;
}