Skip to content
Merged

Dev #2555

Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
40cc5df
WIP add AI generation log context
jacobwillsmith May 8, 2026
72872b3
AB#33221: Soft Delete Applicant
aurelio-aot Jun 6, 2026
638817b
AB#33221: Fix sonarqube issues
aurelio-aot Jun 8, 2026
512e055
AB#33252: Add Paid Date Column in Funding History
aurelio-aot Jun 8, 2026
ebf7157
AB#33009 Replace OpenAI HttpClient transport with Azure OpenAI SDK
jacobwillsmith Jun 5, 2026
a82eb9f
AB#33009 sdk migration and CHEFS download refactor
jacobwillsmith Jun 5, 2026
90a291e
AB#33009 make GPT4oMini token support explicit
jacobwillsmith Jun 5, 2026
e966880
AB#33009 address SDK review feedback
jacobwillsmith Jun 5, 2026
ed7fe95
AB#33009 tighten AI runtime boundary
jacobwillsmith Jun 8, 2026
75c6de4
[AB#32697] Update PaymentInfo layout to 3-column format
plavoie-BC Jun 9, 2026
3b69219
AB#33395 define AI operation input contracts
jacobwillsmith Jun 9, 2026
b5346bc
[AB#32697] Update PaymentInfo layout to 3-column format - fix padding
plavoie-BC Jun 9, 2026
7ca474a
AB#33395 fix input contract branch build
jacobwillsmith Jun 9, 2026
909ac88
AB#33009 address SDK review feedback
jacobwillsmith Jun 9, 2026
9d6377f
AB#33395 refine AI input contracts
jacobwillsmith Jun 9, 2026
0acb1c0
Merge pull request #2547 from bcgov/feature/AB#33221-Soft-Delete-Appl…
JamesPasta Jun 9, 2026
35d4435
Merge pull request #2549 from bcgov/feature/AB#33252-FundingHistory-N…
JamesPasta Jun 9, 2026
3a8acb1
Merge pull request #2550 from bcgov/feature/AB#32697-payment-info-3-c…
JamesPasta Jun 9, 2026
99b8d18
Merge pull request #2545 from bcgov/feature/AB#33009-azure-openai-sdk…
JamesPasta Jun 9, 2026
1c6a063
Merge pull request #2552 from bcgov/feature/AB#33395-ai-operation-inp…
JamesPasta Jun 9, 2026
90f8c57
Merge pull request #2554 from bcgov/feature/AB#33391-ai-observability…
JamesPasta Jun 9, 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
@@ -0,0 +1,26 @@
using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Text.Json.Serialization;
using Unity.AI.Models;

namespace Unity.AI.Operations
{
public class ApplicationAnalysisOperationInputDto
{
[JsonPropertyName("applicationId")]
public Guid ApplicationId { get; set; }

[JsonPropertyName("schema")]
public JsonElement Schema { get; set; }

[JsonPropertyName("data")]
public JsonElement Data { get; set; }

[JsonPropertyName("attachments")]
public List<AIAttachmentItem> Attachments { get; set; } = new();

[JsonPropertyName("promptVersion")]
public string? PromptVersion { get; set; }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Text.Json.Serialization;
using Unity.AI.Models;

namespace Unity.AI.Operations
{
public class ApplicationScoringOperationInputDto
{
[JsonPropertyName("applicationId")]
public Guid ApplicationId { get; set; }

[JsonPropertyName("data")]
public JsonElement Data { get; set; }

[JsonPropertyName("attachments")]
public List<AIAttachmentItem> Attachments { get; set; } = new();

[JsonPropertyName("sections")]
public List<ApplicationScoringSectionOperationInputDto> Sections { get; set; } = new();

[JsonPropertyName("promptVersion")]
public string? PromptVersion { get; set; }
}

public class ApplicationScoringSectionOperationInputDto
{
[JsonPropertyName("sectionName")]
public string SectionName { get; set; } = string.Empty;

[JsonPropertyName("sectionSchema")]
public JsonElement SectionSchema { get; set; }
}
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
using Microsoft.Extensions.Logging;
using System;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Unity.AI.Prompts;
using Unity.AI;
using Unity.AI.Models;
using Unity.AI.Requests;
using Unity.AI.Runtime;
using Unity.GrantManager.Applications;
Expand All @@ -13,60 +12,27 @@ namespace Unity.AI.Operations
{
public class ApplicationAnalysisService(
IApplicationRepository applicationRepository,
IApplicationFormSubmissionRepository applicationFormSubmissionRepository,
IApplicationFormVersionRepository applicationFormVersionRepository,
IApplicationChefsFileAttachmentRepository applicationChefsFileAttachmentRepository,
IAIService aiService,
IAIGenerationPrerequisiteValidator aiGenerationPrerequisiteValidator,
ILogger<ApplicationAnalysisService> logger) : IApplicationAnalysisService, ITransientDependency
IAIGenerationPrerequisiteValidator aiGenerationPrerequisiteValidator) : IApplicationAnalysisService, ITransientDependency
{
public async Task<string> RegenerateAndSaveAsync(Guid applicationId, string? promptVersion = null, CancellationToken cancellationToken = default)
public async Task<string> RegenerateAndSaveAsync(ApplicationAnalysisOperationInputDto input, CancellationToken cancellationToken = default)
{
await aiGenerationPrerequisiteValidator.EnsureApplicationAnalysisAvailableAsync(applicationId);

var application = await applicationRepository.GetAsync(applicationId);
var formSubmission = await applicationFormSubmissionRepository.GetByApplicationAsync(applicationId);
var attachments = await applicationChefsFileAttachmentRepository.GetListAsync(a => a.ApplicationId == applicationId);
var formSchema = await GetFormSchemaAsync(formSubmission?.ApplicationFormVersionId);

var attachmentSummaries = PromptDataPayloadBuilder.BuildAttachmentSummaries(attachments);
var formFieldConfiguration = await PromptDataPayloadBuilder.BuildFormFieldConfigurationAsync(
applicationFormVersionRepository,
formSubmission?.ApplicationFormVersionId,
logger);
await aiGenerationPrerequisiteValidator.EnsureApplicationAnalysisAvailableAsync(input.ApplicationId);

var analysis = await aiService.GenerateApplicationAnalysisAsync(new ApplicationAnalysisRequest
{
Schema = JsonSerializer.SerializeToElement(formFieldConfiguration),
Data = PromptDataPayloadBuilder.BuildPromptDataPayload(application, formSubmission, formSchema, logger),
Attachments = attachmentSummaries,
PromptVersion = promptVersion,
Schema = input.Schema,
Data = input.Data,
Attachments = input.Attachments,
PromptVersion = input.PromptVersion,
}, cancellationToken);

var analysisJson = JsonSerializer.Serialize(analysis, AIJsonDefaults.Indented);
var application = await applicationRepository.GetAsync(input.ApplicationId);
application.AIAnalysis = analysisJson;
await applicationRepository.UpdateAsync(application);
return analysisJson;
}

private async Task<string?> GetFormSchemaAsync(Guid? formVersionId)
{
if (formVersionId == null)
{
return null;
}

try
{
var formVersion = await applicationFormVersionRepository.GetAsync(formVersionId.Value);
return string.IsNullOrWhiteSpace(formVersion?.FormSchema) ? null : formVersion.FormSchema;
}
catch (Exception ex)
{
logger.LogWarning(ex, "Unable to load form schema for prompt data generation for form version {FormVersionId}.", formVersionId);
return null;
}
}

}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,63 +5,31 @@
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Unity.Flex.Domain.Scoresheets;
using Unity.AI;
using Unity.AI.Models;
using Unity.AI.Prompts;
using Unity.AI.Requests;
using Unity.AI.Runtime;
using Unity.AI.Localization;
using Unity.GrantManager.Applications;
using Volo.Abp;
using Volo.Abp.DependencyInjection;
using Microsoft.Extensions.Localization;

namespace Unity.AI.Operations
{
public class ApplicationScoringService(
IApplicationRepository applicationRepository,
IApplicationFormRepository applicationFormRepository,
IApplicationFormSubmissionRepository applicationFormSubmissionRepository,
IApplicationFormVersionRepository applicationFormVersionRepository,
IApplicationChefsFileAttachmentRepository applicationChefsFileAttachmentRepository,
IScoresheetRepository scoresheetRepository,
IAIService aiService,
AIExecutionModeResolver executionModeResolver,
ILogger<ApplicationScoringService> logger,
IStringLocalizer<AIResource> localizer) : IApplicationScoringService, ITransientDependency
ILogger<ApplicationScoringService> logger) : IApplicationScoringService, ITransientDependency
{
public async Task<string> RegenerateAndSaveAsync(Guid applicationId, string? promptVersion = null, CancellationToken cancellationToken = default)
public async Task<string> RegenerateAndSaveAsync(ApplicationScoringOperationInputDto input, CancellationToken cancellationToken = default)
{
var application = await applicationRepository.GetAsync(applicationId);
var applicationForm = await applicationFormRepository.GetAsync(application.ApplicationFormId);
if (applicationForm.ScoresheetId == null)
{
throw new UserFriendlyException(localizer[AILocalizationKeys.ScoringRequiresScoresheet]);
}

var scoresheet = await scoresheetRepository.GetWithChildrenAsync(applicationForm.ScoresheetId.Value);
if (scoresheet == null || !scoresheet.Sections.Any() || !scoresheet.Sections.SelectMany(s => s.Fields).Any())
{
throw new UserFriendlyException(localizer[AILocalizationKeys.ScoringRequiresScoresheetFields]);
}

var attachments = await applicationChefsFileAttachmentRepository.GetListAsync(a => a.ApplicationId == applicationId);
var attachmentSummaries = PromptDataPayloadBuilder.BuildAttachmentSummaries(
attachments,
excludeWhitespaceOnlySummaries: false);

var formSubmission = await applicationFormSubmissionRepository.GetByApplicationAsync(applicationId);
var formSchema = await GetFormSchemaAsync(formSubmission?.ApplicationFormVersionId);
var promptData = PromptDataPayloadBuilder.BuildPromptDataPayload(application, formSubmission, formSchema, logger);

var sections = scoresheet.Sections.OrderBy(s => s.Order).ToList();
var sections = input.Sections;
var mode = executionModeResolver.ResolveMode(AIExecutionModeResolver.ApplicationScoringOperation);

var perSectionResults = await AIExecutionStrategy.RunAsync(
sections,
mode,
section => ProcessSectionAsync(applicationId, section, promptData, attachmentSummaries, promptVersion, cancellationToken),
batch => ProcessSectionsAsync(applicationId, batch, promptData, attachmentSummaries, promptVersion, cancellationToken));
section => ProcessSectionAsync(input.ApplicationId, section, input.Data, input.Attachments, input.PromptVersion, cancellationToken),
batch => ProcessSectionsAsync(input.ApplicationId, batch, input.Data, input.Attachments, input.PromptVersion, cancellationToken));

var allSectionResults = new Dictionary<string, object>();
foreach (var sectionResult in perSectionResults)
Expand All @@ -74,14 +42,15 @@ public async Task<string> RegenerateAndSaveAsync(Guid applicationId, string? pro

var combinedResults = JsonSerializer.Serialize(allSectionResults, AIJsonDefaults.Indented);
var validatedJson = ValidateApplicationScoringJson(combinedResults);
var application = await applicationRepository.GetAsync(input.ApplicationId);
application.AIScoresheetAnswers = validatedJson;
await applicationRepository.UpdateAsync(application);
return validatedJson;
}

private async Task<Dictionary<string, object>> ProcessSectionAsync(
Guid applicationId,
ScoresheetSection section,
ApplicationScoringSectionOperationInputDto section,
JsonElement promptData,
List<AIAttachmentItem> attachmentSummaries,
string? promptVersion,
Expand All @@ -94,8 +63,8 @@ private async Task<Dictionary<string, object>> ProcessSectionAsync(
{
Data = promptData,
Attachments = attachmentSummaries,
SectionName = section.Name,
SectionSchema = JsonSerializer.SerializeToElement(BuildSectionQuestionsData(section), AIJsonDefaults.IndentedCamelCase),
SectionName = section.SectionName,
SectionSchema = section.SectionSchema,
PromptVersion = promptVersion,
};
var applicationScoringResponse = await aiService.GenerateApplicationScoringAsync(applicationScoringRequest, cancellationToken);
Expand All @@ -111,14 +80,15 @@ private async Task<Dictionary<string, object>> ProcessSectionAsync(
}
catch (Exception ex)
{
logger.LogError(ex, "Error processing AI application scoring section {SectionName} for application {ApplicationId}", section.Name, applicationId);
logger.LogError(ex, "Error processing AI application scoring section {SectionName} for application {ApplicationId}", section.SectionName, applicationId);
}

return sectionResults;
}

private async Task<List<Dictionary<string, object>>> ProcessSectionsAsync(
Guid applicationId,
IReadOnlyCollection<ScoresheetSection> sections,
IReadOnlyCollection<ApplicationScoringSectionOperationInputDto> sections,
JsonElement promptData,
List<AIAttachmentItem> attachmentSummaries,
string? promptVersion,
Expand All @@ -127,17 +97,14 @@ private async Task<List<Dictionary<string, object>>> ProcessSectionsAsync(
var sectionResults = new Dictionary<string, object>();
try
{
var questions = sections
.OrderBy(s => s.Order)
.SelectMany(BuildSectionQuestionsData)
.ToList();
var questions = BuildBatchSectionSchema(sections);

var applicationScoringRequest = new ApplicationScoringRequest
{
Data = promptData,
Attachments = attachmentSummaries,
SectionName = "All Sections",
SectionSchema = JsonSerializer.SerializeToElement(questions, AIJsonDefaults.IndentedCamelCase),
SectionSchema = questions,
PromptVersion = promptVersion,
};
var applicationScoringResponse = await aiService.GenerateApplicationScoringAsync(applicationScoringRequest, cancellationToken);
Expand All @@ -159,25 +126,27 @@ private async Task<List<Dictionary<string, object>>> ProcessSectionsAsync(
return [sectionResults];
}

private static List<object> BuildSectionQuestionsData(ScoresheetSection section)
private static JsonElement BuildBatchSectionSchema(IReadOnlyCollection<ApplicationScoringSectionOperationInputDto> sections)
{
var sectionQuestionsData = new List<object>();
foreach (var field in section.Fields.OrderBy(f => f.Order))
foreach (var section in sections)
{
var options = ExtractSelectListOptions(field);
sectionQuestionsData.Add(new
if (section.SectionSchema.ValueKind != JsonValueKind.Array)
{
id = field.Id.ToString(),
section = section.Name,
question = field.Label,
description = field.Description,
type = field.Type.ToString(),
options,
allowed_answers = ExtractSelectListOptionNumbers(options)
});
throw new InvalidOperationException(
$"Section schema for '{section.SectionName}' must be a JSON array.");
}
}

return sectionQuestionsData;
var questions = new List<JsonElement>();
foreach (var section in sections)
{
foreach (var question in section.SectionSchema.EnumerateArray())
{
questions.Add(question.Clone());
}
}

return JsonSerializer.SerializeToElement(questions, AIJsonDefaults.IndentedCamelCase);
}

private void CopyAnswers(Dictionary<string, ApplicationScoringAnswer> answers, Dictionary<string, object> results)
Expand All @@ -190,25 +159,6 @@ private void CopyAnswers(Dictionary<string, ApplicationScoringAnswer> answers, D
}
}

private async Task<string?> GetFormSchemaAsync(Guid? formVersionId)
{
if (formVersionId == null)
{
return null;
}

try
{
var formVersion = await applicationFormVersionRepository.GetAsync(formVersionId.Value);
return string.IsNullOrWhiteSpace(formVersion?.FormSchema) ? null : formVersion.FormSchema;
}
catch (Exception ex)
{
logger.LogWarning(ex, "Unable to load form schema for application scoring prompt data generation for form version {FormVersionId}.", formVersionId);
return null;
}
}

private static string ValidateApplicationScoringJson(string scoresheetAnswers)
{
try
Expand All @@ -226,45 +176,5 @@ private static string ValidateApplicationScoringJson(string scoresheetAnswers)

return "{}";
}

private static object[]? ExtractSelectListOptions(Question field)
{
if (field.Type != Unity.Flex.Scoresheets.Enums.QuestionType.SelectList || string.IsNullOrEmpty(field.Definition))
return null;

try
{
var definition = JsonSerializer.Deserialize<Unity.Flex.Worksheets.Definitions.QuestionSelectListDefinition>(field.Definition);
if (definition?.Options != null && definition.Options.Count > 0)
{
return definition.Options
.Select((option, index) =>
(object)new
{
number = index + 1,
value = option.Value
})
.ToArray();
}
}
catch (JsonException)
{
// Ignore malformed definition and return null options.
}

return null;
}

private static string[]? ExtractSelectListOptionNumbers(object[]? options)
{
if (options == null || options.Length == 0)
{
return null;
}

return options
.Select((_, index) => (index + 1).ToString())
.ToArray();
}
}
}
Loading
Loading