diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Responses/AIPromptCaptureResponse.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Responses/AIPromptCaptureResponse.cs index fc1fac75f3..5c60ea2ae0 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Responses/AIPromptCaptureResponse.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Responses/AIPromptCaptureResponse.cs @@ -23,11 +23,8 @@ public class AIPromptCaptureResponse [JsonPropertyName("userPrompt")] public string UserPrompt { get; set; } = string.Empty; - [JsonPropertyName("rawOutput")] - public string RawOutput { get; set; } = string.Empty; - - [JsonPropertyName("formattedOutput")] - public string FormattedOutput { get; set; } = string.Empty; + [JsonPropertyName("output")] + public string Output { get; set; } = string.Empty; [JsonPropertyName("capturedAt")] public DateTime CapturedAt { get; set; } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/AIOperationResult.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/AIOperationResult.cs new file mode 100644 index 0000000000..f1ffbda307 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/AIOperationResult.cs @@ -0,0 +1,33 @@ +namespace Unity.GrantManager.AI +{ + internal enum AIOperationOutcome + { + Success, + TransientFailure, + PermanentFailure, + InvalidOutput + } + + internal sealed record AIOperationResult( + AIOperationOutcome Outcome, + AIProviderResponse Response) + { + public string Content => Response.Content; + + public string CaptureOutput => Response.CaptureOutput; + + public static AIOperationResult Success(AIProviderResponse? response = null) => + new(AIOperationOutcome.Success, response ?? AIProviderResponse.Empty); + + public static AIOperationResult TransientFailure(AIProviderResponse? response = null) => + new(AIOperationOutcome.TransientFailure, response ?? AIProviderResponse.Empty); + + public static AIOperationResult PermanentFailure(AIProviderResponse? response = null) => + new(AIOperationOutcome.PermanentFailure, response ?? AIProviderResponse.Empty); + + public static AIOperationResult InvalidOutput(AIProviderResponse? response = null) => + new(AIOperationOutcome.InvalidOutput, response ?? AIProviderResponse.Empty); + + public AIOperationResult WithOutcome(AIOperationOutcome outcome) => new(outcome, Response); + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/AIProviderResponse.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/AIProviderResponse.cs new file mode 100644 index 0000000000..973af81507 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/AIProviderResponse.cs @@ -0,0 +1,17 @@ +namespace Unity.GrantManager.AI +{ + internal sealed record AIProviderResponse( + string Content, + string RawResponse = "", + string? Model = null, + string? FinishReason = null, + int? PromptTokens = null, + int? CompletionTokens = null, + int? TotalTokens = null, + int? ReasoningTokens = null) + { + public static AIProviderResponse Empty { get; } = new(string.Empty); + + public string CaptureOutput => string.IsNullOrWhiteSpace(RawResponse) ? Content : RawResponse; + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/AIProviderResponseMetadata.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/AIProviderResponseMetadata.cs new file mode 100644 index 0000000000..34e75b7d0e --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/AIProviderResponseMetadata.cs @@ -0,0 +1,10 @@ +namespace Unity.GrantManager.AI +{ + internal sealed record AIProviderResponseMetadata( + string? Model, + string? FinishReason, + int? PromptTokens, + int? CompletionTokens, + int? TotalTokens, + int? ReasoningTokens); +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/AIResponseJson.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/AIResponseJson.cs new file mode 100644 index 0000000000..13c591f0f2 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/AIResponseJson.cs @@ -0,0 +1,63 @@ +using System; + +namespace Unity.GrantManager.AI +{ + internal static class AIResponseJson + { + public static string CleanJsonResponse(string response) + { + if (string.IsNullOrWhiteSpace(response)) + { + return string.Empty; + } + + var cleaned = response.Trim(); + + if (cleaned.StartsWith("```json", StringComparison.OrdinalIgnoreCase) || cleaned.StartsWith("```")) + { + var startIndex = cleaned.IndexOf('\n'); + if (startIndex >= 0) + { + cleaned = cleaned[(startIndex + 1)..]; + } + else + { + var jsonStart = FindFirstJsonTokenIndex(cleaned); + if (jsonStart > 0) + { + cleaned = cleaned[jsonStart..]; + } + } + } + + if (cleaned.EndsWith("```", StringComparison.Ordinal)) + { + var lastIndex = cleaned.LastIndexOf("```", StringComparison.Ordinal); + if (lastIndex > 0) + { + cleaned = cleaned[..lastIndex]; + } + } + + return cleaned.Trim(); + } + + private static int FindFirstJsonTokenIndex(string value) + { + var objectStart = value.IndexOf('{'); + var arrayStart = value.IndexOf('['); + + if (objectStart >= 0 && arrayStart >= 0) + { + return Math.Min(objectStart, arrayStart); + } + + if (objectStart >= 0) + { + return objectStart; + } + + return arrayStart; + } + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/AIResponseValidator.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/AIResponseValidator.cs new file mode 100644 index 0000000000..7d80341dfb --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/AIResponseValidator.cs @@ -0,0 +1,150 @@ +using System; +using System.Collections.Generic; +using System.Text.Json; + +namespace Unity.GrantManager.AI +{ + internal static class AIResponseValidator + { + public static bool IsValidAttachmentSummaryText(string response) + { + return !string.IsNullOrWhiteSpace(response); + } + + public static bool IsValidApplicationAnalysisJson(string response) + { + if (!TryParseRootObject(response, out var root)) + { + return false; + } + + return root.TryGetProperty(AIJsonKeys.Rating, out var rating) + && rating.ValueKind == JsonValueKind.String + && root.TryGetProperty(AIJsonKeys.Errors, out var errors) + && errors.ValueKind == JsonValueKind.Array + && root.TryGetProperty(AIJsonKeys.Warnings, out var warnings) + && warnings.ValueKind == JsonValueKind.Array + && root.TryGetProperty(AIJsonKeys.Summaries, out var summaries) + && summaries.ValueKind == JsonValueKind.Array + && root.TryGetProperty(AIJsonKeys.NextSteps, out var nextSteps) + && nextSteps.ValueKind == JsonValueKind.Array; + } + + public static bool IsValidScoresheetSectionJson(string response, string sectionJson) + { + if (!TryParseRootObject(response, out var root)) + { + return false; + } + + var expectedQuestionIds = ExtractQuestionIds(sectionJson); + if (expectedQuestionIds.Count == 0) + { + return false; + } + + foreach (var questionId in expectedQuestionIds) + { + if (!root.TryGetProperty(questionId, out var answerObject) || answerObject.ValueKind != JsonValueKind.Object) + { + return false; + } + + if (!answerObject.TryGetProperty(AIJsonKeys.Answer, out var answerValue) + || answerValue.ValueKind == JsonValueKind.Null + || answerValue.ValueKind == JsonValueKind.Object + || answerValue.ValueKind == JsonValueKind.Array) + { + return false; + } + + if (!answerObject.TryGetProperty(AIJsonKeys.Confidence, out var confidenceValue) + || confidenceValue.ValueKind != JsonValueKind.Number + || !confidenceValue.TryGetInt32(out var confidence) + || confidence < 0 + || confidence > 100) + { + return false; + } + } + + return true; + } + + private static HashSet ExtractQuestionIds(string sectionJson) + { + var ids = new HashSet(StringComparer.OrdinalIgnoreCase); + + try + { + using var jsonDoc = JsonDocument.Parse(sectionJson); + var root = jsonDoc.RootElement; + + if (root.ValueKind == JsonValueKind.Array) + { + AddQuestionIds(root, ids); + return ids; + } + + if (root.ValueKind == JsonValueKind.Object && + root.TryGetProperty("questions", out var questionsElement) && + questionsElement.ValueKind == JsonValueKind.Array) + { + AddQuestionIds(questionsElement, ids); + } + } + catch + { + return ids; + } + + return ids; + } + + private static void AddQuestionIds(JsonElement questionsArray, HashSet ids) + { + foreach (var item in questionsArray.EnumerateArray()) + { + if (item.ValueKind != JsonValueKind.Object || + !item.TryGetProperty("id", out var idProperty) || + idProperty.ValueKind != JsonValueKind.String) + { + continue; + } + + var id = idProperty.GetString(); + if (!string.IsNullOrWhiteSpace(id)) + { + ids.Add(id); + } + } + } + + private static bool TryParseRootObject(string response, out JsonElement root) + { + root = default; + + if (string.IsNullOrWhiteSpace(response)) + { + return false; + } + + try + { + using var jsonDoc = JsonDocument.Parse(AIResponseJson.CleanJsonResponse(response)); + if (jsonDoc.RootElement.ValueKind != JsonValueKind.Object) + { + return false; + } + + root = jsonDoc.RootElement.Clone(); + return true; + } + catch + { + return false; + } + } + + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/OpenAIService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/OpenAIService.cs index a1cb4ba783..1ffa2cdee2 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/OpenAIService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/OpenAIService.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Net; using System.Net.Http; using System.Text; using System.Text.Json; @@ -32,13 +33,21 @@ public class OpenAIService : IAIService, ITransientDependency private const string AttachmentUserTemplateName = "attachment.user"; private const string ScoresheetSystemTemplateName = "scoresheet.system"; private const string ScoresheetUserTemplateName = "scoresheet.user"; - private const string NoSummaryGeneratedMessage = "No summary generated."; private const string ServiceNotConfiguredMessage = "AI analysis not available - service not configured."; private const string ServiceTemporarilyUnavailableMessage = "AI analysis failed - service temporarily unavailable."; private const string SummaryFailedRetryMessage = "AI analysis failed - please try again later."; - - private string? ApiKey => _configuration["Azure:OpenAI:ApiKey"]; - private string? ApiUrl => _configuration["Azure:OpenAI:ApiUrl"] ?? "https://api.openai.com/v1/chat/completions"; + private const int MaxAiAttempts = 3; + private const string DefaultMaxTokensParameterName = "max_completion_tokens"; + private const string LegacyMaxTokensParameterName = "max_tokens"; + private const string DefaultProviderName = "OpenAI"; + private const int DefaultCompletionTokens = 150; + private const int DefaultAttachmentSummaryCompletionTokens = 500; + private const int DefaultApplicationAnalysisCompletionTokens = 2500; + private const int DefaultScoresheetSectionCompletionTokens = 5000; + + private int AttachmentSummaryCompletionTokens => ResolveCompletionTokens("AttachmentSummary", DefaultAttachmentSummaryCompletionTokens); + private int ApplicationAnalysisCompletionTokens => ResolveCompletionTokens("ApplicationAnalysis", DefaultApplicationAnalysisCompletionTokens); + private int ScoresheetSectionCompletionTokens => ResolveCompletionTokens("ScoresheetSection", DefaultScoresheetSectionCompletionTokens); private readonly string MissingApiKeyMessage = "OpenAI API key is not configured"; // Optional local debugging sink for prompt payload logs to a local file. @@ -57,8 +66,6 @@ public class OpenAIService : IAIService, ITransientDependency }; private static readonly ConcurrentDictionary PromptTemplateCache = new(StringComparer.OrdinalIgnoreCase); - private string SelectedPromptVersion => ResolvePromptVersion(_configuration["Azure:OpenAI:PromptVersion"]); - public OpenAIService( HttpClient httpClient, IConfiguration configuration, @@ -75,7 +82,7 @@ public OpenAIService( public Task IsAvailableAsync() { - if (string.IsNullOrEmpty(ApiKey)) + if (string.IsNullOrEmpty(ResolveApiKey())) { _logger.LogWarning("Error: {Message}", MissingApiKeyMessage); return Task.FromResult(false); @@ -86,18 +93,21 @@ public Task IsAvailableAsync() public async Task GenerateCompletionAsync(AICompletionRequest request) { - var content = await GenerateSummaryAsync( + var result = await GenerateWithRetryAsync( + () => GenerateSummaryAsync( request?.UserPrompt ?? string.Empty, null, - request?.MaxTokens ?? 150, - request?.Temperature); - return new AICompletionResponse { Content = content }; + request?.MaxTokens ?? DefaultCompletionTokens, + request?.Temperature), + AIResponseValidator.IsValidAttachmentSummaryText, + "completion"); + return new AICompletionResponse { Content = ResolveNarrativeContent(result) }; } public async Task GenerateApplicationAnalysisAsync(ApplicationAnalysisRequest request) { ArgumentNullException.ThrowIfNull(request); - var promptVersion = ResolvePromptVersion(request.PromptVersion ?? SelectedPromptVersion); + var promptVersion = ResolvePromptVersion(request.PromptVersion ?? ResolvePromptVersionSetting(ApplicationAnalysisPromptType)); var capturePromptIo = request.CapturePromptIo; var data = JsonSerializer.Serialize(request.Data, JsonLogOptions); var schema = JsonSerializer.Serialize(request.Schema, JsonLogOptions); @@ -118,22 +128,44 @@ public async Task GenerateApplicationAnalysisAsync( data, attachments); await LogPromptInputAsync(ApplicationAnalysisPromptType, promptVersion, systemPrompt, analysisContent); - var raw = await GenerateSummaryAsync(analysisContent, systemPrompt, 1000); - await LogPromptOutputAsync(ApplicationAnalysisPromptType, promptVersion, raw); - SavePromptCapture(capturePromptIo, request.CaptureContextId, ApplicationAnalysisPromptType, promptVersion, "Application Analysis", systemPrompt, analysisContent, raw); - return ParseApplicationAnalysisResponse(AddIdsToAnalysisItems(raw)); + var result = await GenerateWithRetryAsync( + () => GenerateSummaryAsync( + analysisContent, + systemPrompt, + ApplicationAnalysisCompletionTokens, + operationName: ApplicationAnalysisPromptType), + AIResponseValidator.IsValidApplicationAnalysisJson, + "application analysis"); + await LogPromptOutputAsync(ApplicationAnalysisPromptType, promptVersion, result.CaptureOutput); + SavePromptCapture(capturePromptIo, request.CaptureContextId, ApplicationAnalysisPromptType, promptVersion, "Application Analysis", systemPrompt, analysisContent, result.CaptureOutput); + + if (result.Outcome != AIOperationOutcome.Success) + { + return new ApplicationAnalysisResponse(); + } + + return ParseApplicationAnalysisResponse(AddIdsToAnalysisItems(result.Content)); } - private async Task GenerateSummaryAsync( + private async Task GenerateSummaryAsync( string content, string? systemPrompt, int maxTokens = 150, - double? temperature = null) + double? temperature = null, + string? operationName = null) { - if (string.IsNullOrEmpty(ApiKey)) + var providerName = ResolveProviderName(operationName); + if (!string.Equals(providerName, DefaultProviderName, StringComparison.Ordinal)) + { + _logger.LogWarning("Provider {ProviderName} is not supported by OpenAIService.", providerName); + return AIOperationResult.PermanentFailure(new AIProviderResponse($"Unsupported provider: {providerName}")); + } + + var apiKey = ResolveApiKey(operationName); + if (string.IsNullOrEmpty(apiKey)) { _logger.LogWarning("Error: {Message}", MissingApiKeyMessage); - return ServiceNotConfiguredMessage; + return AIOperationResult.PermanentFailure(new AIProviderResponse(MissingApiKeyMessage)); } _logger.LogDebug("Calling OpenAI chat completions. PromptLength: {PromptLength}, MaxTokens: {MaxTokens}", content?.Length ?? 0, maxTokens); @@ -151,50 +183,74 @@ private async Task GenerateSummaryAsync( { new { role = "system", content = resolvedSystemPrompt }, new { role = "user", content = userPrompt } - }, - max_tokens = maxTokens, - temperature = temperature ?? 0.3 + } }; - var json = JsonSerializer.Serialize(requestBody); + var requestPayload = new Dictionary + { + ["messages"] = requestBody.messages, + [ResolveMaxTokensParameterNameForOperation(operationName)] = maxTokens + }; + + var resolvedTemperature = temperature ?? ResolveConfiguredTemperature(operationName); + if (resolvedTemperature.HasValue) + { + requestPayload["temperature"] = resolvedTemperature.Value; + } + + var json = JsonSerializer.Serialize(requestPayload); var httpContent = new StringContent(json, Encoding.UTF8, "application/json"); _httpClient.DefaultRequestHeaders.Clear(); - _httpClient.DefaultRequestHeaders.Add("Authorization", ApiKey); + _httpClient.DefaultRequestHeaders.Add("Authorization", apiKey); - var response = await _httpClient.PostAsync(ApiUrl, httpContent); + var response = await _httpClient.PostAsync(ResolveApiUrl(operationName), httpContent); var responseContent = await response.Content.ReadAsStringAsync(); + var metadata = TryExtractProviderMetadata(responseContent); + var providerResponse = BuildProviderResponseFromMetadata(string.Empty, responseContent, metadata); _logger.LogDebug( "OpenAI chat completions response received. StatusCode: {StatusCode}, ResponseLength: {ResponseLength}", response.StatusCode, responseContent?.Length ?? 0); + LogProviderMetadata(operationName, providerResponse); if (!response.IsSuccessStatusCode) { _logger.LogError("OpenAI API request failed: {StatusCode} - {Content}", response.StatusCode, responseContent); - return ServiceTemporarilyUnavailableMessage; + return MapFailureOutcome(response.StatusCode, providerResponse); } if (string.IsNullOrWhiteSpace(responseContent)) { - return NoSummaryGeneratedMessage; + return AIOperationResult.InvalidOutput(providerResponse); } - using var jsonDoc = JsonDocument.Parse(responseContent); - var choices = jsonDoc.RootElement.GetProperty("choices"); - if (choices.GetArrayLength() > 0) + try { - var message = choices[0].GetProperty("message"); - return message.GetProperty("content").GetString() ?? NoSummaryGeneratedMessage; - } + using var jsonDoc = JsonDocument.Parse(responseContent); + var choices = jsonDoc.RootElement.GetProperty("choices"); + if (choices.GetArrayLength() > 0) + { + var message = choices[0].GetProperty("message"); + var modelOutput = message.GetProperty("content").GetString(); + return string.IsNullOrWhiteSpace(modelOutput) + ? AIOperationResult.InvalidOutput(providerResponse) + : AIOperationResult.Success(BuildProviderResponseFromMetadata(modelOutput, responseContent, metadata)); + } - return NoSummaryGeneratedMessage; + return AIOperationResult.InvalidOutput(providerResponse); + } + catch (Exception ex) when (ex is JsonException || ex is KeyNotFoundException || ex is InvalidOperationException) + { + _logger.LogWarning(ex, "AI response payload had an invalid output shape"); + return AIOperationResult.InvalidOutput(providerResponse); + } } catch (Exception ex) { _logger.LogError(ex, "Error generating AI summary"); - return SummaryFailedRetryMessage; + return AIOperationResult.TransientFailure(new AIProviderResponse(ex.Message)); } } @@ -204,7 +260,7 @@ public async Task GenerateAttachmentSummaryAsync(Atta var fileName = request.FileName ?? string.Empty; var fileContent = request.FileContent ?? Array.Empty(); var contentType = request.ContentType ?? "application/octet-stream"; - var promptVersion = ResolvePromptVersion(request.PromptVersion ?? SelectedPromptVersion); + var promptVersion = ResolvePromptVersion(request.PromptVersion ?? ResolvePromptVersionSetting(AttachmentSummaryPromptType)); var capturePromptIo = request.CapturePromptIo; try @@ -233,13 +289,28 @@ public async Task GenerateAttachmentSummaryAsync(Atta var contentToAnalyze = BuildAttachmentUserPrompt(promptVersion, attachment); await LogPromptInputAsync(AttachmentSummaryPromptType, promptVersion, prompt, contentToAnalyze); - var modelOutput = await GenerateSummaryAsync(contentToAnalyze, prompt, 150); - await LogPromptOutputAsync(AttachmentSummaryPromptType, promptVersion, modelOutput); - SavePromptCapture(capturePromptIo, request.CaptureContextId, AttachmentSummaryPromptType, promptVersion, fileName, prompt, contentToAnalyze, modelOutput); + var result = await GenerateWithRetryAsync( + () => GenerateSummaryAsync( + contentToAnalyze, + prompt, + AttachmentSummaryCompletionTokens, + operationName: AttachmentSummaryPromptType), + AIResponseValidator.IsValidAttachmentSummaryText, + "attachment summary"); + await LogPromptOutputAsync(AttachmentSummaryPromptType, promptVersion, result.CaptureOutput); + SavePromptCapture(capturePromptIo, request.CaptureContextId, AttachmentSummaryPromptType, promptVersion, fileName, prompt, contentToAnalyze, result.CaptureOutput); + + if (result.Outcome != AIOperationOutcome.Success) + { + return new AttachmentSummaryResponse + { + Summary = $"AI analysis not available for this attachment ({fileName})." + }; + } return new AttachmentSummaryResponse { - Summary = ExtractSummaryFromJson(modelOutput) + Summary = ExtractSummaryFromJson(result.Content) }; } catch (Exception ex) @@ -326,7 +397,7 @@ private string AddIdsToAnalysisItems(string analysisJson) public async Task GenerateScoresheetSectionAsync(ScoresheetSectionRequest request) { ArgumentNullException.ThrowIfNull(request); - var promptVersion = ResolvePromptVersion(request.PromptVersion ?? SelectedPromptVersion); + var promptVersion = ResolvePromptVersion(request.PromptVersion ?? ResolvePromptVersionSetting(ScoresheetSectionPromptType)); var capturePromptIo = request.CapturePromptIo; var dataJson = JsonSerializer.Serialize(request.Data, JsonLogOptions); var sectionJson = JsonSerializer.Serialize(request.SectionSchema, JsonLogOptions); @@ -334,8 +405,7 @@ public async Task GenerateScoresheetSectionAsync(Scor var attachmentSummaries = request.Attachments .Select(a => $"{a.Name}: {a.Summary}") .ToList(); - - if (string.IsNullOrEmpty(ApiKey)) + if (string.IsNullOrEmpty(ResolveApiKey(ScoresheetSectionPromptType))) { _logger.LogWarning("{Message}", MissingApiKeyMessage); return new ScoresheetSectionResponse(); @@ -385,11 +455,23 @@ public async Task GenerateScoresheetSectionAsync(Scor var systemPrompt = BuildScoresheetSectionSystemPrompt(promptVersion); await LogPromptInputAsync(ScoresheetSectionPromptType, promptVersion, systemPrompt, analysisContent); - var modelOutput = await GenerateSummaryAsync(analysisContent, systemPrompt, 2000); - await LogPromptOutputAsync(ScoresheetSectionPromptType, promptVersion, modelOutput); - SavePromptCapture(capturePromptIo, request.CaptureContextId, ScoresheetSectionPromptType, promptVersion, request.SectionName, systemPrompt, analysisContent, modelOutput); + var result = await GenerateWithRetryAsync( + () => GenerateSummaryAsync( + analysisContent, + systemPrompt, + ScoresheetSectionCompletionTokens, + operationName: ScoresheetSectionPromptType), + content => AIResponseValidator.IsValidScoresheetSectionJson(content, sectionJson), + $"scoresheet section {request.SectionName}"); + await LogPromptOutputAsync(ScoresheetSectionPromptType, promptVersion, result.CaptureOutput); + SavePromptCapture(capturePromptIo, request.CaptureContextId, ScoresheetSectionPromptType, promptVersion, request.SectionName, systemPrompt, analysisContent, result.CaptureOutput); + + if (result.Outcome != AIOperationOutcome.Success) + { + return new ScoresheetSectionResponse(); + } - return ParseScoresheetSectionResponse(modelOutput); + return ParseScoresheetSectionResponse(result.Content); } catch (Exception ex) { @@ -398,6 +480,310 @@ public async Task GenerateScoresheetSectionAsync(Scor } } + private async Task GenerateWithRetryAsync( + Func> operation, + Func validator, + string operationName) + { + var lastResult = AIOperationResult.InvalidOutput(); + + for (var attempt = 1; attempt <= MaxAiAttempts; attempt++) + { + lastResult = await operation(); + + if (lastResult.Outcome == AIOperationOutcome.Success && validator(lastResult.Content)) + { + return lastResult; + } + + if (lastResult.Outcome == AIOperationOutcome.Success) + { + lastResult = lastResult.WithOutcome(AIOperationOutcome.InvalidOutput); + } + + if (lastResult.Outcome == AIOperationOutcome.PermanentFailure) + { + return lastResult; + } + + if (attempt < MaxAiAttempts) + { + if (lastResult.Outcome == AIOperationOutcome.TransientFailure) + { + _logger.LogWarning( + "AI {OperationName} attempt {Attempt}/{MaxAttempts} failed transiently; retrying", + operationName, + attempt, + MaxAiAttempts); + } + else if (lastResult.Outcome == AIOperationOutcome.InvalidOutput) + { + _logger.LogWarning( + "AI {OperationName} attempt {Attempt}/{MaxAttempts} returned invalid response shape; retrying", + operationName, + attempt, + MaxAiAttempts); + } + } + } + + _logger.LogWarning( + "AI {OperationName} exhausted retries with outcome {Outcome}; returning last result", + operationName, + lastResult.Outcome); + return lastResult; + } + + private static string ResolveNarrativeContent(AIOperationResult result) + { + return result.Outcome switch + { + AIOperationOutcome.Success => result.Content, + AIOperationOutcome.PermanentFailure => ServiceNotConfiguredMessage, + AIOperationOutcome.TransientFailure => ServiceTemporarilyUnavailableMessage, + _ => SummaryFailedRetryMessage + }; + } + + private static AIOperationResult MapFailureOutcome(HttpStatusCode statusCode, AIProviderResponse response) + { + var statusCodeValue = (int)statusCode; + + if (statusCode == HttpStatusCode.RequestTimeout + || statusCode == (HttpStatusCode)429 + || statusCodeValue >= 500) + { + return AIOperationResult.TransientFailure(response); + } + + return AIOperationResult.PermanentFailure(response); + } + + private static AIProviderResponse BuildProviderResponseFromMetadata(string content, string? rawResponse, AIProviderResponseMetadata? metadata) + { + return new AIProviderResponse( + content, + rawResponse ?? string.Empty, + metadata?.Model, + metadata?.FinishReason, + metadata?.PromptTokens, + metadata?.CompletionTokens, + metadata?.TotalTokens, + metadata?.ReasoningTokens); + } + + private static AIProviderResponseMetadata? TryExtractProviderMetadata(string? responseContent) + { + if (string.IsNullOrWhiteSpace(responseContent)) + { + return null; + } + + try + { + using var jsonDoc = JsonDocument.Parse(responseContent); + var root = jsonDoc.RootElement; + var model = root.TryGetProperty("model", out var modelProp) && modelProp.ValueKind == JsonValueKind.String + ? modelProp.GetString() + : null; + + string? finishReason = null; + if (root.TryGetProperty("choices", out var choices) + && choices.ValueKind == JsonValueKind.Array + && choices.GetArrayLength() > 0) + { + var firstChoice = choices[0]; + if (firstChoice.TryGetProperty("finish_reason", out var finishReasonProp) && finishReasonProp.ValueKind == JsonValueKind.String) + { + finishReason = finishReasonProp.GetString(); + } + } + + int? promptTokens = null; + int? completionTokens = null; + int? totalTokens = null; + int? reasoningTokens = null; + if (root.TryGetProperty("usage", out var usage) && usage.ValueKind == JsonValueKind.Object) + { + promptTokens = TryGetInt32(usage, "prompt_tokens"); + completionTokens = TryGetInt32(usage, "completion_tokens"); + totalTokens = TryGetInt32(usage, "total_tokens"); + + if (usage.TryGetProperty("completion_tokens_details", out var completionTokenDetails) + && completionTokenDetails.ValueKind == JsonValueKind.Object) + { + reasoningTokens = TryGetInt32(completionTokenDetails, "reasoning_tokens"); + } + } + + return new AIProviderResponseMetadata(model, finishReason, promptTokens, completionTokens, totalTokens, reasoningTokens); + } + catch (JsonException) + { + return null; + } + } + + private void LogProviderMetadata(string? operationName, AIProviderResponse response) + { + if (string.IsNullOrWhiteSpace(response.Model) + && string.IsNullOrWhiteSpace(response.FinishReason) + && response.PromptTokens == null + && response.CompletionTokens == null + && response.TotalTokens == null + && response.ReasoningTokens == null) + { + return; + } + + _logger.LogDebug( + "AI provider response metadata for {OperationName}: Model={Model}, FinishReason={FinishReason}, PromptTokens={PromptTokens}, CompletionTokens={CompletionTokens}, TotalTokens={TotalTokens}, ReasoningTokens={ReasoningTokens}", + operationName ?? "completion", + response.Model, + response.FinishReason, + response.PromptTokens, + response.CompletionTokens, + response.TotalTokens, + response.ReasoningTokens); + } + + private static int? TryGetInt32(JsonElement element, string propertyName) + { + return element.TryGetProperty(propertyName, out var property) + && property.ValueKind == JsonValueKind.Number + && property.TryGetInt32(out var value) + ? value + : null; + } + + private static string ResolveMaxTokensParameterName(string? configuredParameterName) + { + if (string.Equals(configuredParameterName, LegacyMaxTokensParameterName, StringComparison.Ordinal)) + { + return LegacyMaxTokensParameterName; + } + + return DefaultMaxTokensParameterName; + } + + private int ResolveCompletionTokens(string operationName, int defaultValue) + { + var configuredValue = _configuration.GetValue($"Azure:Operations:{operationName}:MaxCompletionTokens"); + if (configuredValue is > 0) + { + return configuredValue.Value; + } + + var defaultConfiguredValue = _configuration.GetValue("Azure:Operations:Defaults:MaxCompletionTokens"); + return defaultConfiguredValue is > 0 ? defaultConfiguredValue.Value : defaultValue; + } + + private string? ResolvePromptVersionSetting(string operationName) + { + var operationPromptVersion = _configuration[$"Azure:Operations:{operationName}:PromptVersion"]; + if (!string.IsNullOrWhiteSpace(operationPromptVersion)) + { + return operationPromptVersion; + } + + var defaultPromptVersion = _configuration["Azure:Operations:Defaults:PromptVersion"]; + if (!string.IsNullOrWhiteSpace(defaultPromptVersion)) + { + return defaultPromptVersion; + } + + return _configuration["Azure:OpenAI:PromptVersion"]; + } + + private string ResolveProviderName(string? operationName = null) + { + if (!string.IsNullOrWhiteSpace(operationName)) + { + var configuredProvider = _configuration[$"Azure:Operations:{operationName}:Provider"]; + if (!string.IsNullOrWhiteSpace(configuredProvider)) + { + return configuredProvider.Trim(); + } + } + + var defaultProvider = _configuration["Azure:Operations:Defaults:Provider"]; + return string.IsNullOrWhiteSpace(defaultProvider) ? DefaultProviderName : defaultProvider.Trim(); + } + + private string? ResolveApiKey(string? operationName = null) + { + var providerName = ResolveProviderName(operationName); + return _configuration[$"Azure:{providerName}:ApiKey"]; + } + + private string ResolveMaxTokensParameterNameForOperation(string? operationName = null) + { + var providerName = ResolveProviderName(operationName); + var profileName = ResolveProfileName(operationName); + var profileParameterName = ResolveProfileSetting(providerName, profileName, "MaxTokensParameter"); + return ResolveMaxTokensParameterName(profileParameterName); + } + + private double? ResolveConfiguredTemperature(string? operationName = null) + { + var providerName = ResolveProviderName(operationName); + var profileName = ResolveProfileName(operationName); + var profileTemperature = ResolveProfileSetting(providerName, profileName, "Temperature"); + if (profileTemperature != null + && double.TryParse(profileTemperature, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var parsedTemperature)) + { + return parsedTemperature; + } + + return null; + } + + private string ResolveApiUrl(string? operationName) + { + var providerName = ResolveProviderName(operationName); + var profileName = ResolveProfileName(operationName); + var profileApiUrl = ResolveProfileSetting(providerName, profileName, "ApiUrl"); + var legacyOpenAiApiUrl = _configuration["Azure:OpenAI:ApiUrl"]; + + if (!string.IsNullOrWhiteSpace(profileApiUrl)) + { + return profileApiUrl; + } + + if (!string.IsNullOrWhiteSpace(legacyOpenAiApiUrl)) + { + return legacyOpenAiApiUrl; + } + + throw new InvalidOperationException($"AI API URL is not configured for provider '{providerName}'."); + } + + private string? ResolveProfileName(string? operationName) + { + if (!string.IsNullOrWhiteSpace(operationName)) + { + var operationProfile = _configuration[$"Azure:Operations:{operationName}:Profile"]; + if (!string.IsNullOrWhiteSpace(operationProfile)) + { + return operationProfile.Trim(); + } + } + + var defaultProfile = _configuration["Azure:Operations:Defaults:Profile"]; + return string.IsNullOrWhiteSpace(defaultProfile) ? null : defaultProfile.Trim(); + } + + private string? ResolveProfileSetting(string providerName, string? profileName, string settingName) + { + if (string.IsNullOrWhiteSpace(profileName)) + { + return null; + } + + var profileSetting = _configuration[$"Azure:{providerName}:Profiles:{profileName}:{settingName}"]; + return string.IsNullOrWhiteSpace(profileSetting) ? null : profileSetting; + } + private static ApplicationAnalysisResponse ParseApplicationAnalysisResponse(string raw) { var response = new ApplicationAnalysisResponse(); @@ -668,8 +1054,7 @@ private void SavePromptCapture(bool capturePromptIo, string? contextId, string p CaptureLabel = captureLabel?.Trim() ?? string.Empty, SystemPrompt = systemPrompt?.Trim() ?? string.Empty, UserPrompt = userPrompt?.Trim() ?? string.Empty, - RawOutput = rawOutput?.Trim() ?? string.Empty, - FormattedOutput = FormatPromptOutputForLog(rawOutput ?? string.Empty), + Output = FormatPromptOutputForLog(rawOutput ?? string.Empty), CapturedAt = DateTime.UtcNow }); } @@ -688,6 +1073,11 @@ private static string FormatPromptOutputForLog(string output) return string.Empty; } + if (TryFormatProviderOutput(output, out var formattedProviderOutput)) + { + return formattedProviderOutput; + } + if (TryParseJsonObjectFromResponse(output, out var jsonObject)) { return JsonSerializer.Serialize(jsonObject, JsonLogOptions); @@ -696,24 +1086,63 @@ private static string FormatPromptOutputForLog(string output) return output.Trim(); } - private static bool TryParseJsonObjectFromResponse(string response, out JsonElement objectElement) + private static bool TryFormatProviderOutput(string output, out string formattedOutput) { - objectElement = default; - var cleaned = CleanJsonResponse(response); - if (string.IsNullOrWhiteSpace(cleaned)) - { - return false; - } + formattedOutput = string.Empty; try { - using var doc = JsonDocument.Parse(cleaned); - if (doc.RootElement.ValueKind != JsonValueKind.Object) + using var doc = JsonDocument.Parse(output); + var root = doc.RootElement; + if (root.ValueKind != JsonValueKind.Object + || !root.TryGetProperty("choices", out var choices) + || choices.ValueKind != JsonValueKind.Array + || choices.GetArrayLength() == 0) { return false; } - objectElement = doc.RootElement.Clone(); + var firstChoice = choices[0]; + var content = TryGetChoiceContent(firstChoice); + if (string.IsNullOrWhiteSpace(content)) + { + return false; + } + + var lines = new List(); + + if (root.TryGetProperty("usage", out var usage) && usage.ValueKind == JsonValueKind.Object) + { + var promptTokens = TryGetInt32(usage, "prompt_tokens"); + var completionTokens = TryGetInt32(usage, "completion_tokens"); + int? reasoningTokens = null; + + if (usage.TryGetProperty("completion_tokens_details", out var completionTokenDetails) + && completionTokenDetails.ValueKind == JsonValueKind.Object) + { + reasoningTokens = TryGetInt32(completionTokenDetails, "reasoning_tokens"); + } + + if (promptTokens.HasValue) + { + lines.Add($"PROMPT TOKENS: {promptTokens.Value}"); + } + + if (completionTokens.HasValue) + { + lines.Add($"COMPLETION TOKENS: {completionTokens.Value}"); + } + + if (reasoningTokens.HasValue) + { + lines.Add($"REASONING TOKENS: {reasoningTokens.Value}"); + } + } + + var normalizedContent = FormatPromptOutputContent(content); + lines.Add("CONTENT:"); + lines.Add(normalizedContent); + formattedOutput = string.Join(Environment.NewLine, lines); return true; } catch (JsonException) @@ -722,64 +1151,55 @@ private static bool TryParseJsonObjectFromResponse(string response, out JsonElem } } - private static string CleanJsonResponse(string response) + private static string? TryGetChoiceContent(JsonElement firstChoice) { - if (string.IsNullOrWhiteSpace(response)) + if (!firstChoice.TryGetProperty("message", out var message) || message.ValueKind != JsonValueKind.Object) { - return string.Empty; + return null; } - var cleaned = response.Trim(); - - if (cleaned.StartsWith("```json", StringComparison.OrdinalIgnoreCase) || cleaned.StartsWith("```")) + if (!message.TryGetProperty("content", out var contentProp) || contentProp.ValueKind != JsonValueKind.String) { - var startIndex = cleaned.IndexOf('\n'); - if (startIndex >= 0) - { - // Multi-line fenced code block: remove everything up to and including the first newline. - cleaned = cleaned[(startIndex + 1)..]; - } - else - { - // Single-line fenced JSON, e.g. ```json { ... } ``` or ```{ ... } ```. - // Strip everything before the first likely JSON payload token. - var jsonStart = FindFirstJsonTokenIndex(cleaned); - - if (jsonStart > 0) - { - cleaned = cleaned[jsonStart..]; - } - } + return null; } - if (cleaned.EndsWith("```", StringComparison.Ordinal)) + return contentProp.GetString(); + } + + private static string FormatPromptOutputContent(string content) + { + if (TryParseJsonObjectFromResponse(content, out var contentObject)) { - var lastIndex = cleaned.LastIndexOf("```", StringComparison.Ordinal); - if (lastIndex > 0) - { - cleaned = cleaned[..lastIndex]; - } + return JsonSerializer.Serialize(contentObject, JsonLogOptions); } - return cleaned.Trim(); + return content.Trim(); } - private static int FindFirstJsonTokenIndex(string value) + private static bool TryParseJsonObjectFromResponse(string response, out JsonElement objectElement) { - var objectStart = value.IndexOf('{'); - var arrayStart = value.IndexOf('['); - - if (objectStart >= 0 && arrayStart >= 0) + objectElement = default; + var cleaned = AIResponseJson.CleanJsonResponse(response); + if (string.IsNullOrWhiteSpace(cleaned)) { - return Math.Min(objectStart, arrayStart); + return false; } - if (objectStart >= 0) + try + { + using var doc = JsonDocument.Parse(cleaned); + if (doc.RootElement.ValueKind != JsonValueKind.Object) + { + return false; + } + + objectElement = doc.RootElement.Clone(); + return true; + } + catch (JsonException) { - return objectStart; + return false; } - - return arrayStart; } private static string ResolvePromptVersion(string? version) @@ -1039,3 +1459,5 @@ private static string ExtractSummaryFromJson(string output) } } } + + diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/README.md b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/README.md index cc8d06ef5f..0a2ae41b7b 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/README.md +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/README.md @@ -40,7 +40,8 @@ Placeholders: Version selection: -- `Azure:OpenAI:PromptVersion = v0|v1` +- Preferred: `Azure:Operations:Defaults:PromptVersion = v0|v1`, with optional overrides under `Azure:Operations::PromptVersion` +- Legacy fallback: `Azure:OpenAI:PromptVersion = v0|v1` - Unknown or missing version defaults to `v1`. Template loading is strict: diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/v1/analysis.rules.txt b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/v1/analysis.rules.txt index ee9c1cade7..a250310372 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/v1/analysis.rules.txt +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/v1/analysis.rules.txt @@ -7,6 +7,8 @@ - Prefer direct evidence from DATA over derivative statements in ATTACHMENTS when both address the same point. - If ATTACHMENTS evidence is used, cite the attachment by name in detail. - Each detail must cite concrete evidence from DATA or ATTACHMENTS. +- Write reviewer-facing natural language. Do not refer to prompt section names, internal field keys, or schema labels such as DATA, ATTACHMENTS, ProjectSummary, CustomField1, or OrganizationType. +- Refer to evidence by its plain-language meaning, quoted text, or attachment name rather than internal key names. - Only include warnings when the evidence shows a specific, concrete risk, inconsistency, or meaningful uncertainty; a stated risk label alone is not enough. - Do not state that one amount exceeds, matches, or conflicts with another unless the comparison is directly supported by the provided values. - Do not treat ordinary lack of detailed supporting explanation as a material gap unless the provided evidence creates real uncertainty about feasibility, eligibility, or budget credibility. @@ -23,10 +25,11 @@ - If no findings exist, return empty arrays. - Rating must be HIGH, MEDIUM, or LOW. - Use summaries for overall application quality/readiness synthesis. -- Use nextSteps for reviewer-facing follow-up actions or considerations before scoring or decision-making. -- Only include nextSteps when there is a specific evidence gap, inconsistency, or verification need; otherwise return an empty array. +- Use nextSteps for concrete reviewer-facing next actions based on the provided evidence. +- nextSteps may include proceeding with the normal review process when the application appears ready for that step. +- When evidence shows a meaningful gap, inconsistency, or uncertainty, use nextSteps for specific follow-up or verification actions. +- Return an empty array only when no concrete next action would help the reviewer. - recommendation.decision must be PROCEED or HOLD. - Use HOLD only when provided evidence shows a material eligibility, feasibility, budget, or readiness concern that would reasonably block scoring or decision-making. - recommendation.rationale must explain the high-level recommendation in 1-2 complete sentences using provided evidence. - recommendation.rationale should name the 1-3 strongest evidence-based reasons for the recommendation. - diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/v1/attachment.rules.txt b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/v1/attachment.rules.txt index 0cebe3aa94..2230e39228 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/v1/attachment.rules.txt +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/v1/attachment.rules.txt @@ -6,7 +6,8 @@ - Begin with what the attachment contains or provides, not the file name or file type, unless that metadata is necessary to describe the evidence. - Do not invent missing details. - Do not calculate or restate totals, sums, or aggregates unless they are explicitly present in ATTACHMENT.text. +- Write reviewer-facing natural language. Do not refer to prompt section names, internal field keys, or schema labels such as ATTACHMENT or ATTACHMENT.text. +- Refer to evidence by its plain-language meaning, quoted text, or file name rather than internal key names. - Write 1-2 complete sentences. - Summary must be grounded in concrete ATTACHMENT evidence. - Return exactly one object with only the key: summary. - diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/v1/scoresheet.rules.txt b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/v1/scoresheet.rules.txt index 7c25c7f6bd..81a4132069 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/v1/scoresheet.rules.txt +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/v1/scoresheet.rules.txt @@ -19,7 +19,9 @@ - Never omit "answer", "rationale", or "confidence" for any question type. - The "answer" value type must match question type: Number => numeric; YesNo/SelectList/Text/TextArea => string. - The "rationale" field must be 1-2 complete sentences grounded in concrete DATA/ATTACHMENTS evidence. -- In rationale, cite concrete source evidence from the provided input content rather than prompt section headers. +- In rationale, cite concrete source evidence from the provided input content in plain language rather than prompt section headers or internal field names. +- Write reviewer-facing natural language. Do not refer to prompt section names, internal field keys, or schema labels such as DATA, ATTACHMENTS, ProjectSummary, CustomField1, or OrganizationType. +- Refer to evidence by its plain-language meaning, quoted text, or attachment name rather than internal key names. - For every question, rationale must justify both the selected answer and the selected confidence level based on evidence strength. - The "confidence" field must be an integer from 0 to 100 in increments of 5 and represents confidence in the selected answer. - Set confidence by certainty of the selected answer based on available evidence, regardless of which option is selected. diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/AI/AIPromptToolViewOptionsProvider.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/AI/AIPromptToolViewOptionsProvider.cs index 650eee29ee..7e9f1a5620 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/AI/AIPromptToolViewOptionsProvider.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/AI/AIPromptToolViewOptionsProvider.cs @@ -12,9 +12,20 @@ public class AIPromptToolViewOptionsProvider( public bool IsDevPromptControlsEnabled => string.Equals(webHostEnvironment.EnvironmentName, "Development", StringComparison.OrdinalIgnoreCase); - public string DefaultPromptVersion => - string.IsNullOrWhiteSpace(configuration["Azure:OpenAI:PromptVersion"]) - ? "v1" - : configuration["Azure:OpenAI:PromptVersion"]!.Trim().ToLowerInvariant(); + public string DefaultPromptVersion + { + get + { + var configuredPromptVersion = configuration["Azure:Operations:Defaults:PromptVersion"]; + if (string.IsNullOrWhiteSpace(configuredPromptVersion)) + { + configuredPromptVersion = configuration["Azure:OpenAI:PromptVersion"]; + } + + return string.IsNullOrWhiteSpace(configuredPromptVersion) + ? "v1" + : configuredPromptVersion.Trim().ToLowerInvariant(); + } + } } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.js index 79ec9a5e85..8b3542b988 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.js @@ -885,6 +885,7 @@ $(function () { }); function formatAIPromptCaptureBlock(capture) { + const output = capture.output || ''; const parts = [ `PROMPT TYPE: ${capture.promptType || ''}`, `PROMPT VERSION: ${capture.promptVersion || ''}` @@ -906,11 +907,8 @@ function formatAIPromptCaptureBlock(capture) { 'USER PROMPT', capture.userPrompt || '', '', - 'RAW OUTPUT', - capture.rawOutput || '', - '', - 'FORMATTED OUTPUT', - capture.formattedOutput || '' + 'OUTPUT', + output ); return parts.join('\n'); @@ -1378,3 +1376,4 @@ function clearCurrencyError(input) { document.getElementById(errorSpan).textContent = ''; input.attr('aria-invalid', 'false'); } + diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/appsettings.Development.json b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/appsettings.Development.json index bb92c5bf84..e7487ba79d 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/appsettings.Development.json +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/appsettings.Development.json @@ -135,15 +135,40 @@ "ReportingAI": { "JWTSecret": "" }, + "Azure": { + "Operations": { + "Defaults": { + "Provider": "OpenAI", + "Profile": "Gpt4oMini", + "PromptVersion": "v1" + }, + "AttachmentSummary": { + "MaxCompletionTokens": 1500 + }, + "ApplicationAnalysis": { + "MaxCompletionTokens": 2500 + }, + "ScoresheetSection": { + "MaxCompletionTokens": 5000 + } + }, "OpenAI": { "ApiKey": "", - "ApiUrl": "", - "Model": "" + "Profiles": { + "Gpt4oMini": { + "ApiUrl": "", + "MaxTokensParameter": "max_tokens", + "Temperature": 0.3 + }, + "Gpt5Mini": { + "ApiUrl": "", + "MaxTokensParameter": "max_completion_tokens" + } + } }, - "AgenticAPI": { - "Url": "http://localhost:5000/v1/completions" + "Logging": { + "EnablePromptFileLog": true } } - -} \ No newline at end of file +}