From 0f88cbb4d5e1144ec62d6d19a77e8489e2072403 Mon Sep 17 00:00:00 2001 From: Jacob Smith Date: Fri, 6 Mar 2026 15:40:25 -0800 Subject: [PATCH 1/2] AB#32088 Add regenerate button for AI application analysis # Conflicts: # applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/GrantApplicationAppService.cs # Conflicts: # applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/GrantApplicationAppService.cs # applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-analysis.js --- .../IGrantApplicationAppService.cs | 9 +- .../AI/ApplicationAnalysisService.cs | 267 ++++++++++++++++++ .../AI/IApplicationAnalysisService.cs | 10 + .../GrantApplicationAppService.cs | 18 +- .../Pages/GrantApplications/Details.cshtml | 15 +- .../Pages/GrantApplications/ai-analysis.js | 36 +++ 6 files changed, 344 insertions(+), 11 deletions(-) create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/ApplicationAnalysisService.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/IApplicationAnalysisService.cs diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/IGrantApplicationAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/IGrantApplicationAppService.cs index d02254aad..a8d8e1606 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/IGrantApplicationAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/IGrantApplicationAppService.cs @@ -18,9 +18,10 @@ public interface IGrantApplicationAppService Task> GetApplicationDetailsListAsync(List applicationIds); Task GetAsync(Guid id); Task TriggerAction(Guid applicationId, GrantApplicationAction triggerAction); - Task GetAccountCodingIdFromFormIdAsync(Guid formId); - Task DismissAIIssueAsync(Guid applicationId, string issueId); - Task RestoreAIIssueAsync(Guid applicationId, string issueId); - Task> GetListAsync(GrantApplicationListInputDto input); + Task GetAccountCodingIdFromFormIdAsync(Guid formId); + Task GenerateAIAnalysisAsync(Guid applicationId); + Task DismissAIIssueAsync(Guid applicationId, string issueId); + Task RestoreAIIssueAsync(Guid applicationId, string issueId); + Task> GetListAsync(GrantApplicationListInputDto input); } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/ApplicationAnalysisService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/ApplicationAnalysisService.cs new file mode 100644 index 000000000..fffcba3c9 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/ApplicationAnalysisService.cs @@ -0,0 +1,267 @@ +using Microsoft.Extensions.Logging; +using Newtonsoft.Json.Linq; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Unity.GrantManager.Applications; +using Volo.Abp.DependencyInjection; + +namespace Unity.GrantManager.AI +{ + public class ApplicationAnalysisService( + IApplicationRepository applicationRepository, + IApplicationFormSubmissionRepository applicationFormSubmissionRepository, + IApplicationFormVersionRepository applicationFormVersionRepository, + IApplicationChefsFileAttachmentRepository applicationChefsFileAttachmentRepository, + IAIService aiService, + ILogger logger) : IApplicationAnalysisService, ITransientDependency + { + private const string ComponentsKey = "components"; + + public async Task RegenerateAndSaveAsync(Guid applicationId) + { + var application = await applicationRepository.GetAsync(applicationId); + var formSubmission = await applicationFormSubmissionRepository.GetByApplicationAsync(applicationId); + var attachments = await applicationChefsFileAttachmentRepository.GetListAsync(a => a.ApplicationId == applicationId); + + var attachmentSummaries = attachments + .Where(a => !string.IsNullOrWhiteSpace(a.AISummary)) + .Select(a => $"{a.FileName}: {a.AISummary}") + .ToList(); + + var notSpecified = "Not specified"; + var applicationContent = $@" +Project Name: {application.ProjectName} +Reference Number: {application.ReferenceNo} +Requested Amount: ${application.RequestedAmount:N2} +Total Project Budget: ${application.TotalProjectBudget:N2} +Project Summary: {application.ProjectSummary ?? "Not provided"} +City: {application.City ?? notSpecified} +Economic Region: {application.EconomicRegion ?? notSpecified} +Community: {application.Community ?? notSpecified} +Project Start Date: {application.ProjectStartDate?.ToShortDateString() ?? notSpecified} +Project End Date: {application.ProjectEndDate?.ToShortDateString() ?? notSpecified} +Submission Date: {application.SubmissionDate.ToShortDateString()} + +FULL APPLICATION FORM SUBMISSION: +{formSubmission?.RenderedHTML ?? "Form submission content not available"} +"; + + string formFieldConfiguration = "Form configuration not available."; + if (formSubmission?.ApplicationFormVersionId != null) + { + formFieldConfiguration = await ExtractFormFieldConfigurationAsync(formSubmission.ApplicationFormVersionId.Value); + } + + var analysis = await aiService.AnalyzeApplicationAsync( + applicationContent, + attachmentSummaries, + GetAnalysisRubric(), + formFieldConfiguration); + + var cleanedAnalysis = CleanJsonResponse(analysis); + application.AIAnalysis = cleanedAnalysis; + await applicationRepository.UpdateAsync(application); + return cleanedAnalysis; + } + + private static string CleanJsonResponse(string response) + { + if (string.IsNullOrWhiteSpace(response)) + return response; + + var cleaned = response.Trim(); + + if (cleaned.StartsWith("```json", StringComparison.OrdinalIgnoreCase) || cleaned.StartsWith("```")) + { + var startIndex = cleaned.IndexOf('\n'); + if (startIndex >= 0) + { + cleaned = cleaned.Substring(startIndex + 1); + } + } + + if (cleaned.EndsWith("```")) + { + var lastIndex = cleaned.LastIndexOf("```", StringComparison.Ordinal); + if (lastIndex > 0) + { + cleaned = cleaned.Substring(0, lastIndex); + } + } + + return cleaned.Trim(); + } + + private static string GetAnalysisRubric() => @" +BC GOVERNMENT GRANT EVALUATION RUBRIC: + +1. ELIGIBILITY REQUIREMENTS: + - Project must align with program objectives + - Applicant must be eligible entity type + - Budget must be reasonable and well-justified + - Project timeline must be realistic + +2. COMPLETENESS CHECKS: + - All required fields completed + - Necessary supporting documents provided + - Budget breakdown detailed and accurate + - Project description clear and comprehensive + +3. FINANCIAL REVIEW: + - Requested amount is within program limits + - Budget is reasonable for scope of work + - Matching funds or in-kind contributions identified + - Cost per outcome/beneficiary is reasonable + +4. RISK ASSESSMENT: + - Applicant capacity to deliver project + - Technical feasibility of proposed work + - Environmental or regulatory compliance + - Potential for cost overruns or delays + +5. QUALITY INDICATORS: + - Clear project objectives and outcomes + - Well-defined target audience/beneficiaries + - Appropriate project methodology + - Sustainability plan for long-term impact + +EVALUATION CRITERIA: +- HIGH: Meets all requirements, well-prepared application, low risk +- MEDIUM: Meets most requirements, minor issues or missing elements +- LOW: Missing key requirements, significant concerns, high risk +"; + + private async Task ExtractFormFieldConfigurationAsync(Guid formVersionId) + { + try + { + var formVersion = await applicationFormVersionRepository.GetAsync(formVersionId); + if (formVersion == null || string.IsNullOrEmpty(formVersion.FormSchema)) + { + return "Form configuration not available."; + } + + var schema = JObject.Parse(formVersion.FormSchema); + var components = schema[ComponentsKey] as JArray; + if (components == null || components.Count == 0) + { + return "No form fields configured."; + } + + var requiredFields = new List(); + var optionalFields = new List(); + ExtractFieldRequirements(components, requiredFields, optionalFields, string.Empty); + + var configurationText = new StringBuilder(); + configurationText.AppendLine("FORM FIELD CONFIGURATION:"); + configurationText.AppendLine(); + + if (requiredFields.Count > 0) + { + configurationText.AppendLine("REQUIRED FIELDS (must be completed):"); + foreach (var field in requiredFields) + { + configurationText.AppendLine($"- {field}"); + } + configurationText.AppendLine(); + } + + if (optionalFields.Count > 0) + { + configurationText.AppendLine("OPTIONAL FIELDS (may be left blank):"); + foreach (var field in optionalFields) + { + configurationText.AppendLine($"- {field}"); + } + } + + return configurationText.ToString(); + } + catch (Exception ex) + { + logger.LogError(ex, "Error extracting form field configuration for form version {FormVersionId}", formVersionId); + return "Form configuration could not be extracted."; + } + } + + private static void ExtractFieldRequirements(JArray components, List requiredFields, List optionalFields, string currentPath) + { + foreach (var component in components.OfType()) + { + var key = component["key"]?.ToString(); + var label = component["label"]?.ToString(); + var type = component["type"]?.ToString(); + var skipTypes = new HashSet { "button", "simplebuttonadvanced", "html", "htmlelement", "content", "simpleseparator" }; + + if (string.IsNullOrEmpty(key) || string.IsNullOrEmpty(type) || skipTypes.Contains(type)) + { + 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() ?? false; + + if (component["input"]?.Value() == 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 requiredFields, List 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()) + { + 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()) + { + if (tab[ComponentsKey] is JArray tabComponents) + { + ExtractFieldRequirements(tabComponents, requiredFields, optionalFields, currentPath); + } + } + } + break; + } + } + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/IApplicationAnalysisService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/IApplicationAnalysisService.cs new file mode 100644 index 000000000..cdb31bf8b --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/IApplicationAnalysisService.cs @@ -0,0 +1,10 @@ +using System; +using System.Threading.Tasks; + +namespace Unity.GrantManager.AI +{ + public interface IApplicationAnalysisService + { + Task RegenerateAndSaveAsync(Guid applicationId); + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/GrantApplicationAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/GrantApplicationAppService.cs index 075d56866..a274683fa 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/GrantApplicationAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/GrantApplicationAppService.cs @@ -46,9 +46,10 @@ public class GrantApplicationAppService( IApplicantRepository applicantRepository, IApplicationFormRepository applicationFormRepository, IApplicantAgentRepository applicantAgentRepository, - IApplicantAddressRepository applicantAddressRepository, + IApplicantAddressRepository applicantAddressRepository, IApplicantSupplierAppService applicantSupplierService, - IPaymentRequestAppService paymentRequestService) + IPaymentRequestAppService paymentRequestService, + IApplicationAnalysisService applicationAnalysisService) : GrantManagerAppService, IGrantApplicationAppService { private static readonly JsonSerializerOptions AiAnalysisReadOptions = new() @@ -1059,6 +1060,19 @@ public async Task DismissAIIssueAsync(Guid applicationId, string issueId return await UpdateAIIssueDismissStateAsync(applicationId, issueId, isDismiss: true); } + public async Task GenerateAIAnalysisAsync(Guid applicationId) + { + try + { + return await applicationAnalysisService.RegenerateAndSaveAsync(applicationId); + } + catch (Exception ex) + { + Logger.LogError(ex, "Error regenerating AI analysis for application {ApplicationId}", applicationId); + throw new UserFriendlyException("Failed to regenerate AI analysis. Please try again."); + } + } + public async Task RestoreAIIssueAsync(Guid applicationId, string issueId) { return await UpdateAIIssueDismissStateAsync(applicationId, issueId, isDismiss: false); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml index b8d0fcaf5..e50a5b642 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml @@ -398,11 +398,16 @@ @*-------- History Tab Section END ---------*@ @*-------- AI Analysis Tab Section ---------*@ - @if (aiApplicationAnalysisEnabled) - { -
-
AI Application Analysis
-
+ @if (aiApplicationAnalysisEnabled) + { +
+
+
AI Application Analysis
+ +
+
@* Default message when no analysis data is available *@
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-analysis.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-analysis.js index ece7839f1..8193ddcf6 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-analysis.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-analysis.js @@ -308,6 +308,33 @@ globalThis.restoreAIIssue = function(issueId) { }); } +globalThis.regenerateAIAnalysis = function() { + const applicationId = $('#DetailsViewApplicationId').val(); + const $button = $('#regenerateAiAnalysis'); + const existingHtml = $button.html(); + + if (!applicationId || $button.prop('disabled')) { + return; + } + + $button + .html(' Regenerating...') + .prop('disabled', true); + + unity.grantManager.grantApplications.grantApplication + .generateAIAnalysis(applicationId) + .then(function() { + abp.notify.success('AI analysis regenerated successfully.'); + loadAIAnalysis(); + }) + .catch(function() { + abp.message.error('Failed to regenerate AI analysis. Please try again.'); + }) + .always(function() { + $button.html(existingHtml).prop('disabled', false); + }); +} + function toggleAccordionItem($header) { const targetId = $header.attr('data-target'); const $body = $('#' + targetId); @@ -365,3 +392,12 @@ function loadAIAnalysis() { console.warn('Failed to load application data', error); }); } + +$(function() { + const $regenerateButton = $('#regenerateAiAnalysis'); + if ($regenerateButton.length > 0) { + $regenerateButton.on('click', function() { + regenerateAIAnalysis(); + }); + } +}); From fb0892bce9e7d4e04fc838f6c347f4422f283093 Mon Sep 17 00:00:00 2001 From: Jacob Smith Date: Thu, 26 Feb 2026 16:12:12 -0800 Subject: [PATCH 2/2] AB#32088 Align AI analysis refresh button style and copy with scoring --- .../Pages/GrantApplications/Details.cshtml | 4 ++-- .../Pages/GrantApplications/ai-analysis.js | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml index e50a5b642..64b36a674 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml @@ -403,8 +403,8 @@
AI Application Analysis
-
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-analysis.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-analysis.js index 8193ddcf6..e19b7c459 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-analysis.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-analysis.js @@ -318,17 +318,17 @@ globalThis.regenerateAIAnalysis = function() { } $button - .html(' Regenerating...') + .html(' Refreshing Analysis...') .prop('disabled', true); unity.grantManager.grantApplications.grantApplication .generateAIAnalysis(applicationId) .then(function() { - abp.notify.success('AI analysis regenerated successfully.'); + abp.notify.success('AI analysis refreshed successfully.'); loadAIAnalysis(); }) .catch(function() { - abp.message.error('Failed to regenerate AI analysis. Please try again.'); + abp.message.error('Failed to refresh AI analysis. Please try again.'); }) .always(function() { $button.html(existingHtml).prop('disabled', false);