310 lines
12 KiB
C#
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;
|
|
}
|