Skip to content
Merged

Dev #2543

Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
6ddc76e
AB#33266 prevent empty AI generation requests
jacobwillsmith May 8, 2026
c9955ee
AB#33266 skip unavailable AI pipeline stages
jacobwillsmith Jun 3, 2026
517fc86
AB#33319 clean up AI generation status DTO
jacobwillsmith Jun 3, 2026
6294dac
AB#33313 skip prompt formatting when file logging disabled
jacobwillsmith Jun 3, 2026
41ed639
AB#33267 tighten empty attachment prompt test
jacobwillsmith Jun 3, 2026
251e1f3
AB#33307 fix prompt renderer data placeholders
jacobwillsmith Jun 2, 2026
837a828
AB#32702 fix column filters
AndreGAot Jun 3, 2026
4db92e4
AB#33266 address AI prerequisite review
jacobwillsmith Jun 3, 2026
53c31a0
Apply suggestions from code review
AndreGAot Jun 3, 2026
aa90aef
AB#33319 add AI status null request test
jacobwillsmith Jun 3, 2026
b83d5c0
Potential fix for pull request finding
AndreGAot Jun 3, 2026
a3fbb3c
AB#33266 address AI prerequisite review
jacobwillsmith Jun 3, 2026
de86c7b
AB#32702 remove unused function
AndreGAot Jun 3, 2026
5342f39
AB#33307 validate prompt template placeholders
jacobwillsmith Jun 3, 2026
f1e4831
Merge pull request #2535 from bcgov/bugfix/AB#32702-reporting-config-…
AndreGAot Jun 3, 2026
29b1ca5
AB#33319 harden AI status polling
jacobwillsmith Jun 3, 2026
c91cae6
AB#31529 update WS allowed key and label lengths
AndreGAot Jun 4, 2026
c548ef5
Potential fix for pull request finding 'CodeQL / DOM text reinterpret…
AndreGAot Jun 4, 2026
2992992
AB#31529 revert codeQL fix and add sanitize
AndreGAot Jun 4, 2026
51a4288
Merge pull request #2536 from bcgov/bugfix/AB#31529-worksheet-field-l…
AndreGAot Jun 4, 2026
cbbb54e
Merge pull request #2522 from bcgov/feature/AB#33266-prevent-empty-ai…
JamesPasta Jun 4, 2026
3c9141e
Merge pull request #2533 from bcgov/feature/AB#33319-ai-generation-st…
JamesPasta Jun 4, 2026
e7b3470
AB#33313 only log successful AI operation outputs
jacobwillsmith Jun 4, 2026
2582392
AB#33270 update docs
AndreGAot Jun 4, 2026
cd30b61
AB#33307 retrigger build pipeline
jacobwillsmith Jun 4, 2026
b9591f5
AB#33331: Fix broken links in the Payments page
aurelio-aot Jun 4, 2026
38f7685
WIP extract AI prompt input builders
jacobwillsmith May 8, 2026
cd6559b
AB#33263 add prompt payload builder coverage
jacobwillsmith Jun 4, 2026
9d8eb96
AB#33263 align prompt payload tests with prerequisites
jacobwillsmith Jun 4, 2026
a6f26ab
AB#33263 preserve prompt builder behavior
jacobwillsmith Jun 4, 2026
2a981e9
AB#33099 - Implemented domain and application layer
hasanpour Jun 4, 2026
23f1e02
AB#33099 - Added EF
hasanpour Jun 4, 2026
4b70479
AB#33099 - Implemented frontend
hasanpour Jun 4, 2026
99a584f
AB#33099 - Blocked server side call for editing archived scoresheets,…
hasanpour Jun 5, 2026
c80b68f
AB#33263 Address Copilot PR review: whitespace check and case-insensi…
jacobwillsmith Jun 5, 2026
880f853
Merge pull request #2540 from bcgov/feature/AB#33099-Archive-Scoresheets
JamesPasta Jun 5, 2026
4cb1022
Merge pull request #2541 from bcgov/bugfix/AB#33270-reporting-docs-up…
AndreGAot Jun 5, 2026
77f25b5
AB#33025 - Indent question labels for each section within a scoresheet
hasanpour Jun 5, 2026
94bb7af
Merge pull request #2542 from bcgov/feature/AB#33025-Indent-Question-…
hasanpour Jun 5, 2026
792a187
Merge pull request #2520 from bcgov/bugfix/AB#33307-ai-prompt-rendere…
JamesPasta Jun 5, 2026
cea8168
Merge pull request #2539 from bcgov/feature/AB#33263-extract-ai-promp…
JamesPasta Jun 5, 2026
d7a6e6a
Merge pull request #2538 from bcgov/bugfix/AB#33331-Payments-Broken-L…
JamesPasta Jun 5, 2026
aa10b83
Merge pull request #2537 from bcgov/bugfix/AB#33313-ai-error-logging
JamesPasta Jun 5, 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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ bld/
# Visual Studio cache/options directory
.vs/
.vscode/
*.lscache
# Uncomment if you have tasks that create the project's static files in wwwroot
**/wwwroot/lib/

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
using System;
using System.Threading.Tasks;

namespace Unity.AI.Operations;

public interface IAIGenerationPrerequisiteValidator
{
Task EnsureAttachmentSummaryAvailableAsync(Guid applicationId);

Task EnsureApplicationAnalysisAvailableAsync(Guid applicationId);

Task EnsureApplicationScoringAvailableAsync(Guid applicationId);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
using Microsoft.Extensions.Localization;
using System;
using System.Linq;
using System.Threading.Tasks;
using Unity.Flex.Domain.Scoresheets;
using Unity.AI.Localization;
using Unity.GrantManager.Applications;
using Volo.Abp;
using Volo.Abp.DependencyInjection;
using Volo.Abp.Linq;

namespace Unity.AI.Operations;

public class AIGenerationPrerequisiteValidator(
IApplicationRepository applicationRepository,
IApplicationFormRepository applicationFormRepository,
IApplicationFormSubmissionRepository applicationFormSubmissionRepository,
IApplicationChefsFileAttachmentRepository applicationChefsFileAttachmentRepository,
IScoresheetRepository scoresheetRepository,
IAsyncQueryableExecuter asyncExecuter,
IStringLocalizer<AIResource> localizer) : IAIGenerationPrerequisiteValidator, ITransientDependency
{
public async Task EnsureAttachmentSummaryAvailableAsync(Guid applicationId)
{
var attachmentQuery = await applicationChefsFileAttachmentRepository.GetQueryableAsync();
var hasAttachments = await asyncExecuter.AnyAsync(attachmentQuery.Where(a => a.ApplicationId == applicationId));
if (!hasAttachments)
{
throw new UserFriendlyException(localizer[AILocalizationKeys.NoAttachmentsAvailable]);
}
}

public async Task EnsureApplicationAnalysisAvailableAsync(Guid applicationId)
{
var submission = await applicationFormSubmissionRepository.GetByApplicationAsync(applicationId);
if (submission == null || string.IsNullOrWhiteSpace(submission.Submission))
{
throw new UserFriendlyException(localizer[AILocalizationKeys.ApplicationAnalysisRequiresSubmission]);
}
}

public async Task EnsureApplicationScoringAvailableAsync(Guid applicationId)
{
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]);
}
}
}
Original file line number Diff line number Diff line change
@@ -1,12 +1,8 @@
using Microsoft.Extensions.Logging;
using Newtonsoft.Json.Linq;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Unity.AI.Models;
using Unity.AI.Prompts;
using Unity.AI.Requests;
using Unity.AI.Runtime;
Expand All @@ -21,35 +17,23 @@ public class ApplicationAnalysisService(
IApplicationFormVersionRepository applicationFormVersionRepository,
IApplicationChefsFileAttachmentRepository applicationChefsFileAttachmentRepository,
IAIService aiService,
IAIGenerationPrerequisiteValidator aiGenerationPrerequisiteValidator,
ILogger<ApplicationAnalysisService> logger) : IApplicationAnalysisService, ITransientDependency
{
private const string ComponentsKey = "components";
private static readonly HashSet<string> ExcludedSchemaKeys = new(StringComparer.OrdinalIgnoreCase)
{
"applicantAgent"
};

public async Task<string> RegenerateAndSaveAsync(Guid applicationId, string? promptVersion = null, 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 = attachments
.Where(a => !string.IsNullOrWhiteSpace(a.AISummary))
.Select(a => new AIAttachmentItem
{
Name = string.IsNullOrWhiteSpace(a.FileName) ? "attachment" : a.FileName.Trim(),
Summary = a.AISummary!.Trim()
})
.ToList();

object formFieldConfiguration = new { message = "Form configuration not available." };
if (formSubmission?.ApplicationFormVersionId != null)
{
formFieldConfiguration = await ExtractFormFieldConfigurationAsync(formSubmission.ApplicationFormVersionId.Value);
}
var attachmentSummaries = PromptDataPayloadBuilder.BuildAttachmentSummaries(attachments);
var formFieldConfiguration = await PromptDataPayloadBuilder.BuildFormFieldConfigurationAsync(
applicationFormVersionRepository,
formSubmission?.ApplicationFormVersionId,
logger);

var analysis = await aiService.GenerateApplicationAnalysisAsync(new ApplicationAnalysisRequest
{
Expand Down Expand Up @@ -84,115 +68,5 @@ public async Task<string> RegenerateAndSaveAsync(Guid applicationId, string? pro
}
}

private async Task<object> ExtractFormFieldConfigurationAsync(Guid formVersionId)
{
try
{
var formVersion = await applicationFormVersionRepository.GetAsync(formVersionId);
if (formVersion == null || string.IsNullOrEmpty(formVersion.FormSchema))
{
return new { message = "Form configuration not available." };
}

var schema = JObject.Parse(formVersion.FormSchema);
var components = schema[ComponentsKey] as JArray;
if (components == null || components.Count == 0)
{
return new { message = "No form fields configured." };
}

var requiredFields = new List<string>();
var optionalFields = new List<string>();
ExtractFieldRequirements(components, requiredFields, optionalFields, string.Empty);

return new
{
required_fields = requiredFields,
optional_fields = optionalFields
};
}
catch (Exception ex)
{
logger.LogError(ex, "Error extracting form field configuration for form version {FormVersionId}", formVersionId);
return new { message = "Form configuration could not be extracted." };
}
}

private static void ExtractFieldRequirements(JArray components, List<string> requiredFields, List<string> optionalFields, string currentPath)
{
foreach (var component in components.OfType<JObject>())
{
var key = component["key"]?.ToString();
var label = component["label"]?.ToString();
var type = component["type"]?.ToString();
var skipTypes = new HashSet<string> { "button", "simplebuttonadvanced", "html", "htmlelement", "content", "simpleseparator" };

if (string.IsNullOrEmpty(key) || string.IsNullOrEmpty(type) || skipTypes.Contains(type) || ExcludedSchemaKeys.Contains(key))
{
ProcessNestedFieldRequirements(component, type, requiredFields, optionalFields, currentPath);
continue;
}

var displayName = !string.IsNullOrEmpty(label) ? $"{label} ({key})" : key;
var fullPath = string.IsNullOrEmpty(currentPath) ? displayName : $"{currentPath} > {displayName}";
var validate = component["validate"] as JObject;
var isRequired = validate?["required"]?.Value<bool>() ?? false;

if (component["input"]?.Value<bool>() == true)
{
if (isRequired) requiredFields.Add(fullPath);
else optionalFields.Add(fullPath);
}

ProcessNestedFieldRequirements(component, type, requiredFields, optionalFields, fullPath);
}
}

private static void ProcessNestedFieldRequirements(JObject component, string? type, List<string> requiredFields, List<string> optionalFields, string currentPath)
{
switch (type)
{
case "panel":
case "simplepanel":
case "fieldset":
case "well":
case "container":
case "datagrid":
case "table":
if (component[ComponentsKey] is JArray nestedComponents)
{
ExtractFieldRequirements(nestedComponents, requiredFields, optionalFields, currentPath);
}
break;
case "columns":
case "simplecols2":
case "simplecols3":
case "simplecols4":
if (component["columns"] is JArray columns)
{
foreach (var column in columns.OfType<JObject>())
{
if (column[ComponentsKey] is JArray columnComponents)
{
ExtractFieldRequirements(columnComponents, requiredFields, optionalFields, currentPath);
}
}
}
break;
case "tabs":
case "simpletabs":
if (component[ComponentsKey] is JArray tabs)
{
foreach (var tab in tabs.OfType<JObject>())
{
if (tab[ComponentsKey] is JArray tabComponents)
{
ExtractFieldRequirements(tabComponents, requiredFields, optionalFields, currentPath);
}
}
}
break;
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,11 @@
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
{
Expand All @@ -24,32 +27,28 @@ public class ApplicationScoringService(
IScoresheetRepository scoresheetRepository,
IAIService aiService,
AIExecutionModeResolver executionModeResolver,
ILogger<ApplicationScoringService> logger) : IApplicationScoringService, ITransientDependency
ILogger<ApplicationScoringService> logger,
IStringLocalizer<AIResource> localizer) : IApplicationScoringService, ITransientDependency
{
public async Task<string> RegenerateAndSaveAsync(Guid applicationId, string? promptVersion = null, CancellationToken cancellationToken = default)
{
var application = await applicationRepository.GetAsync(applicationId);
var applicationForm = await applicationFormRepository.GetAsync(application.ApplicationFormId);
if (applicationForm.ScoresheetId == null)
{
return "{}";
throw new UserFriendlyException(localizer[AILocalizationKeys.ScoringRequiresScoresheet]);
}

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

var attachments = await applicationChefsFileAttachmentRepository.GetListAsync(a => a.ApplicationId == applicationId);
var attachmentSummaries = attachments
.Where(a => !string.IsNullOrEmpty(a.AISummary))
.Select(a => new AIAttachmentItem
{
Name = string.IsNullOrWhiteSpace(a.FileName) ? "attachment" : a.FileName.Trim(),
Summary = a.AISummary!.Trim()
})
.ToList();
var attachmentSummaries = PromptDataPayloadBuilder.BuildAttachmentSummaries(
attachments,
excludeWhitespaceOnlySummaries: false);

var formSubmission = await applicationFormSubmissionRepository.GetByApplicationAsync(applicationId);
var formSchema = await GetFormSchemaAsync(formSubmission?.ApplicationFormVersionId);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Localization;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Unity.AI.Extraction;
using Unity.AI.Localization;
using Unity.AI.Requests;
using Unity.GrantManager.Applications;
using Unity.GrantManager.Intakes;
using Volo.Abp;
using Volo.Abp.DependencyInjection;

namespace Unity.AI.Operations;
Expand All @@ -17,8 +20,10 @@ public class AttachmentSummaryService(
IChefsFileAttachmentStreamProvider chefsFileAttachmentStreamProvider,
ITextExtractionService textExtractionService,
IAIService aiService,
IAIGenerationPrerequisiteValidator aiGenerationPrerequisiteValidator,
AIExecutionModeResolver executionModeResolver,
ILogger<AttachmentSummaryService> logger) : IAttachmentSummaryService, ITransientDependency
ILogger<AttachmentSummaryService> logger,
IStringLocalizer<AIResource> localizer) : IAttachmentSummaryService, ITransientDependency
{
private const string SummaryGenerationFailedMessage = "AI summary generation failed.";

Expand Down Expand Up @@ -47,6 +52,11 @@ public async Task<string> GenerateAndSaveAsync(Guid attachmentId, string? prompt
public async Task<List<string>> GenerateAndSaveAsync(IEnumerable<Guid> attachmentIds, string? promptVersion = null, CancellationToken cancellationToken = default)
{
var ids = attachmentIds as IReadOnlyCollection<Guid> ?? attachmentIds.ToList();
if (ids.Count == 0)
{
throw new UserFriendlyException(localizer[AILocalizationKeys.SelectAttachmentForSummaries]);
}

var mode = executionModeResolver.ResolveMode(AIExecutionModeResolver.AttachmentSummaryOperation);
if (mode != AIExecutionMode.Sequential)
{
Expand Down Expand Up @@ -103,6 +113,8 @@ public async Task<List<string>> GenerateForApplicationAsync(
IReadOnlyCollection<Guid>? attachmentIds = null,
CancellationToken cancellationToken = default)
{
await aiGenerationPrerequisiteValidator.EnsureAttachmentSummaryAvailableAsync(applicationId);

var applicationAttachmentIds = (await applicationChefsFileAttachmentRepository.GetListAsync(a => a.ApplicationId == applicationId))
.Select(a => a.Id)
.ToList();
Expand Down
Loading
Loading