Skip to content
Merged

Dev #2459

Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
7a34319
AB#32591 persist scoresheet panel state
jacobwillsmith May 8, 2026
1f434bf
AB#31762: Remove database columns not already being used
aurelio-aot May 12, 2026
fc86f0b
Merge pull request #2442 from bcgov/feature/AB#31762-Remove-RenderedH…
JamesPasta May 12, 2026
913b4f5
AB#32683 - address stricter .net10 json handling
AndreGAot May 13, 2026
bfad74a
AB#32898 fix AI JSON defaults readonly initialization
jacobwillsmith May 13, 2026
c3aca03
AB#32290 fix AI cooldown endpoint wiring
jacobwillsmith May 13, 2026
7882dfe
Merge pull request #2449 from bcgov/bugfix/AB#32683-fix-portal-integr…
AndreGAot May 13, 2026
bc30f75
AB#32290 move AI cooldown config under Azure
jacobwillsmith May 13, 2026
b78a985
AB#28985 adjust worksheet styling
AndreGAot May 13, 2026
e46b8ba
Merge pull request #2453 from bcgov/feature/AB#28985-worksheet-alignm…
JamesPasta May 13, 2026
f1583c7
Merge pull request #2452 from bcgov/feature/AB#32898-consolidate-ai-j…
JamesPasta May 13, 2026
d80b050
Matched scoresheet configuration to worksheet configuration to includ…
DavidBrightBcGov May 13, 2026
7323042
Merge pull request #2440 from bcgov/feature/AB#32591-persist-ui-panel…
JamesPasta May 13, 2026
db402ac
Merge pull request #2451 from bcgov/feature/AB#32290-throttle-ai-anal…
JamesPasta May 13, 2026
4e55e66
Merge pull request #2456 from bcgov/feature/AB#32883-button-standardi…
DavidBrightBcGov May 13, 2026
858f233
AB#32986 make AI runtime configuration explicit
jacobwillsmith May 13, 2026
e9359a8
Merge pull request #2454 from bcgov/feature/AB#32986-strict-ai-runtim…
JamesPasta May 14, 2026
90a401c
AB#33006 enable GPT-5 mini profile compatibility
jacobwillsmith May 8, 2026
a8876e5
Merge pull request #2457 from bcgov/feature/AB#33006-enable-gpt5-mini…
JamesPasta May 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
using Microsoft.Extensions.Configuration;
using System;
using Unity.AI.Prompts;
using Volo.Abp.DependencyInjection;

namespace Unity.AI.Operations;

/// <summary>
/// Resolves the configured <see cref="AIExecutionMode"/> for an AI operation.
/// Configuration keys (all optional, default = Sequential):
/// Configuration keys:
/// Azure:Operations:{operationName}:ExecutionMode - "Sequential" | "Parallel" | "Batch" (case-insensitive)
/// Azure:Operations:Defaults:ExecutionMode - default when operation override is absent
/// Azure:Operations:Defaults:ExecutionMode - required default when operation override is absent
/// </summary>
public class AIExecutionModeResolver(IConfiguration configuration) : ITransientDependency
{
Expand All @@ -25,9 +26,10 @@ public AIExecutionMode ResolveMode(string operationName)

return configured?.Trim().ToLowerInvariant() switch
{
"sequential" => AIExecutionMode.Sequential,
"parallel" => AIExecutionMode.Parallel,
"batch" => AIExecutionMode.Batch,
_ => AIExecutionMode.Sequential
_ => throw new InvalidOperationException($"AI execution mode is not configured or is invalid for operation '{operationName}'.")
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,8 @@ Placeholders:

Version selection:

- Preferred: `Azure:Operations:Defaults:PromptVersion = v0|v1`, with optional overrides under `Azure:Operations:<Operation>:PromptVersion`
- Legacy fallback: `Azure:OpenAI:PromptVersion = v0|v1`
- Unknown or missing version defaults to `v1`.
- Required: `Azure:Operations:Defaults:PromptVersion = v0|v1`, with optional overrides under `Azure:Operations:<Operation>:PromptVersion`.
- Unknown or missing version values fail at runtime.

Template loading is strict:

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ internal static class AIJsonDefaults

static AIJsonDefaults()
{
Indented.MakeReadOnly();
IndentedCamelCase.MakeReadOnly();
Indented.MakeReadOnly(populateMissingResolver: true);
IndentedCamelCase.MakeReadOnly(populateMissingResolver: true);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,46 +7,43 @@ namespace Unity.AI.Runtime;

public class OpenAIConfigurationResolver(IConfiguration configuration) : ITransientDependency
{
private const string DefaultMaxTokensParameterName = "max_completion_tokens";
private const string LegacyMaxTokensParameterName = "max_tokens";
private const string DefaultProviderName = "OpenAI";
private static readonly string[] SupportedReasoningEfforts = ["minimal", "low", "medium", "high"];
private static readonly string[] SupportedVerbosityValues = ["low", "medium", "high"];

private readonly IConfiguration _configuration = configuration;

public string ResolveProviderName(string? operationName = null)
{
if (!string.IsNullOrWhiteSpace(operationName))
{
var configuredProvider = _configuration[$"Azure:Operations:{operationName}:Provider"];
if (!string.IsNullOrWhiteSpace(configuredProvider))
var operationProvider = Optional($"Azure:Operations:{operationName}:Provider");
if (operationProvider != null)
{
return configuredProvider.Trim();
return operationProvider;
}
}

var defaultProvider = _configuration["Azure:Operations:Defaults:Provider"];
return string.IsNullOrWhiteSpace(defaultProvider) ? DefaultProviderName : defaultProvider.Trim();
return Required("Azure:Operations:Defaults:Provider");
}

public string ResolveApiKey(string? operationName = null)
{
var providerName = ResolveProviderName(operationName);
return _configuration[$"Azure:{providerName}:ApiKey"] ?? string.Empty;
return Required($"Azure:{providerName}:ApiKey");
}

public string ResolveMaxTokensParameterNameForOperation(string? operationName = null)
{
var providerName = ResolveProviderName(operationName);
var profileName = ResolveProfileName(operationName);
var profileParameterName = ResolveProfileSetting(providerName, profileName, "MaxTokensParameter");
return ResolveMaxTokensParameterName(profileParameterName);
return RequiredProfile(providerName, profileName, "MaxTokensParameter");
}

public double? ResolveConfiguredTemperature(string? operationName = null)
{
var providerName = ResolveProviderName(operationName);
var profileName = ResolveProfileName(operationName);
var profileTemperature = ResolveProfileSetting(providerName, profileName, "Temperature");
var profileTemperature = OptionalProfile(providerName, profileName, "Temperature");
if (profileTemperature != null
&& double.TryParse(profileTemperature, NumberStyles.Float, CultureInfo.InvariantCulture, out var parsedTemperature))
{
Expand All @@ -56,83 +53,113 @@ public string ResolveMaxTokensParameterNameForOperation(string? operationName =
return null;
}

public int ResolveCompletionTokens(string operationName, int defaultValue)
public string? ResolveConfiguredReasoningEffort(string? operationName = null)
{
var configuredValue = _configuration.GetValue<int?>($"Azure:Operations:{operationName}:MaxCompletionTokens");
return ResolveOptionalProfileValue(operationName, "ReasoningEffort", SupportedReasoningEfforts);
}

public string? ResolveConfiguredVerbosity(string? operationName = null)
{
return ResolveOptionalProfileValue(operationName, "Verbosity", SupportedVerbosityValues);
}

public int ResolveCompletionTokens(string operationName)
{
var configuredValue = OptionalPositiveInt($"Azure:Operations:{operationName}:MaxCompletionTokens");
if (configuredValue is > 0)
{
return configuredValue.Value;
}

var defaultConfiguredValue = _configuration.GetValue<int?>("Azure:Operations:Defaults:MaxCompletionTokens");
return defaultConfiguredValue is > 0 ? defaultConfiguredValue.Value : defaultValue;
var defaultConfiguredValue = OptionalPositiveInt("Azure:Operations:Defaults:MaxCompletionTokens");
if (defaultConfiguredValue is > 0)
{
return defaultConfiguredValue.Value;
}

throw new InvalidOperationException($"AI max completion tokens are not configured for operation '{operationName}'.");
}

public string ResolvePromptVersion(string operationName)
{
return Optional($"Azure:Operations:{operationName}:PromptVersion")
?? Required("Azure:Operations:Defaults:PromptVersion");
}

public string ResolveApiUrl(string? operationName = null)
{
var providerName = ResolveProviderName(operationName);
var endpoint = Required($"Azure:{providerName}:Endpoint");
var profileName = ResolveProfileName(operationName);
var profileApiUrl = ResolveProfileSetting(providerName, profileName, "ApiUrl");
var injectedEndpoint = ResolveInjectedEndpoint(providerName);
var legacyOpenAiApiUrl = _configuration["Azure:OpenAI:ApiUrl"];

if (!string.IsNullOrWhiteSpace(injectedEndpoint) && !string.IsNullOrWhiteSpace(profileApiUrl))
{
return CombineEndpointAndPath(injectedEndpoint, profileApiUrl);
}
var profileApiUrl = RequiredProfile(providerName, profileName, "ApiUrl");
return CombineEndpointAndPath(endpoint, profileApiUrl);
}

if (!string.IsNullOrWhiteSpace(profileApiUrl))
private string ResolveProfileName(string? operationName)
{
if (!string.IsNullOrWhiteSpace(operationName))
{
return profileApiUrl;
var operationProfile = Optional($"Azure:Operations:{operationName}:Profile");
if (operationProfile != null)
{
return operationProfile;
}
}

if (!string.IsNullOrWhiteSpace(legacyOpenAiApiUrl))
{
return legacyOpenAiApiUrl;
}
return Required("Azure:Operations:Defaults:Profile");
}

throw new InvalidOperationException($"AI API URL is not configured for provider '{providerName}'.");
private string RequiredProfile(string providerName, string profileName, string settingName)
{
var key = ProfileKey(providerName, profileName, settingName);
return Required(key);
}

private static string ResolveMaxTokensParameterName(string? configuredParameterName)
private string? OptionalProfile(string providerName, string profileName, string settingName)
{
if (string.Equals(configuredParameterName, LegacyMaxTokensParameterName, StringComparison.Ordinal))
{
return LegacyMaxTokensParameterName;
}
return Optional(ProfileKey(providerName, profileName, settingName));
}

return DefaultMaxTokensParameterName;
private string Required(string key)
{
return Optional(key) ?? throw new InvalidOperationException($"{key} is not configured.");
}

private string? ResolveInjectedEndpoint(string providerName)
private string? Optional(string key)
{
return _configuration[$"Azure:{providerName}:Endpoint"];
var value = _configuration[key];
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}

private string? ResolveProfileName(string? operationName)
private int? OptionalPositiveInt(string key)
{
if (!string.IsNullOrWhiteSpace(operationName))
{
var operationProfile = _configuration[$"Azure:Operations:{operationName}:Profile"];
if (!string.IsNullOrWhiteSpace(operationProfile))
{
return operationProfile.Trim();
}
}
var value = _configuration.GetValue<int?>(key);
return value is > 0 ? value : null;
}

var defaultProfile = _configuration["Azure:Operations:Defaults:Profile"];
return string.IsNullOrWhiteSpace(defaultProfile) ? null : defaultProfile.Trim();
private static string ProfileKey(string providerName, string profileName, string settingName)
{
return $"Azure:{providerName}:Profiles:{profileName}:{settingName}";
}

private string? ResolveProfileSetting(string providerName, string? profileName, string settingName)
private string? ResolveOptionalProfileValue(string? operationName, string settingName, string[] supportedValues)
{
if (string.IsNullOrWhiteSpace(profileName))
var providerName = ResolveProviderName(operationName);
var profileName = ResolveProfileName(operationName);
var configuredValue = OptionalProfile(providerName, profileName, settingName);
if (configuredValue == null)
{
return null;
}

var profileSetting = _configuration[$"Azure:{providerName}:Profiles:{profileName}:{settingName}"];
return string.IsNullOrWhiteSpace(profileSetting) ? null : profileSetting;
var trimmedValue = configuredValue.Trim();
if (Array.Exists(supportedValues, supportedValue => string.Equals(supportedValue, trimmedValue, StringComparison.Ordinal)))
{
return trimmedValue;
}

throw new InvalidOperationException(
$"AI {settingName} value '{configuredValue}' is not supported. Use one of: {string.Join(", ", supportedValues)}.");
}

private static string CombineEndpointAndPath(string endpoint, string profilePath)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ public class OpenAIPromptRenderer : ITransientDependency
private const string ApplicationScoringSystemTemplateName = "application-scoring.system";
private const string ApplicationScoringUserTemplateName = "application-scoring.user";
private static readonly Dictionary<string, string> PromptProfiles =
new(StringComparer.OrdinalIgnoreCase)
new(StringComparer.Ordinal)
{
[PromptVersionV0] = PromptVersionV0,
[PromptVersionV1] = PromptVersionV1
Expand Down Expand Up @@ -184,13 +184,17 @@ public static string BuildAliasedApplicationScoringSection(string? sectionName,

public static string ResolvePromptVersion(string? version)
{
if (!string.IsNullOrWhiteSpace(version) &&
PromptProfiles.TryGetValue(version.Trim(), out var selectedVersion))
if (string.IsNullOrWhiteSpace(version))
{
throw new InvalidOperationException("AI prompt version is not configured.");
}

if (PromptProfiles.TryGetValue(version.Trim(), out var selectedVersion))
{
return selectedVersion;
}

return PromptVersionV1;
throw new InvalidOperationException($"AI prompt version '{version}' is not supported.");
}

private static bool TryGetPromptTemplate(string version, string templateName, out string template)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,15 +26,10 @@ public class OpenAIRuntimeService : IAIService, ITransientDependency
private const string AttachmentSummaryPromptType = AIPromptTypes.AttachmentSummary;
private const string ApplicationScoringPromptType = AIPromptTypes.ApplicationScoring;
private const int MaxAiAttempts = 3;
private const int DefaultAttachmentSummaryCompletionTokens = 2000;
private const int DefaultApplicationAnalysisCompletionTokens = 4000;
private const int DefaultApplicationScoringCompletionTokens = 8000;

private int AttachmentSummaryCompletionTokens => _openAIConfigurationResolver.ResolveCompletionTokens(AttachmentSummaryPromptType, DefaultAttachmentSummaryCompletionTokens);
private int ApplicationAnalysisCompletionTokens => _openAIConfigurationResolver.ResolveCompletionTokens(ApplicationAnalysisPromptType, DefaultApplicationAnalysisCompletionTokens);
private int ApplicationScoringCompletionTokens => _openAIConfigurationResolver.ResolveCompletionTokens(ApplicationScoringPromptType, DefaultApplicationScoringCompletionTokens);
private readonly string MissingApiKeyMessage = "OpenAI API key is not configured";

private int AttachmentSummaryCompletionTokens => _openAIConfigurationResolver.ResolveCompletionTokens(AttachmentSummaryPromptType);
private int ApplicationAnalysisCompletionTokens => _openAIConfigurationResolver.ResolveCompletionTokens(ApplicationAnalysisPromptType);
private int ApplicationScoringCompletionTokens => _openAIConfigurationResolver.ResolveCompletionTokens(ApplicationScoringPromptType);
// Optional local debugging sink for prompt payload logs to a local file.
// Not intended for deployed/shared environments.
private bool IsPromptFileLoggingEnabled => _configuration.GetValue<bool?>("Azure:Logging:EnablePromptFileLog") ?? false;
Expand All @@ -57,19 +52,23 @@ public OpenAIRuntimeService(

public Task<bool> IsAvailableAsync()
{
if (string.IsNullOrEmpty(_openAIConfigurationResolver.ResolveApiKey()))
try
{
_logger.LogWarning("Error: {Message}", MissingApiKeyMessage);
_openAIConfigurationResolver.ResolveApiKey();
return Task.FromResult(true);
}
catch (InvalidOperationException ex)
{
_logger.LogWarning(ex, "AI is unavailable because the OpenAI configuration could not be resolved.");
return Task.FromResult(false);
}

return Task.FromResult(true);
}

public async Task<ApplicationAnalysisResponse> GenerateApplicationAnalysisAsync(ApplicationAnalysisRequest request, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
var promptVersion = OpenAIPromptRenderer.ResolvePromptVersion(request.PromptVersion ?? ResolvePromptVersionSetting(ApplicationAnalysisPromptType));
var promptVersion = OpenAIPromptRenderer.ResolvePromptVersion(
request.PromptVersion ?? _openAIConfigurationResolver.ResolvePromptVersion(ApplicationAnalysisPromptType));
var data = JsonSerializer.Serialize(request.Data, AIJsonDefaults.Indented);
var schema = JsonSerializer.Serialize(request.Schema, AIJsonDefaults.Indented);

Expand Down Expand Up @@ -115,7 +114,8 @@ public async Task<AttachmentSummaryResponse> GenerateAttachmentSummaryAsync(Atta
ArgumentNullException.ThrowIfNull(request);
var fileName = request.FileName ?? string.Empty;
var contentType = request.ContentType ?? "application/octet-stream";
var promptVersion = OpenAIPromptRenderer.ResolvePromptVersion(request.PromptVersion ?? ResolvePromptVersionSetting(AttachmentSummaryPromptType));
var promptVersion = OpenAIPromptRenderer.ResolvePromptVersion(
request.PromptVersion ?? _openAIConfigurationResolver.ResolvePromptVersion(AttachmentSummaryPromptType));

try
{
Expand Down Expand Up @@ -186,19 +186,14 @@ public async Task<AttachmentSummaryResponse> GenerateAttachmentSummaryAsync(Atta
public async Task<ApplicationScoringResponse> GenerateApplicationScoringAsync(ApplicationScoringRequest request, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
var promptVersion = OpenAIPromptRenderer.ResolvePromptVersion(request.PromptVersion ?? ResolvePromptVersionSetting(ApplicationScoringPromptType));
var promptVersion = OpenAIPromptRenderer.ResolvePromptVersion(
request.PromptVersion ?? _openAIConfigurationResolver.ResolvePromptVersion(ApplicationScoringPromptType));
var dataJson = JsonSerializer.Serialize(request.Data, AIJsonDefaults.Indented);
var sectionJson = JsonSerializer.Serialize(request.SectionSchema, AIJsonDefaults.Indented);

var attachmentSummaries = request.Attachments
.Select(a => $"{a.Name}: {a.Summary}")
.ToList();
if (string.IsNullOrEmpty(_openAIConfigurationResolver.ResolveApiKey(ApplicationScoringPromptType)))
{
_logger.LogWarning("{Message}", MissingApiKeyMessage);
return new ApplicationScoringResponse();
}

try
{
var attachments = attachmentSummaries.Count > 0
Expand Down Expand Up @@ -320,23 +315,6 @@ private async Task<AIOperationResult> GenerateWithRetryAsync(
: null;
}

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 async Task LogPromptInputAsync(string promptType, string promptVersion, string? systemPrompt, string userPrompt, CancellationToken cancellationToken = default)
{
var formattedInput = FormatPromptInputForLog(systemPrompt, userPrompt);
Expand Down
Loading
Loading