From 46f6e00fe30f85e5c446922cb7593ce09e13502d Mon Sep 17 00:00:00 2001 From: Patrick <135162612+plavoie-BC@users.noreply.github.com> Date: Thu, 30 Apr 2026 10:00:34 -0700 Subject: [PATCH 001/223] [ARCH-SPIKE] Refactor identity permissions and claims handling Co-authored-by: Copilot --- .../PermissionOrAuthorizationHandler.cs | 40 +++ .../RoleOrPermissionAuthorizationHandler.cs | 44 +++ .../Identity/CurrentUser.cs | 2 +- .../Identity/IdentityExtensionMethods.cs | 19 -- .../IdentityProfileLoginAdminHandler.cs | 21 -- .../LoginHandlers/IdentityProfileLoginBase.cs | 5 +- .../IdentityProfileLoginUserHandler.cs | 40 +-- .../Identity/PermissionChecker.cs | 118 -------- .../Identity/PolicyRegistrant.cs | 251 ++---------------- 9 files changed, 116 insertions(+), 424 deletions(-) create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/Authorization/PermissionOrAuthorizationHandler.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/Authorization/RoleOrPermissionAuthorizationHandler.cs delete mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/IdentityExtensionMethods.cs delete mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/PermissionChecker.cs diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/Authorization/PermissionOrAuthorizationHandler.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/Authorization/PermissionOrAuthorizationHandler.cs new file mode 100644 index 0000000000..7adb9b86e1 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/Authorization/PermissionOrAuthorizationHandler.cs @@ -0,0 +1,40 @@ +using Microsoft.AspNetCore.Authorization; +using System.Threading.Tasks; +using Volo.Abp.Authorization.Permissions; +using Volo.Abp.DependencyInjection; + +namespace Unity.GrantManager.Web.Identity.Authorization; + +public class PermissionOrRequirement : IAuthorizationRequirement +{ + public string[] Permissions { get; } + + public PermissionOrRequirement(params string[] permissions) + { + Permissions = permissions; + } +} + +public class PermissionOrAuthorizationHandler : AuthorizationHandler, ITransientDependency +{ + private readonly IPermissionChecker _permissionChecker; + + public PermissionOrAuthorizationHandler(IPermissionChecker permissionChecker) + { + _permissionChecker = permissionChecker; + } + + protected override async Task HandleRequirementAsync( + AuthorizationHandlerContext context, + PermissionOrRequirement requirement) + { + foreach (var permission in requirement.Permissions) + { + if (await _permissionChecker.IsGrantedAsync(context.User, permission)) + { + context.Succeed(requirement); + return; + } + } + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/Authorization/RoleOrPermissionAuthorizationHandler.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/Authorization/RoleOrPermissionAuthorizationHandler.cs new file mode 100644 index 0000000000..2701b9ba35 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/Authorization/RoleOrPermissionAuthorizationHandler.cs @@ -0,0 +1,44 @@ +using Microsoft.AspNetCore.Authorization; +using System.Threading.Tasks; +using Volo.Abp.Authorization.Permissions; +using Volo.Abp.DependencyInjection; + +namespace Unity.GrantManager.Web.Identity.Authorization; + +public class RoleOrPermissionRequirement : IAuthorizationRequirement +{ + public string RoleName { get; } + public string PermissionName { get; } + + public RoleOrPermissionRequirement(string roleName, string permissionName) + { + RoleName = roleName; + PermissionName = permissionName; + } +} + +public class RoleOrPermissionAuthorizationHandler : AuthorizationHandler, ITransientDependency +{ + private readonly IPermissionChecker _permissionChecker; + + public RoleOrPermissionAuthorizationHandler(IPermissionChecker permissionChecker) + { + _permissionChecker = permissionChecker; + } + + protected override async Task HandleRequirementAsync( + AuthorizationHandlerContext context, + RoleOrPermissionRequirement requirement) + { + if (context.User.IsInRole(requirement.RoleName)) + { + context.Succeed(requirement); + return; + } + + if (await _permissionChecker.IsGrantedAsync(context.User, requirement.PermissionName)) + { + context.Succeed(requirement); + } + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/CurrentUser.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/CurrentUser.cs index ab17dea528..6037b9f5c3 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/CurrentUser.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/CurrentUser.cs @@ -75,7 +75,7 @@ public virtual bool IsInRole(string roleName) var userClaims = _principalAccessor.Principal?.Claims; if (userClaims != null && userClaims.Any()) { - var userId = userClaims.FirstOrDefault(s => s.Type == "UserId"); + var userId = userClaims.FirstOrDefault(s => s.Type == AbpClaimTypes.UserId); if (userId != null) { return Guid.Parse(userId.Value); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/IdentityExtensionMethods.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/IdentityExtensionMethods.cs deleted file mode 100644 index 3eb02c46cb..0000000000 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/IdentityExtensionMethods.cs +++ /dev/null @@ -1,19 +0,0 @@ -using OpenIddict.Abstractions; -using System.Collections.Immutable; -using System.Security.Claims; - -namespace Unity.GrantManager.Web.Identity -{ - public static class IdentityExtensionMethods - { - public static ClaimsPrincipal AddPermission(this ClaimsPrincipal principal, string value) - { - return principal.AddClaim(UnityClaimsTypes.Permission, value); - } - - public static ClaimsPrincipal AddPermissions(this ClaimsPrincipal principal, ImmutableArray values) - { - return principal.AddClaims(UnityClaimsTypes.Permission, values); - } - } -} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/LoginHandlers/IdentityProfileLoginAdminHandler.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/LoginHandlers/IdentityProfileLoginAdminHandler.cs index c80469dbd5..3949150b68 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/LoginHandlers/IdentityProfileLoginAdminHandler.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/LoginHandlers/IdentityProfileLoginAdminHandler.cs @@ -1,13 +1,10 @@ using Microsoft.AspNetCore.Authentication.OpenIdConnect; using System; using System.Collections.Generic; -using System.Collections.Immutable; using System.Linq; using System.Security.Claims; using System.Threading.Tasks; using Unity.GrantManager.Identity; -using Unity.Modules.Shared.Permissions; -using Unity.TenantManagement; using Volo.Abp; using Volo.Abp.Data; using Volo.Abp.Identity; @@ -16,18 +13,6 @@ namespace Unity.GrantManager.Web.Identity.LoginHandlers { internal class IdentityProfileLoginAdminHandler : IdentityProfileLoginBase { - internal readonly ImmutableArray _adminPermissions = ImmutableArray.Create( - TenantManagementPermissions.Tenants.Default, - TenantManagementPermissions.Tenants.Create, - TenantManagementPermissions.Tenants.Update, - TenantManagementPermissions.Tenants.Delete, - TenantManagementPermissions.Tenants.ManageFeatures, - TenantManagementPermissions.Tenants.ManageConnectionStrings, - IdentityPermissions.Users.Create, - IdentityPermissions.UserLookup.Default, - IdentityConsts.ITAdminPermissionName - ); - internal async Task Handle(TokenValidatedContext validatedTokenContext, IList userTenantAccounts, string? idp) @@ -43,7 +28,6 @@ internal async Task Handle(TokenValidatedContext validated userTenantAccount = userTenantAccounts.First(s => s.TenantId == null); } - AssignAdminHostPermissions(validatedTokenContext.Principal!); AssignDefaultClaims(validatedTokenContext.Principal!, userTenantAccount.DisplayName ?? string.Empty, userTenantAccount.Id); return userTenantAccount; } @@ -63,11 +47,6 @@ private static bool AdminHasUserAccount(IList? userTenantA return false; } - private void AssignAdminHostPermissions(ClaimsPrincipal claimsPrincipal) - { - claimsPrincipal.AddPermissions(_adminPermissions); - } - private async Task CreateAdminAccountAsync(TokenValidatedContext validatedTokenContext, string? idp) { var token = validatedTokenContext.SecurityToken; diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/LoginHandlers/IdentityProfileLoginBase.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/LoginHandlers/IdentityProfileLoginBase.cs index 0810de9d47..f152afb7f8 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/LoginHandlers/IdentityProfileLoginBase.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/LoginHandlers/IdentityProfileLoginBase.cs @@ -7,7 +7,7 @@ using Volo.Abp.MultiTenancy; using Volo.Abp.DependencyInjection; using Volo.Abp.Identity; -using Volo.Abp.PermissionManagement; +using Volo.Abp.Security.Claims; using Microsoft.Extensions.Configuration; using Volo.Abp.TenantManagement; @@ -19,7 +19,6 @@ internal abstract class IdentityProfileLoginBase : ITransientDependency protected ICurrentTenant CurrentTenant => LazyServiceProvider.LazyGetRequiredService(); protected IdentityUserManager IdentityUserManager => LazyServiceProvider.LazyGetRequiredService(); protected IdentityRoleManager IdentityRoleManager => LazyServiceProvider.LazyGetRequiredService(); - protected PermissionManager PermissionManager => LazyServiceProvider.LazyGetRequiredService(); protected IIdentityUserRepository IdentityUserRepository => LazyServiceProvider.LazyGetRequiredService(); protected IConfiguration Configuration => LazyServiceProvider.LazyGetRequiredService(); protected IUserImportAppService UserImportAppService => LazyServiceProvider.LazyGetRequiredService(); @@ -29,7 +28,7 @@ internal abstract class IdentityProfileLoginBase : ITransientDependency protected static void AssignDefaultClaims(ClaimsPrincipal claimsPrinicipal, string displayName, Guid userId) { claimsPrinicipal.AddClaim("DisplayName", displayName); - claimsPrinicipal.AddClaim("UserId", userId.ToString()); + claimsPrinicipal.AddClaim(AbpClaimTypes.UserId, userId.ToString()); claimsPrinicipal.AddClaim("Badge", Utils.CreateUserBadge(displayName)); } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/LoginHandlers/IdentityProfileLoginUserHandler.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/LoginHandlers/IdentityProfileLoginUserHandler.cs index a13314a84e..d04f1dc4aa 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/LoginHandlers/IdentityProfileLoginUserHandler.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/LoginHandlers/IdentityProfileLoginUserHandler.cs @@ -4,32 +4,18 @@ using OpenIddict.Abstractions; using System; using System.Collections.Generic; -using System.Collections.Immutable; using System.Linq; using System.Security.Claims; using System.Threading.Tasks; using Unity.GrantManager.Identity; -using Unity.GrantManager.Permissions; using Unity.GrantManager.Web.Exceptions; -using Unity.Modules.Shared.Permissions; using Volo.Abp.Identity; -using Volo.Abp.PermissionManagement; using Volo.Abp.Security.Claims; namespace Unity.GrantManager.Web.Identity.LoginHandlers { internal class IdentityProfileLoginUserHandler : IdentityProfileLoginBase { - internal readonly ImmutableArray _userPermissions = [ - GrantManagerPermissions.Default, - IdentityPermissions.UserLookup.Default - ]; - - internal readonly ImmutableArray _itOperationsPermissions = [ - GrantManagerPermissions.Endpoints.ManageEndpoints, - IdentityConsts.ITOperationsPermissionName - ]; - internal async Task Handle(TokenValidatedContext validatedTokenContext, IList? userTenantAccounts, string? idp) @@ -57,11 +43,6 @@ internal async Task Handle(TokenValidatedContext validated } } - if (validatedTokenContext.Principal != null && validatedTokenContext.Principal.IsInRole(IdentityConsts.ITOperationsRoleName)) - { - AssignITOperationsPermissions(validatedTokenContext.Principal); - } - UserTenantAccountDto? userTenantAccount = null; var setTenant = validatedTokenContext.Request.Cookies["set_tenant"]; if (setTenant != null && setTenant != Guid.Empty.ToString()) @@ -84,20 +65,11 @@ internal async Task Handle(TokenValidatedContext validated { var dbRole = await IdentityRoleManager.GetByIdAsync(role.Id); principal.AddClaim(UnityClaimsTypes.Role, dbRole.Name); + principal.AddClaim(AbpClaimTypes.Role, dbRole.Name); } } - - var userPermissions = (await PermissionManager.GetAllForUserAsync(userTenantAccount.Id)).Where(s => s.IsGranted); - - foreach (var permissionName in userPermissions - .Select(s => s.Name) - .Where(permissionName => !principal.HasClaim(UnityClaimsTypes.Permission, permissionName))) - { - principal.AddClaim(UnityClaimsTypes.Permission, permissionName); - } } - AssignDefaultPermissions(validatedTokenContext.Principal!); AssignDefaultClaims(validatedTokenContext.Principal!, userTenantAccount.DisplayName ?? string.Empty, userTenantAccount.Id); validatedTokenContext.Principal!.AddClaim(AbpClaimTypes.TenantId, userTenantAccount.TenantId?.ToString() ?? Guid.Empty.ToString()); @@ -105,11 +77,6 @@ internal async Task Handle(TokenValidatedContext validated return userTenantAccount; } - private void AssignITOperationsPermissions(ClaimsPrincipal claimsPrincipal) - { - claimsPrincipal.AddPermissions(_itOperationsPermissions); - } - private async Task> AutoRegisterUserWithDefaultAsync(string userIdentifier, string username, string firstName, @@ -151,10 +118,5 @@ private bool IsAutoRegisterFlagSet() { return Configuration.GetValue("IdentityProfileLogin:AutoCreateUser"); } - - private void AssignDefaultPermissions(ClaimsPrincipal claimsPrincipal) - { - claimsPrincipal.AddPermissions(_userPermissions); - } } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/PermissionChecker.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/PermissionChecker.cs deleted file mode 100644 index 53ff40213a..0000000000 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/PermissionChecker.cs +++ /dev/null @@ -1,118 +0,0 @@ -using System.Linq; -using System.Security.Claims; -using System.Security.Principal; -using System.Threading.Tasks; -using Volo.Abp; -using Volo.Abp.Authorization.Permissions; -using Volo.Abp.DependencyInjection; -using Volo.Abp.MultiTenancy; -using Volo.Abp.Security.Claims; -using Volo.Abp.SimpleStateChecking; - -namespace Unity.GrantManager.Web.Identity -{ - [Dependency(ReplaceServices = true)] - [ExposeServices(typeof(PermissionChecker), typeof(IPermissionChecker))] - public class PermissionChecker : IPermissionChecker, ITransientDependency - { - protected IPermissionDefinitionManager PermissionDefinitionManager { get; } - protected ICurrentPrincipalAccessor PrincipalAccessor { get; } - protected ICurrentTenant CurrentTenant { get; } - protected IPermissionValueProviderManager PermissionValueProviderManager { get; } - protected ISimpleStateCheckerManager StateCheckerManager { get; } - - public PermissionChecker( - ICurrentPrincipalAccessor principalAccessor, - IPermissionDefinitionManager permissionDefinitionManager, - ICurrentTenant currentTenant, - IPermissionValueProviderManager permissionValueProviderManager, - ISimpleStateCheckerManager stateCheckerManager) - { - PrincipalAccessor = principalAccessor; - PermissionDefinitionManager = permissionDefinitionManager; - CurrentTenant = currentTenant; - PermissionValueProviderManager = permissionValueProviderManager; - StateCheckerManager = stateCheckerManager; - } - - public virtual async Task IsGrantedAsync(string name) - { - return await IsGrantedAsync(PrincipalAccessor.Principal, name); - } - - public virtual async Task IsGrantedAsync( - ClaimsPrincipal? claimsPrincipal, - string name) - { - Check.NotNull(name, nameof(name)); - - var permission = await PermissionDefinitionManager.GetOrNullAsync(name); - if (permission == null) - { - return false; - } - - if (!permission.IsEnabled) - { - return false; - } - - if (!await StateCheckerManager.IsEnabledAsync(permission)) - { - return false; - } - - var multiTenancySide = claimsPrincipal?.GetMultiTenancySide() - ?? CurrentTenant.GetMultiTenancySide(); - - if (!permission.MultiTenancySide.HasFlag(multiTenancySide)) - { - return false; - } - - var isGranted = false; - - if (claimsPrincipal != null - && claimsPrincipal.Claims.Any(s => s.Type == "Permission" && s.Value == name)) - { - isGranted = true; - } - - return isGranted; - } - - public async Task IsGrantedAsync(string[] names) - { - return await IsGrantedAsync(PrincipalAccessor.Principal, names); - } - - public async Task IsGrantedAsync(ClaimsPrincipal? claimsPrincipal, string[] names) - { - Check.NotNull(names, nameof(names)); - - var result = new MultiplePermissionGrantResult(); - if (names.Length == 0) - { - return result; - } - - if (claimsPrincipal != null) - { - var permissions = claimsPrincipal.Claims.Where(s => s.Type == "Permission"); - foreach (var name in names) - { - if (permissions.Select(s => s.Value).Contains(name)) - { - result.Result.Add(name, PermissionGrantResult.Granted); - } - else - { - result.Result.Add(name, PermissionGrantResult.Prohibited); - } - } - } - - return await Task.FromResult(result); - } - } -} \ No newline at end of file diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/PolicyRegistrant.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/PolicyRegistrant.cs index 10e9446c70..10a51884d1 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/PolicyRegistrant.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/PolicyRegistrant.cs @@ -1,244 +1,49 @@ using Microsoft.Extensions.DependencyInjection; -using Unity.GrantManager.Permissions; +using Unity.GrantManager.Web.Identity.Authorization; using Unity.Modules.Shared; using Unity.Modules.Shared.Permissions; -using Unity.Reporting.Permissions; -using Unity.TenantManagement; -using Volo.Abp.Identity; using Volo.Abp.Modularity; namespace Unity.GrantManager.Web.Identity.Policy; internal static class PolicyRegistrant { - internal const string PermissionConstant = "Permission"; - internal static void Register(ServiceConfigurationContext context) { - // Using AddAuthorizationBuilder to register authorization services and construct policies - var authorizationBuilder = context.Services.AddAuthorizationBuilder(); - - // Identity Role Policies - authorizationBuilder.AddPolicy(IdentityPermissions.Roles.Default, - policy => policy.RequireClaim(PermissionConstant, IdentityPermissions.Roles.Default)); - authorizationBuilder.AddPolicy(IdentityPermissions.Roles.Create, - policy => policy.RequireClaim(PermissionConstant, IdentityPermissions.Roles.Create)); - authorizationBuilder.AddPolicy(IdentityPermissions.Roles.Update, - policy => policy.RequireClaim(PermissionConstant, IdentityPermissions.Roles.Update)); - authorizationBuilder.AddPolicy(IdentityPermissions.Roles.Delete, - policy => policy.RequireClaim(PermissionConstant, IdentityPermissions.Roles.Delete)); - authorizationBuilder.AddPolicy(IdentityPermissions.Roles.ManagePermissions, - policy => policy.RequireClaim(PermissionConstant, IdentityPermissions.Roles.ManagePermissions)); - - // Identity User Policies - authorizationBuilder.AddPolicy(IdentityPermissions.Users.Default, - policy => policy.RequireClaim(PermissionConstant, IdentityPermissions.Users.Default)); - authorizationBuilder.AddPolicy(IdentityPermissions.Users.Create, - policy => policy.RequireClaim(PermissionConstant, IdentityPermissions.Users.Create)); - authorizationBuilder.AddPolicy(IdentityPermissions.Users.Update, - policy => policy.RequireClaim(PermissionConstant, IdentityPermissions.Users.Update)); - authorizationBuilder.AddPolicy(IdentityPermissions.Users.Delete, - policy => policy.RequireClaim(PermissionConstant, IdentityPermissions.Users.Delete)); - authorizationBuilder.AddPolicy(IdentityPermissions.Users.ManagePermissions, - policy => policy.RequireClaim(PermissionConstant, IdentityPermissions.Users.ManagePermissions)); - - // User Lookup Policies - authorizationBuilder.AddPolicy(IdentityPermissions.UserLookup.Default, - policy => policy.RequireClaim(PermissionConstant, IdentityPermissions.UserLookup.Default)); - - // Grant Manager Policies - authorizationBuilder.AddPolicy(GrantManagerPermissions.Default, - policy => policy.RequireClaim(PermissionConstant, GrantManagerPermissions.Default)); - authorizationBuilder.AddPolicy(GrantManagerPermissions.Intakes.Default, - policy => policy.RequireClaim(PermissionConstant, GrantManagerPermissions.Intakes.Default)); - authorizationBuilder.AddPolicy(GrantManagerPermissions.ApplicationForms.Default, - policy => policy.RequireClaim(PermissionConstant, GrantManagerPermissions.ApplicationForms.Default)); - - // Grant Application Policies - authorizationBuilder.AddPolicy(GrantApplicationPermissions.Applications.Default, - policy => policy.RequireClaim(PermissionConstant, GrantApplicationPermissions.Applications.Default)); - authorizationBuilder.AddPolicy(GrantApplicationPermissions.Applicants.Default, - policy => policy.RequireClaim(PermissionConstant, GrantApplicationPermissions.Applicants.Default)); - authorizationBuilder.AddPolicy(GrantApplicationPermissions.Applicants.Edit, - policy => policy.RequireClaim(PermissionConstant, GrantApplicationPermissions.Applicants.Edit)); - authorizationBuilder.AddPolicy(GrantApplicationPermissions.Applicants.AssignApplicant, - policy => policy.RequireClaim(PermissionConstant, GrantApplicationPermissions.Applicants.AssignApplicant)); - authorizationBuilder.AddPolicy(GrantApplicationPermissions.Assignments.Default, - policy => policy.RequireClaim(PermissionConstant, GrantApplicationPermissions.Assignments.Default)); - authorizationBuilder.AddPolicy(GrantApplicationPermissions.Assignments.AssignInitial, - policy => policy.RequireClaim(PermissionConstant, GrantApplicationPermissions.Assignments.AssignInitial)); - authorizationBuilder.AddPolicy(GrantApplicationPermissions.Reviews.Default, - policy => policy.RequireClaim(PermissionConstant, GrantApplicationPermissions.Reviews.Default)); - authorizationBuilder.AddPolicy(GrantApplicationPermissions.Reviews.StartInitial, - policy => policy.RequireClaim(PermissionConstant, GrantApplicationPermissions.Reviews.StartInitial)); - authorizationBuilder.AddPolicy(GrantApplicationPermissions.Reviews.CompleteInitial, - policy => policy.RequireClaim(PermissionConstant, GrantApplicationPermissions.Reviews.CompleteInitial)); - authorizationBuilder.AddPolicy(GrantApplicationPermissions.Approvals.Default, - policy => policy.RequireClaim(PermissionConstant, GrantApplicationPermissions.Approvals.Default)); - authorizationBuilder.AddPolicy(GrantApplicationPermissions.Approvals.Complete, - policy => policy.RequireClaim(PermissionConstant, GrantApplicationPermissions.Approvals.Complete)); - authorizationBuilder.AddPolicy(GrantApplicationPermissions.Comments.Default, - policy => policy.RequireClaim(PermissionConstant, GrantApplicationPermissions.Comments.Default)); - authorizationBuilder.AddPolicy(GrantApplicationPermissions.Comments.Add, - policy => policy.RequireClaim(PermissionConstant, GrantApplicationPermissions.Comments.Add)); + // Simple permission-based policies (e.g., [Authorize("PermissionName")]) are handled + // automatically by ABP's AbpAuthorizationPolicyProvider + PermissionRequirementHandler. + // Only register custom composite policies here. - // R&A Policies - authorizationBuilder.AddPolicy(UnitySelector.Review.Default, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Review.Default)); - - // R&A - Approval Policies - authorizationBuilder.AddPolicy(UnitySelector.Review.Approval.Default, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Review.Approval.Default)); - authorizationBuilder.AddPolicy(UnitySelector.Review.Approval.Update.Default, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Review.Approval.Update.Default)); - authorizationBuilder.AddPolicy(UnitySelector.Review.Approval.Update.UpdateFinalStateFields, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Review.Approval.Update.UpdateFinalStateFields)); - - // R&A - Assessment Results Policies - authorizationBuilder.AddPolicy(UnitySelector.Review.AssessmentResults.Default, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Review.AssessmentResults.Default)); - authorizationBuilder.AddPolicy(UnitySelector.Review.AssessmentResults.Update.Default, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Review.AssessmentResults.Update.Default)); - authorizationBuilder.AddPolicy(UnitySelector.Review.AssessmentResults.Update.UpdateFinalStateFields, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Review.AssessmentResults.Update.UpdateFinalStateFields)); - - // R&A - Assessment Review List Policies - authorizationBuilder.AddPolicy(UnitySelector.Review.AssessmentReviewList.Default, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Review.AssessmentReviewList.Default)); - authorizationBuilder.AddPolicy(UnitySelector.Review.AssessmentReviewList.Create, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Review.AssessmentReviewList.Create)); - authorizationBuilder.AddPolicy(UnitySelector.Review.AssessmentReviewList.Update.SendBack, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Review.AssessmentReviewList.Update.SendBack)); - authorizationBuilder.AddPolicy(UnitySelector.Review.AssessmentReviewList.Update.Complete, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Review.AssessmentReviewList.Update.Complete)); - - //-- APPLICANT INFO - authorizationBuilder.AddPolicy(UnitySelector.Applicant.Default, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Applicant.Default)); - authorizationBuilder.AddPolicy(UnitySelector.Applicant.Authority.Default, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Applicant.Authority.Default)); - authorizationBuilder.AddPolicy(UnitySelector.Applicant.Authority.Update, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Applicant.Authority.Update)); - authorizationBuilder.AddPolicy(UnitySelector.Applicant.Contact.Default, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Applicant.Contact.Default)); - authorizationBuilder.AddPolicy(UnitySelector.Applicant.Contact.Update, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Applicant.Contact.Update)); - authorizationBuilder.AddPolicy(UnitySelector.Applicant.Location.Default, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Applicant.Location.Default)); - authorizationBuilder.AddPolicy(UnitySelector.Applicant.Location.Update, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Applicant.Location.Update)); - authorizationBuilder.AddPolicy(UnitySelector.Applicant.Summary.Default, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Applicant.Summary.Default)); - authorizationBuilder.AddPolicy(UnitySelector.Applicant.Summary.Update, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Applicant.Summary.Update)); - authorizationBuilder.AddPolicy(UnitySelector.Applicant.AdditionalContact.Default, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Applicant.AdditionalContact.Default)); - authorizationBuilder.AddPolicy(UnitySelector.Applicant.AdditionalContact.Create, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Applicant.AdditionalContact.Create)); - authorizationBuilder.AddPolicy(UnitySelector.Applicant.AdditionalContact.Update, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Applicant.AdditionalContact.Update)); - - // Applicant Info Logical OR policy - authorizationBuilder.AddPolicy(UnitySelector.Applicant.UpdatePolicy, - policy => policy.RequireAssertion(context => - context.User.HasClaim(PermissionConstant, UnitySelector.Applicant.Summary.Update) || - context.User.HasClaim(PermissionConstant, UnitySelector.Applicant.Contact.Update) || - context.User.HasClaim(PermissionConstant, UnitySelector.Applicant.Authority.Update) || - context.User.HasClaim(PermissionConstant, UnitySelector.Applicant.Location.Update) || - context.User.HasClaim(PermissionConstant, UnitySelector.Applicant.AdditionalContact.Update) || - - // NOTE: This will be replaced when Worksheets are normalized with UnitySelector.Applicant.Worksheet.Update - context.User.HasClaim(PermissionConstant, UnitySelector.Applicant.Default) - )); - - //-- PAYMENT INFO - authorizationBuilder.AddPolicy(UnitySelector.Payment.Default, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Payment.Default)); - authorizationBuilder.AddPolicy(UnitySelector.Payment.Supplier.Default, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Payment.Summary.Default)); - authorizationBuilder.AddPolicy(UnitySelector.Payment.Supplier.Default, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Payment.Supplier.Default)); - authorizationBuilder.AddPolicy(UnitySelector.Payment.Supplier.Update, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Payment.Supplier.Update)); - authorizationBuilder.AddPolicy(UnitySelector.Payment.Supplier.Default, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Payment.PaymentList.Default)); - - // Tenancy Policies - authorizationBuilder.AddPolicy(TenantManagementPermissions.Tenants.Default, - policy => policy.RequireClaim(PermissionConstant, TenantManagementPermissions.Tenants.Default)); - authorizationBuilder.AddPolicy(TenantManagementPermissions.Tenants.Create, - policy => policy.RequireClaim(PermissionConstant, TenantManagementPermissions.Tenants.Create)); - authorizationBuilder.AddPolicy(TenantManagementPermissions.Tenants.Update, - policy => policy.RequireClaim(PermissionConstant, TenantManagementPermissions.Tenants.Update)); - authorizationBuilder.AddPolicy(TenantManagementPermissions.Tenants.Delete, - policy => policy.RequireClaim(PermissionConstant, TenantManagementPermissions.Tenants.Delete)); - authorizationBuilder.AddPolicy(TenantManagementPermissions.Tenants.ManageFeatures, - policy => policy.RequireClaim(PermissionConstant, TenantManagementPermissions.Tenants.ManageFeatures)); - authorizationBuilder.AddPolicy(TenantManagementPermissions.Tenants.ManageConnectionStrings, - policy => policy.RequireClaim(PermissionConstant, TenantManagementPermissions.Tenants.ManageConnectionStrings)); - - // Setting Management - Tag Management - authorizationBuilder.AddPolicy(UnitySelector.SettingManagement.Tags.Default, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.SettingManagement.Tags.Default)); - authorizationBuilder.AddPolicy(UnitySelector.SettingManagement.Tags.Create, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.SettingManagement.Tags.Create)); - authorizationBuilder.AddPolicy(UnitySelector.SettingManagement.Tags.Update, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.SettingManagement.Tags.Update)); - authorizationBuilder.AddPolicy(UnitySelector.SettingManagement.Tags.Delete, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.SettingManagement.Tags.Delete)); + var authorizationBuilder = context.Services.AddAuthorizationBuilder(); - // IT Administrator Policies + // IT Administrator Policy: granted by role OR permission authorizationBuilder.AddPolicy(IdentityConsts.ITAdminPolicyName, - policy => policy.RequireAssertion(context => - context.User.IsInRole(IdentityConsts.ITAdminRoleName) || - context.User.HasClaim(c => c.Type == PermissionConstant && c.Value == IdentityConsts.ITAdminPermissionName) - )); + policy => policy.AddRequirements( + new RoleOrPermissionRequirement(IdentityConsts.ITAdminRoleName, IdentityConsts.ITAdminPermissionName))); - // IT Operations Policies + // IT Operations Policy: granted by role OR permission authorizationBuilder.AddPolicy(IdentityConsts.ITOperationsPolicyName, - policy => policy.RequireAssertion(context => - context.User.IsInRole(IdentityConsts.ITOperationsRoleName) || - context.User.HasClaim(c => c.Type == PermissionConstant && c.Value == IdentityConsts.ITOperationsPermissionName) - )); - - // Project Info Policies - authorizationBuilder.AddPolicy(UnitySelector.Project.Default, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Project.Default)); + policy => policy.AddRequirements( + new RoleOrPermissionRequirement(IdentityConsts.ITOperationsRoleName, IdentityConsts.ITOperationsPermissionName))); - // Project Info Logical OR policy + // Applicant Info Logical OR policy: any update sub-permission grants access + authorizationBuilder.AddPolicy(UnitySelector.Applicant.UpdatePolicy, + policy => policy.AddRequirements( + new PermissionOrRequirement( + UnitySelector.Applicant.Summary.Update, + UnitySelector.Applicant.Contact.Update, + UnitySelector.Applicant.Authority.Update, + UnitySelector.Applicant.Location.Update, + UnitySelector.Applicant.AdditionalContact.Update, + UnitySelector.Applicant.Default))); + + // Project Info Logical OR policy: any update sub-permission grants access authorizationBuilder.AddPolicy(UnitySelector.Project.UpdatePolicy, - policy => policy.RequireAssertion(context => - context.User.HasClaim(PermissionConstant, UnitySelector.Project.Location.Update.Default) || - context.User.HasClaim(PermissionConstant, UnitySelector.Project.Summary.Update.Default) || - - // NOTE: This will be replaced when Worksheets are normalized with UnitySelector.Project.Worksheet.Update - context.User.HasClaim(PermissionConstant, UnitySelector.Project.Default) - )); - - // Project Info - Summary Policies - authorizationBuilder.AddPolicy(UnitySelector.Project.Summary.Default, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Project.Summary.Default)); - authorizationBuilder.AddPolicy(UnitySelector.Project.Summary.Update.Default, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Project.Summary.Update.Default)); - authorizationBuilder.AddPolicy(UnitySelector.Project.Summary.Update.UpdateFinalStateFields, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Project.Summary.Update.UpdateFinalStateFields)); - - // Project Info - Location Policies - authorizationBuilder.AddPolicy(UnitySelector.Project.Location.Default, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Project.Location.Default)); - authorizationBuilder.AddPolicy(UnitySelector.Project.Location.Update.Default, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Project.Location.Update.Default)); - authorizationBuilder.AddPolicy(UnitySelector.Project.Location.Update.UpdateFinalStateFields, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Project.Location.Update.UpdateFinalStateFields)); - - - // Reporting Configuration - authorizationBuilder.AddPolicy(ReportingPermissions.Configuration.Default, - policy => policy.RequireClaim(PermissionConstant, ReportingPermissions.Configuration.Default)); - authorizationBuilder.AddPolicy(ReportingPermissions.Configuration.Update, - policy => policy.RequireClaim(PermissionConstant, ReportingPermissions.Configuration.Update)); - authorizationBuilder.AddPolicy(ReportingPermissions.Configuration.Delete, - policy => policy.RequireClaim(PermissionConstant, ReportingPermissions.Configuration.Delete)); + policy => policy.AddRequirements( + new PermissionOrRequirement( + UnitySelector.Project.Location.Update.Default, + UnitySelector.Project.Summary.Update.Default, + UnitySelector.Project.Default))); } } From 3c52090763ce785127071ac7fd59d308c245180a Mon Sep 17 00:00:00 2001 From: Patrick <135162612+plavoie-BC@users.noreply.github.com> Date: Thu, 30 Apr 2026 10:36:42 -0700 Subject: [PATCH 002/223] [AB#32738] Update claim handling for user ID compatibility --- .../Assessments/AssessmentAuthorizationHandler.cs | 4 +++- .../Identity/LoginHandlers/IdentityProfileLoginBase.cs | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Assessments/AssessmentAuthorizationHandler.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Assessments/AssessmentAuthorizationHandler.cs index 3a8a4bbffd..0458eee55e 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Assessments/AssessmentAuthorizationHandler.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Assessments/AssessmentAuthorizationHandler.cs @@ -8,6 +8,7 @@ using Volo.Abp; using Volo.Abp.Authorization.Permissions; using Volo.Abp.DependencyInjection; +using Volo.Abp.Security.Claims; namespace Unity.GrantManager.Assessments; public class AssessmentAuthorizationHandler : AuthorizationHandler, ISingletonDependency @@ -81,7 +82,8 @@ protected virtual async Task CheckPolicyAsync(string permissionName, Autho { Check.NotNull(principal, nameof(principal)); - var userIdOrNull = principal.Claims?.FirstOrDefault(c => c.Type == "UserId"); + var userIdOrNull = principal.Claims?.FirstOrDefault(c => c.Type == AbpClaimTypes.UserId) + ?? principal.Claims?.FirstOrDefault(c => c.Type == "UserId"); if (userIdOrNull == null || userIdOrNull.Value.IsNullOrWhiteSpace()) { return null; diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/LoginHandlers/IdentityProfileLoginBase.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/LoginHandlers/IdentityProfileLoginBase.cs index f152afb7f8..4ca8271e8b 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/LoginHandlers/IdentityProfileLoginBase.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/LoginHandlers/IdentityProfileLoginBase.cs @@ -29,6 +29,7 @@ protected static void AssignDefaultClaims(ClaimsPrincipal claimsPrinicipal, stri { claimsPrinicipal.AddClaim("DisplayName", displayName); claimsPrinicipal.AddClaim(AbpClaimTypes.UserId, userId.ToString()); + claimsPrinicipal.AddClaim("UserId", userId.ToString()); // Legacy claim for backward compatibility claimsPrinicipal.AddClaim("Badge", Utils.CreateUserBadge(displayName)); } From 68192ca4e76dfd96bfe9e5e1c4774ef2de70f826 Mon Sep 17 00:00:00 2001 From: Patrick <135162612+plavoie-BC@users.noreply.github.com> Date: Thu, 30 Apr 2026 10:36:54 -0700 Subject: [PATCH 003/223] [AB#32738] Simplify permission check logic in handler Co-authored-by: Copilot --- .../Authorization/PermissionOrAuthorizationHandler.cs | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/Authorization/PermissionOrAuthorizationHandler.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/Authorization/PermissionOrAuthorizationHandler.cs index 7adb9b86e1..90f1034f54 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/Authorization/PermissionOrAuthorizationHandler.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/Authorization/PermissionOrAuthorizationHandler.cs @@ -1,3 +1,4 @@ +using System.Linq; using Microsoft.AspNetCore.Authorization; using System.Threading.Tasks; using Volo.Abp.Authorization.Permissions; @@ -28,13 +29,11 @@ protected override async Task HandleRequirementAsync( AuthorizationHandlerContext context, PermissionOrRequirement requirement) { - foreach (var permission in requirement.Permissions) + var result = await _permissionChecker.IsGrantedAsync(context.User, requirement.Permissions); + + if (result.Result.Any(r => r.Value == PermissionGrantResult.Granted)) { - if (await _permissionChecker.IsGrantedAsync(context.User, permission)) - { - context.Succeed(requirement); - return; - } + context.Succeed(requirement); } } } From cad2330125bacf5f7e356e7f51b52c74ccb99cc3 Mon Sep 17 00:00:00 2001 From: Patrick <135162612+plavoie-BC@users.noreply.github.com> Date: Thu, 30 Apr 2026 10:54:35 -0700 Subject: [PATCH 004/223] [AB#32738] Seed host-level ITAdmin/ITOps permissions Co-authored-by: Copilot --- .../Permissions/PermissionGrantsDataSeeder.cs | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Permissions/PermissionGrantsDataSeeder.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Permissions/PermissionGrantsDataSeeder.cs index d92d21a3dd..d2678d8078 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Permissions/PermissionGrantsDataSeeder.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Permissions/PermissionGrantsDataSeeder.cs @@ -4,6 +4,7 @@ using Unity.Flex.Permissions; using Unity.GrantManager.Identity; using Unity.Modules.Shared; +using Unity.Modules.Shared.Permissions; using Unity.Notifications.Permissions; using Unity.Payments.Permissions; using Volo.Abp.Authorization.Permissions; @@ -99,6 +100,12 @@ public PermissionGrantsDataSeeder(IPermissionDataSeeder permissionDataSeeder) public async Task SeedAsync(DataSeedContext context) { + if (context.TenantId == null) + { + await SeedHostPermissionsAsync(); + return; + } + // Default permission grants based on role // - Program Manager @@ -332,6 +339,30 @@ await _permissionDataSeeder.SeedAsync(RolePermissionValueProvider.ProviderName, ], context.TenantId); } + + private async Task SeedHostPermissionsAsync() + { + // ITAdministrator host-level permissions (previously stamped at login) + await _permissionDataSeeder.SeedAsync(RolePermissionValueProvider.ProviderName, IdentityConsts.ITAdminRoleName, + [ + "UnityTenantManagement.Tenants", + "UnityTenantManagement.Tenants.Create", + "UnityTenantManagement.Tenants.Update", + "UnityTenantManagement.Tenants.Delete", + "AbpTenantManagement.Tenants.ManageFeatures", + "UnityTenantManagement.Tenants.ManageConnectionStrings", + IdentitySeedPermissions.Users.Create, + IdentitySeedPermissions.UserLookup.Default, + IdentityConsts.ITAdminPermissionName + ], tenantId: null); + + // ITOperations host-level permissions (previously stamped at login) + await _permissionDataSeeder.SeedAsync(RolePermissionValueProvider.ProviderName, IdentityConsts.ITOperationsRoleName, + [ + GrantManagerPermissions.Endpoints.ManageEndpoints, + IdentityConsts.ITOperationsPermissionName + ], tenantId: null); + } } } From 3c404b8dc01d99127c2f2085f5dccf7b2ee7f94e Mon Sep 17 00:00:00 2001 From: Patrick <135162612+plavoie-BC@users.noreply.github.com> Date: Thu, 30 Apr 2026 11:07:59 -0700 Subject: [PATCH 005/223] [AB#32738] Add role claim for ITAdmin in login handler Co-authored-by: Copilot --- .../Identity/LoginHandlers/IdentityProfileLoginAdminHandler.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/LoginHandlers/IdentityProfileLoginAdminHandler.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/LoginHandlers/IdentityProfileLoginAdminHandler.cs index 3949150b68..8b0ff2641d 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/LoginHandlers/IdentityProfileLoginAdminHandler.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/LoginHandlers/IdentityProfileLoginAdminHandler.cs @@ -5,9 +5,11 @@ using System.Security.Claims; using System.Threading.Tasks; using Unity.GrantManager.Identity; +using Unity.Modules.Shared.Permissions; using Volo.Abp; using Volo.Abp.Data; using Volo.Abp.Identity; +using Volo.Abp.Security.Claims; namespace Unity.GrantManager.Web.Identity.LoginHandlers { @@ -29,6 +31,7 @@ internal async Task Handle(TokenValidatedContext validated } AssignDefaultClaims(validatedTokenContext.Principal!, userTenantAccount.DisplayName ?? string.Empty, userTenantAccount.Id); + (validatedTokenContext.Principal!.Identity as ClaimsIdentity)?.AddClaim(new Claim(AbpClaimTypes.Role, IdentityConsts.ITAdminRoleName)); return userTenantAccount; } From bf8761c83b0092a3f6802977680682ab37d77095 Mon Sep 17 00:00:00 2001 From: Patrick <135162612+plavoie-BC@users.noreply.github.com> Date: Thu, 30 Apr 2026 11:37:59 -0700 Subject: [PATCH 006/223] [AB#32738] Add unit tests for user ID claim handling --- .../Assessments/FindUserIdirIdTests.cs | 131 ++++++++++++++ .../PermissionOrAuthorizationHandlerTests.cs | 166 ++++++++++++++++++ ...leOrPermissionAuthorizationHandlerTests.cs | 145 +++++++++++++++ 3 files changed, 442 insertions(+) create mode 100644 applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Assessments/FindUserIdirIdTests.cs create mode 100644 applications/Unity.GrantManager/test/Unity.GrantManager.Web.Tests/Identity/Authorization/PermissionOrAuthorizationHandlerTests.cs create mode 100644 applications/Unity.GrantManager/test/Unity.GrantManager.Web.Tests/Identity/Authorization/RoleOrPermissionAuthorizationHandlerTests.cs diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Assessments/FindUserIdirIdTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Assessments/FindUserIdirIdTests.cs new file mode 100644 index 0000000000..8c0caf808e --- /dev/null +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Assessments/FindUserIdirIdTests.cs @@ -0,0 +1,131 @@ +using System; +using System.Security.Claims; +using Shouldly; +using Unity.GrantManager.Assessments; +using Volo.Abp.Security.Claims; +using Xunit; + +namespace Unity.GrantManager.Assessments; + +public class FindUserIdirIdTests +{ + private static ClaimsPrincipal CreatePrincipal(params Claim[] claims) + { + var identity = new ClaimsIdentity("TestAuth"); + identity.AddClaims(claims); + return new ClaimsPrincipal(identity); + } + + [Fact] + public void ShouldReturnGuid_WhenAbpUserIdClaimPresent() + { + // Arrange + var userId = Guid.NewGuid(); + var principal = CreatePrincipal( + new Claim(AbpClaimTypes.UserId, userId.ToString())); + + // Act + var result = AssessmentAuthorizationHandler.FindUserIdirId(principal); + + // Assert + result.ShouldBe(userId); + } + + [Fact] + public void ShouldReturnGuid_WhenLegacyUserIdClaimPresent() + { + // Arrange + var userId = Guid.NewGuid(); + var principal = CreatePrincipal( + new Claim("UserId", userId.ToString())); + + // Act + var result = AssessmentAuthorizationHandler.FindUserIdirId(principal); + + // Assert + result.ShouldBe(userId); + } + + [Fact] + public void ShouldPreferAbpClaim_WhenBothPresent() + { + // Arrange + var abpUserId = Guid.NewGuid(); + var legacyUserId = Guid.NewGuid(); + var principal = CreatePrincipal( + new Claim(AbpClaimTypes.UserId, abpUserId.ToString()), + new Claim("UserId", legacyUserId.ToString())); + + // Act + var result = AssessmentAuthorizationHandler.FindUserIdirId(principal); + + // Assert + result.ShouldBe(abpUserId); + } + + [Fact] + public void ShouldFallbackToLegacy_WhenAbpClaimMissing() + { + // Arrange + var legacyUserId = Guid.NewGuid(); + var principal = CreatePrincipal( + new Claim("DisplayName", "Test User"), + new Claim("UserId", legacyUserId.ToString())); + + // Act + var result = AssessmentAuthorizationHandler.FindUserIdirId(principal); + + // Assert + result.ShouldBe(legacyUserId); + } + + [Fact] + public void ShouldReturnNull_WhenNeitherClaimPresent() + { + // Arrange + var principal = CreatePrincipal( + new Claim("DisplayName", "Test User")); + + // Act + var result = AssessmentAuthorizationHandler.FindUserIdirId(principal); + + // Assert + result.ShouldBeNull(); + } + + [Fact] + public void ShouldReturnNull_WhenClaimValueIsEmpty() + { + // Arrange + var principal = CreatePrincipal( + new Claim(AbpClaimTypes.UserId, ""), + new Claim("UserId", "")); + + // Act + var result = AssessmentAuthorizationHandler.FindUserIdirId(principal); + + // Assert + result.ShouldBeNull(); + } + + [Fact] + public void ShouldReturnNull_WhenClaimValueIsNotValidGuid() + { + // Arrange + var principal = CreatePrincipal( + new Claim(AbpClaimTypes.UserId, "not-a-guid")); + + // Act + var result = AssessmentAuthorizationHandler.FindUserIdirId(principal); + + // Assert + result.ShouldBeNull(); + } + + [Fact] + public void ShouldThrow_WhenPrincipalIsNull() + { + Should.Throw(() => + AssessmentAuthorizationHandler.FindUserIdirId(null!)); + } +} diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Web.Tests/Identity/Authorization/PermissionOrAuthorizationHandlerTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Web.Tests/Identity/Authorization/PermissionOrAuthorizationHandlerTests.cs new file mode 100644 index 0000000000..afe984730c --- /dev/null +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Web.Tests/Identity/Authorization/PermissionOrAuthorizationHandlerTests.cs @@ -0,0 +1,166 @@ +using System.Security.Claims; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using NSubstitute; +using Shouldly; +using Unity.GrantManager.Web.Identity.Authorization; +using Volo.Abp.Authorization.Permissions; +using Xunit; + +namespace Unity.GrantManager.Identity.Authorization; + +public class PermissionOrAuthorizationHandlerTests +{ + private readonly IPermissionChecker _permissionChecker; + private readonly PermissionOrAuthorizationHandler _handler; + + public PermissionOrAuthorizationHandlerTests() + { + _permissionChecker = Substitute.For(); + _handler = new PermissionOrAuthorizationHandler(_permissionChecker); + } + + private static AuthorizationHandlerContext CreateContext( + ClaimsPrincipal user, + PermissionOrRequirement requirement) + { + return new AuthorizationHandlerContext( + [requirement], + user, + resource: null); + } + + private static ClaimsPrincipal CreateUser() + { + var identity = new ClaimsIdentity("TestAuth"); + return new ClaimsPrincipal(identity); + } + + [Fact] + public async Task HandleAsync_ShouldSucceed_WhenAnyPermissionIsGranted() + { + // Arrange + var user = CreateUser(); + var permissions = new[] { "Perm.A", "Perm.B", "Perm.C" }; + var requirement = new PermissionOrRequirement(permissions); + + var grantResult = new MultiplePermissionGrantResult(); + grantResult.Result.Add("Perm.A", PermissionGrantResult.Prohibited); + grantResult.Result.Add("Perm.B", PermissionGrantResult.Granted); + grantResult.Result.Add("Perm.C", PermissionGrantResult.Prohibited); + + _permissionChecker.IsGrantedAsync(user, permissions) + .Returns(grantResult); + + var context = CreateContext(user, requirement); + + // Act + await _handler.HandleAsync(context); + + // Assert + context.HasSucceeded.ShouldBeTrue(); + } + + [Fact] + public async Task HandleAsync_ShouldNotSucceed_WhenNoPermissionsGranted() + { + // Arrange + var user = CreateUser(); + var permissions = new[] { "Perm.A", "Perm.B" }; + var requirement = new PermissionOrRequirement(permissions); + + var grantResult = new MultiplePermissionGrantResult(); + grantResult.Result.Add("Perm.A", PermissionGrantResult.Prohibited); + grantResult.Result.Add("Perm.B", PermissionGrantResult.Prohibited); + + _permissionChecker.IsGrantedAsync(user, permissions) + .Returns(grantResult); + + var context = CreateContext(user, requirement); + + // Act + await _handler.HandleAsync(context); + + // Assert + context.HasSucceeded.ShouldBeFalse(); + } + + [Fact] + public async Task HandleAsync_ShouldSucceed_WhenAllPermissionsGranted() + { + // Arrange + var user = CreateUser(); + var permissions = new[] { "Perm.A", "Perm.B" }; + var requirement = new PermissionOrRequirement(permissions); + + var grantResult = new MultiplePermissionGrantResult(); + grantResult.Result.Add("Perm.A", PermissionGrantResult.Granted); + grantResult.Result.Add("Perm.B", PermissionGrantResult.Granted); + + _permissionChecker.IsGrantedAsync(user, permissions) + .Returns(grantResult); + + var context = CreateContext(user, requirement); + + // Act + await _handler.HandleAsync(context); + + // Assert + context.HasSucceeded.ShouldBeTrue(); + } + + [Fact] + public async Task HandleAsync_ShouldUseBatchApi_NotIndividualCalls() + { + // Arrange + var user = CreateUser(); + var permissions = new[] { "Perm.X", "Perm.Y", "Perm.Z" }; + var requirement = new PermissionOrRequirement(permissions); + + var grantResult = new MultiplePermissionGrantResult(); + grantResult.Result.Add("Perm.X", PermissionGrantResult.Prohibited); + grantResult.Result.Add("Perm.Y", PermissionGrantResult.Prohibited); + grantResult.Result.Add("Perm.Z", PermissionGrantResult.Prohibited); + + _permissionChecker.IsGrantedAsync(user, permissions) + .Returns(grantResult); + + var context = CreateContext(user, requirement); + + // Act + await _handler.HandleAsync(context); + + // Assert - single batch call, not individual calls + await _permissionChecker.Received(1).IsGrantedAsync(user, permissions); + await _permissionChecker.DidNotReceive().IsGrantedAsync(user, Arg.Any()); + } + + [Fact] + public async Task HandleAsync_ShouldNotSucceed_WhenResultIsEmpty() + { + // Arrange + var user = CreateUser(); + var permissions = new[] { "Perm.A" }; + var requirement = new PermissionOrRequirement(permissions); + + var grantResult = new MultiplePermissionGrantResult(); + + _permissionChecker.IsGrantedAsync(user, permissions) + .Returns(grantResult); + + var context = CreateContext(user, requirement); + + // Act + await _handler.HandleAsync(context); + + // Assert + context.HasSucceeded.ShouldBeFalse(); + } + + [Fact] + public void Requirement_ShouldStorePermissions() + { + var requirement = new PermissionOrRequirement("A", "B", "C"); + requirement.Permissions.ShouldBe(new[] { "A", "B", "C" }); + } +} diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Web.Tests/Identity/Authorization/RoleOrPermissionAuthorizationHandlerTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Web.Tests/Identity/Authorization/RoleOrPermissionAuthorizationHandlerTests.cs new file mode 100644 index 0000000000..399c8146df --- /dev/null +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Web.Tests/Identity/Authorization/RoleOrPermissionAuthorizationHandlerTests.cs @@ -0,0 +1,145 @@ +using System.Security.Claims; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using NSubstitute; +using Shouldly; +using Unity.GrantManager.Web.Identity.Authorization; +using Volo.Abp.Authorization.Permissions; +using Xunit; + +namespace Unity.GrantManager.Identity.Authorization; + +public class RoleOrPermissionAuthorizationHandlerTests +{ + private readonly IPermissionChecker _permissionChecker; + private readonly RoleOrPermissionAuthorizationHandler _handler; + + public RoleOrPermissionAuthorizationHandlerTests() + { + _permissionChecker = Substitute.For(); + _handler = new RoleOrPermissionAuthorizationHandler(_permissionChecker); + } + + private static AuthorizationHandlerContext CreateContext( + ClaimsPrincipal user, + RoleOrPermissionRequirement requirement) + { + return new AuthorizationHandlerContext( + [requirement], + user, + resource: null); + } + + private static ClaimsPrincipal CreateUserWithRole(string roleName) + { + var identity = new ClaimsIdentity("TestAuth"); + identity.AddClaim(new Claim(ClaimTypes.Role, roleName)); + return new ClaimsPrincipal(identity); + } + + private static ClaimsPrincipal CreateUserWithoutRole() + { + var identity = new ClaimsIdentity("TestAuth"); + return new ClaimsPrincipal(identity); + } + + [Fact] + public async Task HandleAsync_ShouldSucceed_WhenUserHasRole() + { + // Arrange + var user = CreateUserWithRole("ITAdministrator"); + var requirement = new RoleOrPermissionRequirement("ITAdministrator", "Unity.ITAdmin"); + var context = CreateContext(user, requirement); + + // Act + await _handler.HandleAsync(context); + + // Assert + context.HasSucceeded.ShouldBeTrue(); + await _permissionChecker.DidNotReceive().IsGrantedAsync( + Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task HandleAsync_ShouldSucceed_WhenUserHasPermission() + { + // Arrange + var user = CreateUserWithoutRole(); + var requirement = new RoleOrPermissionRequirement("ITAdministrator", "Unity.ITAdmin"); + + _permissionChecker.IsGrantedAsync(user, "Unity.ITAdmin") + .Returns(true); + + var context = CreateContext(user, requirement); + + // Act + await _handler.HandleAsync(context); + + // Assert + context.HasSucceeded.ShouldBeTrue(); + } + + [Fact] + public async Task HandleAsync_ShouldNotSucceed_WhenNeitherRoleNorPermission() + { + // Arrange + var user = CreateUserWithoutRole(); + var requirement = new RoleOrPermissionRequirement("ITAdministrator", "Unity.ITAdmin"); + + _permissionChecker.IsGrantedAsync(user, "Unity.ITAdmin") + .Returns(false); + + var context = CreateContext(user, requirement); + + // Act + await _handler.HandleAsync(context); + + // Assert + context.HasSucceeded.ShouldBeFalse(); + } + + [Fact] + public async Task HandleAsync_ShouldShortCircuit_WhenRoleMatches() + { + // Arrange + var user = CreateUserWithRole("ITOperations"); + var requirement = new RoleOrPermissionRequirement("ITOperations", "Unity.ITOperations"); + var context = CreateContext(user, requirement); + + // Act + await _handler.HandleAsync(context); + + // Assert - should not call permission checker at all + context.HasSucceeded.ShouldBeTrue(); + await _permissionChecker.DidNotReceive().IsGrantedAsync( + Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task HandleAsync_ShouldCheckPermission_WhenRoleDoesNotMatch() + { + // Arrange + var user = CreateUserWithRole("SomeOtherRole"); + var requirement = new RoleOrPermissionRequirement("ITAdministrator", "Unity.ITAdmin"); + + _permissionChecker.IsGrantedAsync(user, "Unity.ITAdmin") + .Returns(false); + + var context = CreateContext(user, requirement); + + // Act + await _handler.HandleAsync(context); + + // Assert + context.HasSucceeded.ShouldBeFalse(); + await _permissionChecker.Received(1).IsGrantedAsync(user, "Unity.ITAdmin"); + } + + [Fact] + public void Requirement_ShouldStoreRoleAndPermission() + { + var requirement = new RoleOrPermissionRequirement("MyRole", "MyPermission"); + requirement.RoleName.ShouldBe("MyRole"); + requirement.PermissionName.ShouldBe("MyPermission"); + } +} From f1115c4644b8f535e086d5952c1cba7f5cfabe70 Mon Sep 17 00:00:00 2001 From: Patrick <135162612+plavoie-BC@users.noreply.github.com> Date: Thu, 30 Apr 2026 13:45:20 -0700 Subject: [PATCH 007/223] [AB#32738] Enhance user ID claim handling for GUID parsing --- .../Unity.GrantManager.Web/Identity/CurrentUser.cs | 14 +++++++++++++- .../Identity/IdentityProfileLoginHandler.cs | 1 - 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/CurrentUser.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/CurrentUser.cs index 6037b9f5c3..8bf2c7fb2b 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/CurrentUser.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/CurrentUser.cs @@ -75,10 +75,22 @@ public virtual bool IsInRole(string roleName) var userClaims = _principalAccessor.Principal?.Claims; if (userClaims != null && userClaims.Any()) { + // First try the IDIR-specific GUID claim + var idirGuid = userClaims.FirstOrDefault(s => s.Type == UnityClaimsTypes.IDirUserGuid); + if (idirGuid != null && Guid.TryParse(idirGuid.Value, out var guid)) + { + return guid; + } + + // Fallback to UserId claim (strip @azureidir suffix if present) var userId = userClaims.FirstOrDefault(s => s.Type == AbpClaimTypes.UserId); if (userId != null) { - return Guid.Parse(userId.Value); + var value = userId.Value.Split('@')[0]; // Remove @azureidir suffix + if (Guid.TryParse(value, out guid)) + { + return guid; + } } } return null; diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/IdentityProfileLoginHandler.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/IdentityProfileLoginHandler.cs index bf5cf48de0..d94f32e958 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/IdentityProfileLoginHandler.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/IdentityProfileLoginHandler.cs @@ -1,7 +1,6 @@ using Microsoft.AspNetCore.Authentication.OpenIdConnect; using Microsoft.Extensions.DependencyInjection; using OpenIddict.Abstractions; -using System; using System.Collections.Generic; using System.Linq; using System.Security.Claims; From 1db4b227f9612c51dbb67912a10c1e571aa7262c Mon Sep 17 00:00:00 2001 From: JamesPasta Date: Thu, 7 May 2026 13:01:51 -0700 Subject: [PATCH 008/223] feature/AB#32049-Promethious --- .../Unity.GrantManager/docker-compose.yml | 21 +++++ .../scripts/prometheus/alert-rules.yml | 31 ++++++ .../scripts/prometheus/alertmanager.yml | 15 +++ .../scripts/prometheus/prometheus.yml | 17 ++++ .../Controllers/Monitoring/AlertPayload.cs | 38 ++++++++ .../Monitoring/AlertWebhookController.cs | 94 +++++++++++++++++++ .../GrantManagerWebModule.cs | 3 + .../Middleware/ErrorCountingLoggerProvider.cs | 34 +++++++ .../Middleware/ExceptionCounterMiddleware.cs | 74 +++++++++++++++ .../src/Unity.GrantManager.Web/Program.cs | 17 +++- .../Unity.GrantManager.Web.csproj | 6 ++ 11 files changed, 349 insertions(+), 1 deletion(-) create mode 100644 applications/Unity.GrantManager/scripts/prometheus/alert-rules.yml create mode 100644 applications/Unity.GrantManager/scripts/prometheus/alertmanager.yml create mode 100644 applications/Unity.GrantManager/scripts/prometheus/prometheus.yml create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Web/Controllers/Monitoring/AlertPayload.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Web/Controllers/Monitoring/AlertWebhookController.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/ErrorCountingLoggerProvider.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/ExceptionCounterMiddleware.cs diff --git a/applications/Unity.GrantManager/docker-compose.yml b/applications/Unity.GrantManager/docker-compose.yml index a3015c37e3..720f9574d6 100644 --- a/applications/Unity.GrantManager/docker-compose.yml +++ b/applications/Unity.GrantManager/docker-compose.yml @@ -146,6 +146,27 @@ services: networks: - common-network + prometheus: + image: prom/prometheus:latest + ports: + - "9090:9090" + volumes: + - ./scripts/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro + - ./scripts/prometheus/alert-rules.yml:/etc/prometheus/alert-rules.yml:ro + depends_on: + - unity-grantmanager-web + networks: + - common-network + + alertmanager: + image: prom/alertmanager:latest + ports: + - "9093:9093" + volumes: + - ./scripts/prometheus/alertmanager.yml:/etc/alertmanager/alertmanager.yml:ro + networks: + - common-network + volumes: postgres_data: redis_volume_data: diff --git a/applications/Unity.GrantManager/scripts/prometheus/alert-rules.yml b/applications/Unity.GrantManager/scripts/prometheus/alert-rules.yml new file mode 100644 index 0000000000..b53ad823d8 --- /dev/null +++ b/applications/Unity.GrantManager/scripts/prometheus/alert-rules.yml @@ -0,0 +1,31 @@ +groups: + - name: unity-grantmanager-exceptions + rules: + # Fire if any exception type exceeds 5 occurrences in a 5-minute window + - alert: HighExceptionRate + expr: | + increase(application_exceptions_total[5m]) > 5 + for: 1m + labels: + severity: critical + annotations: + summary: "High exception rate in Unity GrantManager" + description: > + Exception type {{ $labels.type }} has fired {{ $value | humanize }} times + in the last 5 minutes (namespace: {{ $labels.kubernetes_namespace_name }}). + + # Fire if any new exception type appears (catches regressions) + - alert: NewExceptionType + expr: | + increase(application_exceptions_total[10m]) > 0 + unless ( + increase(application_exceptions_total[10m] offset 10m) > 0 + ) + for: 0m + labels: + severity: warning + annotations: + summary: "New exception type detected in Unity GrantManager" + description: > + A new exception type {{ $labels.type }} appeared for the first time + in the last 10 minutes. diff --git a/applications/Unity.GrantManager/scripts/prometheus/alertmanager.yml b/applications/Unity.GrantManager/scripts/prometheus/alertmanager.yml new file mode 100644 index 0000000000..14af60fdd9 --- /dev/null +++ b/applications/Unity.GrantManager/scripts/prometheus/alertmanager.yml @@ -0,0 +1,15 @@ +global: + resolve_timeout: 5m + +route: + group_by: ["alertname", "type"] + group_wait: 10s + group_interval: 5m + repeat_interval: 1h + receiver: unity-webhook + +receivers: + - name: unity-webhook + webhook_configs: + - url: "http://unity-grantmanager-web:80/api/monitoring/alert" + send_resolved: false diff --git a/applications/Unity.GrantManager/scripts/prometheus/prometheus.yml b/applications/Unity.GrantManager/scripts/prometheus/prometheus.yml new file mode 100644 index 0000000000..1acc88d37d --- /dev/null +++ b/applications/Unity.GrantManager/scripts/prometheus/prometheus.yml @@ -0,0 +1,17 @@ +global: + scrape_interval: 15s + evaluation_interval: 15s + +alerting: + alertmanagers: + - static_configs: + - targets: ["alertmanager:9093"] + +rule_files: + - /etc/prometheus/alert-rules.yml + +scrape_configs: + - job_name: unity-grantmanager + static_configs: + - targets: ["unity-grantmanager-web:80"] + metrics_path: /metrics diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Controllers/Monitoring/AlertPayload.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Controllers/Monitoring/AlertPayload.cs new file mode 100644 index 0000000000..69b3c758d3 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Controllers/Monitoring/AlertPayload.cs @@ -0,0 +1,38 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Unity.GrantManager.Web.Controllers.Monitoring; + +public class AlertManagerPayload +{ + [JsonPropertyName("receiver")] + public string Receiver { get; set; } = string.Empty; + + [JsonPropertyName("status")] + public string Status { get; set; } = string.Empty; + + [JsonPropertyName("alerts")] + public List Alerts { get; set; } = []; +} + +public class AlertItem +{ + [JsonPropertyName("status")] + public string Status { get; set; } = string.Empty; + + [JsonPropertyName("labels")] + public Dictionary Labels { get; set; } = []; + + [JsonPropertyName("annotations")] + public Dictionary Annotations { get; set; } = []; + + [JsonPropertyName("startsAt")] + public DateTimeOffset StartsAt { get; set; } + + [JsonPropertyName("generatorURL")] + public string GeneratorURL { get; set; } = string.Empty; + + [JsonPropertyName("fingerprint")] + public string Fingerprint { get; set; } = string.Empty; +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Controllers/Monitoring/AlertWebhookController.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Controllers/Monitoring/AlertWebhookController.cs new file mode 100644 index 0000000000..499e5ab4dc --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Controllers/Monitoring/AlertWebhookController.cs @@ -0,0 +1,94 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using Unity.GrantManager.Notifications; +using Unity.Notifications.TeamsNotifications; +using Volo.Abp.AspNetCore.Mvc; + +namespace Unity.GrantManager.Web.Controllers.Monitoring; + +[ApiController] +[Route("api/monitoring")] +[AllowAnonymous] +[IgnoreAntiforgeryToken] +public class AlertWebhookController( + INotificationsAppService notificationsAppService, + ILogger logger) : AbpController +{ + /// + /// Receives Alertmanager webhook payloads and forwards a concise summary to Teams. + /// + [HttpPost("alert")] + public async Task ProcessAlert([FromBody] AlertManagerPayload? payload) + { + if (payload is null || !ModelState.IsValid || payload.Alerts.Count == 0) + { + return BadRequest(); + } + + try + { + var firing = payload.Alerts + .Where(a => a.Status.Equals("firing", StringComparison.OrdinalIgnoreCase)) + .ToList(); + + if (firing.Count == 0) + { + return Ok(); + } + + // Use the first (or most severe) alert as the headline + var lead = firing[0]; + string alertName = lead.Labels.GetValueOrDefault("alertname", "Unknown Alert"); + string severity = lead.Labels.GetValueOrDefault("severity", "unknown"); + string summary = lead.Annotations.GetValueOrDefault("summary", alertName); + string description = lead.Annotations.GetValueOrDefault("description", string.Empty); + string @namespace = lead.Labels.GetValueOrDefault("kubernetes_namespace_name", + lead.Labels.GetValueOrDefault("namespace", string.Empty)); + string endpoint = lead.Labels.GetValueOrDefault("handler", + lead.Labels.GetValueOrDefault("endpoint", string.Empty)); + string? envInfo = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT"); + + string activityTitle = $"[{severity.ToUpperInvariant()}] {summary}"; + string activitySubtitle = $"Environment: {envInfo} | Namespace: {@namespace}"; + + var facts = new List(); + + if (!string.IsNullOrEmpty(description)) + { + facts.Add(new Fact { Name = "Description", Value = description }); + } + + if (firing.Count > 1) + { + facts.Add(new Fact { Name = "Firing alerts", Value = firing.Count.ToString() }); + } + + if (!string.IsNullOrEmpty(endpoint)) + { + facts.Add(new Fact { Name = "Affected endpoint", Value = endpoint }); + } + + facts.Add(new Fact { Name = "First seen", Value = lead.StartsAt.ToString("u") }); + + if (!string.IsNullOrEmpty(lead.GeneratorURL)) + { + facts.Add(new Fact { Name = "Source", Value = lead.GeneratorURL }); + } + + await notificationsAppService.PostToTeamsAsync(activityTitle, activitySubtitle, facts); + + return Ok(); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to forward alert {AlertName} to Teams", + payload.Alerts.FirstOrDefault()?.Labels.GetValueOrDefault("alertname")); + return StatusCode(500); + } + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/GrantManagerWebModule.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/GrantManagerWebModule.cs index e6f9d5eb17..79565fc7bf 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/GrantManagerWebModule.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/GrantManagerWebModule.cs @@ -77,6 +77,7 @@ using Unity.Reporting.Web; using Unity.AI.Web; using Unity.GrantManager.Web.Views.Settings; +using Prometheus; namespace Unity.GrantManager.Web; @@ -588,6 +589,8 @@ public override void OnApplicationInitialization(ApplicationInitializationContex app.UseCorrelationId(); app.UseStaticFiles(); app.UseRouting(); + app.UseHttpMetrics(); + app.MapMetrics(); app.UseAuthentication(); if (MultiTenancyConsts.IsEnabled) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/ErrorCountingLoggerProvider.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/ErrorCountingLoggerProvider.cs new file mode 100644 index 0000000000..4fd7a5b9b8 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/ErrorCountingLoggerProvider.cs @@ -0,0 +1,34 @@ +using System; +using Microsoft.Extensions.Logging; +using Prometheus; +using Serilog.Core; +using Serilog.Events; + +namespace Unity.GrantManager.Web.Middleware; + +/// +/// Shared Prometheus counter for application-level errors. +/// Labelled by log level ("error" / "critical") and exception type (empty when no exception). +/// Implemented as a Serilog ILogEventSink so it works alongside UseSerilog(). +/// Register via: .WriteTo.Sink(new ErrorCountingLoggerSink()) +/// +public sealed class ErrorCountingLoggerSink : ILogEventSink +{ + internal static readonly Counter ErrorCounter = + Metrics.CreateCounter( + "application_errors_total", + "Total application errors captured via Serilog", + new CounterConfiguration + { + LabelNames = ["level", "exception"] + }); + + public void Emit(LogEvent logEvent) + { + if (logEvent.Level < LogEventLevel.Error) return; + + string level = logEvent.Level.ToString().ToLowerInvariant(); + string exceptionType = logEvent.Exception?.GetType().Name ?? string.Empty; + ErrorCounter.WithLabels(level, exceptionType).Inc(); + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/ExceptionCounterMiddleware.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/ExceptionCounterMiddleware.cs new file mode 100644 index 0000000000..0550455097 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/ExceptionCounterMiddleware.cs @@ -0,0 +1,74 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Prometheus; +using Unity.GrantManager.Notifications; +using Unity.Notifications.TeamsNotifications; + +namespace Unity.GrantManager.Web.Middleware; + +public class ExceptionCounterMiddleware(RequestDelegate next, INotificationsAppService notificationsAppService) +{ + private static readonly Counter ExceptionCounter = + Metrics.CreateCounter( + "application_exceptions_total", + "Total number of application exceptions", + new CounterConfiguration + { + LabelNames = ["type"] + }); + + public async Task InvokeAsync(HttpContext context) + { + try + { + await next(context); + } + catch (Exception ex) + { + ExceptionCounter.WithLabels(ex.GetType().Name).Inc(); + ErrorCountingLoggerSink.ErrorCounter.WithLabels("critical", ex.GetType().Name).Inc(); + await NotifyTeamsAsync(context, ex); + throw; + } + } + + private async Task NotifyTeamsAsync(HttpContext context, Exception ex) + { + try + { + string? env = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT"); + string endpoint = $"{context.Request.Method} {context.Request.Path}"; + + // Truncate stack trace — Teams message cards have a ~28 KB body limit + string stackTrace = ex.StackTrace ?? "(no stack trace)"; + if (stackTrace.Length > 1500) + { + stackTrace = stackTrace[..1500] + "\n... (truncated)"; + } + + string activityTitle = $"[CRITICAL] {ex.GetType().Name}"; + string activitySubtitle = $"Environment: {env} | {endpoint}"; + + var facts = new List + { + new() { Name = "Exception", Value = ex.GetType().FullName ?? ex.GetType().Name }, + new() { Name = "Message", Value = ex.Message }, + new() { Name = "Endpoint", Value = endpoint }, + new() { Name = "Stack Trace", Value = stackTrace }, + }; + + if (ex.InnerException is not null) + { + facts.Add(new Fact { Name = "Inner Exception", Value = ex.InnerException.Message }); + } + + await notificationsAppService.PostToTeamsAsync(activityTitle, activitySubtitle, facts); + } + catch + { + // Never let a Teams notification failure affect request handling + } + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Program.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Program.cs index 2f3f11cbd0..0f437e02a3 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Program.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Program.cs @@ -2,9 +2,12 @@ using Microsoft.AspNetCore.Diagnostics.HealthChecks; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; +using OpenTelemetry.Metrics; +using OpenTelemetry.Trace; using Serilog; using System; using System.Threading.Tasks; +using Unity.GrantManager.Web.Middleware; namespace Unity.GrantManager.Web; @@ -20,13 +23,25 @@ public async static Task Main(string[] args) Console.WriteLine("Starting web host."); var builder = WebApplication.CreateBuilder(args); builder.Services.AddHttpContextAccessor(); + builder.Services.AddOpenTelemetry() + .WithTracing(tracing => tracing + .AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation()) + .WithMetrics(metrics => metrics + .AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddRuntimeInstrumentation()); builder.Host.AddAppSettingsSecretsJson() .UseAutofac() .UseSerilog((hostingContext, loggerConfiguration) => - loggerConfiguration.ReadFrom.Configuration(hostingContext.Configuration)); + loggerConfiguration + .ReadFrom.Configuration(hostingContext.Configuration) + .WriteTo.Sink(new ErrorCountingLoggerSink())); await builder.AddApplicationAsync(); var app = builder.Build(); + app.UseMiddleware(); + app.MapHealthChecks("/healthz/live", new HealthCheckOptions() { Predicate = healthCheck => healthCheck.Tags.Contains("live") }); // Liveness (dumb) app.MapHealthChecks("/healthz/ready", diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Unity.GrantManager.Web.csproj b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Unity.GrantManager.Web.csproj index f44d2fc836..553a7af521 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Unity.GrantManager.Web.csproj +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Unity.GrantManager.Web.csproj @@ -76,6 +76,12 @@ + + + + + + From 865d808091f6751d1f843832573d5464cb8c8bd3 Mon Sep 17 00:00:00 2001 From: JamesPasta Date: Thu, 7 May 2026 13:04:17 -0700 Subject: [PATCH 009/223] feature/AB#32049-Promethious --- .../openshift/alertmanager-config.yaml | 29 ++++++++++++ .../scripts/openshift/prometheus-rule.yaml | 44 +++++++++++++++++++ .../scripts/openshift/service-monitor.yaml | 23 ++++++++++ 3 files changed, 96 insertions(+) create mode 100644 applications/Unity.GrantManager/scripts/openshift/alertmanager-config.yaml create mode 100644 applications/Unity.GrantManager/scripts/openshift/prometheus-rule.yaml create mode 100644 applications/Unity.GrantManager/scripts/openshift/service-monitor.yaml diff --git a/applications/Unity.GrantManager/scripts/openshift/alertmanager-config.yaml b/applications/Unity.GrantManager/scripts/openshift/alertmanager-config.yaml new file mode 100644 index 0000000000..507a0546ce --- /dev/null +++ b/applications/Unity.GrantManager/scripts/openshift/alertmanager-config.yaml @@ -0,0 +1,29 @@ +# AlertmanagerConfig CRD — routes fired alerts to the app webhook → Teams +# Deploy with: oc apply -f scripts/openshift/alertmanager-config.yaml -n d18498- +# +# Replaces: scripts/prometheus/alertmanager.yml (docker-compose local only) +# +# Prerequisites: +# The cluster Alertmanager must have alertmanagerConfigSelector set to pick up this config. +# On BC Gov Silver this is typically enabled by default in user namespaces. +apiVersion: monitoring.coreos.com/v1alpha1 +kind: AlertmanagerConfig +metadata: + name: unity-grantmanager-alerts + labels: + alertmanagerConfig: unity-grantmanager # must match Alertmanager's alertmanagerConfigSelector +spec: + route: + groupBy: ["alertname", "type"] + groupWait: 10s + groupInterval: 5m + repeatInterval: 1h + receiver: unity-webhook + + receivers: + - name: unity-webhook + webhookConfigs: + - url: "https:///api/monitoring/alert" + # Replace with the OpenShift Route hostname, e.g.: + # unity-grantmanager-web-d18498-test.apps.silver.devops.gov.bc.ca + sendResolved: false diff --git a/applications/Unity.GrantManager/scripts/openshift/prometheus-rule.yaml b/applications/Unity.GrantManager/scripts/openshift/prometheus-rule.yaml new file mode 100644 index 0000000000..d4096abc2a --- /dev/null +++ b/applications/Unity.GrantManager/scripts/openshift/prometheus-rule.yaml @@ -0,0 +1,44 @@ +# PrometheusRule CRD — loaded by the OpenShift cluster Prometheus Operator +# Deploy with: oc apply -f scripts/openshift/prometheus-rule.yaml -n d18498- +# +# Replaces: scripts/prometheus/alert-rules.yml (docker-compose local only) +apiVersion: monitoring.coreos.com/v1 +kind: PrometheusRule +metadata: + name: unity-grantmanager-exceptions + labels: + # These labels must match the Prometheus Operator's ruleSelector in your namespace. + # On BC Gov Silver cluster the label below is standard. + role: alert-rules +spec: + groups: + - name: unity-grantmanager-exceptions + rules: + # Fire if any exception type exceeds 5 occurrences in a 5-minute window + - alert: HighExceptionRate + expr: | + increase(application_exceptions_total[5m]) > 5 + for: 1m + labels: + severity: critical + annotations: + summary: "High exception rate in Unity GrantManager" + description: > + Exception type {{ $labels.type }} has fired {{ $value | humanize }} times + in the last 5 minutes (namespace: {{ $labels.namespace }}). + + # Fire if a new exception type appears (catches regressions after deploys) + - alert: NewExceptionType + expr: | + increase(application_exceptions_total[10m]) > 0 + unless ( + increase(application_exceptions_total[10m] offset 10m) > 0 + ) + for: 0m + labels: + severity: warning + annotations: + summary: "New exception type detected in Unity GrantManager" + description: > + A new exception type {{ $labels.type }} appeared for the first time + in the last 10 minutes (namespace: {{ $labels.namespace }}). diff --git a/applications/Unity.GrantManager/scripts/openshift/service-monitor.yaml b/applications/Unity.GrantManager/scripts/openshift/service-monitor.yaml new file mode 100644 index 0000000000..f5428da670 --- /dev/null +++ b/applications/Unity.GrantManager/scripts/openshift/service-monitor.yaml @@ -0,0 +1,23 @@ +# ServiceMonitor CRD — tells the Prometheus Operator how to scrape /metrics from the app +# Deploy with: oc apply -f scripts/openshift/service-monitor.yaml -n d18498- +# +# Replaces: scrape_configs in scripts/prometheus/prometheus.yml (docker-compose local only) +# +# Prerequisites: +# The app Service must exist and expose port 8080 (or 80). +# Adjust 'port' below to match your Service's named port. +apiVersion: monitoring.coreos.com/v1 +kind: ServiceMonitor +metadata: + name: unity-grantmanager + labels: + app: unity-grantmanager +spec: + selector: + matchLabels: + app: unity-grantmanager # must match labels on your OpenShift Service + endpoints: + - port: http # named port on the Service pointing to 8080 + path: /metrics + interval: 15s + scheme: http From 428fa0b68082667504fd850df941c6024db88f09 Mon Sep 17 00:00:00 2001 From: JamesPasta Date: Thu, 7 May 2026 13:15:47 -0700 Subject: [PATCH 010/223] feature/AB#32049-Promethious --- .../Unity.GrantManager.Web/Unity.GrantManager.Web.csproj | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Unity.GrantManager.Web.csproj b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Unity.GrantManager.Web.csproj index 553a7af521..4d503c0820 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Unity.GrantManager.Web.csproj +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Unity.GrantManager.Web.csproj @@ -78,10 +78,11 @@ - - - - + + + + + From d40bb76892779a1c7f9a546fc9044718199bd9ff Mon Sep 17 00:00:00 2001 From: JamesPasta Date: Thu, 7 May 2026 13:31:52 -0700 Subject: [PATCH 011/223] feature/AB#32049-Promethious --- .../openshift/alertmanager-config.yaml | 29 -------- .../scripts/prometheus/alert-rules.yml | 4 +- .../scripts/prometheus/alertmanager.yml | 2 +- .../scripts/prometheus/prometheus.yml | 2 +- .../Controllers/Monitoring/AlertPayload.cs | 23 ++++++- .../Monitoring/AlertWebhookController.cs | 17 ++++- .../GrantManagerWebModule.cs | 3 +- .../Identity/InternalNetworkRequirement.cs | 69 +++++++++++++++++++ .../Identity/PolicyRegistrant.cs | 5 ++ .../src/Unity.GrantManager.Web/Program.cs | 6 +- 10 files changed, 117 insertions(+), 43 deletions(-) create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/InternalNetworkRequirement.cs diff --git a/applications/Unity.GrantManager/scripts/openshift/alertmanager-config.yaml b/applications/Unity.GrantManager/scripts/openshift/alertmanager-config.yaml index 507a0546ce..e69de29bb2 100644 --- a/applications/Unity.GrantManager/scripts/openshift/alertmanager-config.yaml +++ b/applications/Unity.GrantManager/scripts/openshift/alertmanager-config.yaml @@ -1,29 +0,0 @@ -# AlertmanagerConfig CRD — routes fired alerts to the app webhook → Teams -# Deploy with: oc apply -f scripts/openshift/alertmanager-config.yaml -n d18498- -# -# Replaces: scripts/prometheus/alertmanager.yml (docker-compose local only) -# -# Prerequisites: -# The cluster Alertmanager must have alertmanagerConfigSelector set to pick up this config. -# On BC Gov Silver this is typically enabled by default in user namespaces. -apiVersion: monitoring.coreos.com/v1alpha1 -kind: AlertmanagerConfig -metadata: - name: unity-grantmanager-alerts - labels: - alertmanagerConfig: unity-grantmanager # must match Alertmanager's alertmanagerConfigSelector -spec: - route: - groupBy: ["alertname", "type"] - groupWait: 10s - groupInterval: 5m - repeatInterval: 1h - receiver: unity-webhook - - receivers: - - name: unity-webhook - webhookConfigs: - - url: "https:///api/monitoring/alert" - # Replace with the OpenShift Route hostname, e.g.: - # unity-grantmanager-web-d18498-test.apps.silver.devops.gov.bc.ca - sendResolved: false diff --git a/applications/Unity.GrantManager/scripts/prometheus/alert-rules.yml b/applications/Unity.GrantManager/scripts/prometheus/alert-rules.yml index b53ad823d8..c8ec1ad6e6 100644 --- a/applications/Unity.GrantManager/scripts/prometheus/alert-rules.yml +++ b/applications/Unity.GrantManager/scripts/prometheus/alert-rules.yml @@ -12,9 +12,7 @@ groups: summary: "High exception rate in Unity GrantManager" description: > Exception type {{ $labels.type }} has fired {{ $value | humanize }} times - in the last 5 minutes (namespace: {{ $labels.kubernetes_namespace_name }}). - - # Fire if any new exception type appears (catches regressions) + in the last 5 minutes (job: {{ $labels.job }}, instance: {{ $labels.instance }}). - alert: NewExceptionType expr: | increase(application_exceptions_total[10m]) > 0 diff --git a/applications/Unity.GrantManager/scripts/prometheus/alertmanager.yml b/applications/Unity.GrantManager/scripts/prometheus/alertmanager.yml index 14af60fdd9..f313fbf3b7 100644 --- a/applications/Unity.GrantManager/scripts/prometheus/alertmanager.yml +++ b/applications/Unity.GrantManager/scripts/prometheus/alertmanager.yml @@ -11,5 +11,5 @@ route: receivers: - name: unity-webhook webhook_configs: - - url: "http://unity-grantmanager-web:80/api/monitoring/alert" + - url: "http://unity-grantmanager-web:8080/api/monitoring/alert" send_resolved: false diff --git a/applications/Unity.GrantManager/scripts/prometheus/prometheus.yml b/applications/Unity.GrantManager/scripts/prometheus/prometheus.yml index 1acc88d37d..4bc80b2be4 100644 --- a/applications/Unity.GrantManager/scripts/prometheus/prometheus.yml +++ b/applications/Unity.GrantManager/scripts/prometheus/prometheus.yml @@ -13,5 +13,5 @@ rule_files: scrape_configs: - job_name: unity-grantmanager static_configs: - - targets: ["unity-grantmanager-web:80"] + - targets: ["unity-grantmanager-web:8080"] metrics_path: /metrics diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Controllers/Monitoring/AlertPayload.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Controllers/Monitoring/AlertPayload.cs index 69b3c758d3..75a9c2bfa0 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Controllers/Monitoring/AlertPayload.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Controllers/Monitoring/AlertPayload.cs @@ -12,8 +12,14 @@ public class AlertManagerPayload [JsonPropertyName("status")] public string Status { get; set; } = string.Empty; + private List _alerts = []; + [JsonPropertyName("alerts")] - public List Alerts { get; set; } = []; + public List Alerts + { + get => _alerts; + set => _alerts = value ?? []; + } } public class AlertItem @@ -21,11 +27,22 @@ public class AlertItem [JsonPropertyName("status")] public string Status { get; set; } = string.Empty; + private Dictionary _labels = []; + private Dictionary _annotations = []; + [JsonPropertyName("labels")] - public Dictionary Labels { get; set; } = []; + public Dictionary Labels + { + get => _labels; + set => _labels = value ?? []; + } [JsonPropertyName("annotations")] - public Dictionary Annotations { get; set; } = []; + public Dictionary Annotations + { + get => _annotations; + set => _annotations = value ?? []; + } [JsonPropertyName("startsAt")] public DateTimeOffset StartsAt { get; set; } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Controllers/Monitoring/AlertWebhookController.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Controllers/Monitoring/AlertWebhookController.cs index 499e5ab4dc..6d655ca17d 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Controllers/Monitoring/AlertWebhookController.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Controllers/Monitoring/AlertWebhookController.cs @@ -33,7 +33,7 @@ public async Task ProcessAlert([FromBody] AlertManagerPayload? pa try { var firing = payload.Alerts - .Where(a => a.Status.Equals("firing", StringComparison.OrdinalIgnoreCase)) + .Where(a => a is not null && a.Status.Equals("firing", StringComparison.OrdinalIgnoreCase)) .ToList(); if (firing.Count == 0) @@ -41,8 +41,10 @@ public async Task ProcessAlert([FromBody] AlertManagerPayload? pa return Ok(); } - // Use the first (or most severe) alert as the headline - var lead = firing[0]; + // Pick the most severe alert as the headline (critical > warning > info > unknown) + var lead = firing + .OrderBy(a => SeverityOrder(a.Labels.GetValueOrDefault("severity", "unknown"))) + .First(); string alertName = lead.Labels.GetValueOrDefault("alertname", "Unknown Alert"); string severity = lead.Labels.GetValueOrDefault("severity", "unknown"); string summary = lead.Annotations.GetValueOrDefault("summary", alertName); @@ -91,4 +93,13 @@ public async Task ProcessAlert([FromBody] AlertManagerPayload? pa return StatusCode(500); } } + + private static int SeverityOrder(string severity) => severity.ToLowerInvariant() switch + { + "critical" => 0, + "error" => 1, + "warning" => 2, + "info" => 3, + _ => 4 + }; } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/GrantManagerWebModule.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/GrantManagerWebModule.cs index 79565fc7bf..5b6a2d1b38 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/GrantManagerWebModule.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/GrantManagerWebModule.cs @@ -277,6 +277,7 @@ private static void ConfgureFormsApiAuhentication(ServiceConfigurationContext co private static void ConfigurePolicies(ServiceConfigurationContext context) { + context.Services.AddScoped(); PolicyRegistrant.Register(context); } @@ -590,7 +591,6 @@ public override void OnApplicationInitialization(ApplicationInitializationContex app.UseStaticFiles(); app.UseRouting(); app.UseHttpMetrics(); - app.MapMetrics(); app.UseAuthentication(); if (MultiTenancyConsts.IsEnabled) @@ -600,6 +600,7 @@ public override void OnApplicationInitialization(ApplicationInitializationContex app.UseUnitOfWork(); app.UseAuthorization(); + app.MapMetrics().RequireAuthorization(Unity.GrantManager.Web.Identity.Policy.PolicyRegistrant.MetricsAccessPolicy); if (IsProfilingAllowed(env, configuration)) { app.UseMiniProfiler(); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/InternalNetworkRequirement.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/InternalNetworkRequirement.cs new file mode 100644 index 0000000000..9ba56bdf96 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/InternalNetworkRequirement.cs @@ -0,0 +1,69 @@ +using System.Net; +using System.Net.Sockets; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; + +namespace Unity.GrantManager.Web.Identity; + +/// +/// Allows access to /metrics only from loopback or RFC-1918 private addresses. +/// This permits Prometheus to scrape pod-to-pod within the OpenShift cluster +/// while blocking external callers. +/// +public class InternalNetworkRequirement : IAuthorizationRequirement { } + +public class InternalNetworkHandler(IHttpContextAccessor httpContextAccessor) + : AuthorizationHandler +{ + protected override Task HandleRequirementAsync( + AuthorizationHandlerContext context, + InternalNetworkRequirement requirement) + { + var remoteIp = httpContextAccessor.HttpContext?.Connection.RemoteIpAddress; + + if (remoteIp is null) + { + context.Fail(); + return Task.CompletedTask; + } + + // Map IPv4-in-IPv6 (::ffff:x.x.x.x) back to IPv4 for range checks + if (remoteIp.IsIPv4MappedToIPv6) + { + remoteIp = remoteIp.MapToIPv4(); + } + + if (IsAllowed(remoteIp)) + { + context.Succeed(requirement); + } + else + { + context.Fail(); + } + + return Task.CompletedTask; + } + + private static bool IsAllowed(IPAddress ip) + { + if (IPAddress.IsLoopback(ip)) return true; + + if (ip.AddressFamily == AddressFamily.InterNetwork) + { + byte[] bytes = ip.GetAddressBytes(); + + // 10.0.0.0/8 + if (bytes[0] == 10) return true; + + // 172.16.0.0/12 + if (bytes[0] == 172 && bytes[1] >= 16 && bytes[1] <= 31) return true; + + // 192.168.0.0/16 + if (bytes[0] == 192 && bytes[1] == 168) return true; + } + + return false; + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/PolicyRegistrant.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/PolicyRegistrant.cs index 10e9446c70..ea05317e48 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/PolicyRegistrant.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/PolicyRegistrant.cs @@ -12,12 +12,17 @@ namespace Unity.GrantManager.Web.Identity.Policy; internal static class PolicyRegistrant { internal const string PermissionConstant = "Permission"; + internal const string MetricsAccessPolicy = "MetricsAccess"; internal static void Register(ServiceConfigurationContext context) { // Using AddAuthorizationBuilder to register authorization services and construct policies var authorizationBuilder = context.Services.AddAuthorizationBuilder(); + // Metrics endpoint — allow only loopback / RFC-1918 (cluster-internal) callers + authorizationBuilder.AddPolicy(MetricsAccessPolicy, + policy => policy.AddRequirements(new InternalNetworkRequirement())); + // Identity Role Policies authorizationBuilder.AddPolicy(IdentityPermissions.Roles.Default, policy => policy.RequireClaim(PermissionConstant, IdentityPermissions.Roles.Default)); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Program.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Program.cs index 0f437e02a3..8920d5f3ee 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Program.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Program.cs @@ -26,11 +26,13 @@ public async static Task Main(string[] args) builder.Services.AddOpenTelemetry() .WithTracing(tracing => tracing .AddAspNetCoreInstrumentation() - .AddHttpClientInstrumentation()) + .AddHttpClientInstrumentation() + .AddOtlpExporter()) .WithMetrics(metrics => metrics .AddAspNetCoreInstrumentation() .AddHttpClientInstrumentation() - .AddRuntimeInstrumentation()); + .AddRuntimeInstrumentation() + .AddOtlpExporter()); builder.Host.AddAppSettingsSecretsJson() .UseAutofac() .UseSerilog((hostingContext, loggerConfiguration) => From da8652c982f6caedf737e009da078e764826ffa4 Mon Sep 17 00:00:00 2001 From: JamesPasta Date: Thu, 7 May 2026 13:40:29 -0700 Subject: [PATCH 012/223] feature/AB#32049-Promethious --- .../src/Unity.GrantManager.Web/GrantManagerWebModule.cs | 6 +++++- .../src/Unity.GrantManager.Web/Program.cs | 2 -- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/GrantManagerWebModule.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/GrantManagerWebModule.cs index 5b6a2d1b38..eb77d16259 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/GrantManagerWebModule.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/GrantManagerWebModule.cs @@ -589,6 +589,7 @@ public override void OnApplicationInitialization(ApplicationInitializationContex app.UseCorrelationId(); app.UseStaticFiles(); + app.UseMiddleware(); app.UseRouting(); app.UseHttpMetrics(); app.UseAuthentication(); @@ -600,7 +601,10 @@ public override void OnApplicationInitialization(ApplicationInitializationContex app.UseUnitOfWork(); app.UseAuthorization(); - app.MapMetrics().RequireAuthorization(Unity.GrantManager.Web.Identity.Policy.PolicyRegistrant.MetricsAccessPolicy); + app.UseEndpoints(endpoints => + { + endpoints.MapMetrics().RequireAuthorization(Unity.GrantManager.Web.Identity.Policy.PolicyRegistrant.MetricsAccessPolicy); + }); if (IsProfilingAllowed(env, configuration)) { app.UseMiniProfiler(); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Program.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Program.cs index 8920d5f3ee..6debc33b41 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Program.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Program.cs @@ -42,8 +42,6 @@ public async static Task Main(string[] args) await builder.AddApplicationAsync(); var app = builder.Build(); - app.UseMiddleware(); - app.MapHealthChecks("/healthz/live", new HealthCheckOptions() { Predicate = healthCheck => healthCheck.Tags.Contains("live") }); // Liveness (dumb) app.MapHealthChecks("/healthz/ready", From a9b63d03881be49bd06a7dce85ef8830b400683a Mon Sep 17 00:00:00 2001 From: JamesPasta Date: Thu, 7 May 2026 16:35:48 -0700 Subject: [PATCH 013/223] feature/AB#32049-Promethious --- .../Monitoring/AlertWebhookController.cs | 6 +- .../GrantManagerWebModule.cs | 3 +- .../Identity/InternalNetworkRequirement.cs | 2 +- ...Provider.cs => ErrorCountingLoggerSink.cs} | 4 +- .../Middleware/ExceptionCounterMiddleware.cs | 95 +++++++++----- .../ExceptionNotificationThrottle.cs | 58 +++++++++ .../Unity.GrantManager.Web.csproj | 10 +- .../Identity/InternalNetworkHandlerTests.cs | 118 ++++++++++++++++++ 8 files changed, 253 insertions(+), 43 deletions(-) rename applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/{ErrorCountingLoggerProvider.cs => ErrorCountingLoggerSink.cs} (87%) create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/ExceptionNotificationThrottle.cs create mode 100644 applications/Unity.GrantManager/test/Unity.GrantManager.Web.Tests/Identity/InternalNetworkHandlerTests.cs diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Controllers/Monitoring/AlertWebhookController.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Controllers/Monitoring/AlertWebhookController.cs index 6d655ca17d..cf49e053d9 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Controllers/Monitoring/AlertWebhookController.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Controllers/Monitoring/AlertWebhookController.cs @@ -33,7 +33,7 @@ public async Task ProcessAlert([FromBody] AlertManagerPayload? pa try { var firing = payload.Alerts - .Where(a => a is not null && a.Status.Equals("firing", StringComparison.OrdinalIgnoreCase)) + .Where(a => a is not null && string.Equals(a.Status, "firing", StringComparison.OrdinalIgnoreCase)) .ToList(); if (firing.Count == 0) @@ -89,12 +89,12 @@ public async Task ProcessAlert([FromBody] AlertManagerPayload? pa catch (Exception ex) { logger.LogError(ex, "Failed to forward alert {AlertName} to Teams", - payload.Alerts.FirstOrDefault()?.Labels.GetValueOrDefault("alertname")); + payload.Alerts.FirstOrDefault()?.Labels?.GetValueOrDefault("alertname")); return StatusCode(500); } } - private static int SeverityOrder(string severity) => severity.ToLowerInvariant() switch + private static int SeverityOrder(string? severity) => severity?.ToLowerInvariant() switch { "critical" => 0, "error" => 1, diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/GrantManagerWebModule.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/GrantManagerWebModule.cs index eb77d16259..747e6cbb9f 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/GrantManagerWebModule.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/GrantManagerWebModule.cs @@ -277,7 +277,8 @@ private static void ConfgureFormsApiAuhentication(ServiceConfigurationContext co private static void ConfigurePolicies(ServiceConfigurationContext context) { - context.Services.AddScoped(); + context.Services.AddScoped(); + context.Services.AddSingleton(); PolicyRegistrant.Register(context); } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/InternalNetworkRequirement.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/InternalNetworkRequirement.cs index 9ba56bdf96..ade7147bca 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/InternalNetworkRequirement.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/InternalNetworkRequirement.cs @@ -4,7 +4,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; -namespace Unity.GrantManager.Web.Identity; +namespace Unity.GrantManager.Web.Identity.Policy; /// /// Allows access to /metrics only from loopback or RFC-1918 private addresses. diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/ErrorCountingLoggerProvider.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/ErrorCountingLoggerSink.cs similarity index 87% rename from applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/ErrorCountingLoggerProvider.cs rename to applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/ErrorCountingLoggerSink.cs index 4fd7a5b9b8..e7380fb093 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/ErrorCountingLoggerProvider.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/ErrorCountingLoggerSink.cs @@ -1,5 +1,3 @@ -using System; -using Microsoft.Extensions.Logging; using Prometheus; using Serilog.Core; using Serilog.Events; @@ -8,7 +6,7 @@ namespace Unity.GrantManager.Web.Middleware; /// /// Shared Prometheus counter for application-level errors. -/// Labelled by log level ("error" / "critical") and exception type (empty when no exception). +/// Labelled by log level ("error" / "fatal") and exception type (empty when no exception). /// Implemented as a Serilog ILogEventSink so it works alongside UseSerilog(). /// Register via: .WriteTo.Sink(new ErrorCountingLoggerSink()) /// diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/ExceptionCounterMiddleware.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/ExceptionCounterMiddleware.cs index 0550455097..e9a33146eb 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/ExceptionCounterMiddleware.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/ExceptionCounterMiddleware.cs @@ -2,14 +2,23 @@ using System.Collections.Generic; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using Prometheus; using Unity.GrantManager.Notifications; using Unity.Notifications.TeamsNotifications; namespace Unity.GrantManager.Web.Middleware; -public class ExceptionCounterMiddleware(RequestDelegate next, INotificationsAppService notificationsAppService) +public class ExceptionCounterMiddleware( + RequestDelegate next, + ExceptionNotificationThrottle throttle, + ILogger logger) { + // Notify only in these environments; add "Staging" if desired + private static readonly HashSet NotifyEnvironments = + new(StringComparer.OrdinalIgnoreCase) { "Production" }; + private static readonly Counter ExceptionCounter = Metrics.CreateCounter( "application_exceptions_total", @@ -28,47 +37,73 @@ public async Task InvokeAsync(HttpContext context) catch (Exception ex) { ExceptionCounter.WithLabels(ex.GetType().Name).Inc(); - ErrorCountingLoggerSink.ErrorCounter.WithLabels("critical", ex.GetType().Name).Inc(); - await NotifyTeamsAsync(context, ex); + ErrorCountingLoggerSink.ErrorCounter.WithLabels("fatal", ex.GetType().Name).Inc(); + + QueueTeamsNotification(context, ex); + throw; } } - private async Task NotifyTeamsAsync(HttpContext context, Exception ex) + private void QueueTeamsNotification(HttpContext context, Exception ex) { - try + string? env = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT"); + + if (!NotifyEnvironments.Contains(env ?? string.Empty)) { - string? env = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT"); - string endpoint = $"{context.Request.Method} {context.Request.Path}"; + return; + } - // Truncate stack trace — Teams message cards have a ~28 KB body limit - string stackTrace = ex.StackTrace ?? "(no stack trace)"; - if (stackTrace.Length > 1500) - { - stackTrace = stackTrace[..1500] + "\n... (truncated)"; - } + if (!throttle.ShouldNotify(ex.GetType().Name)) + { + return; + } - string activityTitle = $"[CRITICAL] {ex.GetType().Name}"; - string activitySubtitle = $"Environment: {env} | {endpoint}"; + // Capture values from the request context before it is disposed + string endpoint = $"{context.Request.Method} {context.Request.Path}"; + string exTypeName = ex.GetType().FullName ?? ex.GetType().Name; + string exMessage = ex.Message; + string innerMessage = ex.InnerException?.Message ?? string.Empty; + string stackTrace = ex.StackTrace ?? "(no stack trace)"; + if (stackTrace.Length > 1500) + { + stackTrace = stackTrace[..1500] + "\n... (truncated)"; + } - var facts = new List + // Resolve a scoped INotificationsAppService from a fresh DI scope so + // we can safely use it after the request scope has ended + var scopeFactory = context.RequestServices.GetRequiredService(); + + _ = Task.Run(async () => + { + try { - new() { Name = "Exception", Value = ex.GetType().FullName ?? ex.GetType().Name }, - new() { Name = "Message", Value = ex.Message }, - new() { Name = "Endpoint", Value = endpoint }, - new() { Name = "Stack Trace", Value = stackTrace }, - }; + await using var scope = scopeFactory.CreateAsyncScope(); + var notifications = scope.ServiceProvider.GetRequiredService(); + + string activityTitle = $"[CRITICAL] {ex.GetType().Name}"; + string activitySubtitle = $"Environment: {env} | {endpoint}"; - if (ex.InnerException is not null) + var facts = new List + { + new() { Name = "Exception", Value = exTypeName }, + new() { Name = "Message", Value = exMessage }, + new() { Name = "Endpoint", Value = endpoint }, + new() { Name = "Stack Trace", Value = stackTrace }, + }; + + if (!string.IsNullOrEmpty(innerMessage)) + { + facts.Add(new Fact { Name = "Inner Exception", Value = innerMessage }); + } + + await notifications.PostToTeamsAsync(activityTitle, activitySubtitle, facts); + } + catch (Exception notifyEx) { - facts.Add(new Fact { Name = "Inner Exception", Value = ex.InnerException.Message }); + logger.LogWarning(notifyEx, "Failed to send Teams exception notification"); } - - await notificationsAppService.PostToTeamsAsync(activityTitle, activitySubtitle, facts); - } - catch - { - // Never let a Teams notification failure affect request handling - } + }); } } + diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/ExceptionNotificationThrottle.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/ExceptionNotificationThrottle.cs new file mode 100644 index 0000000000..1c6e1d2422 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/ExceptionNotificationThrottle.cs @@ -0,0 +1,58 @@ +using System; +using System.Collections.Concurrent; +using System.Threading; + +namespace Unity.GrantManager.Web.Middleware; + +/// +/// Singleton that tracks per-exception-type cooldowns and a global rate limit +/// to prevent Teams notification storms during an outage. +/// +public sealed class ExceptionNotificationThrottle +{ + // Only send one notification per exception type per cooldown window + private static readonly TimeSpan PerTypeCooldown = TimeSpan.FromMinutes(5); + + // Global cap: at most N notifications per rolling minute across all types + private const int GlobalMaxPerMinute = 5; + + private readonly ConcurrentDictionary _lastSent = new(); + private int _sentThisMinute; + private DateTimeOffset _windowStart = DateTimeOffset.UtcNow; + + /// + /// Returns true if a Teams notification should be sent for this exception type. + /// Thread-safe. + /// + public bool ShouldNotify(string exceptionTypeName) + { + ResetWindowIfNeeded(); + + if (_sentThisMinute >= GlobalMaxPerMinute) + { + return false; + } + + var now = DateTimeOffset.UtcNow; + + if (_lastSent.TryGetValue(exceptionTypeName, out var last) && + now - last < PerTypeCooldown) + { + return false; + } + + _lastSent[exceptionTypeName] = now; + Interlocked.Increment(ref _sentThisMinute); + return true; + } + + private void ResetWindowIfNeeded() + { + var now = DateTimeOffset.UtcNow; + if (now - _windowStart >= TimeSpan.FromMinutes(1)) + { + Interlocked.Exchange(ref _sentThisMinute, 0); + _windowStart = now; + } + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Unity.GrantManager.Web.csproj b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Unity.GrantManager.Web.csproj index 4d503c0820..ea3f46bc86 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Unity.GrantManager.Web.csproj +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Unity.GrantManager.Web.csproj @@ -78,11 +78,11 @@ - - - - - + + + + + diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Web.Tests/Identity/InternalNetworkHandlerTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Web.Tests/Identity/InternalNetworkHandlerTests.cs new file mode 100644 index 0000000000..0351767ab3 --- /dev/null +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Web.Tests/Identity/InternalNetworkHandlerTests.cs @@ -0,0 +1,118 @@ +using System.Net; +using System.Security.Claims; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using NSubstitute; +using Shouldly; +using Unity.GrantManager.Web.Identity.Policy; +using Xunit; + +namespace Unity.GrantManager.Identity; + +public class InternalNetworkHandlerTests +{ + private static Task BuildContextAsync(IPAddress remoteIp) + { + var httpContext = new DefaultHttpContext(); + httpContext.Connection.RemoteIpAddress = remoteIp; + + var httpContextAccessor = Substitute.For(); + httpContextAccessor.HttpContext.Returns(httpContext); + + var requirement = new InternalNetworkRequirement(); + var authContext = new AuthorizationHandlerContext( + [requirement], + new ClaimsPrincipal(), + null); + + var handler = new InternalNetworkHandler(httpContextAccessor); + return handler.HandleAsync(authContext).ContinueWith(_ => authContext); + } + + [Theory] + [InlineData("127.0.0.1")] // IPv4 loopback + [InlineData("::1")] // IPv6 loopback + [InlineData("10.0.0.1")] // 10/8 start + [InlineData("10.255.255.255")] // 10/8 end + [InlineData("172.16.0.1")] // 172.16/12 start + [InlineData("172.31.255.255")] // 172.16/12 end + [InlineData("192.168.0.1")] // 192.168/16 start + [InlineData("192.168.255.255")] // 192.168/16 end + public async Task Allows_InternalAddresses(string ip) + { + var ctx = await BuildContextAsync(IPAddress.Parse(ip)); + ctx.HasSucceeded.ShouldBeTrue($"{ip} should be allowed"); + } + + [Theory] + [InlineData("8.8.8.8")] // public internet + [InlineData("172.15.255.255")] // just below 172.16/12 + [InlineData("172.32.0.0")] // just above 172.16/12 + [InlineData("192.167.255.255")] // just below 192.168/16 + [InlineData("192.169.0.0")] // just above 192.168/16 + [InlineData("11.0.0.0")] // not 10/8 + [InlineData("203.0.113.1")] // TEST-NET-3 (documentation range) + public async Task Denies_ExternalAddresses(string ip) + { + var ctx = await BuildContextAsync(IPAddress.Parse(ip)); + ctx.HasSucceeded.ShouldBeFalse($"{ip} should be denied"); + } + + [Fact] + public async Task Allows_IPv4MappedToIPv6_Loopback() + { + // ::ffff:127.0.0.1 — loopback mapped into IPv6 + var ip = IPAddress.Parse("::ffff:127.0.0.1"); + var ctx = await BuildContextAsync(ip); + ctx.HasSucceeded.ShouldBeTrue("IPv4-mapped loopback should be allowed"); + } + + [Fact] + public async Task Allows_IPv4MappedToIPv6_PrivateRange() + { + // ::ffff:10.0.0.1 — private range mapped into IPv6 + var ip = IPAddress.Parse("::ffff:10.0.0.1"); + var ctx = await BuildContextAsync(ip); + ctx.HasSucceeded.ShouldBeTrue("IPv4-mapped private address should be allowed"); + } + + [Fact] + public async Task Denies_NullRemoteIp() + { + var httpContext = new DefaultHttpContext(); + // RemoteIpAddress is null by default on DefaultHttpContext + + var httpContextAccessor = Substitute.For(); + httpContextAccessor.HttpContext.Returns(httpContext); + + var requirement = new InternalNetworkRequirement(); + var authContext = new AuthorizationHandlerContext( + [requirement], + new ClaimsPrincipal(), + null); + + var handler = new InternalNetworkHandler(httpContextAccessor); + await handler.HandleAsync(authContext); + + authContext.HasSucceeded.ShouldBeFalse("null remote IP should be denied"); + } + + [Fact] + public async Task Denies_NullHttpContext() + { + var httpContextAccessor = Substitute.For(); + httpContextAccessor.HttpContext.Returns((HttpContext?)null); + + var requirement = new InternalNetworkRequirement(); + var authContext = new AuthorizationHandlerContext( + [requirement], + new ClaimsPrincipal(), + null); + + var handler = new InternalNetworkHandler(httpContextAccessor); + await handler.HandleAsync(authContext); + + authContext.HasSucceeded.ShouldBeFalse("null HttpContext should be denied"); + } +} From 2ea577204bc670f41a2f49d800f26c16051e498b Mon Sep 17 00:00:00 2001 From: JamesPasta Date: Thu, 7 May 2026 16:45:33 -0700 Subject: [PATCH 014/223] feature/AB#32049-Promethious --- .../Monitoring/AlertWebhookController.cs | 2 +- .../GrantManagerWebModule.cs | 21 ++++++++++++++++ .../src/Unity.GrantManager.Web/Program.cs | 25 ++++++++++++------- 3 files changed, 38 insertions(+), 10 deletions(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Controllers/Monitoring/AlertWebhookController.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Controllers/Monitoring/AlertWebhookController.cs index cf49e053d9..ea32f890c5 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Controllers/Monitoring/AlertWebhookController.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Controllers/Monitoring/AlertWebhookController.cs @@ -41,7 +41,7 @@ public async Task ProcessAlert([FromBody] AlertManagerPayload? pa return Ok(); } - // Pick the most severe alert as the headline (critical > warning > info > unknown) + // Pick the most severe alert as the headline (critical > error > warning > info > unknown) var lead = firing .OrderBy(a => SeverityOrder(a.Labels.GetValueOrDefault("severity", "unknown"))) .First(); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/GrantManagerWebModule.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/GrantManagerWebModule.cs index 747e6cbb9f..c3eb88b711 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/GrantManagerWebModule.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/GrantManagerWebModule.cs @@ -5,6 +5,7 @@ using Microsoft.AspNetCore.CookiePolicy; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.Localization; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -150,6 +151,22 @@ public override void ConfigureServices(ServiceConfigurationContext context) ConfigureDataProtection(context, configuration); ConfigureMiniProfiler(context, configuration); + // Trust X-Forwarded-For only from internal RFC-1918 proxies (OpenShift HAProxy router). + // This ensures RemoteIpAddress reflects the real client IP so the + // InternalNetworkHandler correctly blocks external callers reaching /metrics via ingress. + context.Services.Configure(options => + { + options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto; + options.ForwardLimit = 1; + // Clear defaults and allow the three RFC-1918 blocks plus loopback. + options.KnownProxies.Clear(); + options.KnownIPNetworks.Clear(); + options.KnownIPNetworks.Add(System.Net.IPNetwork.Parse("127.0.0.0/8")); + options.KnownIPNetworks.Add(System.Net.IPNetwork.Parse("10.0.0.0/8")); + options.KnownIPNetworks.Add(System.Net.IPNetwork.Parse("172.16.0.0/12")); + options.KnownIPNetworks.Add(System.Net.IPNetwork.Parse("192.168.0.0/16")); + }); + Configure(options => { options.TokenCookie.Expiration = TimeSpan.FromDays(365); @@ -558,6 +575,10 @@ public override void OnApplicationInitialization(ApplicationInitializationContex IdentityModelEventSource.ShowPII = true; } + // Rewrite RemoteIpAddress from X-Forwarded-For before any IP-based checks run. + // Trusted networks are configured in ConfigureServices above. + app.UseForwardedHeaders(); + app.UseAbpRequestLocalization(); if (env.IsProduction() || env.IsStaging()) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Program.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Program.cs index 6debc33b41..62474357f4 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Program.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Program.cs @@ -23,16 +23,23 @@ public async static Task Main(string[] args) Console.WriteLine("Starting web host."); var builder = WebApplication.CreateBuilder(args); builder.Services.AddHttpContextAccessor(); + bool otlpEnabled = !string.IsNullOrWhiteSpace( + Environment.GetEnvironmentVariable("OTEL_EXPORTER_OTLP_ENDPOINT")); + builder.Services.AddOpenTelemetry() - .WithTracing(tracing => tracing - .AddAspNetCoreInstrumentation() - .AddHttpClientInstrumentation() - .AddOtlpExporter()) - .WithMetrics(metrics => metrics - .AddAspNetCoreInstrumentation() - .AddHttpClientInstrumentation() - .AddRuntimeInstrumentation() - .AddOtlpExporter()); + .WithTracing(tracing => + { + tracing.AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation(); + if (otlpEnabled) tracing.AddOtlpExporter(); + }) + .WithMetrics(metrics => + { + metrics.AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddRuntimeInstrumentation(); + if (otlpEnabled) metrics.AddOtlpExporter(); + }); builder.Host.AddAppSettingsSecretsJson() .UseAutofac() .UseSerilog((hostingContext, loggerConfiguration) => From c96ea116a822d15c795fa31a3a77596f3b7de664 Mon Sep 17 00:00:00 2001 From: JamesPasta Date: Tue, 12 May 2026 09:17:22 -0700 Subject: [PATCH 015/223] feature/AB#32049-Prometheus --- .../src/Unity.GrantManager.Web/Program.cs | 19 ------------------- .../Unity.GrantManager.Web.csproj | 6 +----- 2 files changed, 1 insertion(+), 24 deletions(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Program.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Program.cs index 62474357f4..f4e92e7a2e 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Program.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Program.cs @@ -2,8 +2,6 @@ using Microsoft.AspNetCore.Diagnostics.HealthChecks; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; -using OpenTelemetry.Metrics; -using OpenTelemetry.Trace; using Serilog; using System; using System.Threading.Tasks; @@ -23,23 +21,6 @@ public async static Task Main(string[] args) Console.WriteLine("Starting web host."); var builder = WebApplication.CreateBuilder(args); builder.Services.AddHttpContextAccessor(); - bool otlpEnabled = !string.IsNullOrWhiteSpace( - Environment.GetEnvironmentVariable("OTEL_EXPORTER_OTLP_ENDPOINT")); - - builder.Services.AddOpenTelemetry() - .WithTracing(tracing => - { - tracing.AddAspNetCoreInstrumentation() - .AddHttpClientInstrumentation(); - if (otlpEnabled) tracing.AddOtlpExporter(); - }) - .WithMetrics(metrics => - { - metrics.AddAspNetCoreInstrumentation() - .AddHttpClientInstrumentation() - .AddRuntimeInstrumentation(); - if (otlpEnabled) metrics.AddOtlpExporter(); - }); builder.Host.AddAppSettingsSecretsJson() .UseAutofac() .UseSerilog((hostingContext, loggerConfiguration) => diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Unity.GrantManager.Web.csproj b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Unity.GrantManager.Web.csproj index ea3f46bc86..cc87311b1a 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Unity.GrantManager.Web.csproj +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Unity.GrantManager.Web.csproj @@ -78,11 +78,7 @@ - - - - - + From ec4bf5dabe8a42c206b9079ec926ebfb0c519c27 Mon Sep 17 00:00:00 2001 From: JamesPasta Date: Tue, 12 May 2026 09:49:33 -0700 Subject: [PATCH 016/223] feature/AB#32049-Prometheus --- .../src/Unity.GrantManager.Web/GrantManagerWebModule.cs | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/GrantManagerWebModule.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/GrantManagerWebModule.cs index c3eb88b711..72736b4ccf 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/GrantManagerWebModule.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/GrantManagerWebModule.cs @@ -623,10 +623,6 @@ public override void OnApplicationInitialization(ApplicationInitializationContex app.UseUnitOfWork(); app.UseAuthorization(); - app.UseEndpoints(endpoints => - { - endpoints.MapMetrics().RequireAuthorization(Unity.GrantManager.Web.Identity.Policy.PolicyRegistrant.MetricsAccessPolicy); - }); if (IsProfilingAllowed(env, configuration)) { app.UseMiniProfiler(); @@ -638,7 +634,10 @@ public override void OnApplicationInitialization(ApplicationInitializationContex }); app.UseAuditing(); app.UseAbpSerilogEnrichers(); - app.UseConfiguredEndpoints(); + app.UseConfiguredEndpoints(endpoints => + { + endpoints.MapMetrics().RequireAuthorization(Unity.GrantManager.Web.Identity.Policy.PolicyRegistrant.MetricsAccessPolicy); + }); var supportedCultures = new[] { From 9fabdf2f04f630d7dd9f496766dbe86c7b3be639 Mon Sep 17 00:00:00 2001 From: James Pasta <129337673+JamesPasta@users.noreply.github.com> Date: Tue, 12 May 2026 09:50:20 -0700 Subject: [PATCH 017/223] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../GrantManagerWebModule.cs | 42 +++++++++++++++---- 1 file changed, 33 insertions(+), 9 deletions(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/GrantManagerWebModule.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/GrantManagerWebModule.cs index 72736b4ccf..3a6f891dff 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/GrantManagerWebModule.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/GrantManagerWebModule.cs @@ -151,20 +151,44 @@ public override void ConfigureServices(ServiceConfigurationContext context) ConfigureDataProtection(context, configuration); ConfigureMiniProfiler(context, configuration); - // Trust X-Forwarded-For only from internal RFC-1918 proxies (OpenShift HAProxy router). - // This ensures RemoteIpAddress reflects the real client IP so the - // InternalNetworkHandler correctly blocks external callers reaching /metrics via ingress. + // Trust forwarded client IP headers only from explicitly configured ingress/router addresses. + // This ensures RemoteIpAddress reflects the real client IP only when the request came + // through a known proxy, so IP-based checks such as the /metrics policy cannot be spoofed + // by arbitrary internal callers. + var knownForwardedHeaderProxies = configuration + .GetSection("ForwardedHeaders:KnownProxies") + .Get() ?? Array.Empty(); + var knownForwardedHeaderNetworks = configuration + .GetSection("ForwardedHeaders:KnownNetworks") + .Get() ?? Array.Empty(); + context.Services.Configure(options => { - options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto; + options.ForwardedHeaders = ForwardedHeaders.XForwardedProto; options.ForwardLimit = 1; - // Clear defaults and allow the three RFC-1918 blocks plus loopback. options.KnownProxies.Clear(); options.KnownIPNetworks.Clear(); - options.KnownIPNetworks.Add(System.Net.IPNetwork.Parse("127.0.0.0/8")); - options.KnownIPNetworks.Add(System.Net.IPNetwork.Parse("10.0.0.0/8")); - options.KnownIPNetworks.Add(System.Net.IPNetwork.Parse("172.16.0.0/12")); - options.KnownIPNetworks.Add(System.Net.IPNetwork.Parse("192.168.0.0/16")); + + foreach (var proxy in knownForwardedHeaderProxies) + { + if (!string.IsNullOrWhiteSpace(proxy)) + { + options.KnownProxies.Add(System.Net.IPAddress.Parse(proxy)); + } + } + + foreach (var network in knownForwardedHeaderNetworks) + { + if (!string.IsNullOrWhiteSpace(network)) + { + options.KnownIPNetworks.Add(System.Net.IPNetwork.Parse(network)); + } + } + + if (options.KnownProxies.Count > 0 || options.KnownIPNetworks.Count > 0) + { + options.ForwardedHeaders |= ForwardedHeaders.XForwardedFor; + } }); Configure(options => From 95c94f018fba90a3c240e12e0e352874ea7bf0fd Mon Sep 17 00:00:00 2001 From: JamesPasta Date: Tue, 12 May 2026 10:08:11 -0700 Subject: [PATCH 018/223] feature/AB#32049-Prometheus --- .../Middleware/ExceptionCounterMiddleware.cs | 21 ++++++++++ .../ExceptionNotificationThrottle.cs | 39 ++++++++++--------- 2 files changed, 42 insertions(+), 18 deletions(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/ExceptionCounterMiddleware.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/ExceptionCounterMiddleware.cs index e9a33146eb..73ab8b6b02 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/ExceptionCounterMiddleware.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/ExceptionCounterMiddleware.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Reflection; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; @@ -7,6 +8,7 @@ using Prometheus; using Unity.GrantManager.Notifications; using Unity.Notifications.TeamsNotifications; +using Volo.Abp.Uow; namespace Unity.GrantManager.Web.Middleware; @@ -28,6 +30,20 @@ public class ExceptionCounterMiddleware( LabelNames = ["type"] }); + // Git SHA baked in at build time via -p:SourceRevisionId= in the Dockerfile. + // Format is "+" e.g. "1.0.0+a3f8c21"; we extract just the SHA. + private static readonly string CommitSha = ParseCommitSha( + typeof(ExceptionCounterMiddleware).Assembly + .GetCustomAttribute()? + .InformationalVersion); + + private static string ParseCommitSha(string? informationalVersion) + { + if (string.IsNullOrWhiteSpace(informationalVersion)) return "unknown"; + var plusIndex = informationalVersion.IndexOf('+'); + return plusIndex >= 0 ? informationalVersion[(plusIndex + 1)..] : informationalVersion; + } + public async Task InvokeAsync(HttpContext context) { try @@ -79,8 +95,11 @@ private void QueueTeamsNotification(HttpContext context, Exception ex) try { await using var scope = scopeFactory.CreateAsyncScope(); + var uowManager = scope.ServiceProvider.GetRequiredService(); var notifications = scope.ServiceProvider.GetRequiredService(); + using var uow = uowManager.Begin(requiresNew: true, isTransactional: false); + string activityTitle = $"[CRITICAL] {ex.GetType().Name}"; string activitySubtitle = $"Environment: {env} | {endpoint}"; @@ -90,6 +109,7 @@ private void QueueTeamsNotification(HttpContext context, Exception ex) new() { Name = "Message", Value = exMessage }, new() { Name = "Endpoint", Value = endpoint }, new() { Name = "Stack Trace", Value = stackTrace }, + new() { Name = "Commit", Value = CommitSha }, }; if (!string.IsNullOrEmpty(innerMessage)) @@ -98,6 +118,7 @@ private void QueueTeamsNotification(HttpContext context, Exception ex) } await notifications.PostToTeamsAsync(activityTitle, activitySubtitle, facts); + await uow.CompleteAsync(); } catch (Exception notifyEx) { diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/ExceptionNotificationThrottle.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/ExceptionNotificationThrottle.cs index 1c6e1d2422..f0ab071469 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/ExceptionNotificationThrottle.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/ExceptionNotificationThrottle.cs @@ -17,6 +17,9 @@ public sealed class ExceptionNotificationThrottle private const int GlobalMaxPerMinute = 5; private readonly ConcurrentDictionary _lastSent = new(); + + // _sentThisMinute and _windowStart are always accessed together under _lock + private readonly object _lock = new(); private int _sentThisMinute; private DateTimeOffset _windowStart = DateTimeOffset.UtcNow; @@ -26,33 +29,33 @@ public sealed class ExceptionNotificationThrottle /// public bool ShouldNotify(string exceptionTypeName) { - ResetWindowIfNeeded(); - - if (_sentThisMinute >= GlobalMaxPerMinute) - { - return false; - } - var now = DateTimeOffset.UtcNow; + // Per-type cooldown check — ConcurrentDictionary read is lock-free if (_lastSent.TryGetValue(exceptionTypeName, out var last) && now - last < PerTypeCooldown) { return false; } - _lastSent[exceptionTypeName] = now; - Interlocked.Increment(ref _sentThisMinute); - return true; - } - - private void ResetWindowIfNeeded() - { - var now = DateTimeOffset.UtcNow; - if (now - _windowStart >= TimeSpan.FromMinutes(1)) + lock (_lock) { - Interlocked.Exchange(ref _sentThisMinute, 0); - _windowStart = now; + // Reset the window if a full minute has elapsed + if (now - _windowStart >= TimeSpan.FromMinutes(1)) + { + _sentThisMinute = 0; + _windowStart = now; + } + + if (_sentThisMinute >= GlobalMaxPerMinute) + { + return false; + } + + _sentThisMinute++; } + + _lastSent[exceptionTypeName] = now; + return true; } } From 8cb15ad6249073b89e2ab79cfd2aed38429f104a Mon Sep 17 00:00:00 2001 From: JamesPasta Date: Tue, 12 May 2026 10:11:46 -0700 Subject: [PATCH 019/223] feature/AB#32049-Prometheus --- .../Middleware/ExceptionCounterMiddleware.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/ExceptionCounterMiddleware.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/ExceptionCounterMiddleware.cs index 73ab8b6b02..1e96b723d0 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/ExceptionCounterMiddleware.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/ExceptionCounterMiddleware.cs @@ -19,7 +19,7 @@ public class ExceptionCounterMiddleware( { // Notify only in these environments; add "Staging" if desired private static readonly HashSet NotifyEnvironments = - new(StringComparer.OrdinalIgnoreCase) { "Production" }; + new(StringComparer.OrdinalIgnoreCase) { "Production", "Test", "Development" }; private static readonly Counter ExceptionCounter = Metrics.CreateCounter( From 9802719d7138b847ac666cf1f4de1af8fcd78881 Mon Sep 17 00:00:00 2001 From: JamesPasta Date: Tue, 12 May 2026 12:01:41 -0700 Subject: [PATCH 020/223] feature/AB#32049-Prometheus --- .../ExceptionNotificationThrottle.cs | 30 +++++++++---------- 1 file changed, 14 insertions(+), 16 deletions(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/ExceptionNotificationThrottle.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/ExceptionNotificationThrottle.cs index f0ab071469..c41dc6f91d 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/ExceptionNotificationThrottle.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/ExceptionNotificationThrottle.cs @@ -1,6 +1,5 @@ using System; -using System.Collections.Concurrent; -using System.Threading; +using System.Collections.Generic; namespace Unity.GrantManager.Web.Middleware; @@ -16,10 +15,9 @@ public sealed class ExceptionNotificationThrottle // Global cap: at most N notifications per rolling minute across all types private const int GlobalMaxPerMinute = 5; - private readonly ConcurrentDictionary _lastSent = new(); - - // _sentThisMinute and _windowStart are always accessed together under _lock + // All state is accessed exclusively under _lock — no concurrent collections needed private readonly object _lock = new(); + private readonly Dictionary _lastSent = new(); private int _sentThisMinute; private DateTimeOffset _windowStart = DateTimeOffset.UtcNow; @@ -31,31 +29,31 @@ public bool ShouldNotify(string exceptionTypeName) { var now = DateTimeOffset.UtcNow; - // Per-type cooldown check — ConcurrentDictionary read is lock-free - if (_lastSent.TryGetValue(exceptionTypeName, out var last) && - now - last < PerTypeCooldown) - { - return false; - } - lock (_lock) { - // Reset the window if a full minute has elapsed + // Reset the global window if a full minute has elapsed if (now - _windowStart >= TimeSpan.FromMinutes(1)) { _sentThisMinute = 0; _windowStart = now; } + // Per-type cooldown check — inside the lock to prevent concurrent + // callers with the same exception type both passing the check + if (_lastSent.TryGetValue(exceptionTypeName, out var last) && + now - last < PerTypeCooldown) + { + return false; + } + if (_sentThisMinute >= GlobalMaxPerMinute) { return false; } _sentThisMinute++; + _lastSent[exceptionTypeName] = now; + return true; } - - _lastSent[exceptionTypeName] = now; - return true; } } From 4a9c974e1a0e91d3f7d91374e07749fdf120fb3d Mon Sep 17 00:00:00 2001 From: JamesPasta Date: Tue, 12 May 2026 12:53:44 -0700 Subject: [PATCH 021/223] feature/AB#32049-Prometheus --- .../scripts/openshift/service-monitor.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/applications/Unity.GrantManager/scripts/openshift/service-monitor.yaml b/applications/Unity.GrantManager/scripts/openshift/service-monitor.yaml index f5428da670..3229f49107 100644 --- a/applications/Unity.GrantManager/scripts/openshift/service-monitor.yaml +++ b/applications/Unity.GrantManager/scripts/openshift/service-monitor.yaml @@ -11,13 +11,13 @@ kind: ServiceMonitor metadata: name: unity-grantmanager labels: - app: unity-grantmanager + app.kubernetes.io/name: unity-grant-manager spec: selector: matchLabels: - app: unity-grantmanager # must match labels on your OpenShift Service + app.kubernetes.io/name: unity-grant-manager # matches all env Service labels endpoints: - - port: http # named port on the Service pointing to 8080 + - port: 80-tcp # named port on the Service pointing to 8080 path: /metrics interval: 15s scheme: http From 22bd29ea560091c3a45ecc92b3bd29a79ecb07cb Mon Sep 17 00:00:00 2001 From: JamesPasta Date: Tue, 12 May 2026 15:24:07 -0700 Subject: [PATCH 022/223] feature/AB#32049-Prometheus-Tests --- .../Monitoring/TestExceptionController.cs | 89 +++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Web/Controllers/Monitoring/TestExceptionController.cs diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Controllers/Monitoring/TestExceptionController.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Controllers/Monitoring/TestExceptionController.cs new file mode 100644 index 0000000000..61e6fcb99d --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Controllers/Monitoring/TestExceptionController.cs @@ -0,0 +1,89 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Unity.GrantManager.Notifications; +using Unity.Notifications.TeamsNotifications; +using Volo.Abp.AspNetCore.Mvc; +using Volo.Abp.Uow; + +namespace Unity.GrantManager.Web.Controllers.Monitoring; + +/// +/// Temporary test endpoints — force exceptions/logs/notifications to verify +/// Prometheus error counting and Teams alerting. REMOVE BEFORE MERGING TO MAIN. +/// +[ApiController] +[Route("api/monitoring/test")] +[Authorize] +public class TestExceptionController : AbpControllerBase +{ + // Same SHA parsing as ExceptionCounterMiddleware + private static readonly string CommitSha = ParseCommitSha( + typeof(TestExceptionController).Assembly + .GetCustomAttribute()? + .InformationalVersion); + + private static string ParseCommitSha(string? informationalVersion) + { + if (string.IsNullOrWhiteSpace(informationalVersion)) return "unknown"; + var plusIndex = informationalVersion.IndexOf('+'); + return plusIndex >= 0 ? informationalVersion[(plusIndex + 1)..] : informationalVersion; + } + + /// + /// GET /api/monitoring/test/log-error + /// Logs an Error-level Serilog event → increments application_errors_total via ErrorCountingLoggerSink. + /// + [HttpGet("log-error")] + public IActionResult LogError() + { + var ex = new InvalidOperationException("Test exception for Prometheus error counter verification."); + Logger.LogError(ex, "Test error log for application_errors_total counter — CommitSha: {CommitSha}", CommitSha); + return Ok(new { logged = true, commitSha = CommitSha, message = "Error logged — check /metrics for application_errors_total." }); + } + + /// + /// GET /api/monitoring/test/notify + /// Directly fires the Teams notification with commit SHA — same path as ExceptionCounterMiddleware. + /// ABP catches controller exceptions before they reach the middleware, so this endpoint + /// exercises the notification code directly. + /// + [HttpGet("notify")] + public async Task NotifyTeams() + { + var ex = new InvalidOperationException("Test exception for Teams notification verification."); + string env = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "Unknown"; + string endpoint = $"{Request.Method} {Request.Path}"; + + var scopeFactory = HttpContext.RequestServices.GetRequiredService(); + + await using var scope = scopeFactory.CreateAsyncScope(); + var uowManager = scope.ServiceProvider.GetRequiredService(); + var notifications = scope.ServiceProvider.GetRequiredService(); + + using var uow = uowManager.Begin(requiresNew: true, isTransactional: false); + + var facts = new List + { + new() { Name = "Exception", Value = ex.GetType().FullName ?? ex.GetType().Name }, + new() { Name = "Message", Value = ex.Message }, + new() { Name = "Endpoint", Value = endpoint }, + new() { Name = "Stack Trace", Value = ex.StackTrace ?? "(no stack trace)" }, + new() { Name = "Commit", Value = CommitSha }, + }; + + await notifications.PostToTeamsAsync( + $"[TEST] {ex.GetType().Name}", + $"Environment: {env} | {endpoint}", + facts); + + await uow.CompleteAsync(); + + return Ok(new { notified = true, commitSha = CommitSha, environment = env }); + } +} From bf481e724be9664b58116356914b43780eac553a Mon Sep 17 00:00:00 2001 From: JamesPasta Date: Tue, 12 May 2026 16:13:22 -0700 Subject: [PATCH 023/223] feature/AB#32049-Prometheus-Tests --- .../Controllers/Monitoring/TestExceptionController.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Controllers/Monitoring/TestExceptionController.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Controllers/Monitoring/TestExceptionController.cs index 61e6fcb99d..da5959c9eb 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Controllers/Monitoring/TestExceptionController.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Controllers/Monitoring/TestExceptionController.cs @@ -19,7 +19,7 @@ namespace Unity.GrantManager.Web.Controllers.Monitoring; /// [ApiController] [Route("api/monitoring/test")] -[Authorize] +[AllowAnonymous] public class TestExceptionController : AbpControllerBase { // Same SHA parsing as ExceptionCounterMiddleware From 36b0446c45a2226b990d08f5c53b0eac83d8dd19 Mon Sep 17 00:00:00 2001 From: JamesPasta Date: Tue, 12 May 2026 17:02:35 -0700 Subject: [PATCH 024/223] feature/AB#32049-Prometheus-Tests --- .../Monitoring/TestExceptionController.cs | 18 ++---------------- 1 file changed, 2 insertions(+), 16 deletions(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Controllers/Monitoring/TestExceptionController.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Controllers/Monitoring/TestExceptionController.cs index da5959c9eb..4bfeefe493 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Controllers/Monitoring/TestExceptionController.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Controllers/Monitoring/TestExceptionController.cs @@ -4,12 +4,10 @@ using System.Threading.Tasks; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Unity.GrantManager.Notifications; using Unity.Notifications.TeamsNotifications; using Volo.Abp.AspNetCore.Mvc; -using Volo.Abp.Uow; namespace Unity.GrantManager.Web.Controllers.Monitoring; @@ -20,7 +18,7 @@ namespace Unity.GrantManager.Web.Controllers.Monitoring; [ApiController] [Route("api/monitoring/test")] [AllowAnonymous] -public class TestExceptionController : AbpControllerBase +public class TestExceptionController(INotificationsAppService notifications) : AbpControllerBase { // Same SHA parsing as ExceptionCounterMiddleware private static readonly string CommitSha = ParseCommitSha( @@ -49,9 +47,7 @@ public IActionResult LogError() /// /// GET /api/monitoring/test/notify - /// Directly fires the Teams notification with commit SHA — same path as ExceptionCounterMiddleware. - /// ABP catches controller exceptions before they reach the middleware, so this endpoint - /// exercises the notification code directly. + /// Fires a Teams notification via the same INotificationsAppService used by ExceptionCounterMiddleware. /// [HttpGet("notify")] public async Task NotifyTeams() @@ -60,14 +56,6 @@ public async Task NotifyTeams() string env = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "Unknown"; string endpoint = $"{Request.Method} {Request.Path}"; - var scopeFactory = HttpContext.RequestServices.GetRequiredService(); - - await using var scope = scopeFactory.CreateAsyncScope(); - var uowManager = scope.ServiceProvider.GetRequiredService(); - var notifications = scope.ServiceProvider.GetRequiredService(); - - using var uow = uowManager.Begin(requiresNew: true, isTransactional: false); - var facts = new List { new() { Name = "Exception", Value = ex.GetType().FullName ?? ex.GetType().Name }, @@ -82,8 +70,6 @@ await notifications.PostToTeamsAsync( $"Environment: {env} | {endpoint}", facts); - await uow.CompleteAsync(); - return Ok(new { notified = true, commitSha = CommitSha, environment = env }); } } From e5e0f81f2e06eda36bf4d479f7e860dda75dfca1 Mon Sep 17 00:00:00 2001 From: JamesPasta Date: Tue, 12 May 2026 17:21:22 -0700 Subject: [PATCH 025/223] feature/AB#32049-Prometheus-Tests --- .../AbpExceptionNotificationSubscriber.cs | 100 ++++++++++++++++++ .../Middleware/ExceptionCounterMiddleware.cs | 2 +- 2 files changed, 101 insertions(+), 1 deletion(-) create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/AbpExceptionNotificationSubscriber.cs diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/AbpExceptionNotificationSubscriber.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/AbpExceptionNotificationSubscriber.cs new file mode 100644 index 0000000000..122c6821ed --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/AbpExceptionNotificationSubscriber.cs @@ -0,0 +1,100 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Unity.GrantManager.Notifications; +using Unity.Notifications.TeamsNotifications; +using Volo.Abp.DependencyInjection; +using Volo.Abp.ExceptionHandling; +using Volo.Abp.Uow; + +namespace Unity.GrantManager.Web.Middleware; + +/// +/// Hooks into ABP's exception pipeline via IExceptionSubscriber. +/// ABP calls this for every exception it handles (controller actions, app services, etc.) +/// — complementing ExceptionCounterMiddleware which only catches exceptions that bypass ABP. +/// Registered automatically by ABP's DI scan (ITransientDependency). +/// +public class AbpExceptionNotificationSubscriber( + ExceptionNotificationThrottle throttle, + IServiceScopeFactory scopeFactory, + IHttpContextAccessor httpContextAccessor, + ILogger logger) : IExceptionSubscriber, ITransientDependency +{ + private static readonly HashSet NotifyEnvironments = + new(StringComparer.OrdinalIgnoreCase) { "Production", "Test", "Development" }; + + public Task HandleAsync(ExceptionNotificationContext context) + { + var ex = context.Exception; + + // Increment Prometheus counters + ErrorCountingLoggerSink.ErrorCounter + .WithLabels("error", ex.GetType().Name) + .Inc(); + + QueueTeamsNotification(ex); + + return Task.CompletedTask; + } + + private void QueueTeamsNotification(Exception ex) + { + string? env = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT"); + + if (!NotifyEnvironments.Contains(env ?? string.Empty)) + return; + + if (!throttle.ShouldNotify(ex.GetType().Name)) + return; + + var httpContext = httpContextAccessor.HttpContext; + string endpoint = httpContext != null + ? $"{httpContext.Request.Method} {httpContext.Request.Path}" + : "(background)"; + + string exTypeName = ex.GetType().FullName ?? ex.GetType().Name; + string exMessage = ex.Message; + string innerMessage = ex.InnerException?.Message ?? string.Empty; + string stackTrace = ex.StackTrace ?? "(no stack trace)"; + if (stackTrace.Length > 1500) + stackTrace = stackTrace[..1500] + "\n... (truncated)"; + + _ = Task.Run(async () => + { + try + { + await using var scope = scopeFactory.CreateAsyncScope(); + var uowManager = scope.ServiceProvider.GetRequiredService(); + var notifications = scope.ServiceProvider.GetRequiredService(); + + using var uow = uowManager.Begin(requiresNew: true, isTransactional: false); + + string activityTitle = $"[{env?.ToUpperInvariant()}] {ex.GetType().Name}"; + string activitySubtitle = $"Environment: {env} | {endpoint}"; + + var facts = new List + { + new() { Name = "Exception", Value = exTypeName }, + new() { Name = "Message", Value = exMessage }, + new() { Name = "Endpoint", Value = endpoint }, + new() { Name = "Stack Trace", Value = stackTrace }, + new() { Name = "Commit", Value = ExceptionCounterMiddleware.CommitSha }, + }; + + if (!string.IsNullOrEmpty(innerMessage)) + facts.Add(new Fact { Name = "Inner Exception", Value = innerMessage }); + + await notifications.PostToTeamsAsync(activityTitle, activitySubtitle, facts); + await uow.CompleteAsync(); + } + catch (Exception notifyEx) + { + logger.LogWarning(notifyEx, "Failed to send Teams exception notification via IExceptionSubscriber"); + } + }); + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/ExceptionCounterMiddleware.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/ExceptionCounterMiddleware.cs index 1e96b723d0..3d0d5eda1e 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/ExceptionCounterMiddleware.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/ExceptionCounterMiddleware.cs @@ -32,7 +32,7 @@ public class ExceptionCounterMiddleware( // Git SHA baked in at build time via -p:SourceRevisionId= in the Dockerfile. // Format is "+" e.g. "1.0.0+a3f8c21"; we extract just the SHA. - private static readonly string CommitSha = ParseCommitSha( + internal static readonly string CommitSha = ParseCommitSha( typeof(ExceptionCounterMiddleware).Assembly .GetCustomAttribute()? .InformationalVersion); From e128b85b31226aadaf8e4852cb39b109255950cb Mon Sep 17 00:00:00 2001 From: JamesPasta Date: Tue, 12 May 2026 17:38:00 -0700 Subject: [PATCH 026/223] feature/AB#32049-Prometheus-Tests --- .../Controllers/Monitoring/TestExceptionController.cs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Controllers/Monitoring/TestExceptionController.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Controllers/Monitoring/TestExceptionController.cs index 4bfeefe493..a2df606071 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Controllers/Monitoring/TestExceptionController.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Controllers/Monitoring/TestExceptionController.cs @@ -33,6 +33,16 @@ private static string ParseCommitSha(string? informationalVersion) return plusIndex >= 0 ? informationalVersion[(plusIndex + 1)..] : informationalVersion; } + /// + /// GET /api/monitoring/test/throw + /// Throws an unhandled exception → ABP catches it → IExceptionSubscriber fires → Teams notification sent. + /// + [HttpGet("throw")] + public IActionResult ThrowException() + { + throw new InvalidOperationException("Test exception to verify AbpExceptionNotificationSubscriber and Teams notification."); + } + /// /// GET /api/monitoring/test/log-error /// Logs an Error-level Serilog event → increments application_errors_total via ErrorCountingLoggerSink. From c60eef73b15704c001eb2f84f3a9619c85c0c02b Mon Sep 17 00:00:00 2001 From: JamesPasta Date: Tue, 12 May 2026 22:57:30 -0700 Subject: [PATCH 027/223] feature/AB#32049-Prometheus-Tests --- .../src/Unity.GrantManager.Web/GrantManagerWebModule.cs | 1 + .../Middleware/AbpExceptionNotificationSubscriber.cs | 6 ++++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/GrantManagerWebModule.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/GrantManagerWebModule.cs index 3a6f891dff..4b7f381854 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/GrantManagerWebModule.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/GrantManagerWebModule.cs @@ -320,6 +320,7 @@ private static void ConfigurePolicies(ServiceConfigurationContext context) { context.Services.AddScoped(); context.Services.AddSingleton(); + context.Services.AddTransient(); PolicyRegistrant.Register(context); } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/AbpExceptionNotificationSubscriber.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/AbpExceptionNotificationSubscriber.cs index 122c6821ed..ebd809e350 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/AbpExceptionNotificationSubscriber.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/AbpExceptionNotificationSubscriber.cs @@ -16,13 +16,13 @@ namespace Unity.GrantManager.Web.Middleware; /// Hooks into ABP's exception pipeline via IExceptionSubscriber. /// ABP calls this for every exception it handles (controller actions, app services, etc.) /// — complementing ExceptionCounterMiddleware which only catches exceptions that bypass ABP. -/// Registered automatically by ABP's DI scan (ITransientDependency). +/// Registered explicitly in GrantManagerWebModule.ConfigurePolicies. /// public class AbpExceptionNotificationSubscriber( ExceptionNotificationThrottle throttle, IServiceScopeFactory scopeFactory, IHttpContextAccessor httpContextAccessor, - ILogger logger) : IExceptionSubscriber, ITransientDependency + ILogger logger) : IExceptionSubscriber { private static readonly HashSet NotifyEnvironments = new(StringComparer.OrdinalIgnoreCase) { "Production", "Test", "Development" }; @@ -31,6 +31,8 @@ public Task HandleAsync(ExceptionNotificationContext context) { var ex = context.Exception; + logger.LogInformation("AbpExceptionNotificationSubscriber.HandleAsync called for {ExceptionType}", ex.GetType().Name); + // Increment Prometheus counters ErrorCountingLoggerSink.ErrorCounter .WithLabels("error", ex.GetType().Name) From 16b296a1c37652fa00a9d24f189060b153a88381 Mon Sep 17 00:00:00 2001 From: JamesPasta Date: Wed, 13 May 2026 14:50:25 -0700 Subject: [PATCH 028/223] feature/AB#32049-Prometheus-Tests --- .../IEndpointManagementAppService.cs | 1 + .../Endpoints/EndpointManagementAppService.cs | 11 + .../Integrations/DynamicUrlKeyNames.cs | 1 + .../Integrations/DynamicUrlDataSeeder.cs | 3 + .../GrantManagerWebModule.cs | 3 +- .../AbpExceptionNotificationSubscriber.cs | 63 ++- .../Middleware/ExceptionCounterMiddleware.cs | 8 + .../Middleware/GitHubBlameLookupService.cs | 358 ++++++++++++++++++ .../Middleware/IBlameLookupService.cs | 18 + 9 files changed, 462 insertions(+), 4 deletions(-) create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/GitHubBlameLookupService.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/IBlameLookupService.cs diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Integration/Endpoints/IEndpointManagementAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Integration/Endpoints/IEndpointManagementAppService.cs index 7c233192c8..9ae45a61ad 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Integration/Endpoints/IEndpointManagementAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Integration/Endpoints/IEndpointManagementAppService.cs @@ -16,5 +16,6 @@ public interface IEndpointManagementAppService : ICrudAppService< Task GetChefsApiBaseUrlAsync(); Task GetUrlByKeyNameAsync(string keyName); Task GetUgmUrlByKeyNameAsync(string keyName); + Task GetGitHubRepoUrlAsync(); Task ClearCacheAsync(Guid? tenantId = null); } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Integrations/Endpoints/EndpointManagementAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Integrations/Endpoints/EndpointManagementAppService.cs index 59f7fa3a8c..0375917a32 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Integrations/Endpoints/EndpointManagementAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Integrations/Endpoints/EndpointManagementAppService.cs @@ -63,6 +63,17 @@ private async Task RemoveFromKeySetAsync(string cacheKey, Guid? tenantId) }); } } + + [UnitOfWork] + [RemoteService(false)] + [AllowAnonymous] + public async Task GetGitHubRepoUrlAsync() + { + var url = await GetUrlByKeyNameInternalAsync(DynamicUrlKeyNames.GITHUB_REPO, tenantSpecific: false); + if (string.IsNullOrWhiteSpace(url)) + throw new UserFriendlyException("GitHub repo URL not configured."); + return url!; + } [UnitOfWork] [RemoteService(false)] diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/Integrations/DynamicUrlKeyNames.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/Integrations/DynamicUrlKeyNames.cs index 64379739bc..a601d6f27d 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/Integrations/DynamicUrlKeyNames.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/Integrations/DynamicUrlKeyNames.cs @@ -13,6 +13,7 @@ public static class DynamicUrlKeyNames public const string DIRECT_MESSAGE_KEY_PREFIX = "DIRECT_MESSAGE_"; // Teams Direct Message URL Weebhook- Dynamically incremented public const string WEBHOOK_KEY_PREFIX = "WEBHOOK_"; // General Webhook URL - Dynamically incremented public const string GEOCODER_API_BASE = "GEOCODER_API_BASE"; + public const string GITHUB_REPO = "GITHUB_REPO"; public const string GEOCODER_LOCATION_API_BASE = "GEOCODER_LOCATION_API_BASE"; public const string ANALYTICS_MATOMO_BASE = "ANALYTICS_MATOMO_BASE"; } \ No newline at end of file diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Integrations/DynamicUrlDataSeeder.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Integrations/DynamicUrlDataSeeder.cs index f14f85f12a..419efc801e 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Integrations/DynamicUrlDataSeeder.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Integrations/DynamicUrlDataSeeder.cs @@ -35,6 +35,7 @@ public static class DynamicUrls public const string MATOMO_DEV_URL = $"{PROTOCOL}//dev-analytics-matomo.apps.silver.devops.gov.bc.ca"; public const string MATOMO_TEST_URL = $"{PROTOCOL}//test-analytics-matomo.apps.silver.devops.gov.bc.ca"; public const string MATOMO_PROD_URL = $"{PROTOCOL}//prod-analytics-matomo.apps.silver.devops.gov.bc.ca"; + public const string GITHUB_REPO = $"{PROTOCOL}//github.com/bcgov/Unity"; } private static string GetMatomoUrl() @@ -67,12 +68,14 @@ private async Task SeedDynamicUrlAsync() new() { KeyName = DynamicUrlKeyNames.REPORTING_AI, Url = DynamicUrls.REPORTING_AI, Description = "Reporting AI iFrame Source" }, new() { KeyName = DynamicUrlKeyNames.NOTIFICATION_AUTH, Url = DynamicUrls.CHES_PROD_AUTH, Description = "Common Hosted Email Service OAUTH" }, new() { KeyName = DynamicUrlKeyNames.ANALYTICS_MATOMO_BASE, Url = GetMatomoUrl(), Description = "Matomo Analytics" }, + new() { KeyName = DynamicUrlKeyNames.GITHUB_REPO, Url = DynamicUrls.GITHUB_REPO, Description = "GitHub Repository" }, new() { KeyName = $"{DynamicUrlKeyNames.DIRECT_MESSAGE_KEY_PREFIX}{messageIndex++}", Url = "", Description = $"Direct message webhook {messageIndex}" }, new() { KeyName = $"{DynamicUrlKeyNames.DIRECT_MESSAGE_KEY_PREFIX}{messageIndex++}", Url = "", Description = $"Direct message webhook {messageIndex}" }, new() { KeyName = $"{DynamicUrlKeyNames.DIRECT_MESSAGE_KEY_PREFIX}{messageIndex++}", Url = "", Description = $"Direct message webhook {messageIndex}" }, new() { KeyName = $"{DynamicUrlKeyNames.WEBHOOK_KEY_PREFIX}{webhookIndex++}", Url = "", Description = $"Webhook {webhookIndex}" }, new() { KeyName = $"{DynamicUrlKeyNames.WEBHOOK_KEY_PREFIX}{webhookIndex++}", Url = "", Description = $"Webhook {webhookIndex}" }, new() { KeyName = $"{DynamicUrlKeyNames.WEBHOOK_KEY_PREFIX}{webhookIndex++}", Url = "", Description = $"Webhook {webhookIndex}" }, + }; foreach (var dynamicUrl in dynamicUrls) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/GrantManagerWebModule.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/GrantManagerWebModule.cs index 4b7f381854..e989558dc0 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/GrantManagerWebModule.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/GrantManagerWebModule.cs @@ -318,9 +318,10 @@ private static void ConfgureFormsApiAuhentication(ServiceConfigurationContext co private static void ConfigurePolicies(ServiceConfigurationContext context) { - context.Services.AddScoped(); + context.Services.AddScoped(); context.Services.AddSingleton(); context.Services.AddTransient(); + context.Services.AddHttpClient(); PolicyRegistrant.Register(context); } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/AbpExceptionNotificationSubscriber.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/AbpExceptionNotificationSubscriber.cs index ebd809e350..9ee9ac3ebe 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/AbpExceptionNotificationSubscriber.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/AbpExceptionNotificationSubscriber.cs @@ -1,12 +1,12 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Unity.GrantManager.Notifications; using Unity.Notifications.TeamsNotifications; -using Volo.Abp.DependencyInjection; using Volo.Abp.ExceptionHandling; using Volo.Abp.Uow; @@ -31,8 +31,6 @@ public Task HandleAsync(ExceptionNotificationContext context) { var ex = context.Exception; - logger.LogInformation("AbpExceptionNotificationSubscriber.HandleAsync called for {ExceptionType}", ex.GetType().Name); - // Increment Prometheus counters ErrorCountingLoggerSink.ErrorCounter .WithLabels("error", ex.GetType().Name) @@ -78,18 +76,46 @@ private void QueueTeamsNotification(Exception ex) string activityTitle = $"[{env?.ToUpperInvariant()}] {ex.GetType().Name}"; string activitySubtitle = $"Environment: {env} | {endpoint}"; + var frame = GetTopFrame(ex); + string sourceFile = NormalizeRepoPath(frame?.File ?? "(unknown)"); + int? sourceLine = frame?.Line; + var facts = new List { new() { Name = "Exception", Value = exTypeName }, new() { Name = "Message", Value = exMessage }, new() { Name = "Endpoint", Value = endpoint }, new() { Name = "Stack Trace", Value = stackTrace }, + new() { Name = "Source", Value = sourceLine.HasValue ? $"{sourceFile}:{sourceLine}" : sourceFile }, new() { Name = "Commit", Value = ExceptionCounterMiddleware.CommitSha }, + new() { Name = "Author", Value = ExceptionCounterMiddleware.CommitAuthor }, }; if (!string.IsNullOrEmpty(innerMessage)) facts.Add(new Fact { Name = "Inner Exception", Value = innerMessage }); + if (sourceLine.HasValue) + { + try + { + var blameService = scope.ServiceProvider.GetRequiredService(); + var blame = await blameService.GetBlameAsync(sourceFile, sourceLine.Value); + + if (blame != null) + { + facts.Add(new Fact { Name = "Blame Author", Value = $"{blame.Author} <{blame.Email}>" }); + facts.Add(new Fact { Name = "Blame Commit", Value = $"{blame.CommitSha[..Math.Min(7, blame.CommitSha.Length)]} {blame.Message}" }); + + if (blame.PullRequestUrl != null) + facts.Add(new Fact { Name = "Blame PR", Value = $"#{blame.PullRequestNumber} {blame.PullRequestUrl}" }); + } + } + catch (Exception blameEx) + { + logger.LogDebug(blameEx, "Blame lookup failed for {File}:{Line}", sourceFile, sourceLine); + } + } + await notifications.PostToTeamsAsync(activityTitle, activitySubtitle, facts); await uow.CompleteAsync(); } @@ -99,4 +125,35 @@ private void QueueTeamsNotification(Exception ex) } }); } + + private static string NormalizeRepoPath(string fullPath) + { + const string marker = "src/"; + + int idx = fullPath.IndexOf(marker, StringComparison.OrdinalIgnoreCase); + + if (idx < 0) + return fullPath.Replace("\\", "/"); + + return fullPath[(idx + marker.Length)..] + .Replace("\\", "/"); + } + + private static (string? File, int? Line)? GetTopFrame(Exception ex) + { + var trace = new StackTrace(ex, true); + + foreach (var frame in trace.GetFrames() ?? []) + { + var file = frame.GetFileName(); + var line = frame.GetFileLineNumber(); + + if (!string.IsNullOrWhiteSpace(file) && line > 0) + { + return (file, line); + } + } + + return null; + } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/ExceptionCounterMiddleware.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/ExceptionCounterMiddleware.cs index 3d0d5eda1e..dac5c4d632 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/ExceptionCounterMiddleware.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/ExceptionCounterMiddleware.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Reflection; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; @@ -37,6 +38,12 @@ public class ExceptionCounterMiddleware( .GetCustomAttribute()? .InformationalVersion); + // Commit author baked in at build time via -p:AssemblyMetadata_CommitAuthor=. + internal static readonly string CommitAuthor = + typeof(ExceptionCounterMiddleware).Assembly + .GetCustomAttributes() + .FirstOrDefault(a => a.Key == "CommitAuthor")?.Value ?? "unknown"; + private static string ParseCommitSha(string? informationalVersion) { if (string.IsNullOrWhiteSpace(informationalVersion)) return "unknown"; @@ -110,6 +117,7 @@ private void QueueTeamsNotification(HttpContext context, Exception ex) new() { Name = "Endpoint", Value = endpoint }, new() { Name = "Stack Trace", Value = stackTrace }, new() { Name = "Commit", Value = CommitSha }, + new() { Name = "Author", Value = CommitAuthor }, }; if (!string.IsNullOrEmpty(innerMessage)) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/GitHubBlameLookupService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/GitHubBlameLookupService.cs new file mode 100644 index 0000000000..2c86a2e1e7 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/GitHubBlameLookupService.cs @@ -0,0 +1,358 @@ +using System; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Unity.GrantManager.Integrations; + +namespace Unity.GrantManager.Web.Middleware; + + + +public class GitHubBlameLookupService : IBlameLookupService +{ + private readonly HttpClient _httpClient; + private readonly ILogger _logger; + private readonly IEndpointManagementAppService? _endpointService; + + private readonly string _owner = string.Empty; + private readonly string _repo = string.Empty; + + private string _branch; + + public GitHubBlameLookupService( + HttpClient httpClient, + ILogger logger, + IEndpointManagementAppService? endpointService = null) + { + _httpClient = httpClient; + _logger = logger; + _endpointService = endpointService; + + // Try to get repo from endpoint service if available + string? repoUrl = null; + if (_endpointService != null) + { + try + { + repoUrl = _endpointService.GetGitHubRepoUrlAsync().GetAwaiter().GetResult(); + } + catch { } + } + if (!string.IsNullOrWhiteSpace(repoUrl)) + { + var parts = repoUrl.TrimEnd('/').Split('/'); + if (parts.Length >= 2) + { + _owner = parts[^2]; + _repo = parts[^1]; + } + } + else + { + _owner = Environment.GetEnvironmentVariable("GITHUB_OWNER") ?? ""; + _repo = Environment.GetEnvironmentVariable("GITHUB_REPO") ?? ""; + } + + var env = + Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") + ?? "Production"; + + _branch = env switch + { + "Development" => "dev", + "Test" => "test", + "Staging" => "main", + "Production" => "main", + _ => "main" + }; + + // Optional override + var branchOverride = + Environment.GetEnvironmentVariable("GITHUB_BRANCH"); + + if (!string.IsNullOrWhiteSpace(branchOverride)) + { + _branch = branchOverride; + } + + string token = + Environment.GetEnvironmentVariable("GITHUB_TOKEN") ?? ""; + + _httpClient.DefaultRequestHeaders.Authorization = + new AuthenticationHeaderValue("Bearer", token); + + _httpClient.DefaultRequestHeaders.UserAgent.ParseAdd( + "Unity-GrantManager"); + } + + /// + /// Creates a compact blame reference. + /// + /// Example: + /// main/src/MyFile.cs#L123 + /// + public string BuildBlameReference(string path, int line) + { + return $"{_branch}/{path}#L{line}"; + } + + /// + /// Converts compact reference into a full GitHub URL. + /// + /// Example: + /// https://github.com/bcgov/Unity/blame/main/src/MyFile.cs#L123 + /// + public string BuildBlameUrl(string reference) + { + return + $"https://github.com/{_owner}/{_repo}/blame/{reference}"; + } + + /// + /// Lookup blame information from a compact reference. + /// + /// Supported: + /// main/src/MyFile.cs#L123 + /// src/MyFile.cs#L123 + /// + public async Task GetBlameFromReferenceAsync( + string reference) + { + if (string.IsNullOrWhiteSpace(reference)) + { + return null; + } + + string branch = _branch; + string pathWithFragment = reference; + + // Try extracting branch + int firstSlash = reference.IndexOf('/'); + + if (firstSlash > 0) + { + string possibleBranch = reference[..firstSlash]; + + if (possibleBranch is "main" or "dev" or "test") + { + branch = possibleBranch; + pathWithFragment = reference[(firstSlash + 1)..]; + } + } + + // Parse: + // src/MyFile.cs#L123 + string[] parts = + pathWithFragment.Split("#L"); + + string path = parts[0]; + + int line = 1; + + if (parts.Length > 1 && + int.TryParse(parts[1], out int parsedLine)) + { + line = parsedLine; + } + + return await GetBlameAsync( + _owner, + _repo, + branch, + path, + line); + } + + /// + /// Lookup blame using configured repo + branch. + /// + public async Task GetBlameAsync( + string repoPath, + int line) + { + return await GetBlameAsync( + _owner, + _repo, + _branch, + repoPath, + line); + } + + /// + /// Full blame lookup. + /// + public async Task GetBlameAsync( + string owner, + string repo, + string branch, + string repoPath, + int line) + { + if (string.IsNullOrWhiteSpace(owner) || + string.IsNullOrWhiteSpace(repo)) + { + _logger.LogDebug( + "GitHub blame lookup skipped — owner or repo missing"); + + return null; + } + + var query = BuildQuery( + owner, + repo, + branch, + repoPath); + + var payload = JsonSerializer.Serialize(new + { + query + }); + + using var response = await _httpClient.PostAsync( + "https://api.github.com/graphql", + new StringContent( + payload, + Encoding.UTF8, + "application/json")); + + response.EnsureSuccessStatusCode(); + + string json = + await response.Content.ReadAsStringAsync(); + + using JsonDocument doc = + JsonDocument.Parse(json); + + var root = doc.RootElement; + + if (root.TryGetProperty("errors", out var errors)) + { + _logger.LogWarning( + "GitHub GraphQL errors: {Errors}", + errors.ToString()); + + return null; + } + + var ranges = + root + .GetProperty("data") + .GetProperty("repository") + .GetProperty("object") + .GetProperty("blame") + .GetProperty("ranges"); + + foreach (var range in ranges.EnumerateArray()) + { + int start = + range.GetProperty("startingLine").GetInt32(); + + int end = + range.GetProperty("endingLine").GetInt32(); + + if (line < start || line > end) + { + continue; + } + + var commit = + range.GetProperty("commit"); + + string sha = + commit.GetProperty("oid").GetString() + ?? ""; + + string message = + commit.GetProperty("messageHeadline").GetString() + ?? ""; + + var author = + commit.GetProperty("author"); + + string authorName = + author.GetProperty("name").GetString() + ?? ""; + + string email = + author.GetProperty("email").GetString() + ?? ""; + + string? prUrl = null; + int? prNumber = null; + + var prs = + commit + .GetProperty("associatedPullRequests") + .GetProperty("nodes"); + + if (prs.GetArrayLength() > 0) + { + var pr = prs[0]; + + prUrl = + pr.GetProperty("url").GetString(); + + if (pr.TryGetProperty( + "number", + out var numberProp)) + { + prNumber = + numberProp.GetInt32(); + } + } + + return new GitHubBlameInfo + { + CommitSha = sha, + Author = authorName, + Email = email, + Message = message, + PullRequestUrl = prUrl, + PullRequestNumber = prNumber + }; + } + + return null; + } + + private string BuildQuery( + string owner, + string repo, + string branch, + string path) + { + return $@" +query {{ + repository(owner: ""{owner}"", name: ""{repo}"") {{ + object(expression: ""{branch}"") {{ + ... on Commit {{ + blame(path: ""{path}"") {{ + ranges {{ + startingLine + endingLine + commit {{ + oid + messageHeadline + author {{ + name + email + }} + associatedPullRequests(first: 1) {{ + nodes {{ + number + url + title + }} + }} + }} + }} + }} + }} + }} + }} +}}"; + } +} \ No newline at end of file diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/IBlameLookupService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/IBlameLookupService.cs new file mode 100644 index 0000000000..714c61f978 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/IBlameLookupService.cs @@ -0,0 +1,18 @@ +using System.Threading.Tasks; + +namespace Unity.GrantManager.Web.Middleware; + +public record GitHubBlameInfo +{ + public string CommitSha { get; init; } = ""; + public string Author { get; init; } = ""; + public string Email { get; init; } = ""; + public string Message { get; init; } = ""; + public string? PullRequestUrl { get; init; } + public int? PullRequestNumber { get; init; } +} + +public interface IBlameLookupService +{ + Task GetBlameAsync(string repoPath, int line); +} From f4a2fa16423abc1d6f2d4f2db73e6b610919bf38 Mon Sep 17 00:00:00 2001 From: JamesPasta Date: Thu, 14 May 2026 09:26:39 -0700 Subject: [PATCH 029/223] feature/AB#32049-Prometheus-Tests --- .../IEndpointManagementAppService.cs | 1 + .../Endpoints/EndpointManagementAppService.cs | 12 ++ .../Integrations/DynamicUrlKeyNames.cs | 1 + .../Integrations/DynamicUrlDataSeeder.cs | 1 + .../AbpExceptionNotificationSubscriber.cs | 12 +- .../Middleware/ExceptionCounterMiddleware.cs | 167 +++++++++++++++--- .../Middleware/GitHubBlameLookupService.cs | 47 +++-- .../Middleware/IBlameLookupService.cs | 1 + 8 files changed, 199 insertions(+), 43 deletions(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Integration/Endpoints/IEndpointManagementAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Integration/Endpoints/IEndpointManagementAppService.cs index 9ae45a61ad..401b7e8728 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Integration/Endpoints/IEndpointManagementAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Integration/Endpoints/IEndpointManagementAppService.cs @@ -17,5 +17,6 @@ public interface IEndpointManagementAppService : ICrudAppService< Task GetUrlByKeyNameAsync(string keyName); Task GetUgmUrlByKeyNameAsync(string keyName); Task GetGitHubRepoUrlAsync(); + Task GetGitHubGraphQlUrlAsync(); Task ClearCacheAsync(Guid? tenantId = null); } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Integrations/Endpoints/EndpointManagementAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Integrations/Endpoints/EndpointManagementAppService.cs index 0375917a32..f581bd38ee 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Integrations/Endpoints/EndpointManagementAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Integrations/Endpoints/EndpointManagementAppService.cs @@ -75,6 +75,18 @@ public async Task GetGitHubRepoUrlAsync() return url!; } + [UnitOfWork] + [RemoteService(false)] + [AllowAnonymous] + public async Task GetGitHubGraphQlUrlAsync() + { + var url = await GetUrlByKeyNameInternalAsync(DynamicUrlKeyNames.GITHUB_GRAPHQL, tenantSpecific: false); + if (string.IsNullOrWhiteSpace(url)) + throw new UserFriendlyException("GitHub GraphQL URL not configured."); + return url!; + } + + [UnitOfWork] [RemoteService(false)] [AllowAnonymous] diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/Integrations/DynamicUrlKeyNames.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/Integrations/DynamicUrlKeyNames.cs index a601d6f27d..a0d3efd332 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/Integrations/DynamicUrlKeyNames.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/Integrations/DynamicUrlKeyNames.cs @@ -14,6 +14,7 @@ public static class DynamicUrlKeyNames public const string WEBHOOK_KEY_PREFIX = "WEBHOOK_"; // General Webhook URL - Dynamically incremented public const string GEOCODER_API_BASE = "GEOCODER_API_BASE"; public const string GITHUB_REPO = "GITHUB_REPO"; + public const string GITHUB_GRAPHQL = "GITHUB_GRAPHQL"; public const string GEOCODER_LOCATION_API_BASE = "GEOCODER_LOCATION_API_BASE"; public const string ANALYTICS_MATOMO_BASE = "ANALYTICS_MATOMO_BASE"; } \ No newline at end of file diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Integrations/DynamicUrlDataSeeder.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Integrations/DynamicUrlDataSeeder.cs index 419efc801e..2bad58ab17 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Integrations/DynamicUrlDataSeeder.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Integrations/DynamicUrlDataSeeder.cs @@ -36,6 +36,7 @@ public static class DynamicUrls public const string MATOMO_TEST_URL = $"{PROTOCOL}//test-analytics-matomo.apps.silver.devops.gov.bc.ca"; public const string MATOMO_PROD_URL = $"{PROTOCOL}//prod-analytics-matomo.apps.silver.devops.gov.bc.ca"; public const string GITHUB_REPO = $"{PROTOCOL}//github.com/bcgov/Unity"; + public const string GITHUB_GRAPHQL = $"{PROTOCOL}//api.github.com/graphql"; } private static string GetMatomoUrl() diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/AbpExceptionNotificationSubscriber.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/AbpExceptionNotificationSubscriber.cs index 9ee9ac3ebe..9d70656521 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/AbpExceptionNotificationSubscriber.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/AbpExceptionNotificationSubscriber.cs @@ -100,14 +100,22 @@ private void QueueTeamsNotification(Exception ex) { var blameService = scope.ServiceProvider.GetRequiredService(); var blame = await blameService.GetBlameAsync(sourceFile, sourceLine.Value); - + if (blame != null) { facts.Add(new Fact { Name = "Blame Author", Value = $"{blame.Author} <{blame.Email}>" }); facts.Add(new Fact { Name = "Blame Commit", Value = $"{blame.CommitSha[..Math.Min(7, blame.CommitSha.Length)]} {blame.Message}" }); if (blame.PullRequestUrl != null) - facts.Add(new Fact { Name = "Blame PR", Value = $"#{blame.PullRequestNumber} {blame.PullRequestUrl}" }); + { + facts.Add(new Fact { Name = "Blame PR", Value = $"#{blame.PullRequestNumber} {blame.PullRequestUrl}" }); + } + + if (!string.IsNullOrWhiteSpace(blame.PullRequestTitle)) + { + // Look at the PR title - detect the AB# Pattern + facts.Add(new Fact { Name = "Blame PR Title", Value = blame.PullRequestTitle }); + } } } catch (Exception blameEx) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/ExceptionCounterMiddleware.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/ExceptionCounterMiddleware.cs index dac5c4d632..83ee37b0d1 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/ExceptionCounterMiddleware.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/ExceptionCounterMiddleware.cs @@ -1,5 +1,7 @@ using System; using System.Collections.Generic; +using System.Diagnostics; +using System.IO; using System.Linq; using System.Reflection; using System.Threading.Tasks; @@ -20,7 +22,12 @@ public class ExceptionCounterMiddleware( { // Notify only in these environments; add "Staging" if desired private static readonly HashSet NotifyEnvironments = - new(StringComparer.OrdinalIgnoreCase) { "Production", "Test", "Development" }; + new(StringComparer.OrdinalIgnoreCase) + { + "Production", + "Test", + "Development" + }; private static readonly Counter ExceptionCounter = Metrics.CreateCounter( @@ -46,9 +53,16 @@ public class ExceptionCounterMiddleware( private static string ParseCommitSha(string? informationalVersion) { - if (string.IsNullOrWhiteSpace(informationalVersion)) return "unknown"; + if (string.IsNullOrWhiteSpace(informationalVersion)) + { + return "unknown"; + } + var plusIndex = informationalVersion.IndexOf('+'); - return plusIndex >= 0 ? informationalVersion[(plusIndex + 1)..] : informationalVersion; + + return plusIndex >= 0 + ? informationalVersion[(plusIndex + 1)..] + : informationalVersion; } public async Task InvokeAsync(HttpContext context) @@ -60,7 +74,10 @@ public async Task InvokeAsync(HttpContext context) catch (Exception ex) { ExceptionCounter.WithLabels(ex.GetType().Name).Inc(); - ErrorCountingLoggerSink.ErrorCounter.WithLabels("fatal", ex.GetType().Name).Inc(); + + ErrorCountingLoggerSink.ErrorCounter + .WithLabels("fatal", ex.GetType().Name) + .Inc(); QueueTeamsNotification(context, ex); @@ -82,16 +99,17 @@ private void QueueTeamsNotification(HttpContext context, Exception ex) return; } + // Use the real root exception + ex = ex.GetBaseException(); + // Capture values from the request context before it is disposed string endpoint = $"{context.Request.Method} {context.Request.Path}"; string exTypeName = ex.GetType().FullName ?? ex.GetType().Name; string exMessage = ex.Message; string innerMessage = ex.InnerException?.Message ?? string.Empty; - string stackTrace = ex.StackTrace ?? "(no stack trace)"; - if (stackTrace.Length > 1500) - { - stackTrace = stackTrace[..1500] + "\n... (truncated)"; - } + + // Compact stack trace with only application frames + string stackTrace = BuildApplicationStackExcerpt(ex); // Resolve a scoped INotificationsAppService from a fresh DI scope so // we can safely use it after the request scope has ended @@ -102,37 +120,140 @@ private void QueueTeamsNotification(HttpContext context, Exception ex) try { await using var scope = scopeFactory.CreateAsyncScope(); + var uowManager = scope.ServiceProvider.GetRequiredService(); - var notifications = scope.ServiceProvider.GetRequiredService(); - using var uow = uowManager.Begin(requiresNew: true, isTransactional: false); + var notifications = + scope.ServiceProvider.GetRequiredService(); + + using var uow = uowManager.Begin( + requiresNew: true, + isTransactional: false); string activityTitle = $"[CRITICAL] {ex.GetType().Name}"; - string activitySubtitle = $"Environment: {env} | {endpoint}"; + + string activitySubtitle = + $"Environment: {env} | {endpoint}"; var facts = new List { - new() { Name = "Exception", Value = exTypeName }, - new() { Name = "Message", Value = exMessage }, - new() { Name = "Endpoint", Value = endpoint }, - new() { Name = "Stack Trace", Value = stackTrace }, - new() { Name = "Commit", Value = CommitSha }, - new() { Name = "Author", Value = CommitAuthor }, + new() + { + Name = "Exception", + Value = exTypeName + }, + new() + { + Name = "Message", + Value = exMessage + }, + new() + { + Name = "Endpoint", + Value = endpoint + }, + new() + { + Name = "Application Stack", + Value = stackTrace + }, + new() + { + Name = "Commit", + Value = CommitSha + }, + new() + { + Name = "Author", + Value = CommitAuthor + } }; - if (!string.IsNullOrEmpty(innerMessage)) + if (!string.IsNullOrWhiteSpace(innerMessage)) { - facts.Add(new Fact { Name = "Inner Exception", Value = innerMessage }); + facts.Add(new Fact + { + Name = "Inner Exception", + Value = innerMessage + }); } - await notifications.PostToTeamsAsync(activityTitle, activitySubtitle, facts); + await notifications.PostToTeamsAsync( + activityTitle, + activitySubtitle, + facts); + await uow.CompleteAsync(); } catch (Exception notifyEx) { - logger.LogWarning(notifyEx, "Failed to send Teams exception notification"); + logger.LogWarning( + notifyEx, + "Failed to send Teams exception notification"); } }); } -} + private static string BuildApplicationStackExcerpt(Exception ex) + { + var trace = new StackTrace(ex, true); + + var frames = trace.GetFrames(); + + if (frames == null || frames.Length == 0) + { + return "(no stack trace)"; + } + + // Keep only application frames + var appFrames = frames + .Where(f => + { + var typeName = + f.GetMethod()?.DeclaringType?.FullName; + + if (string.IsNullOrWhiteSpace(typeName)) + { + return false; + } + + // Include only your application namespaces + return typeName.StartsWith( + "Unity.", + StringComparison.Ordinal); + }) + .Take(5) + .ToList(); + + if (appFrames.Count == 0) + { + return ex.Message; + } + + return string.Join( + Environment.NewLine, + appFrames.Select((f, i) => + { + var method = f.GetMethod(); + + var className = + method?.DeclaringType?.Name ?? "UnknownClass"; + + var methodName = + method?.Name ?? "UnknownMethod"; + + var file = f.GetFileName(); + + var fileName = string.IsNullOrWhiteSpace(file) + ? "unknown" + : Path.GetFileName(file); + + var line = f.GetFileLineNumber(); + + return + $"{i + 1}. " + + $"{className}.{methodName}() " + + $"in {fileName}:{line}"; + })); + } +} \ No newline at end of file diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/GitHubBlameLookupService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/GitHubBlameLookupService.cs index 2c86a2e1e7..c8fafaabd0 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/GitHubBlameLookupService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/GitHubBlameLookupService.cs @@ -9,8 +9,6 @@ namespace Unity.GrantManager.Web.Middleware; - - public class GitHubBlameLookupService : IBlameLookupService { private readonly HttpClient _httpClient; @@ -78,11 +76,13 @@ public GitHubBlameLookupService( _branch = branchOverride; } - string token = - Environment.GetEnvironmentVariable("GITHUB_TOKEN") ?? ""; - - _httpClient.DefaultRequestHeaders.Authorization = - new AuthenticationHeaderValue("Bearer", token); + // Set Authorization header if UNITY_GITHUB_PAT is present + string pat = Environment.GetEnvironmentVariable("UNITY_GITHUB_PAT") ?? string.Empty; + if (!string.IsNullOrWhiteSpace(pat)) + { + _httpClient.DefaultRequestHeaders.Authorization = + new AuthenticationHeaderValue("Bearer", pat); + } _httpClient.DefaultRequestHeaders.UserAgent.ParseAdd( "Unity-GrantManager"); @@ -211,8 +211,22 @@ public string BuildBlameUrl(string reference) query }); + string? githubGraphQlUrl = null; + if (_endpointService != null) + { + try + { + githubGraphQlUrl = _endpointService.GetGitHubGraphQlUrlAsync().GetAwaiter().GetResult(); + } + catch { } + } + + if (githubGraphQlUrl == null) { + return null; + } + using var response = await _httpClient.PostAsync( - "https://api.github.com/graphql", + githubGraphQlUrl, new StringContent( payload, Encoding.UTF8, @@ -282,6 +296,7 @@ public string BuildBlameUrl(string reference) string? prUrl = null; int? prNumber = null; + string? prTitle = null; var prs = commit @@ -291,16 +306,11 @@ public string BuildBlameUrl(string reference) if (prs.GetArrayLength() > 0) { var pr = prs[0]; - - prUrl = - pr.GetProperty("url").GetString(); - - if (pr.TryGetProperty( - "number", - out var numberProp)) + prUrl = pr.GetProperty("url").GetString(); + prTitle = pr.GetProperty("title").GetString(); + if (pr.TryGetProperty("number", out var numberProp)) { - prNumber = - numberProp.GetInt32(); + prNumber = numberProp.GetInt32(); } } @@ -311,7 +321,8 @@ public string BuildBlameUrl(string reference) Email = email, Message = message, PullRequestUrl = prUrl, - PullRequestNumber = prNumber + PullRequestNumber = prNumber, + PullRequestTitle = prTitle }; } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/IBlameLookupService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/IBlameLookupService.cs index 714c61f978..d1429c411e 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/IBlameLookupService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/IBlameLookupService.cs @@ -10,6 +10,7 @@ public record GitHubBlameInfo public string Message { get; init; } = ""; public string? PullRequestUrl { get; init; } public int? PullRequestNumber { get; init; } + public string? PullRequestTitle { get; init; } } public interface IBlameLookupService From a0598dec9641de04128a7456ccb7ea8fe0e5df27 Mon Sep 17 00:00:00 2001 From: JamesPasta Date: Thu, 14 May 2026 11:06:57 -0700 Subject: [PATCH 030/223] feature/AB#32049-Prometheus-Tests --- .../Integrations/DynamicUrlDataSeeder.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Integrations/DynamicUrlDataSeeder.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Integrations/DynamicUrlDataSeeder.cs index 2bad58ab17..d383550eb0 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Integrations/DynamicUrlDataSeeder.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Integrations/DynamicUrlDataSeeder.cs @@ -70,6 +70,7 @@ private async Task SeedDynamicUrlAsync() new() { KeyName = DynamicUrlKeyNames.NOTIFICATION_AUTH, Url = DynamicUrls.CHES_PROD_AUTH, Description = "Common Hosted Email Service OAUTH" }, new() { KeyName = DynamicUrlKeyNames.ANALYTICS_MATOMO_BASE, Url = GetMatomoUrl(), Description = "Matomo Analytics" }, new() { KeyName = DynamicUrlKeyNames.GITHUB_REPO, Url = DynamicUrls.GITHUB_REPO, Description = "GitHub Repository" }, + new() { KeyName = DynamicUrlKeyNames.GITHUB_GRAPHQL, Url = DynamicUrls.GITHUB_GRAPHQL, Description = "GitHub GraphQL Endpoint" }, new() { KeyName = $"{DynamicUrlKeyNames.DIRECT_MESSAGE_KEY_PREFIX}{messageIndex++}", Url = "", Description = $"Direct message webhook {messageIndex}" }, new() { KeyName = $"{DynamicUrlKeyNames.DIRECT_MESSAGE_KEY_PREFIX}{messageIndex++}", Url = "", Description = $"Direct message webhook {messageIndex}" }, new() { KeyName = $"{DynamicUrlKeyNames.DIRECT_MESSAGE_KEY_PREFIX}{messageIndex++}", Url = "", Description = $"Direct message webhook {messageIndex}" }, From 381de392fa904292106f0aae2a7093a924fd880d Mon Sep 17 00:00:00 2001 From: JamesPasta Date: Thu, 14 May 2026 11:37:27 -0700 Subject: [PATCH 031/223] feature/AB#32049-Prometheus-TestsLogging --- .../AbpExceptionNotificationSubscriber.cs | 16 ++--- .../Middleware/GitHubBlameLookupService.cs | 64 +++++++------------ 2 files changed, 32 insertions(+), 48 deletions(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/AbpExceptionNotificationSubscriber.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/AbpExceptionNotificationSubscriber.cs index 9d70656521..2ff2dad8e6 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/AbpExceptionNotificationSubscriber.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/AbpExceptionNotificationSubscriber.cs @@ -103,18 +103,18 @@ private void QueueTeamsNotification(Exception ex) if (blame != null) { + logger.LogInformation("[ExceptionNotify] Blame lookup result: Author={Author}, Commit={Commit}, PR={PR}, PRTitle={PRTitle}", blame.Author, blame.CommitSha, blame.PullRequestUrl, blame.PullRequestTitle); facts.Add(new Fact { Name = "Blame Author", Value = $"{blame.Author} <{blame.Email}>" }); - facts.Add(new Fact { Name = "Blame Commit", Value = $"{blame.CommitSha[..Math.Min(7, blame.CommitSha.Length)]} {blame.Message}" }); + var shortSha = !string.IsNullOrEmpty(blame.CommitSha) && blame.CommitSha.Length > 7 ? blame.CommitSha.Substring(0, 7) : blame.CommitSha; + facts.Add(new Fact { Name = "Blame Commit", Value = $"{shortSha} {blame.Message}" }); if (blame.PullRequestUrl != null) { - facts.Add(new Fact { Name = "Blame PR", Value = $"#{blame.PullRequestNumber} {blame.PullRequestUrl}" }); - } - - if (!string.IsNullOrWhiteSpace(blame.PullRequestTitle)) - { - // Look at the PR title - detect the AB# Pattern - facts.Add(new Fact { Name = "Blame PR Title", Value = blame.PullRequestTitle }); + facts.Add(new Fact { Name = "Blame PR", Value = $"#{blame.PullRequestNumber} {blame.PullRequestUrl}" }); + if (!string.IsNullOrWhiteSpace(blame.PullRequestTitle)) + { + facts.Add(new Fact { Name = "Blame PR Title", Value = blame.PullRequestTitle }); + } } } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/GitHubBlameLookupService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/GitHubBlameLookupService.cs index c8fafaabd0..82ff2b6e19 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/GitHubBlameLookupService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/GitHubBlameLookupService.cs @@ -158,6 +158,8 @@ public string BuildBlameUrl(string reference) line = parsedLine; } + _logger.LogInformation("[BlameLookup] GetBlameFromReferenceAsync called with reference: {Reference}", reference); + return await GetBlameAsync( _owner, _repo, @@ -194,9 +196,8 @@ public string BuildBlameUrl(string reference) if (string.IsNullOrWhiteSpace(owner) || string.IsNullOrWhiteSpace(repo)) { - _logger.LogDebug( - "GitHub blame lookup skipped — owner or repo missing"); - + _logger.LogWarning("[BlameLookup] Owner or repo missing. Owner: {Owner}, Repo: {Repo}", owner, repo); + _logger.LogDebug("GitHub blame lookup skipped — owner or repo missing"); return null; } @@ -225,56 +226,38 @@ public string BuildBlameUrl(string reference) return null; } + _logger.LogInformation("[BlameLookup] Starting blame lookup for {Owner}/{Repo} branch {Branch} path {RepoPath} line {Line}", owner, repo, branch, repoPath, line); + _logger.LogInformation("[BlameLookup] Sending GraphQL request to {Url} with payload: {Payload}", githubGraphQlUrl, payload); using var response = await _httpClient.PostAsync( githubGraphQlUrl, - new StringContent( - payload, - Encoding.UTF8, - "application/json")); - + new StringContent(payload, Encoding.UTF8, "application/json")); + _logger.LogInformation("[BlameLookup] Received response: {StatusCode}", response.StatusCode); response.EnsureSuccessStatusCode(); - - string json = - await response.Content.ReadAsStringAsync(); - - using JsonDocument doc = - JsonDocument.Parse(json); - + string json = await response.Content.ReadAsStringAsync(); + _logger.LogInformation("[BlameLookup] Response JSON: {Json}", json); + using JsonDocument doc = JsonDocument.Parse(json); var root = doc.RootElement; - + _logger.LogInformation("[BlameLookup] Parsed JSON root"); if (root.TryGetProperty("errors", out var errors)) { - _logger.LogWarning( - "GitHub GraphQL errors: {Errors}", - errors.ToString()); - + _logger.LogWarning("[BlameLookup] GraphQL errors: {Errors}", errors.ToString()); + _logger.LogWarning("GitHub GraphQL errors: {Errors}", errors.ToString()); return null; } - - var ranges = - root - .GetProperty("data") - .GetProperty("repository") - .GetProperty("object") - .GetProperty("blame") - .GetProperty("ranges"); - + var ranges = root.GetProperty("data").GetProperty("repository").GetProperty("object").GetProperty("blame").GetProperty("ranges"); + _logger.LogInformation("[BlameLookup] Found {Count} blame ranges", ranges.GetArrayLength()); foreach (var range in ranges.EnumerateArray()) { - int start = - range.GetProperty("startingLine").GetInt32(); - - int end = - range.GetProperty("endingLine").GetInt32(); - + _logger.LogInformation("[BlameLookup] Checking range: start {Start}, end {End}", range.GetProperty("startingLine").GetInt32(), range.GetProperty("endingLine").GetInt32()); + int start = range.GetProperty("startingLine").GetInt32(); + int end = range.GetProperty("endingLine").GetInt32(); if (line < start || line > end) { + _logger.LogInformation("[BlameLookup] Line {Line} not in range {Start}-{End}", line, start, end); continue; } - - var commit = - range.GetProperty("commit"); - + var commit = range.GetProperty("commit"); + _logger.LogInformation("[BlameLookup] Found commit: {Sha}", commit.GetProperty("oid").GetString()); string sha = commit.GetProperty("oid").GetString() ?? ""; @@ -314,6 +297,7 @@ public string BuildBlameUrl(string reference) } } + _logger.LogInformation("[BlameLookup] Returning blame info: Author={Author}, Commit={Commit}, PR={PR}, PRTitle={PRTitle}", authorName, sha, prUrl, prTitle); return new GitHubBlameInfo { CommitSha = sha, @@ -325,7 +309,7 @@ public string BuildBlameUrl(string reference) PullRequestTitle = prTitle }; } - + _logger.LogWarning("[BlameLookup] No matching blame range found for line {Line}", line); return null; } From 11bc3c97dcc8127a1d6afde2ef185bc8fd9d58cf Mon Sep 17 00:00:00 2001 From: JamesPasta Date: Thu, 14 May 2026 12:06:16 -0700 Subject: [PATCH 032/223] feature/AB#32049-Prometheus-TestsLogging --- .../Middleware/AbpExceptionNotificationSubscriber.cs | 3 +++ .../Middleware/GitHubBlameLookupService.cs | 12 +++++++----- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/AbpExceptionNotificationSubscriber.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/AbpExceptionNotificationSubscriber.cs index 2ff2dad8e6..39e3fc6637 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/AbpExceptionNotificationSubscriber.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/AbpExceptionNotificationSubscriber.cs @@ -29,6 +29,7 @@ public class AbpExceptionNotificationSubscriber( public Task HandleAsync(ExceptionNotificationContext context) { + logger.LogInformation("[ExceptionNotify] HandleAsync called for exception: {ExceptionType} - {Message}", context.Exception.GetType().FullName, context.Exception.Message); var ex = context.Exception; // Increment Prometheus counters @@ -43,7 +44,9 @@ public Task HandleAsync(ExceptionNotificationContext context) private void QueueTeamsNotification(Exception ex) { + logger.LogInformation("[ExceptionNotify] QueueTeamsNotification called for exception: {ExceptionType} - {Message}", ex.GetType().FullName, ex.Message); string? env = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT"); + logger.LogInformation("[ExceptionNotify] Environment: {Env}", env); if (!NotifyEnvironments.Contains(env ?? string.Empty)) return; diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/GitHubBlameLookupService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/GitHubBlameLookupService.cs index 82ff2b6e19..d545bf17db 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/GitHubBlameLookupService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/GitHubBlameLookupService.cs @@ -193,6 +193,7 @@ public string BuildBlameUrl(string reference) string repoPath, int line) { + _logger.LogInformation("[BlameLookup] GetBlameAsync entry: owner={Owner}, repo={Repo}, branch={Branch}, repoPath={RepoPath}, line={Line}", owner, repo, branch, repoPath, line); if (string.IsNullOrWhiteSpace(owner) || string.IsNullOrWhiteSpace(repo)) { @@ -206,11 +207,13 @@ public string BuildBlameUrl(string reference) repo, branch, repoPath); + _logger.LogInformation("[BlameLookup] Built GraphQL query: {Query}", query); var payload = JsonSerializer.Serialize(new { query }); + _logger.LogInformation("[BlameLookup] Built payload: {Payload}", payload); string? githubGraphQlUrl = null; if (_endpointService != null) @@ -221,13 +224,12 @@ public string BuildBlameUrl(string reference) } catch { } } - - if (githubGraphQlUrl == null) { + if (githubGraphQlUrl == null) + { + _logger.LogWarning("[BlameLookup] githubGraphQlUrl is null, aborting GraphQL call."); return null; } - - _logger.LogInformation("[BlameLookup] Starting blame lookup for {Owner}/{Repo} branch {Branch} path {RepoPath} line {Line}", owner, repo, branch, repoPath, line); - _logger.LogInformation("[BlameLookup] Sending GraphQL request to {Url} with payload: {Payload}", githubGraphQlUrl, payload); + _logger.LogInformation("[BlameLookup] About to POST to GraphQL endpoint: {Url}", githubGraphQlUrl); using var response = await _httpClient.PostAsync( githubGraphQlUrl, new StringContent(payload, Encoding.UTF8, "application/json")); From d030a406ec7f70a3235a9f272e218ced45c9392a Mon Sep 17 00:00:00 2001 From: JamesPasta Date: Thu, 14 May 2026 12:08:30 -0700 Subject: [PATCH 033/223] feature/AB#32049-Prometheus-TestsLogging --- .../Middleware/GitHubBlameLookupService.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/GitHubBlameLookupService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/GitHubBlameLookupService.cs index d545bf17db..f34511143b 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/GitHubBlameLookupService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/GitHubBlameLookupService.cs @@ -207,6 +207,7 @@ public string BuildBlameUrl(string reference) repo, branch, repoPath); + _logger.LogInformation("[BlameLookup] Using blame path: {Path}", repoPath); _logger.LogInformation("[BlameLookup] Built GraphQL query: {Query}", query); var payload = JsonSerializer.Serialize(new From bb05370ce04c452c06a8ea61e51c2959691386a6 Mon Sep 17 00:00:00 2001 From: JamesPasta Date: Thu, 14 May 2026 12:32:13 -0700 Subject: [PATCH 034/223] feature/AB#32049-Prometheus-TestsLogging --- .../Middleware/GitHubBlameLookupService.cs | 321 +++++++----------- 1 file changed, 130 insertions(+), 191 deletions(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/GitHubBlameLookupService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/GitHubBlameLookupService.cs index f34511143b..9b9b34234a 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/GitHubBlameLookupService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/GitHubBlameLookupService.cs @@ -15,10 +15,9 @@ public class GitHubBlameLookupService : IBlameLookupService private readonly ILogger _logger; private readonly IEndpointManagementAppService? _endpointService; - private readonly string _owner = string.Empty; - private readonly string _repo = string.Empty; - - private string _branch; + private readonly string _owner; + private readonly string _repo; + private readonly string _branch; public GitHubBlameLookupService( HttpClient httpClient, @@ -29,24 +28,23 @@ public GitHubBlameLookupService( _logger = logger; _endpointService = endpointService; - // Try to get repo from endpoint service if available string? repoUrl = null; + if (_endpointService != null) { try { - repoUrl = _endpointService.GetGitHubRepoUrlAsync().GetAwaiter().GetResult(); + repoUrl = _endpointService.GetGitHubRepoUrlAsync() + .GetAwaiter().GetResult(); } catch { } } + if (!string.IsNullOrWhiteSpace(repoUrl)) { var parts = repoUrl.TrimEnd('/').Split('/'); - if (parts.Length >= 2) - { - _owner = parts[^2]; - _repo = parts[^1]; - } + _owner = parts.Length >= 2 ? parts[^2] : ""; + _repo = parts.Length >= 1 ? parts[^1] : ""; } else { @@ -58,83 +56,44 @@ public GitHubBlameLookupService( Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "Production"; - _branch = env switch - { - "Development" => "dev", - "Test" => "test", - "Staging" => "main", - "Production" => "main", - _ => "main" - }; - - // Optional override - var branchOverride = - Environment.GetEnvironmentVariable("GITHUB_BRANCH"); - - if (!string.IsNullOrWhiteSpace(branchOverride)) - { - _branch = branchOverride; - } + _branch = Environment.GetEnvironmentVariable("GITHUB_BRANCH") + ?? env switch + { + "Development" => "dev", + "Test" => "test", + _ => "main" + }; + + string pat = Environment.GetEnvironmentVariable("UNITY_GITHUB_PAT") ?? ""; - // Set Authorization header if UNITY_GITHUB_PAT is present - string pat = Environment.GetEnvironmentVariable("UNITY_GITHUB_PAT") ?? string.Empty; if (!string.IsNullOrWhiteSpace(pat)) { _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", pat); } - _httpClient.DefaultRequestHeaders.UserAgent.ParseAdd( - "Unity-GrantManager"); + _httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("Unity-GrantManager"); } - /// - /// Creates a compact blame reference. - /// - /// Example: - /// main/src/MyFile.cs#L123 - /// public string BuildBlameReference(string path, int line) - { - return $"{_branch}/{path}#L{line}"; - } + => $"{_branch}/{path}#L{line}"; - /// - /// Converts compact reference into a full GitHub URL. - /// - /// Example: - /// https://github.com/bcgov/Unity/blame/main/src/MyFile.cs#L123 - /// public string BuildBlameUrl(string reference) - { - return - $"https://github.com/{_owner}/{_repo}/blame/{reference}"; - } + => $"https://github.com/{_owner}/{_repo}/blame/{reference}"; - /// - /// Lookup blame information from a compact reference. - /// - /// Supported: - /// main/src/MyFile.cs#L123 - /// src/MyFile.cs#L123 - /// - public async Task GetBlameFromReferenceAsync( - string reference) + public Task GetBlameFromReferenceAsync(string reference) { if (string.IsNullOrWhiteSpace(reference)) - { - return null; - } + return Task.FromResult(null); string branch = _branch; string pathWithFragment = reference; - // Try extracting branch int firstSlash = reference.IndexOf('/'); if (firstSlash > 0) { - string possibleBranch = reference[..firstSlash]; + var possibleBranch = reference[..firstSlash]; if (possibleBranch is "main" or "dev" or "test") { @@ -143,49 +102,16 @@ public string BuildBlameUrl(string reference) } } - // Parse: - // src/MyFile.cs#L123 - string[] parts = - pathWithFragment.Split("#L"); - - string path = parts[0]; - - int line = 1; - - if (parts.Length > 1 && - int.TryParse(parts[1], out int parsedLine)) - { - line = parsedLine; - } - - _logger.LogInformation("[BlameLookup] GetBlameFromReferenceAsync called with reference: {Reference}", reference); + var parts = pathWithFragment.Split("#L"); + var path = parts[0]; + var line = (parts.Length > 1 && int.TryParse(parts[1], out var l)) ? l : 1; - return await GetBlameAsync( - _owner, - _repo, - branch, - path, - line); + return GetBlameAsync(_owner, _repo, branch, path, line); } - /// - /// Lookup blame using configured repo + branch. - /// - public async Task GetBlameAsync( - string repoPath, - int line) - { - return await GetBlameAsync( - _owner, - _repo, - _branch, - repoPath, - line); - } + public Task GetBlameAsync(string repoPath, int line) + => GetBlameAsync(_owner, _repo, _branch, repoPath, line); - /// - /// Full blame lookup. - /// public async Task GetBlameAsync( string owner, string repo, @@ -193,139 +119,152 @@ public string BuildBlameUrl(string reference) string repoPath, int line) { - _logger.LogInformation("[BlameLookup] GetBlameAsync entry: owner={Owner}, repo={Repo}, branch={Branch}, repoPath={RepoPath}, line={Line}", owner, repo, branch, repoPath, line); - if (string.IsNullOrWhiteSpace(owner) || - string.IsNullOrWhiteSpace(repo)) - { - _logger.LogWarning("[BlameLookup] Owner or repo missing. Owner: {Owner}, Repo: {Repo}", owner, repo); - _logger.LogDebug("GitHub blame lookup skipped — owner or repo missing"); + if (string.IsNullOrWhiteSpace(owner) || string.IsNullOrWhiteSpace(repo)) return null; - } - var query = BuildQuery( - owner, - repo, - branch, - repoPath); - _logger.LogInformation("[BlameLookup] Using blame path: {Path}", repoPath); - _logger.LogInformation("[BlameLookup] Built GraphQL query: {Query}", query); + var sha = await ResolveBranchShaAsync(owner, repo, branch); + if (string.IsNullOrWhiteSpace(sha)) + return null; - var payload = JsonSerializer.Serialize(new - { - query - }); - _logger.LogInformation("[BlameLookup] Built payload: {Payload}", payload); + var query = BuildBlameQuery(owner, repo, sha, repoPath); - string? githubGraphQlUrl = null; - if (_endpointService != null) - { - try - { - githubGraphQlUrl = _endpointService.GetGitHubGraphQlUrlAsync().GetAwaiter().GetResult(); - } - catch { } - } - if (githubGraphQlUrl == null) - { - _logger.LogWarning("[BlameLookup] githubGraphQlUrl is null, aborting GraphQL call."); + var payload = JsonSerializer.Serialize(new { query }); + + var url = await GetGraphQlUrlAsync(); + if (url == null) return null; - } - _logger.LogInformation("[BlameLookup] About to POST to GraphQL endpoint: {Url}", githubGraphQlUrl); + using var response = await _httpClient.PostAsync( - githubGraphQlUrl, + url, new StringContent(payload, Encoding.UTF8, "application/json")); - _logger.LogInformation("[BlameLookup] Received response: {StatusCode}", response.StatusCode); - response.EnsureSuccessStatusCode(); - string json = await response.Content.ReadAsStringAsync(); - _logger.LogInformation("[BlameLookup] Response JSON: {Json}", json); - using JsonDocument doc = JsonDocument.Parse(json); + + var json = await response.Content.ReadAsStringAsync(); + + if (!response.IsSuccessStatusCode) + { + _logger.LogWarning("[BlameLookup] GitHub error: {Json}", json); + return null; + } + + using var doc = JsonDocument.Parse(json); var root = doc.RootElement; - _logger.LogInformation("[BlameLookup] Parsed JSON root"); + if (root.TryGetProperty("errors", out var errors)) { - _logger.LogWarning("[BlameLookup] GraphQL errors: {Errors}", errors.ToString()); - _logger.LogWarning("GitHub GraphQL errors: {Errors}", errors.ToString()); + _logger.LogWarning("[BlameLookup] GraphQL errors: {Errors}", errors); return null; } - var ranges = root.GetProperty("data").GetProperty("repository").GetProperty("object").GetProperty("blame").GetProperty("ranges"); - _logger.LogInformation("[BlameLookup] Found {Count} blame ranges", ranges.GetArrayLength()); + + var ranges = root + .GetProperty("data") + .GetProperty("repository") + .GetProperty("object") + .GetProperty("blame") + .GetProperty("ranges"); + foreach (var range in ranges.EnumerateArray()) { - _logger.LogInformation("[BlameLookup] Checking range: start {Start}, end {End}", range.GetProperty("startingLine").GetInt32(), range.GetProperty("endingLine").GetInt32()); int start = range.GetProperty("startingLine").GetInt32(); int end = range.GetProperty("endingLine").GetInt32(); + if (line < start || line > end) - { - _logger.LogInformation("[BlameLookup] Line {Line} not in range {Start}-{End}", line, start, end); continue; - } - var commit = range.GetProperty("commit"); - _logger.LogInformation("[BlameLookup] Found commit: {Sha}", commit.GetProperty("oid").GetString()); - string sha = - commit.GetProperty("oid").GetString() - ?? ""; - - string message = - commit.GetProperty("messageHeadline").GetString() - ?? ""; - var author = - commit.GetProperty("author"); + var commit = range.GetProperty("commit"); - string authorName = - author.GetProperty("name").GetString() - ?? ""; + var author = commit.GetProperty("author"); - string email = - author.GetProperty("email").GetString() - ?? ""; + var prs = commit + .GetProperty("associatedPullRequests") + .GetProperty("nodes"); string? prUrl = null; - int? prNumber = null; string? prTitle = null; - - var prs = - commit - .GetProperty("associatedPullRequests") - .GetProperty("nodes"); + int? prNumber = null; if (prs.GetArrayLength() > 0) { var pr = prs[0]; prUrl = pr.GetProperty("url").GetString(); prTitle = pr.GetProperty("title").GetString(); - if (pr.TryGetProperty("number", out var numberProp)) - { - prNumber = numberProp.GetInt32(); - } + + if (pr.TryGetProperty("number", out var n)) + prNumber = n.GetInt32(); } - _logger.LogInformation("[BlameLookup] Returning blame info: Author={Author}, Commit={Commit}, PR={PR}, PRTitle={PRTitle}", authorName, sha, prUrl, prTitle); return new GitHubBlameInfo { - CommitSha = sha, - Author = authorName, - Email = email, - Message = message, + CommitSha = commit.GetProperty("oid").GetString() ?? "", + Author = author.GetProperty("name").GetString() ?? "", + Email = author.GetProperty("email").GetString() ?? "", + Message = commit.GetProperty("messageHeadline").GetString() ?? "", PullRequestUrl = prUrl, PullRequestNumber = prNumber, PullRequestTitle = prTitle }; } - _logger.LogWarning("[BlameLookup] No matching blame range found for line {Line}", line); + return null; } - private string BuildQuery( - string owner, - string repo, - string branch, - string path) + private async Task ResolveBranchShaAsync(string owner, string repo, string branch) + { + var query = $@" +query {{ + repository(owner: ""{owner}"", name: ""{repo}"") {{ + ref(qualifiedName: ""refs/heads/{branch}"") {{ + target {{ + ... on Commit {{ + oid + }} + }} + }} + }} +}}"; + + var payload = JsonSerializer.Serialize(new { query }); + + var url = await GetGraphQlUrlAsync(); + if (url == null) + return null; + + using var response = await _httpClient.PostAsync( + url, + new StringContent(payload, Encoding.UTF8, "application/json")); + + var json = await response.Content.ReadAsStringAsync(); + + using var doc = JsonDocument.Parse(json); + + return doc.RootElement + .GetProperty("data") + .GetProperty("repository") + .GetProperty("ref") + .GetProperty("target") + .GetProperty("oid") + .GetString(); + } + + private async Task GetGraphQlUrlAsync() + { + try + { + return _endpointService != null + ? await _endpointService.GetGitHubGraphQlUrlAsync() + : "https://api.github.com/graphql"; + } + catch + { + return "https://api.github.com/graphql"; + } + } + + private static string BuildBlameQuery(string owner, string repo, string sha, string path) { return $@" query {{ repository(owner: ""{owner}"", name: ""{repo}"") {{ - object(expression: ""{branch}"") {{ + object(oid: ""{sha}"") {{ ... on Commit {{ blame(path: ""{path}"") {{ ranges {{ From 5e6ed09c178092a55cbb5a4233451a2a8ceb038b Mon Sep 17 00:00:00 2001 From: JamesPasta Date: Thu, 14 May 2026 12:56:37 -0700 Subject: [PATCH 035/223] feature/AB#32049-Prometheus-TestsLogging --- .../Middleware/GitHubBlameLookupService.cs | 39 +++++++++++++------ 1 file changed, 27 insertions(+), 12 deletions(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/GitHubBlameLookupService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/GitHubBlameLookupService.cs index 9b9b34234a..52c6539d1c 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/GitHubBlameLookupService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/GitHubBlameLookupService.cs @@ -122,12 +122,13 @@ public string BuildBlameUrl(string reference) if (string.IsNullOrWhiteSpace(owner) || string.IsNullOrWhiteSpace(repo)) return null; - var sha = await ResolveBranchShaAsync(owner, repo, branch); + // 🔥 FIX: resolve ACTUAL HEAD commit safely (not ambiguous ref target) + var sha = await ResolveBranchHeadCommitShaAsync(owner, repo, branch); + if (string.IsNullOrWhiteSpace(sha)) return null; var query = BuildBlameQuery(owner, repo, sha, repoPath); - var payload = JsonSerializer.Serialize(new { query }); var url = await GetGraphQlUrlAsync(); @@ -171,7 +172,6 @@ public string BuildBlameUrl(string reference) continue; var commit = range.GetProperty("commit"); - var author = commit.GetProperty("author"); var prs = commit @@ -207,7 +207,8 @@ public string BuildBlameUrl(string reference) return null; } - private async Task ResolveBranchShaAsync(string owner, string repo, string branch) + // 🔥 FIXED: reliable HEAD commit resolution (prevents wrong object types) + private async Task ResolveBranchHeadCommitShaAsync(string owner, string repo, string branch) { var query = $@" query {{ @@ -215,7 +216,11 @@ public string BuildBlameUrl(string reference) ref(qualifiedName: ""refs/heads/{branch}"") {{ target {{ ... on Commit {{ - oid + history(first: 1) {{ + nodes {{ + oid + }} + }} }} }} }} @@ -236,13 +241,23 @@ ... on Commit {{ using var doc = JsonDocument.Parse(json); - return doc.RootElement - .GetProperty("data") - .GetProperty("repository") - .GetProperty("ref") - .GetProperty("target") - .GetProperty("oid") - .GetString(); + try + { + return doc.RootElement + .GetProperty("data") + .GetProperty("repository") + .GetProperty("ref") + .GetProperty("target") + .GetProperty("history") + .GetProperty("nodes")[0] + .GetProperty("oid") + .GetString(); + } + catch + { + _logger.LogWarning("[BlameLookup] Failed to resolve HEAD commit SHA"); + return null; + } } private async Task GetGraphQlUrlAsync() From 1f16da05c8e1e6efad6f2641cd90fa8307a6a5a9 Mon Sep 17 00:00:00 2001 From: JamesPasta Date: Thu, 14 May 2026 14:22:29 -0700 Subject: [PATCH 036/223] feature/AB#32049-Prometheus-TestsLogging --- .../Middleware/GitHubBlameLookupService.cs | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/GitHubBlameLookupService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/GitHubBlameLookupService.cs index 52c6539d1c..06765bd93e 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/GitHubBlameLookupService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/GitHubBlameLookupService.cs @@ -134,18 +134,13 @@ public string BuildBlameUrl(string reference) var url = await GetGraphQlUrlAsync(); if (url == null) return null; - + _logger.LogInformation("[BlameLookup] Request payload: {Payload}", payload); using var response = await _httpClient.PostAsync( url, new StringContent(payload, Encoding.UTF8, "application/json")); - - var json = await response.Content.ReadAsStringAsync(); - - if (!response.IsSuccessStatusCode) - { - _logger.LogWarning("[BlameLookup] GitHub error: {Json}", json); - return null; - } + response.EnsureSuccessStatusCode(); + string json = await response.Content.ReadAsStringAsync(); + _logger.LogInformation("[BlameLookup] Response body: {Json}", json); using var doc = JsonDocument.Parse(json); var root = doc.RootElement; @@ -236,8 +231,9 @@ ... on Commit {{ using var response = await _httpClient.PostAsync( url, new StringContent(payload, Encoding.UTF8, "application/json")); - - var json = await response.Content.ReadAsStringAsync(); + response.EnsureSuccessStatusCode(); + string json = await response.Content.ReadAsStringAsync(); + _logger.LogInformation("[BlameLookup] Response body: {Json}", json); using var doc = JsonDocument.Parse(json); From 32b329515457dbb75bbc2d1aefe22e16aa7650da Mon Sep 17 00:00:00 2001 From: JamesPasta Date: Thu, 14 May 2026 14:36:04 -0700 Subject: [PATCH 037/223] feature/AB#32049-Prometheus-TestsLogging --- .../Middleware/AbpExceptionNotificationSubscriber.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/AbpExceptionNotificationSubscriber.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/AbpExceptionNotificationSubscriber.cs index 39e3fc6637..bd11b1a29e 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/AbpExceptionNotificationSubscriber.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/AbpExceptionNotificationSubscriber.cs @@ -83,6 +83,9 @@ private void QueueTeamsNotification(Exception ex) string sourceFile = NormalizeRepoPath(frame?.File ?? "(unknown)"); int? sourceLine = frame?.Line; + // Fix: prepend repo root for blame lookup + string blamePath = $"applications/Unity.GrantManager/{sourceFile}"; + var facts = new List { new() { Name = "Exception", Value = exTypeName }, @@ -102,7 +105,7 @@ private void QueueTeamsNotification(Exception ex) try { var blameService = scope.ServiceProvider.GetRequiredService(); - var blame = await blameService.GetBlameAsync(sourceFile, sourceLine.Value); + var blame = await blameService.GetBlameAsync(blamePath, sourceLine.Value); if (blame != null) { From c660c419d0c955458a30cdb7b77ab239705c4de2 Mon Sep 17 00:00:00 2001 From: JamesPasta Date: Thu, 14 May 2026 15:05:04 -0700 Subject: [PATCH 038/223] feature/AB#32049-Prometheus-TestsLogging --- .../AbpExceptionNotificationSubscriber.cs | 4 +- .../Middleware/GitHubBlameLookupService.cs | 75 +++---------------- 2 files changed, 12 insertions(+), 67 deletions(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/AbpExceptionNotificationSubscriber.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/AbpExceptionNotificationSubscriber.cs index bd11b1a29e..f5fce8b1d3 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/AbpExceptionNotificationSubscriber.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/AbpExceptionNotificationSubscriber.cs @@ -83,8 +83,6 @@ private void QueueTeamsNotification(Exception ex) string sourceFile = NormalizeRepoPath(frame?.File ?? "(unknown)"); int? sourceLine = frame?.Line; - // Fix: prepend repo root for blame lookup - string blamePath = $"applications/Unity.GrantManager/{sourceFile}"; var facts = new List { @@ -104,6 +102,8 @@ private void QueueTeamsNotification(Exception ex) { try { + // Fix: prepend repo root for blame lookup + string blamePath = $"applications/Unity.GrantManager/{sourceFile}"; var blameService = scope.ServiceProvider.GetRequiredService(); var blame = await blameService.GetBlameAsync(blamePath, sourceLine.Value); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/GitHubBlameLookupService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/GitHubBlameLookupService.cs index 06765bd93e..bc7d5d375c 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/GitHubBlameLookupService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/GitHubBlameLookupService.cs @@ -59,7 +59,7 @@ public GitHubBlameLookupService( _branch = Environment.GetEnvironmentVariable("GITHUB_BRANCH") ?? env switch { - "Development" => "dev", + "Development" => "dev2", // FIX LATER "Test" => "test", _ => "main" }; @@ -95,7 +95,7 @@ public string BuildBlameUrl(string reference) { var possibleBranch = reference[..firstSlash]; - if (possibleBranch is "main" or "dev" or "test") + if (possibleBranch is "main" or "dev" or "dev2" or "test") { branch = possibleBranch; pathWithFragment = reference[(firstSlash + 1)..]; @@ -122,24 +122,23 @@ public string BuildBlameUrl(string reference) if (string.IsNullOrWhiteSpace(owner) || string.IsNullOrWhiteSpace(repo)) return null; - // 🔥 FIX: resolve ACTUAL HEAD commit safely (not ambiguous ref target) - var sha = await ResolveBranchHeadCommitShaAsync(owner, repo, branch); - - if (string.IsNullOrWhiteSpace(sha)) - return null; - - var query = BuildBlameQuery(owner, repo, sha, repoPath); + var query = BuildBlameQuery(owner, repo, branch, repoPath); var payload = JsonSerializer.Serialize(new { query }); var url = await GetGraphQlUrlAsync(); if (url == null) return null; + _logger.LogInformation("[BlameLookup] Request payload: {Payload}", payload); + using var response = await _httpClient.PostAsync( url, new StringContent(payload, Encoding.UTF8, "application/json")); + response.EnsureSuccessStatusCode(); + string json = await response.Content.ReadAsStringAsync(); + _logger.LogInformation("[BlameLookup] Response body: {Json}", json); using var doc = JsonDocument.Parse(json); @@ -202,60 +201,6 @@ public string BuildBlameUrl(string reference) return null; } - // 🔥 FIXED: reliable HEAD commit resolution (prevents wrong object types) - private async Task ResolveBranchHeadCommitShaAsync(string owner, string repo, string branch) - { - var query = $@" -query {{ - repository(owner: ""{owner}"", name: ""{repo}"") {{ - ref(qualifiedName: ""refs/heads/{branch}"") {{ - target {{ - ... on Commit {{ - history(first: 1) {{ - nodes {{ - oid - }} - }} - }} - }} - }} - }} -}}"; - - var payload = JsonSerializer.Serialize(new { query }); - - var url = await GetGraphQlUrlAsync(); - if (url == null) - return null; - - using var response = await _httpClient.PostAsync( - url, - new StringContent(payload, Encoding.UTF8, "application/json")); - response.EnsureSuccessStatusCode(); - string json = await response.Content.ReadAsStringAsync(); - _logger.LogInformation("[BlameLookup] Response body: {Json}", json); - - using var doc = JsonDocument.Parse(json); - - try - { - return doc.RootElement - .GetProperty("data") - .GetProperty("repository") - .GetProperty("ref") - .GetProperty("target") - .GetProperty("history") - .GetProperty("nodes")[0] - .GetProperty("oid") - .GetString(); - } - catch - { - _logger.LogWarning("[BlameLookup] Failed to resolve HEAD commit SHA"); - return null; - } - } - private async Task GetGraphQlUrlAsync() { try @@ -270,12 +215,12 @@ ... on Commit {{ } } - private static string BuildBlameQuery(string owner, string repo, string sha, string path) + private static string BuildBlameQuery(string owner, string repo, string branch, string path) { return $@" query {{ repository(owner: ""{owner}"", name: ""{repo}"") {{ - object(oid: ""{sha}"") {{ + object(expression: ""{branch}"") {{ ... on Commit {{ blame(path: ""{path}"") {{ ranges {{ From 7590cae62e655b2269caca1f4024df1538538bbd Mon Sep 17 00:00:00 2001 From: JamesPasta Date: Thu, 14 May 2026 15:35:50 -0700 Subject: [PATCH 039/223] feature/AB#32049-Prometheus-TestsLogging --- .../AbpExceptionNotificationSubscriber.cs | 15 ++++++++------- .../Middleware/GitHubBlameLookupService.cs | 12 +++++------- 2 files changed, 13 insertions(+), 14 deletions(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/AbpExceptionNotificationSubscriber.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/AbpExceptionNotificationSubscriber.cs index f5fce8b1d3..1926bbafc8 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/AbpExceptionNotificationSubscriber.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/AbpExceptionNotificationSubscriber.cs @@ -63,8 +63,10 @@ private void QueueTeamsNotification(Exception ex) string exMessage = ex.Message; string innerMessage = ex.InnerException?.Message ?? string.Empty; string stackTrace = ex.StackTrace ?? "(no stack trace)"; - if (stackTrace.Length > 1500) - stackTrace = stackTrace[..1500] + "\n... (truncated)"; + // Shorten stack trace to first 5 lines + var stackLines = stackTrace.Split('\n'); + if (stackLines.Length > 5) + stackTrace = string.Join("\n", stackLines[..5]) + "\n... (truncated)"; _ = Task.Run(async () => { @@ -91,8 +93,7 @@ private void QueueTeamsNotification(Exception ex) new() { Name = "Endpoint", Value = endpoint }, new() { Name = "Stack Trace", Value = stackTrace }, new() { Name = "Source", Value = sourceLine.HasValue ? $"{sourceFile}:{sourceLine}" : sourceFile }, - new() { Name = "Commit", Value = ExceptionCounterMiddleware.CommitSha }, - new() { Name = "Author", Value = ExceptionCounterMiddleware.CommitAuthor }, + new() { Name = "Release Number", Value = ExceptionCounterMiddleware.CommitSha }, }; if (!string.IsNullOrEmpty(innerMessage)) @@ -110,16 +111,16 @@ private void QueueTeamsNotification(Exception ex) if (blame != null) { logger.LogInformation("[ExceptionNotify] Blame lookup result: Author={Author}, Commit={Commit}, PR={PR}, PRTitle={PRTitle}", blame.Author, blame.CommitSha, blame.PullRequestUrl, blame.PullRequestTitle); - facts.Add(new Fact { Name = "Blame Author", Value = $"{blame.Author} <{blame.Email}>" }); + facts.Add(new Fact { Name = "Author", Value = $"{blame.Author} <{blame.Email}>" }); var shortSha = !string.IsNullOrEmpty(blame.CommitSha) && blame.CommitSha.Length > 7 ? blame.CommitSha.Substring(0, 7) : blame.CommitSha; facts.Add(new Fact { Name = "Blame Commit", Value = $"{shortSha} {blame.Message}" }); if (blame.PullRequestUrl != null) { - facts.Add(new Fact { Name = "Blame PR", Value = $"#{blame.PullRequestNumber} {blame.PullRequestUrl}" }); + facts.Add(new Fact { Name = "PR", Value = $"#{blame.PullRequestNumber} {blame.PullRequestUrl}" }); if (!string.IsNullOrWhiteSpace(blame.PullRequestTitle)) { - facts.Add(new Fact { Name = "Blame PR Title", Value = blame.PullRequestTitle }); + facts.Add(new Fact { Name = "PR Title", Value = blame.PullRequestTitle }); } } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/GitHubBlameLookupService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/GitHubBlameLookupService.cs index bc7d5d375c..cc6e8d439f 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/GitHubBlameLookupService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/GitHubBlameLookupService.cs @@ -59,11 +59,15 @@ public GitHubBlameLookupService( _branch = Environment.GetEnvironmentVariable("GITHUB_BRANCH") ?? env switch { - "Development" => "dev2", // FIX LATER + "Development" => "dev", "Test" => "test", _ => "main" }; + if( Environment.GetEnvironmentVariable("RabbitMQ__VirtualHost") == "dev2") { + _branch = "dev2"; + } + string pat = Environment.GetEnvironmentVariable("UNITY_GITHUB_PAT") ?? ""; if (!string.IsNullOrWhiteSpace(pat)) @@ -75,12 +79,6 @@ public GitHubBlameLookupService( _httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("Unity-GrantManager"); } - public string BuildBlameReference(string path, int line) - => $"{_branch}/{path}#L{line}"; - - public string BuildBlameUrl(string reference) - => $"https://github.com/{_owner}/{_repo}/blame/{reference}"; - public Task GetBlameFromReferenceAsync(string reference) { if (string.IsNullOrWhiteSpace(reference)) From 3db80f56b20ea507ec5b06cbe3b08cbafee43b3d Mon Sep 17 00:00:00 2001 From: JamesPasta Date: Thu, 14 May 2026 15:49:17 -0700 Subject: [PATCH 040/223] feature/AB#32049-Prometheus-TestsLogging --- .../Middleware/AbpExceptionNotificationSubscriber.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/AbpExceptionNotificationSubscriber.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/AbpExceptionNotificationSubscriber.cs index 1926bbafc8..4093d536b7 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/AbpExceptionNotificationSubscriber.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/AbpExceptionNotificationSubscriber.cs @@ -113,7 +113,7 @@ private void QueueTeamsNotification(Exception ex) logger.LogInformation("[ExceptionNotify] Blame lookup result: Author={Author}, Commit={Commit}, PR={PR}, PRTitle={PRTitle}", blame.Author, blame.CommitSha, blame.PullRequestUrl, blame.PullRequestTitle); facts.Add(new Fact { Name = "Author", Value = $"{blame.Author} <{blame.Email}>" }); var shortSha = !string.IsNullOrEmpty(blame.CommitSha) && blame.CommitSha.Length > 7 ? blame.CommitSha.Substring(0, 7) : blame.CommitSha; - facts.Add(new Fact { Name = "Blame Commit", Value = $"{shortSha} {blame.Message}" }); + facts.Add(new Fact { Name = "Commit", Value = $"{shortSha} {blame.Message}" }); if (blame.PullRequestUrl != null) { From 080fd140b5ff051200da5e745a6e7bbc35fc40b6 Mon Sep 17 00:00:00 2001 From: JamesPasta Date: Fri, 22 May 2026 16:06:14 -0700 Subject: [PATCH 041/223] feature/AB#32049-FixBuildExceptionConsistency --- .../AbpExceptionNotificationSubscriber.cs | 103 ++++------- .../Middleware/AbpUserTenantAccessor.cs | 43 +++++ .../Middleware/ExceptionCounterMiddleware.cs | 171 +++++++----------- .../ExceptionNotificationHelpers.cs | 126 +++++++++++++ 4 files changed, 262 insertions(+), 181 deletions(-) create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/AbpUserTenantAccessor.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/ExceptionNotificationHelpers.cs diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/AbpExceptionNotificationSubscriber.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/AbpExceptionNotificationSubscriber.cs index 4093d536b7..5ba4cd3119 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/AbpExceptionNotificationSubscriber.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/AbpExceptionNotificationSubscriber.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Diagnostics; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; @@ -62,11 +61,7 @@ private void QueueTeamsNotification(Exception ex) string exTypeName = ex.GetType().FullName ?? ex.GetType().Name; string exMessage = ex.Message; string innerMessage = ex.InnerException?.Message ?? string.Empty; - string stackTrace = ex.StackTrace ?? "(no stack trace)"; - // Shorten stack trace to first 5 lines - var stackLines = stackTrace.Split('\n'); - if (stackLines.Length > 5) - stackTrace = string.Join("\n", stackLines[..5]) + "\n... (truncated)"; + string stackTrace = ExceptionNotificationHelpers.BuildApplicationStackExcerpt(ex); _ = Task.Run(async () => { @@ -79,56 +74,51 @@ private void QueueTeamsNotification(Exception ex) using var uow = uowManager.Begin(requiresNew: true, isTransactional: false); string activityTitle = $"[{env?.ToUpperInvariant()}] {ex.GetType().Name}"; - string activitySubtitle = $"Environment: {env} | {endpoint}"; - var frame = GetTopFrame(ex); - string sourceFile = NormalizeRepoPath(frame?.File ?? "(unknown)"); + var frame = ExceptionNotificationHelpers.GetTopFrame(ex); + string sourceFile = ExceptionNotificationHelpers.NormalizeRepoPath(frame?.File ?? "(unknown)"); int? sourceLine = frame?.Line; + var userName = AbpUserTenantAccessor.GetCurrentUserName(scope.ServiceProvider) ?? "unknown"; + var tenantName = AbpUserTenantAccessor.GetCurrentTenantName(scope.ServiceProvider) ?? "unknown"; - var facts = new List - { - new() { Name = "Exception", Value = exTypeName }, - new() { Name = "Message", Value = exMessage }, - new() { Name = "Endpoint", Value = endpoint }, - new() { Name = "Stack Trace", Value = stackTrace }, - new() { Name = "Source", Value = sourceLine.HasValue ? $"{sourceFile}:{sourceLine}" : sourceFile }, - new() { Name = "Release Number", Value = ExceptionCounterMiddleware.CommitSha }, - }; + string activitySubtitle = $"Environment: {env} | {endpoint} | {userName}@{tenantName}"; - if (!string.IsNullOrEmpty(innerMessage)) - facts.Add(new Fact { Name = "Inner Exception", Value = innerMessage }); + var facts = ExceptionNotificationHelpers.BuildFacts( + exTypeName, + exMessage, + endpoint, + userName, + tenantName, + stackTrace, + sourceFile, + sourceLine, + ExceptionCounterMiddleware.CommitSha, + innerMessage); if (sourceLine.HasValue) { - try + // Use required service to fail-fast if the blame lookup service is not registered + string blamePath = ExceptionNotificationHelpers.BuildBlamePath(sourceFile); + var blameService = scope.ServiceProvider.GetRequiredService(); + var blame = await blameService.GetBlameAsync(blamePath, sourceLine.Value); + + if (blame != null) { - // Fix: prepend repo root for blame lookup - string blamePath = $"applications/Unity.GrantManager/{sourceFile}"; - var blameService = scope.ServiceProvider.GetRequiredService(); - var blame = await blameService.GetBlameAsync(blamePath, sourceLine.Value); - - if (blame != null) - { - logger.LogInformation("[ExceptionNotify] Blame lookup result: Author={Author}, Commit={Commit}, PR={PR}, PRTitle={PRTitle}", blame.Author, blame.CommitSha, blame.PullRequestUrl, blame.PullRequestTitle); - facts.Add(new Fact { Name = "Author", Value = $"{blame.Author} <{blame.Email}>" }); - var shortSha = !string.IsNullOrEmpty(blame.CommitSha) && blame.CommitSha.Length > 7 ? blame.CommitSha.Substring(0, 7) : blame.CommitSha; - facts.Add(new Fact { Name = "Commit", Value = $"{shortSha} {blame.Message}" }); + logger.LogInformation("[ExceptionNotify] Blame lookup result: Author={Author}, Commit={Commit}, PR={PR}, PRTitle={PRTitle}", blame.Author, blame.CommitSha, blame.PullRequestUrl, blame.PullRequestTitle); + facts.Add(new Fact { Name = "Author", Value = $"{blame.Author} <{blame.Email}>" }); + var shortSha = !string.IsNullOrEmpty(blame.CommitSha) && blame.CommitSha.Length > 7 ? blame.CommitSha.Substring(0, 7) : blame.CommitSha; + facts.Add(new Fact { Name = "Commit", Value = $"{shortSha} {blame.Message}" }); - if (blame.PullRequestUrl != null) + if (blame.PullRequestUrl != null) + { + facts.Add(new Fact { Name = "PR", Value = $"#{blame.PullRequestNumber} {blame.PullRequestUrl}" }); + if (!string.IsNullOrWhiteSpace(blame.PullRequestTitle)) { - facts.Add(new Fact { Name = "PR", Value = $"#{blame.PullRequestNumber} {blame.PullRequestUrl}" }); - if (!string.IsNullOrWhiteSpace(blame.PullRequestTitle)) - { - facts.Add(new Fact { Name = "PR Title", Value = blame.PullRequestTitle }); - } + facts.Add(new Fact { Name = "PR Title", Value = blame.PullRequestTitle }); } } } - catch (Exception blameEx) - { - logger.LogDebug(blameEx, "Blame lookup failed for {File}:{Line}", sourceFile, sourceLine); - } } await notifications.PostToTeamsAsync(activityTitle, activitySubtitle, facts); @@ -141,34 +131,5 @@ private void QueueTeamsNotification(Exception ex) }); } - private static string NormalizeRepoPath(string fullPath) - { - const string marker = "src/"; - - int idx = fullPath.IndexOf(marker, StringComparison.OrdinalIgnoreCase); - if (idx < 0) - return fullPath.Replace("\\", "/"); - - return fullPath[(idx + marker.Length)..] - .Replace("\\", "/"); - } - - private static (string? File, int? Line)? GetTopFrame(Exception ex) - { - var trace = new StackTrace(ex, true); - - foreach (var frame in trace.GetFrames() ?? []) - { - var file = frame.GetFileName(); - var line = frame.GetFileLineNumber(); - - if (!string.IsNullOrWhiteSpace(file) && line > 0) - { - return (file, line); - } - } - - return null; - } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/AbpUserTenantAccessor.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/AbpUserTenantAccessor.cs new file mode 100644 index 0000000000..452a954383 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/AbpUserTenantAccessor.cs @@ -0,0 +1,43 @@ +using System; +using Microsoft.Extensions.DependencyInjection; +using Volo.Abp.Users; +using Volo.Abp.MultiTenancy; + +namespace Unity.GrantManager.Web.Middleware +{ + // Accessor used by middleware to resolve current user/tenant information from a service provider. + internal static class AbpUserTenantAccessor + { + public static string? GetCurrentUserName(IServiceProvider serviceProvider) + { + var currentUser = serviceProvider.GetService(); + return currentUser?.UserName ?? currentUser?.Name; + } + + public static string? GetCurrentTenantName(IServiceProvider serviceProvider) + { + var currentTenant = serviceProvider.GetService(); + + if (currentTenant != null) + { + var nameProp = currentTenant.GetType().GetProperty("Name"); + if (nameProp != null) + { + var value = nameProp.GetValue(currentTenant) as string; + if (!string.IsNullOrWhiteSpace(value)) + { + return value; + } + } + } + + var currentUser = serviceProvider.GetService(); + if (currentUser?.TenantId != null) + { + return currentUser.TenantId.ToString(); + } + + return null; + } + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/ExceptionCounterMiddleware.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/ExceptionCounterMiddleware.cs index 83ee37b0d1..dbd2b20439 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/ExceptionCounterMiddleware.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/ExceptionCounterMiddleware.cs @@ -35,7 +35,7 @@ public class ExceptionCounterMiddleware( "Total number of application exceptions", new CounterConfiguration { - LabelNames = ["type"] + LabelNames = new[] { "type" } }); // Git SHA baked in at build time via -p:SourceRevisionId= in the Dockerfile. @@ -85,6 +85,8 @@ public async Task InvokeAsync(HttpContext context) } } + // Repo path and frame helpers are provided by ExceptionNotificationHelpers to avoid duplication + private void QueueTeamsNotification(HttpContext context, Exception ex) { string? env = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT"); @@ -109,7 +111,7 @@ private void QueueTeamsNotification(HttpContext context, Exception ex) string innerMessage = ex.InnerException?.Message ?? string.Empty; // Compact stack trace with only application frames - string stackTrace = BuildApplicationStackExcerpt(ex); + string stackTrace = ExceptionNotificationHelpers.BuildApplicationStackExcerpt(ex); // Resolve a scoped INotificationsAppService from a fresh DI scope so // we can safely use it after the request scope has ended @@ -126,64 +128,71 @@ private void QueueTeamsNotification(HttpContext context, Exception ex) var notifications = scope.ServiceProvider.GetRequiredService(); - using var uow = uowManager.Begin( - requiresNew: true, - isTransactional: false); - - string activityTitle = $"[CRITICAL] {ex.GetType().Name}"; - - string activitySubtitle = - $"Environment: {env} | {endpoint}"; - - var facts = new List + // Get current user and tenant name + var userName = AbpUserTenantAccessor.GetCurrentUserName(scope.ServiceProvider) ?? "unknown"; + var tenantName = AbpUserTenantAccessor.GetCurrentTenantName(scope.ServiceProvider) ?? "unknown"; + + // Determine top frame (file/line) for initial facts so variables exist when creating the list + var topForFacts = ExceptionNotificationHelpers.GetTopFrame(ex); + string sourceFile = ExceptionNotificationHelpers.NormalizeRepoPath(topForFacts?.File ?? "(unknown)"); + int? sourceLine = topForFacts?.Line; + + var facts = ExceptionNotificationHelpers.BuildFacts( + exTypeName, + exMessage, + endpoint, + userName, + tenantName, + stackTrace, + sourceFile, + sourceLine, + ExceptionCounterMiddleware.CommitSha, + innerMessage); + + // Try to enrich with blame info similar to AbpExceptionNotificationSubscriber + try { - new() - { - Name = "Exception", - Value = exTypeName - }, - new() - { - Name = "Message", - Value = exMessage - }, - new() - { - Name = "Endpoint", - Value = endpoint - }, - new() - { - Name = "Application Stack", - Value = stackTrace - }, - new() - { - Name = "Commit", - Value = CommitSha - }, - new() + if (sourceLine.HasValue) { - Name = "Author", - Value = CommitAuthor + // Use required service to fail-fast if the blame lookup service is not registered + string blamePath = ExceptionNotificationHelpers.BuildBlamePath(sourceFile); + var blameService = scope.ServiceProvider.GetRequiredService(); + var blame = await blameService.GetBlameAsync(blamePath, sourceLine.Value); + if (blame != null) + { + facts.Add(new Fact { Name = "Author", Value = $"{blame.Author} <{blame.Email}>" }); + var shortSha = !string.IsNullOrEmpty(blame.CommitSha) && blame.CommitSha.Length > 7 ? blame.CommitSha.Substring(0, 7) : blame.CommitSha; + facts.Add(new Fact { Name = "Commit", Value = $"{shortSha} {blame.Message}" }); + + if (blame.PullRequestUrl != null) + { + facts.Add(new Fact { Name = "PR", Value = $"#{blame.PullRequestNumber} {blame.PullRequestUrl}" }); + if (!string.IsNullOrWhiteSpace(blame.PullRequestTitle)) + { + facts.Add(new Fact { Name = "PR Title", Value = blame.PullRequestTitle }); + } + } + } } - }; - - if (!string.IsNullOrWhiteSpace(innerMessage)) + } + catch (Exception) { - facts.Add(new Fact - { - Name = "Inner Exception", - Value = innerMessage - }); + // Let higher-level notification error handling observe and log failures (fail-fast behavior) + throw; } + // Provide simple activity title/subtitle for the notification + var activityTitle = $"{exTypeName} thrown at {endpoint}"; + var activitySubtitle = $"Environment: {env} | {endpoint} | {userName}@{tenantName}"; + + // If blame service not available or blame lookup fails we log and continue — do not block notifications + // (blame lookup is best-effort) + // Note: any exceptions from blame lookup are already caught and ignored above. + await notifications.PostToTeamsAsync( activityTitle, activitySubtitle, facts); - - await uow.CompleteAsync(); } catch (Exception notifyEx) { @@ -196,64 +205,6 @@ await notifications.PostToTeamsAsync( private static string BuildApplicationStackExcerpt(Exception ex) { - var trace = new StackTrace(ex, true); - - var frames = trace.GetFrames(); - - if (frames == null || frames.Length == 0) - { - return "(no stack trace)"; - } - - // Keep only application frames - var appFrames = frames - .Where(f => - { - var typeName = - f.GetMethod()?.DeclaringType?.FullName; - - if (string.IsNullOrWhiteSpace(typeName)) - { - return false; - } - - // Include only your application namespaces - return typeName.StartsWith( - "Unity.", - StringComparison.Ordinal); - }) - .Take(5) - .ToList(); - - if (appFrames.Count == 0) - { - return ex.Message; - } - - return string.Join( - Environment.NewLine, - appFrames.Select((f, i) => - { - var method = f.GetMethod(); - - var className = - method?.DeclaringType?.Name ?? "UnknownClass"; - - var methodName = - method?.Name ?? "UnknownMethod"; - - var file = f.GetFileName(); - - var fileName = string.IsNullOrWhiteSpace(file) - ? "unknown" - : Path.GetFileName(file); - - var line = f.GetFileLineNumber(); - - return - $"{i + 1}. " + - $"{className}.{methodName}() " + - $"in {fileName}:{line}"; - })); + return ExceptionNotificationHelpers.BuildApplicationStackExcerpt(ex); } -} \ No newline at end of file +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/ExceptionNotificationHelpers.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/ExceptionNotificationHelpers.cs new file mode 100644 index 0000000000..7bae852d32 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/ExceptionNotificationHelpers.cs @@ -0,0 +1,126 @@ +using System; +using System.Diagnostics; +using System.Collections.Generic; +using Unity.Notifications.TeamsNotifications; + +namespace Unity.GrantManager.Web.Middleware +{ + internal static class ExceptionNotificationHelpers + { + public static string NormalizeRepoPath(string fullPath) + { + const string marker = "src/"; + + int idx = fullPath.IndexOf(marker, StringComparison.OrdinalIgnoreCase); + + if (idx < 0) + return fullPath.Replace("\\", "/"); + + return fullPath[(idx + marker.Length)..] + .Replace("\\", "/"); + } + + public static (string? File, int? Line)? GetTopFrame(Exception ex) + { + var trace = new StackTrace(ex, true); + + foreach (var frame in trace.GetFrames() ?? Array.Empty()) + { + var file = frame.GetFileName(); + var line = frame.GetFileLineNumber(); + + if (!string.IsNullOrWhiteSpace(file) && line > 0) + { + return (file, line); + } + } + + return null; + } + + public static string BuildBlamePath(string sourceFile) + { + return $"applications/Unity.GrantManager/{sourceFile}"; + } + + public static string BuildApplicationStackExcerpt(Exception ex) + { + var trace = new StackTrace(ex, true); + + var frames = trace.GetFrames(); + + if (frames == null || frames.Length == 0) + { + return "(no stack trace)"; + } + + // Keep only application frames + var appFrames = new System.Collections.Generic.List(); + + foreach (var f in frames) + { + var typeName = f.GetMethod()?.DeclaringType?.FullName; + + if (string.IsNullOrWhiteSpace(typeName)) + continue; + + if (typeName.StartsWith("Unity.", StringComparison.Ordinal)) + { + appFrames.Add(f); + if (appFrames.Count >= 5) + break; + } + } + + if (appFrames.Count == 0) + return ex.Message; + + var lines = new System.Collections.Generic.List(); + for (int i = 0; i < appFrames.Count; i++) + { + var f = appFrames[i]; + var method = f.GetMethod(); + var className = method?.DeclaringType?.Name ?? "UnknownClass"; + var methodName = method?.Name ?? "UnknownMethod"; + var file = f.GetFileName(); + var fileName = string.IsNullOrWhiteSpace(file) ? "unknown" : System.IO.Path.GetFileName(file); + var line = f.GetFileLineNumber(); + lines.Add($"{i + 1}. {className}.{methodName}() in {fileName}:{line}"); + } + + return string.Join(Environment.NewLine, lines); + } + + public static List BuildFacts( + string exTypeName, + string exMessage, + string endpoint, + string userName, + string tenantName, + string stackTrace, + string sourceFile, + int? sourceLine, + string releaseNumber, + string? innerMessage = null) + { + var facts = new List + { + new() { Name = "Exception", Value = exTypeName }, + new() { Name = "Message", Value = exMessage }, + new() { Name = "Endpoint", Value = endpoint }, + new() { Name = "User", Value = userName }, + new() { Name = "Tenant", Value = tenantName }, + new() { Name = "Stack Trace", Value = stackTrace }, + new() { Name = "Source", Value = sourceLine.HasValue ? $"{sourceFile}:{sourceLine}" : sourceFile }, + new() { Name = "Release Number", Value = releaseNumber }, + }; + + if (!string.IsNullOrEmpty(innerMessage)) + { + facts.Add(new Fact { Name = "Inner Exception", Value = innerMessage }); + } + + return facts; + } + } +} From 2711bf5be8e69ae47e2f0c460e69850daf1666b3 Mon Sep 17 00:00:00 2001 From: JamesPasta Date: Fri, 22 May 2026 16:17:17 -0700 Subject: [PATCH 042/223] feature/AB#32049-FixBuildExceptionConsistency --- .../AbpExceptionNotificationSubscriber.cs | 328 +++++++++++++----- 1 file changed, 245 insertions(+), 83 deletions(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/AbpExceptionNotificationSubscriber.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/AbpExceptionNotificationSubscriber.cs index 5ba4cd3119..57985194b9 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/AbpExceptionNotificationSubscriber.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/AbpExceptionNotificationSubscriber.cs @@ -7,7 +7,6 @@ using Unity.GrantManager.Notifications; using Unity.Notifications.TeamsNotifications; using Volo.Abp.ExceptionHandling; -using Volo.Abp.Uow; namespace Unity.GrantManager.Web.Middleware; @@ -15,121 +14,284 @@ namespace Unity.GrantManager.Web.Middleware; /// Hooks into ABP's exception pipeline via IExceptionSubscriber. /// ABP calls this for every exception it handles (controller actions, app services, etc.) /// — complementing ExceptionCounterMiddleware which only catches exceptions that bypass ABP. -/// Registered explicitly in GrantManagerWebModule.ConfigurePolicies. +/// Registered explicitly in GrantManagerWebModule.ConfigureServices. /// public class AbpExceptionNotificationSubscriber( ExceptionNotificationThrottle throttle, IServiceScopeFactory scopeFactory, IHttpContextAccessor httpContextAccessor, - ILogger logger) : IExceptionSubscriber + ILogger logger) + : IExceptionSubscriber { private static readonly HashSet NotifyEnvironments = - new(StringComparer.OrdinalIgnoreCase) { "Production", "Test", "Development" }; + new(StringComparer.OrdinalIgnoreCase) + { + "Production", + "Test", + "Development" + }; public Task HandleAsync(ExceptionNotificationContext context) { - logger.LogInformation("[ExceptionNotify] HandleAsync called for exception: {ExceptionType} - {Message}", context.Exception.GetType().FullName, context.Exception.Message); - var ex = context.Exception; + Exception ex = context.Exception; + + logger.LogInformation( + "[ExceptionNotify] Processing exception {ExceptionType}", + ex.GetType().FullName); // Increment Prometheus counters ErrorCountingLoggerSink.ErrorCounter .WithLabels("error", ex.GetType().Name) .Inc(); - QueueTeamsNotification(ex); + TryQueueTeamsNotification(ex); return Task.CompletedTask; } - private void QueueTeamsNotification(Exception ex) + private void TryQueueTeamsNotification(Exception ex) { - logger.LogInformation("[ExceptionNotify] QueueTeamsNotification called for exception: {ExceptionType} - {Message}", ex.GetType().FullName, ex.Message); - string? env = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT"); - logger.LogInformation("[ExceptionNotify] Environment: {Env}", env); + string? env = + Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT"); - if (!NotifyEnvironments.Contains(env ?? string.Empty)) + if (string.IsNullOrWhiteSpace(env) || + !NotifyEnvironments.Contains(env)) + { return; + } if (!throttle.ShouldNotify(ex.GetType().Name)) + { + logger.LogDebug( + "[ExceptionNotify] Notification throttled for {ExceptionType}", + ex.GetType().Name); + return; + } + + // Fire-and-forget by design. + // Notification failures are handled internally. + _ = SendNotificationAsync(ex, env); + } + + private async Task SendNotificationAsync( + Exception ex, + string environment) + { + try + { + await using AsyncServiceScope scope = + scopeFactory.CreateAsyncScope(); + + IServiceProvider services = scope.ServiceProvider; + + var notifications = + services.GetRequiredService(); + + string endpoint = GetEndpoint(); + + var frame = + ExceptionNotificationHelpers.GetTopFrame(ex); + + string sourceFile = + ExceptionNotificationHelpers.NormalizeRepoPath( + frame?.File ?? "(unknown)"); + + int? sourceLine = frame?.Line; + + string userName = + AbpUserTenantAccessor.GetCurrentUserName(services) + ?? "unknown"; + + string tenantName = + AbpUserTenantAccessor.GetCurrentTenantName(services) + ?? "unknown"; + + string activityTitle = + $"[{environment.ToUpperInvariant()}] {ex.GetType().Name}"; - var httpContext = httpContextAccessor.HttpContext; - string endpoint = httpContext != null - ? $"{httpContext.Request.Method} {httpContext.Request.Path}" - : "(background)"; + string activitySubtitle = + $"Environment: {environment} | {endpoint} | {userName}@{tenantName}"; - string exTypeName = ex.GetType().FullName ?? ex.GetType().Name; - string exMessage = ex.Message; - string innerMessage = ex.InnerException?.Message ?? string.Empty; - string stackTrace = ExceptionNotificationHelpers.BuildApplicationStackExcerpt(ex); + List facts = BuildFacts( + ex, + endpoint, + userName, + tenantName, + sourceFile, + sourceLine); - _ = Task.Run(async () => + await EnrichWithBlameInfoAsync( + services, + facts, + sourceFile, + sourceLine); + + await notifications.PostToTeamsAsync( + activityTitle, + activitySubtitle, + facts); + } + catch (Exception notificationException) { - try - { - await using var scope = scopeFactory.CreateAsyncScope(); - var uowManager = scope.ServiceProvider.GetRequiredService(); - var notifications = scope.ServiceProvider.GetRequiredService(); - - using var uow = uowManager.Begin(requiresNew: true, isTransactional: false); - - string activityTitle = $"[{env?.ToUpperInvariant()}] {ex.GetType().Name}"; - - var frame = ExceptionNotificationHelpers.GetTopFrame(ex); - string sourceFile = ExceptionNotificationHelpers.NormalizeRepoPath(frame?.File ?? "(unknown)"); - int? sourceLine = frame?.Line; - - var userName = AbpUserTenantAccessor.GetCurrentUserName(scope.ServiceProvider) ?? "unknown"; - var tenantName = AbpUserTenantAccessor.GetCurrentTenantName(scope.ServiceProvider) ?? "unknown"; - - string activitySubtitle = $"Environment: {env} | {endpoint} | {userName}@{tenantName}"; - - var facts = ExceptionNotificationHelpers.BuildFacts( - exTypeName, - exMessage, - endpoint, - userName, - tenantName, - stackTrace, - sourceFile, - sourceLine, - ExceptionCounterMiddleware.CommitSha, - innerMessage); - - if (sourceLine.HasValue) - { - // Use required service to fail-fast if the blame lookup service is not registered - string blamePath = ExceptionNotificationHelpers.BuildBlamePath(sourceFile); - var blameService = scope.ServiceProvider.GetRequiredService(); - var blame = await blameService.GetBlameAsync(blamePath, sourceLine.Value); - - if (blame != null) - { - logger.LogInformation("[ExceptionNotify] Blame lookup result: Author={Author}, Commit={Commit}, PR={PR}, PRTitle={PRTitle}", blame.Author, blame.CommitSha, blame.PullRequestUrl, blame.PullRequestTitle); - facts.Add(new Fact { Name = "Author", Value = $"{blame.Author} <{blame.Email}>" }); - var shortSha = !string.IsNullOrEmpty(blame.CommitSha) && blame.CommitSha.Length > 7 ? blame.CommitSha.Substring(0, 7) : blame.CommitSha; - facts.Add(new Fact { Name = "Commit", Value = $"{shortSha} {blame.Message}" }); - - if (blame.PullRequestUrl != null) - { - facts.Add(new Fact { Name = "PR", Value = $"#{blame.PullRequestNumber} {blame.PullRequestUrl}" }); - if (!string.IsNullOrWhiteSpace(blame.PullRequestTitle)) - { - facts.Add(new Fact { Name = "PR Title", Value = blame.PullRequestTitle }); - } - } - } - } - - await notifications.PostToTeamsAsync(activityTitle, activitySubtitle, facts); - await uow.CompleteAsync(); - } - catch (Exception notifyEx) + logger.LogWarning( + notificationException, + "Failed to send Teams exception notification"); + } + } + + private string GetEndpoint() + { + HttpContext? httpContext = httpContextAccessor.HttpContext; + + if (httpContext == null) + { + return "(background)"; + } + + return $"{httpContext.Request.Method} {httpContext.Request.Path}"; + } + + private static List BuildFacts( + Exception ex, + string endpoint, + string userName, + string tenantName, + string sourceFile, + int? sourceLine) + { + string exceptionType = + ex.GetType().FullName ?? ex.GetType().Name; + + string innerMessage = + ex.InnerException?.Message ?? string.Empty; + + string stackTrace = + ExceptionNotificationHelpers.BuildApplicationStackExcerpt(ex); + + return ExceptionNotificationHelpers.BuildFacts( + exceptionType, + ex.Message, + endpoint, + userName, + tenantName, + stackTrace, + sourceFile, + sourceLine, + ExceptionCounterMiddleware.CommitSha, + innerMessage); + } + + private async Task EnrichWithBlameInfoAsync( + IServiceProvider services, + List facts, + string sourceFile, + int? sourceLine) + { + if (!sourceLine.HasValue) + { + return; + } + + try + { + string blamePath = + ExceptionNotificationHelpers.BuildBlamePath(sourceFile); + + var blameService = + services.GetRequiredService(); + + var blame = + await blameService.GetBlameAsync( + blamePath, + sourceLine.Value); + + if (blame == null) { - logger.LogWarning(notifyEx, "Failed to send Teams exception notification via IExceptionSubscriber"); + return; } + + logger.LogInformation( + "[ExceptionNotify] Blame lookup successful: {Author} {Commit}", + blame.Author, + blame.CommitSha); + + AddAuthorFact(facts, blame); + + AddCommitFact(facts, blame); + + AddPullRequestFacts(facts, blame); + } + catch (Exception blameException) + { + logger.LogWarning( + blameException, + "Failed to enrich exception with GitHub blame information"); + } + } + + private static void AddAuthorFact( + ICollection facts, + GitHubBlameInfo blame) + { + facts.Add(new Fact + { + Name = "Author", + Value = $"{blame.Author} <{blame.Email}>" + }); + } + + private static void AddCommitFact( + ICollection facts, + GitHubBlameInfo blame) + { + string shortSha = + GetShortSha(blame.CommitSha); + + facts.Add(new Fact + { + Name = "Commit", + Value = $"{shortSha} {blame.Message}" }); } + private static void AddPullRequestFacts( + ICollection facts, + GitHubBlameInfo blame) + { + if (string.IsNullOrWhiteSpace(blame.PullRequestUrl)) + { + return; + } + + facts.Add(new Fact + { + Name = "PR", + Value = + $"#{blame.PullRequestNumber} {blame.PullRequestUrl}" + }); -} + if (string.IsNullOrWhiteSpace(blame.PullRequestTitle)) + { + return; + } + + facts.Add(new Fact + { + Name = "PR Title", + Value = blame.PullRequestTitle + }); + } + + private static string GetShortSha(string? sha) + { + if (string.IsNullOrWhiteSpace(sha)) + { + return string.Empty; + } + + return sha.Length > 7 + ? sha[..7] + : sha; + } +} \ No newline at end of file From 07252bdca69fdd85b9d946b44aaffa7b58397a11 Mon Sep 17 00:00:00 2001 From: JamesPasta Date: Fri, 22 May 2026 16:18:55 -0700 Subject: [PATCH 043/223] feature/AB#32049-FixBuildExceptionConsistency --- .../Middleware/ExceptionCounterMiddleware.cs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/ExceptionCounterMiddleware.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/ExceptionCounterMiddleware.cs index dbd2b20439..cb45aede82 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/ExceptionCounterMiddleware.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/ExceptionCounterMiddleware.cs @@ -1,7 +1,5 @@ using System; using System.Collections.Generic; -using System.Diagnostics; -using System.IO; using System.Linq; using System.Reflection; using System.Threading.Tasks; @@ -146,7 +144,7 @@ private void QueueTeamsNotification(HttpContext context, Exception ex) stackTrace, sourceFile, sourceLine, - ExceptionCounterMiddleware.CommitSha, + CommitSha, innerMessage); // Try to enrich with blame info similar to AbpExceptionNotificationSubscriber From f06f8c33025466cc5c6631be59603214ae59110f Mon Sep 17 00:00:00 2001 From: JamesPasta Date: Fri, 22 May 2026 16:39:31 -0700 Subject: [PATCH 044/223] feature/AB#32049-FixBuildUOW --- .../AbpExceptionNotificationSubscriber.cs | 7 ++- .../Middleware/ExceptionCounterMiddleware.cs | 44 ++++++++++++------- 2 files changed, 35 insertions(+), 16 deletions(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/AbpExceptionNotificationSubscriber.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/AbpExceptionNotificationSubscriber.cs index 57985194b9..f15074f743 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/AbpExceptionNotificationSubscriber.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/AbpExceptionNotificationSubscriber.cs @@ -7,6 +7,7 @@ using Unity.GrantManager.Notifications; using Unity.Notifications.TeamsNotifications; using Volo.Abp.ExceptionHandling; +using Volo.Abp.Uow; namespace Unity.GrantManager.Web.Middleware; @@ -85,6 +86,7 @@ private async Task SendNotificationAsync( IServiceProvider services = scope.ServiceProvider; + var uowManager = services.GetRequiredService(); var notifications = services.GetRequiredService(); @@ -120,17 +122,20 @@ private async Task SendNotificationAsync( tenantName, sourceFile, sourceLine); - await EnrichWithBlameInfoAsync( services, facts, sourceFile, sourceLine); + using var uow = uowManager.Begin(requiresNew: true, isTransactional: false); + await notifications.PostToTeamsAsync( activityTitle, activitySubtitle, facts); + + await uow.CompleteAsync(); } catch (Exception notificationException) { diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/ExceptionCounterMiddleware.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/ExceptionCounterMiddleware.cs index cb45aede82..90edde9f0f 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/ExceptionCounterMiddleware.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/ExceptionCounterMiddleware.cs @@ -152,31 +152,45 @@ private void QueueTeamsNotification(HttpContext context, Exception ex) { if (sourceLine.HasValue) { - // Use required service to fail-fast if the blame lookup service is not registered + // Blame enrichment is best-effort here: don't let failures block notifications string blamePath = ExceptionNotificationHelpers.BuildBlamePath(sourceFile); - var blameService = scope.ServiceProvider.GetRequiredService(); - var blame = await blameService.GetBlameAsync(blamePath, sourceLine.Value); - if (blame != null) + var blameService = scope.ServiceProvider.GetService(); + if (blameService != null) { - facts.Add(new Fact { Name = "Author", Value = $"{blame.Author} <{blame.Email}>" }); - var shortSha = !string.IsNullOrEmpty(blame.CommitSha) && blame.CommitSha.Length > 7 ? blame.CommitSha.Substring(0, 7) : blame.CommitSha; - facts.Add(new Fact { Name = "Commit", Value = $"{shortSha} {blame.Message}" }); - - if (blame.PullRequestUrl != null) + try { - facts.Add(new Fact { Name = "PR", Value = $"#{blame.PullRequestNumber} {blame.PullRequestUrl}" }); - if (!string.IsNullOrWhiteSpace(blame.PullRequestTitle)) + var blame = await blameService.GetBlameAsync(blamePath, sourceLine.Value); + if (blame != null) { - facts.Add(new Fact { Name = "PR Title", Value = blame.PullRequestTitle }); + facts.Add(new Fact { Name = "Author", Value = $"{blame.Author} <{blame.Email}>" }); + var shortSha = !string.IsNullOrEmpty(blame.CommitSha) && blame.CommitSha.Length > 7 ? blame.CommitSha.Substring(0, 7) : blame.CommitSha; + facts.Add(new Fact { Name = "Commit", Value = $"{shortSha} {blame.Message}" }); + + if (blame.PullRequestUrl != null) + { + facts.Add(new Fact { Name = "PR", Value = $"#{blame.PullRequestNumber} {blame.PullRequestUrl}" }); + if (!string.IsNullOrWhiteSpace(blame.PullRequestTitle)) + { + facts.Add(new Fact { Name = "PR Title", Value = blame.PullRequestTitle }); + } + } } } + catch (Exception blameEx) + { + logger.LogDebug(blameEx, "Blame lookup failed; continuing without blame info for {File}:{Line}", sourceFile, sourceLine); + } + } + else + { + logger.LogDebug("Blame lookup service not registered; skipping blame enrichment for {File}:{Line}", sourceFile, sourceLine); } } } - catch (Exception) + catch (Exception ex) { - // Let higher-level notification error handling observe and log failures (fail-fast behavior) - throw; + // Catch-all: ensure notifications still send even if enrichment logic fails + logger.LogDebug(ex, "Unexpected error during blame enrichment; continuing without blame info for {File}:{Line}", sourceFile, sourceLine); } // Provide simple activity title/subtitle for the notification From 184a12dc87ea8cebc12b42489cd6e08a68ae5a97 Mon Sep 17 00:00:00 2001 From: JamesPasta Date: Fri, 22 May 2026 16:59:15 -0700 Subject: [PATCH 045/223] feature/AB#32049-FixBuildUOW --- .../Norifications/NotificationsAppService.cs | 45 +++++++++++++++---- 1 file changed, 36 insertions(+), 9 deletions(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Norifications/NotificationsAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Norifications/NotificationsAppService.cs index b01b2881d1..7c13a192af 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Norifications/NotificationsAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Norifications/NotificationsAppService.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Threading.Tasks; +using Microsoft.Extensions.Logging; using Unity.GrantManager.Applications; using Unity.GrantManager.Integrations; using Unity.Notifications.TeamsNotifications; @@ -17,21 +18,26 @@ public class NotificationsAppService : INotificationsAppService, ITransientDepen { private readonly IDynamicUrlRepository _dynamicUrlRepository; private readonly TeamsNotificationService _teamsNotificationService; + private readonly ILogger _logger; - public NotificationsAppService(IDynamicUrlRepository dynamicUrlRepository) + public NotificationsAppService(IDynamicUrlRepository dynamicUrlRepository, ILogger logger) { _dynamicUrlRepository = dynamicUrlRepository; _teamsNotificationService = new TeamsNotificationService(); + _logger = logger; } public async Task InitializeTeamsChannelAsync(string keyName) { - DynamicUrl? teamsChannel = await _dynamicUrlRepository.FirstOrDefaultAsync(q => q.KeyName == keyName); - if (teamsChannel?.Url == null) - { - return ""; - } - return teamsChannel.Url; + DynamicUrl? teamsChannel = await _dynamicUrlRepository.FirstOrDefaultAsync(q => q.KeyName == keyName); + if (teamsChannel?.Url == null) + { + _logger.LogWarning("Teams channel not found for key {KeyName}", keyName); + return string.Empty; + } + + _logger.LogDebug("Resolved Teams channel for key {KeyName}: {Url}", keyName, teamsChannel.Url); + return teamsChannel.Url; } [RemoteService(false)] @@ -55,11 +61,21 @@ public async Task PostToTeamsAsync(string activityTitle, string activitySubtitle string teamsChannel = await InitializeTeamsChannelAsync(TeamsNotificationService.TEAMS_NOTIFICATION); if (teamsChannel.IsNullOrEmpty()) { + _logger.LogWarning("PostToTeamsAsync: no Teams channel configured, skipping notification {Title}", activityTitle); return; } string messageCard = TeamsNotificationService.InitializeMessageCard(activityTitle, activitySubtitle, facts); - await TeamsNotificationService.PostToTeamsChannelAsync(teamsChannel, messageCard); + try + { + _logger.LogDebug("Posting Teams notification to {Channel} with title {Title} and {FactCount} facts", teamsChannel, activityTitle, facts?.Count ?? 0); + await TeamsNotificationService.PostToTeamsChannelAsync(teamsChannel, messageCard); + _logger.LogInformation("Posted Teams notification '{Title}' to channel {Channel}", activityTitle, teamsChannel); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to post Teams notification '{Title}' to channel {Channel}", activityTitle, teamsChannel); + } } public async Task PostToTeamsAsync(string activityTitle, string activitySubtitle) @@ -67,11 +83,22 @@ public async Task PostToTeamsAsync(string activityTitle, string activitySubtitle string teamsChannel = await InitializeTeamsChannelAsync(TeamsNotificationService.TEAMS_NOTIFICATION); if (teamsChannel.IsNullOrEmpty()) { + _logger.LogWarning("PostToTeamsAsync (no-facts): no Teams channel configured, skipping notification {Title}", activityTitle); return; } + List facts = new() { }; string messageCard = TeamsNotificationService.InitializeMessageCard(activityTitle, activitySubtitle, facts); - await TeamsNotificationService.PostToTeamsChannelAsync(teamsChannel, messageCard); + try + { + _logger.LogDebug("Posting Teams notification (no-facts) to {Channel} with title {Title}", teamsChannel, activityTitle); + await TeamsNotificationService.PostToTeamsChannelAsync(teamsChannel, messageCard); + _logger.LogInformation("Posted Teams notification '{Title}' to channel {Channel}", activityTitle, teamsChannel); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to post Teams notification '{Title}' to channel {Channel}", activityTitle, teamsChannel); + } } public async Task PostChefsEventToTeamsAsync(string subscriptionEvent, dynamic form, dynamic chefsFormVersion) From 00a98b289fc292827fa681ba9dabf79437e58ff8 Mon Sep 17 00:00:00 2001 From: JamesPasta Date: Mon, 25 May 2026 10:58:22 -0700 Subject: [PATCH 046/223] feature/AB#32049-FixBuildExceptionConsistency --- .../Norifications/NotificationsAppService.cs | 40 +++++------- .../AbpExceptionNotificationSubscriber.cs | 64 ++++++++++++++----- .../Middleware/AbpUserTenantAccessor.cs | 39 ++++++++++- .../Middleware/ExceptionCounterMiddleware.cs | 35 +++++----- .../ExceptionNotificationHelpers.cs | 51 +++++++++++++-- 5 files changed, 164 insertions(+), 65 deletions(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Norifications/NotificationsAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Norifications/NotificationsAppService.cs index 7c13a192af..9508d38abb 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Norifications/NotificationsAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Norifications/NotificationsAppService.cs @@ -8,36 +8,29 @@ using Volo.Abp; using Volo.Abp.DependencyInjection; using Volo.Abp.Domain.Repositories; +using Volo.Abp.MultiTenancy; namespace Unity.GrantManager.Notifications { // This class is responsible for first lookup up the Teams channel URL from the database and then posting notifications to the Teams Service. [Dependency(ReplaceServices = true)] [ExposeServices(typeof(NotificationsAppService), typeof(INotificationsAppService))] - public class NotificationsAppService : INotificationsAppService, ITransientDependency + public class NotificationsAppService(IDynamicUrlRepository dynamicUrlRepository, + ILogger logger, ICurrentTenant currentTenant) : INotificationsAppService, ITransientDependency { - private readonly IDynamicUrlRepository _dynamicUrlRepository; - private readonly TeamsNotificationService _teamsNotificationService; - private readonly ILogger _logger; - - public NotificationsAppService(IDynamicUrlRepository dynamicUrlRepository, ILogger logger) - { - _dynamicUrlRepository = dynamicUrlRepository; - _teamsNotificationService = new TeamsNotificationService(); - _logger = logger; - } - public async Task InitializeTeamsChannelAsync(string keyName) { - DynamicUrl? teamsChannel = await _dynamicUrlRepository.FirstOrDefaultAsync(q => q.KeyName == keyName); + using (currentTenant.Change(null)) + { + DynamicUrl? teamsChannel = await dynamicUrlRepository.FirstOrDefaultAsync(q => q.KeyName == keyName && q.TenantId == null); if (teamsChannel?.Url == null) { - _logger.LogWarning("Teams channel not found for key {KeyName}", keyName); + logger.LogWarning("Teams channel not found for key {KeyName}", keyName); return string.Empty; } - _logger.LogDebug("Resolved Teams channel for key {KeyName}: {Url}", keyName, teamsChannel.Url); return teamsChannel.Url; + } } [RemoteService(false)] @@ -52,8 +45,9 @@ public async Task NotifyChefsEventToTeamsAsync(string factName, string factValue string? envInfo = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT"); string activityTitle = "Chefs Submission Event Validation Error"; string activitySubtitle = "Environment: " + envInfo; - _teamsNotificationService.AddFact(factName, factValue); - await _teamsNotificationService.PostFactsToTeamsAsync(teamsChannel, activityTitle, activitySubtitle); + TeamsNotificationService teamsNotificationService = new(); + teamsNotificationService.AddFact(factName, factValue); + await teamsNotificationService.PostFactsToTeamsAsync(teamsChannel, activityTitle, activitySubtitle); } public async Task PostToTeamsAsync(string activityTitle, string activitySubtitle, List facts) @@ -61,20 +55,18 @@ public async Task PostToTeamsAsync(string activityTitle, string activitySubtitle string teamsChannel = await InitializeTeamsChannelAsync(TeamsNotificationService.TEAMS_NOTIFICATION); if (teamsChannel.IsNullOrEmpty()) { - _logger.LogWarning("PostToTeamsAsync: no Teams channel configured, skipping notification {Title}", activityTitle); + logger.LogWarning("PostToTeamsAsync: no Teams channel configured, skipping notification {Title}", activityTitle); return; } string messageCard = TeamsNotificationService.InitializeMessageCard(activityTitle, activitySubtitle, facts); try { - _logger.LogDebug("Posting Teams notification to {Channel} with title {Title} and {FactCount} facts", teamsChannel, activityTitle, facts?.Count ?? 0); await TeamsNotificationService.PostToTeamsChannelAsync(teamsChannel, messageCard); - _logger.LogInformation("Posted Teams notification '{Title}' to channel {Channel}", activityTitle, teamsChannel); } catch (Exception ex) { - _logger.LogWarning(ex, "Failed to post Teams notification '{Title}' to channel {Channel}", activityTitle, teamsChannel); + logger.LogWarning(ex, "Failed to post Teams notification '{Title}' to channel {Channel}", activityTitle, teamsChannel); } } @@ -83,7 +75,7 @@ public async Task PostToTeamsAsync(string activityTitle, string activitySubtitle string teamsChannel = await InitializeTeamsChannelAsync(TeamsNotificationService.TEAMS_NOTIFICATION); if (teamsChannel.IsNullOrEmpty()) { - _logger.LogWarning("PostToTeamsAsync (no-facts): no Teams channel configured, skipping notification {Title}", activityTitle); + logger.LogWarning("PostToTeamsAsync (no-facts): no Teams channel configured, skipping notification {Title}", activityTitle); return; } @@ -91,13 +83,11 @@ public async Task PostToTeamsAsync(string activityTitle, string activitySubtitle string messageCard = TeamsNotificationService.InitializeMessageCard(activityTitle, activitySubtitle, facts); try { - _logger.LogDebug("Posting Teams notification (no-facts) to {Channel} with title {Title}", teamsChannel, activityTitle); await TeamsNotificationService.PostToTeamsChannelAsync(teamsChannel, messageCard); - _logger.LogInformation("Posted Teams notification '{Title}' to channel {Channel}", activityTitle, teamsChannel); } catch (Exception ex) { - _logger.LogWarning(ex, "Failed to post Teams notification '{Title}' to channel {Channel}", activityTitle, teamsChannel); + logger.LogWarning(ex, "Failed to post Teams notification '{Title}' to channel {Channel}", activityTitle, teamsChannel); } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/AbpExceptionNotificationSubscriber.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/AbpExceptionNotificationSubscriber.cs index f15074f743..528493f80d 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/AbpExceptionNotificationSubscriber.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/AbpExceptionNotificationSubscriber.cs @@ -101,13 +101,8 @@ private async Task SendNotificationAsync( int? sourceLine = frame?.Line; - string userName = - AbpUserTenantAccessor.GetCurrentUserName(services) - ?? "unknown"; - - string tenantName = - AbpUserTenantAccessor.GetCurrentTenantName(services) - ?? "unknown"; + string userName = AbpUserTenantAccessor.GetCurrentUserName(services) ?? "unknown"; + string tenantName = await AbpUserTenantAccessor.GetCurrentTenantNameAsync(services) ?? "unknown"; string activityTitle = $"[{environment.ToUpperInvariant()}] {ex.GetType().Name}"; @@ -202,20 +197,60 @@ private async Task EnrichWithBlameInfoAsync( { string blamePath = ExceptionNotificationHelpers.BuildBlamePath(sourceFile); + string blamePath = ExceptionNotificationHelpers.BuildBlamePath(sourceFile, logger); - var blameService = - services.GetRequiredService(); + // Resolve blame lookup as optional — it may not be registered in some environments + var blameService = services.GetService(); + if (blameService == null) + { + logger.LogDebug("Blame lookup service not available; skipping blame enrichment for {File}:{Line}", sourceFile, sourceLine); + return; + } - var blame = - await blameService.GetBlameAsync( - blamePath, - sourceLine.Value); + GitHubBlameInfo? blame = null; + try + { + blame = await blameService.GetBlameAsync(blamePath, sourceLine.Value); + } + catch (Exception innerBlameEx) + { + logger.LogDebug(innerBlameEx, "Blame lookup failed; continuing without blame info for {File}:{Line}", sourceFile, sourceLine); + } if (blame == null) { return; } + logger.LogInformation( + "[ExceptionNotify] Blame lookup successful: {Author} {Commit}", + blame.Author, + blame.CommitSha); + + AddAuthorFact(facts, blame); + AddCommitFact(facts, blame); + AddPullRequestFacts(facts, blame); + { + return; + } + + logger.LogInformation( + "[ExceptionNotify] Blame lookup successful: {Author} {Commit}", + blame.Author, + blame.CommitSha); + + AddAuthorFact(facts, blame); + AddCommitFact(facts, blame); + AddPullRequestFacts(facts, blame); + } + catch (Exception blameException) + { + logger.LogWarning( + blameException, + "Failed to enrich exception with GitHub blame information"); +>>>>>>> f09149c73 (feature/AB#32049-FixBuildExceptionConsistency) + } + logger.LogInformation( "[ExceptionNotify] Blame lookup successful: {Author} {Commit}", blame.Author, @@ -250,8 +285,7 @@ private static void AddCommitFact( ICollection facts, GitHubBlameInfo blame) { - string shortSha = - GetShortSha(blame.CommitSha); + string shortSha = GetShortSha(blame.CommitSha); facts.Add(new Fact { diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/AbpUserTenantAccessor.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/AbpUserTenantAccessor.cs index 452a954383..7280b70646 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/AbpUserTenantAccessor.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/AbpUserTenantAccessor.cs @@ -1,7 +1,9 @@ using System; +using System.Threading.Tasks; using Microsoft.Extensions.DependencyInjection; using Volo.Abp.Users; using Volo.Abp.MultiTenancy; +using Volo.Abp.TenantManagement; namespace Unity.GrantManager.Web.Middleware { @@ -11,11 +13,12 @@ internal static class AbpUserTenantAccessor public static string? GetCurrentUserName(IServiceProvider serviceProvider) { var currentUser = serviceProvider.GetService(); - return currentUser?.UserName ?? currentUser?.Name; + return currentUser?.Name + " " + currentUser?.SurName; } - public static string? GetCurrentTenantName(IServiceProvider serviceProvider) + public static async Task GetCurrentTenantNameAsync(IServiceProvider serviceProvider) { + // Try resolving ICurrentTenant first var currentTenant = serviceProvider.GetService(); if (currentTenant != null) @@ -31,6 +34,38 @@ internal static class AbpUserTenantAccessor } } + // Fall back to current user tenant id if available + var currentUser = serviceProvider.GetService(); + if (currentUser?.TenantId != null) + { + try + { + // Get the current tenant id (returns Guid.Empty when not set) + if (currentUser.TenantId != Guid.Empty) + { + // Try tenant repository (may not be registered in some host contexts) + var tenantRepo = serviceProvider.GetService(); + if (tenantRepo != null) + { + var tenant = await tenantRepo.FindAsync(currentUser.TenantId.Value); + if (tenant != null) + { + return tenant.Name; + } + } + } + } + catch + { + // Swallow any errors and fall back to other methods + } + } + + return null; + } + + public static string? GetCurrentTenantId(IServiceProvider serviceProvider) + { var currentUser = serviceProvider.GetService(); if (currentUser?.TenantId != null) { diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/ExceptionCounterMiddleware.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/ExceptionCounterMiddleware.cs index 90edde9f0f..2126bf37fa 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/ExceptionCounterMiddleware.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/ExceptionCounterMiddleware.cs @@ -36,14 +36,11 @@ public class ExceptionCounterMiddleware( LabelNames = new[] { "type" } }); - // Git SHA baked in at build time via -p:SourceRevisionId= in the Dockerfile. - // Format is "+" e.g. "1.0.0+a3f8c21"; we extract just the SHA. internal static readonly string CommitSha = ParseCommitSha( typeof(ExceptionCounterMiddleware).Assembly .GetCustomAttribute()? .InformationalVersion); - // Commit author baked in at build time via -p:AssemblyMetadata_CommitAuthor=. internal static readonly string CommitAuthor = typeof(ExceptionCounterMiddleware).Assembly .GetCustomAttributes() @@ -122,13 +119,11 @@ private void QueueTeamsNotification(HttpContext context, Exception ex) await using var scope = scopeFactory.CreateAsyncScope(); var uowManager = scope.ServiceProvider.GetRequiredService(); - - var notifications = - scope.ServiceProvider.GetRequiredService(); + var notifications = scope.ServiceProvider.GetRequiredService(); // Get current user and tenant name var userName = AbpUserTenantAccessor.GetCurrentUserName(scope.ServiceProvider) ?? "unknown"; - var tenantName = AbpUserTenantAccessor.GetCurrentTenantName(scope.ServiceProvider) ?? "unknown"; + var tenantName = await AbpUserTenantAccessor.GetCurrentTenantNameAsync(scope.ServiceProvider) ?? "unknown"; // Determine top frame (file/line) for initial facts so variables exist when creating the list var topForFacts = ExceptionNotificationHelpers.GetTopFrame(ex); @@ -187,24 +182,32 @@ private void QueueTeamsNotification(HttpContext context, Exception ex) } } } - catch (Exception ex) + catch (Exception ex2) { // Catch-all: ensure notifications still send even if enrichment logic fails - logger.LogDebug(ex, "Unexpected error during blame enrichment; continuing without blame info for {File}:{Line}", sourceFile, sourceLine); + logger.LogDebug(ex2, "Unexpected error during blame enrichment; continuing without blame info for {File}:{Line}", sourceFile, sourceLine); } // Provide simple activity title/subtitle for the notification var activityTitle = $"{exTypeName} thrown at {endpoint}"; var activitySubtitle = $"Environment: {env} | {endpoint} | {userName}@{tenantName}"; - // If blame service not available or blame lookup fails we log and continue — do not block notifications - // (blame lookup is best-effort) - // Note: any exceptions from blame lookup are already caught and ignored above. + // Ensure a Unit-of-Work is active for any DB access inside NotificationsAppService + try + { + using var uow = uowManager.Begin(requiresNew: true, isTransactional: false); + + await notifications.PostToTeamsAsync( + activityTitle, + activitySubtitle, + facts); - await notifications.PostToTeamsAsync( - activityTitle, - activitySubtitle, - facts); + await uow.CompleteAsync(); + } + catch (Exception uowEx) + { + logger.LogWarning(uowEx, "Failed to send Teams exception notification within UnitOfWork"); + } } catch (Exception notifyEx) { diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/ExceptionNotificationHelpers.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/ExceptionNotificationHelpers.cs index 7bae852d32..8ff2d0d3fe 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/ExceptionNotificationHelpers.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/ExceptionNotificationHelpers.cs @@ -9,15 +9,37 @@ internal static class ExceptionNotificationHelpers { public static string NormalizeRepoPath(string fullPath) { - const string marker = "src/"; + if (string.IsNullOrWhiteSpace(fullPath)) return fullPath; - int idx = fullPath.IndexOf(marker, StringComparison.OrdinalIgnoreCase); + // Normalize separators for comparison + var path = fullPath.Replace("\\", "/"); - if (idx < 0) - return fullPath.Replace("\\", "/"); + // Prefer repository-relative path under applications/Unity.GrantManager/src/ + const string repoMarker = "applications/unity.grantmanager/src/"; + int idx = path.IndexOf(repoMarker, StringComparison.OrdinalIgnoreCase); + if (idx >= 0) + { + return path[(idx + repoMarker.Length)..].TrimStart('/'); + } + + // Fallback to any src/ directory + const string srcMarker = "src/"; + idx = path.IndexOf(srcMarker, StringComparison.OrdinalIgnoreCase); + if (idx >= 0) + { + return path[(idx + srcMarker.Length)..].TrimStart('/'); + } - return fullPath[(idx + marker.Length)..] - .Replace("\\", "/"); + // Last resort: strip drive letter (Windows) and return the path relative to repository root if possible + // If we can't determine a repo-relative path, return just the file name so notifications remain readable + try + { + return System.IO.Path.GetFileName(path); + } + catch + { + return path; + } } public static (string? File, int? Line)? GetTopFrame(Exception ex) @@ -40,7 +62,22 @@ public static (string? File, int? Line)? GetTopFrame(Exception ex) public static string BuildBlamePath(string sourceFile) { - return $"applications/Unity.GrantManager/{sourceFile}"; + if (string.IsNullOrWhiteSpace(sourceFile)) return sourceFile; + + // Normalize separators + var path = sourceFile.Replace("\\", "/").TrimStart('/'); + + // If caller already passed a repo-rooted path, return as-is + string result; + if (path.StartsWith("applications/", StringComparison.OrdinalIgnoreCase)) + result = path; + else if (path.StartsWith("src/", StringComparison.OrdinalIgnoreCase)) + result = $"applications/Unity.GrantManager/{path}"; + else + // Default: assume sourceFile is the portion after src/, so include src/ + result = $"applications/Unity.GrantManager/src/{path}"; + + return result; } public static string BuildApplicationStackExcerpt(Exception ex) From 102b7055d4d1e7dc08577318889f1fbded87a003 Mon Sep 17 00:00:00 2001 From: JamesPasta Date: Mon, 25 May 2026 14:09:35 -0700 Subject: [PATCH 047/223] feature/AB#32049-FixBuildExceptionConsistency --- .../Monitoring/TestExceptionController.cs | 85 ------------------- 1 file changed, 85 deletions(-) delete mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Web/Controllers/Monitoring/TestExceptionController.cs diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Controllers/Monitoring/TestExceptionController.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Controllers/Monitoring/TestExceptionController.cs deleted file mode 100644 index a2df606071..0000000000 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Controllers/Monitoring/TestExceptionController.cs +++ /dev/null @@ -1,85 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Reflection; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Logging; -using Unity.GrantManager.Notifications; -using Unity.Notifications.TeamsNotifications; -using Volo.Abp.AspNetCore.Mvc; - -namespace Unity.GrantManager.Web.Controllers.Monitoring; - -/// -/// Temporary test endpoints — force exceptions/logs/notifications to verify -/// Prometheus error counting and Teams alerting. REMOVE BEFORE MERGING TO MAIN. -/// -[ApiController] -[Route("api/monitoring/test")] -[AllowAnonymous] -public class TestExceptionController(INotificationsAppService notifications) : AbpControllerBase -{ - // Same SHA parsing as ExceptionCounterMiddleware - private static readonly string CommitSha = ParseCommitSha( - typeof(TestExceptionController).Assembly - .GetCustomAttribute()? - .InformationalVersion); - - private static string ParseCommitSha(string? informationalVersion) - { - if (string.IsNullOrWhiteSpace(informationalVersion)) return "unknown"; - var plusIndex = informationalVersion.IndexOf('+'); - return plusIndex >= 0 ? informationalVersion[(plusIndex + 1)..] : informationalVersion; - } - - /// - /// GET /api/monitoring/test/throw - /// Throws an unhandled exception → ABP catches it → IExceptionSubscriber fires → Teams notification sent. - /// - [HttpGet("throw")] - public IActionResult ThrowException() - { - throw new InvalidOperationException("Test exception to verify AbpExceptionNotificationSubscriber and Teams notification."); - } - - /// - /// GET /api/monitoring/test/log-error - /// Logs an Error-level Serilog event → increments application_errors_total via ErrorCountingLoggerSink. - /// - [HttpGet("log-error")] - public IActionResult LogError() - { - var ex = new InvalidOperationException("Test exception for Prometheus error counter verification."); - Logger.LogError(ex, "Test error log for application_errors_total counter — CommitSha: {CommitSha}", CommitSha); - return Ok(new { logged = true, commitSha = CommitSha, message = "Error logged — check /metrics for application_errors_total." }); - } - - /// - /// GET /api/monitoring/test/notify - /// Fires a Teams notification via the same INotificationsAppService used by ExceptionCounterMiddleware. - /// - [HttpGet("notify")] - public async Task NotifyTeams() - { - var ex = new InvalidOperationException("Test exception for Teams notification verification."); - string env = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "Unknown"; - string endpoint = $"{Request.Method} {Request.Path}"; - - var facts = new List - { - new() { Name = "Exception", Value = ex.GetType().FullName ?? ex.GetType().Name }, - new() { Name = "Message", Value = ex.Message }, - new() { Name = "Endpoint", Value = endpoint }, - new() { Name = "Stack Trace", Value = ex.StackTrace ?? "(no stack trace)" }, - new() { Name = "Commit", Value = CommitSha }, - }; - - await notifications.PostToTeamsAsync( - $"[TEST] {ex.GetType().Name}", - $"Environment: {env} | {endpoint}", - facts); - - return Ok(new { notified = true, commitSha = CommitSha, environment = env }); - } -} From d8b196755a449d294458b243e73bad8c84b9911f Mon Sep 17 00:00:00 2001 From: JamesPasta Date: Wed, 3 Jun 2026 16:24:05 -0700 Subject: [PATCH 048/223] feature/AB#32049-PrometheusCoPilotSuggestions --- .../Utilities/AbpUserTenantAccessor.cs | 6 ++++-- .../Norifications/NotificationsAppService.cs | 16 ++++------------ .../Middleware/GitHubBlameLookupService.cs | 12 +++++------- 3 files changed, 13 insertions(+), 21 deletions(-) diff --git a/applications/Unity.GrantManager/modules/Unity.SharedKernel/src/Unity.SharedKernel/Utilities/AbpUserTenantAccessor.cs b/applications/Unity.GrantManager/modules/Unity.SharedKernel/src/Unity.SharedKernel/Utilities/AbpUserTenantAccessor.cs index ac60ae4cee..82a3595c99 100644 --- a/applications/Unity.GrantManager/modules/Unity.SharedKernel/src/Unity.SharedKernel/Utilities/AbpUserTenantAccessor.cs +++ b/applications/Unity.GrantManager/modules/Unity.SharedKernel/src/Unity.SharedKernel/Utilities/AbpUserTenantAccessor.cs @@ -18,10 +18,12 @@ public static class AbpUserTenantAccessor var surname = currentUser.SurName; if (!string.IsNullOrWhiteSpace(given) || !string.IsNullOrWhiteSpace(surname)) { - return $"{given} {surname}".Trim(); + var fullName = $"{given} {surname}".Trim(); + if (!string.IsNullOrWhiteSpace(fullName)) return fullName; } - return currentUser.UserName; + var userName = currentUser.UserName; + return string.IsNullOrWhiteSpace(userName) ? null : userName; } public static async Task GetCurrentTenantNameAsync(IServiceProvider serviceProvider) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Norifications/NotificationsAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Norifications/NotificationsAppService.cs index be2e9213d9..13cf4044c2 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Norifications/NotificationsAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Norifications/NotificationsAppService.cs @@ -19,14 +19,6 @@ namespace Unity.GrantManager.Notifications public class NotificationsAppService(IDynamicUrlRepository dynamicUrlRepository, ILogger logger, ICurrentTenant currentTenant) : INotificationsAppService, ITransientDependency { - private readonly IDynamicUrlRepository _dynamicUrlRepository; - private readonly TeamsNotificationService _teamsNotificationService; - - public NotificationsAppService(IDynamicUrlRepository dynamicUrlRepository) - { - _dynamicUrlRepository = dynamicUrlRepository; - _teamsNotificationService = new TeamsNotificationService(); - } [UnitOfWork] public async Task InitializeTeamsChannelAsync(string keyName) @@ -67,7 +59,7 @@ public async Task PostToTeamsAsync(string activityTitle, string activitySubtitle string teamsChannel = await InitializeTeamsChannelAsync(TeamsNotificationService.TEAMS_NOTIFICATION); if (teamsChannel.IsNullOrEmpty()) { - logger.LogWarning("PostToTeamsAsync: no Teams channel configured, skipping notification {Title}", activityTitle); + logger.LogWarning("PostToTeamsAsync: no Teams channel configured, skipping notification TeamsNotificationService.TEAMS_NOTIFICATION"); return; } @@ -78,7 +70,7 @@ public async Task PostToTeamsAsync(string activityTitle, string activitySubtitle } catch (Exception ex) { - logger.LogWarning(ex, "Failed to post Teams notification '{Title}' to channel {Channel}", activityTitle, teamsChannel); + logger.LogWarning(ex, "Failed to post Teams notification to channel"); } } @@ -87,7 +79,7 @@ public async Task PostToTeamsAsync(string activityTitle, string activitySubtitle string teamsChannel = await InitializeTeamsChannelAsync(TeamsNotificationService.TEAMS_NOTIFICATION); if (teamsChannel.IsNullOrEmpty()) { - logger.LogWarning("PostToTeamsAsync (no-facts): no Teams channel configured, skipping notification {Title}", activityTitle); + logger.LogWarning("PostToTeamsAsync (no-facts): no Teams channel configured, skipping notification"); return; } @@ -99,7 +91,7 @@ public async Task PostToTeamsAsync(string activityTitle, string activitySubtitle } catch (Exception ex) { - logger.LogWarning(ex, "Failed to post Teams notification '{Title}' to channel {Channel}", activityTitle, teamsChannel); + logger.LogWarning(ex, "Failed to post Teams notification to channel"); } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/GitHubBlameLookupService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/GitHubBlameLookupService.cs index cc6e8d439f..306bde0b6d 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/GitHubBlameLookupService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/GitHubBlameLookupService.cs @@ -37,7 +37,10 @@ public GitHubBlameLookupService( repoUrl = _endpointService.GetGitHubRepoUrlAsync() .GetAwaiter().GetResult(); } - catch { } + catch (Exception ex) + { + _logger.LogDebug(ex, "Failed to resolve GitHub repo URL from endpoint configuration; falling back to environment variables."); + } } if (!string.IsNullOrWhiteSpace(repoUrl)) @@ -127,8 +130,6 @@ public GitHubBlameLookupService( if (url == null) return null; - _logger.LogInformation("[BlameLookup] Request payload: {Payload}", payload); - using var response = await _httpClient.PostAsync( url, new StringContent(payload, Encoding.UTF8, "application/json")); @@ -136,15 +137,12 @@ public GitHubBlameLookupService( response.EnsureSuccessStatusCode(); string json = await response.Content.ReadAsStringAsync(); - - _logger.LogInformation("[BlameLookup] Response body: {Json}", json); - using var doc = JsonDocument.Parse(json); var root = doc.RootElement; if (root.TryGetProperty("errors", out var errors)) { - _logger.LogWarning("[BlameLookup] GraphQL errors: {Errors}", errors); + _logger.LogWarning("[BlameLookup] GraphQL errors"); return null; } From e34f0a81b9ab537b04fc337986c7668df7fe4f9c Mon Sep 17 00:00:00 2001 From: JamesPasta Date: Thu, 4 Jun 2026 10:30:24 -0700 Subject: [PATCH 049/223] feature/AB#32049-PrometheusCoPilotSuggestions --- .../openshift/alertmanager-config.yaml | 0 .../scripts/openshift/prometheus-rule.yaml | 44 ------------------- .../scripts/openshift/service-monitor.yaml | 23 ---------- .../scripts/prometheus/alert-rules.yml | 29 ------------ .../scripts/prometheus/alertmanager.yml | 15 ------- .../scripts/prometheus/prometheus.yml | 17 ------- 6 files changed, 128 deletions(-) delete mode 100644 applications/Unity.GrantManager/scripts/openshift/alertmanager-config.yaml delete mode 100644 applications/Unity.GrantManager/scripts/openshift/prometheus-rule.yaml delete mode 100644 applications/Unity.GrantManager/scripts/openshift/service-monitor.yaml delete mode 100644 applications/Unity.GrantManager/scripts/prometheus/alert-rules.yml delete mode 100644 applications/Unity.GrantManager/scripts/prometheus/alertmanager.yml delete mode 100644 applications/Unity.GrantManager/scripts/prometheus/prometheus.yml diff --git a/applications/Unity.GrantManager/scripts/openshift/alertmanager-config.yaml b/applications/Unity.GrantManager/scripts/openshift/alertmanager-config.yaml deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/applications/Unity.GrantManager/scripts/openshift/prometheus-rule.yaml b/applications/Unity.GrantManager/scripts/openshift/prometheus-rule.yaml deleted file mode 100644 index d4096abc2a..0000000000 --- a/applications/Unity.GrantManager/scripts/openshift/prometheus-rule.yaml +++ /dev/null @@ -1,44 +0,0 @@ -# PrometheusRule CRD — loaded by the OpenShift cluster Prometheus Operator -# Deploy with: oc apply -f scripts/openshift/prometheus-rule.yaml -n d18498- -# -# Replaces: scripts/prometheus/alert-rules.yml (docker-compose local only) -apiVersion: monitoring.coreos.com/v1 -kind: PrometheusRule -metadata: - name: unity-grantmanager-exceptions - labels: - # These labels must match the Prometheus Operator's ruleSelector in your namespace. - # On BC Gov Silver cluster the label below is standard. - role: alert-rules -spec: - groups: - - name: unity-grantmanager-exceptions - rules: - # Fire if any exception type exceeds 5 occurrences in a 5-minute window - - alert: HighExceptionRate - expr: | - increase(application_exceptions_total[5m]) > 5 - for: 1m - labels: - severity: critical - annotations: - summary: "High exception rate in Unity GrantManager" - description: > - Exception type {{ $labels.type }} has fired {{ $value | humanize }} times - in the last 5 minutes (namespace: {{ $labels.namespace }}). - - # Fire if a new exception type appears (catches regressions after deploys) - - alert: NewExceptionType - expr: | - increase(application_exceptions_total[10m]) > 0 - unless ( - increase(application_exceptions_total[10m] offset 10m) > 0 - ) - for: 0m - labels: - severity: warning - annotations: - summary: "New exception type detected in Unity GrantManager" - description: > - A new exception type {{ $labels.type }} appeared for the first time - in the last 10 minutes (namespace: {{ $labels.namespace }}). diff --git a/applications/Unity.GrantManager/scripts/openshift/service-monitor.yaml b/applications/Unity.GrantManager/scripts/openshift/service-monitor.yaml deleted file mode 100644 index 3229f49107..0000000000 --- a/applications/Unity.GrantManager/scripts/openshift/service-monitor.yaml +++ /dev/null @@ -1,23 +0,0 @@ -# ServiceMonitor CRD — tells the Prometheus Operator how to scrape /metrics from the app -# Deploy with: oc apply -f scripts/openshift/service-monitor.yaml -n d18498- -# -# Replaces: scrape_configs in scripts/prometheus/prometheus.yml (docker-compose local only) -# -# Prerequisites: -# The app Service must exist and expose port 8080 (or 80). -# Adjust 'port' below to match your Service's named port. -apiVersion: monitoring.coreos.com/v1 -kind: ServiceMonitor -metadata: - name: unity-grantmanager - labels: - app.kubernetes.io/name: unity-grant-manager -spec: - selector: - matchLabels: - app.kubernetes.io/name: unity-grant-manager # matches all env Service labels - endpoints: - - port: 80-tcp # named port on the Service pointing to 8080 - path: /metrics - interval: 15s - scheme: http diff --git a/applications/Unity.GrantManager/scripts/prometheus/alert-rules.yml b/applications/Unity.GrantManager/scripts/prometheus/alert-rules.yml deleted file mode 100644 index c8ec1ad6e6..0000000000 --- a/applications/Unity.GrantManager/scripts/prometheus/alert-rules.yml +++ /dev/null @@ -1,29 +0,0 @@ -groups: - - name: unity-grantmanager-exceptions - rules: - # Fire if any exception type exceeds 5 occurrences in a 5-minute window - - alert: HighExceptionRate - expr: | - increase(application_exceptions_total[5m]) > 5 - for: 1m - labels: - severity: critical - annotations: - summary: "High exception rate in Unity GrantManager" - description: > - Exception type {{ $labels.type }} has fired {{ $value | humanize }} times - in the last 5 minutes (job: {{ $labels.job }}, instance: {{ $labels.instance }}). - - alert: NewExceptionType - expr: | - increase(application_exceptions_total[10m]) > 0 - unless ( - increase(application_exceptions_total[10m] offset 10m) > 0 - ) - for: 0m - labels: - severity: warning - annotations: - summary: "New exception type detected in Unity GrantManager" - description: > - A new exception type {{ $labels.type }} appeared for the first time - in the last 10 minutes. diff --git a/applications/Unity.GrantManager/scripts/prometheus/alertmanager.yml b/applications/Unity.GrantManager/scripts/prometheus/alertmanager.yml deleted file mode 100644 index f313fbf3b7..0000000000 --- a/applications/Unity.GrantManager/scripts/prometheus/alertmanager.yml +++ /dev/null @@ -1,15 +0,0 @@ -global: - resolve_timeout: 5m - -route: - group_by: ["alertname", "type"] - group_wait: 10s - group_interval: 5m - repeat_interval: 1h - receiver: unity-webhook - -receivers: - - name: unity-webhook - webhook_configs: - - url: "http://unity-grantmanager-web:8080/api/monitoring/alert" - send_resolved: false diff --git a/applications/Unity.GrantManager/scripts/prometheus/prometheus.yml b/applications/Unity.GrantManager/scripts/prometheus/prometheus.yml deleted file mode 100644 index 4bc80b2be4..0000000000 --- a/applications/Unity.GrantManager/scripts/prometheus/prometheus.yml +++ /dev/null @@ -1,17 +0,0 @@ -global: - scrape_interval: 15s - evaluation_interval: 15s - -alerting: - alertmanagers: - - static_configs: - - targets: ["alertmanager:9093"] - -rule_files: - - /etc/prometheus/alert-rules.yml - -scrape_configs: - - job_name: unity-grantmanager - static_configs: - - targets: ["unity-grantmanager-web:8080"] - metrics_path: /metrics From 6da4f7ad6abc1a3b89aba8ff70f17afc223c2e74 Mon Sep 17 00:00:00 2001 From: JamesPasta Date: Thu, 4 Jun 2026 11:15:32 -0700 Subject: [PATCH 050/223] feature/AB#32049-PrometheusCoPilotSuggestions --- .../AbpExceptionNotificationSubscriber.cs | 37 +------------------ 1 file changed, 1 insertion(+), 36 deletions(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/AbpExceptionNotificationSubscriber.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/AbpExceptionNotificationSubscriber.cs index 528493f80d..7b603bd922 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/AbpExceptionNotificationSubscriber.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/AbpExceptionNotificationSubscriber.cs @@ -195,11 +195,7 @@ private async Task EnrichWithBlameInfoAsync( try { - string blamePath = - ExceptionNotificationHelpers.BuildBlamePath(sourceFile); - string blamePath = ExceptionNotificationHelpers.BuildBlamePath(sourceFile, logger); - - // Resolve blame lookup as optional — it may not be registered in some environments + string blamePath = ExceptionNotificationHelpers.BuildBlamePath(sourceFile); var blameService = services.GetService(); if (blameService == null) { @@ -229,37 +225,6 @@ private async Task EnrichWithBlameInfoAsync( AddAuthorFact(facts, blame); AddCommitFact(facts, blame); - AddPullRequestFacts(facts, blame); - { - return; - } - - logger.LogInformation( - "[ExceptionNotify] Blame lookup successful: {Author} {Commit}", - blame.Author, - blame.CommitSha); - - AddAuthorFact(facts, blame); - AddCommitFact(facts, blame); - AddPullRequestFacts(facts, blame); - } - catch (Exception blameException) - { - logger.LogWarning( - blameException, - "Failed to enrich exception with GitHub blame information"); ->>>>>>> f09149c73 (feature/AB#32049-FixBuildExceptionConsistency) - } - - logger.LogInformation( - "[ExceptionNotify] Blame lookup successful: {Author} {Commit}", - blame.Author, - blame.CommitSha); - - AddAuthorFact(facts, blame); - - AddCommitFact(facts, blame); - AddPullRequestFacts(facts, blame); } catch (Exception blameException) From d0353c4958a763fb6c5aa040af50a8b2be169fe5 Mon Sep 17 00:00:00 2001 From: Andre Goncalves Date: Wed, 8 Jul 2026 16:15:03 -0700 Subject: [PATCH 051/223] AB#32738 Fix ABP permission/role authorization gaps and reduce auth cookie size - Fix PolicyRegistrant.cs registering claim-based policies that shadowed ABP's dynamic permission resolution, denying access to every user - Restore ITAdmin/ITOperations host access via role-or-permission composite policies (RoleOrPermissionRequirement) - Fix menu visibility for ITAdmin/ITOperations across menu contributors (new .OnlyWhenInRole() extension, replacing broken permission-name checks) - Fix duplicate/incorrect UserId claim resolution causing comment save failures, and remove IDIR-guid claim shadowing the real user id - Fix Dynamic Claims middleware ordering (UseAuthentication must run before UseDynamicClaims) so role/permission changes refresh without a full re-login - Keep AbpClaimTypes.Role and UnityClaimsTypes.Role as distinct claim types so ABP's dynamic-claims refresh doesn't wipe Keycloak-native ITAdministrator/ITOperations roles - Reduce auth cookie size (2 chunks -> 1 chunk) by dropping the dead legacy UserId claim and unused raw JWT/OIDC metadata claims Co-Authored-By: Claude Sonnet 5 --- .../Unity.AI.Web/Menus/AIMenuContributor.cs | 8 +- .../Menus/ReportingMenuContributor.cs | 12 +- .../Navigation/MenuItemExtensions.cs | 62 +++- ...yTenantManagementWebMainMenuContributor.cs | 13 +- .../GrantManagerWebModule.cs | 18 +- .../RoleOrPermissionAuthorizationHandler.cs | 9 +- .../Identity/CurrentUser.cs | 14 +- .../Identity/IdentityProfileLoginHandler.cs | 19 ++ .../IdentityProfileLoginAdminHandler.cs | 7 +- .../LoginHandlers/IdentityProfileLoginBase.cs | 9 +- .../IdentityProfileLoginUserHandler.cs | 6 + .../Identity/PermissionOrPolicyRegistrant.cs | 38 +++ .../Identity/PolicyRegistrant.cs | 303 ++++-------------- .../Menus/GrantManagerMenuContributor.cs | 29 +- ...leOrPermissionAuthorizationHandlerTests.cs | 33 +- 15 files changed, 261 insertions(+), 319 deletions(-) create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/PermissionOrPolicyRegistrant.cs diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Menus/AIMenuContributor.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Menus/AIMenuContributor.cs index 50eea177db..a60007085a 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Menus/AIMenuContributor.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Menus/AIMenuContributor.cs @@ -2,6 +2,7 @@ using Microsoft.Extensions.DependencyInjection; using Unity.AI.Localization; using Unity.AI.Permissions; +using Unity.Modules.Shared.Navigation; using Unity.Modules.Shared.Specializations; using Unity.Modules.Shared.Permissions; using Volo.Abp.Features; @@ -27,14 +28,13 @@ private static async Task ConfigureMainMenuAsync(MenuConfigurationContext contex var specializationChecker = context.ServiceProvider.GetRequiredService(); if (!await specializationChecker.IsEnabledAsync(SpecializationConsts.Onboarding)) { - context.Menu.AddItem(new ApplicationMenuItem( + await context.AddItemAsync(new ApplicationMenuItem( name: AIMenus.Prompts, displayName: "AI Prompts", url: "~/Prompts", icon: "fl fl-ai-prompts", - order: 900, - requiredPermissionName: IdentityConsts.ITOperationsPermissionName - )); + order: 900 + ).OnlyWhenInRole(IdentityConsts.ITOperationsRoleName)); } if (await featureChecker.IsEnabledAsync("Unity.AIReporting")) diff --git a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/Menus/ReportingMenuContributor.cs b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/Menus/ReportingMenuContributor.cs index d6e91808e7..0819c74972 100644 --- a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/Menus/ReportingMenuContributor.cs +++ b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/Menus/ReportingMenuContributor.cs @@ -1,4 +1,5 @@ using System.Threading.Tasks; +using Unity.Modules.Shared.Navigation; using Unity.Modules.Shared.Permissions; using Volo.Abp.UI.Navigation; @@ -31,16 +32,15 @@ public async Task ConfigureMenuAsync(MenuConfigurationContext context) /// /// The menu configuration context for adding reporting menu items. /// A completed task representing the synchronous menu item addition operations. - private static Task ConfigureReportingMenuAsync(MenuConfigurationContext context) + private static async Task ConfigureReportingMenuAsync(MenuConfigurationContext context) { // Add Reporting Configuration menu item for IT Admin users - context.Menu.AddItem( + await context.AddItemAsync( new ApplicationMenuItem( ReportingMenus.Prefix, displayName: "Reporting", - "~/ReportingAdmin/Configuration", - requiredPermissionName: IdentityConsts.ITAdminPermissionName - )); - return Task.CompletedTask; + "~/ReportingAdmin/Configuration") + .OnlyWhenInRole(IdentityConsts.ITAdminRoleName) + ); } } diff --git a/applications/Unity.GrantManager/modules/Unity.SharedKernel/Navigation/MenuItemExtensions.cs b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Navigation/MenuItemExtensions.cs index 4c2a541385..80ed085121 100644 --- a/applications/Unity.GrantManager/modules/Unity.SharedKernel/Navigation/MenuItemExtensions.cs +++ b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Navigation/MenuItemExtensions.cs @@ -1,8 +1,10 @@ +using System; using System.Threading.Tasks; using Microsoft.Extensions.DependencyInjection; using Unity.Modules.Shared.Specializations; using Volo.Abp.Features; using Volo.Abp.UI.Navigation; +using Volo.Abp.Users; namespace Unity.Modules.Shared.Navigation; @@ -12,6 +14,7 @@ public static class MenuItemExtensions private const string OnlyWhenFeaturesKey = "_OnlyWhenFeatures"; private const string ExcludeWhenSpecializationsKey = "_ExcludeWhenSpecializations"; private const string OnlyWhenSpecializationsKey = "_OnlyWhenSpecializations"; + private const string OnlyWhenInRoleKey = "_OnlyWhenInRole"; /// /// Hides this menu item when any of the given features are enabled. @@ -58,21 +61,56 @@ public static ApplicationMenuItem OnlyWhenSpecializations( } /// - /// Adds the item to the menu, respecting any feature or specialization visibility declarations. + /// Shows this menu item only when the current user is in any of the given roles + /// (checked via ICurrentUser.IsInRole, e.g. Keycloak-issued client roles). + /// + public static ApplicationMenuItem OnlyWhenInRole( + this ApplicationMenuItem item, + params string[] roleNames) + { + item.CustomData[OnlyWhenInRoleKey] = roleNames; + return item; + } + + /// + /// Adds the item to the menu, respecting any feature, specialization or role visibility declarations. /// public static async Task AddItemAsync( this MenuConfigurationContext context, ApplicationMenuItem item) { - var featureChecker = context.ServiceProvider.GetRequiredService(); - var specializationChecker = context.ServiceProvider.GetRequiredService(); + if (await IsVisibleAsync(item, context.ServiceProvider)) + { + context.Menu.AddItem(item); + } + } + + /// + /// Adds the item as a child of the given parent menu item, respecting any feature, + /// specialization or role visibility declarations. + /// + public static async Task AddItemAsync( + this ApplicationMenuItem parent, + IServiceProvider serviceProvider, + ApplicationMenuItem item) + { + if (await IsVisibleAsync(item, serviceProvider)) + { + parent.AddItem(item); + } + } + + private static async Task IsVisibleAsync(ApplicationMenuItem item, IServiceProvider serviceProvider) + { + var featureChecker = serviceProvider.GetRequiredService(); + var specializationChecker = serviceProvider.GetRequiredService(); if (item.CustomData.TryGetValue(ExcludeWhenFeaturesKey, out var excludeFeatObj) && excludeFeatObj is string[] excludeFeatures) { foreach (var feature in excludeFeatures) if (await featureChecker.IsEnabledAsync(feature)) - return; + return false; } if (item.CustomData.TryGetValue(OnlyWhenFeaturesKey, out var onlyFeatObj) @@ -80,7 +118,7 @@ public static async Task AddItemAsync( { foreach (var feature in onlyFeatures) if (!await featureChecker.IsEnabledAsync(feature)) - return; + return false; } if (item.CustomData.TryGetValue(ExcludeWhenSpecializationsKey, out var excludeSpecObj) @@ -88,7 +126,7 @@ public static async Task AddItemAsync( { foreach (var spec in excludeSpecs) if (await specializationChecker.IsEnabledAsync(spec)) - return; + return false; } if (item.CustomData.TryGetValue(OnlyWhenSpecializationsKey, out var onlySpecObj) @@ -96,9 +134,17 @@ public static async Task AddItemAsync( { foreach (var spec in onlySpecs) if (!await specializationChecker.IsEnabledAsync(spec)) - return; + return false; + } + + if (item.CustomData.TryGetValue(OnlyWhenInRoleKey, out var onlyRoleObj) + && onlyRoleObj is string[] onlyRoles) + { + var currentUser = serviceProvider.GetRequiredService(); + if (!Array.Exists(onlyRoles, currentUser.IsInRole)) + return false; } - context.Menu.AddItem(item); + return true; } } diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Navigation/UnityTenantManagementWebMainMenuContributor.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Navigation/UnityTenantManagementWebMainMenuContributor.cs index 54c9e44dea..9915343860 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Navigation/UnityTenantManagementWebMainMenuContributor.cs +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Navigation/UnityTenantManagementWebMainMenuContributor.cs @@ -1,18 +1,18 @@ using System.Threading.Tasks; +using Unity.Modules.Shared.Navigation; using Unity.Modules.Shared.Permissions; using Volo.Abp.TenantManagement.Localization; using Volo.Abp.UI.Navigation; -using Volo.Abp.Authorization.Permissions; namespace Unity.TenantManagement.Web.Navigation; public class AbpTenantManagementWebMainMenuContributor : IMenuContributor { - public virtual Task ConfigureMenuAsync(MenuConfigurationContext context) + public virtual async Task ConfigureMenuAsync(MenuConfigurationContext context) { if (context.Menu.Name != StandardMenus.Main) { - return Task.CompletedTask; + return; } var administrationMenu = context.Menu.GetAdministration(); @@ -22,11 +22,10 @@ public virtual Task ConfigureMenuAsync(MenuConfigurationContext context) var tenantManagementMenuItem = new ApplicationMenuItem(TenantManagementMenuNames.GroupName, l["Menu:TenantManagement"], icon: "fa fa-users"); administrationMenu.AddItem(tenantManagementMenuItem); - tenantManagementMenuItem.AddItem( + await tenantManagementMenuItem.AddItemAsync( + context.ServiceProvider, new ApplicationMenuItem(TenantManagementMenuNames.Tenants, l["Tenants"], url: "~/TenantManagement/Tenants") - .RequirePermissions(TenantManagementPermissions.Tenants.Default, IdentityConsts.ITOperationsPermissionName) + .OnlyWhenInRole(IdentityConsts.ITAdminRoleName, IdentityConsts.ITOperationsRoleName) ); - - return Task.CompletedTask; } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/GrantManagerWebModule.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/GrantManagerWebModule.cs index fff250360e..ddd33139d5 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/GrantManagerWebModule.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/GrantManagerWebModule.cs @@ -62,6 +62,7 @@ using Volo.Abp.Modularity; using Volo.Abp.OpenIddict.Tokens; using Volo.Abp.SecurityLog; +using Volo.Abp.Security.Claims; using Volo.Abp.SettingManagement.Web; using Volo.Abp.SettingManagement.Web.Pages.SettingManagement; using Volo.Abp.Swashbuckle; @@ -277,10 +278,24 @@ private static void ConfgureFormsApiAuhentication(ServiceConfigurationContext co private static void ConfigurePolicies(ServiceConfigurationContext context) { PolicyRegistrant.Register(context); + PermissionOrPolicyRegistrant.Register(context); } private static void ConfigureAuthentication(ServiceConfigurationContext context, IConfiguration configuration) { + context.Services.Configure(options => + { + options.IsDynamicClaimsEnabled = true; //set it "true" to enable "Dynamic Claims" or "false" to disable it. + }); + + // NOTE: AbpClaimTypes.Role must stay a DISTINCT claim type from UnityClaimsTypes.Role + // ("client_roles"). ABP's dynamic claims refresh (AbpDynamicClaimsPrincipalContributorBase) + // recomputes AbpClaimTypes.Role claims purely from the user's real DB IdentityUserRole + // assignments and REPLACES (RemoveAll + re-add) whatever was there. Keycloak-only roles + // (ITAdministrator/ITOperations) are never DB roles, so if the two claim types were unified, + // every dynamic-claims refresh would wipe them out. Unifying them was tried as a cookie-size + // optimization (one claim per role instead of two) but reverted for this reason. + context.Services.AddAuthentication(options => { options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme; @@ -589,7 +604,7 @@ public override void OnApplicationInitialization(ApplicationInitializationContex app.UseStaticFiles(); app.UseMiddleware(); app.UseRouting(); - app.UseAuthentication(); + app.UseAuthentication(); if (MultiTenancyConsts.IsEnabled) { @@ -597,6 +612,7 @@ public override void OnApplicationInitialization(ApplicationInitializationContex } app.UseUnitOfWork(); + app.UseDynamicClaims(); app.UseAuthorization(); if (IsProfilingAllowed(env, configuration)) { diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/Authorization/RoleOrPermissionAuthorizationHandler.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/Authorization/RoleOrPermissionAuthorizationHandler.cs index 2701b9ba35..cbb607da22 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/Authorization/RoleOrPermissionAuthorizationHandler.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/Authorization/RoleOrPermissionAuthorizationHandler.cs @@ -1,3 +1,4 @@ +using System.Linq; using Microsoft.AspNetCore.Authorization; using System.Threading.Tasks; using Volo.Abp.Authorization.Permissions; @@ -7,12 +8,12 @@ namespace Unity.GrantManager.Web.Identity.Authorization; public class RoleOrPermissionRequirement : IAuthorizationRequirement { - public string RoleName { get; } + public string[] RoleNames { get; } public string PermissionName { get; } - public RoleOrPermissionRequirement(string roleName, string permissionName) + public RoleOrPermissionRequirement(string[] roleNames, string permissionName) { - RoleName = roleName; + RoleNames = roleNames; PermissionName = permissionName; } } @@ -30,7 +31,7 @@ protected override async Task HandleRequirementAsync( AuthorizationHandlerContext context, RoleOrPermissionRequirement requirement) { - if (context.User.IsInRole(requirement.RoleName)) + if (requirement.RoleNames.Any(context.User.IsInRole)) { context.Succeed(requirement); return; diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/CurrentUser.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/CurrentUser.cs index 8bf2c7fb2b..5e67687d08 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/CurrentUser.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/CurrentUser.cs @@ -75,19 +75,15 @@ public virtual bool IsInRole(string roleName) var userClaims = _principalAccessor.Principal?.Claims; if (userClaims != null && userClaims.Any()) { - // First try the IDIR-specific GUID claim - var idirGuid = userClaims.FirstOrDefault(s => s.Type == UnityClaimsTypes.IDirUserGuid); - if (idirGuid != null && Guid.TryParse(idirGuid.Value, out var guid)) - { - return guid; - } - - // Fallback to UserId claim (strip @azureidir suffix if present) + // UnityClaimsTypes.IDirUserGuid ("idir_user_guid") is Keycloak's IDIR-specific + // identifier, used only to derive the OIDC subject for account matching on login + // (see UserImportAppService) - it is NOT the database user id and must not be used + // here, even though it happens to also be GUID-formatted. var userId = userClaims.FirstOrDefault(s => s.Type == AbpClaimTypes.UserId); if (userId != null) { var value = userId.Value.Split('@')[0]; // Remove @azureidir suffix - if (Guid.TryParse(value, out guid)) + if (Guid.TryParse(value, out var guid)) { return guid; } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/IdentityProfileLoginHandler.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/IdentityProfileLoginHandler.cs index d94f32e958..b54a3f5f6b 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/IdentityProfileLoginHandler.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/IdentityProfileLoginHandler.cs @@ -4,6 +4,7 @@ using System.Collections.Generic; using System.Linq; using System.Security.Claims; +using System.Security.Principal; using System.Threading.Tasks; using Unity.GrantManager.Identity; using Unity.GrantManager.Web.Identity.LoginHandlers; @@ -44,6 +45,7 @@ internal async Task HandleAsync(TokenValidatedContext validatedTokenContext) } AddTenantClaims(validatedTokenContext.Principal!, userTenantAccounts); + RemoveRawJwtMetadataClaims(validatedTokenContext.Principal!); // Create security log await securityLogManager.SaveAsync(securityLog => @@ -65,5 +67,22 @@ private static void AddTenantClaims(ClaimsPrincipal claimsPrincipal, IList Handle(TokenValidatedContext validated } AssignDefaultClaims(validatedTokenContext.Principal!, userTenantAccount.DisplayName ?? string.Empty, userTenantAccount.Id); - (validatedTokenContext.Principal!.Identity as ClaimsIdentity)?.AddClaim(new Claim(AbpClaimTypes.Role, IdentityConsts.ITAdminRoleName)); + // No explicit role claim stamped here - ITAdministrator/ITOperations role recognition + // relies entirely on the client_roles claim Keycloak already sends natively (the routing + // check at the top of IdentityProfileLoginHandler.HandleAsync depends on that being true + // before this handler even runs). return userTenantAccount; } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/LoginHandlers/IdentityProfileLoginBase.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/LoginHandlers/IdentityProfileLoginBase.cs index 4ca8271e8b..59c76fa0ec 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/LoginHandlers/IdentityProfileLoginBase.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/LoginHandlers/IdentityProfileLoginBase.cs @@ -1,5 +1,6 @@ using System.Security.Claims; using System; +using System.Security.Principal; using Unity.GrantManager.Identity; using OpenIddict.Abstractions; using System.IdentityModel.Tokens.Jwt; @@ -27,9 +28,15 @@ internal abstract class IdentityProfileLoginBase : ITransientDependency protected static void AssignDefaultClaims(ClaimsPrincipal claimsPrinicipal, string displayName, Guid userId) { + // AbpClaimTypes.UserId is the same claim type URI as ClaimTypes.NameIdentifier, which the + // OIDC/JWT handler already populates from the token's "sub" claim before this runs. Without + // clearing it first, the principal ends up with two UserId claims (Keycloak's sub, then ours), + // and CurrentUser.FindUserId()'s FirstOrDefault picks the wrong (sub) one. + var identity = claimsPrinicipal.Identity as ClaimsIdentity; + identity?.RemoveAll(AbpClaimTypes.UserId); + claimsPrinicipal.AddClaim("DisplayName", displayName); claimsPrinicipal.AddClaim(AbpClaimTypes.UserId, userId.ToString()); - claimsPrinicipal.AddClaim("UserId", userId.ToString()); // Legacy claim for backward compatibility claimsPrinicipal.AddClaim("Badge", Utils.CreateUserBadge(displayName)); } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/LoginHandlers/IdentityProfileLoginUserHandler.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/LoginHandlers/IdentityProfileLoginUserHandler.cs index d04f1dc4aa..e65400bab9 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/LoginHandlers/IdentityProfileLoginUserHandler.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/LoginHandlers/IdentityProfileLoginUserHandler.cs @@ -64,6 +64,12 @@ internal async Task Handle(TokenValidatedContext validated foreach (var role in userRoles) { var dbRole = await IdentityRoleManager.GetByIdAsync(role.Id); + // Two distinct claim types are intentional here - see the NOTE in + // GrantManagerWebModule.ConfigureAuthentication. UnityClaimsTypes.Role + // ("client_roles") drives ASP.NET Core's RoleClaimType/IsInRole and also + // carries Keycloak-only roles (ITAdministrator/ITOperations). AbpClaimTypes.Role + // drives ABP's RolePermissionValueProvider and is safe for ABP's dynamic claims + // refresh to recompute from the DB, since it only ever holds real DB roles. principal.AddClaim(UnityClaimsTypes.Role, dbRole.Name); principal.AddClaim(AbpClaimTypes.Role, dbRole.Name); } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/PermissionOrPolicyRegistrant.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/PermissionOrPolicyRegistrant.cs new file mode 100644 index 0000000000..89bc5d623d --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/PermissionOrPolicyRegistrant.cs @@ -0,0 +1,38 @@ +using Microsoft.Extensions.DependencyInjection; +using Unity.GrantManager.Web.Identity.Authorization; +using Unity.Modules.Shared; +using Volo.Abp.Modularity; + +namespace Unity.GrantManager.Web.Identity.Policy; + +// Composite "any of these permissions" policies - no Keycloak role involved. +// TODO: remove once the underlying permissions are consolidated so a single +// real permission can be checked directly instead of an OR across several. +internal static class PermissionOrPolicyRegistrant +{ + internal static void Register(ServiceConfigurationContext context) + { + var authorizationBuilder = context.Services.AddAuthorizationBuilder(); + + // Applicant Info Logical OR policy + authorizationBuilder.AddPolicy(UnitySelector.Applicant.UpdatePolicy, + policy => policy.AddRequirements(new PermissionOrRequirement( + UnitySelector.Applicant.Summary.Update, + UnitySelector.Applicant.Contact.Update, + UnitySelector.Applicant.Authority.Update, + UnitySelector.Applicant.Location.Update, + UnitySelector.Applicant.AdditionalContact.Update, + + // NOTE: This will be replaced when Worksheets are normalized with UnitySelector.Applicant.Worksheet.Update + UnitySelector.Applicant.Default))); + + // Project Info Logical OR policy + authorizationBuilder.AddPolicy(UnitySelector.Project.UpdatePolicy, + policy => policy.AddRequirements(new PermissionOrRequirement( + UnitySelector.Project.Location.Update.Default, + UnitySelector.Project.Summary.Update.Default, + + // NOTE: This will be replaced when Worksheets are normalized with UnitySelector.Project.Worksheet.Update + UnitySelector.Project.Default))); + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/PolicyRegistrant.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/PolicyRegistrant.cs index 777d5f6066..a08db9ed97 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/PolicyRegistrant.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/PolicyRegistrant.cs @@ -1,8 +1,6 @@ using Microsoft.Extensions.DependencyInjection; -using Unity.GrantManager.Permissions; -using Unity.Modules.Shared; +using Unity.GrantManager.Web.Identity.Authorization; using Unity.Modules.Shared.Permissions; -using Unity.Reporting.Permissions; using Unity.TenantManagement; using Volo.Abp.Identity; using Volo.Abp.Modularity; @@ -11,262 +9,71 @@ namespace Unity.GrantManager.Web.Identity.Policy; internal static class PolicyRegistrant { - internal const string PermissionConstant = "Permission"; + // IT Administrator is the "host" superuser login (see IdentityProfileLoginAdminHandler) - + // it must retain at least the host/tenant-admin access ITOperations has, plus a few + // admin-only permissions (user creation/lookup, tenant delete/connection strings) that + // used to be granted via a hardcoded claim stamp at login (_adminPermissions, removed + // when cookie-stamped permission claims were dropped in favour of IPermissionChecker). + private static readonly string[] ITAdminOrITOperationsRoles = + [IdentityConsts.ITAdminRoleName, IdentityConsts.ITOperationsRoleName]; internal static void Register(ServiceConfigurationContext context) { - // Using AddAuthorizationBuilder to register authorization services and construct policies + // All permission-based policies (single permission or otherwise) are resolved + // dynamically by ABP's AbpAuthorizationPolicyProvider via IPermissionChecker + // (Redis-cached). Only policies that need to check a Keycloak-issued role claim + // (IsInRole) need explicit registration here. var authorizationBuilder = context.Services.AddAuthorizationBuilder(); - // Identity Role Policies - authorizationBuilder.AddPolicy(IdentityPermissions.Roles.Default, - policy => policy.RequireClaim(PermissionConstant, IdentityPermissions.Roles.Default)); - authorizationBuilder.AddPolicy(IdentityPermissions.Roles.Create, - policy => policy.RequireClaim(PermissionConstant, IdentityPermissions.Roles.Create)); - authorizationBuilder.AddPolicy(IdentityPermissions.Roles.Update, - policy => policy.RequireClaim(PermissionConstant, IdentityPermissions.Roles.Update)); - authorizationBuilder.AddPolicy(IdentityPermissions.Roles.Delete, - policy => policy.RequireClaim(PermissionConstant, IdentityPermissions.Roles.Delete)); - authorizationBuilder.AddPolicy(IdentityPermissions.Roles.ManagePermissions, - policy => policy.RequireClaim(PermissionConstant, IdentityPermissions.Roles.ManagePermissions)); - - // Identity User Policies - authorizationBuilder.AddPolicy(IdentityPermissions.Users.Default, - policy => policy.RequireClaim(PermissionConstant, IdentityPermissions.Users.Default)); - authorizationBuilder.AddPolicy(IdentityPermissions.Users.Create, - policy => policy.RequireClaim(PermissionConstant, IdentityPermissions.Users.Create)); - authorizationBuilder.AddPolicy(IdentityPermissions.Users.Update, - policy => policy.RequireClaim(PermissionConstant, IdentityPermissions.Users.Update)); - authorizationBuilder.AddPolicy(IdentityPermissions.Users.Delete, - policy => policy.RequireClaim(PermissionConstant, IdentityPermissions.Users.Delete)); - authorizationBuilder.AddPolicy(IdentityPermissions.Users.ManagePermissions, - policy => policy.RequireClaim(PermissionConstant, IdentityPermissions.Users.ManagePermissions)); - - // User Lookup Policies - authorizationBuilder.AddPolicy(IdentityPermissions.UserLookup.Default, - policy => policy.RequireClaim(PermissionConstant, IdentityPermissions.UserLookup.Default)); - - // Grant Manager Policies - authorizationBuilder.AddPolicy(GrantManagerPermissions.Default, - policy => policy.RequireClaim(PermissionConstant, GrantManagerPermissions.Default)); - authorizationBuilder.AddPolicy(GrantManagerPermissions.Intakes.Default, - policy => policy.RequireClaim(PermissionConstant, GrantManagerPermissions.Intakes.Default)); - authorizationBuilder.AddPolicy(GrantManagerPermissions.ApplicationForms.Default, - policy => policy.RequireClaim(PermissionConstant, GrantManagerPermissions.ApplicationForms.Default)); - - // Grant Application Policies - authorizationBuilder.AddPolicy(GrantApplicationPermissions.Applications.Default, - policy => policy.RequireClaim(PermissionConstant, GrantApplicationPermissions.Applications.Default)); - authorizationBuilder.AddPolicy(GrantApplicationPermissions.Applicants.Default, - policy => policy.RequireClaim(PermissionConstant, GrantApplicationPermissions.Applicants.Default)); - authorizationBuilder.AddPolicy(GrantApplicationPermissions.Applicants.Edit, - policy => policy.RequireClaim(PermissionConstant, GrantApplicationPermissions.Applicants.Edit)); - authorizationBuilder.AddPolicy(GrantApplicationPermissions.Applicants.AssignApplicant, - policy => policy.RequireClaim(PermissionConstant, GrantApplicationPermissions.Applicants.AssignApplicant)); - authorizationBuilder.AddPolicy(GrantApplicationPermissions.Assignments.Default, - policy => policy.RequireClaim(PermissionConstant, GrantApplicationPermissions.Assignments.Default)); - authorizationBuilder.AddPolicy(GrantApplicationPermissions.Assignments.AssignInitial, - policy => policy.RequireClaim(PermissionConstant, GrantApplicationPermissions.Assignments.AssignInitial)); - authorizationBuilder.AddPolicy(GrantApplicationPermissions.Reviews.Default, - policy => policy.RequireClaim(PermissionConstant, GrantApplicationPermissions.Reviews.Default)); - authorizationBuilder.AddPolicy(GrantApplicationPermissions.Reviews.StartInitial, - policy => policy.RequireClaim(PermissionConstant, GrantApplicationPermissions.Reviews.StartInitial)); - authorizationBuilder.AddPolicy(GrantApplicationPermissions.Reviews.CompleteInitial, - policy => policy.RequireClaim(PermissionConstant, GrantApplicationPermissions.Reviews.CompleteInitial)); - authorizationBuilder.AddPolicy(GrantApplicationPermissions.Approvals.Default, - policy => policy.RequireClaim(PermissionConstant, GrantApplicationPermissions.Approvals.Default)); - authorizationBuilder.AddPolicy(GrantApplicationPermissions.Approvals.Complete, - policy => policy.RequireClaim(PermissionConstant, GrantApplicationPermissions.Approvals.Complete)); - authorizationBuilder.AddPolicy(GrantApplicationPermissions.Comments.Default, - policy => policy.RequireClaim(PermissionConstant, GrantApplicationPermissions.Comments.Default)); - authorizationBuilder.AddPolicy(GrantApplicationPermissions.Comments.Add, - policy => policy.RequireClaim(PermissionConstant, GrantApplicationPermissions.Comments.Add)); - - // R&A Policies - authorizationBuilder.AddPolicy(UnitySelector.Review.Default, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Review.Default)); - - // R&A - Approval Policies - authorizationBuilder.AddPolicy(UnitySelector.Review.Approval.Default, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Review.Approval.Default)); - authorizationBuilder.AddPolicy(UnitySelector.Review.Approval.Update.Default, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Review.Approval.Update.Default)); - authorizationBuilder.AddPolicy(UnitySelector.Review.Approval.Update.UpdateFinalStateFields, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Review.Approval.Update.UpdateFinalStateFields)); - - // R&A - Assessment Results Policies - authorizationBuilder.AddPolicy(UnitySelector.Review.AssessmentResults.Default, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Review.AssessmentResults.Default)); - authorizationBuilder.AddPolicy(UnitySelector.Review.AssessmentResults.Update.Default, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Review.AssessmentResults.Update.Default)); - authorizationBuilder.AddPolicy(UnitySelector.Review.AssessmentResults.Update.UpdateFinalStateFields, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Review.AssessmentResults.Update.UpdateFinalStateFields)); - - // R&A - Assessment Review List Policies - authorizationBuilder.AddPolicy(UnitySelector.Review.AssessmentReviewList.Default, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Review.AssessmentReviewList.Default)); - authorizationBuilder.AddPolicy(UnitySelector.Review.AssessmentReviewList.Create, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Review.AssessmentReviewList.Create)); - authorizationBuilder.AddPolicy(UnitySelector.Review.AssessmentReviewList.Update.SendBack, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Review.AssessmentReviewList.Update.SendBack)); - authorizationBuilder.AddPolicy(UnitySelector.Review.AssessmentReviewList.Update.Complete, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Review.AssessmentReviewList.Update.Complete)); - - //-- APPLICANT INFO - authorizationBuilder.AddPolicy(UnitySelector.Applicant.Default, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Applicant.Default)); - authorizationBuilder.AddPolicy(UnitySelector.Applicant.Authority.Default, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Applicant.Authority.Default)); - authorizationBuilder.AddPolicy(UnitySelector.Applicant.Authority.Update, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Applicant.Authority.Update)); - authorizationBuilder.AddPolicy(UnitySelector.Applicant.Contact.Default, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Applicant.Contact.Default)); - authorizationBuilder.AddPolicy(UnitySelector.Applicant.Contact.Update, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Applicant.Contact.Update)); - authorizationBuilder.AddPolicy(UnitySelector.Applicant.Location.Default, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Applicant.Location.Default)); - authorizationBuilder.AddPolicy(UnitySelector.Applicant.Location.Update, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Applicant.Location.Update)); - authorizationBuilder.AddPolicy(UnitySelector.Applicant.Summary.Default, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Applicant.Summary.Default)); - authorizationBuilder.AddPolicy(UnitySelector.Applicant.Summary.Update, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Applicant.Summary.Update)); - authorizationBuilder.AddPolicy(UnitySelector.Applicant.AdditionalContact.Default, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Applicant.AdditionalContact.Default)); - authorizationBuilder.AddPolicy(UnitySelector.Applicant.AdditionalContact.Create, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Applicant.AdditionalContact.Create)); - authorizationBuilder.AddPolicy(UnitySelector.Applicant.AdditionalContact.Update, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Applicant.AdditionalContact.Update)); - - // Applicant Info Logical OR policy - authorizationBuilder.AddPolicy(UnitySelector.Applicant.UpdatePolicy, - policy => policy.RequireAssertion(context => - context.User.HasClaim(PermissionConstant, UnitySelector.Applicant.Summary.Update) || - context.User.HasClaim(PermissionConstant, UnitySelector.Applicant.Contact.Update) || - context.User.HasClaim(PermissionConstant, UnitySelector.Applicant.Authority.Update) || - context.User.HasClaim(PermissionConstant, UnitySelector.Applicant.Location.Update) || - context.User.HasClaim(PermissionConstant, UnitySelector.Applicant.AdditionalContact.Update) || - - // NOTE: This will be replaced when Worksheets are normalized with UnitySelector.Applicant.Worksheet.Update - context.User.HasClaim(PermissionConstant, UnitySelector.Applicant.Default) - )); - - //-- PAYMENT INFO - authorizationBuilder.AddPolicy(UnitySelector.Payment.Default, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Payment.Default)); - authorizationBuilder.AddPolicy(UnitySelector.Payment.Supplier.Default, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Payment.Summary.Default)); - authorizationBuilder.AddPolicy(UnitySelector.Payment.Supplier.Default, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Payment.Supplier.Default)); - authorizationBuilder.AddPolicy(UnitySelector.Payment.Supplier.Update, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Payment.Supplier.Update)); - authorizationBuilder.AddPolicy(UnitySelector.Payment.Supplier.Default, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Payment.PaymentList.Default)); - - // Tenancy Policies - authorizationBuilder.AddPolicy(TenantManagementPermissions.Tenants.Default, - policy => policy.RequireClaim(PermissionConstant, TenantManagementPermissions.Tenants.Default)); - authorizationBuilder.AddPolicy(TenantManagementPermissions.Tenants.Create, - policy => policy.RequireClaim(PermissionConstant, TenantManagementPermissions.Tenants.Create)); - authorizationBuilder.AddPolicy(TenantManagementPermissions.Tenants.Update, - policy => policy.RequireClaim(PermissionConstant, TenantManagementPermissions.Tenants.Update)); - authorizationBuilder.AddPolicy(TenantManagementPermissions.Tenants.Delete, - policy => policy.RequireClaim(PermissionConstant, TenantManagementPermissions.Tenants.Delete)); - authorizationBuilder.AddPolicy(TenantManagementPermissions.Tenants.ManageFeatures, - policy => policy.RequireAssertion(context => - context.User.HasClaim(PermissionConstant, TenantManagementPermissions.Tenants.ManageFeatures) || - context.User.IsInRole(IdentityConsts.ITOperationsRoleName) || - context.User.HasClaim(c => c.Type == PermissionConstant && c.Value == IdentityConsts.ITOperationsPermissionName) - )); - authorizationBuilder.AddPolicy(TenantManagementPermissions.Tenants.ManageConnectionStrings, - policy => policy.RequireClaim(PermissionConstant, TenantManagementPermissions.Tenants.ManageConnectionStrings)); - - // Setting Management - Tag Management - authorizationBuilder.AddPolicy(UnitySelector.SettingManagement.Tags.Default, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.SettingManagement.Tags.Default)); - authorizationBuilder.AddPolicy(UnitySelector.SettingManagement.Tags.Create, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.SettingManagement.Tags.Create)); - authorizationBuilder.AddPolicy(UnitySelector.SettingManagement.Tags.Update, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.SettingManagement.Tags.Update)); - authorizationBuilder.AddPolicy(UnitySelector.SettingManagement.Tags.Delete, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.SettingManagement.Tags.Delete)); - - // IT Administrator Policies + // IT Administrator / IT Operations role policies authorizationBuilder.AddPolicy(IdentityConsts.ITAdminPolicyName, - policy => policy.RequireAssertion(context => - context.User.IsInRole(IdentityConsts.ITAdminRoleName) || - context.User.HasClaim(c => c.Type == PermissionConstant && c.Value == IdentityConsts.ITAdminPermissionName) - )); - - // IT Operations Policies + policy => policy.RequireRole(IdentityConsts.ITAdminRoleName)); authorizationBuilder.AddPolicy(IdentityConsts.ITOperationsPolicyName, - policy => policy.RequireAssertion(context => - context.User.IsInRole(IdentityConsts.ITOperationsRoleName) || - context.User.HasClaim(c => c.Type == PermissionConstant && c.Value == IdentityConsts.ITOperationsPermissionName) - )); - - // Tenant management combined: Tenants.Default OR ITOperations + policy => policy.RequireRole(IdentityConsts.ITOperationsRoleName)); + + // Tenant management combined: Tenants. OR ITAdmin/ITOperations + // NOTE: TenantManagementPermissions.Tenants.Default/Create/Update/Delete/ManageConnectionStrings + // are not real ABP permissions (only ManageFeatures/ManageEndpoints come from the base ABP + // TenantManagement module - see UnityTenantManagementPermissionDefinitionProvider). They're + // referenced directly (not via the TenantsXOrITOps composite names) by some Razor Pages/ + // toolbar conventions in UnityTenantManagementWebModule, so both the raw name and its + // composite-policy equivalent must be registered with the same effective check. + authorizationBuilder.AddPolicy(TenantManagementPermissions.Tenants.ManageFeatures, + policy => policy.AddRequirements(new RoleOrPermissionRequirement( + ITAdminOrITOperationsRoles, TenantManagementPermissions.Tenants.ManageFeatures))); + authorizationBuilder.AddPolicy(TenantManagementPermissions.Tenants.Default, + policy => policy.AddRequirements(new RoleOrPermissionRequirement( + ITAdminOrITOperationsRoles, TenantManagementPermissions.Tenants.Default))); authorizationBuilder.AddPolicy(TenantManagementPermissions.Policies.TenantsOrITOps, - policy => policy.RequireAssertion(context => - context.User.HasClaim(PermissionConstant, TenantManagementPermissions.Tenants.Default) || - context.User.IsInRole(IdentityConsts.ITOperationsRoleName) || - context.User.HasClaim(c => c.Type == PermissionConstant && c.Value == IdentityConsts.ITOperationsPermissionName) - )); - - // Tenant management combined: Tenants.Update OR ITOperations + policy => policy.AddRequirements(new RoleOrPermissionRequirement( + ITAdminOrITOperationsRoles, TenantManagementPermissions.Tenants.Default))); + authorizationBuilder.AddPolicy(TenantManagementPermissions.Tenants.Update, + policy => policy.AddRequirements(new RoleOrPermissionRequirement( + ITAdminOrITOperationsRoles, TenantManagementPermissions.Tenants.Update))); authorizationBuilder.AddPolicy(TenantManagementPermissions.Policies.TenantsUpdateOrITOps, - policy => policy.RequireAssertion(context => - context.User.HasClaim(PermissionConstant, TenantManagementPermissions.Tenants.Update) || - context.User.IsInRole(IdentityConsts.ITOperationsRoleName) || - context.User.HasClaim(c => c.Type == PermissionConstant && c.Value == IdentityConsts.ITOperationsPermissionName) - )); - - // Tenant management combined: Tenants.Create OR ITOperations + policy => policy.AddRequirements(new RoleOrPermissionRequirement( + ITAdminOrITOperationsRoles, TenantManagementPermissions.Tenants.Update))); + authorizationBuilder.AddPolicy(TenantManagementPermissions.Tenants.Create, + policy => policy.AddRequirements(new RoleOrPermissionRequirement( + ITAdminOrITOperationsRoles, TenantManagementPermissions.Tenants.Create))); authorizationBuilder.AddPolicy(TenantManagementPermissions.Policies.TenantsCreateOrITOps, - policy => policy.RequireAssertion(context => - context.User.HasClaim(PermissionConstant, TenantManagementPermissions.Tenants.Create) || - context.User.IsInRole(IdentityConsts.ITOperationsRoleName) || - context.User.HasClaim(c => c.Type == PermissionConstant && c.Value == IdentityConsts.ITOperationsPermissionName) - )); + policy => policy.AddRequirements(new RoleOrPermissionRequirement( + ITAdminOrITOperationsRoles, TenantManagementPermissions.Tenants.Create))); - // Project Info Policies - authorizationBuilder.AddPolicy(UnitySelector.Project.Default, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Project.Default)); - - // Project Info Logical OR policy - authorizationBuilder.AddPolicy(UnitySelector.Project.UpdatePolicy, - policy => policy.RequireAssertion(context => - context.User.HasClaim(PermissionConstant, UnitySelector.Project.Location.Update.Default) || - context.User.HasClaim(PermissionConstant, UnitySelector.Project.Summary.Update.Default) || - - // NOTE: This will be replaced when Worksheets are normalized with UnitySelector.Project.Worksheet.Update - context.User.HasClaim(PermissionConstant, UnitySelector.Project.Default) - )); - - // Project Info - Summary Policies - authorizationBuilder.AddPolicy(UnitySelector.Project.Summary.Default, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Project.Summary.Default)); - authorizationBuilder.AddPolicy(UnitySelector.Project.Summary.Update.Default, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Project.Summary.Update.Default)); - authorizationBuilder.AddPolicy(UnitySelector.Project.Summary.Update.UpdateFinalStateFields, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Project.Summary.Update.UpdateFinalStateFields)); - - // Project Info - Location Policies - authorizationBuilder.AddPolicy(UnitySelector.Project.Location.Default, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Project.Location.Default)); - authorizationBuilder.AddPolicy(UnitySelector.Project.Location.Update.Default, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Project.Location.Update.Default)); - authorizationBuilder.AddPolicy(UnitySelector.Project.Location.Update.UpdateFinalStateFields, - policy => policy.RequireClaim(PermissionConstant, UnitySelector.Project.Location.Update.UpdateFinalStateFields)); - - - // Reporting Configuration - authorizationBuilder.AddPolicy(ReportingPermissions.Configuration.Default, - policy => policy.RequireClaim(PermissionConstant, ReportingPermissions.Configuration.Default)); - authorizationBuilder.AddPolicy(ReportingPermissions.Configuration.Update, - policy => policy.RequireClaim(PermissionConstant, ReportingPermissions.Configuration.Update)); - authorizationBuilder.AddPolicy(ReportingPermissions.Configuration.Delete, - policy => policy.RequireClaim(PermissionConstant, ReportingPermissions.Configuration.Delete)); + // ITAdmin-only: Tenant delete/connection-string management and Identity user + // creation/lookup - previously covered by the removed admin claim stamp. + authorizationBuilder.AddPolicy(TenantManagementPermissions.Tenants.Delete, + policy => policy.AddRequirements(new RoleOrPermissionRequirement( + [IdentityConsts.ITAdminRoleName], TenantManagementPermissions.Tenants.Delete))); + authorizationBuilder.AddPolicy(TenantManagementPermissions.Tenants.ManageConnectionStrings, + policy => policy.AddRequirements(new RoleOrPermissionRequirement( + [IdentityConsts.ITAdminRoleName], TenantManagementPermissions.Tenants.ManageConnectionStrings))); + authorizationBuilder.AddPolicy(IdentityPermissions.Users.Create, + policy => policy.AddRequirements(new RoleOrPermissionRequirement( + [IdentityConsts.ITAdminRoleName], IdentityPermissions.Users.Create))); + authorizationBuilder.AddPolicy(IdentityPermissions.UserLookup.Default, + policy => policy.AddRequirements(new RoleOrPermissionRequirement( + [IdentityConsts.ITAdminRoleName], IdentityPermissions.UserLookup.Default))); } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Menus/GrantManagerMenuContributor.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Menus/GrantManagerMenuContributor.cs index 0e559fbe83..dd06155606 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Menus/GrantManagerMenuContributor.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Menus/GrantManagerMenuContributor.cs @@ -5,7 +5,6 @@ using Unity.Modules.Shared.Navigation; using Unity.Modules.Shared.Specializations; using Unity.Modules.Shared.Permissions; -using Unity.TenantManagement; using Unity.TenantManagement.Web.Navigation; using Volo.Abp.Identity; using Volo.Abp.UI.Navigation; @@ -35,9 +34,9 @@ await context.AddItemAsync( l["Menu:Onboarding"], "~/TenantManagement/Onboarding", icon: "fl fl-other-user", - order: 1, - requiredPermissionName: IdentityConsts.ITOperationsPermissionName + order: 1 ).OnlyWhenSpecializations(SpecializationConsts.Onboarding) + .OnlyWhenInRole(IdentityConsts.ITOperationsRoleName) ); await context.AddItemAsync( @@ -117,14 +116,14 @@ await context.AddItemAsync( ).ExcludeWhenSpecializations(SpecializationConsts.Onboarding) ); - // Displayed in the Grant Manager - Used at Tenant Level if the user in the IT Operations role + // Displayed in the Grant Manager - Used at Tenant Level for ITAdmin/ITOperations users await context.AddItemAsync( new ApplicationMenuItem( GrantManagerMenus.EndpointManagement, displayName: "Endpoints", - "~/EndpointManagement/Endpoints", - requiredPermissionName: IdentityConsts.ITOperationsPermissionName + "~/EndpointManagement/Endpoints" ).ExcludeWhenSpecializations(SpecializationConsts.Onboarding) + .OnlyWhenInRole(IdentityConsts.ITAdminRoleName, IdentityConsts.ITOperationsRoleName) ); // ******************** @@ -135,9 +134,9 @@ await context.AddItemAsync( l["Menu:TenantManagement"], "~/TenantManagement/Tenants", icon: "fl fl-view-dashboard", - order: 8, - requiredPermissionName: TenantManagementPermissions.Tenants.Default + order: 8 ).ExcludeWhenSpecializations(SpecializationConsts.Onboarding) + .OnlyWhenInRole(IdentityConsts.ITAdminRoleName, IdentityConsts.ITOperationsRoleName) ); // Tenants list for ITOperations users on the Onboarding tenant @@ -147,19 +146,9 @@ await context.AddItemAsync( l["Menu:TenantManagement"], "~/TenantManagement/Tenants", icon: "fl fl-view-dashboard", - order: 8, - requiredPermissionName: IdentityConsts.ITOperationsPermissionName + order: 8 ).OnlyWhenSpecializations(SpecializationConsts.Onboarding) - ); - - // Displayed on the Tenant Management area if the user has the ITAdministrator Role - await context.AddItemAsync( - new ApplicationMenuItem( - GrantManagerMenus.EndpointManagement, - displayName: "Endpoints", - "~/EndpointManagement/Endpoints", - requiredPermissionName: TenantManagementPermissions.Tenants.Default - ).ExcludeWhenSpecializations(SpecializationConsts.Onboarding) + .OnlyWhenInRole(IdentityConsts.ITOperationsRoleName) ); // End Admin ******************** diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Web.Tests/Identity/Authorization/RoleOrPermissionAuthorizationHandlerTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Web.Tests/Identity/Authorization/RoleOrPermissionAuthorizationHandlerTests.cs index 399c8146df..ff25e4cee1 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Web.Tests/Identity/Authorization/RoleOrPermissionAuthorizationHandlerTests.cs +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Web.Tests/Identity/Authorization/RoleOrPermissionAuthorizationHandlerTests.cs @@ -48,7 +48,7 @@ public async Task HandleAsync_ShouldSucceed_WhenUserHasRole() { // Arrange var user = CreateUserWithRole("ITAdministrator"); - var requirement = new RoleOrPermissionRequirement("ITAdministrator", "Unity.ITAdmin"); + var requirement = new RoleOrPermissionRequirement(["ITAdministrator"], "Unity.ITAdmin"); var context = CreateContext(user, requirement); // Act @@ -65,7 +65,7 @@ public async Task HandleAsync_ShouldSucceed_WhenUserHasPermission() { // Arrange var user = CreateUserWithoutRole(); - var requirement = new RoleOrPermissionRequirement("ITAdministrator", "Unity.ITAdmin"); + var requirement = new RoleOrPermissionRequirement(["ITAdministrator"], "Unity.ITAdmin"); _permissionChecker.IsGrantedAsync(user, "Unity.ITAdmin") .Returns(true); @@ -84,7 +84,7 @@ public async Task HandleAsync_ShouldNotSucceed_WhenNeitherRoleNorPermission() { // Arrange var user = CreateUserWithoutRole(); - var requirement = new RoleOrPermissionRequirement("ITAdministrator", "Unity.ITAdmin"); + var requirement = new RoleOrPermissionRequirement(["ITAdministrator"], "Unity.ITAdmin"); _permissionChecker.IsGrantedAsync(user, "Unity.ITAdmin") .Returns(false); @@ -103,7 +103,7 @@ public async Task HandleAsync_ShouldShortCircuit_WhenRoleMatches() { // Arrange var user = CreateUserWithRole("ITOperations"); - var requirement = new RoleOrPermissionRequirement("ITOperations", "Unity.ITOperations"); + var requirement = new RoleOrPermissionRequirement(["ITOperations"], "Unity.ITOperations"); var context = CreateContext(user, requirement); // Act @@ -120,7 +120,7 @@ public async Task HandleAsync_ShouldCheckPermission_WhenRoleDoesNotMatch() { // Arrange var user = CreateUserWithRole("SomeOtherRole"); - var requirement = new RoleOrPermissionRequirement("ITAdministrator", "Unity.ITAdmin"); + var requirement = new RoleOrPermissionRequirement(["ITAdministrator"], "Unity.ITAdmin"); _permissionChecker.IsGrantedAsync(user, "Unity.ITAdmin") .Returns(false); @@ -136,10 +136,27 @@ public async Task HandleAsync_ShouldCheckPermission_WhenRoleDoesNotMatch() } [Fact] - public void Requirement_ShouldStoreRoleAndPermission() + public void Requirement_ShouldStoreRolesAndPermission() { - var requirement = new RoleOrPermissionRequirement("MyRole", "MyPermission"); - requirement.RoleName.ShouldBe("MyRole"); + var requirement = new RoleOrPermissionRequirement(["MyRole"], "MyPermission"); + requirement.RoleNames.ShouldBe(["MyRole"]); requirement.PermissionName.ShouldBe("MyPermission"); } + + [Fact] + public async Task HandleAsync_ShouldSucceed_WhenUserHasAnyOfMultipleRoles() + { + // Arrange + var user = CreateUserWithRole("ITAdministrator"); + var requirement = new RoleOrPermissionRequirement(["ITOperations", "ITAdministrator"], "Unity.ITOperations"); + var context = CreateContext(user, requirement); + + // Act + await _handler.HandleAsync(context); + + // Assert + context.HasSucceeded.ShouldBeTrue(); + await _permissionChecker.DidNotReceive().IsGrantedAsync( + Arg.Any(), Arg.Any()); + } } From 0b940440bb3a5b86a1fbab22b31a495054e0ac6c Mon Sep 17 00:00:00 2001 From: Jacob Smith Date: Thu, 9 Jul 2026 13:16:08 -0700 Subject: [PATCH 052/223] AB#33569 add onboarding mapping suggestion endpoint --- .../AI/IAIService.cs | 1 + .../AI/Requests/MappingSuggestionRequest.cs | 13 + .../AI/Responses/MappingSuggestionResponse.cs | 79 ++++++ .../AI/Operations/AIExecutionModeResolver.cs | 1 + .../AI/Prompts/AIPromptTypes.cs | 1 + .../Versions/v2/onboarding-mapping.system.txt | 4 + .../Versions/v2/onboarding-mapping.user.txt | 53 ++++ .../AI/Runtime/AIPromptTemplateRenderer.cs | 14 + .../AI/Runtime/AIProviderPayloadValidator.cs | 10 + .../AI/Runtime/OpenAIResponseParser.cs | 243 ++++++++++++------ .../AI/Runtime/OpenAIRuntimeService.cs | 50 ++++ .../DataSeed/AIOperationDataSeeder.cs | 3 +- .../DataSeed/AIPromptDataSeeder.cs | 79 ++++++ .../IApplicationFormVersionService.cs | 4 +- .../ApplicationFormMappingReadModelDto.cs | 15 ++ .../ApplicationFormMappingSuggestionDto.cs | 13 + .../Mapping/MappingFieldDto.cs | 9 + .../Mapping/MappingIssueDto.cs | 7 + .../Mapping/MappingSuggestionDto.cs | 9 + .../Mapping/WorksheetCreationSuggestionDto.cs | 10 + .../Mapping/WorksheetMappingFieldsDto.cs | 11 + .../Mapping/WorksheetMappingSuggestionDto.cs | 9 + .../ApplicationFormVersionAppService.cs | 61 ++++- ...pplicationFormVersionMappingReadService.cs | 165 ++++++++++++ .../appsettings.json | 8 +- .../Pages/ApplicationForms/Mapping.cshtml.cs | 127 +++------ 26 files changed, 829 insertions(+), 170 deletions(-) create mode 100644 applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Requests/MappingSuggestionRequest.cs create mode 100644 applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Responses/MappingSuggestionResponse.cs create mode 100644 applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/Versions/v2/onboarding-mapping.system.txt create mode 100644 applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/Versions/v2/onboarding-mapping.user.txt create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/Mapping/ApplicationFormMappingReadModelDto.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/Mapping/ApplicationFormMappingSuggestionDto.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/Mapping/MappingFieldDto.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/Mapping/MappingIssueDto.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/Mapping/MappingSuggestionDto.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/Mapping/WorksheetCreationSuggestionDto.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/Mapping/WorksheetMappingFieldsDto.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/Mapping/WorksheetMappingSuggestionDto.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicationForms/Mapping/ApplicationFormVersionMappingReadService.cs diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/IAIService.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/IAIService.cs index 8caf69258d..92f918035c 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/IAIService.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/IAIService.cs @@ -13,5 +13,6 @@ public interface IAIService Task GenerateAttachmentSummaryBatchAsync(AttachmentSummaryBatchRequest request, CancellationToken cancellationToken = default); Task GenerateApplicationAnalysisAsync(ApplicationAnalysisRequest request, CancellationToken cancellationToken = default); Task GenerateApplicationScoringAsync(ApplicationScoringRequest request, CancellationToken cancellationToken = default); + Task GenerateMappingSuggestionAsync(MappingSuggestionRequest request, CancellationToken cancellationToken = default); } } diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Requests/MappingSuggestionRequest.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Requests/MappingSuggestionRequest.cs new file mode 100644 index 0000000000..02c1892445 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Requests/MappingSuggestionRequest.cs @@ -0,0 +1,13 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Unity.AI.Requests; + +public class MappingSuggestionRequest +{ + [JsonPropertyName("data")] + public JsonElement Data { get; set; } + + [JsonPropertyName("promptVersion")] + public string? PromptVersion { get; set; } +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Responses/MappingSuggestionResponse.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Responses/MappingSuggestionResponse.cs new file mode 100644 index 0000000000..449925bfff --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Responses/MappingSuggestionResponse.cs @@ -0,0 +1,79 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Unity.AI.Responses; + +public class MappingSuggestionResponse +{ + [JsonPropertyName("coreFieldMatches")] + public List CoreFieldMatches { get; set; } = []; + + [JsonPropertyName("worksheetMatches")] + public List WorksheetMatches { get; set; } = []; + + [JsonPropertyName("worksheetCreationSuggestions")] + public List WorksheetCreationSuggestions { get; set; } = []; + + [JsonPropertyName("issues")] + public List Issues { get; set; } = []; +} + +public class MappingSuggestionItemResponse +{ + [JsonPropertyName("sourceField")] + public string SourceField { get; set; } = string.Empty; + + [JsonPropertyName("targetField")] + public string TargetField { get; set; } = string.Empty; + + [JsonPropertyName("reason")] + public string Reason { get; set; } = string.Empty; + + [JsonPropertyName("confidence")] + public decimal Confidence { get; set; } +} + +public class WorksheetMappingSuggestionResponse +{ + [JsonPropertyName("worksheetName")] + public string WorksheetName { get; set; } = string.Empty; + + [JsonPropertyName("fieldMatches")] + public List FieldMatches { get; set; } = []; +} + +public class WorksheetCreationSuggestionResponse +{ + [JsonPropertyName("worksheetName")] + public string WorksheetName { get; set; } = string.Empty; + + [JsonPropertyName("suggestedFields")] + public List SuggestedFields { get; set; } = []; + + [JsonPropertyName("reason")] + public string Reason { get; set; } = string.Empty; +} + +public class MappingFieldResponse +{ + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + [JsonPropertyName("type")] + public string Type { get; set; } = string.Empty; + + [JsonPropertyName("label")] + public string Label { get; set; } = string.Empty; + + [JsonPropertyName("isCustom")] + public bool IsCustom { get; set; } +} + +public class MappingIssueResponse +{ + [JsonPropertyName("code")] + public string Code { get; set; } = string.Empty; + + [JsonPropertyName("message")] + public string Message { get; set; } = string.Empty; +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/AIExecutionModeResolver.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/AIExecutionModeResolver.cs index d7fae9b818..d6b69e7ab0 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/AIExecutionModeResolver.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/AIExecutionModeResolver.cs @@ -15,6 +15,7 @@ public class AIExecutionModeResolver(IConfiguration configuration) : ITransientD { public const string AttachmentSummaryOperation = AIPromptTypes.AttachmentSummary; public const string ApplicationScoringOperation = AIPromptTypes.ApplicationScoring; + public const string OnboardingMappingOperation = AIPromptTypes.OnboardingMapping; public AIExecutionMode ResolveMode(string operationName) { diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/AIPromptTypes.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/AIPromptTypes.cs index f908e1a388..605ca6450e 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/AIPromptTypes.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/AIPromptTypes.cs @@ -5,4 +5,5 @@ public static class AIPromptTypes public const string AttachmentSummary = "AttachmentSummary"; public const string ApplicationAnalysis = "ApplicationAnalysis"; public const string ApplicationScoring = "ApplicationScoring"; + public const string OnboardingMapping = "OnboardingMapping"; } diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/Versions/v2/onboarding-mapping.system.txt b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/Versions/v2/onboarding-mapping.system.txt new file mode 100644 index 0000000000..3c791b606b --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/Versions/v2/onboarding-mapping.system.txt @@ -0,0 +1,4 @@ +You are a careful mapping assistant for human reviewers. +Compare CHEFS fields, Unity core fields, and worksheet fields to suggest likely mappings. +Do not invent fields, persist changes, or assume a worksheet should exist if one is not clearly justified. +Return only valid JSON in the exact format requested. diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/Versions/v2/onboarding-mapping.user.txt b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/Versions/v2/onboarding-mapping.user.txt new file mode 100644 index 0000000000..b598d58ec4 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/Versions/v2/onboarding-mapping.user.txt @@ -0,0 +1,53 @@ +FORM MAPPING CONTEXT: +{{DATA}} + +OUTPUT +{ + "coreFieldMatches": [ + { + "sourceField": "", + "targetField": "", + "reason": "", + "confidence": + } + ], + "worksheetMatches": [ + { + "worksheetName": "", + "fieldMatches": [ + { + "sourceField": "", + "targetField": "", + "reason": "", + "confidence": + } + ] + } + ], + "worksheetCreationSuggestions": [ + { + "worksheetName": "", + "suggestedFields": [ + { + "name": "", + "type": "", + "label": "", + "isCustom": true + } + ], + "reason": "" + } + ], + "issues": [ + { + "code": "", + "message": "" + } + ] +} + +Important: +- Use only FORM MAPPING CONTEXT as evidence. +- Return only fields that are supported by the context. +- Keep reasons specific and concise. +- Return valid plain JSON only in the exact OUTPUT shape. diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/AIPromptTemplateRenderer.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/AIPromptTemplateRenderer.cs index e915cad4e8..efc7871757 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/AIPromptTemplateRenderer.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/AIPromptTemplateRenderer.cs @@ -74,6 +74,20 @@ public static string BuildApplicationScoringUserPrompt( }); } + public static string BuildMappingSuggestionUserPrompt( + string userPromptTemplate, + string data, + string? metadataJson = null) + { + return RenderPromptTemplate( + userPromptTemplate, + metadataJson, + new Dictionary + { + ["DATA"] = data + }); + } + private static string RenderPromptTemplate( string template, string? metadataJson, diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/AIProviderPayloadValidator.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/AIProviderPayloadValidator.cs index e481ee5823..52cdf81136 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/AIProviderPayloadValidator.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/AIProviderPayloadValidator.cs @@ -145,6 +145,16 @@ public static AIResponseValidationResult ValidateApplicationScoringJson(string r return AIResponseValidationResult.Success(); } + public static AIResponseValidationResult ValidateMappingSuggestionJson(string response) + { + if (!TryParseRootObject(response, out _)) + { + return AIResponseValidationResult.Invalid("Mapping suggestion response was not valid JSON."); + } + + return AIResponseValidationResult.Success(); + } + private static HashSet ExtractQuestionIds(string sectionJson) { var ids = new HashSet(StringComparer.OrdinalIgnoreCase); diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIResponseParser.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIResponseParser.cs index 05c1ebb027..92adac681e 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIResponseParser.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIResponseParser.cs @@ -47,64 +47,47 @@ public static ApplicationAnalysisResponse ParseApplicationAnalysisResponse(strin return response; } - private static string AddIdsToAnalysisItems(string analysisJson) + public static AttachmentSummaryBatchResponse ParseAttachmentSummaryBatchResponse(string raw) { - try + var response = new AttachmentSummaryBatchResponse(); + if (!TryParseJsonObjectFromResponse(raw, out var root)) { - using var jsonDoc = JsonDocument.Parse(analysisJson); - using var memoryStream = new System.IO.MemoryStream(); - using (var writer = new Utf8JsonWriter(memoryStream, new JsonWriterOptions { Indented = true })) - { - writer.WriteStartObject(); - - foreach (var property in jsonDoc.RootElement.EnumerateObject()) - { - var outputPropertyName = property.Name; - - if (outputPropertyName == AIJsonKeys.Errors || - outputPropertyName == AIJsonKeys.Warnings || - outputPropertyName == AIJsonKeys.Summaries || - outputPropertyName == AIJsonKeys.Recommendations) - { - writer.WritePropertyName(outputPropertyName); - writer.WriteStartArray(); - - foreach (var item in property.Value.EnumerateArray()) - { - writer.WriteStartObject(); - - foreach (var itemProperty in item.EnumerateObject()) - { - itemProperty.WriteTo(writer); - } - - if (!item.TryGetProperty(AIJsonKeys.Id, out var idProp) || - idProp.ValueKind != JsonValueKind.String || - string.IsNullOrWhiteSpace(idProp.GetString())) - { - writer.WriteString(AIJsonKeys.Id, Guid.NewGuid().ToString()); - } + return response; + } - writer.WriteEndObject(); - } + if (!root.TryGetProperty("attachments", out var attachments) || attachments.ValueKind != JsonValueKind.Array) + { + return response; + } - writer.WriteEndArray(); - continue; - } + foreach (var attachment in attachments.EnumerateArray()) + { + if (attachment.ValueKind != JsonValueKind.Object) + { + continue; + } - property.WriteTo(writer); - } + var attachmentId = attachment.TryGetProperty("attachmentId", out var idProp) && idProp.ValueKind == JsonValueKind.String + ? idProp.GetString() ?? string.Empty + : string.Empty; - writer.WriteEndObject(); - writer.Flush(); + if (string.IsNullOrWhiteSpace(attachmentId)) + { + continue; } - return Encoding.UTF8.GetString(memoryStream.ToArray()); - } - catch - { - return analysisJson; + var summary = attachment.TryGetProperty(AIJsonKeys.Summary, out var summaryProp) && summaryProp.ValueKind == JsonValueKind.String + ? summaryProp.GetString() ?? string.Empty + : string.Empty; + + response.Attachments.Add(new AttachmentSummaryBatchItemResponse + { + AttachmentId = attachmentId, + Summary = summary + }); } + + return response; } public static ApplicationScoringResponse ParseApplicationScoringResponse(string raw, IReadOnlyDictionary? questionIdAliasMap = null) @@ -151,47 +134,121 @@ public static ApplicationScoringResponse ParseApplicationScoringResponse(string return response; } - public static AttachmentSummaryBatchResponse ParseAttachmentSummaryBatchResponse(string raw) + public static MappingSuggestionResponse ParseMappingSuggestionResponse(string raw) { - var response = new AttachmentSummaryBatchResponse(); + var response = new MappingSuggestionResponse(); if (!TryParseJsonObjectFromResponse(raw, out var root)) { return response; } - if (!root.TryGetProperty("attachments", out var attachments) || attachments.ValueKind != JsonValueKind.Array) + if (root.TryGetProperty("coreFieldMatches", out var coreFieldMatches) && coreFieldMatches.ValueKind == JsonValueKind.Array) { - return response; + response.CoreFieldMatches = ParseMappingSuggestionItems(coreFieldMatches).ToList(); } - foreach (var attachment in attachments.EnumerateArray()) + if (root.TryGetProperty("worksheetMatches", out var worksheetMatches) && worksheetMatches.ValueKind == JsonValueKind.Array) { - if (attachment.ValueKind != JsonValueKind.Object) - { - continue; - } + response.WorksheetMatches = worksheetMatches.EnumerateArray() + .Where(item => item.ValueKind == JsonValueKind.Object) + .Select(item => new WorksheetMappingSuggestionResponse + { + WorksheetName = item.TryGetProperty("worksheetName", out var name) && name.ValueKind == JsonValueKind.String ? name.GetString() ?? string.Empty : string.Empty, + FieldMatches = item.TryGetProperty("fieldMatches", out var matches) && matches.ValueKind == JsonValueKind.Array + ? ParseMappingSuggestionItems(matches).ToList() + : [] + }) + .ToList(); + } - var attachmentId = attachment.TryGetProperty("attachmentId", out var idProp) && idProp.ValueKind == JsonValueKind.String - ? idProp.GetString() ?? string.Empty - : string.Empty; + if (root.TryGetProperty("worksheetCreationSuggestions", out var worksheetCreationSuggestions) && worksheetCreationSuggestions.ValueKind == JsonValueKind.Array) + { + response.WorksheetCreationSuggestions = worksheetCreationSuggestions.EnumerateArray() + .Where(item => item.ValueKind == JsonValueKind.Object) + .Select(item => new WorksheetCreationSuggestionResponse + { + WorksheetName = item.TryGetProperty("worksheetName", out var name) && name.ValueKind == JsonValueKind.String ? name.GetString() ?? string.Empty : string.Empty, + Reason = item.TryGetProperty("reason", out var reason) && reason.ValueKind == JsonValueKind.String ? reason.GetString() ?? string.Empty : string.Empty, + SuggestedFields = item.TryGetProperty("suggestedFields", out var fields) && fields.ValueKind == JsonValueKind.Array + ? ParseMappingFields(fields).ToList() + : [] + }) + .ToList(); + } - if (string.IsNullOrWhiteSpace(attachmentId)) - { - continue; - } + if (root.TryGetProperty("issues", out var issues) && issues.ValueKind == JsonValueKind.Array) + { + response.Issues = issues.EnumerateArray() + .Where(item => item.ValueKind == JsonValueKind.Object) + .Select(item => new MappingIssueResponse + { + Code = item.TryGetProperty("code", out var code) && code.ValueKind == JsonValueKind.String ? code.GetString() ?? string.Empty : string.Empty, + Message = item.TryGetProperty("message", out var message) && message.ValueKind == JsonValueKind.String ? message.GetString() ?? string.Empty : string.Empty + }) + .ToList(); + } - var summary = attachment.TryGetProperty(AIJsonKeys.Summary, out var summaryProp) && summaryProp.ValueKind == JsonValueKind.String - ? summaryProp.GetString() ?? string.Empty - : string.Empty; + return response; + } - response.Attachments.Add(new AttachmentSummaryBatchItemResponse + private static string AddIdsToAnalysisItems(string analysisJson) + { + try + { + using var jsonDoc = JsonDocument.Parse(analysisJson); + using var memoryStream = new System.IO.MemoryStream(); + using (var writer = new Utf8JsonWriter(memoryStream, new JsonWriterOptions { Indented = true })) { - AttachmentId = attachmentId, - Summary = summary - }); - } + writer.WriteStartObject(); - return response; + foreach (var property in jsonDoc.RootElement.EnumerateObject()) + { + var outputPropertyName = property.Name; + + if (outputPropertyName == AIJsonKeys.Errors || + outputPropertyName == AIJsonKeys.Warnings || + outputPropertyName == AIJsonKeys.Summaries || + outputPropertyName == AIJsonKeys.Recommendations) + { + writer.WritePropertyName(outputPropertyName); + writer.WriteStartArray(); + + foreach (var item in property.Value.EnumerateArray()) + { + writer.WriteStartObject(); + + foreach (var itemProperty in item.EnumerateObject()) + { + itemProperty.WriteTo(writer); + } + + if (!item.TryGetProperty(AIJsonKeys.Id, out var idProp) || + idProp.ValueKind != JsonValueKind.String || + string.IsNullOrWhiteSpace(idProp.GetString())) + { + writer.WriteString(AIJsonKeys.Id, Guid.NewGuid().ToString()); + } + + writer.WriteEndObject(); + } + + writer.WriteEndArray(); + continue; + } + + property.WriteTo(writer); + } + + writer.WriteEndObject(); + writer.Flush(); + } + + return Encoding.UTF8.GetString(memoryStream.ToArray()); + } + catch + { + return analysisJson; + } } private static IEnumerable ParseFindings(JsonElement findingsArray) @@ -235,6 +292,44 @@ private static IEnumerable ParseFindings(JsonElement } } + private static IEnumerable ParseMappingSuggestionItems(JsonElement itemsArray) + { + foreach (var item in itemsArray.EnumerateArray()) + { + if (item.ValueKind != JsonValueKind.Object) + { + continue; + } + + yield return new MappingSuggestionItemResponse + { + SourceField = item.TryGetProperty("sourceField", out var sourceField) && sourceField.ValueKind == JsonValueKind.String ? sourceField.GetString() ?? string.Empty : string.Empty, + TargetField = item.TryGetProperty("targetField", out var targetField) && targetField.ValueKind == JsonValueKind.String ? targetField.GetString() ?? string.Empty : string.Empty, + Reason = item.TryGetProperty("reason", out var reason) && reason.ValueKind == JsonValueKind.String ? reason.GetString() ?? string.Empty : string.Empty, + Confidence = item.TryGetProperty("confidence", out var confidence) && confidence.ValueKind == JsonValueKind.Number && confidence.TryGetDecimal(out var parsedConfidence) ? parsedConfidence : 0m + }; + } + } + + private static IEnumerable ParseMappingFields(JsonElement itemsArray) + { + foreach (var item in itemsArray.EnumerateArray()) + { + if (item.ValueKind != JsonValueKind.Object) + { + continue; + } + + yield return new MappingFieldResponse + { + Name = item.TryGetProperty("name", out var name) && name.ValueKind == JsonValueKind.String ? name.GetString() ?? string.Empty : string.Empty, + Type = item.TryGetProperty("type", out var type) && type.ValueKind == JsonValueKind.String ? type.GetString() ?? string.Empty : string.Empty, + Label = item.TryGetProperty("label", out var label) && label.ValueKind == JsonValueKind.String ? label.GetString() ?? string.Empty : string.Empty, + IsCustom = item.TryGetProperty("isCustom", out var isCustom) && (isCustom.ValueKind == JsonValueKind.True || isCustom.ValueKind == JsonValueKind.False) && isCustom.GetBoolean() + }; + } + } + private static bool TryParseJsonObjectFromResponse(string response, out JsonElement objectElement) { objectElement = default; diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIRuntimeService.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIRuntimeService.cs index 3cafa504d9..164af0db42 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIRuntimeService.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIRuntimeService.cs @@ -25,6 +25,7 @@ public class OpenAIRuntimeService : IAIService, ITransientDependency private const string ApplicationAnalysisPromptType = AIPromptTypes.ApplicationAnalysis; private const string AttachmentSummaryPromptType = AIPromptTypes.AttachmentSummary; private const string ApplicationScoringPromptType = AIPromptTypes.ApplicationScoring; + private const string MappingSuggestionPromptType = AIPromptTypes.OnboardingMapping; private const int MaxAiAttempts = 3; public OpenAIRuntimeService( @@ -333,6 +334,55 @@ public async Task GenerateApplicationScoringAsync(Ap } } + public async Task GenerateMappingSuggestionAsync(MappingSuggestionRequest request, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + try + { + var settings = await _openAIConfigurationResolver.ResolveOperationSettingsAsync(MappingSuggestionPromptType, cancellationToken); + var promptTemplate = await _promptTemplateProvider.GetRequiredPromptAsync( + MappingSuggestionPromptType, + request.PromptVersion ?? settings.PromptVersion, + cancellationToken); + var promptVersion = promptTemplate.PromptVersion; + var dataJson = request.Data.GetRawText(); + var systemPrompt = promptTemplate.SystemPrompt; + var content = AIPromptTemplateRenderer.BuildMappingSuggestionUserPrompt( + promptTemplate.UserPrompt, + dataJson, + promptTemplate.MetadataJson); + + await _promptFileLogger.LogPromptInputAsync(MappingSuggestionPromptType, promptVersion, systemPrompt, content, cancellationToken); + var result = await GenerateWithRetryAsync( + () => _openAITransportService.GenerateSummaryAsync( + content, + systemPrompt, + settings, + settings.CompletionTokens, + cancellationToken: cancellationToken), + AIProviderPayloadValidator.ValidateMappingSuggestionJson, + "mapping suggestion", + cancellationToken); + await _promptFileLogger.LogPromptOutputAsync(MappingSuggestionPromptType, promptVersion, result.CaptureOutput, cancellationToken); + + if (result.Outcome != AIOperationOutcome.Success) + { + return new MappingSuggestionResponse(); + } + + return OpenAIResponseParser.ParseMappingSuggestionResponse(result.Content); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + _logger.LogError(ex, "Mapping suggestion generation failed."); + return new MappingSuggestionResponse(); + } + } + private async Task GenerateWithRetryAsync( Func> operation, Func validator, diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/DataSeed/AIOperationDataSeeder.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/DataSeed/AIOperationDataSeeder.cs index bf8357a391..c9aa93471e 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/DataSeed/AIOperationDataSeeder.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/DataSeed/AIOperationDataSeeder.cs @@ -27,7 +27,8 @@ public class AIOperationDataSeeder( [ new(AIPromptTypes.ApplicationAnalysis, AIPromptTypes.ApplicationAnalysis, 1, 4000), new(AIPromptTypes.AttachmentSummary, AIPromptTypes.AttachmentSummary, 1, 2000), - new(AIPromptTypes.ApplicationScoring, AIPromptTypes.ApplicationScoring, 1, 8000) + new(AIPromptTypes.ApplicationScoring, AIPromptTypes.ApplicationScoring, 1, 8000), + new(AIPromptTypes.OnboardingMapping, AIPromptTypes.OnboardingMapping, 2, 2000) ]; public async Task SeedAsync(DataSeedContext context) diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/DataSeed/AIPromptDataSeeder.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/DataSeed/AIPromptDataSeeder.cs index 64041c1d0b..bf5e88de20 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/DataSeed/AIPromptDataSeeder.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/DataSeed/AIPromptDataSeeder.cs @@ -28,6 +28,7 @@ public async Task SeedAsync(DataSeedContext context) await SeedAnalysisPromptAsync(); await SeedAttachmentPromptAsync(); await SeedScoresheetPromptAsync(); + await SeedOnboardingMappingPromptAsync(); } } @@ -110,6 +111,13 @@ await EnsurePromptAsync( commonRules: CommonRules)); } + // ─── MAPPING SUGGESTION ───────────────────────────────────────────────── + + private async Task SeedOnboardingMappingPromptAsync() + { + await EnsurePromptAsync(AIPromptTypes.OnboardingMapping, 2, OnboardingMappingSystemV2, OnboardingMappingUserV2, OnboardingMappingMetadataV2); + } + // ─── HELPERS ────────────────────────────────────────────────────────────── private static string BuildSections( @@ -780,6 +788,77 @@ 4. Choose the most conservative valid answer supported by that evidence. - The "answer" value type must match question type: Number => numeric; YesNo/SelectList/Text/TextArea => string. """; + // ── v0/mapping-suggestion.system.txt ──────────────────────────────────── + private const string OnboardingMappingSystemV2 = """ + You are a careful mapping assistant for human reviewers. + Compare CHEFS fields, Unity core fields, and worksheet fields to suggest likely mappings. + Do not invent fields, persist changes, or assume a worksheet should exist if one is not clearly justified. + Return only valid JSON in the exact format requested. + """; + + // ── v2/onboarding-mapping.user.txt ───────────────────────────────────── + private const string OnboardingMappingUserV2 = """ + FORM MAPPING CONTEXT: + {{DATA}} + + OUTPUT + { + "coreFieldMatches": [ + { + "sourceField": "", + "targetField": "", + "reason": "", + "confidence": + } + ], + "worksheetMatches": [ + { + "worksheetName": "", + "fieldMatches": [ + { + "sourceField": "", + "targetField": "", + "reason": "", + "confidence": + } + ] + } + ], + "worksheetCreationSuggestions": [ + { + "worksheetName": "", + "suggestedFields": [ + { + "name": "", + "type": "", + "label": "", + "isCustom": true + } + ], + "reason": "" + } + ], + "issues": [ + { + "code": "", + "message": "" + } + ] + } + + Important: + - Use only FORM MAPPING CONTEXT as evidence. + - Return only fields that are supported by the context. + - Keep reasons specific and concise. + - Return valid plain JSON only in the exact OUTPUT shape. + """; + + private const string OnboardingMappingMetadataV2 = """ + { + "DATA": "Serialized JSON payload containing CHEFS fields, Unity core fields, and worksheet-derived custom fields." + } + """; + // ── v1/common.rules.txt ────────────────────────────────────────────────── private const string CommonRules = """ - Any narrative text response must be at least 12 words. diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/IApplicationFormVersionService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/IApplicationFormVersionService.cs index 1edd931473..0c73d530de 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/IApplicationFormVersionService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/IApplicationFormVersionService.cs @@ -1,6 +1,7 @@ -using Newtonsoft.Json.Linq; +using Newtonsoft.Json.Linq; using System; using System.Threading.Tasks; +using Unity.GrantManager.ApplicationForms.Mapping; using Unity.GrantManager.Forms; using Volo.Abp.Application.Dtos; using Volo.Abp.Application.Services; @@ -22,5 +23,6 @@ public interface IApplicationFormVersionAppService : ICrudAppService< Task GetByChefsFormVersionId(Guid chefsFormVersionId); Task GetFormVersionByApplicationIdAsync(Guid applicationId); Task DeleteWorkSheetMappingByFormName(string formName, Guid formVersionId); + Task SuggestMappingAsync(Guid id); } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/Mapping/ApplicationFormMappingReadModelDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/Mapping/ApplicationFormMappingReadModelDto.cs new file mode 100644 index 0000000000..832aed89e3 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/Mapping/ApplicationFormMappingReadModelDto.cs @@ -0,0 +1,15 @@ +using System; +using System.Collections.Generic; + +namespace Unity.GrantManager.ApplicationForms.Mapping; + +public class ApplicationFormMappingReadModelDto +{ + public Guid ApplicationFormVersionId { get; set; } + public Guid ApplicationFormId { get; set; } + public string? ChefsApplicationFormGuid { get; set; } + public string? ChefsFormVersionGuid { get; set; } + public List ChefsFields { get; set; } = []; + public List UnityCoreFields { get; set; } = []; + public List Worksheets { get; set; } = []; +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/Mapping/ApplicationFormMappingSuggestionDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/Mapping/ApplicationFormMappingSuggestionDto.cs new file mode 100644 index 0000000000..c77bab6029 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/Mapping/ApplicationFormMappingSuggestionDto.cs @@ -0,0 +1,13 @@ +using System; +using System.Collections.Generic; + +namespace Unity.GrantManager.ApplicationForms.Mapping; + +public class ApplicationFormMappingSuggestionDto +{ + public Guid ApplicationFormVersionId { get; set; } + public List CoreFieldMatches { get; set; } = []; + public List WorksheetMatches { get; set; } = []; + public List WorksheetCreationSuggestions { get; set; } = []; + public List Issues { get; set; } = []; +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/Mapping/MappingFieldDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/Mapping/MappingFieldDto.cs new file mode 100644 index 0000000000..654f39dba2 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/Mapping/MappingFieldDto.cs @@ -0,0 +1,9 @@ +namespace Unity.GrantManager.ApplicationForms.Mapping; + +public class MappingFieldDto +{ + public string Name { get; set; } = string.Empty; + public string Type { get; set; } = string.Empty; + public string Label { get; set; } = string.Empty; + public bool IsCustom { get; set; } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/Mapping/MappingIssueDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/Mapping/MappingIssueDto.cs new file mode 100644 index 0000000000..31c5f1728e --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/Mapping/MappingIssueDto.cs @@ -0,0 +1,7 @@ +namespace Unity.GrantManager.ApplicationForms.Mapping; + +public class MappingIssueDto +{ + public string Code { get; set; } = string.Empty; + public string Message { get; set; } = string.Empty; +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/Mapping/MappingSuggestionDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/Mapping/MappingSuggestionDto.cs new file mode 100644 index 0000000000..e744c9ecbc --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/Mapping/MappingSuggestionDto.cs @@ -0,0 +1,9 @@ +namespace Unity.GrantManager.ApplicationForms.Mapping; + +public class MappingSuggestionDto +{ + public string SourceField { get; set; } = string.Empty; + public string TargetField { get; set; } = string.Empty; + public string Reason { get; set; } = string.Empty; + public decimal Confidence { get; set; } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/Mapping/WorksheetCreationSuggestionDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/Mapping/WorksheetCreationSuggestionDto.cs new file mode 100644 index 0000000000..4c574f92aa --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/Mapping/WorksheetCreationSuggestionDto.cs @@ -0,0 +1,10 @@ +using System.Collections.Generic; + +namespace Unity.GrantManager.ApplicationForms.Mapping; + +public class WorksheetCreationSuggestionDto +{ + public string WorksheetName { get; set; } = string.Empty; + public List SuggestedFields { get; set; } = []; + public string Reason { get; set; } = string.Empty; +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/Mapping/WorksheetMappingFieldsDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/Mapping/WorksheetMappingFieldsDto.cs new file mode 100644 index 0000000000..070384e7f9 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/Mapping/WorksheetMappingFieldsDto.cs @@ -0,0 +1,11 @@ +using System; +using System.Collections.Generic; + +namespace Unity.GrantManager.ApplicationForms.Mapping; + +public class WorksheetMappingFieldsDto +{ + public Guid WorksheetId { get; set; } + public string WorksheetName { get; set; } = string.Empty; + public List Fields { get; set; } = []; +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/Mapping/WorksheetMappingSuggestionDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/Mapping/WorksheetMappingSuggestionDto.cs new file mode 100644 index 0000000000..03d0b68b64 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/Mapping/WorksheetMappingSuggestionDto.cs @@ -0,0 +1,9 @@ +using System.Collections.Generic; + +namespace Unity.GrantManager.ApplicationForms.Mapping; + +public class WorksheetMappingSuggestionDto +{ + public string WorksheetName { get; set; } = string.Empty; + public List FieldMatches { get; set; } = []; +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicationForms/ApplicationFormVersionAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicationForms/ApplicationFormVersionAppService.cs index 63fa4d8cf8..991998c4c4 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicationForms/ApplicationFormVersionAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicationForms/ApplicationFormVersionAppService.cs @@ -3,12 +3,17 @@ using System; using System.Linq; using System.Text.RegularExpressions; +using System.Text.Json; using System.Collections.Generic; using System.Threading.Tasks; using Unity.GrantManager.Applications; +using Unity.AI; +using Unity.AI.Requests; +using Unity.AI.Responses; using Unity.GrantManager.Forms; using Unity.GrantManager.Intakes; using Unity.GrantManager.Integrations.Chefs; +using Unity.GrantManager.ApplicationForms.Mapping; using Unity.GrantManager.Reporting.FieldGenerators; using Unity.Modules.Shared.Features; using Volo.Abp.Application.Dtos; @@ -29,7 +34,9 @@ public class ApplicationFormVersionAppService( IApplicationFormVersionRepository formVersionRepository, IApplicationFormSubmissionRepository formSubmissionRepository, IReportingFieldsGeneratorService reportingFieldsGeneratorService, - IFeatureChecker featureChecker) : + IFeatureChecker featureChecker, + IApplicationFormVersionMappingReadService mappingReadService, + IAIService aiService) : CrudAppService< ApplicationFormVersion, ApplicationFormVersionDto, @@ -38,6 +45,9 @@ public class ApplicationFormVersionAppService( CreateUpdateApplicationFormVersionDto>(repository), IApplicationFormVersionAppService { + private readonly IApplicationFormVersionMappingReadService _mappingReadService = mappingReadService; + private readonly IAIService _aiService = aiService; + public override async Task CreateAsync(CreateUpdateApplicationFormVersionDto input) => await base.CreateAsync(input); @@ -311,6 +321,55 @@ public async Task DeleteWorkSheetMappingByFormName(string formName, Guid formVer await formVersionRepository.UpdateAsync(applicationFormVersion); } + public virtual async Task SuggestMappingAsync(Guid id) + { + var readModel = await _mappingReadService.GetAsync(id); + var response = await _aiService.GenerateMappingSuggestionAsync(new MappingSuggestionRequest + { + Data = JsonSerializer.SerializeToElement(readModel) + }); + + return new ApplicationFormMappingSuggestionDto + { + ApplicationFormVersionId = id, + CoreFieldMatches = response.CoreFieldMatches.Select(item => new MappingSuggestionDto + { + SourceField = item.SourceField, + TargetField = item.TargetField, + Reason = item.Reason, + Confidence = item.Confidence + }).ToList(), + WorksheetMatches = response.WorksheetMatches.Select(item => new WorksheetMappingSuggestionDto + { + WorksheetName = item.WorksheetName, + FieldMatches = item.FieldMatches.Select(match => new MappingSuggestionDto + { + SourceField = match.SourceField, + TargetField = match.TargetField, + Reason = match.Reason, + Confidence = match.Confidence + }).ToList() + }).ToList(), + WorksheetCreationSuggestions = response.WorksheetCreationSuggestions.Select(item => new WorksheetCreationSuggestionDto + { + WorksheetName = item.WorksheetName, + SuggestedFields = item.SuggestedFields.Select(field => new MappingFieldDto + { + Name = field.Name, + Type = field.Type, + Label = field.Label, + IsCustom = field.IsCustom + }).ToList(), + Reason = item.Reason + }).ToList(), + Issues = response.Issues.Select(item => new MappingIssueDto + { + Code = item.Code, + Message = item.Message + }).ToList() + }; + } + private async Task GetVersion(Guid formVersionId) { var formVersion = await formVersionRepository.GetByChefsFormVersionAsync(formVersionId); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicationForms/Mapping/ApplicationFormVersionMappingReadService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicationForms/Mapping/ApplicationFormVersionMappingReadService.cs new file mode 100644 index 0000000000..3b2497b1a5 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicationForms/Mapping/ApplicationFormVersionMappingReadService.cs @@ -0,0 +1,165 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; +using System.Threading.Tasks; +using Newtonsoft.Json.Linq; +using Unity.Flex; +using Unity.Flex.Worksheets; +using Unity.Flex.Worksheets.Definitions; +using Unity.GrantManager.ApplicationForms.Mapping; +using Unity.GrantManager.Intakes; +using Unity.GrantManager.Intakes.Mapping; +using Unity.Modules.Shared.Correlation; +using Volo.Abp.DependencyInjection; +using Volo.Abp.Features; + +namespace Unity.GrantManager.ApplicationForms.Mapping; + +public interface IApplicationFormVersionMappingReadService +{ + Task GetAsync(Guid formVersionId); +} + +public class ApplicationFormVersionMappingReadService( + IApplicationFormVersionAppService applicationFormVersionAppService, + IWorksheetAppService worksheetAppService, + IFeatureChecker featureChecker) : IApplicationFormVersionMappingReadService, ITransientDependency +{ + public async Task GetAsync(Guid formVersionId) + { + var formVersion = await applicationFormVersionAppService.GetAsync(formVersionId); + + var model = new ApplicationFormMappingReadModelDto + { + ApplicationFormVersionId = formVersion.Id, + ApplicationFormId = formVersion.ApplicationFormId, + ChefsApplicationFormGuid = formVersion.ChefsApplicationFormGuid, + ChefsFormVersionGuid = formVersion.ChefsFormVersionGuid, + ChefsFields = BuildChefsFields(formVersion.AvailableChefsFields), + UnityCoreFields = BuildUnityCoreFields() + }; + + if (await featureChecker.IsEnabledAsync("Unity.Flex")) + { + var worksheets = await worksheetAppService.GetListByCorrelationAsync(formVersionId, CorrelationConsts.FormVersion); + model.Worksheets = worksheets.Select(MapWorksheet).ToList(); + } + + return model; + } + + private static List BuildChefsFields(string? availableChefsFields) + { + if (string.IsNullOrWhiteSpace(availableChefsFields)) + { + return []; + } + + var jObject = JObject.Parse(availableChefsFields); + return jObject.Properties() + .Select(property => + { + var fieldConfig = JObject.Parse(property.Value.ToString()); + return new MappingFieldDto + { + Name = property.Name, + Type = fieldConfig["type"]?.ToString() ?? "String", + IsCustom = false, + Label = fieldConfig["label"]?.ToString() ?? property.Name + }; + }) + .OrderBy(field => field.Label) + .ToList(); + } + + private static List BuildUnityCoreFields() + { + var intakeMapping = new IntakeMapping(); + return intakeMapping.GetType() + .GetProperties() + .Select(property => new + { + Property = property, + Browsable = property.GetCustomAttributes(typeof(BrowsableAttribute), true).Cast().SingleOrDefault(), + DisplayName = property.GetCustomAttributes(typeof(DisplayNameAttribute), true).Cast().SingleOrDefault(), + FieldType = property.GetCustomAttributes(typeof(MapFieldTypeAttribute), true).Cast().SingleOrDefault() + }) + .Where(item => item.Browsable?.IsDefaultAttribute() == true) + .Select(item => new MappingFieldDto + { + Name = item.Property.Name, + Type = item.FieldType?.Type ?? "String", + IsCustom = false, + Label = item.DisplayName?.DisplayName ?? item.Property.Name + }) + .OrderBy(field => field.Label) + .ToList(); + } + + private static WorksheetMappingFieldsDto MapWorksheet(WorksheetDto worksheet) + { + return new WorksheetMappingFieldsDto + { + WorksheetId = worksheet.Id, + WorksheetName = worksheet.Name, + Fields = worksheet.Sections + .SelectMany(section => section.Fields) + .Where(IsMappable) + .Select(field => new MappingFieldDto + { + Name = $"{field.Name}.{field.Type}", + Type = ConvertCustomType(field.Type), + IsCustom = true, + Label = $"{field.Label} ({worksheet.Name})" + }) + .OrderBy(field => field.Label) + .ToList() + }; + } + + private static bool IsMappable(CustomFieldDto? fieldDto) + { + if (fieldDto == null) + { + return false; + } + + return fieldDto.Type switch + { + CustomFieldType.DataGrid => IsDataGridMappable(fieldDto), + _ => true + }; + } + + private static bool IsDataGridMappable(CustomFieldDto fieldDto) + { + if (fieldDto.Definition == null) + { + return true; + } + + var definition = (DataGridDefinition?)fieldDto.Definition.ConvertDefinition(CustomFieldType.DataGrid); + return definition?.Dynamic ?? true; + } + + private static string ConvertCustomType(CustomFieldType type) => type switch + { + CustomFieldType.Text => "String", + CustomFieldType.Date => "Date", + CustomFieldType.Email => "Email", + CustomFieldType.Phone => "Phone", + CustomFieldType.DateTime => "Date", + CustomFieldType.YesNo => "YesNo", + CustomFieldType.Currency => "Currency", + CustomFieldType.Numeric => "Number", + CustomFieldType.Radio => "Radio", + CustomFieldType.Checkbox => "Checkbox", + CustomFieldType.CheckboxGroup => "CheckboxGroup", + CustomFieldType.SelectList => "SelectList", + CustomFieldType.BCAddress => "BCAddress", + CustomFieldType.TextArea => "TextArea", + CustomFieldType.DataGrid => "DataGrid", + _ => string.Empty + }; +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.DbMigrator/appsettings.json b/applications/Unity.GrantManager/src/Unity.GrantManager.DbMigrator/appsettings.json index 1e36d565d5..fcf5d9b099 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.DbMigrator/appsettings.json +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.DbMigrator/appsettings.json @@ -1,8 +1,8 @@ { "ConnectionStrings": { - "Default": "Host=localhost;port=5432;Database=UnityGrantManager;Username=postgres;", - "Tenant": "Host=localhost;port=5432;Database=UnityGrantTenant;Username=postgres;", - "Onboarding": "Host=localhost;port=5432;Database=UnityOnboarding;Username=postgres;" + "Default": "Host=localhost;port=5432;Database=UnityGrantManager;Username=postgres;Password=admin", + "Tenant": "Host=localhost;port=5432;Database=UnityGrantTenant;Username=postgres;Password=admin", + "Onboarding": "Host=localhost;port=5432;Database=Onboarding;Username=postgres;Password=admin" }, "StringEncryption": { "DefaultPassPhrase": "g2IuZx7PwXDvCmlW" @@ -31,4 +31,4 @@ "Settings": { "Abp.Localization.DefaultLanguage": "en-CA" } -} \ No newline at end of file +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationForms/Mapping.cshtml.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationForms/Mapping.cshtml.cs index a1c12bf85a..7c27e13930 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationForms/Mapping.cshtml.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationForms/Mapping.cshtml.cs @@ -7,25 +7,26 @@ using System.Text.Json; using System.Threading.Tasks; using Unity.Flex.Worksheets; -using Unity.GrantManager.ApplicationForms; -using Unity.GrantManager.Forms; -using Unity.GrantManager.Intakes; -using Volo.Abp.AspNetCore.Mvc.UI.RazorPages; -using Volo.Abp.Features; -using Unity.Modules.Shared.Correlation; -using Unity.Flex.Worksheets.Definitions; -using Unity.AI.Settings; -using Unity.Flex; -using Volo.Abp.Settings; +using Unity.GrantManager.ApplicationForms; +using Unity.GrantManager.ApplicationForms.Mapping; +using Unity.GrantManager.Forms; +using Unity.GrantManager.Intakes; +using Volo.Abp.AspNetCore.Mvc.UI.RazorPages; +using Volo.Abp.Features; +using Unity.Modules.Shared.Correlation; +using Unity.Flex.Worksheets.Definitions; +using Unity.AI.Settings; +using Unity.Flex; +using Volo.Abp.Settings; namespace Unity.GrantManager.Web.Pages.ApplicationForms { [Authorize] - public class MappingModel(IApplicationFormAppService applicationFormAppService, - IApplicationFormVersionAppService applicationFormVersionAppService, - IWorksheetAppService worksheetAppService, - IFeatureChecker featureChecker, - ISettingProvider settingProvider) : AbpPageModel + public class MappingModel(IApplicationFormAppService applicationFormAppService, + IApplicationFormVersionAppService applicationFormVersionAppService, + IApplicationFormVersionMappingReadService mappingReadService, + IFeatureChecker featureChecker, + ISettingProvider settingProvider) : AbpPageModel { [BindProperty(SupportsGet = true)] @@ -100,80 +101,28 @@ public async Task OnGetAsync() IntakeProperties = JsonSerializer.Serialize(await GenerateMappingFieldsAsync()); } - private async Task> GenerateMappingFieldsAsync() - { - IntakeMapping intakeMapping = new(); - List properties = []; - - foreach (var property in intakeMapping.GetType().GetProperties()) - { - var browsable = property.GetCustomAttributes(typeof(BrowsableAttribute), true).Cast().SingleOrDefault(); - var displayName = property.GetCustomAttributes(typeof(DisplayNameAttribute), true).Cast().SingleOrDefault(); - var fieldType = property.GetCustomAttributes(typeof(MapFieldTypeAttribute), true).Cast().SingleOrDefault(); - - if (browsable != null && browsable.IsDefaultAttribute()) - { - properties.Add(new MapField() - { - Name = property.Name, - Type = fieldType?.Type ?? "String", - IsCustom = false, - Label = displayName?.DisplayName ?? property.Name - }); - } - } - - if (await featureChecker.IsEnabledAsync("Unity.Flex")) - { - // Get the available field from the worksheets for the current Form - var formVersion = await applicationFormVersionAppService.GetByChefsFormVersionId(ChefsFormVersionGuid); - var worksheets = await worksheetAppService.GetListByCorrelationAsync(formVersion?.Id ?? Guid.Empty, CorrelationConsts.FormVersion); - - foreach (var worksheet in worksheets) - { - // Get worksheet name - var fields = worksheet - .Sections - .SelectMany(f => f.Fields) - .ToList(); - - properties.AddRange(from CustomFieldDto? field in fields - where field.IsMappable() - select new MapField() - { - Name = $"{field.Name}.{field.Type}", - Type = ConvertCustomType(field.Type), - IsCustom = true, - Label = $"{field.Label} ({worksheet.Name})" - }); - } - } - - return [.. properties.OrderBy(s => s.Label)]; - } - - private static string ConvertCustomType(CustomFieldType type) - { - return type switch - { - CustomFieldType.Text => "String", - CustomFieldType.Date => "Date", - CustomFieldType.Email => "Email", - CustomFieldType.Phone => "Phone", - CustomFieldType.DateTime => "Date", - CustomFieldType.YesNo => "YesNo", - CustomFieldType.Currency => "Currency", - CustomFieldType.Numeric => "Number", - CustomFieldType.Radio => "Radio", - CustomFieldType.Checkbox => "Checkbox", - CustomFieldType.CheckboxGroup => "CheckboxGroup", - CustomFieldType.SelectList => "SelectList", - CustomFieldType.BCAddress => "BCAddress", - CustomFieldType.TextArea => "TextArea", - CustomFieldType.DataGrid => "DataGrid", - _ => "", - }; - } + private async Task> GenerateMappingFieldsAsync() + { + var readModel = await mappingReadService.GetAsync(ApplicationFormVersionDto?.Id ?? Guid.Empty); + var properties = readModel.ChefsFields + .Select(field => new MapField + { + Name = field.Name, + Type = field.Type, + IsCustom = field.IsCustom, + Label = field.Label + }) + .Concat(readModel.Worksheets.SelectMany(worksheet => worksheet.Fields).Select(field => new MapField + { + Name = field.Name, + Type = field.Type, + IsCustom = field.IsCustom, + Label = field.Label + })) + .ToList(); + + return [.. properties.OrderBy(s => s.Label)]; + } public class MapField { From 9d1bc882624c3ba2f69462ee71f76e7facbae179 Mon Sep 17 00:00:00 2001 From: Jacob Smith Date: Thu, 9 Jul 2026 13:33:23 -0700 Subject: [PATCH 053/223] AB#33569 add generated mapping modal flow --- .../AI/Operations/AttachmentSummaryService.cs | 11 ++ .../Pages/ApplicationForms/Mapping.cshtml | 44 ++++--- .../Pages/ApplicationForms/Mapping.cshtml.cs | 11 +- .../Pages/ApplicationForms/Mapping.js | 112 +++++++++++++----- 4 files changed, 128 insertions(+), 50 deletions(-) diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/AttachmentSummaryService.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/AttachmentSummaryService.cs index 0f5a437289..46ea22809f 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/AttachmentSummaryService.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/AttachmentSummaryService.cs @@ -288,6 +288,17 @@ private async Task OpenAttachmentStreamAsync( } } + private async Task LoadAttachmentAsync(Guid attachmentId) + { + var attachment = await attachmentSummaryPersistence.LoadAsync(attachmentId); + return attachment; + } + + private async Task SaveSummaryAsync(Guid attachmentId, string summary) + { + await attachmentSummaryPersistence.SaveSummaryAsync(attachmentId, summary); + } + private static bool ShouldStopOnEmptyExtraction(string fileName, string extractedText) { return string.IsNullOrWhiteSpace(extractedText) && IsSupportedOfficeOrPdf(fileName); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationForms/Mapping.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationForms/Mapping.cshtml index c27118c845..2fe54704ce 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationForms/Mapping.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationForms/Mapping.cshtml @@ -103,13 +103,18 @@ style="pointer-events: all;" abp-tooltip="Save the Scoresheet and the Mapping of CHEFS fields to Unity Fields" button-type="Primary" /> - - + + + @@ -340,15 +345,18 @@ - - - - - - - + + + + + + + + - \ No newline at end of file + diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationForms/Mapping.cshtml.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationForms/Mapping.cshtml.cs index 7c27e13930..e6648c931f 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationForms/Mapping.cshtml.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationForms/Mapping.cshtml.cs @@ -45,10 +45,13 @@ public class MappingModel(IApplicationFormAppService applicationFormAppService, public List? ApplicationFormVersionDtoList { get; set; } [BindProperty] - public string? ApplicationFormVersionDtoString { get; set; } - - [BindProperty] - public string? IntakeProperties { get; set; } + public string? ApplicationFormVersionDtoString { get; set; } + + [BindProperty] + public string? IntakeProperties { get; set; } + + [BindProperty] + public string? MappingSuggestionJson { get; set; } [BindProperty] public bool FlexEnabled { get; set; } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationForms/Mapping.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationForms/Mapping.js index 6798dc0caf..dd65b466b3 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationForms/Mapping.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationForms/Mapping.js @@ -53,16 +53,18 @@ 'bcaddress', 'datagrid']); - const UIElements = { - btnBack: $('#btn-back'), - btnSave: $('#btn-save'), - btnEdit: $('#btn-edit'), - btnSync: $('#btn-sync'), - btnReset: $('#btn-reset'), - btnClose: $('.btn-close'), - btnSaveMapping: $('#btn-save-mapping'), - btnCancel: $('#btn-cancel-mapping'), - inputSearchBar: $('#search-bar'), + const UIElements = { + btnBack: $('#btn-back'), + btnSave: $('#btn-save'), + btnEdit: $('#btn-edit'), + btnSuggest: $('#btn-suggest'), + btnSync: $('#btn-sync'), + btnReset: $('#btn-reset'), + btnClose: $('.btn-close'), + btnApplySuggestion: $('#btn-apply-suggestion'), + btnSaveMapping: $('#btn-save-mapping'), + btnCancel: $('#btn-cancel-mapping'), + inputSearchBar: $('#search-bar'), selectVersionList: $('#applicationFormVersion'), editMappingModal: $('#editMappingModal'), uiConfigurationTab: $('#nav-ui-configuration'), @@ -94,12 +96,14 @@ function bindUIEvents() { UIElements.btnBack.on('click', handleBack); UIElements.btnSave.on('click', handleSave); - UIElements.btnSaveMapping.on('click', handleSaveEditMapping); - UIElements.btnSync.on('click', handleSync); - UIElements.btnEdit.on('click', handleEdit); - UIElements.btnReset.on('click', handleReset); - UIElements.btnCancel.on('click', handleCancelMapping); - UIElements.btnClose.on('click', handleCancelMapping); + UIElements.btnSaveMapping.on('click', handleSaveEditMapping); + UIElements.btnApplySuggestion.on('click', handleApplySuggestion); + UIElements.btnSync.on('click', handleSync); + UIElements.btnEdit.on('click', handleEdit); + UIElements.btnSuggest.on('click', handleSuggest); + UIElements.btnReset.on('click', handleReset); + UIElements.btnCancel.on('click', handleCancelMapping); + UIElements.btnClose.on('click', handleCancelMapping); UIElements.inputSearchBar.on('keyup', handleSeearchBar); UIElements.selectVersionList.on('change', handleSelectVersion); UIElements.mappingTab.on('click', handleMappingTabClick); @@ -146,15 +150,50 @@ }); } - function handleEdit() { - $('#jsonText').val(prettyJson(existingMappingString)); - UIElements.editMappingModal.addClass('display-modal'); - } - - function handleSaveEditMapping() { - try { - let jsonText = $('#jsonText').val(); - $.parseJSON(jsonText); + function handleEdit() { + $('#jsonText').val(prettyJson(existingMappingString)); + UIElements.editMappingModal.addClass('display-modal'); + } + + function handleSuggest() { + const formVersion = document.getElementById('formVersionId').value; + if (!validateGuid(formVersion)) { + abp.notify.error('', 'The Form Version ID is not in a GUID format'); + return; + } + + UIElements.btnSuggest.prop('disabled', true); + $.ajax({ + url: `/api/app/application-form-version/${formVersion}/suggest-mapping`, + type: 'POST', + success: function (data) { + $('#jsonText').val(prettyJson(JSON.stringify(data))); + UIElements.editMappingModal.addClass('display-modal'); + }, + error: function () { + abp.notify.error('', 'Failed to generate mapping suggestion.'); + }, + complete: function () { + UIElements.btnSuggest.prop('disabled', false); + } + }); + } + + function handleApplySuggestion() { + try { + const suggestion = JSON.parse($('#jsonText').val() || '{}'); + const mappingJson = buildMappingFromSuggestion(suggestion); + $('#jsonText').val(prettyJson(JSON.stringify(mappingJson))); + abp.notify.success('', 'Suggestion applied to the editor.'); + } catch (err) { + abp.notify.error('', 'The suggestion JSON could not be applied: ' + err); + } + } + + function handleSaveEditMapping() { + try { + let jsonText = $('#jsonText').val(); + $.parseJSON(jsonText); let mappingJsonStr = jsonText.replace(/\s+/g, ' ').replace(/(\r\n|\n|\r)/gm, ""); UIElements.btnSaveMapping.prop('disabled', true); handleSaveMapping($.parseJSON(mappingJsonStr)); @@ -176,8 +215,25 @@ '', 'The JSON is not valid:' + err ); - } - } + } + } + + function buildMappingFromSuggestion(suggestion) { + const mapping = {}; + const addMatch = (match) => { + if (!match || !match.sourceField || !match.targetField) { + return; + } + mapping[match.sourceField] = match.targetField; + }; + + (suggestion.coreFieldMatches || []).forEach(addMatch); + (suggestion.worksheetMatches || []).forEach(worksheet => { + (worksheet.fieldMatches || []).forEach(addMatch); + }); + + return mapping; + } function handleCancelMapping() { UIElements.editMappingModal.removeClass('display-modal'); @@ -571,4 +627,4 @@ function dragEnd(ev) { if (draggedEl.classList + "" !== "undefined") { draggedEl.classList.remove('dragging'); } -} \ No newline at end of file +} From a86e479d2c5168e05ae799d9f1a3ff32ecc7daa9 Mon Sep 17 00:00:00 2001 From: Jacob Smith Date: Thu, 9 Jul 2026 13:41:36 -0700 Subject: [PATCH 054/223] AB#32311 break mapping read service cycle --- ...pplicationFormVersionMappingReadService.cs | 6 +- .../AttachmentSummaryServiceTests.cs | 293 ++++++++++++++++-- 2 files changed, 269 insertions(+), 30 deletions(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicationForms/Mapping/ApplicationFormVersionMappingReadService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicationForms/Mapping/ApplicationFormVersionMappingReadService.cs index 3b2497b1a5..17af5b7ac5 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicationForms/Mapping/ApplicationFormVersionMappingReadService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicationForms/Mapping/ApplicationFormVersionMappingReadService.cs @@ -8,11 +8,13 @@ using Unity.Flex.Worksheets; using Unity.Flex.Worksheets.Definitions; using Unity.GrantManager.ApplicationForms.Mapping; +using Unity.GrantManager.Applications; using Unity.GrantManager.Intakes; using Unity.GrantManager.Intakes.Mapping; using Unity.Modules.Shared.Correlation; using Volo.Abp.DependencyInjection; using Volo.Abp.Features; +using Volo.Abp.Domain.Repositories; namespace Unity.GrantManager.ApplicationForms.Mapping; @@ -22,13 +24,13 @@ public interface IApplicationFormVersionMappingReadService } public class ApplicationFormVersionMappingReadService( - IApplicationFormVersionAppService applicationFormVersionAppService, + IRepository applicationFormVersionRepository, IWorksheetAppService worksheetAppService, IFeatureChecker featureChecker) : IApplicationFormVersionMappingReadService, ITransientDependency { public async Task GetAsync(Guid formVersionId) { - var formVersion = await applicationFormVersionAppService.GetAsync(formVersionId); + var formVersion = await applicationFormVersionRepository.GetAsync(formVersionId); var model = new ApplicationFormMappingReadModelDto { diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/Operations/AttachmentSummaryServiceTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/Operations/AttachmentSummaryServiceTests.cs index 85510abda3..e4610a47e7 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/Operations/AttachmentSummaryServiceTests.cs +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/Operations/AttachmentSummaryServiceTests.cs @@ -2,10 +2,13 @@ using Microsoft.Extensions.Localization; using Microsoft.Extensions.Logging.Abstractions; using NSubstitute; +using NPOI.XWPF.UserModel; using Shouldly; using System; using System.Collections.Generic; using System.IO; +using System.Linq; +using System.Text; using System.Threading; using System.Threading.Tasks; using Unity.AI; @@ -32,22 +35,27 @@ public async Task GenerateAndSaveAsync_Uses_Streamed_Attachment_Text() var fileId = Guid.NewGuid(); var stream = new MemoryStream([1, 2, 3]); AttachmentSummaryRequest? capturedRequest = null; + string? savedSummary = null; - var provider = CreateProvider(attachmentId, "test.txt", submissionId, fileId); + var persistence = CreatePersistence(attachmentId, "test.txt", submissionId, fileId, savedSummary: summary => savedSummary = summary); var streamProvider = Substitute.For(); streamProvider.OpenAsync(submissionId, fileId, "test.txt") .Returns(new ChefsFileAttachmentStream(stream, "text/plain")); var textExtractionService = Substitute.For(); - textExtractionService.ExtractTextAsync("test.txt", stream, "text/plain", Arg.Any()) + textExtractionService.ExtractTextAsync("test.txt", stream, "text/plain") .Returns("extracted text"); var aiService = Substitute.For(); - aiService.GenerateAttachmentSummaryAsync(Arg.Do(request => capturedRequest = request), Arg.Any()) + aiService.GenerateAttachmentSummaryAsync(Arg.Do(request => capturedRequest = request)) .Returns(new AttachmentSummaryResponse { Summary = "summary text" }); - var service = CreateService(provider, streamProvider, textExtractionService, aiService); + var service = CreateService( + persistence, + streamProvider, + textExtractionService, + aiService); var result = await service.GenerateAndSaveAsync(attachmentId, "v1"); @@ -57,7 +65,7 @@ public async Task GenerateAndSaveAsync_Uses_Streamed_Attachment_Text() capturedRequest.ContentType.ShouldBe("text/plain"); capturedRequest.ExtractedText.ShouldBe("extracted text"); capturedRequest.PromptVersion.ShouldBe("v1"); - await provider.Received(1).UpdateAttachmentSummaryAsync(attachmentId, "summary text"); + savedSummary.ShouldBe("summary text"); stream.CanRead.ShouldBeFalse(); } @@ -71,13 +79,17 @@ public async Task GenerateAndSaveAsync_Should_Propagate_Cancellation() using var cancellationTokenSource = new CancellationTokenSource(); await cancellationTokenSource.CancelAsync(); - var provider = CreateProvider(attachmentId, "test.txt", submissionId, fileId); + var persistence = CreatePersistence(attachmentId, "test.txt", submissionId, fileId); var streamProvider = Substitute.For(); streamProvider.OpenAsync(submissionId, fileId, "test.txt") .Returns(new ChefsFileAttachmentStream(stream, "text/plain")); - var service = CreateService(provider, streamProvider, Substitute.For(), Substitute.For()); + var service = CreateService( + persistence, + streamProvider, + Substitute.For(), + Substitute.For()); await Should.ThrowAsync(() => service.GenerateAndSaveAsync(attachmentId, "v1", cancellationTokenSource.Token)); @@ -86,10 +98,49 @@ await Should.ThrowAsync(() => [Fact] public async Task GenerateAndSaveAsync_Should_Reject_Empty_Attachment_List() { - var provider = Substitute.For(); - var service = CreateService(provider, Substitute.For(), Substitute.For(), Substitute.For()); + var persistence = Substitute.For(); + var service = CreateService( + persistence, + Substitute.For(), + Substitute.For(), + Substitute.For()); await Should.ThrowAsync(() => service.GenerateAndSaveAsync([], "v1")); + + await persistence.DidNotReceive().LoadApplicationAttachmentIdsAsync(Arg.Any()); + } + + [Fact] + public async Task GenerateAndSaveAsync_Should_Not_Call_AI_When_Supported_File_Extraction_Is_Empty() + { + var attachmentId = Guid.NewGuid(); + var submissionId = Guid.NewGuid(); + var fileId = Guid.NewGuid(); + var stream = new MemoryStream([1, 2, 3]); + string? savedSummary = null; + + var persistence = CreatePersistence(attachmentId, "test.docx", submissionId, fileId, summary => savedSummary = summary); + + var streamProvider = Substitute.For(); + streamProvider.OpenAsync(submissionId, fileId, "test.docx") + .Returns(new ChefsFileAttachmentStream(stream, "application/vnd.openxmlformats-officedocument.wordprocessingml.document")); + + var textExtractionService = Substitute.For(); + textExtractionService.ExtractTextAsync("test.docx", stream, "application/vnd.openxmlformats-officedocument.wordprocessingml.document") + .Returns(string.Empty); + + var aiService = Substitute.For(); + var service = CreateService( + persistence, + streamProvider, + textExtractionService, + aiService); + + var result = await service.GenerateAndSaveAsync(attachmentId, "v1"); + + result.ShouldBe("Attachment text could not be extracted for AI summary generation."); + savedSummary.ShouldBe(result); + await aiService.DidNotReceive().GenerateAttachmentSummaryAsync(Arg.Any()); } [Fact] @@ -97,18 +148,41 @@ public async Task GenerateAndSaveAsync_Should_Use_Batch_Mode_When_Configured() { var firstAttachmentId = Guid.NewGuid(); var secondAttachmentId = Guid.NewGuid(); + var applicationId = Guid.NewGuid(); var submissionId = Guid.NewGuid(); var fileId1 = Guid.NewGuid(); var fileId2 = Guid.NewGuid(); var stream1 = new MemoryStream([1, 2, 3]); var stream2 = new MemoryStream([4, 5, 6]); - var applicationId = Guid.NewGuid(); - var provider = Substitute.For(); - provider.GetAttachmentAsync(firstAttachmentId).Returns(new AttachmentSummarySource(firstAttachmentId, "first.txt", submissionId.ToString(), fileId1.ToString())); - provider.GetAttachmentAsync(secondAttachmentId).Returns(new AttachmentSummarySource(secondAttachmentId, "second.txt", submissionId.ToString(), fileId2.ToString())); - provider.GetApplicationAttachmentIdsAsync(applicationId).Returns(new List { firstAttachmentId, secondAttachmentId }); - provider.GetApplicationAttachmentIdsAsync(Arg.Is(id => id != applicationId)).Returns(new List()); + var firstAttachment = new ApplicationChefsFileAttachment + { + ApplicationId = Guid.NewGuid(), + FileName = "first.txt", + ChefsSubmissionId = submissionId.ToString(), + ChefsFileId = fileId1.ToString() + }; + + var secondAttachment = new ApplicationChefsFileAttachment + { + ApplicationId = firstAttachment.ApplicationId, + FileName = "second.txt", + ChefsSubmissionId = submissionId.ToString(), + ChefsFileId = fileId2.ToString() + }; + + var persistence = Substitute.For(); + persistence.LoadAsync(firstAttachmentId).Returns(new AttachmentSummarySource( + firstAttachmentId, + firstAttachment.FileName, + firstAttachment.ChefsSubmissionId, + firstAttachment.ChefsFileId)); + persistence.LoadAsync(secondAttachmentId).Returns(new AttachmentSummarySource( + secondAttachmentId, + secondAttachment.FileName, + secondAttachment.ChefsSubmissionId, + secondAttachment.ChefsFileId)); + persistence.LoadApplicationAttachmentIdsAsync(applicationId).Returns([firstAttachmentId, secondAttachmentId]); var streamProvider = Substitute.For(); streamProvider.OpenAsync(submissionId, fileId1, "first.txt") @@ -123,7 +197,7 @@ public async Task GenerateAndSaveAsync_Should_Use_Batch_Mode_When_Configured() .Returns("second extracted"); var aiService = Substitute.For(); - aiService.GenerateAttachmentSummaryBatchAsync(Arg.Any(), Arg.Any()) + aiService.GenerateAttachmentSummaryBatchAsync(Arg.Any()) .Returns(new AttachmentSummaryBatchResponse { Attachments = @@ -141,7 +215,7 @@ public async Task GenerateAndSaveAsync_Should_Use_Batch_Mode_When_Configured() .Build(); var service = new AttachmentSummaryService( - provider, + persistence, streamProvider, textExtractionService, aiService, @@ -154,19 +228,121 @@ public async Task GenerateAndSaveAsync_Should_Use_Batch_Mode_When_Configured() var summaries = await service.GenerateAndSaveAsync([firstAttachmentId, secondAttachmentId], "v1"); summaries.ShouldBe(["first summary", "second summary"]); - await aiService.Received(1).GenerateAttachmentSummaryBatchAsync(Arg.Any(), Arg.Any()); - await provider.Received(1).UpdateAttachmentSummaryAsync(firstAttachmentId, "first summary"); - await provider.Received(1).UpdateAttachmentSummaryAsync(secondAttachmentId, "second summary"); + await aiService.Received(1).GenerateAttachmentSummaryBatchAsync(Arg.Any()); + await persistence.Received(1).SaveSummaryAsync(firstAttachmentId, "first summary"); + await persistence.Received(1).SaveSummaryAsync(secondAttachmentId, "second summary"); + } + + [Fact] + public async Task GenerateAndSaveAsync_Should_Pass_Extracted_Docx_Text_To_AI() + { + var attachmentId = Guid.NewGuid(); + var submissionId = Guid.NewGuid(); + var fileId = Guid.NewGuid(); + var stream = CreateDocxStream("Riverside mock attachment content"); + AttachmentSummaryRequest? capturedRequest = null; + + var persistence = CreatePersistence(attachmentId, "riverside-profile.docx", submissionId, fileId); + + var streamProvider = Substitute.For(); + streamProvider.OpenAsync(submissionId, fileId, "riverside-profile.docx") + .Returns(new ChefsFileAttachmentStream(stream, "application/vnd.openxmlformats-officedocument.wordprocessingml.document")); + + var aiService = Substitute.For(); + aiService.GenerateAttachmentSummaryAsync(Arg.Do(request => capturedRequest = request)) + .Returns(new AttachmentSummaryResponse { Summary = "summary text" }); + + var service = CreateService( + persistence, + streamProvider, + new TextExtractionService(NullLogger.Instance), + aiService); + + var result = await service.GenerateAndSaveAsync(attachmentId, "v1"); + + result.ShouldBe("summary text"); + capturedRequest.ShouldNotBeNull(); + capturedRequest.ExtractedText.ShouldNotBeNull(); + capturedRequest.ExtractedText.ShouldContain("Riverside mock attachment content"); + await aiService.Received(1).GenerateAttachmentSummaryAsync(Arg.Any()); + } + + [Fact] + public async Task GenerateAndSaveAsync_Should_Pass_Extracted_Text_From_Text_Attachment_To_AI() + { + var attachmentId = Guid.NewGuid(); + var submissionId = Guid.NewGuid(); + var fileId = Guid.NewGuid(); + var attachmentText = "Mock CHEFS attachment content for extraction."; + var stream = new MemoryStream(Encoding.UTF8.GetBytes(attachmentText)); + AttachmentSummaryRequest? capturedRequest = null; + + var persistence = CreatePersistence(attachmentId, "mock-attachment.txt", submissionId, fileId); + + var streamProvider = Substitute.For(); + streamProvider.OpenAsync(submissionId, fileId, "mock-attachment.txt") + .Returns(new ChefsFileAttachmentStream(stream, "text/plain")); + + var aiService = Substitute.For(); + aiService.GenerateAttachmentSummaryAsync(Arg.Do(request => capturedRequest = request)) + .Returns(new AttachmentSummaryResponse { Summary = "summary text" }); + + var service = CreateService( + persistence, + streamProvider, + new TextExtractionService(NullLogger.Instance), + aiService); + + var result = await service.GenerateAndSaveAsync(attachmentId, "v1"); + + result.ShouldBe("summary text"); + capturedRequest.ShouldNotBeNull(); + capturedRequest.ExtractedText.ShouldBe(attachmentText); + await aiService.Received(1).GenerateAttachmentSummaryAsync(Arg.Any()); + } + + [Fact] + public async Task GenerateAndSaveAsync_Should_Pass_Extracted_Pdf_Text_To_AI() + { + var attachmentId = Guid.NewGuid(); + var submissionId = Guid.NewGuid(); + var fileId = Guid.NewGuid(); + var attachmentText = "Mock CHEFS PDF attachment content"; + var stream = CreatePdfStream(attachmentText); + AttachmentSummaryRequest? capturedRequest = null; + + var persistence = CreatePersistence(attachmentId, "mock-attachment.pdf", submissionId, fileId); + + var streamProvider = Substitute.For(); + streamProvider.OpenAsync(submissionId, fileId, "mock-attachment.pdf") + .Returns(new ChefsFileAttachmentStream(stream, "application/pdf")); + + var aiService = Substitute.For(); + aiService.GenerateAttachmentSummaryAsync(Arg.Do(request => capturedRequest = request)) + .Returns(new AttachmentSummaryResponse { Summary = "summary text" }); + + var service = CreateService( + persistence, + streamProvider, + new TextExtractionService(NullLogger.Instance), + aiService); + + var result = await service.GenerateAndSaveAsync(attachmentId, "v1"); + + result.ShouldBe("summary text"); + capturedRequest.ShouldNotBeNull(); + capturedRequest.ExtractedText.ShouldContain(attachmentText); + await aiService.Received(1).GenerateAttachmentSummaryAsync(Arg.Any()); } private static AttachmentSummaryService CreateService( - IAttachmentSummaryDataProvider provider, + IAttachmentSummaryPersistence persistence, IChefsFileAttachmentStreamProvider streamProvider, ITextExtractionService textExtractionService, IAIService aiService) { return new AttachmentSummaryService( - provider, + persistence, streamProvider, textExtractionService, aiService, @@ -177,22 +353,83 @@ private static AttachmentSummaryService CreateService( Substitute.For>()); } - private static IAttachmentSummaryDataProvider CreateProvider( + private static IAttachmentSummaryPersistence CreatePersistence( Guid attachmentId, string fileName, Guid submissionId, Guid fileId, Action? savedSummary = null) { - var provider = Substitute.For(); - provider.GetAttachmentAsync(attachmentId).Returns(new AttachmentSummarySource(attachmentId, fileName, submissionId.ToString(), fileId.ToString())); - provider.GetApplicationAttachmentIdsAsync(Arg.Any()).Returns(new List { attachmentId }); - provider.UpdateAttachmentSummaryAsync(attachmentId, Arg.Any()).Returns(callInfo => + var persistence = Substitute.For(); + persistence.LoadAsync(attachmentId).Returns(new AttachmentSummarySource( + attachmentId, + fileName, + submissionId.ToString(), + fileId.ToString())); + persistence.LoadApplicationAttachmentIdsAsync(Arg.Any()).Returns(new List()); + persistence.SaveSummaryAsync(attachmentId, Arg.Any()).Returns(callInfo => { savedSummary?.Invoke(callInfo.ArgAt(1)); return Task.CompletedTask; }); - return provider; + return persistence; + } + + private static MemoryStream CreateDocxStream(string paragraphText) + { + var writeStream = new MemoryStream(); + using (var document = new XWPFDocument()) + { + document.CreateParagraph().CreateRun().SetText(paragraphText); + document.Write(writeStream); + } + + return new MemoryStream(writeStream.ToArray()); + } + + private static MemoryStream CreatePdfStream(string text) + { + static string EscapePdfText(string value) => + value.Replace(@"\", @"\\").Replace("(", @"\(").Replace(")", @"\)"); + + var contentStream = $"BT /F1 18 Tf 72 144 Td ({EscapePdfText(text)}) Tj ET\n"; + var contentBytes = Encoding.ASCII.GetBytes(contentStream); + + var objects = new[] + { + "1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n", + "2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n", + "3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>\nendobj\n", + $"4 0 obj\n<< /Length {contentBytes.Length} >>\nstream\n{contentStream}endstream\nendobj\n", + "5 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>\nendobj\n" + }; + + var builder = new StringBuilder(); + builder.Append("%PDF-1.4\n"); + var offsets = new List { 0 }; + + foreach (var pdfObject in objects) + { + offsets.Add(Encoding.ASCII.GetByteCount(builder.ToString())); + builder.Append(pdfObject); + } + + var xrefStart = Encoding.ASCII.GetByteCount(builder.ToString()); + builder.Append("xref\n"); + builder.Append("0 6\n"); + builder.Append("0000000000 65535 f \n"); + foreach (var offset in offsets.Skip(1)) + { + builder.Append($"{offset:0000000000} 00000 n \n"); + } + + builder.Append("trailer\n"); + builder.Append("<< /Size 6 /Root 1 0 R >>\n"); + builder.Append("startxref\n"); + builder.Append($"{xrefStart}\n"); + builder.Append("%%EOF\n"); + + return new MemoryStream(Encoding.ASCII.GetBytes(builder.ToString())); } private static IUnitOfWorkManager CreateUnitOfWorkManager() From 3bc8bcb3edc8824a16ba222cc1cab85ed1da8b49 Mon Sep 17 00:00:00 2001 From: Jacob Smith Date: Thu, 9 Jul 2026 13:48:34 -0700 Subject: [PATCH 055/223] AB#33569 refine mapping suggestion button --- .../Pages/ApplicationForms/Mapping.cshtml | 6 +++--- .../Pages/ApplicationForms/Mapping.js | 2 +- .../Views/Shared/Components/_Shared/string-utils.js | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationForms/Mapping.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationForms/Mapping.cshtml index 2fe54704ce..6b1485fe7c 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationForms/Mapping.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationForms/Mapping.cshtml @@ -107,10 +107,10 @@ icon="fl fl-edit" class="btn unt-btn-primary btn-primary" data-toggle="modal" style="pointer-events: all;" abp-tooltip="Edit the Mapping JSON Manually" button-type="Primary" /> - Date: Thu, 9 Jul 2026 14:56:27 -0700 Subject: [PATCH 056/223] AB#33569 align onboarding mapping prompt seed --- .../DataSeed/AIPromptDataSeeder.cs | 71 ++++++------------- 1 file changed, 23 insertions(+), 48 deletions(-) diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/DataSeed/AIPromptDataSeeder.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/DataSeed/AIPromptDataSeeder.cs index bf5e88de20..468aac8de5 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/DataSeed/AIPromptDataSeeder.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/DataSeed/AIPromptDataSeeder.cs @@ -791,65 +791,40 @@ 4. Choose the most conservative valid answer supported by that evidence. // ── v0/mapping-suggestion.system.txt ──────────────────────────────────── private const string OnboardingMappingSystemV2 = """ You are a careful mapping assistant for human reviewers. - Compare CHEFS fields, Unity core fields, and worksheet fields to suggest likely mappings. - Do not invent fields, persist changes, or assume a worksheet should exist if one is not clearly justified. - Return only valid JSON in the exact format requested. + Return a flat JSON object that maps CHEFS field keys to Unity core field keys. + Do not invent fields, persist changes, or add any wrapper sections. + Return only valid JSON in the exact mapping shape requested. """; // ── v2/onboarding-mapping.user.txt ───────────────────────────────────── private const string OnboardingMappingUserV2 = """ - FORM MAPPING CONTEXT: + DATA {{DATA}} + ATTACHMENTS + {{ATTACHMENTS}} + + SECTION + {{SECTION}} + + RESPONSE + {{RESPONSE}} + + RULES + {{RULES}} + {{COMMON_RULES}} + OUTPUT { - "coreFieldMatches": [ - { - "sourceField": "", - "targetField": "", - "reason": "", - "confidence": - } - ], - "worksheetMatches": [ - { - "worksheetName": "", - "fieldMatches": [ - { - "sourceField": "", - "targetField": "", - "reason": "", - "confidence": - } - ] - } - ], - "worksheetCreationSuggestions": [ - { - "worksheetName": "", - "suggestedFields": [ - { - "name": "", - "type": "", - "label": "", - "isCustom": true - } - ], - "reason": "" - } - ], - "issues": [ - { - "code": "", - "message": "" - } - ] + "": "", + "": "" } Important: - - Use only FORM MAPPING CONTEXT as evidence. - - Return only fields that are supported by the context. - - Keep reasons specific and concise. + - Use only DATA and ATTACHMENTS as evidence. + - Do not invent missing application details. + - Return only a flat JSON object with source field keys mapped to target field keys. + - Do not add wrapper sections or nested arrays. - Return valid plain JSON only in the exact OUTPUT shape. """; From a20700564d26432240a844edd2e8cdb8834af91b Mon Sep 17 00:00:00 2001 From: Jacob Smith Date: Thu, 9 Jul 2026 14:58:43 -0700 Subject: [PATCH 057/223] AB#33569 refine mapping prompt ui --- .../Pages/ApplicationForms/Mapping.cshtml | 18 ++++---- .../Pages/ApplicationForms/Mapping.js | 45 +++++-------------- .../Shared/Components/_Shared/string-utils.js | 2 +- 3 files changed, 20 insertions(+), 45 deletions(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationForms/Mapping.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationForms/Mapping.cshtml index 6b1485fe7c..c3005b32b6 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationForms/Mapping.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationForms/Mapping.cshtml @@ -107,11 +107,14 @@ icon="fl fl-edit" class="btn unt-btn-primary btn-primary" data-toggle="modal" style="pointer-events: all;" abp-tooltip="Edit the Mapping JSON Manually" button-type="Primary" /> - + @@ -344,15 +347,12 @@ - + - { - if (!match || !match.sourceField || !match.targetField) { - return; - } - mapping[match.sourceField] = match.targetField; - }; - - (suggestion.coreFieldMatches || []).forEach(addMatch); - (suggestion.worksheetMatches || []).forEach(worksheet => { - (worksheet.fieldMatches || []).forEach(addMatch); - }); - - return mapping; + function handleCancelMapping() { + UIElements.editMappingModal.removeClass('display-modal'); } - function handleCancelMapping() { - UIElements.editMappingModal.removeClass('display-modal'); - } - function handleSeearchBar(e) { let filterValue = e.currentTarget.value; let oTable = $('#ApplicationFormsTable').dataTable(); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/_Shared/string-utils.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/_Shared/string-utils.js index fd1edb7ac8..dc7862ccf7 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/_Shared/string-utils.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/_Shared/string-utils.js @@ -79,5 +79,5 @@ function escapeHtmlAttribute(value) { * @returns {boolean} True if valid GUID format */ function validateGuid(textString) { - return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-5][0-9a-f]{3}-[089ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(String(textString ?? '').trim()); + return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(String(textString ?? '').trim()); } From aff85361ea9a99cd6a1992b44581a5f7391bdd09 Mon Sep 17 00:00:00 2001 From: Jacob Smith Date: Fri, 10 Jul 2026 12:25:33 -0700 Subject: [PATCH 058/223] AB#33569 add AI form generation operations --- .../modules/Unity.AI/docs/README.md | 15 + .../modules/Unity.AI/docs/flow-map.md | 15 + .../Unity.AI/docs/implementation-playbook.md | 74 +++++ .../modules/Unity.AI/docs/index.md | 82 +++++ .../docs/operations/application-analysis.md | 19 ++ .../docs/operations/application-scoring.md | 20 ++ .../docs/operations/attachment-summary.md | 18 ++ .../Unity.AI/docs/operations/form-mapping.md | 32 ++ .../docs/operations/form-scoresheet.md | 29 ++ .../docs/operations/form-worksheet.md | 29 ++ .../modules/Unity.AI/docs/prompt-map.md | 23 ++ .../AI/IAIService.cs | 3 + .../IAIGenerationPrerequisiteValidator.cs | 6 + .../IApplicationAIGenerationQueue.cs | 3 + .../Generation/FormMappingResultDto.cs | 6 + .../Generation/FormScoresheetResultDto.cs | 6 + .../Generation/FormWorksheetResultDto.cs | 6 + .../Generation/IAIGenerationAppService.cs | 6 + .../AIPermissionDefinitionProvider.cs | 35 ++- .../Permissions/AIPermissions.cs | 6 + .../AI/Operations/AIExecutionModeResolver.cs | 4 +- .../AIGenerationPrerequisiteValidator.cs | 77 ++++- .../AI/Prompts/AIPromptTypes.cs | 4 +- .../AI/Runtime/OpenAIRuntimeService.cs | 106 ++++++- .../AI/Runtime/OpenAITransportService.cs | 2 +- .../DataSeed/AIOperationDataSeeder.cs | 4 +- .../DataSeed/AIPromptDataSeeder.cs | 198 +++++++++++-- .../Generation/AIGenerationAppService.cs | 52 +++- .../Features/AIFeatures.cs | 3 + .../Localization/AI/en.json | 12 + .../Localization/AILocalizationKeys.cs | 6 + .../Unity.AI.Web/Menus/AIMenuContributor.cs | 24 ++ .../src/Unity.AI.Web/Menus/AIMenus.cs | 1 + .../Worksheets/CreateWorksheetDto.cs | 5 + .../Worksheets/WorksheetAppService.cs | 3 + .../IApplicationFormVersionService.cs | 6 +- .../GenerateFormMappingBackgroundJobArgs.cs | 16 + .../GenerateFormWorksheetBackgroundJobArgs.cs | 16 + .../GrantManagerFeaturesDefinitionProvider.cs | 18 ++ .../ApplicationFormVersionAppService.cs | 38 ++- ...pplicationFormVersionMappingReadService.cs | 25 +- .../ApplicationAIGenerationQueue.cs | 63 ++++ .../BackgroundJobs/GenerateFormMappingJob.cs | 119 ++++++++ ...GenerateFormScoresheetBackgroundJobArgs.cs | 12 + .../GenerateFormScoresheetJob.cs | 249 ++++++++++++++++ .../GenerateFormWorksheetJob.cs | 246 +++++++++++++++ .../AIGenerationRequestKeyHelper.cs | 6 + .../Pages/ApplicationForms/Mapping.cshtml | 42 +-- .../Pages/ApplicationForms/Mapping.cshtml.cs | 7 +- .../Pages/ApplicationForms/Mapping.js | 279 ++++++++++++++++-- .../Components/CustomFields/Default.cshtml | 26 +- .../AI/Runtime/OpenAIRuntimeServiceTests.cs | 1 + .../ApplicationFormVersionAppServiceTests.cs | 108 +++++++ .../Automation/AIGenerationQueueTests.cs | 51 +++- 54 files changed, 2155 insertions(+), 107 deletions(-) create mode 100644 applications/Unity.GrantManager/modules/Unity.AI/docs/README.md create mode 100644 applications/Unity.GrantManager/modules/Unity.AI/docs/flow-map.md create mode 100644 applications/Unity.GrantManager/modules/Unity.AI/docs/implementation-playbook.md create mode 100644 applications/Unity.GrantManager/modules/Unity.AI/docs/index.md create mode 100644 applications/Unity.GrantManager/modules/Unity.AI/docs/operations/application-analysis.md create mode 100644 applications/Unity.GrantManager/modules/Unity.AI/docs/operations/application-scoring.md create mode 100644 applications/Unity.GrantManager/modules/Unity.AI/docs/operations/attachment-summary.md create mode 100644 applications/Unity.GrantManager/modules/Unity.AI/docs/operations/form-mapping.md create mode 100644 applications/Unity.GrantManager/modules/Unity.AI/docs/operations/form-scoresheet.md create mode 100644 applications/Unity.GrantManager/modules/Unity.AI/docs/operations/form-worksheet.md create mode 100644 applications/Unity.GrantManager/modules/Unity.AI/docs/prompt-map.md create mode 100644 applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Generation/FormMappingResultDto.cs create mode 100644 applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Generation/FormScoresheetResultDto.cs create mode 100644 applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Generation/FormWorksheetResultDto.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/Automation/BackgroundJobs/GenerateFormMappingBackgroundJobArgs.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/Automation/BackgroundJobs/GenerateFormWorksheetBackgroundJobArgs.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateFormMappingJob.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateFormScoresheetBackgroundJobArgs.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateFormScoresheetJob.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateFormWorksheetJob.cs create mode 100644 applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/ApplicationForms/ApplicationFormVersionAppServiceTests.cs diff --git a/applications/Unity.GrantManager/modules/Unity.AI/docs/README.md b/applications/Unity.GrantManager/modules/Unity.AI/docs/README.md new file mode 100644 index 0000000000..0972be7397 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/docs/README.md @@ -0,0 +1,15 @@ +# Unity.AI Docs + +## Architecture +- [`index.md`](./index.md) +- [`flow-map.md`](./flow-map.md) +- [`prompt-map.md`](./prompt-map.md) +- [`implementation-playbook.md`](./implementation-playbook.md) + +## Operations +- [`operations/application-analysis.md`](./operations/application-analysis.md) +- [`operations/attachment-summary.md`](./operations/attachment-summary.md) +- [`operations/application-scoring.md`](./operations/application-scoring.md) +- [`operations/form-mapping.md`](./operations/form-mapping.md) +- [`operations/form-worksheet.md`](./operations/form-worksheet.md) +- [`operations/form-scoresheet.md`](./operations/form-scoresheet.md) diff --git a/applications/Unity.GrantManager/modules/Unity.AI/docs/flow-map.md b/applications/Unity.GrantManager/modules/Unity.AI/docs/flow-map.md new file mode 100644 index 0000000000..9c0fba87e6 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/docs/flow-map.md @@ -0,0 +1,15 @@ +# Flow Map + +## Standard path +UI -> API app service -> queue -> background job -> AI runtime -> persisted result + +## Operation families +- Application Analysis: submission -> analysis +- Attachment Summary: attachment ids -> summaries +- Application Scoring: application + scoresheet -> scoring +- Form Mapping: form version -> mapping +- Form Worksheet: form version -> worksheet +- Form Scoresheet: form version -> scoresheet + +## Build Rule +See [`implementation-playbook.md`](./implementation-playbook.md) for the canonical add-a-new-operation sequence. diff --git a/applications/Unity.GrantManager/modules/Unity.AI/docs/implementation-playbook.md b/applications/Unity.GrantManager/modules/Unity.AI/docs/implementation-playbook.md new file mode 100644 index 0000000000..eb7a950f85 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/docs/implementation-playbook.md @@ -0,0 +1,74 @@ +# AI Operation Implementation Playbook + +## Purpose +Use this when adding a new AI operation. Start with the bare minimum and only add optional pieces when the operation needs them. + +Use these existing operations as the canonical references: + +1. `ApplicationAnalysis` +2. `ApplicationScoring` +3. `AttachmentSummary` +4. `FormMapping` +5. `FormScoresheet` +6. `FormWorksheet` + +## Base Pattern +1. Define the prompt type. +2. Add the v2 prompt seed. +3. Add the operation seed. +4. Add the runtime contract method. +5. Add the runtime implementation. +6. Add the app service or queue entry. +7. Add the background job only if the result must be applied or persisted. +8. Add the UI button and status polling only if users trigger the operation from the web app. +9. Add tests for the prompt, runtime parsing, and job or service path. + +## Bare Minimum +For the first pass, only add what is required for a working operation: + +- prompt type +- prompt seed +- operation seed +- runtime method +- queue/app service entry +- job or direct apply path, if needed + +## Optional Pieces +Add these only when the operation needs them: + +- feature flag +- permissions +- permission definition provider entries +- menu entry +- UI button +- status polling +- refresh-after-complete behavior +- persistence/import/publish/assign behavior + +## Rules +- Keep the prompt as the source of truth. +- Reuse the existing async generation pattern. +- Do not hardcode field buckets or response shapes in UI code. +- Do not invent new plumbing if an existing operation already does the same job. +- Do not add tenant feature seeding. +- Do not add write-back UI behavior unless the operation already persists output. + +## Expected Flow +1. User clicks Generate. +2. UI disables the button and shows generating state, if the operation has UI. +3. API checks permission and feature flag, if the operation uses them. +4. API queues the generation request. +5. Background job loads the operation context. +6. Job builds the prompt payload from existing data. +7. AI runtime renders v2 prompts and logs input/output. +8. Job parses the AI response. +9. Job applies the result if needed. +10. Job stamps status and rate limit state. +11. UI polls status and refreshes after completion, if applicable. + +## Validation +- Confirm the prompt version is v2. +- Confirm the operation exists in the AI operation seed. +- Confirm any required feature flag exists in the host feature definitions. +- Confirm any required permission is wired in the permission definition provider. +- Confirm the UI button uses the same generating/status flow as the other operations, if it is user-triggered. diff --git a/applications/Unity.GrantManager/modules/Unity.AI/docs/index.md b/applications/Unity.GrantManager/modules/Unity.AI/docs/index.md new file mode 100644 index 0000000000..f87fc46d74 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/docs/index.md @@ -0,0 +1,82 @@ +# Unity.AI Index + +## Domain.Shared +AI constants: +- feature flags +- permission names +- localization keys +- prompt type names + +## Application.Contracts +Public AI surface: +- app service interfaces +- queue interfaces +- DTOs +- permission definitions + +## Application +AI implementation: +- runtime +- prompt seeding +- generation app services +- validators +- prompt logging + +## Web +UI-facing AI bits: +- menus +- generation buttons +- status polling + +## Files +### Application +- `AI/Operations` - validators and helpers +- `AI/Runtime` - rendering, parsing, logging, provider calls +- `AI/Prompts` - prompt types and template plumbing +- `DataSeed` - seeded prompt and operation data +- `Generation/AIGenerationAppService.cs` - generation API + +### Application.Contracts +- `AI/IAIService.cs` - runtime contract +- `Generation/IAIGenerationAppService.cs` - generation app service contract +- `Generation/*ResultDto.cs` - queued result DTOs +- `AI/Operations/IAIGenerationPrerequisiteValidator.cs` - queue prerequisites +- `Automation/IApplicationAIGenerationQueue.cs` - queue contract +- `Permissions/*` - permissions + +### Domain.Shared +- `Features/AIFeatures.cs` - feature flags +- `Localization/AILocalizationKeys.cs` - messages +- `PromptTypes/AIPromptTypes.cs` - prompt family names + +### Web +- `Menus/AIMenuContributor.cs` - menu entries +- `Menus/AIMenus.cs` - menu item names + +## Access +| Operation | View | Generate | +| --- | --- | --- | +| Application Analysis | `ViewApplicationAnalysis` | `GenerateApplicationAnalysis` | +| Attachment Summary | `ViewAttachmentSummary` | `GenerateAttachmentSummaries` | +| Application Scoring | `ViewScoringResult` | `GenerateScoring` | +| Form Mapping | `ViewFormMapping` | `GenerateFormMapping` | +| Form Worksheet | `ViewFormWorksheet` | `GenerateFormWorksheet` | +| Form Scoresheet | `ViewFormScoresheet` | `GenerateFormScoresheet` | + +- Features: + - `Unity.AI.ApplicationAnalysis` + - `Unity.AI.AttachmentSummaries` + - `Unity.AI.Scoring` + - `Unity.AI.FormMapping` + - `Unity.AI.FormWorksheet` + - `Unity.AI.FormScoresheet` + +- Rule: + - Both permission and feature gate must allow generation. + +## AI Notes +- Prompt logging: logs rendered system/user prompts and provider output. +- Response parsing: parses provider output into stable app-facing results. +- Feature gating: disabled features fail early at the API boundary. +- Background jobs: mark failures, then re-throw. +- New operation playbook: see `implementation-playbook.md`. diff --git a/applications/Unity.GrantManager/modules/Unity.AI/docs/operations/application-analysis.md b/applications/Unity.GrantManager/modules/Unity.AI/docs/operations/application-analysis.md new file mode 100644 index 0000000000..1b2bfdb194 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/docs/operations/application-analysis.md @@ -0,0 +1,19 @@ +# Application Analysis + +## Goal +Generate an AI analysis of an application submission. + +## Inputs +- Application submission data +- Application context +- Optional attachments, when present + +## Surface +- `POST /api/app/ai/generation/application-analysis` +- `GET /api/app/ai/generation/status` + +## Contract +- Structured analysis output. Returns an immediate queued result via API app service, queue, background job, and AI runtime. + +## Notes +- This is a reviewer-oriented summary and recommendation flow. diff --git a/applications/Unity.GrantManager/modules/Unity.AI/docs/operations/application-scoring.md b/applications/Unity.GrantManager/modules/Unity.AI/docs/operations/application-scoring.md new file mode 100644 index 0000000000..a19e1f8bb1 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/docs/operations/application-scoring.md @@ -0,0 +1,20 @@ +# Application Scoring + +## Goal +Generate scored answers for a submitted application against an assigned scoresheet. + +## Inputs +- Application submission data +- Assigned scoresheet +- Scoresheet questions and definitions + +## Surface +- `POST /api/app/ai/generation/application-scoring` +- `GET /api/app/ai/generation/status` + +## Contract +- Structured scoring output. Returns an immediate queued result via API app service, queue, background job, and AI runtime. + +## Notes +- The prompt asks for answers only for the configured section or scoresheet context. +- The parsed output must align with the scoresheet question ids. diff --git a/applications/Unity.GrantManager/modules/Unity.AI/docs/operations/attachment-summary.md b/applications/Unity.GrantManager/modules/Unity.AI/docs/operations/attachment-summary.md new file mode 100644 index 0000000000..5735c3996f --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/docs/operations/attachment-summary.md @@ -0,0 +1,18 @@ +# Attachment Summary + +## Goal +Generate summaries for selected application attachments. + +## Inputs +- One or more attachment IDs +- Application context + +## Surface +- `POST /api/app/ai/generation/attachment-summary` +- `GET /api/app/ai/generation/status` + +## Contract +- Structured attachment summary output. Returns an immediate queued result via API app service, queue, background job, and AI runtime. + +## Notes +- Each attachment is processed as part of the generation request. diff --git a/applications/Unity.GrantManager/modules/Unity.AI/docs/operations/form-mapping.md b/applications/Unity.GrantManager/modules/Unity.AI/docs/operations/form-mapping.md new file mode 100644 index 0000000000..2881cefe9f --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/docs/operations/form-mapping.md @@ -0,0 +1,32 @@ +# Form Mapping + +## Goal +Generate recommended CHEFS-to-Unity field mapping for a form version. + +## Inputs +- CHEFS fields from the form version +- Unity core intake fields +- Worksheet-derived custom fields when available + +## Rule +- Prefer existing Unity core intake fields where they already fit the source field. +- Only suggest worksheet fields or worksheet creation when the form genuinely needs them. + +## Surface +- `POST /api/app/ai/generation/form-mapping` +- `GET /api/app/ai/generation/status` +- `GET /api/app/application-form-version/{id}` + +## Contract +- Structured mapping recommendation JSON output. Returns an immediate queued result via API app service, queue, background job, and AI runtime. + +## Output Shape +- Core field matches. +- Worksheet field matches. +- Worksheet creation suggestions. +- Issues or conflicts. +- Keep the result valid JSON and compatible with the mapping page flow. + +## Notes +- The AI response is expected to stay structured and JSON-shaped. +- For new operations, follow [`implementation-playbook.md`](../implementation-playbook.md). diff --git a/applications/Unity.GrantManager/modules/Unity.AI/docs/operations/form-scoresheet.md b/applications/Unity.GrantManager/modules/Unity.AI/docs/operations/form-scoresheet.md new file mode 100644 index 0000000000..86e0f06b14 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/docs/operations/form-scoresheet.md @@ -0,0 +1,29 @@ +# Form Scoresheet + +## Goal +Generate a recommended scoresheet definition for a form version. + +## Inputs +- Form version context +- Form name +- Existing scoresheet context +- Assigned form and scoresheet identifiers + +## Rule +- Generate the assessor rubric for scoring submitted applications. +- Keep the result focused on reviewer criteria, scoring sections, and comments. + +## Surface +- `POST /api/app/ai/generation/form-scoresheet` +- `GET /api/app/ai/generation/status` + +## Contract +- Structured Flex scoresheet JSON output. Returns an immediate queued result via API app service, queue, background job, and AI runtime. + +## Output Shape +- Full scoresheet definition JSON. +- Keep the result focused on assessor criteria, scoring sections, comments, and totals. +- Keep the result valid JSON and compatible with Flex import. + +## Notes +- The AI output should stay valid JSON. diff --git a/applications/Unity.GrantManager/modules/Unity.AI/docs/operations/form-worksheet.md b/applications/Unity.GrantManager/modules/Unity.AI/docs/operations/form-worksheet.md new file mode 100644 index 0000000000..49d76dd9df --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/docs/operations/form-worksheet.md @@ -0,0 +1,29 @@ +# Form Worksheet + +## Goal +Generate a recommended worksheet definition for a form version. + +## Inputs +- Form version context +- Form name +- Existing worksheet links +- Worksheet field context + +## Rule +- Prefer existing Unity core fields when they already fit the need. +- Only add new worksheet fields when the form genuinely needs extra Unity fields. + +## Surface +- `POST /api/app/ai/generation/form-worksheet` +- `GET /api/app/ai/generation/status` + +## Contract +- Structured Flex worksheet JSON output. Returns an immediate queued result via API app service, queue, background job, and AI runtime. + +## Output Shape +- Full worksheet definition JSON. +- Include only additional worksheet fields that the form needs beyond core Unity fields. +- Keep the result valid JSON and compatible with Flex import. + +## Notes +- The AI output should stay valid JSON. diff --git a/applications/Unity.GrantManager/modules/Unity.AI/docs/prompt-map.md b/applications/Unity.GrantManager/modules/Unity.AI/docs/prompt-map.md new file mode 100644 index 0000000000..f6296b755c --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/docs/prompt-map.md @@ -0,0 +1,23 @@ +# Prompt Map + +## Prompt families +- `ApplicationAnalysis` - review and recommendation +- `AttachmentSummary` - attachment summary +- `ApplicationScoring` - question scoring +- `FormMapping` - CHEFS to Unity mapping +- `FormWorksheet` - worksheet generation +- `FormScoresheet` - scoresheet generation + +## Versions +- `v0`, `v1`, `v2` live under `AI/Prompts/Versions` +- The seeder loads built-in prompt rows from those versions +- Runtime selects by prompt family and version + +## Prompt rules +- Versioned prompts are the source of truth. +- Prompt templates define the request shape. +- Structured outputs should stay JSON-shaped. +- New versions should not silently change behavior. + +## Build Rule +Use [`implementation-playbook.md`](./implementation-playbook.md) when adding a new prompt-backed operation. diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/IAIService.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/IAIService.cs index 92f918035c..894001b98f 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/IAIService.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/IAIService.cs @@ -14,5 +14,8 @@ public interface IAIService Task GenerateApplicationAnalysisAsync(ApplicationAnalysisRequest request, CancellationToken cancellationToken = default); Task GenerateApplicationScoringAsync(ApplicationScoringRequest request, CancellationToken cancellationToken = default); Task GenerateMappingSuggestionAsync(MappingSuggestionRequest request, CancellationToken cancellationToken = default); + Task GenerateFormMappingAsync(MappingSuggestionRequest request, CancellationToken cancellationToken = default); + Task GenerateFormWorksheetAsync(MappingSuggestionRequest request, CancellationToken cancellationToken = default); + Task GenerateFormScoresheetAsync(MappingSuggestionRequest request, CancellationToken cancellationToken = default); } } diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Operations/IAIGenerationPrerequisiteValidator.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Operations/IAIGenerationPrerequisiteValidator.cs index 67df324548..cbbfeae8ea 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Operations/IAIGenerationPrerequisiteValidator.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Operations/IAIGenerationPrerequisiteValidator.cs @@ -10,4 +10,10 @@ public interface IAIGenerationPrerequisiteValidator Task EnsureApplicationAnalysisAvailableAsync(Guid applicationId); Task EnsureApplicationScoringAvailableAsync(Guid applicationId); + + Task EnsureFormMappingAvailableAsync(Guid applicationFormVersionId); + + Task EnsureFormWorksheetAvailableAsync(Guid applicationFormVersionId); + + Task EnsureFormScoresheetAvailableAsync(Guid applicationFormVersionId); } diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Automation/IApplicationAIGenerationQueue.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Automation/IApplicationAIGenerationQueue.cs index e7c0dbbb6c..58cc0fa925 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Automation/IApplicationAIGenerationQueue.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Automation/IApplicationAIGenerationQueue.cs @@ -9,5 +9,8 @@ public interface IApplicationAIGenerationQueue Task QueueAttachmentSummaryAsync(Guid applicationId, Guid? tenantId, string? promptVersion = null, List? attachmentIds = null); Task QueueApplicationAnalysisAsync(Guid applicationId, Guid? tenantId, string? promptVersion = null); Task QueueApplicationScoringAsync(Guid applicationId, Guid? tenantId, string? promptVersion = null); + Task QueueFormMappingAsync(Guid applicationId, Guid? tenantId, Guid applicationFormVersionId, string? promptVersion = null); + Task QueueFormWorksheetAsync(Guid applicationId, Guid? tenantId, Guid applicationFormVersionId, string? promptVersion = null); + Task QueueFormScoresheetAsync(Guid applicationId, Guid? tenantId, Guid applicationFormVersionId, string? promptVersion = null); Task QueueAllAIStagesAsync(Guid applicationId, Guid? tenantId, string? promptVersion = null); } diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Generation/FormMappingResultDto.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Generation/FormMappingResultDto.cs new file mode 100644 index 0000000000..8e3a54506c --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Generation/FormMappingResultDto.cs @@ -0,0 +1,6 @@ +namespace Unity.AI.Generation; + +public class FormMappingResultDto +{ + public bool Completed { get; set; } +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Generation/FormScoresheetResultDto.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Generation/FormScoresheetResultDto.cs new file mode 100644 index 0000000000..cc526e5a27 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Generation/FormScoresheetResultDto.cs @@ -0,0 +1,6 @@ +namespace Unity.AI.Generation; + +public class FormScoresheetResultDto +{ + public bool Completed { get; set; } +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Generation/FormWorksheetResultDto.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Generation/FormWorksheetResultDto.cs new file mode 100644 index 0000000000..6b356d3bc6 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Generation/FormWorksheetResultDto.cs @@ -0,0 +1,6 @@ +namespace Unity.AI.Generation; + +public class FormWorksheetResultDto +{ + public bool Completed { get; set; } +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Generation/IAIGenerationAppService.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Generation/IAIGenerationAppService.cs index c22118ed39..bc8915c23b 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Generation/IAIGenerationAppService.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Generation/IAIGenerationAppService.cs @@ -15,5 +15,11 @@ public interface IAIGenerationAppService : IApplicationService Task GenerateApplicationScoringAsync(Guid applicationId, string? promptVersion = null); + Task GenerateFormMappingAsync(Guid applicationId, Guid applicationFormVersionId, string? promptVersion = null); + + Task GenerateFormWorksheetAsync(Guid applicationId, Guid applicationFormVersionId, string? promptVersion = null); + + Task GenerateFormScoresheetAsync(Guid applicationId, Guid applicationFormVersionId, string? promptVersion = null); + Task GetStatusAsync(Guid applicationId, string operationType); } diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Permissions/AIPermissionDefinitionProvider.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Permissions/AIPermissionDefinitionProvider.cs index 3e4a2a7b9d..195bedf97b 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Permissions/AIPermissionDefinitionProvider.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Permissions/AIPermissionDefinitionProvider.cs @@ -55,6 +55,36 @@ public override void Define(IPermissionDefinitionContext context) L("Permission:AI.GenerateScoring")) .RequireFeatures("Unity.AI.Scoring"); + var viewFormMapping = aiPermissionsGroup.AddPermission( + AIPermissions.Analysis.ViewFormMapping, + L("Permission:AI.ViewFormMapping")) + .RequireFeatures("Unity.AI.FormMapping"); + + viewFormMapping.AddChild( + AIPermissions.Analysis.GenerateFormMapping, + L("Permission:AI.GenerateFormMapping")) + .RequireFeatures("Unity.AI.FormMapping"); + + var viewFormWorksheet = aiPermissionsGroup.AddPermission( + AIPermissions.Analysis.ViewFormWorksheet, + L("Permission:AI.ViewFormWorksheet")) + .RequireFeatures("Unity.AI.FormWorksheet"); + + viewFormWorksheet.AddChild( + AIPermissions.Analysis.GenerateFormWorksheet, + L("Permission:AI.GenerateFormWorksheet")) + .RequireFeatures("Unity.AI.FormWorksheet"); + + var viewFormScoresheet = aiPermissionsGroup.AddPermission( + AIPermissions.Analysis.ViewFormScoresheet, + L("Permission:AI.ViewFormScoresheet")) + .RequireFeatures("Unity.AI.FormScoresheet"); + + viewFormScoresheet.AddChild( + AIPermissions.Analysis.GenerateFormScoresheet, + L("Permission:AI.GenerateFormScoresheet")) + .RequireFeatures("Unity.AI.FormScoresheet"); + var settingManagement = context.GetGroup(SettingManagementPermissions.GroupName); var configureAI = settingManagement.AddPermission( AIPermissions.Configuration.ConfigureAI, @@ -62,7 +92,10 @@ public override void Define(IPermissionDefinitionContext context) configureAI.StateCheckers.Add(new AnyFeaturePermissionStateProvider( "Unity.AI.Scoring", "Unity.AI.AttachmentSummaries", - "Unity.AI.ApplicationAnalysis")); + "Unity.AI.ApplicationAnalysis", + "Unity.AI.FormMapping", + "Unity.AI.FormWorksheet", + "Unity.AI.FormScoresheet")); } private static LocalizableString L(string name) diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Permissions/AIPermissions.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Permissions/AIPermissions.cs index b9ea59607f..aae21a43ca 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Permissions/AIPermissions.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Permissions/AIPermissions.cs @@ -17,10 +17,16 @@ public static class Analysis public const string ViewApplicationAnalysis = GroupName + ".ViewApplicationAnalysis"; public const string ViewAttachmentSummary = GroupName + ".ViewAttachmentSummary"; public const string ViewScoringResult = GroupName + ".ViewScoringResult"; + public const string ViewFormMapping = GroupName + ".ViewFormMapping"; + public const string ViewFormWorksheet = GroupName + ".ViewFormWorksheet"; + public const string ViewFormScoresheet = GroupName + ".ViewFormScoresheet"; public const string GenerateApplicationAnalysis = GroupName + ".GenerateApplicationAnalysis"; public const string GenerateAttachmentSummaries = GroupName + ".GenerateAttachmentSummaries"; public const string GenerateScoring = GroupName + ".GenerateScoring"; + public const string GenerateFormMapping = GroupName + ".GenerateFormMapping"; + public const string GenerateFormWorksheet = GroupName + ".GenerateFormWorksheet"; + public const string GenerateFormScoresheet = GroupName + ".GenerateFormScoresheet"; } public static class Configuration diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/AIExecutionModeResolver.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/AIExecutionModeResolver.cs index d6b69e7ab0..5408e2eade 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/AIExecutionModeResolver.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/AIExecutionModeResolver.cs @@ -15,7 +15,9 @@ public class AIExecutionModeResolver(IConfiguration configuration) : ITransientD { public const string AttachmentSummaryOperation = AIPromptTypes.AttachmentSummary; public const string ApplicationScoringOperation = AIPromptTypes.ApplicationScoring; - public const string OnboardingMappingOperation = AIPromptTypes.OnboardingMapping; + public const string FormMappingOperation = AIPromptTypes.FormMapping; + public const string FormWorksheetOperation = AIPromptTypes.FormWorksheet; + public const string FormScoresheetOperation = AIPromptTypes.FormScoresheet; public AIExecutionMode ResolveMode(string operationName) { diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/AIGenerationPrerequisiteValidator.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/AIGenerationPrerequisiteValidator.cs index eb882c8cae..be6ba44f1d 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/AIGenerationPrerequisiteValidator.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/AIGenerationPrerequisiteValidator.cs @@ -2,19 +2,33 @@ using System; using System.Linq; using System.Threading.Tasks; +using Unity.Flex.Domain.Scoresheets; +using Unity.Flex.Domain.Worksheets; using Unity.AI.Localization; +using Unity.GrantManager.Applications; +using Unity.Modules.Shared.Correlation; using Volo.Abp; using Volo.Abp.DependencyInjection; +using Volo.Abp.Linq; namespace Unity.AI.Operations; public class AIGenerationPrerequisiteValidator( - IAIApplicationInputDataProvider dataProvider, + IApplicationRepository applicationRepository, + IApplicationFormRepository applicationFormRepository, + IApplicationFormVersionRepository applicationFormVersionRepository, + IApplicationFormSubmissionRepository applicationFormSubmissionRepository, + IApplicationChefsFileAttachmentRepository applicationChefsFileAttachmentRepository, + IScoresheetRepository scoresheetRepository, + IWorksheetListRepository worksheetListRepository, + IAsyncQueryableExecuter asyncExecuter, IStringLocalizer localizer) : IAIGenerationPrerequisiteValidator, ITransientDependency { public async Task EnsureAttachmentSummaryAvailableAsync(Guid applicationId) { - if (!await dataProvider.HasAttachmentsAsync(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]); } @@ -22,7 +36,8 @@ public async Task EnsureAttachmentSummaryAvailableAsync(Guid applicationId) public async Task EnsureApplicationAnalysisAvailableAsync(Guid applicationId) { - if (!await dataProvider.HasSubmissionAsync(applicationId)) + var submission = await applicationFormSubmissionRepository.GetByApplicationAsync(applicationId); + if (submission == null || string.IsNullOrWhiteSpace(submission.Submission)) { throw new UserFriendlyException(localizer[AILocalizationKeys.ApplicationAnalysisRequiresSubmission]); } @@ -30,16 +45,66 @@ public async Task EnsureApplicationAnalysisAvailableAsync(Guid applicationId) public async Task EnsureApplicationScoringAvailableAsync(Guid applicationId) { - var applicationForm = await dataProvider.GetApplicationFormAsync(applicationId); - if (applicationForm?.ScoresheetId == null) + 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 dataProvider.GetScoresheetAsync(applicationForm.ScoresheetId.Value); + 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]); } } + + public async Task EnsureFormMappingAvailableAsync(Guid applicationFormVersionId) + { + var formVersion = await applicationFormVersionRepository.FindAsync(applicationFormVersionId); + if (formVersion == null) + { + throw new UserFriendlyException(localizer[AILocalizationKeys.FormMappingRequiresFormVersion]); + } + } + + public async Task EnsureFormWorksheetAvailableAsync(Guid applicationFormVersionId) + { + var formVersion = await applicationFormVersionRepository.FindAsync(applicationFormVersionId); + if (formVersion == null) + { + throw new UserFriendlyException(localizer[AILocalizationKeys.FormWorksheetRequiresFormVersion]); + } + + var worksheetLinks = await worksheetListRepository.GetListByCorrelationAsync( + formVersion.Id, + CorrelationConsts.FormVersion, + true); + + if (worksheetLinks == null) + { + throw new UserFriendlyException(localizer[AILocalizationKeys.FormWorksheetRequiresFormVersion]); + } + } + + public async Task EnsureFormScoresheetAvailableAsync(Guid applicationFormVersionId) + { + var formVersion = await applicationFormVersionRepository.FindAsync(applicationFormVersionId); + if (formVersion == null) + { + throw new UserFriendlyException(localizer[AILocalizationKeys.FormScoresheetRequiresFormVersion]); + } + + var applicationForm = await applicationFormRepository.GetAsync(formVersion.ApplicationFormId); + if (applicationForm.ScoresheetId == null) + { + throw new UserFriendlyException(localizer[AILocalizationKeys.FormScoresheetRequiresFormVersion]); + } + + 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.FormScoresheetRequiresFormVersion]); + } + } } diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/AIPromptTypes.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/AIPromptTypes.cs index 605ca6450e..081410e782 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/AIPromptTypes.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/AIPromptTypes.cs @@ -5,5 +5,7 @@ public static class AIPromptTypes public const string AttachmentSummary = "AttachmentSummary"; public const string ApplicationAnalysis = "ApplicationAnalysis"; public const string ApplicationScoring = "ApplicationScoring"; - public const string OnboardingMapping = "OnboardingMapping"; + public const string FormMapping = "FormMapping"; + public const string FormWorksheet = "FormWorksheet"; + public const string FormScoresheet = "FormScoresheet"; } diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIRuntimeService.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIRuntimeService.cs index 164af0db42..eb442b361b 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIRuntimeService.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIRuntimeService.cs @@ -25,7 +25,9 @@ public class OpenAIRuntimeService : IAIService, ITransientDependency private const string ApplicationAnalysisPromptType = AIPromptTypes.ApplicationAnalysis; private const string AttachmentSummaryPromptType = AIPromptTypes.AttachmentSummary; private const string ApplicationScoringPromptType = AIPromptTypes.ApplicationScoring; - private const string MappingSuggestionPromptType = AIPromptTypes.OnboardingMapping; + private const string MappingSuggestionPromptType = AIPromptTypes.FormMapping; + private const string FormWorksheetPromptType = AIPromptTypes.FormWorksheet; + private const string FormScoresheetPromptType = AIPromptTypes.FormScoresheet; private const int MaxAiAttempts = 3; public OpenAIRuntimeService( @@ -335,13 +337,60 @@ public async Task GenerateApplicationScoringAsync(Ap } public async Task GenerateMappingSuggestionAsync(MappingSuggestionRequest request, CancellationToken cancellationToken = default) + => await GenerateMappingSuggestionAsync(request, MappingSuggestionPromptType, cancellationToken); + + public async Task GenerateFormWorksheetAsync(MappingSuggestionRequest request, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + try + { + var settings = await _openAIConfigurationResolver.ResolveOperationSettingsAsync(FormWorksheetPromptType, cancellationToken); + var promptTemplate = await _promptTemplateProvider.GetRequiredPromptAsync( + FormWorksheetPromptType, + request.PromptVersion ?? settings.PromptVersion, + cancellationToken); + var promptVersion = promptTemplate.PromptVersion; + var dataJson = request.Data.GetRawText(); + var systemPrompt = promptTemplate.SystemPrompt; + var content = AIPromptTemplateRenderer.BuildMappingSuggestionUserPrompt( + promptTemplate.UserPrompt, + dataJson, + promptTemplate.MetadataJson); + + await _promptFileLogger.LogPromptInputAsync(FormWorksheetPromptType, promptVersion, systemPrompt, content, cancellationToken); + var result = await GenerateWithRetryAsync( + () => _openAITransportService.GenerateSummaryAsync( + content, + systemPrompt, + settings, + settings.CompletionTokens, + cancellationToken: cancellationToken), + AIProviderPayloadValidator.ValidateMappingSuggestionJson, + "form worksheet", + cancellationToken); + await _promptFileLogger.LogPromptOutputAsync(FormWorksheetPromptType, promptVersion, result.CaptureOutput, cancellationToken); + + return result.Outcome == AIOperationOutcome.Success ? result.Content : "{}"; + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + _logger.LogError(ex, "Form worksheet generation failed."); + return "{}"; + } + } + + public async Task GenerateFormScoresheetAsync(MappingSuggestionRequest request, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(request); try { - var settings = await _openAIConfigurationResolver.ResolveOperationSettingsAsync(MappingSuggestionPromptType, cancellationToken); + var settings = await _openAIConfigurationResolver.ResolveOperationSettingsAsync(FormScoresheetPromptType, cancellationToken); var promptTemplate = await _promptTemplateProvider.GetRequiredPromptAsync( - MappingSuggestionPromptType, + FormScoresheetPromptType, request.PromptVersion ?? settings.PromptVersion, cancellationToken); var promptVersion = promptTemplate.PromptVersion; @@ -352,7 +401,51 @@ public async Task GenerateMappingSuggestionAsync(Mapp dataJson, promptTemplate.MetadataJson); - await _promptFileLogger.LogPromptInputAsync(MappingSuggestionPromptType, promptVersion, systemPrompt, content, cancellationToken); + await _promptFileLogger.LogPromptInputAsync(FormScoresheetPromptType, promptVersion, systemPrompt, content, cancellationToken); + var result = await GenerateWithRetryAsync( + () => _openAITransportService.GenerateSummaryAsync( + content, + systemPrompt, + settings, + settings.CompletionTokens, + cancellationToken: cancellationToken), + AIProviderPayloadValidator.ValidateMappingSuggestionJson, + "form scoresheet", + cancellationToken); + await _promptFileLogger.LogPromptOutputAsync(FormScoresheetPromptType, promptVersion, result.CaptureOutput, cancellationToken); + + return result.Outcome == AIOperationOutcome.Success ? result.Content : "{}"; + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + _logger.LogError(ex, "Form scoresheet generation failed."); + return "{}"; + } + } + + private async Task GenerateMappingSuggestionAsync(MappingSuggestionRequest request, string promptType, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + try + { + var settings = await _openAIConfigurationResolver.ResolveOperationSettingsAsync(promptType, cancellationToken); + var promptTemplate = await _promptTemplateProvider.GetRequiredPromptAsync( + promptType, + request.PromptVersion ?? settings.PromptVersion, + cancellationToken); + var promptVersion = promptTemplate.PromptVersion; + var dataJson = request.Data.GetRawText(); + var systemPrompt = promptTemplate.SystemPrompt; + var content = AIPromptTemplateRenderer.BuildMappingSuggestionUserPrompt( + promptTemplate.UserPrompt, + dataJson, + promptTemplate.MetadataJson); + + await _promptFileLogger.LogPromptInputAsync(promptType, promptVersion, systemPrompt, content, cancellationToken); var result = await GenerateWithRetryAsync( () => _openAITransportService.GenerateSummaryAsync( content, @@ -363,7 +456,7 @@ public async Task GenerateMappingSuggestionAsync(Mapp AIProviderPayloadValidator.ValidateMappingSuggestionJson, "mapping suggestion", cancellationToken); - await _promptFileLogger.LogPromptOutputAsync(MappingSuggestionPromptType, promptVersion, result.CaptureOutput, cancellationToken); + await _promptFileLogger.LogPromptOutputAsync(promptType, promptVersion, result.CaptureOutput, cancellationToken); if (result.Outcome != AIOperationOutcome.Success) { @@ -383,6 +476,9 @@ public async Task GenerateMappingSuggestionAsync(Mapp } } + public Task GenerateFormMappingAsync(MappingSuggestionRequest request, CancellationToken cancellationToken = default) => + GenerateMappingSuggestionAsync(request, MappingSuggestionPromptType, cancellationToken); + private async Task GenerateWithRetryAsync( Func> operation, Func validator, diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAITransportService.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAITransportService.cs index 769a538af1..ae0a9fe8ec 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAITransportService.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAITransportService.cs @@ -51,7 +51,7 @@ public async Task GenerateSummaryAsync( var completion = result.Value; var rawResponse = result.GetRawResponse(); - var responseContent = rawResponse.Content.ToString(); + var responseContent = rawResponse?.Content?.ToString() ?? string.Empty; var modelOutput = ExtractModelOutput(completion, responseContent); var providerResponse = BuildProviderResponseFromMetadata( modelOutput ?? string.Empty, diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/DataSeed/AIOperationDataSeeder.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/DataSeed/AIOperationDataSeeder.cs index c9aa93471e..be3c374c56 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/DataSeed/AIOperationDataSeeder.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/DataSeed/AIOperationDataSeeder.cs @@ -28,7 +28,9 @@ public class AIOperationDataSeeder( new(AIPromptTypes.ApplicationAnalysis, AIPromptTypes.ApplicationAnalysis, 1, 4000), new(AIPromptTypes.AttachmentSummary, AIPromptTypes.AttachmentSummary, 1, 2000), new(AIPromptTypes.ApplicationScoring, AIPromptTypes.ApplicationScoring, 1, 8000), - new(AIPromptTypes.OnboardingMapping, AIPromptTypes.OnboardingMapping, 2, 2000) + new(AIPromptTypes.FormMapping, AIPromptTypes.FormMapping, 2, 2000), + new(AIPromptTypes.FormWorksheet, AIPromptTypes.FormWorksheet, 2, 4000), + new(AIPromptTypes.FormScoresheet, AIPromptTypes.FormScoresheet, 2, 4000) ]; public async Task SeedAsync(DataSeedContext context) diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/DataSeed/AIPromptDataSeeder.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/DataSeed/AIPromptDataSeeder.cs index 468aac8de5..434a367265 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/DataSeed/AIPromptDataSeeder.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/DataSeed/AIPromptDataSeeder.cs @@ -28,7 +28,9 @@ public async Task SeedAsync(DataSeedContext context) await SeedAnalysisPromptAsync(); await SeedAttachmentPromptAsync(); await SeedScoresheetPromptAsync(); - await SeedOnboardingMappingPromptAsync(); + await SeedFormMappingPromptAsync(); + await SeedFormWorksheetPromptAsync(); + await SeedFormScoresheetPromptAsync(); } } @@ -113,9 +115,19 @@ await EnsurePromptAsync( // ─── MAPPING SUGGESTION ───────────────────────────────────────────────── - private async Task SeedOnboardingMappingPromptAsync() + private async Task SeedFormMappingPromptAsync() { - await EnsurePromptAsync(AIPromptTypes.OnboardingMapping, 2, OnboardingMappingSystemV2, OnboardingMappingUserV2, OnboardingMappingMetadataV2); + await EnsurePromptAsync(AIPromptTypes.FormMapping, 2, FormMappingSystemV2, FormMappingUserV2, FormMappingMetadataV2); + } + + private async Task SeedFormWorksheetPromptAsync() + { + await EnsurePromptAsync(AIPromptTypes.FormWorksheet, 2, FormWorksheetSystemV2, FormWorksheetUserV2, FormWorksheetMetadataV2); + } + + private async Task SeedFormScoresheetPromptAsync() + { + await EnsurePromptAsync(AIPromptTypes.FormScoresheet, 2, FormScoresheetSystemV2, FormScoresheetUserV2, FormScoresheetMetadataV2); } // ─── HELPERS ────────────────────────────────────────────────────────────── @@ -789,48 +801,184 @@ 4. Choose the most conservative valid answer supported by that evidence. """; // ── v0/mapping-suggestion.system.txt ──────────────────────────────────── - private const string OnboardingMappingSystemV2 = """ + private const string FormMappingSystemV2 = """ You are a careful mapping assistant for human reviewers. - Return a flat JSON object that maps CHEFS field keys to Unity core field keys. - Do not invent fields, persist changes, or add any wrapper sections. + Return structured JSON for recommended CHEFS-to-Unity field mapping. + Do not invent fields, persist changes, or add wrapper sections. Return only valid JSON in the exact mapping shape requested. """; // ── v2/onboarding-mapping.user.txt ───────────────────────────────────── - private const string OnboardingMappingUserV2 = """ - DATA + private const string FormMappingUserV2 = """ + FORM MAPPING CONTEXT: {{DATA}} - ATTACHMENTS - {{ATTACHMENTS}} + OUTPUT + { + "coreFieldMatches": [ + { + "sourceField": "", + "targetField": "", + "reason": "", + "confidence": + } + ], + "worksheetMatches": [ + { + "worksheetName": "", + "fieldMatches": [ + { + "sourceField": "", + "targetField": "", + "reason": "", + "confidence": + } + ] + } + ], + "worksheetCreationSuggestions": [ + { + "worksheetName": "", + "suggestedFields": [ + { + "name": "", + "type": "", + "label": "", + "isCustom": true + } + ], + "reason": "" + } + ], + "issues": [ + { + "code": "", + "message": "" + } + ] + } - SECTION - {{SECTION}} + Important: + - Use only FORM MAPPING CONTEXT as evidence. + - Prefer existing Unity core intake fields when they already fit the source field. + - Only suggest worksheet fields or worksheet creation when the form genuinely needs them. + - Return only fields that are supported by the context. + - Keep reasons specific and concise. + - Return valid plain JSON only in the exact OUTPUT shape. + """; - RESPONSE - {{RESPONSE}} + private const string FormMappingMetadataV2 = """ + { + "DATA": "Serialized JSON payload containing CHEFS fields, Unity core fields, and worksheet-derived custom fields." + } + """; - RULES - {{RULES}} - {{COMMON_RULES}} + // ── v2/form-worksheet.system.txt ─────────────────────────────────────── + private const string FormWorksheetSystemV2 = """ + You are a worksheet definition generator for Unity Grant Manager. + Generate a recommended worksheet definition JSON that can be used to create a Flex worksheet. + Return only valid JSON. + """; + + // ── v2/form-worksheet.user.txt ────────────────────────────────────────── + private const string FormWorksheetUserV2 = """ + WORKSHEET CONTEXT: + {{DATA}} OUTPUT { - "": "", - "": "" + "Name": "", + "Title": "", + "Version": , + "Published": true, + "Sections": [ + { + "Name": "", + "Order": 1, + "Fields": [ + { + "Name": "", + "Key": "", + "Label": "", + "Type": , + "Definition": "" + } + ] + } + ], + "ReportColumns": "", + "ReportKeys": "", + "ReportViewName": "" } Important: - - Use only DATA and ATTACHMENTS as evidence. - - Do not invent missing application details. - - Return only a flat JSON object with source field keys mapped to target field keys. - - Do not add wrapper sections or nested arrays. + - Use only WORKSHEET CONTEXT as evidence. + - Prefer existing Unity core fields when they already cover the need. + - Only create additional worksheet fields when they are genuinely needed for this form. + - Use the numeric CustomFieldType values from Unity Flex. + - Return only fields supported by the context. - Return valid plain JSON only in the exact OUTPUT shape. """; - private const string OnboardingMappingMetadataV2 = """ + private const string FormWorksheetMetadataV2 = """ { - "DATA": "Serialized JSON payload containing CHEFS fields, Unity core fields, and worksheet-derived custom fields." + "DATA": "Serialized JSON payload containing the form name, form version, existing worksheet links, and worksheet field context." + } + """; + + // ── v2/form-scoresheet.system.txt ─────────────────────────────────────── + private const string FormScoresheetSystemV2 = """ + You are a scoresheet definition generator for Unity Grant Manager. + Generate a recommended scoresheet definition JSON that can be imported into Flex. + Return only valid JSON. + """; + + // ── v2/form-scoresheet.user.txt ────────────────────────────────────────── + private const string FormScoresheetUserV2 = """ + SCORESHEET CONTEXT: + {{DATA}} + + OUTPUT + { + "Title": "", + "Name": "", + "Version": , + "Order": 0, + "Published": false, + "ReportColumns": "", + "ReportKeys": "", + "ReportViewName": "", + "Sections": [ + { + "Name": "", + "Order": 0, + "Fields": [ + { + "Name": "", + "Label": "", + "Description": "", + "Order": 0, + "Type": , + "Enabled": true, + "Definition": "" + } + ] + } + ] + } + + Important: + - Use only SCORESHEET CONTEXT as evidence. + - Generate the rubric that assessors use to score submitted applications. + - Keep the structure focused on reviewer criteria, comments, and scoring sections. + - Use the numeric QuestionType values from Unity Flex. + - Return only fields supported by the context. + - Return valid plain JSON only in the exact OUTPUT shape. + """; + + private const string FormScoresheetMetadataV2 = """ + { + "DATA": "Serialized JSON payload containing the form name, form version, scoresheet identifier, and existing scoresheet context." } """; diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Generation/AIGenerationAppService.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Generation/AIGenerationAppService.cs index 443b3602cc..bbb06119e5 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Generation/AIGenerationAppService.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Generation/AIGenerationAppService.cs @@ -31,6 +31,9 @@ public class AIGenerationAppService( private const string ApplicationAnalysisOperationType = "application-analysis"; private const string AttachmentSummaryOperationType = "attachment-summary"; private const string ApplicationScoringOperationType = "application-scoring"; + private const string FormMappingOperationType = "form-mapping"; + private const string FormWorksheetOperationType = "form-worksheet"; + private const string FormScoresheetOperationType = "form-scoresheet"; [Authorize(AIPermissions.Analysis.GenerateAttachmentSummaries)] [HttpPost("attachment-summary")] @@ -80,6 +83,42 @@ await featureGuard.EnsureEnabledAsync( return new ApplicationScoringResultDto { Completed = false }; } + [Authorize(AIPermissions.Analysis.GenerateFormMapping)] + [HttpPost("form-mapping")] + public virtual async Task GenerateFormMappingAsync(Guid applicationId, Guid applicationFormVersionId, string? promptVersion = null) + { + await featureGuard.EnsureEnabledAsync( + AIFeatures.FormMapping, + AILocalizationKeys.FormMappingDisabled); + + await aiGenerationQueue.QueueFormMappingAsync(applicationId, currentTenant.Id, applicationFormVersionId, promptVersion); + return new FormMappingResultDto { Completed = false }; + } + + [Authorize(AIPermissions.Analysis.GenerateFormWorksheet)] + [HttpPost("form-worksheet")] + public virtual async Task GenerateFormWorksheetAsync(Guid applicationId, Guid applicationFormVersionId, string? promptVersion = null) + { + await featureGuard.EnsureEnabledAsync( + AIFeatures.FormWorksheet, + AILocalizationKeys.FormWorksheetDisabled); + + await aiGenerationQueue.QueueFormWorksheetAsync(applicationId, currentTenant.Id, applicationFormVersionId, promptVersion); + return new FormWorksheetResultDto { Completed = false }; + } + + [Authorize(AIPermissions.Analysis.GenerateFormScoresheet)] + [HttpPost("form-scoresheet")] + public virtual async Task GenerateFormScoresheetAsync(Guid applicationId, Guid applicationFormVersionId, string? promptVersion = null) + { + await featureGuard.EnsureEnabledAsync( + AIFeatures.FormScoresheet, + AILocalizationKeys.FormScoresheetDisabled); + + await aiGenerationQueue.QueueFormScoresheetAsync(applicationId, currentTenant.Id, applicationFormVersionId, promptVersion); + return new FormScoresheetResultDto { Completed = false }; + } + [Authorize] [HttpGet("status")] public virtual async Task GetStatusAsync(Guid applicationId, string operationType) @@ -105,7 +144,6 @@ public virtual async Task GetStatusAsync(Guid application FailureReason = request.FailureReason, IsActive = request.IsActive }, - FailureReason = request?.FailureReason, IsGenerating = state.IsGenerating, RetryAfterSeconds = state.RetryAfterSeconds }; @@ -118,18 +156,12 @@ private async Task EnsureStatusAccessAsync(string operationType) ApplicationAnalysisOperationType => AIPermissions.Analysis.ViewApplicationAnalysis, AttachmentSummaryOperationType => AIPermissions.Analysis.ViewAttachmentSummary, ApplicationScoringOperationType => AIPermissions.Analysis.ViewScoringResult, - AIGenerationRequestKeyHelper.PipelineOperationType => null, + FormMappingOperationType => AIPermissions.Analysis.ViewFormMapping, + FormWorksheetOperationType => AIPermissions.Analysis.ViewFormWorksheet, + FormScoresheetOperationType => AIPermissions.Analysis.ViewFormScoresheet, _ => throw new UserFriendlyException($"Unsupported AI generation operation type: {operationType}") }; - if (permission is null) - { - await CheckPolicyAsync(AIPermissions.Analysis.ViewApplicationAnalysis); - await CheckPolicyAsync(AIPermissions.Analysis.ViewAttachmentSummary); - await CheckPolicyAsync(AIPermissions.Analysis.ViewScoringResult); - return; - } - await CheckPolicyAsync(permission); } } diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/Features/AIFeatures.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/Features/AIFeatures.cs index e658c36350..e6ace26df3 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/Features/AIFeatures.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/Features/AIFeatures.cs @@ -6,4 +6,7 @@ public static class AIFeatures public const string AttachmentSummaries = "Unity.AI.AttachmentSummaries"; public const string ApplicationAnalysis = "Unity.AI.ApplicationAnalysis"; public const string Scoring = "Unity.AI.Scoring"; + public const string FormMapping = "Unity.AI.FormMapping"; + public const string FormWorksheet = "Unity.AI.FormWorksheet"; + public const string FormScoresheet = "Unity.AI.FormScoresheet"; } diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/Localization/AI/en.json b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/Localization/AI/en.json index 3f72aff220..f6e56f7e9c 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/Localization/AI/en.json +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/Localization/AI/en.json @@ -7,9 +7,15 @@ "Permission:AI.ViewApplicationAnalysis": "View AI Application Analysis", "Permission:AI.ViewAttachmentSummary": "View AI Attachment Summary", "Permission:AI.ViewScoringResult": "View AI Scoring Result", + "Permission:AI.ViewFormMapping": "View AI Form Mapping", + "Permission:AI.ViewFormWorksheet": "View AI Form Worksheet", + "Permission:AI.ViewFormScoresheet": "View AI Form Scoresheet", "Permission:AI.GenerateApplicationAnalysis": "Generate AI Application Analysis", "Permission:AI.GenerateAttachmentSummaries": "Generate AI Attachment Summaries", "Permission:AI.GenerateScoring": "Generate AI Scoring", + "Permission:AI.GenerateFormMapping": "Generate AI Form Mapping", + "Permission:AI.GenerateFormWorksheet": "Generate AI Form Worksheet", + "Permission:AI.GenerateFormScoresheet": "Generate AI Form Scoresheet", "Permission:AI.ConfigureAI": "AI Configuration", "Permission:AI.Prompts": "AI Prompt Management", "Permission:AI.Prompts.Create": "Create Prompts", @@ -23,6 +29,12 @@ "AI:AttachmentSummariesDisabled": "AI attachment summaries are not enabled.", "AI:ApplicationAnalysisDisabled": "AI application analysis is not enabled.", "AI:ScoringDisabled": "AI scoring is not enabled.", + "AI:FormMappingRequiresFormVersion": "AI form mapping requires a valid form version.", + "AI:FormMappingDisabled": "AI form mapping is not enabled.", + "AI:FormWorksheetRequiresFormVersion": "AI form worksheet requires a valid form version.", + "AI:FormWorksheetDisabled": "AI form worksheet is not enabled.", + "AI:FormScoresheetRequiresFormVersion": "AI form scoresheet requires a valid form version and configured scoresheet.", + "AI:FormScoresheetDisabled": "AI form scoresheet is not enabled.", "AI:GenerateAllDisabled": "AI generation is not enabled.", "AI:NoAttachmentsAvailable": "No attachments are available to summarize.", "AI:ApplicationAnalysisRequiresSubmission": "AI application analysis requires application submission data.", diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/Localization/AILocalizationKeys.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/Localization/AILocalizationKeys.cs index 07b526d9a4..3af858cf44 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/Localization/AILocalizationKeys.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/Localization/AILocalizationKeys.cs @@ -5,6 +5,12 @@ public static class AILocalizationKeys public const string AttachmentSummariesDisabled = "AI:AttachmentSummariesDisabled"; public const string ApplicationAnalysisDisabled = "AI:ApplicationAnalysisDisabled"; public const string ScoringDisabled = "AI:ScoringDisabled"; + public const string FormMappingRequiresFormVersion = "AI:FormMappingRequiresFormVersion"; + public const string FormMappingDisabled = "AI:FormMappingDisabled"; + public const string FormWorksheetRequiresFormVersion = "AI:FormWorksheetRequiresFormVersion"; + public const string FormWorksheetDisabled = "AI:FormWorksheetDisabled"; + public const string FormScoresheetRequiresFormVersion = "AI:FormScoresheetRequiresFormVersion"; + public const string FormScoresheetDisabled = "AI:FormScoresheetDisabled"; public const string GenerateAllDisabled = "AI:GenerateAllDisabled"; public const string NoAttachmentsAvailable = "AI:NoAttachmentsAvailable"; public const string ApplicationAnalysisRequiresSubmission = "AI:ApplicationAnalysisRequiresSubmission"; diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Menus/AIMenuContributor.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Menus/AIMenuContributor.cs index 50eea177db..3bd9a3ae7c 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Menus/AIMenuContributor.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Menus/AIMenuContributor.cs @@ -48,5 +48,29 @@ private static async Task ConfigureMainMenuAsync(MenuConfigurationContext contex requiredPermissionName: AIPermissions.Reporting.ReportingDefault )); } + + if (await featureChecker.IsEnabledAsync("Unity.AI.FormMapping")) + { + context.Menu.AddItem(new ApplicationMenuItem( + name: AIMenus.FormMapping, + displayName: l["Permission:AI.ViewFormMapping"], + url: "~/ApplicationForms", + icon: "fl fl-map", + order: 10, + requiredPermissionName: AIPermissions.Analysis.GenerateFormMapping + )); + } + + if (await featureChecker.IsEnabledAsync("Unity.AI.FormWorksheet")) + { + context.Menu.AddItem(new ApplicationMenuItem( + name: AIMenus.FormMapping + ".Worksheet", + displayName: l["Permission:AI.ViewFormWorksheet"], + url: "~/ApplicationForms", + icon: "fl fl-ai-prompts", + order: 11, + requiredPermissionName: AIPermissions.Analysis.GenerateFormWorksheet + )); + } } } diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Menus/AIMenus.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Menus/AIMenus.cs index c455ea168c..6cd16e018a 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Menus/AIMenus.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Menus/AIMenus.cs @@ -6,4 +6,5 @@ public static class AIMenus public const string Prompts = Prefix + ".Prompts"; public const string Reporting = Prefix + ".Reporting"; + public const string FormMapping = Prefix + ".FormMapping"; } diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application.Contracts/Worksheets/CreateWorksheetDto.cs b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application.Contracts/Worksheets/CreateWorksheetDto.cs index b91598fed9..25e217be4a 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application.Contracts/Worksheets/CreateWorksheetDto.cs +++ b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application.Contracts/Worksheets/CreateWorksheetDto.cs @@ -8,6 +8,11 @@ public sealed class CreateWorksheetDto { public string Name { get; set; } = string.Empty; public string Title { get; set; } = string.Empty; + public uint Version { get; set; } = 1; + public bool Published { get; set; } = false; + public string ReportColumns { get; set; } = string.Empty; + public string ReportKeys { get; set; } = string.Empty; + public string ReportViewName { get; set; } = string.Empty; public List Sections { get; set; } = []; } } diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application/Worksheets/WorksheetAppService.cs b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application/Worksheets/WorksheetAppService.cs index 3f1a07c05a..7d5c2e8a8d 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application/Worksheets/WorksheetAppService.cs +++ b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application/Worksheets/WorksheetAppService.cs @@ -58,6 +58,9 @@ public virtual async Task CreateAsync(CreateWorksheetDto dto) } var newWorksheet = new Worksheet(Guid.NewGuid(), worksheetName, dto.Title); + newWorksheet.SetVersion(dto.Version); + newWorksheet.SetPublished(dto.Published); + newWorksheet.SetReportingFields(dto.ReportKeys, dto.ReportColumns, dto.ReportViewName); foreach (var section in dto.Sections.OrderBy(s => s.Order)) { diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/IApplicationFormVersionService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/IApplicationFormVersionService.cs index 0c73d530de..838129221e 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/IApplicationFormVersionService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/IApplicationFormVersionService.cs @@ -23,6 +23,6 @@ public interface IApplicationFormVersionAppService : ICrudAppService< Task GetByChefsFormVersionId(Guid chefsFormVersionId); Task GetFormVersionByApplicationIdAsync(Guid applicationId); Task DeleteWorkSheetMappingByFormName(string formName, Guid formVersionId); - Task SuggestMappingAsync(Guid id); - } -} + Task GenerateMappingAsync(Guid id); + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/Automation/BackgroundJobs/GenerateFormMappingBackgroundJobArgs.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/Automation/BackgroundJobs/GenerateFormMappingBackgroundJobArgs.cs new file mode 100644 index 0000000000..129dc0af6f --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/Automation/BackgroundJobs/GenerateFormMappingBackgroundJobArgs.cs @@ -0,0 +1,16 @@ +using System; + +namespace Unity.GrantManager.GrantApplications.Automation.BackgroundJobs; + +public class GenerateFormMappingBackgroundJobArgs +{ + public Guid ApplicationId { get; set; } + + public Guid? TenantId { get; set; } + + public Guid? RequestedByUserId { get; set; } + + public Guid ApplicationFormVersionId { get; set; } + + public string? PromptVersion { get; set; } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/Automation/BackgroundJobs/GenerateFormWorksheetBackgroundJobArgs.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/Automation/BackgroundJobs/GenerateFormWorksheetBackgroundJobArgs.cs new file mode 100644 index 0000000000..27785fdbb1 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/Automation/BackgroundJobs/GenerateFormWorksheetBackgroundJobArgs.cs @@ -0,0 +1,16 @@ +using System; + +namespace Unity.GrantManager.GrantApplications.Automation.BackgroundJobs; + +public class GenerateFormWorksheetBackgroundJobArgs +{ + public Guid ApplicationId { get; set; } + + public Guid? TenantId { get; set; } + + public Guid? RequestedByUserId { get; set; } + + public Guid ApplicationFormVersionId { get; set; } + + public string? PromptVersion { get; set; } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantManagerFeaturesDefinitionProvider.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantManagerFeaturesDefinitionProvider.cs index 2fa79c7c18..f302199750 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantManagerFeaturesDefinitionProvider.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantManagerFeaturesDefinitionProvider.cs @@ -64,6 +64,24 @@ public override void Define(IFeatureDefinitionContext context) .Create("AI Scoring"), valueType: new ToggleStringValueType()); + myGroup.AddFeature("Unity.AI.FormMapping", + defaultValue: defaultValue, + displayName: LocalizableString + .Create("AI Form Mapping"), + valueType: new ToggleStringValueType()); + + myGroup.AddFeature("Unity.AI.FormWorksheet", + defaultValue: defaultValue, + displayName: LocalizableString + .Create("AI Form Worksheet"), + valueType: new ToggleStringValueType()); + + myGroup.AddFeature("Unity.AI.FormScoresheet", + defaultValue: defaultValue, + displayName: LocalizableString + .Create("AI Form Scoresheet"), + valueType: new ToggleStringValueType()); + myGroup.AddFeature("Unity.Analytics", defaultValue: defaultValue, displayName: LocalizableString diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicationForms/ApplicationFormVersionAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicationForms/ApplicationFormVersionAppService.cs index 991998c4c4..c8fe86695c 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicationForms/ApplicationFormVersionAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicationForms/ApplicationFormVersionAppService.cs @@ -321,13 +321,17 @@ public async Task DeleteWorkSheetMappingByFormName(string formName, Guid formVer await formVersionRepository.UpdateAsync(applicationFormVersion); } - public virtual async Task SuggestMappingAsync(Guid id) + public virtual async Task GenerateMappingAsync(Guid id) { var readModel = await _mappingReadService.GetAsync(id); - var response = await _aiService.GenerateMappingSuggestionAsync(new MappingSuggestionRequest + var response = await _aiService.GenerateFormMappingAsync(new MappingSuggestionRequest { Data = JsonSerializer.SerializeToElement(readModel) }); + var submissionHeaderMapping = BuildSubmissionHeaderMapping(response); + var applicationFormVersion = await repository.GetAsync(id); + applicationFormVersion.SubmissionHeaderMapping = JsonSerializer.Serialize(submissionHeaderMapping); + await repository.UpdateAsync(applicationFormVersion, true); return new ApplicationFormMappingSuggestionDto { @@ -370,6 +374,36 @@ public virtual async Task SuggestMappingAsy }; } + private static Dictionary BuildSubmissionHeaderMapping(Unity.AI.Responses.MappingSuggestionResponse response) + { + var mapping = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var match in response.CoreFieldMatches) + { + AddMapping(mapping, match.SourceField, match.TargetField); + } + + foreach (var worksheetMatch in response.WorksheetMatches) + { + foreach (var match in worksheetMatch.FieldMatches) + { + AddMapping(mapping, match.SourceField, match.TargetField); + } + } + + return mapping; + } + + private static void AddMapping(Dictionary mapping, string? sourceField, string? targetField) + { + if (string.IsNullOrWhiteSpace(sourceField) || string.IsNullOrWhiteSpace(targetField)) + { + return; + } + + mapping[sourceField] = targetField; + } + private async Task GetVersion(Guid formVersionId) { var formVersion = await formVersionRepository.GetByChefsFormVersionAsync(formVersionId); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicationForms/Mapping/ApplicationFormVersionMappingReadService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicationForms/Mapping/ApplicationFormVersionMappingReadService.cs index 17af5b7ac5..9cef7ab9ab 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicationForms/Mapping/ApplicationFormVersionMappingReadService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicationForms/Mapping/ApplicationFormVersionMappingReadService.cs @@ -7,6 +7,7 @@ using Unity.Flex; using Unity.Flex.Worksheets; using Unity.Flex.Worksheets.Definitions; +using Unity.Flex.Domain.Worksheets; using Unity.GrantManager.ApplicationForms.Mapping; using Unity.GrantManager.Applications; using Unity.GrantManager.Intakes; @@ -25,7 +26,7 @@ public interface IApplicationFormVersionMappingReadService public class ApplicationFormVersionMappingReadService( IRepository applicationFormVersionRepository, - IWorksheetAppService worksheetAppService, + IWorksheetListRepository worksheetListRepository, IFeatureChecker featureChecker) : IApplicationFormVersionMappingReadService, ITransientDependency { public async Task GetAsync(Guid formVersionId) @@ -44,8 +45,8 @@ public async Task GetAsync(Guid formVersionI if (await featureChecker.IsEnabledAsync("Unity.Flex")) { - var worksheets = await worksheetAppService.GetListByCorrelationAsync(formVersionId, CorrelationConsts.FormVersion); - model.Worksheets = worksheets.Select(MapWorksheet).ToList(); + var worksheets = await worksheetListRepository.GetListByCorrelationAsync(formVersionId, CorrelationConsts.FormVersion, includeDetails: true); + model.Worksheets = worksheets.Select((Worksheet worksheet) => MapWorksheet(worksheet)).ToList(); } return model; @@ -99,7 +100,7 @@ private static List BuildUnityCoreFields() .ToList(); } - private static WorksheetMappingFieldsDto MapWorksheet(WorksheetDto worksheet) + private static WorksheetMappingFieldsDto MapWorksheet(Worksheet worksheet) { return new WorksheetMappingFieldsDto { @@ -107,7 +108,7 @@ private static WorksheetMappingFieldsDto MapWorksheet(WorksheetDto worksheet) WorksheetName = worksheet.Name, Fields = worksheet.Sections .SelectMany(section => section.Fields) - .Where(IsMappable) + .Where(field => IsMappable(field)) .Select(field => new MappingFieldDto { Name = $"{field.Name}.{field.Type}", @@ -120,28 +121,28 @@ private static WorksheetMappingFieldsDto MapWorksheet(WorksheetDto worksheet) }; } - private static bool IsMappable(CustomFieldDto? fieldDto) + private static bool IsMappable(CustomField? field) { - if (fieldDto == null) + if (field == null) { return false; } - return fieldDto.Type switch + return field.Type switch { - CustomFieldType.DataGrid => IsDataGridMappable(fieldDto), + CustomFieldType.DataGrid => IsDataGridMappable(field), _ => true }; } - private static bool IsDataGridMappable(CustomFieldDto fieldDto) + private static bool IsDataGridMappable(CustomField field) { - if (fieldDto.Definition == null) + if (string.IsNullOrWhiteSpace(field.Definition)) { return true; } - var definition = (DataGridDefinition?)fieldDto.Definition.ConvertDefinition(CustomFieldType.DataGrid); + var definition = (DataGridDefinition?)field.Definition.ConvertDefinition(CustomFieldType.DataGrid); return definition?.Dynamic ?? true; } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/ApplicationAIGenerationQueue.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/ApplicationAIGenerationQueue.cs index dbcdc17c33..707e5b8890 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/ApplicationAIGenerationQueue.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/ApplicationAIGenerationQueue.cs @@ -96,6 +96,69 @@ await EnsureRequestAndEnqueueAsync( }); } + public async Task QueueFormMappingAsync(Guid applicationId, Guid? tenantId, Guid applicationFormVersionId, string? promptVersion = null) + { + await EnsureRequestAndEnqueueAsync( + tenantId, + AIGenerationRequestKeyHelper.FormMappingOperationType, + applicationId, + () => aiGenerationPrerequisiteValidator.EnsureFormMappingAvailableAsync(applicationFormVersionId), + () => + { + return backgroundJobManager.EnqueueAsync(new GenerateFormMappingBackgroundJobArgs + { + ApplicationId = applicationId, + ApplicationFormVersionId = applicationFormVersionId, + PromptVersion = promptVersion, + RequestedByUserId = currentUser.Id, + TenantId = tenantId + }); + }); + } + + public async Task QueueFormWorksheetAsync(Guid applicationId, Guid? tenantId, Guid applicationFormVersionId, string? promptVersion = null) + { + await EnsureRequestAndEnqueueAsync( + tenantId, + AIGenerationRequestKeyHelper.FormWorksheetOperationType, + applicationId, + () => aiGenerationPrerequisiteValidator.EnsureFormWorksheetAvailableAsync(applicationFormVersionId), + () => + { + return backgroundJobManager.EnqueueAsync(new GenerateFormWorksheetBackgroundJobArgs + { + ApplicationId = applicationId, + ApplicationFormVersionId = applicationFormVersionId, + PromptVersion = promptVersion, + RequestedByUserId = currentUser.Id, + TenantId = tenantId + }); + }); + } + + public async Task QueueFormScoresheetAsync(Guid applicationId, Guid? tenantId, Guid applicationFormVersionId, string? promptVersion = null) + { + await EnsureRequestAndEnqueueAsync( + tenantId, + AIGenerationRequestKeyHelper.FormScoresheetOperationType, + applicationId, + () => aiGenerationPrerequisiteValidator.EnsureFormScoresheetAvailableAsync(applicationFormVersionId), + () => + { + var requestedByUserId = currentUser.Id + ?? throw new UserFriendlyException("A logged-in user is required to generate a form scoresheet."); + + return backgroundJobManager.EnqueueAsync(new GenerateFormScoresheetBackgroundJobArgs + { + ApplicationId = applicationId, + ApplicationFormVersionId = applicationFormVersionId, + PromptVersion = promptVersion, + RequestedByUserId = requestedByUserId, + TenantId = tenantId + }); + }); + } + public async Task QueueAllAIStagesAsync(Guid applicationId, Guid? tenantId, string? promptVersion = null) { var hasEnabledStage = false; diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateFormMappingJob.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateFormMappingJob.cs new file mode 100644 index 0000000000..ce9d06e697 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateFormMappingJob.cs @@ -0,0 +1,119 @@ +using Microsoft.Extensions.Logging; +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Threading.Tasks; +using Unity.AI.Domain; +using Unity.AI; +using Unity.AI.Requests; +using Unity.AI.Responses; +using Unity.GrantManager.ApplicationForms; +using Unity.AI.RateLimit; +using Unity.GrantManager.ApplicationForms.Mapping; +using Unity.GrantManager.Applications; +using Volo.Abp.Domain.Repositories; +using Volo.Abp.BackgroundJobs; +using Volo.Abp.DependencyInjection; +using Volo.Abp.MultiTenancy; +using Volo.Abp.Uow; + +namespace Unity.GrantManager.GrantApplications.Automation.BackgroundJobs; + +public class GenerateFormMappingJob( + IApplicationFormVersionMappingReadService mappingReadService, + IAIService aiService, + IRepository applicationFormVersionRepository, + IRepository generationRequestRepository, + IRepository operationRepository, + ICurrentTenant currentTenant, + IUnitOfWorkManager unitOfWorkManager, + IAIRateLimiter aiRateLimiter, + ILogger logger) : AsyncBackgroundJob, ITransientDependency +{ + public override async Task ExecuteAsync(GenerateFormMappingBackgroundJobArgs args) + { + using var logScope = AIGenerationLogScope.Begin( + logger, + AIGenerationRequestKeyHelper.FormMappingOperationType, + args.ApplicationId, + args.TenantId, + args.PromptVersion, + args.RequestedByUserId); + + using (currentTenant.Change(args.TenantId)) + { + await AIGenerationRequestJobHelper.MarkRunningInNewUowAsync( + unitOfWorkManager, + generationRequestRepository, + operationRepository, + args.TenantId, + args.ApplicationId, + AIGenerationRequestKeyHelper.FormMappingOperationType); + try + { + var readModel = await mappingReadService.GetAsync(args.ApplicationFormVersionId); + var response = await aiService.GenerateFormMappingAsync(new MappingSuggestionRequest + { + Data = JsonSerializer.SerializeToElement(readModel), + PromptVersion = args.PromptVersion + }); + + var submissionHeaderMapping = BuildSubmissionHeaderMapping(response); + var applicationFormVersion = await applicationFormVersionRepository.GetAsync(args.ApplicationFormVersionId); + applicationFormVersion.SubmissionHeaderMapping = JsonSerializer.Serialize(submissionHeaderMapping); + await applicationFormVersionRepository.UpdateAsync(applicationFormVersion, true); + + await AIGenerationRequestJobHelper.StampRateLimitBestEffortAsync(aiRateLimiter, logger, args.RequestedByUserId, args.ApplicationId, AIGenerationRequestKeyHelper.FormMappingOperationType); + await AIGenerationRequestJobHelper.MarkCompletedInNewUowAsync( + unitOfWorkManager, + generationRequestRepository, + operationRepository, + args.TenantId, + args.ApplicationId, + AIGenerationRequestKeyHelper.FormMappingOperationType); + } + catch (System.Exception ex) + { + await AIGenerationRequestJobHelper.MarkFailedInNewUowAsync( + unitOfWorkManager, + generationRequestRepository, + operationRepository, + args.TenantId, + args.ApplicationId, + AIGenerationRequestKeyHelper.FormMappingOperationType, + ex.Message); + throw; + } + } + } + + private static Dictionary BuildSubmissionHeaderMapping(MappingSuggestionResponse response) + { + var mapping = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var match in response.CoreFieldMatches) + { + AddMapping(mapping, match.SourceField, match.TargetField); + } + + foreach (var worksheetMatch in response.WorksheetMatches) + { + foreach (var match in worksheetMatch.FieldMatches) + { + AddMapping(mapping, match.SourceField, match.TargetField); + } + } + + return mapping; + } + + private static void AddMapping(Dictionary mapping, string? sourceField, string? targetField) + { + if (string.IsNullOrWhiteSpace(sourceField) || string.IsNullOrWhiteSpace(targetField)) + { + return; + } + + mapping[sourceField] = targetField; + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateFormScoresheetBackgroundJobArgs.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateFormScoresheetBackgroundJobArgs.cs new file mode 100644 index 0000000000..704e15726a --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateFormScoresheetBackgroundJobArgs.cs @@ -0,0 +1,12 @@ +using System; + +namespace Unity.GrantManager.GrantApplications.Automation.BackgroundJobs; + +public class GenerateFormScoresheetBackgroundJobArgs +{ + public Guid ApplicationId { get; set; } + public Guid ApplicationFormVersionId { get; set; } + public Guid? TenantId { get; set; } + public Guid RequestedByUserId { get; set; } + public string? PromptVersion { get; set; } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateFormScoresheetJob.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateFormScoresheetJob.cs new file mode 100644 index 0000000000..82804f4ae9 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateFormScoresheetJob.cs @@ -0,0 +1,249 @@ +using Microsoft.Extensions.Logging; +using System; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using Unity.AI; +using Unity.AI.Domain; +using Unity.AI.Requests; +using Unity.GrantManager.ApplicationForms; +using Unity.GrantManager.Applications; +using Unity.AI.RateLimit; +using Unity.Flex.Domain.Scoresheets; +using Unity.Flex.Scoresheets; +using Volo.Abp.BackgroundJobs; +using Volo.Abp.DependencyInjection; +using Volo.Abp.Domain.Repositories; +using Volo.Abp.MultiTenancy; +using Volo.Abp.Uow; + +namespace Unity.GrantManager.GrantApplications.Automation.BackgroundJobs; + +public class GenerateFormScoresheetJob( + IApplicationFormVersionRepository applicationFormVersionRepository, + IApplicationFormRepository applicationFormRepository, + IScoresheetRepository scoresheetRepository, + IAIService aiService, + IRepository generationRequestRepository, + IRepository operationRepository, + ICurrentTenant currentTenant, + IUnitOfWorkManager unitOfWorkManager, + IAIRateLimiter aiRateLimiter, + ILogger logger) : AsyncBackgroundJob, ITransientDependency +{ + public override async Task ExecuteAsync(GenerateFormScoresheetBackgroundJobArgs args) + { + using var logScope = AIGenerationLogScope.Begin( + logger, + AIGenerationRequestKeyHelper.FormScoresheetOperationType, + args.ApplicationId, + args.TenantId, + args.PromptVersion, + args.RequestedByUserId); + + using (currentTenant.Change(args.TenantId)) + { + await AIGenerationRequestJobHelper.MarkRunningInNewUowAsync( + unitOfWorkManager, + generationRequestRepository, + operationRepository, + args.TenantId, + args.ApplicationId, + AIGenerationRequestKeyHelper.FormScoresheetOperationType); + + try + { + var formVersion = await applicationFormVersionRepository.GetAsync(args.ApplicationFormVersionId); + var applicationForm = await applicationFormRepository.GetAsync(formVersion.ApplicationFormId); + var scoresheetName = BuildScoresheetName(formVersion.Id, applicationForm.Id); + var existingScoresheet = await scoresheetRepository.GetByNameAsync(scoresheetName, true) + ?? (applicationForm.ScoresheetId.HasValue + ? await scoresheetRepository.GetWithChildrenAsync(applicationForm.ScoresheetId.Value) + : null); + + var promptData = new + { + applicationFormVersionId = formVersion.Id, + chefsFormVersionGuid = formVersion.ChefsFormVersionGuid, + applicationFormId = applicationForm.Id, + formName = applicationForm.ApplicationFormName, + scoresheetId = applicationForm.ScoresheetId, + existingScoresheet = existingScoresheet == null + ? null + : new + { + existingScoresheet.Id, + existingScoresheet.Title, + existingScoresheet.Name, + existingScoresheet.Version, + existingScoresheet.Order, + existingScoresheet.Published, + existingScoresheet.ReportColumns, + existingScoresheet.ReportKeys, + existingScoresheet.ReportViewName, + sections = existingScoresheet.Sections.Select(section => new + { + section.Name, + section.Order, + fields = section.Fields.Select(field => new + { + field.Name, + field.Label, + field.Description, + field.Order, + field.Type, + field.Enabled, + field.Definition + }) + }) + } + }; + + var scoresheetJson = await aiService.GenerateFormScoresheetAsync(new MappingSuggestionRequest + { + Data = JsonSerializer.SerializeToElement(promptData), + PromptVersion = args.PromptVersion + }); + + var importDto = ParseScoresheetDefinition(scoresheetJson); + var scoresheet = existingScoresheet ?? await scoresheetRepository.InsertAsync(BuildScoresheet(importDto, scoresheetJson, scoresheetName)); + RebuildScoresheet(scoresheet, importDto, scoresheetJson, scoresheetName); + scoresheet.Published = true; + await scoresheetRepository.UpdateAsync(scoresheet); + + applicationForm.ScoresheetId = scoresheet.Id; + await applicationFormRepository.UpdateAsync(applicationForm); + + await AIGenerationRequestJobHelper.StampRateLimitBestEffortAsync(aiRateLimiter, logger, args.RequestedByUserId, args.ApplicationId, AIGenerationRequestKeyHelper.FormScoresheetOperationType); + await AIGenerationRequestJobHelper.MarkCompletedInNewUowAsync( + unitOfWorkManager, + generationRequestRepository, + operationRepository, + args.TenantId, + args.ApplicationId, + AIGenerationRequestKeyHelper.FormScoresheetOperationType); + } + catch (Exception ex) + { + await AIGenerationRequestJobHelper.MarkFailedInNewUowAsync( + unitOfWorkManager, + generationRequestRepository, + operationRepository, + args.TenantId, + args.ApplicationId, + AIGenerationRequestKeyHelper.FormScoresheetOperationType, + ex.Message); + throw; + } + } + } + + private static CreateScoresheetDto ParseScoresheetDefinition(string json) + { + if (string.IsNullOrWhiteSpace(json)) + { + throw new InvalidOperationException("Scoresheet generation returned empty content."); + } + + var dto = JsonSerializer.Deserialize(json, new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }); + + return dto ?? throw new InvalidOperationException("Scoresheet generation returned an unusable scoresheet definition."); + } + + private static string BuildScoresheetName(Guid formVersionId, Guid formId) + { + return $"ai-form-{formId}-version-{formVersionId}-scoresheet"; + } + + private static Scoresheet BuildScoresheet(CreateScoresheetDto dto, string json, string scoresheetName) + { + var scoresheet = new Scoresheet(Guid.NewGuid(), dto.Title, scoresheetName); + var parsed = JsonSerializer.Deserialize(json, new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }); + + if (!parsed.TryGetProperty("Version", out var versionElement) || versionElement.ValueKind != JsonValueKind.Number) + { + throw new InvalidOperationException("Scoresheet generation returned a definition without a valid Version."); + } + + scoresheet.Version = versionElement.GetUInt32(); + + foreach (var section in parsed.GetProperty("Sections").EnumerateArray()) + { + var sectionName = section.GetProperty("Name").GetString() ?? string.Empty; + var sectionOrder = section.GetProperty("Order").GetUInt32(); + var scoresheetSection = new ScoresheetSection(Guid.NewGuid(), sectionName, sectionOrder); + scoresheet.AddSection(scoresheetSection); + + foreach (var field in section.GetProperty("Fields").EnumerateArray()) + { + var question = new Question( + Guid.NewGuid(), + field.GetProperty("Name").GetString() ?? string.Empty, + field.GetProperty("Label").GetString() ?? string.Empty, + (Unity.Flex.Scoresheets.Enums.QuestionType)field.GetProperty("Type").GetInt32(), + field.GetProperty("Order").GetUInt32(), + field.TryGetProperty("Description", out var description) && description.ValueKind != JsonValueKind.Null + ? description.GetString() + : null, + field.TryGetProperty("Definition", out var definition) ? definition.GetString() : null); + question.SectionId = scoresheetSection.Id; + scoresheetSection.Fields.Add(question); + } + } + + scoresheet.SetReportingFields( + parsed.GetProperty("ReportKeys").GetString() ?? string.Empty, + parsed.GetProperty("ReportColumns").GetString() ?? string.Empty, + parsed.GetProperty("ReportViewName").GetString() ?? string.Empty); + + return scoresheet; + } + + private static void RebuildScoresheet(Scoresheet scoresheet, CreateScoresheetDto dto, string json, string scoresheetName) + { + var parsed = JsonSerializer.Deserialize(json, new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }); + + scoresheet.SetName(scoresheetName); + scoresheet.Title = dto.Title; + scoresheet.Version = parsed.GetProperty("Version").GetUInt32(); + scoresheet.SetReportingFields( + parsed.GetProperty("ReportKeys").GetString() ?? string.Empty, + parsed.GetProperty("ReportColumns").GetString() ?? string.Empty, + parsed.GetProperty("ReportViewName").GetString() ?? string.Empty); + + scoresheet.Sections.Clear(); + + foreach (var section in parsed.GetProperty("Sections").EnumerateArray()) + { + var sectionName = section.GetProperty("Name").GetString() ?? string.Empty; + var sectionOrder = section.GetProperty("Order").GetUInt32(); + var scoresheetSection = new ScoresheetSection(Guid.NewGuid(), sectionName, sectionOrder); + scoresheet.AddSection(scoresheetSection); + + foreach (var field in section.GetProperty("Fields").EnumerateArray()) + { + var question = new Question( + Guid.NewGuid(), + field.GetProperty("Name").GetString() ?? string.Empty, + field.GetProperty("Label").GetString() ?? string.Empty, + (Unity.Flex.Scoresheets.Enums.QuestionType)field.GetProperty("Type").GetInt32(), + field.GetProperty("Order").GetUInt32(), + field.TryGetProperty("Description", out var description) && description.ValueKind != JsonValueKind.Null + ? description.GetString() + : null, + field.TryGetProperty("Definition", out var definition) ? definition.GetString() : null); + question.SectionId = scoresheetSection.Id; + scoresheetSection.Fields.Add(question); + } + } + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateFormWorksheetJob.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateFormWorksheetJob.cs new file mode 100644 index 0000000000..a21847748a --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateFormWorksheetJob.cs @@ -0,0 +1,246 @@ +using Microsoft.Extensions.Logging; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using Unity.AI; +using Unity.AI.Domain; +using Unity.AI.Requests; +using Unity.GrantManager.ApplicationForms; +using Unity.GrantManager.Applications; +using Unity.GrantManager.Flex; +using Unity.Flex.Domain.WorksheetLinks; +using Unity.Flex.Domain.Worksheets; +using Unity.Flex.Worksheets; +using Unity.Modules.Shared.Correlation; +using Unity.AI.RateLimit; +using Volo.Abp.BackgroundJobs; +using Volo.Abp.DependencyInjection; +using Volo.Abp.Domain.Repositories; +using Volo.Abp.MultiTenancy; +using Volo.Abp.Uow; + +namespace Unity.GrantManager.GrantApplications.Automation.BackgroundJobs; + +public class GenerateFormWorksheetJob( + IApplicationFormVersionRepository applicationFormVersionRepository, + IApplicationFormRepository applicationFormRepository, + IWorksheetRepository worksheetRepository, + IWorksheetLinkRepository worksheetLinkRepository, + IAIService aiService, + IRepository generationRequestRepository, + IRepository operationRepository, + ICurrentTenant currentTenant, + IUnitOfWorkManager unitOfWorkManager, + IAIRateLimiter aiRateLimiter, + ILogger logger) : AsyncBackgroundJob, ITransientDependency +{ + public override async Task ExecuteAsync(GenerateFormWorksheetBackgroundJobArgs args) + { + using var logScope = AIGenerationLogScope.Begin( + logger, + AIGenerationRequestKeyHelper.FormWorksheetOperationType, + args.ApplicationId, + args.TenantId, + args.PromptVersion, + args.RequestedByUserId); + + using (currentTenant.Change(args.TenantId)) + { + await AIGenerationRequestJobHelper.MarkRunningInNewUowAsync( + unitOfWorkManager, + generationRequestRepository, + operationRepository, + args.TenantId, + args.ApplicationId, + AIGenerationRequestKeyHelper.FormWorksheetOperationType); + try + { + var formVersion = await applicationFormVersionRepository.GetAsync(args.ApplicationFormVersionId); + var applicationForm = await applicationFormRepository.GetAsync(formVersion.ApplicationFormId); + var worksheetName = BuildWorksheetName(formVersion.Id, applicationForm.Id); + var existingWorksheet = await worksheetRepository.GetByNameAsync(worksheetName, true) + ?? (await worksheetRepository.GetListOrderedAsync(formVersion.Id, CorrelationConsts.FormVersion, includeDetails: true)).FirstOrDefault(); + + List worksheetSnapshots = []; + if (existingWorksheet != null) + { + worksheetSnapshots.Add(existingWorksheet); + } + var promptData = new + { + applicationFormVersionId = formVersion.Id, + chefsFormVersionGuid = formVersion.ChefsFormVersionGuid, + applicationFormId = applicationForm.Id, + formName = applicationForm.ApplicationFormName, + scoresheetId = applicationForm.ScoresheetId, + existingWorksheets = worksheetSnapshots.Select(worksheet => new + { + worksheet.Id, + worksheet.Name, + worksheet.Title, + worksheet.Version, + worksheet.Published, + worksheet.ReportViewName, + sections = worksheet.Sections.Select(section => new + { + section.Name, + section.Order, + fields = section.Fields.Select(field => new + { + field.Name, + field.Key, + field.Label, + field.Type, + field.Order, + field.Enabled, + field.Definition + }) + }) + }) + }; + + var worksheetJson = await aiService.GenerateFormWorksheetAsync(new MappingSuggestionRequest + { + Data = JsonSerializer.SerializeToElement(promptData), + PromptVersion = args.PromptVersion + }); + + var createDto = ParseWorksheetDefinition(worksheetJson); + var worksheet = existingWorksheet ?? await worksheetRepository.InsertAsync(BuildWorksheet(createDto, worksheetName)); + RebuildWorksheet(worksheet, createDto); + worksheet.SetPublished(true); + await worksheetRepository.UpdateAsync(worksheet); + + await UpsertWorksheetLinkAsync(worksheet.Id, formVersion.Id); + + await AIGenerationRequestJobHelper.StampRateLimitBestEffortAsync(aiRateLimiter, logger, args.RequestedByUserId, args.ApplicationId, AIGenerationRequestKeyHelper.FormWorksheetOperationType); + await AIGenerationRequestJobHelper.MarkCompletedInNewUowAsync( + unitOfWorkManager, + generationRequestRepository, + operationRepository, + args.TenantId, + args.ApplicationId, + AIGenerationRequestKeyHelper.FormWorksheetOperationType); + } + catch (Exception ex) + { + await AIGenerationRequestJobHelper.MarkFailedInNewUowAsync( + unitOfWorkManager, + generationRequestRepository, + operationRepository, + args.TenantId, + args.ApplicationId, + AIGenerationRequestKeyHelper.FormWorksheetOperationType, + ex.Message); + throw; + } + } + } + + private static CreateWorksheetDto ParseWorksheetDefinition(string json) + { + if (string.IsNullOrWhiteSpace(json)) + { + throw new InvalidOperationException("Worksheet generation returned empty content."); + } + + var dto = JsonSerializer.Deserialize(json, new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }); + + return dto ?? throw new InvalidOperationException("Worksheet generation returned an unusable worksheet definition."); + } + + private static string BuildWorksheetName(Guid formVersionId, Guid formId) + { + return $"ai-form-{formId}-version-{formVersionId}-worksheet"; + } + + private static Worksheet BuildWorksheet(CreateWorksheetDto dto, string worksheetName) + { + var worksheet = new Worksheet(Guid.NewGuid(), worksheetName, dto.Title) + { + ReportColumns = dto.ReportColumns, + ReportKeys = dto.ReportKeys, + ReportViewName = dto.ReportViewName + }; + + worksheet.SetVersion(dto.Version); + worksheet.SetPublished(dto.Published); + + foreach (var section in dto.Sections.OrderBy(s => s.Order)) + { + var worksheetSection = new WorksheetSection(Guid.NewGuid(), section.Name).SetOrder(section.Order); + worksheetSection.Worksheet = worksheet; + worksheet.AddSection(worksheetSection); + + foreach (var field in section.Fields) + { + var customField = new CustomField( + Guid.NewGuid(), + field.Key, + worksheet.Name, + field.Label, + field.Type, + field.Definition); + customField.Section = worksheetSection; + worksheetSection.Fields.Add(customField); + } + } + + return worksheet; + } + + private static void RebuildWorksheet(Worksheet worksheet, CreateWorksheetDto dto) + { + worksheet.SetName(worksheet.Name); + worksheet.SetTitle(dto.Title); + worksheet.SetVersion(dto.Version); + worksheet.SetPublished(dto.Published); + worksheet.SetReportingFields(dto.ReportKeys, dto.ReportColumns, dto.ReportViewName); + + worksheet.Sections.Clear(); + + foreach (var section in dto.Sections.OrderBy(s => s.Order)) + { + var worksheetSection = new WorksheetSection(Guid.NewGuid(), section.Name).SetOrder(section.Order); + worksheetSection.Worksheet = worksheet; + worksheet.AddSection(worksheetSection); + + foreach (var field in section.Fields) + { + var customField = new CustomField( + Guid.NewGuid(), + field.Key, + worksheet.Name, + field.Label, + field.Type, + field.Definition); + customField.Section = worksheetSection; + worksheetSection.Fields.Add(customField); + } + } + } + + private async Task UpsertWorksheetLinkAsync(Guid worksheetId, Guid correlationId) + { + var existingLink = await worksheetLinkRepository.GetExistingLinkAsync(worksheetId, correlationId, CorrelationConsts.FormVersion); + if (existingLink != null) + { + existingLink.SetAnchor(FlexConsts.CustomTab).SetOrder(1); + await worksheetLinkRepository.UpdateAsync(existingLink); + return; + } + + await worksheetLinkRepository.InsertAsync(new WorksheetLink( + Guid.NewGuid(), + worksheetId, + correlationId, + CorrelationConsts.FormVersion, + FlexConsts.CustomTab, + 1)); + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/GrantApplications/AIGenerationRequestKeyHelper.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/GrantApplications/AIGenerationRequestKeyHelper.cs index d478b3af4f..bcffaffee1 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/GrantApplications/AIGenerationRequestKeyHelper.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/GrantApplications/AIGenerationRequestKeyHelper.cs @@ -8,6 +8,9 @@ public static class AIGenerationRequestKeyHelper public const string ApplicationAnalysisOperationType = "application-analysis"; public const string ApplicationScoringOperationType = "application-scoring"; public const string PipelineOperationType = "pipeline"; + public const string FormMappingOperationType = "form-mapping"; + public const string FormWorksheetOperationType = "form-worksheet"; + public const string FormScoresheetOperationType = "form-scoresheet"; public static string BuildRequestKey(Guid? tenantId, Guid applicationId, string operationType) { @@ -31,6 +34,9 @@ public static string BuildRequestKey(Guid? tenantId, Guid applicationId, string ApplicationAnalysisOperationType => "ApplicationAnalysis", AttachmentSummaryOperationType => "AttachmentSummary", ApplicationScoringOperationType => "ApplicationScoring", + FormMappingOperationType => "FormMapping", + FormWorksheetOperationType => "FormWorksheet", + FormScoresheetOperationType => "FormScoresheet", PipelineOperationType => "Default", _ => null }; diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationForms/Mapping.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationForms/Mapping.cshtml index c3005b32b6..f3e9d9ed50 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationForms/Mapping.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationForms/Mapping.cshtml @@ -1,10 +1,11 @@ @page @using Unity.GrantManager.ApplicationForms -@using Unity.Reporting.Permissions -@using Volo.Abp.AspNetCore.Mvc.UI.Layout; -@using Unity.GrantManager.Web.Pages.ApplicationForms; -@using Unity.GrantManager.Permissions; -@using Unity.Notifications.Permissions; +@using Unity.Reporting.Permissions +@using Volo.Abp.AspNetCore.Mvc.UI.Layout; +@using Unity.GrantManager.Web.Pages.ApplicationForms; +@using Unity.GrantManager.Permissions; +@using Unity.Notifications.Permissions; +@using Unity.AI.Permissions; @using Volo.Abp.Authorization.Permissions; @using Unity.GrantManager.Web.Views.Shared.Components.Notifications; @@ -23,11 +24,13 @@ } @section scripts { - - - - -} + + + + + + +} @section styles { @@ -107,14 +110,17 @@ icon="fl fl-edit" class="btn unt-btn-primary btn-primary" data-toggle="modal" style="pointer-events: all;" abp-tooltip="Edit the Mapping JSON Manually" button-type="Primary" /> - + @if (await PermissionChecker.IsGrantedAsync(AIPermissions.Analysis.GenerateFormMapping)) + { + + } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationForms/Mapping.cshtml.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationForms/Mapping.cshtml.cs index e6648c931f..19d14879ba 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationForms/Mapping.cshtml.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationForms/Mapping.cshtml.cs @@ -106,7 +106,12 @@ public async Task OnGetAsync() private async Task> GenerateMappingFieldsAsync() { - var readModel = await mappingReadService.GetAsync(ApplicationFormVersionDto?.Id ?? Guid.Empty); + if (ApplicationFormVersionDto?.Id is not Guid formVersionId || formVersionId == Guid.Empty) + { + return []; + } + + var readModel = await mappingReadService.GetAsync(formVersionId); var properties = readModel.ChefsFields .Select(field => new MapField { diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationForms/Mapping.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationForms/Mapping.js index 0180c19194..e7bddb566c 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationForms/Mapping.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationForms/Mapping.js @@ -57,7 +57,9 @@ btnBack: $('#btn-back'), btnSave: $('#btn-save'), btnEdit: $('#btn-edit'), - btnSuggest: $('#btn-suggest'), + btnGenerate: $('#btn-generate'), + btnGenerateWorksheet: $('#btn-generate-worksheet'), + btnGenerateScoresheet: $('#btn-generate-scoresheet'), btnSync: $('#btn-sync'), btnReset: $('#btn-reset'), btnClose: $('.btn-close'), @@ -98,12 +100,14 @@ UIElements.btnSaveMapping.on('click', handleSaveEditMapping); UIElements.btnSync.on('click', handleSync); UIElements.btnEdit.on('click', handleEdit); - UIElements.btnSuggest.on('click', handleSuggest); + UIElements.btnGenerate.on('click', queueFormMapping); + UIElements.btnGenerateWorksheet.on('click', queueFormWorksheet); + UIElements.btnGenerateScoresheet.on('click', queueFormScoresheet); UIElements.btnReset.on('click', handleReset); UIElements.btnCancel.on('click', handleCancelMapping); UIElements.btnClose.on('click', handleCancelMapping); - UIElements.inputSearchBar.on('keyup', handleSeearchBar); - UIElements.selectVersionList.on('change', handleSelectVersion); + UIElements.inputSearchBar.on('keyup', handleSeearchBar); + UIElements.selectVersionList.on('change', handleSelectVersion); UIElements.mappingTab.on('click', handleMappingTabClick); // Persist active tab to localStorage on switch @@ -153,33 +157,272 @@ UIElements.editMappingModal.addClass('display-modal'); } - function handleSuggest() { + function queueFormMapping(triggerButton = null) { const formVersion = String(document.getElementById('formVersionId')?.value ?? '').trim(); + const applicationId = String(document.getElementById('applicationFormId')?.value ?? '').trim(); if (!validateGuid(formVersion)) { abp.notify.error('', 'The Form Version ID is not in a GUID format'); return; } + if (!validateGuid(applicationId)) { + abp.notify.error('', 'The Application ID is not in a GUID format'); + return; + } + + const buttonElement = triggerButton?.currentTarget || triggerButton?.target || triggerButton || UIElements.btnGenerate?.get?.(0); + const $button = $(buttonElement); + const existingHtml = $button.html(); + + if ($button.prop('disabled')) { + return; + } + + globalThis.AIGenerationButtonState?.setGenerating($button); + + abp.ajax({ + url: `/api/app/ai/generation/form-mapping?applicationId=${encodeURIComponent(applicationId)}&applicationFormVersionId=${encodeURIComponent(formVersion)}`, + type: 'POST', + }) + .done(function (generationStatus) { + const request = generationStatus?.generationRequest; + const status = globalThis.AIGenerationButtonState?.resolveStatus(request?.status) ?? ''; + + if (status === 'Completed') { + globalThis.AIGenerationButtonState?.restoreForCooldownCheck($button, existingHtml); + globalThis.AIGenerationButtonState?.applyStatusState(generationStatus); + refreshMappingAfterGeneration(applicationId, formVersion); + return; + } + + monitorFormMappingGeneration(applicationId, $button, existingHtml); + }) + .fail(function () { + abp.message.error('Failed to queue AI mapping generation. Please try again.'); + restoreGenerateMappingButton($button, existingHtml); + globalThis.syncAIRateLimitButtons?.(); + }); + } + + function queueFormWorksheet(triggerButton = null) { + const formVersion = String(document.getElementById('formVersionId')?.value ?? '').trim(); + const applicationId = String(document.getElementById('applicationFormId')?.value ?? '').trim(); + if (!validateGuid(formVersion) || !validateGuid(applicationId)) { + abp.notify.error('', 'The Form Version ID or Application ID is not in a GUID format'); + return; + } + + const buttonElement = triggerButton?.currentTarget || triggerButton?.target || triggerButton || UIElements.btnGenerateWorksheet?.get?.(0); + const $button = $(buttonElement); + const existingHtml = $button.html(); + + if ($button.prop('disabled')) { + return; + } + + globalThis.AIGenerationButtonState?.setGenerating($button); - setSuggestButtonState(true); - $.ajax({ - url: `/api/app/application-form-version/${formVersion}/suggest-mapping`, + abp.ajax({ + url: `/api/app/ai/generation/form-worksheet?applicationId=${encodeURIComponent(applicationId)}&applicationFormVersionId=${encodeURIComponent(formVersion)}`, type: 'POST', - success: function (data) { - $('#jsonText').val(prettyJson(JSON.stringify(data))); - UIElements.editMappingModal.addClass('display-modal'); + }) + .done(function (generationStatus) { + const request = generationStatus?.generationRequest; + const status = globalThis.AIGenerationButtonState?.resolveStatus(request?.status) ?? ''; + if (status === 'Completed') { + globalThis.AIGenerationButtonState?.restoreForCooldownCheck($button, existingHtml); + globalThis.AIGenerationButtonState?.applyStatusState(generationStatus); + refreshWorksheetAfterGeneration(); + return; + } + + monitorFormWorksheetGeneration(applicationId, $button, existingHtml); + }) + .fail(function () { + abp.message.error('Failed to queue AI worksheet generation. Please try again.'); + restoreGenerateWorksheetButton($button, existingHtml); + globalThis.syncAIRateLimitButtons?.(); + }); + } + + function queueFormScoresheet(triggerButton = null) { + const formVersion = String(document.getElementById('formVersionId')?.value ?? '').trim(); + const applicationId = String(document.getElementById('applicationFormId')?.value ?? '').trim(); + if (!validateGuid(formVersion) || !validateGuid(applicationId)) { + abp.notify.error('', 'The Form Version ID or Application ID is not in a GUID format'); + return; + } + + const buttonElement = triggerButton?.currentTarget || triggerButton?.target || triggerButton || UIElements.btnGenerateScoresheet?.get?.(0); + const $button = $(buttonElement); + const existingHtml = $button.html(); + + if ($button.prop('disabled')) { + return; + } + + globalThis.AIGenerationButtonState?.setGenerating($button); + + abp.ajax({ + url: `/api/app/ai/generation/form-scoresheet?applicationId=${encodeURIComponent(applicationId)}&applicationFormVersionId=${encodeURIComponent(formVersion)}`, + type: 'POST', + }) + .done(function (generationStatus) { + const request = generationStatus?.generationRequest; + const status = globalThis.AIGenerationButtonState?.resolveStatus(request?.status) ?? ''; + if (status === 'Completed') { + globalThis.AIGenerationButtonState?.restoreForCooldownCheck($button, existingHtml); + globalThis.AIGenerationButtonState?.applyStatusState(generationStatus); + refreshScoresheetAfterGeneration(); + return; + } + + monitorFormScoresheetGeneration(applicationId, $button, existingHtml); + }) + .fail(function () { + abp.message.error('Failed to queue AI scoresheet generation. Please try again.'); + restoreGenerateScoresheetButton($button, existingHtml); + globalThis.syncAIRateLimitButtons?.(); + }); + } + + function monitorFormWorksheetGeneration(applicationId, $button, existingHtml) { + globalThis.AIGenerationButtonState?.monitor({ + $button, + originalHtml: existingHtml, + getStatus: () => abp.ajax({ + url: `/api/app/ai/generation/status?applicationId=${encodeURIComponent(applicationId)}&operationType=form-worksheet`, + type: 'GET' + }), + onComplete: function () { + refreshWorksheetAfterGeneration(); }, - error: function () { - abp.notify.error('', 'Failed to load mapping suggestion.'); + onFailed: function (request) { + abp.message.error(request?.failureReason || 'AI worksheet generation failed.'); }, - complete: function () { - setSuggestButtonState(false); + onPollFailed: function () { + abp.message.error('Unable to load AI worksheet generation status. Please try again.'); } }); } - function setSuggestButtonState(isGenerating) { - UIElements.btnSuggest.prop('disabled', isGenerating); - UIElements.btnSuggest.find('span').last().text(isGenerating ? 'Generating...' : 'Generate Mapping'); + function monitorFormScoresheetGeneration(applicationId, $button, existingHtml) { + globalThis.AIGenerationButtonState?.monitor({ + $button, + originalHtml: existingHtml, + getStatus: () => abp.ajax({ + url: `/api/app/ai/generation/status?applicationId=${encodeURIComponent(applicationId)}&operationType=form-scoresheet`, + type: 'GET' + }), + onComplete: function () { + refreshScoresheetAfterGeneration(); + }, + onFailed: function (request) { + abp.message.error(request?.failureReason || 'AI scoresheet generation failed.'); + }, + onPollFailed: function () { + abp.message.error('Unable to load AI scoresheet generation status. Please try again.'); + } + }); + } + + function refreshWorksheetAfterGeneration() { + abp.notify.success('', 'Worksheet generated and assigned successfully. Reloading page.'); + setTimeout(function () { + globalThis.location.reload(); + }, 500); + } + + function refreshScoresheetAfterGeneration() { + abp.notify.success('', 'Scoresheet generated and assigned successfully. Reloading page.'); + setTimeout(function () { + globalThis.location.reload(); + }, 500); + } + + function monitorFormMappingGeneration(applicationId, $button, existingHtml) { + globalThis.AIGenerationButtonState?.monitor({ + $button, + originalHtml: existingHtml, + getStatus: () => abp.ajax({ + url: `/api/app/ai/generation/status?applicationId=${encodeURIComponent(applicationId)}&operationType=form-mapping`, + type: 'GET' + }), + onComplete: function () { + refreshMappingAfterGeneration(applicationId); + }, + onFailed: function (request) { + abp.message.error(request?.failureReason || 'AI mapping generation failed.'); + }, + onPollFailed: function () { + abp.message.error('Unable to load AI mapping generation status. Please try again.'); + } + }); + } + + function refreshMappingAfterGeneration(applicationId, formVersion = null) { + const resolvedFormVersion = String(formVersion ?? document.getElementById('formVersionId')?.value ?? '').trim(); + if (!validateGuid(resolvedFormVersion)) { + abp.notify.error('', 'Unable to refresh the generated mapping because the Form Version ID is invalid.'); + return; + } + + abp.ajax({ + url: `/api/app/application-form-version/${encodeURIComponent(resolvedFormVersion)}`, + type: 'GET' + }) + .done(function (applicationFormVersionDto) { + const availableChefsFields = applicationFormVersionDto?.availableChefsFields + ? JSON.parse(applicationFormVersionDto.availableChefsFields) + : []; + + $('#applicationFormVersionDtoString').val(JSON.stringify(applicationFormVersionDto ?? {})); + $('#availableChefsFields').val(applicationFormVersionDto?.availableChefsFields ?? ''); + $('#existingMapping').val(applicationFormVersionDto?.submissionHeaderMapping ?? ''); + + existingMappingString = applicationFormVersionDto?.submissionHeaderMapping ?? ''; + availableChefFieldsString = applicationFormVersionDto?.availableChefsFields ?? ''; + + $(intakeMapColumn).empty(); + $(worksheetMapColumn).empty(); + dataTable.clear().draw(); + initializeIntakeMap(availableChefsFields); + bindExistingMaps(); + + abp.notify.success('', 'Form mapping generated and saved successfully.'); + }) + .fail(function () { + abp.notify.error('', 'Form mapping generated, but the page could not refresh the saved mapping.'); + }); + } + + function restoreGenerateMappingButton($button, existingHtml) { + if (!$button?.length) { + return; + } + + globalThis.AIGenerationButtonState?.restore($button); + $button.html(existingHtml).prop('disabled', false); + $button.find('span').last().text('Generate Mapping'); + } + + function restoreGenerateWorksheetButton($button, existingHtml) { + if (!$button?.length) { + return; + } + + globalThis.AIGenerationButtonState?.restore($button); + $button.html(existingHtml).prop('disabled', false); + $button.find('span').last().text('Generate Worksheet'); + } + + function restoreGenerateScoresheetButton($button, existingHtml) { + if (!$button?.length) { + return; + } + + globalThis.AIGenerationButtonState?.restore($button); + $button.html(existingHtml).prop('disabled', false); + $button.find('span').last().text('Generate Scoresheet'); } function handleSaveEditMapping() { diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/CustomFields/Default.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/CustomFields/Default.cshtml index 03e84b9a4f..dfbb24807c 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/CustomFields/Default.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/CustomFields/Default.cshtml @@ -1,4 +1,6 @@ -@using Unity.GrantManager.Web.Views.Shared.Components.CustomFields @* Suppress:S1128 *@ +@using Unity.GrantManager.Web.Views.Shared.Components.CustomFields @* Suppress:S1128 *@ +@using Unity.AI.Permissions +@inject Volo.Abp.Authorization.Permissions.IPermissionChecker PermissionChecker @model CustomFieldsViewModel @{ @@ -20,6 +22,28 @@ abp-tooltip="Save the worksheet associations for mapping to the available Unity worksheet fields" button-type="Primary" form="worksheet-config-form" /> + @if (await PermissionChecker.IsGrantedAsync(AIPermissions.Analysis.GenerateFormScoresheet)) + { + + } + @if (await PermissionChecker.IsGrantedAsync(AIPermissions.Analysis.GenerateFormWorksheet)) + { + + } >(); + var formVersion = new ApplicationFormVersion + { + ApplicationFormId = Guid.NewGuid(), + SubmissionHeaderMapping = "{}" + }; + repository.GetAsync(formVersionId).Returns(formVersion); + repository.UpdateAsync(formVersion, true).Returns(formVersion); + + var readService = Substitute.For(); + readService.GetAsync(formVersionId).Returns(new ApplicationFormMappingReadModelDto + { + ApplicationFormVersionId = formVersionId, + ApplicationFormId = formVersion.ApplicationFormId, + ChefsApplicationFormGuid = "chefs-form", + ChefsFormVersionGuid = "chefs-version", + ChefsFields = new List + { + new() { Name = "ProjectName", Label = "Project Name", Type = "Text", IsCustom = false } + }, + UnityCoreFields = new List + { + new() { Name = "ProjectName", Label = "Project Name", Type = "String", IsCustom = false } + } + }); + + var aiService = Substitute.For(); + aiService.GenerateFormMappingAsync(Arg.Do(request => capturedRequest = request), Arg.Any()) + .Returns(new MappingSuggestionResponse + { + CoreFieldMatches = + [ + new MappingSuggestionItemResponse + { + SourceField = "ProjectName", + TargetField = "ProjectName", + Reason = "same meaning", + Confidence = 0.99M + } + ] + }); + + var service = CreateService(repository, readService, aiService); + + var result = await service.GenerateMappingAsync(formVersionId); + + result.ApplicationFormVersionId.ShouldBe(formVersionId); + result.CoreFieldMatches.Count.ShouldBe(1); + capturedRequest.ShouldNotBeNull(); + capturedRequest!.Data.GetProperty("ChefsFields").ValueKind.ShouldBe(System.Text.Json.JsonValueKind.Array); + capturedRequest.Data.GetProperty("UnityCoreFields").ValueKind.ShouldBe(System.Text.Json.JsonValueKind.Array); + capturedRequest.Data.GetProperty("Worksheets").ValueKind.ShouldBe(System.Text.Json.JsonValueKind.Array); + formVersion.SubmissionHeaderMapping.ShouldBe("""{"ProjectName":"ProjectName"}"""); + await repository.Received(1).UpdateAsync(formVersion, true); + } + + private static ApplicationFormVersionAppService CreateService( + IRepository repository, + IApplicationFormVersionMappingReadService mappingReadService, + IAIService aiService) + { + var service = new ApplicationFormVersionAppService( + repository, + Substitute.For(), + Substitute.For(), + Substitute.For(), + Substitute.For(), + Substitute.For(), + Substitute.For(), + Substitute.For(), + mappingReadService, + aiService); + return service; + } +} diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantApplications/Automation/AIGenerationQueueTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantApplications/Automation/AIGenerationQueueTests.cs index a1668c20b9..924789b18c 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantApplications/Automation/AIGenerationQueueTests.cs +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantApplications/Automation/AIGenerationQueueTests.cs @@ -33,6 +33,8 @@ public class AIGenerationQueueTests(ITestOutputHelper outputHelper) : GrantManag private static readonly Guid AttachmentSummaryOperationId = Guid.Parse("11111111-1111-1111-1111-111111111111"); private static readonly Guid ApplicationAnalysisOperationId = Guid.Parse("22222222-2222-2222-2222-222222222222"); private static readonly Guid ApplicationScoringOperationId = Guid.Parse("33333333-3333-3333-3333-333333333333"); + private static readonly Guid FormMappingOperationId = Guid.Parse("44444444-4444-4444-4444-444444444444"); + private static readonly Guid FormWorksheetOperationId = Guid.Parse("55555555-5555-5555-5555-555555555555"); [Fact] public async Task QueueAllAIStagesAsync_Should_Enqueue_Pipeline_Job_When_None_Exists() { @@ -307,6 +309,51 @@ await repository.Received(1).InsertAsync(Arg.Is(r => r.Status == AIGenerationRequestStatus.Queued), Arg.Any(), Arg.Any()); } + [Fact] + public async Task QueueFormWorksheetAsync_Should_Enqueue_New_Request_When_None_Exists() + { + var applicationId = Guid.NewGuid(); + var applicationFormVersionId = Guid.NewGuid(); + var tenantId = Guid.NewGuid(); + var promptVersion = "v2"; + var repository = Substitute.For>(); + repository.GetQueryableAsync().Returns(Task.FromResult>(Array.Empty().AsQueryable())); + repository.InsertAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(callInfo => Task.FromResult(callInfo.Arg())); + + GenerateFormWorksheetBackgroundJobArgs? capturedArgs = null; + var backgroundJobManager = Substitute.For(); + backgroundJobManager.EnqueueAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(callInfo => + { + capturedArgs = callInfo.Arg(); + return Task.FromResult(string.Empty); + }); + + var prerequisiteValidator = Substitute.For(); + prerequisiteValidator.EnsureFormWorksheetAvailableAsync(applicationFormVersionId).Returns(Task.CompletedTask); + + var queue = CreateQueue( + backgroundJobManager, + repository, + prerequisiteValidator: prerequisiteValidator, + operationRepository: CreateOperationRepository(), + asyncQueryableExecuter: Substitute.For()); + + await queue.QueueFormWorksheetAsync(applicationId, tenantId, applicationFormVersionId, promptVersion); + + capturedArgs.ShouldNotBeNull(); + capturedArgs!.ApplicationId.ShouldBe(applicationId); + capturedArgs.ApplicationFormVersionId.ShouldBe(applicationFormVersionId); + capturedArgs.TenantId.ShouldBe(tenantId); + capturedArgs.PromptVersion.ShouldBe(promptVersion); + capturedArgs.RequestedByUserId.ShouldBe(CreateQueueCurrentUserId); + await repository.Received(1).InsertAsync(Arg.Is(r => + r.ApplicationId == applicationId && + r.TenantId == tenantId && + r.Status == AIGenerationRequestStatus.Queued), Arg.Any(), Arg.Any()); + } + private sealed class TestDistributedLockProvider : IDistributedLockProvider { public IDistributedLock CreateLock(string name) => new TestDistributedLock(name); @@ -408,7 +455,9 @@ private static IRepository CreateOperationRepository() { new(AttachmentSummaryOperationId, "AttachmentSummary", Guid.NewGuid(), Guid.NewGuid()) { IsActive = true }, new(ApplicationAnalysisOperationId, "ApplicationAnalysis", Guid.NewGuid(), Guid.NewGuid()) { IsActive = true }, - new(ApplicationScoringOperationId, "ApplicationScoring", Guid.NewGuid(), Guid.NewGuid()) { IsActive = true } + new(ApplicationScoringOperationId, "ApplicationScoring", Guid.NewGuid(), Guid.NewGuid()) { IsActive = true }, + new(FormMappingOperationId, "FormMapping", Guid.NewGuid(), Guid.NewGuid()) { IsActive = true }, + new(FormWorksheetOperationId, "FormWorksheet", Guid.NewGuid(), Guid.NewGuid()) { IsActive = true } }; var repository = Substitute.For>(); From 76599d6220caea397e5d4fe1952627d0ab655b33 Mon Sep 17 00:00:00 2001 From: Jacob Smith Date: Fri, 10 Jul 2026 13:30:17 -0700 Subject: [PATCH 059/223] AB#33569 fix form generation review issues --- .../AIGenerationPrerequisiteValidator.cs | 12 -- .../Generation/AIGenerationAppService.cs | 19 +-- .../ApplicationFormVersionAppService.cs | 32 +--- .../MappingSuggestionResponseMapper.cs | 38 +++++ .../BackgroundJobs/GenerateFormMappingJob.cs | 31 +--- .../GenerateFormScoresheetJob.cs | 156 +++++++++++++----- .../GenerateFormWorksheetJob.cs | 31 ++-- 7 files changed, 180 insertions(+), 139 deletions(-) create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicationForms/Mapping/MappingSuggestionResponseMapper.cs diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/AIGenerationPrerequisiteValidator.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/AIGenerationPrerequisiteValidator.cs index be6ba44f1d..f7abdbf0b2 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/AIGenerationPrerequisiteValidator.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/AIGenerationPrerequisiteValidator.cs @@ -3,7 +3,6 @@ using System.Linq; using System.Threading.Tasks; using Unity.Flex.Domain.Scoresheets; -using Unity.Flex.Domain.Worksheets; using Unity.AI.Localization; using Unity.GrantManager.Applications; using Unity.Modules.Shared.Correlation; @@ -20,7 +19,6 @@ public class AIGenerationPrerequisiteValidator( IApplicationFormSubmissionRepository applicationFormSubmissionRepository, IApplicationChefsFileAttachmentRepository applicationChefsFileAttachmentRepository, IScoresheetRepository scoresheetRepository, - IWorksheetListRepository worksheetListRepository, IAsyncQueryableExecuter asyncExecuter, IStringLocalizer localizer) : IAIGenerationPrerequisiteValidator, ITransientDependency { @@ -75,16 +73,6 @@ public async Task EnsureFormWorksheetAvailableAsync(Guid applicationFormVersionI { throw new UserFriendlyException(localizer[AILocalizationKeys.FormWorksheetRequiresFormVersion]); } - - var worksheetLinks = await worksheetListRepository.GetListByCorrelationAsync( - formVersion.Id, - CorrelationConsts.FormVersion, - true); - - if (worksheetLinks == null) - { - throw new UserFriendlyException(localizer[AILocalizationKeys.FormWorksheetRequiresFormVersion]); - } } public async Task EnsureFormScoresheetAvailableAsync(Guid applicationFormVersionId) diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Generation/AIGenerationAppService.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Generation/AIGenerationAppService.cs index bbb06119e5..524a51481b 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Generation/AIGenerationAppService.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Generation/AIGenerationAppService.cs @@ -28,13 +28,6 @@ public class AIGenerationAppService( ICurrentTenant currentTenant) : AIAppService, IAIGenerationAppService { - private const string ApplicationAnalysisOperationType = "application-analysis"; - private const string AttachmentSummaryOperationType = "attachment-summary"; - private const string ApplicationScoringOperationType = "application-scoring"; - private const string FormMappingOperationType = "form-mapping"; - private const string FormWorksheetOperationType = "form-worksheet"; - private const string FormScoresheetOperationType = "form-scoresheet"; - [Authorize(AIPermissions.Analysis.GenerateAttachmentSummaries)] [HttpPost("attachment-summary")] public virtual async Task> GenerateAttachmentSummariesAsync(GenerateAttachmentSummariesInputDto input) @@ -153,12 +146,12 @@ private async Task EnsureStatusAccessAsync(string operationType) { var permission = operationType switch { - ApplicationAnalysisOperationType => AIPermissions.Analysis.ViewApplicationAnalysis, - AttachmentSummaryOperationType => AIPermissions.Analysis.ViewAttachmentSummary, - ApplicationScoringOperationType => AIPermissions.Analysis.ViewScoringResult, - FormMappingOperationType => AIPermissions.Analysis.ViewFormMapping, - FormWorksheetOperationType => AIPermissions.Analysis.ViewFormWorksheet, - FormScoresheetOperationType => AIPermissions.Analysis.ViewFormScoresheet, + AIGenerationRequestKeyHelper.ApplicationAnalysisOperationType => AIPermissions.Analysis.ViewApplicationAnalysis, + AIGenerationRequestKeyHelper.AttachmentSummaryOperationType => AIPermissions.Analysis.ViewAttachmentSummary, + AIGenerationRequestKeyHelper.ApplicationScoringOperationType => AIPermissions.Analysis.ViewScoringResult, + AIGenerationRequestKeyHelper.FormMappingOperationType => AIPermissions.Analysis.ViewFormMapping, + AIGenerationRequestKeyHelper.FormWorksheetOperationType => AIPermissions.Analysis.ViewFormWorksheet, + AIGenerationRequestKeyHelper.FormScoresheetOperationType => AIPermissions.Analysis.ViewFormScoresheet, _ => throw new UserFriendlyException($"Unsupported AI generation operation type: {operationType}") }; diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicationForms/ApplicationFormVersionAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicationForms/ApplicationFormVersionAppService.cs index c8fe86695c..bfdbfa0420 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicationForms/ApplicationFormVersionAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicationForms/ApplicationFormVersionAppService.cs @@ -328,7 +328,7 @@ public virtual async Task GenerateMappingAs { Data = JsonSerializer.SerializeToElement(readModel) }); - var submissionHeaderMapping = BuildSubmissionHeaderMapping(response); + var submissionHeaderMapping = MappingSuggestionResponseMapper.BuildSubmissionHeaderMapping(response); var applicationFormVersion = await repository.GetAsync(id); applicationFormVersion.SubmissionHeaderMapping = JsonSerializer.Serialize(submissionHeaderMapping); await repository.UpdateAsync(applicationFormVersion, true); @@ -374,36 +374,6 @@ public virtual async Task GenerateMappingAs }; } - private static Dictionary BuildSubmissionHeaderMapping(Unity.AI.Responses.MappingSuggestionResponse response) - { - var mapping = new Dictionary(StringComparer.OrdinalIgnoreCase); - - foreach (var match in response.CoreFieldMatches) - { - AddMapping(mapping, match.SourceField, match.TargetField); - } - - foreach (var worksheetMatch in response.WorksheetMatches) - { - foreach (var match in worksheetMatch.FieldMatches) - { - AddMapping(mapping, match.SourceField, match.TargetField); - } - } - - return mapping; - } - - private static void AddMapping(Dictionary mapping, string? sourceField, string? targetField) - { - if (string.IsNullOrWhiteSpace(sourceField) || string.IsNullOrWhiteSpace(targetField)) - { - return; - } - - mapping[sourceField] = targetField; - } - private async Task GetVersion(Guid formVersionId) { var formVersion = await formVersionRepository.GetByChefsFormVersionAsync(formVersionId); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicationForms/Mapping/MappingSuggestionResponseMapper.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicationForms/Mapping/MappingSuggestionResponseMapper.cs new file mode 100644 index 0000000000..7f8dd664f7 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicationForms/Mapping/MappingSuggestionResponseMapper.cs @@ -0,0 +1,38 @@ +using System; +using System.Collections.Generic; +using Unity.AI.Responses; + +namespace Unity.GrantManager.ApplicationForms.Mapping; + +internal static class MappingSuggestionResponseMapper +{ + internal static Dictionary BuildSubmissionHeaderMapping(MappingSuggestionResponse response) + { + var mapping = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var match in response.CoreFieldMatches) + { + AddMapping(mapping, match.SourceField, match.TargetField); + } + + foreach (var worksheetMatch in response.WorksheetMatches) + { + foreach (var match in worksheetMatch.FieldMatches) + { + AddMapping(mapping, match.SourceField, match.TargetField); + } + } + + return mapping; + } + + private static void AddMapping(Dictionary mapping, string? sourceField, string? targetField) + { + if (string.IsNullOrWhiteSpace(sourceField) || string.IsNullOrWhiteSpace(targetField)) + { + return; + } + + mapping[sourceField] = targetField; + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateFormMappingJob.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateFormMappingJob.cs index ce9d06e697..5dc8e26172 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateFormMappingJob.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateFormMappingJob.cs @@ -58,7 +58,7 @@ await AIGenerationRequestJobHelper.MarkRunningInNewUowAsync( PromptVersion = args.PromptVersion }); - var submissionHeaderMapping = BuildSubmissionHeaderMapping(response); + var submissionHeaderMapping = MappingSuggestionResponseMapper.BuildSubmissionHeaderMapping(response); var applicationFormVersion = await applicationFormVersionRepository.GetAsync(args.ApplicationFormVersionId); applicationFormVersion.SubmissionHeaderMapping = JsonSerializer.Serialize(submissionHeaderMapping); await applicationFormVersionRepository.UpdateAsync(applicationFormVersion, true); @@ -87,33 +87,4 @@ await AIGenerationRequestJobHelper.MarkFailedInNewUowAsync( } } - private static Dictionary BuildSubmissionHeaderMapping(MappingSuggestionResponse response) - { - var mapping = new Dictionary(StringComparer.OrdinalIgnoreCase); - - foreach (var match in response.CoreFieldMatches) - { - AddMapping(mapping, match.SourceField, match.TargetField); - } - - foreach (var worksheetMatch in response.WorksheetMatches) - { - foreach (var match in worksheetMatch.FieldMatches) - { - AddMapping(mapping, match.SourceField, match.TargetField); - } - } - - return mapping; - } - - private static void AddMapping(Dictionary mapping, string? sourceField, string? targetField) - { - if (string.IsNullOrWhiteSpace(sourceField) || string.IsNullOrWhiteSpace(targetField)) - { - return; - } - - mapping[sourceField] = targetField; - } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateFormScoresheetJob.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateFormScoresheetJob.cs index 82804f4ae9..a880028201 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateFormScoresheetJob.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateFormScoresheetJob.cs @@ -31,6 +31,11 @@ public class GenerateFormScoresheetJob( IAIRateLimiter aiRateLimiter, ILogger logger) : AsyncBackgroundJob, ITransientDependency { + private static readonly JsonSerializerOptions CaseInsensitiveJsonOptions = new() + { + PropertyNameCaseInsensitive = true + }; + public override async Task ExecuteAsync(GenerateFormScoresheetBackgroundJobArgs args) { using var logScope = AIGenerationLogScope.Begin( @@ -106,10 +111,18 @@ await AIGenerationRequestJobHelper.MarkRunningInNewUowAsync( }); var importDto = ParseScoresheetDefinition(scoresheetJson); - var scoresheet = existingScoresheet ?? await scoresheetRepository.InsertAsync(BuildScoresheet(importDto, scoresheetJson, scoresheetName)); - RebuildScoresheet(scoresheet, importDto, scoresheetJson, scoresheetName); + var scoresheet = existingScoresheet == null + ? BuildScoresheet(importDto, scoresheetJson, scoresheetName) + : RebuildScoresheet(existingScoresheet, importDto, scoresheetJson, scoresheetName); scoresheet.Published = true; - await scoresheetRepository.UpdateAsync(scoresheet); + if (existingScoresheet == null) + { + await scoresheetRepository.InsertAsync(scoresheet); + } + else + { + await scoresheetRepository.UpdateAsync(scoresheet); + } applicationForm.ScoresheetId = scoresheet.Id; await applicationFormRepository.UpdateAsync(applicationForm); @@ -145,10 +158,7 @@ private static CreateScoresheetDto ParseScoresheetDefinition(string json) throw new InvalidOperationException("Scoresheet generation returned empty content."); } - var dto = JsonSerializer.Deserialize(json, new JsonSerializerOptions - { - PropertyNameCaseInsensitive = true - }); + var dto = JsonSerializer.Deserialize(json, CaseInsensitiveJsonOptions); return dto ?? throw new InvalidOperationException("Scoresheet generation returned an unusable scoresheet definition."); } @@ -161,89 +171,149 @@ private static string BuildScoresheetName(Guid formVersionId, Guid formId) private static Scoresheet BuildScoresheet(CreateScoresheetDto dto, string json, string scoresheetName) { var scoresheet = new Scoresheet(Guid.NewGuid(), dto.Title, scoresheetName); - var parsed = JsonSerializer.Deserialize(json, new JsonSerializerOptions - { - PropertyNameCaseInsensitive = true - }); - - if (!parsed.TryGetProperty("Version", out var versionElement) || versionElement.ValueKind != JsonValueKind.Number) + var parsed = ParseScoresheetElement(json); + if (!TryGetNumberProperty(parsed, "Version", out var version)) { throw new InvalidOperationException("Scoresheet generation returned a definition without a valid Version."); } - scoresheet.Version = versionElement.GetUInt32(); + scoresheet.Version = version; + + if (!parsed.TryGetProperty("Sections", out var sectionsElement) || sectionsElement.ValueKind != JsonValueKind.Array) + { + throw new InvalidOperationException("Scoresheet generation returned a definition without Sections."); + } - foreach (var section in parsed.GetProperty("Sections").EnumerateArray()) + foreach (var section in sectionsElement.EnumerateArray()) { - var sectionName = section.GetProperty("Name").GetString() ?? string.Empty; - var sectionOrder = section.GetProperty("Order").GetUInt32(); + var sectionName = GetRequiredStringProperty(section, "Name", "section"); + var sectionOrder = GetRequiredNumberProperty(section, "Order", "section"); var scoresheetSection = new ScoresheetSection(Guid.NewGuid(), sectionName, sectionOrder); scoresheet.AddSection(scoresheetSection); - foreach (var field in section.GetProperty("Fields").EnumerateArray()) + if (!section.TryGetProperty("Fields", out var fieldsElement) || fieldsElement.ValueKind != JsonValueKind.Array) + { + throw new InvalidOperationException($"Scoresheet generation returned section '{sectionName}' without Fields."); + } + + foreach (var field in fieldsElement.EnumerateArray()) { var question = new Question( Guid.NewGuid(), - field.GetProperty("Name").GetString() ?? string.Empty, - field.GetProperty("Label").GetString() ?? string.Empty, - (Unity.Flex.Scoresheets.Enums.QuestionType)field.GetProperty("Type").GetInt32(), - field.GetProperty("Order").GetUInt32(), + GetRequiredStringProperty(field, "Name", "field"), + GetRequiredStringProperty(field, "Label", "field"), + (Unity.Flex.Scoresheets.Enums.QuestionType)GetRequiredNumberProperty(field, "Type", "field"), + GetRequiredNumberProperty(field, "Order", "field"), field.TryGetProperty("Description", out var description) && description.ValueKind != JsonValueKind.Null ? description.GetString() : null, - field.TryGetProperty("Definition", out var definition) ? definition.GetString() : null); + field.TryGetProperty("Definition", out var definition) && definition.ValueKind != JsonValueKind.Null + ? definition.GetString() + : null); question.SectionId = scoresheetSection.Id; scoresheetSection.Fields.Add(question); } } scoresheet.SetReportingFields( - parsed.GetProperty("ReportKeys").GetString() ?? string.Empty, - parsed.GetProperty("ReportColumns").GetString() ?? string.Empty, - parsed.GetProperty("ReportViewName").GetString() ?? string.Empty); + GetRequiredStringProperty(parsed, "ReportKeys", "scoresheet"), + GetRequiredStringProperty(parsed, "ReportColumns", "scoresheet"), + GetRequiredStringProperty(parsed, "ReportViewName", "scoresheet")); return scoresheet; } - private static void RebuildScoresheet(Scoresheet scoresheet, CreateScoresheetDto dto, string json, string scoresheetName) + private static Scoresheet RebuildScoresheet(Scoresheet scoresheet, CreateScoresheetDto dto, string json, string scoresheetName) { - var parsed = JsonSerializer.Deserialize(json, new JsonSerializerOptions + var parsed = ParseScoresheetElement(json); + if (!TryGetNumberProperty(parsed, "Version", out var version)) { - PropertyNameCaseInsensitive = true - }); + throw new InvalidOperationException("Scoresheet generation returned a definition without a valid Version."); + } scoresheet.SetName(scoresheetName); scoresheet.Title = dto.Title; - scoresheet.Version = parsed.GetProperty("Version").GetUInt32(); + scoresheet.Version = version; scoresheet.SetReportingFields( - parsed.GetProperty("ReportKeys").GetString() ?? string.Empty, - parsed.GetProperty("ReportColumns").GetString() ?? string.Empty, - parsed.GetProperty("ReportViewName").GetString() ?? string.Empty); + GetRequiredStringProperty(parsed, "ReportKeys", "scoresheet"), + GetRequiredStringProperty(parsed, "ReportColumns", "scoresheet"), + GetRequiredStringProperty(parsed, "ReportViewName", "scoresheet")); scoresheet.Sections.Clear(); - foreach (var section in parsed.GetProperty("Sections").EnumerateArray()) + if (!parsed.TryGetProperty("Sections", out var sectionsElement) || sectionsElement.ValueKind != JsonValueKind.Array) { - var sectionName = section.GetProperty("Name").GetString() ?? string.Empty; - var sectionOrder = section.GetProperty("Order").GetUInt32(); + throw new InvalidOperationException("Scoresheet generation returned a definition without Sections."); + } + + foreach (var section in sectionsElement.EnumerateArray()) + { + var sectionName = GetRequiredStringProperty(section, "Name", "section"); + var sectionOrder = GetRequiredNumberProperty(section, "Order", "section"); var scoresheetSection = new ScoresheetSection(Guid.NewGuid(), sectionName, sectionOrder); scoresheet.AddSection(scoresheetSection); - foreach (var field in section.GetProperty("Fields").EnumerateArray()) + if (!section.TryGetProperty("Fields", out var fieldsElement) || fieldsElement.ValueKind != JsonValueKind.Array) + { + throw new InvalidOperationException($"Scoresheet generation returned section '{sectionName}' without Fields."); + } + + foreach (var field in fieldsElement.EnumerateArray()) { var question = new Question( Guid.NewGuid(), - field.GetProperty("Name").GetString() ?? string.Empty, - field.GetProperty("Label").GetString() ?? string.Empty, - (Unity.Flex.Scoresheets.Enums.QuestionType)field.GetProperty("Type").GetInt32(), - field.GetProperty("Order").GetUInt32(), + GetRequiredStringProperty(field, "Name", "field"), + GetRequiredStringProperty(field, "Label", "field"), + (Unity.Flex.Scoresheets.Enums.QuestionType)GetRequiredNumberProperty(field, "Type", "field"), + GetRequiredNumberProperty(field, "Order", "field"), field.TryGetProperty("Description", out var description) && description.ValueKind != JsonValueKind.Null ? description.GetString() : null, - field.TryGetProperty("Definition", out var definition) ? definition.GetString() : null); + field.TryGetProperty("Definition", out var definition) && definition.ValueKind != JsonValueKind.Null + ? definition.GetString() + : null); question.SectionId = scoresheetSection.Id; scoresheetSection.Fields.Add(question); } } + + return scoresheet; + } + + private static JsonElement ParseScoresheetElement(string json) + { + return JsonSerializer.Deserialize(json, CaseInsensitiveJsonOptions); + } + + private static bool TryGetNumberProperty(JsonElement element, string propertyName, out uint value) + { + if (element.TryGetProperty(propertyName, out var property) && property.ValueKind == JsonValueKind.Number) + { + value = property.GetUInt32(); + return true; + } + + value = default; + return false; + } + + private static string GetRequiredStringProperty(JsonElement element, string propertyName, string sourceName) + { + if (element.TryGetProperty(propertyName, out var property) && property.ValueKind != JsonValueKind.Null) + { + return property.GetString() ?? string.Empty; + } + + throw new InvalidOperationException($"Scoresheet generation returned a {sourceName} without {propertyName}."); + } + + private static uint GetRequiredNumberProperty(JsonElement element, string propertyName, string sourceName) + { + if (TryGetNumberProperty(element, propertyName, out var value)) + { + return value; + } + + throw new InvalidOperationException($"Scoresheet generation returned a {sourceName} without a valid {propertyName}."); } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateFormWorksheetJob.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateFormWorksheetJob.cs index a21847748a..28ae6021c7 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateFormWorksheetJob.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateFormWorksheetJob.cs @@ -36,6 +36,11 @@ public class GenerateFormWorksheetJob( IAIRateLimiter aiRateLimiter, ILogger logger) : AsyncBackgroundJob, ITransientDependency { + private static readonly JsonSerializerOptions CaseInsensitiveJsonOptions = new() + { + PropertyNameCaseInsensitive = true + }; + public override async Task ExecuteAsync(GenerateFormWorksheetBackgroundJobArgs args) { using var logScope = AIGenerationLogScope.Begin( @@ -60,8 +65,7 @@ await AIGenerationRequestJobHelper.MarkRunningInNewUowAsync( var formVersion = await applicationFormVersionRepository.GetAsync(args.ApplicationFormVersionId); var applicationForm = await applicationFormRepository.GetAsync(formVersion.ApplicationFormId); var worksheetName = BuildWorksheetName(formVersion.Id, applicationForm.Id); - var existingWorksheet = await worksheetRepository.GetByNameAsync(worksheetName, true) - ?? (await worksheetRepository.GetListOrderedAsync(formVersion.Id, CorrelationConsts.FormVersion, includeDetails: true)).FirstOrDefault(); + var existingWorksheet = await worksheetRepository.GetByNameAsync(worksheetName, true); List worksheetSnapshots = []; if (existingWorksheet != null) @@ -108,10 +112,18 @@ await AIGenerationRequestJobHelper.MarkRunningInNewUowAsync( }); var createDto = ParseWorksheetDefinition(worksheetJson); - var worksheet = existingWorksheet ?? await worksheetRepository.InsertAsync(BuildWorksheet(createDto, worksheetName)); - RebuildWorksheet(worksheet, createDto); + var worksheet = existingWorksheet == null + ? BuildWorksheet(createDto, worksheetName) + : RebuildWorksheet(existingWorksheet, createDto); worksheet.SetPublished(true); - await worksheetRepository.UpdateAsync(worksheet); + if (existingWorksheet == null) + { + await worksheetRepository.InsertAsync(worksheet); + } + else + { + await worksheetRepository.UpdateAsync(worksheet); + } await UpsertWorksheetLinkAsync(worksheet.Id, formVersion.Id); @@ -146,10 +158,7 @@ private static CreateWorksheetDto ParseWorksheetDefinition(string json) throw new InvalidOperationException("Worksheet generation returned empty content."); } - var dto = JsonSerializer.Deserialize(json, new JsonSerializerOptions - { - PropertyNameCaseInsensitive = true - }); + var dto = JsonSerializer.Deserialize(json, CaseInsensitiveJsonOptions); return dto ?? throw new InvalidOperationException("Worksheet generation returned an unusable worksheet definition."); } @@ -194,7 +203,7 @@ private static Worksheet BuildWorksheet(CreateWorksheetDto dto, string worksheet return worksheet; } - private static void RebuildWorksheet(Worksheet worksheet, CreateWorksheetDto dto) + private static Worksheet RebuildWorksheet(Worksheet worksheet, CreateWorksheetDto dto) { worksheet.SetName(worksheet.Name); worksheet.SetTitle(dto.Title); @@ -223,6 +232,8 @@ private static void RebuildWorksheet(Worksheet worksheet, CreateWorksheetDto dto worksheetSection.Fields.Add(customField); } } + + return worksheet; } private async Task UpsertWorksheetLinkAsync(Guid worksheetId, Guid correlationId) From 4a00f566948e24d43a76be7ad15947cdb02d9e22 Mon Sep 17 00:00:00 2001 From: Jacob Smith Date: Fri, 10 Jul 2026 13:31:59 -0700 Subject: [PATCH 060/223] AB#33569 fix attachment summary rebase fallout --- .../AI/Operations/AttachmentSummaryService.cs | 11 -------- .../AttachmentSummaryServiceTests.cs | 28 +++++++++---------- 2 files changed, 14 insertions(+), 25 deletions(-) diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/AttachmentSummaryService.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/AttachmentSummaryService.cs index 46ea22809f..0f5a437289 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/AttachmentSummaryService.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/AttachmentSummaryService.cs @@ -288,17 +288,6 @@ private async Task OpenAttachmentStreamAsync( } } - private async Task LoadAttachmentAsync(Guid attachmentId) - { - var attachment = await attachmentSummaryPersistence.LoadAsync(attachmentId); - return attachment; - } - - private async Task SaveSummaryAsync(Guid attachmentId, string summary) - { - await attachmentSummaryPersistence.SaveSummaryAsync(attachmentId, summary); - } - private static bool ShouldStopOnEmptyExtraction(string fileName, string extractedText) { return string.IsNullOrWhiteSpace(extractedText) && IsSupportedOfficeOrPdf(fileName); diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/Operations/AttachmentSummaryServiceTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/Operations/AttachmentSummaryServiceTests.cs index e4610a47e7..e4b75df95a 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/Operations/AttachmentSummaryServiceTests.cs +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/Operations/AttachmentSummaryServiceTests.cs @@ -98,7 +98,7 @@ await Should.ThrowAsync(() => [Fact] public async Task GenerateAndSaveAsync_Should_Reject_Empty_Attachment_List() { - var persistence = Substitute.For(); + var persistence = Substitute.For(); var service = CreateService( persistence, Substitute.For(), @@ -107,7 +107,7 @@ public async Task GenerateAndSaveAsync_Should_Reject_Empty_Attachment_List() await Should.ThrowAsync(() => service.GenerateAndSaveAsync([], "v1")); - await persistence.DidNotReceive().LoadApplicationAttachmentIdsAsync(Arg.Any()); + await persistence.DidNotReceive().GetApplicationAttachmentIdsAsync(Arg.Any()); } [Fact] @@ -171,18 +171,18 @@ public async Task GenerateAndSaveAsync_Should_Use_Batch_Mode_When_Configured() ChefsFileId = fileId2.ToString() }; - var persistence = Substitute.For(); - persistence.LoadAsync(firstAttachmentId).Returns(new AttachmentSummarySource( + var persistence = Substitute.For(); + persistence.GetAttachmentAsync(firstAttachmentId).Returns(new AttachmentSummarySource( firstAttachmentId, firstAttachment.FileName, firstAttachment.ChefsSubmissionId, firstAttachment.ChefsFileId)); - persistence.LoadAsync(secondAttachmentId).Returns(new AttachmentSummarySource( + persistence.GetAttachmentAsync(secondAttachmentId).Returns(new AttachmentSummarySource( secondAttachmentId, secondAttachment.FileName, secondAttachment.ChefsSubmissionId, secondAttachment.ChefsFileId)); - persistence.LoadApplicationAttachmentIdsAsync(applicationId).Returns([firstAttachmentId, secondAttachmentId]); + persistence.GetApplicationAttachmentIdsAsync(applicationId).Returns([firstAttachmentId, secondAttachmentId]); var streamProvider = Substitute.For(); streamProvider.OpenAsync(submissionId, fileId1, "first.txt") @@ -229,8 +229,8 @@ public async Task GenerateAndSaveAsync_Should_Use_Batch_Mode_When_Configured() summaries.ShouldBe(["first summary", "second summary"]); await aiService.Received(1).GenerateAttachmentSummaryBatchAsync(Arg.Any()); - await persistence.Received(1).SaveSummaryAsync(firstAttachmentId, "first summary"); - await persistence.Received(1).SaveSummaryAsync(secondAttachmentId, "second summary"); + await persistence.Received(1).UpdateAttachmentSummaryAsync(firstAttachmentId, "first summary"); + await persistence.Received(1).UpdateAttachmentSummaryAsync(secondAttachmentId, "second summary"); } [Fact] @@ -336,7 +336,7 @@ public async Task GenerateAndSaveAsync_Should_Pass_Extracted_Pdf_Text_To_AI() } private static AttachmentSummaryService CreateService( - IAttachmentSummaryPersistence persistence, + IAttachmentSummaryDataProvider persistence, IChefsFileAttachmentStreamProvider streamProvider, ITextExtractionService textExtractionService, IAIService aiService) @@ -353,21 +353,21 @@ private static AttachmentSummaryService CreateService( Substitute.For>()); } - private static IAttachmentSummaryPersistence CreatePersistence( + private static IAttachmentSummaryDataProvider CreatePersistence( Guid attachmentId, string fileName, Guid submissionId, Guid fileId, Action? savedSummary = null) { - var persistence = Substitute.For(); - persistence.LoadAsync(attachmentId).Returns(new AttachmentSummarySource( + var persistence = Substitute.For(); + persistence.GetAttachmentAsync(attachmentId).Returns(new AttachmentSummarySource( attachmentId, fileName, submissionId.ToString(), fileId.ToString())); - persistence.LoadApplicationAttachmentIdsAsync(Arg.Any()).Returns(new List()); - persistence.SaveSummaryAsync(attachmentId, Arg.Any()).Returns(callInfo => + persistence.GetApplicationAttachmentIdsAsync(Arg.Any()).Returns(new List()); + persistence.UpdateAttachmentSummaryAsync(attachmentId, Arg.Any()).Returns(callInfo => { savedSummary?.Invoke(callInfo.ArgAt(1)); return Task.CompletedTask; From f2ccd159fd27de22325f53507ae64df8e1466adf Mon Sep 17 00:00:00 2001 From: Jacob Smith Date: Fri, 10 Jul 2026 14:59:06 -0700 Subject: [PATCH 061/223] AB#33569 normalize AI form generation contracts --- .../AI/IAIService.cs | 8 +- .../Requests/AttachmentSummaryBatchRequest.cs | 28 ----- ...estionRequest.cs => FormMappingRequest.cs} | 2 +- .../AI/Requests/FormScoresheetRequest.cs | 13 ++ .../AI/Requests/FormWorksheetRequest.cs | 13 ++ .../AttachmentSummaryBatchResponse.cs | 19 --- .../AI/Responses/FormMappingFieldResponse.cs | 18 +++ .../AI/Responses/FormMappingIssueResponse.cs | 12 ++ .../AI/Responses/FormMappingMatchResponse.cs | 18 +++ .../AI/Responses/FormMappingResponse.cs | 19 +++ .../Responses/FormMappingWorksheetResponse.cs | 13 ++ .../AI/Responses/FormScoresheetResponse.cs | 70 +++++++++++ .../FormWorksheetCreationResponse.cs | 16 +++ .../AI/Responses/FormWorksheetResponse.cs | 67 ++++++++++ .../AI/Responses/MappingSuggestionResponse.cs | 79 ------------ .../AI/Operations/AttachmentSummaryService.cs | 119 ++---------------- .../AI/Runtime/AIPromptTemplateRenderer.cs | 16 +-- .../AI/Runtime/AIProviderPayloadValidator.cs | 35 +----- .../AI/Runtime/OpenAIResponseParser.cs | 83 +++--------- .../AI/Runtime/OpenAIRuntimeService.cs | 109 ++++------------ .../IApplicationFormVersionService.cs | 2 +- ...ionDto.cs => ApplicationFormMappingDto.cs} | 6 +- ...pingSuggestionDto.cs => FormMappingDto.cs} | 2 +- ...ngSuggestionDto.cs => FormWorksheetDto.cs} | 4 +- .../ApplicationFormVersionAppService.cs | 14 +-- ...Mapper.cs => FormMappingResponseMapper.cs} | 4 +- .../BackgroundJobs/GenerateFormMappingJob.cs | 4 +- .../GenerateFormScoresheetJob.cs | 3 +- .../GenerateFormWorksheetJob.cs | 3 +- .../AttachmentSummaryServiceTests.cs | 91 -------------- .../AIProviderPayloadValidatorTests.cs | 31 ----- .../AI/Runtime/OpenAIResponseParserTests.cs | 20 --- .../ApplicationFormVersionAppServiceTests.cs | 8 +- 33 files changed, 342 insertions(+), 607 deletions(-) delete mode 100644 applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Requests/AttachmentSummaryBatchRequest.cs rename applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Requests/{MappingSuggestionRequest.cs => FormMappingRequest.cs} (87%) create mode 100644 applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Requests/FormScoresheetRequest.cs create mode 100644 applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Requests/FormWorksheetRequest.cs delete mode 100644 applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Responses/AttachmentSummaryBatchResponse.cs create mode 100644 applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Responses/FormMappingFieldResponse.cs create mode 100644 applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Responses/FormMappingIssueResponse.cs create mode 100644 applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Responses/FormMappingMatchResponse.cs create mode 100644 applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Responses/FormMappingResponse.cs create mode 100644 applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Responses/FormMappingWorksheetResponse.cs create mode 100644 applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Responses/FormScoresheetResponse.cs create mode 100644 applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Responses/FormWorksheetCreationResponse.cs create mode 100644 applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Responses/FormWorksheetResponse.cs delete mode 100644 applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Responses/MappingSuggestionResponse.cs rename applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/Mapping/{ApplicationFormMappingSuggestionDto.cs => ApplicationFormMappingDto.cs} (60%) rename applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/Mapping/{MappingSuggestionDto.cs => FormMappingDto.cs} (89%) rename applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/Mapping/{WorksheetMappingSuggestionDto.cs => FormWorksheetDto.cs} (57%) rename applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicationForms/Mapping/{MappingSuggestionResponseMapper.cs => FormMappingResponseMapper.cs} (90%) diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/IAIService.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/IAIService.cs index 894001b98f..565dca95cb 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/IAIService.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/IAIService.cs @@ -10,12 +10,10 @@ public interface IAIService Task IsAvailableAsync(); Task GenerateAttachmentSummaryAsync(AttachmentSummaryRequest request, CancellationToken cancellationToken = default); - Task GenerateAttachmentSummaryBatchAsync(AttachmentSummaryBatchRequest request, CancellationToken cancellationToken = default); Task GenerateApplicationAnalysisAsync(ApplicationAnalysisRequest request, CancellationToken cancellationToken = default); Task GenerateApplicationScoringAsync(ApplicationScoringRequest request, CancellationToken cancellationToken = default); - Task GenerateMappingSuggestionAsync(MappingSuggestionRequest request, CancellationToken cancellationToken = default); - Task GenerateFormMappingAsync(MappingSuggestionRequest request, CancellationToken cancellationToken = default); - Task GenerateFormWorksheetAsync(MappingSuggestionRequest request, CancellationToken cancellationToken = default); - Task GenerateFormScoresheetAsync(MappingSuggestionRequest request, CancellationToken cancellationToken = default); + Task GenerateFormMappingAsync(FormMappingRequest request, CancellationToken cancellationToken = default); + Task GenerateFormWorksheetAsync(FormWorksheetRequest request, CancellationToken cancellationToken = default); + Task GenerateFormScoresheetAsync(FormScoresheetRequest request, CancellationToken cancellationToken = default); } } diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Requests/AttachmentSummaryBatchRequest.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Requests/AttachmentSummaryBatchRequest.cs deleted file mode 100644 index 031acd1937..0000000000 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Requests/AttachmentSummaryBatchRequest.cs +++ /dev/null @@ -1,28 +0,0 @@ -using System.Collections.Generic; -using System.Text.Json.Serialization; - -namespace Unity.AI.Requests; - -public sealed class AttachmentSummaryBatchRequest -{ - [JsonPropertyName("attachments")] - public List Attachments { get; set; } = []; - - [JsonPropertyName("promptVersion")] - public string? PromptVersion { get; set; } -} - -public sealed class AttachmentSummaryBatchItemRequest -{ - [JsonPropertyName("attachmentId")] - public string AttachmentId { get; set; } = string.Empty; - - [JsonPropertyName("fileName")] - public string FileName { get; set; } = string.Empty; - - [JsonPropertyName("contentType")] - public string ContentType { get; set; } = "application/octet-stream"; - - [JsonPropertyName("extractedText")] - public string? ExtractedText { get; set; } -} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Requests/MappingSuggestionRequest.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Requests/FormMappingRequest.cs similarity index 87% rename from applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Requests/MappingSuggestionRequest.cs rename to applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Requests/FormMappingRequest.cs index 02c1892445..caed63764c 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Requests/MappingSuggestionRequest.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Requests/FormMappingRequest.cs @@ -3,7 +3,7 @@ namespace Unity.AI.Requests; -public class MappingSuggestionRequest +public class FormMappingRequest { [JsonPropertyName("data")] public JsonElement Data { get; set; } diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Requests/FormScoresheetRequest.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Requests/FormScoresheetRequest.cs new file mode 100644 index 0000000000..64ba94b28c --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Requests/FormScoresheetRequest.cs @@ -0,0 +1,13 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Unity.AI.Requests; + +public class FormScoresheetRequest +{ + [JsonPropertyName("data")] + public JsonElement Data { get; set; } + + [JsonPropertyName("promptVersion")] + public string? PromptVersion { get; set; } +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Requests/FormWorksheetRequest.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Requests/FormWorksheetRequest.cs new file mode 100644 index 0000000000..290445b9a3 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Requests/FormWorksheetRequest.cs @@ -0,0 +1,13 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Unity.AI.Requests; + +public class FormWorksheetRequest +{ + [JsonPropertyName("data")] + public JsonElement Data { get; set; } + + [JsonPropertyName("promptVersion")] + public string? PromptVersion { get; set; } +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Responses/AttachmentSummaryBatchResponse.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Responses/AttachmentSummaryBatchResponse.cs deleted file mode 100644 index 4751fe9122..0000000000 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Responses/AttachmentSummaryBatchResponse.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System.Collections.Generic; -using System.Text.Json.Serialization; - -namespace Unity.AI.Responses; - -public sealed class AttachmentSummaryBatchResponse -{ - [JsonPropertyName("attachments")] - public List Attachments { get; set; } = []; -} - -public sealed class AttachmentSummaryBatchItemResponse -{ - [JsonPropertyName("attachmentId")] - public string AttachmentId { get; set; } = string.Empty; - - [JsonPropertyName("summary")] - public string Summary { get; set; } = string.Empty; -} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Responses/FormMappingFieldResponse.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Responses/FormMappingFieldResponse.cs new file mode 100644 index 0000000000..2ed0538b8a --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Responses/FormMappingFieldResponse.cs @@ -0,0 +1,18 @@ +using System.Text.Json.Serialization; + +namespace Unity.AI.Responses; + +public class FormMappingFieldResponse +{ + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + [JsonPropertyName("type")] + public string Type { get; set; } = string.Empty; + + [JsonPropertyName("label")] + public string Label { get; set; } = string.Empty; + + [JsonPropertyName("isCustom")] + public bool IsCustom { get; set; } +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Responses/FormMappingIssueResponse.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Responses/FormMappingIssueResponse.cs new file mode 100644 index 0000000000..faa0e5eddb --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Responses/FormMappingIssueResponse.cs @@ -0,0 +1,12 @@ +using System.Text.Json.Serialization; + +namespace Unity.AI.Responses; + +public class FormMappingIssueResponse +{ + [JsonPropertyName("code")] + public string Code { get; set; } = string.Empty; + + [JsonPropertyName("message")] + public string Message { get; set; } = string.Empty; +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Responses/FormMappingMatchResponse.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Responses/FormMappingMatchResponse.cs new file mode 100644 index 0000000000..d86116d2e0 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Responses/FormMappingMatchResponse.cs @@ -0,0 +1,18 @@ +using System.Text.Json.Serialization; + +namespace Unity.AI.Responses; + +public class FormMappingMatchResponse +{ + [JsonPropertyName("sourceField")] + public string SourceField { get; set; } = string.Empty; + + [JsonPropertyName("targetField")] + public string TargetField { get; set; } = string.Empty; + + [JsonPropertyName("reason")] + public string Reason { get; set; } = string.Empty; + + [JsonPropertyName("confidence")] + public decimal Confidence { get; set; } +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Responses/FormMappingResponse.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Responses/FormMappingResponse.cs new file mode 100644 index 0000000000..ef6552a3cc --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Responses/FormMappingResponse.cs @@ -0,0 +1,19 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Unity.AI.Responses; + +public class FormMappingResponse +{ + [JsonPropertyName("coreFieldMatches")] + public List CoreFieldMatches { get; set; } = []; + + [JsonPropertyName("worksheetMatches")] + public List WorksheetMatches { get; set; } = []; + + [JsonPropertyName("worksheetCreationSuggestions")] + public List WorksheetCreationSuggestions { get; set; } = []; + + [JsonPropertyName("issues")] + public List Issues { get; set; } = []; +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Responses/FormMappingWorksheetResponse.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Responses/FormMappingWorksheetResponse.cs new file mode 100644 index 0000000000..4d5e207b5e --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Responses/FormMappingWorksheetResponse.cs @@ -0,0 +1,13 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Unity.AI.Responses; + +public class FormMappingWorksheetResponse +{ + [JsonPropertyName("worksheetName")] + public string WorksheetName { get; set; } = string.Empty; + + [JsonPropertyName("fieldMatches")] + public List FieldMatches { get; set; } = []; +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Responses/FormScoresheetResponse.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Responses/FormScoresheetResponse.cs new file mode 100644 index 0000000000..d911834e18 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Responses/FormScoresheetResponse.cs @@ -0,0 +1,70 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Unity.AI.Responses; + +public class FormScoresheetResponse +{ + [JsonPropertyName("title")] + public string Title { get; set; } = string.Empty; + + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + [JsonPropertyName("version")] + public uint Version { get; set; } = 1; + + [JsonPropertyName("order")] + public uint Order { get; set; } + + [JsonPropertyName("published")] + public bool Published { get; set; } + + [JsonPropertyName("reportColumns")] + public string ReportColumns { get; set; } = string.Empty; + + [JsonPropertyName("reportKeys")] + public string ReportKeys { get; set; } = string.Empty; + + [JsonPropertyName("reportViewName")] + public string ReportViewName { get; set; } = string.Empty; + + [JsonPropertyName("sections")] + public List Sections { get; set; } = []; +} + +public class FormScoresheetSectionResponse +{ + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + [JsonPropertyName("order")] + public uint Order { get; set; } + + [JsonPropertyName("fields")] + public List Fields { get; set; } = []; +} + +public class FormScoresheetFieldResponse +{ + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + [JsonPropertyName("label")] + public string Label { get; set; } = string.Empty; + + [JsonPropertyName("description")] + public string? Description { get; set; } + + [JsonPropertyName("order")] + public uint Order { get; set; } + + [JsonPropertyName("type")] + public int Type { get; set; } + + [JsonPropertyName("enabled")] + public bool Enabled { get; set; } = true; + + [JsonPropertyName("definition")] + public string? Definition { get; set; } +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Responses/FormWorksheetCreationResponse.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Responses/FormWorksheetCreationResponse.cs new file mode 100644 index 0000000000..9ecc8f3ac1 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Responses/FormWorksheetCreationResponse.cs @@ -0,0 +1,16 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Unity.AI.Responses; + +public class FormWorksheetCreationResponse +{ + [JsonPropertyName("worksheetName")] + public string WorksheetName { get; set; } = string.Empty; + + [JsonPropertyName("suggestedFields")] + public List SuggestedFields { get; set; } = []; + + [JsonPropertyName("reason")] + public string Reason { get; set; } = string.Empty; +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Responses/FormWorksheetResponse.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Responses/FormWorksheetResponse.cs new file mode 100644 index 0000000000..28bf74e2c0 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Responses/FormWorksheetResponse.cs @@ -0,0 +1,67 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Unity.AI.Responses; + +public class FormWorksheetResponse +{ + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + [JsonPropertyName("title")] + public string Title { get; set; } = string.Empty; + + [JsonPropertyName("version")] + public uint Version { get; set; } = 1; + + [JsonPropertyName("published")] + public bool Published { get; set; } + + [JsonPropertyName("reportColumns")] + public string ReportColumns { get; set; } = string.Empty; + + [JsonPropertyName("reportKeys")] + public string ReportKeys { get; set; } = string.Empty; + + [JsonPropertyName("reportViewName")] + public string ReportViewName { get; set; } = string.Empty; + + [JsonPropertyName("sections")] + public List Sections { get; set; } = []; +} + +public class FormWorksheetSectionResponse +{ + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + [JsonPropertyName("order")] + public uint Order { get; set; } + + [JsonPropertyName("fields")] + public List Fields { get; set; } = []; +} + +public class FormWorksheetFieldResponse +{ + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + [JsonPropertyName("key")] + public string Key { get; set; } = string.Empty; + + [JsonPropertyName("label")] + public string Label { get; set; } = string.Empty; + + [JsonPropertyName("type")] + public int Type { get; set; } + + [JsonPropertyName("order")] + public uint Order { get; set; } + + [JsonPropertyName("enabled")] + public bool Enabled { get; set; } = true; + + [JsonPropertyName("definition")] + public string? Definition { get; set; } +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Responses/MappingSuggestionResponse.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Responses/MappingSuggestionResponse.cs deleted file mode 100644 index 449925bfff..0000000000 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Responses/MappingSuggestionResponse.cs +++ /dev/null @@ -1,79 +0,0 @@ -using System.Collections.Generic; -using System.Text.Json.Serialization; - -namespace Unity.AI.Responses; - -public class MappingSuggestionResponse -{ - [JsonPropertyName("coreFieldMatches")] - public List CoreFieldMatches { get; set; } = []; - - [JsonPropertyName("worksheetMatches")] - public List WorksheetMatches { get; set; } = []; - - [JsonPropertyName("worksheetCreationSuggestions")] - public List WorksheetCreationSuggestions { get; set; } = []; - - [JsonPropertyName("issues")] - public List Issues { get; set; } = []; -} - -public class MappingSuggestionItemResponse -{ - [JsonPropertyName("sourceField")] - public string SourceField { get; set; } = string.Empty; - - [JsonPropertyName("targetField")] - public string TargetField { get; set; } = string.Empty; - - [JsonPropertyName("reason")] - public string Reason { get; set; } = string.Empty; - - [JsonPropertyName("confidence")] - public decimal Confidence { get; set; } -} - -public class WorksheetMappingSuggestionResponse -{ - [JsonPropertyName("worksheetName")] - public string WorksheetName { get; set; } = string.Empty; - - [JsonPropertyName("fieldMatches")] - public List FieldMatches { get; set; } = []; -} - -public class WorksheetCreationSuggestionResponse -{ - [JsonPropertyName("worksheetName")] - public string WorksheetName { get; set; } = string.Empty; - - [JsonPropertyName("suggestedFields")] - public List SuggestedFields { get; set; } = []; - - [JsonPropertyName("reason")] - public string Reason { get; set; } = string.Empty; -} - -public class MappingFieldResponse -{ - [JsonPropertyName("name")] - public string Name { get; set; } = string.Empty; - - [JsonPropertyName("type")] - public string Type { get; set; } = string.Empty; - - [JsonPropertyName("label")] - public string Label { get; set; } = string.Empty; - - [JsonPropertyName("isCustom")] - public bool IsCustom { get; set; } -} - -public class MappingIssueResponse -{ - [JsonPropertyName("code")] - public string Code { get; set; } = string.Empty; - - [JsonPropertyName("message")] - public string Message { get; set; } = string.Empty; -} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/AttachmentSummaryService.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/AttachmentSummaryService.cs index 0f5a437289..89dacc0e07 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/AttachmentSummaryService.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/AttachmentSummaryService.cs @@ -22,7 +22,6 @@ public class AttachmentSummaryService( ITextExtractionService textExtractionService, IAIService aiService, IAIGenerationPrerequisiteValidator aiGenerationPrerequisiteValidator, - AIExecutionModeResolver executionModeResolver, IUnitOfWorkManager unitOfWorkManager, ILogger logger, IStringLocalizer localizer) : IAttachmentSummaryService, ITransientDependency @@ -65,124 +64,20 @@ public async Task> GenerateAndSaveAsync(IEnumerable attachmen throw new UserFriendlyException(localizer[AILocalizationKeys.SelectAttachmentForSummaries]); } - var mode = executionModeResolver.ResolveMode(AIExecutionModeResolver.AttachmentSummaryOperation); - if (mode == AIExecutionMode.Batch) - { - return await GenerateBatchAsync(ids, promptVersion, cancellationToken); - } - - if (mode != AIExecutionMode.Sequential) - { - logger.LogWarning( - "AI attachment summary {ExecutionMode} mode is not supported by the current repository-backed execution path. Falling back to sequential execution.", - mode); - mode = AIExecutionMode.Sequential; - } - return await AIExecutionStrategy.RunAsync( ids, - mode, + AIExecutionMode.Sequential, id => GenerateOrFallbackAsync(id, promptVersion, cancellationToken), - batch => GenerateSequentiallyAsync(batch, promptVersion, cancellationToken)); - } - - private async Task> GenerateBatchAsync( - IReadOnlyCollection attachmentIds, - string? promptVersion, - CancellationToken cancellationToken) - { - var attachments = new List<(Guid Id, AttachmentSummarySource Source, string ContentType, string ExtractedText)>(attachmentIds.Count); - var failures = new Dictionary(); - - foreach (var attachmentId in attachmentIds) - { - try + async batch => { - var attachment = await LoadAttachmentAsync(attachmentId); - var fileName = string.IsNullOrWhiteSpace(attachment.FileName) ? "unknown" : attachment.FileName; - await using var attachmentStream = await OpenAttachmentStreamAsync(attachment, fileName, cancellationToken); - var extractedText = await textExtractionService.ExtractTextAsync(fileName, attachmentStream.Content, attachmentStream.ContentType, cancellationToken); - if (ShouldStopOnEmptyExtraction(fileName, extractedText)) + var summaries = new List(batch.Count); + foreach (var attachmentId in batch) { - LogEmptyExtraction(attachmentId, fileName, attachmentStream); - failures[attachmentId] = TextExtractionFailedSummary; - continue; + summaries.Add(await GenerateOrFallbackAsync(attachmentId, promptVersion, cancellationToken)); } - attachments.Add((attachmentId, attachment, attachmentStream.ContentType, extractedText)); - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception ex) - { - logger.LogError(ex, "Error preparing AI summary batch item {AttachmentId}", attachmentId); - failures[attachmentId] = SummaryGenerationFailedMessage; - } - } - - if (attachments.Count == 0) - { - return attachmentIds.Select(id => failures.TryGetValue(id, out var failure) ? failure : SummaryGenerationFailedMessage).ToList(); - } - - var batchRequest = new AttachmentSummaryBatchRequest - { - PromptVersion = promptVersion, - Attachments = attachments.Select(item => new AttachmentSummaryBatchItemRequest - { - AttachmentId = item.Id.ToString(), - FileName = string.IsNullOrWhiteSpace(item.Source.FileName) ? "unknown" : item.Source.FileName!, - ContentType = item.ContentType, - ExtractedText = item.ExtractedText - }).ToList() - }; - - var batchResponse = await aiService.GenerateAttachmentSummaryBatchAsync(batchRequest, cancellationToken); - var responseMap = new Dictionary(StringComparer.OrdinalIgnoreCase); - foreach (var item in batchResponse.Attachments) - { - if (!string.IsNullOrWhiteSpace(item.AttachmentId)) - { - responseMap[item.AttachmentId] = item.Summary; - } - } - - var results = new List(attachmentIds.Count); - foreach (var attachmentId in attachmentIds) - { - if (failures.TryGetValue(attachmentId, out var failure)) - { - results.Add(failure); - continue; - } - - if (responseMap.TryGetValue(attachmentId.ToString(), out var summary)) - { - await SaveSummaryAsync(attachmentId, summary); - results.Add(summary); - continue; - } - - results.Add(SummaryGenerationFailedMessage); - } - - return results; - } - - private async Task> GenerateSequentiallyAsync( - IReadOnlyCollection attachmentIds, - string? promptVersion, - CancellationToken cancellationToken) - { - var summaries = new List(attachmentIds.Count); - foreach (var attachmentId in attachmentIds) - { - summaries.Add(await GenerateOrFallbackAsync(attachmentId, promptVersion, cancellationToken)); - } - - return summaries; + return summaries; + }); } private async Task GenerateOrFallbackAsync( diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/AIPromptTemplateRenderer.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/AIPromptTemplateRenderer.cs index efc7871757..67e248c811 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/AIPromptTemplateRenderer.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/AIPromptTemplateRenderer.cs @@ -40,20 +40,6 @@ public static string BuildAttachmentSummaryUserPrompt( }); } - public static string BuildAttachmentSummaryBatchUserPrompt( - string userPromptTemplate, - string attachments, - string? metadataJson = null) - { - return RenderPromptTemplate( - userPromptTemplate, - metadataJson, - new Dictionary - { - ["ATTACHMENTS"] = attachments - }); - } - public static string BuildApplicationScoringUserPrompt( string userPromptTemplate, string data, @@ -74,7 +60,7 @@ public static string BuildApplicationScoringUserPrompt( }); } - public static string BuildMappingSuggestionUserPrompt( + public static string BuildFormMappingUserPrompt( string userPromptTemplate, string data, string? metadataJson = null) diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/AIProviderPayloadValidator.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/AIProviderPayloadValidator.cs index 52cdf81136..b15b9e18c8 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/AIProviderPayloadValidator.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/AIProviderPayloadValidator.cs @@ -14,39 +14,6 @@ public static AIResponseValidationResult ValidateAttachmentSummaryText(string re : AIResponseValidationResult.Invalid("Attachment summary response was empty."); } - public static AIResponseValidationResult ValidateAttachmentSummaryBatchJson(string response) - { - if (!TryParseRootObject(response, out var root)) - { - return AIResponseValidationResult.Invalid("Attachment summary batch response was not valid JSON."); - } - - if (!root.TryGetProperty("attachments", out var attachments) || attachments.ValueKind != JsonValueKind.Array) - { - return AIResponseValidationResult.Invalid("Attachment summary batch response is missing required field 'attachments' (expected array)."); - } - - foreach (var attachment in attachments.EnumerateArray()) - { - if (attachment.ValueKind != JsonValueKind.Object) - { - return AIResponseValidationResult.Invalid("Attachment summary batch response includes an invalid attachment item."); - } - - if (!attachment.TryGetProperty("attachmentId", out var attachmentId) || attachmentId.ValueKind != JsonValueKind.String || string.IsNullOrWhiteSpace(attachmentId.GetString())) - { - return AIResponseValidationResult.Invalid("Attachment summary batch response is missing a valid attachmentId."); - } - - if (!attachment.TryGetProperty(AIJsonKeys.Summary, out var summary) || summary.ValueKind != JsonValueKind.String) - { - return AIResponseValidationResult.Invalid("Attachment summary batch response is missing a valid summary."); - } - } - - return AIResponseValidationResult.Success(); - } - public static AIResponseValidationResult ValidateApplicationAnalysisJson(string response) { if (!TryParseRootObject(response, out var root)) @@ -145,7 +112,7 @@ public static AIResponseValidationResult ValidateApplicationScoringJson(string r return AIResponseValidationResult.Success(); } - public static AIResponseValidationResult ValidateMappingSuggestionJson(string response) + public static AIResponseValidationResult ValidateFormMappingJson(string response) { if (!TryParseRootObject(response, out _)) { diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIResponseParser.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIResponseParser.cs index 92adac681e..a8b08e237e 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIResponseParser.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIResponseParser.cs @@ -47,49 +47,6 @@ public static ApplicationAnalysisResponse ParseApplicationAnalysisResponse(strin return response; } - public static AttachmentSummaryBatchResponse ParseAttachmentSummaryBatchResponse(string raw) - { - var response = new AttachmentSummaryBatchResponse(); - if (!TryParseJsonObjectFromResponse(raw, out var root)) - { - return response; - } - - if (!root.TryGetProperty("attachments", out var attachments) || attachments.ValueKind != JsonValueKind.Array) - { - return response; - } - - foreach (var attachment in attachments.EnumerateArray()) - { - if (attachment.ValueKind != JsonValueKind.Object) - { - continue; - } - - var attachmentId = attachment.TryGetProperty("attachmentId", out var idProp) && idProp.ValueKind == JsonValueKind.String - ? idProp.GetString() ?? string.Empty - : string.Empty; - - if (string.IsNullOrWhiteSpace(attachmentId)) - { - continue; - } - - var summary = attachment.TryGetProperty(AIJsonKeys.Summary, out var summaryProp) && summaryProp.ValueKind == JsonValueKind.String - ? summaryProp.GetString() ?? string.Empty - : string.Empty; - - response.Attachments.Add(new AttachmentSummaryBatchItemResponse - { - AttachmentId = attachmentId, - Summary = summary - }); - } - - return response; - } - public static ApplicationScoringResponse ParseApplicationScoringResponse(string raw, IReadOnlyDictionary? questionIdAliasMap = null) { var response = new ApplicationScoringResponse(); @@ -134,9 +91,9 @@ public static ApplicationScoringResponse ParseApplicationScoringResponse(string return response; } - public static MappingSuggestionResponse ParseMappingSuggestionResponse(string raw) + public static FormMappingResponse ParseFormMappingResponse(string raw) { - var response = new MappingSuggestionResponse(); + var response = new FormMappingResponse(); if (!TryParseJsonObjectFromResponse(raw, out var root)) { return response; @@ -144,18 +101,18 @@ public static MappingSuggestionResponse ParseMappingSuggestionResponse(string ra if (root.TryGetProperty("coreFieldMatches", out var coreFieldMatches) && coreFieldMatches.ValueKind == JsonValueKind.Array) { - response.CoreFieldMatches = ParseMappingSuggestionItems(coreFieldMatches).ToList(); + response.CoreFieldMatches = ParseFormMappingMatches(coreFieldMatches).ToList(); } if (root.TryGetProperty("worksheetMatches", out var worksheetMatches) && worksheetMatches.ValueKind == JsonValueKind.Array) { response.WorksheetMatches = worksheetMatches.EnumerateArray() .Where(item => item.ValueKind == JsonValueKind.Object) - .Select(item => new WorksheetMappingSuggestionResponse - { - WorksheetName = item.TryGetProperty("worksheetName", out var name) && name.ValueKind == JsonValueKind.String ? name.GetString() ?? string.Empty : string.Empty, - FieldMatches = item.TryGetProperty("fieldMatches", out var matches) && matches.ValueKind == JsonValueKind.Array - ? ParseMappingSuggestionItems(matches).ToList() + .Select(item => new FormMappingWorksheetResponse + { + WorksheetName = item.TryGetProperty("worksheetName", out var name) && name.ValueKind == JsonValueKind.String ? name.GetString() ?? string.Empty : string.Empty, + FieldMatches = item.TryGetProperty("fieldMatches", out var matches) && matches.ValueKind == JsonValueKind.Array + ? ParseFormMappingMatches(matches).ToList() : [] }) .ToList(); @@ -165,10 +122,10 @@ public static MappingSuggestionResponse ParseMappingSuggestionResponse(string ra { response.WorksheetCreationSuggestions = worksheetCreationSuggestions.EnumerateArray() .Where(item => item.ValueKind == JsonValueKind.Object) - .Select(item => new WorksheetCreationSuggestionResponse - { - WorksheetName = item.TryGetProperty("worksheetName", out var name) && name.ValueKind == JsonValueKind.String ? name.GetString() ?? string.Empty : string.Empty, - Reason = item.TryGetProperty("reason", out var reason) && reason.ValueKind == JsonValueKind.String ? reason.GetString() ?? string.Empty : string.Empty, + .Select(item => new FormWorksheetCreationResponse + { + WorksheetName = item.TryGetProperty("worksheetName", out var name) && name.ValueKind == JsonValueKind.String ? name.GetString() ?? string.Empty : string.Empty, + Reason = item.TryGetProperty("reason", out var reason) && reason.ValueKind == JsonValueKind.String ? reason.GetString() ?? string.Empty : string.Empty, SuggestedFields = item.TryGetProperty("suggestedFields", out var fields) && fields.ValueKind == JsonValueKind.Array ? ParseMappingFields(fields).ToList() : [] @@ -180,10 +137,10 @@ public static MappingSuggestionResponse ParseMappingSuggestionResponse(string ra { response.Issues = issues.EnumerateArray() .Where(item => item.ValueKind == JsonValueKind.Object) - .Select(item => new MappingIssueResponse - { - Code = item.TryGetProperty("code", out var code) && code.ValueKind == JsonValueKind.String ? code.GetString() ?? string.Empty : string.Empty, - Message = item.TryGetProperty("message", out var message) && message.ValueKind == JsonValueKind.String ? message.GetString() ?? string.Empty : string.Empty + .Select(item => new FormMappingIssueResponse + { + Code = item.TryGetProperty("code", out var code) && code.ValueKind == JsonValueKind.String ? code.GetString() ?? string.Empty : string.Empty, + Message = item.TryGetProperty("message", out var message) && message.ValueKind == JsonValueKind.String ? message.GetString() ?? string.Empty : string.Empty }) .ToList(); } @@ -292,7 +249,7 @@ private static IEnumerable ParseFindings(JsonElement } } - private static IEnumerable ParseMappingSuggestionItems(JsonElement itemsArray) + private static IEnumerable ParseFormMappingMatches(JsonElement itemsArray) { foreach (var item in itemsArray.EnumerateArray()) { @@ -301,7 +258,7 @@ private static IEnumerable ParseMappingSuggestion continue; } - yield return new MappingSuggestionItemResponse + yield return new FormMappingMatchResponse { SourceField = item.TryGetProperty("sourceField", out var sourceField) && sourceField.ValueKind == JsonValueKind.String ? sourceField.GetString() ?? string.Empty : string.Empty, TargetField = item.TryGetProperty("targetField", out var targetField) && targetField.ValueKind == JsonValueKind.String ? targetField.GetString() ?? string.Empty : string.Empty, @@ -311,7 +268,7 @@ private static IEnumerable ParseMappingSuggestion } } - private static IEnumerable ParseMappingFields(JsonElement itemsArray) + private static IEnumerable ParseMappingFields(JsonElement itemsArray) { foreach (var item in itemsArray.EnumerateArray()) { @@ -320,7 +277,7 @@ private static IEnumerable ParseMappingFields(JsonElement continue; } - yield return new MappingFieldResponse + yield return new FormMappingFieldResponse { Name = item.TryGetProperty("name", out var name) && name.ValueKind == JsonValueKind.String ? name.GetString() ?? string.Empty : string.Empty, Type = item.TryGetProperty("type", out var type) && type.ValueKind == JsonValueKind.String ? type.GetString() ?? string.Empty : string.Empty, diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIRuntimeService.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIRuntimeService.cs index eb442b361b..f51fcab9ca 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIRuntimeService.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIRuntimeService.cs @@ -25,7 +25,7 @@ public class OpenAIRuntimeService : IAIService, ITransientDependency private const string ApplicationAnalysisPromptType = AIPromptTypes.ApplicationAnalysis; private const string AttachmentSummaryPromptType = AIPromptTypes.AttachmentSummary; private const string ApplicationScoringPromptType = AIPromptTypes.ApplicationScoring; - private const string MappingSuggestionPromptType = AIPromptTypes.FormMapping; + private const string FormMappingPromptType = AIPromptTypes.FormMapping; private const string FormWorksheetPromptType = AIPromptTypes.FormWorksheet; private const string FormScoresheetPromptType = AIPromptTypes.FormScoresheet; private const int MaxAiAttempts = 3; @@ -158,7 +158,7 @@ public async Task GenerateAttachmentSummaryAsync(Atta } }; var attachments = JsonSerializer.Serialize(attachmentPayload, AIJsonDefaults.Indented); - var contentToAnalyze = AIPromptTemplateRenderer.BuildAttachmentSummaryBatchUserPrompt( + var contentToAnalyze = AIPromptTemplateRenderer.BuildAttachmentSummaryUserPrompt( promptTemplate.UserPrompt, attachments, promptTemplate.MetadataJson); @@ -203,68 +203,6 @@ public async Task GenerateAttachmentSummaryAsync(Atta } } - public async Task GenerateAttachmentSummaryBatchAsync(AttachmentSummaryBatchRequest request, CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(request); - try - { - if (request.Attachments is null || request.Attachments.Count == 0) - { - return new AttachmentSummaryBatchResponse(); - } - - var settings = await _openAIConfigurationResolver.ResolveOperationSettingsAsync(AttachmentSummaryPromptType, cancellationToken); - var promptTemplate = await _promptTemplateProvider.GetRequiredPromptAsync( - AttachmentSummaryPromptType, - request.PromptVersion ?? settings.PromptVersion, - cancellationToken); - var promptVersion = promptTemplate.PromptVersion; - - var attachmentsPayload = request.Attachments.Select(attachment => new - { - attachmentId = attachment.AttachmentId, - name = string.IsNullOrWhiteSpace(attachment.FileName) ? "attachment" : attachment.FileName.Trim(), - contentType = attachment.ContentType ?? "application/octet-stream", - text = string.IsNullOrWhiteSpace(attachment.ExtractedText) ? null : attachment.ExtractedText - }); - - var attachments = JsonSerializer.Serialize(attachmentsPayload, AIJsonDefaults.Indented); - var contentToAnalyze = AIPromptTemplateRenderer.BuildAttachmentSummaryBatchUserPrompt( - promptTemplate.UserPrompt, - attachments, - promptTemplate.MetadataJson); - - await _promptFileLogger.LogPromptInputAsync(AttachmentSummaryPromptType, promptVersion, promptTemplate.SystemPrompt, contentToAnalyze, cancellationToken); - var result = await GenerateWithRetryAsync( - () => _openAITransportService.GenerateSummaryAsync( - contentToAnalyze, - promptTemplate.SystemPrompt, - settings, - settings.CompletionTokens, - cancellationToken: cancellationToken), - AIProviderPayloadValidator.ValidateAttachmentSummaryBatchJson, - "attachment summary batch", - cancellationToken); - await _promptFileLogger.LogPromptOutputAsync(AttachmentSummaryPromptType, promptVersion, result.CaptureOutput, cancellationToken); - - if (result.Outcome != AIOperationOutcome.Success) - { - return new AttachmentSummaryBatchResponse(); - } - - return OpenAIResponseParser.ParseAttachmentSummaryBatchResponse(result.Content); - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception ex) - { - _logger.LogError(ex, "Attachment summary batch generation failed."); - return new AttachmentSummaryBatchResponse(); - } - } - public async Task GenerateApplicationScoringAsync(ApplicationScoringRequest request, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(request); @@ -336,10 +274,7 @@ public async Task GenerateApplicationScoringAsync(Ap } } - public async Task GenerateMappingSuggestionAsync(MappingSuggestionRequest request, CancellationToken cancellationToken = default) - => await GenerateMappingSuggestionAsync(request, MappingSuggestionPromptType, cancellationToken); - - public async Task GenerateFormWorksheetAsync(MappingSuggestionRequest request, CancellationToken cancellationToken = default) + public async Task GenerateFormWorksheetAsync(FormWorksheetRequest request, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(request); try @@ -352,7 +287,7 @@ public async Task GenerateFormWorksheetAsync(MappingSuggestionRequest re var promptVersion = promptTemplate.PromptVersion; var dataJson = request.Data.GetRawText(); var systemPrompt = promptTemplate.SystemPrompt; - var content = AIPromptTemplateRenderer.BuildMappingSuggestionUserPrompt( + var content = AIPromptTemplateRenderer.BuildFormMappingUserPrompt( promptTemplate.UserPrompt, dataJson, promptTemplate.MetadataJson); @@ -365,12 +300,14 @@ public async Task GenerateFormWorksheetAsync(MappingSuggestionRequest re settings, settings.CompletionTokens, cancellationToken: cancellationToken), - AIProviderPayloadValidator.ValidateMappingSuggestionJson, + AIProviderPayloadValidator.ValidateFormMappingJson, "form worksheet", cancellationToken); await _promptFileLogger.LogPromptOutputAsync(FormWorksheetPromptType, promptVersion, result.CaptureOutput, cancellationToken); - return result.Outcome == AIOperationOutcome.Success ? result.Content : "{}"; + return JsonSerializer.Deserialize( + result.Outcome == AIOperationOutcome.Success ? result.Content : "{}", + AIJsonDefaults.IndentedCamelCase) ?? new FormWorksheetResponse(); } catch (OperationCanceledException) { @@ -379,11 +316,11 @@ public async Task GenerateFormWorksheetAsync(MappingSuggestionRequest re catch (Exception ex) { _logger.LogError(ex, "Form worksheet generation failed."); - return "{}"; + return new FormWorksheetResponse(); } } - public async Task GenerateFormScoresheetAsync(MappingSuggestionRequest request, CancellationToken cancellationToken = default) + public async Task GenerateFormScoresheetAsync(FormScoresheetRequest request, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(request); try @@ -396,7 +333,7 @@ public async Task GenerateFormScoresheetAsync(MappingSuggestionRequest r var promptVersion = promptTemplate.PromptVersion; var dataJson = request.Data.GetRawText(); var systemPrompt = promptTemplate.SystemPrompt; - var content = AIPromptTemplateRenderer.BuildMappingSuggestionUserPrompt( + var content = AIPromptTemplateRenderer.BuildFormMappingUserPrompt( promptTemplate.UserPrompt, dataJson, promptTemplate.MetadataJson); @@ -409,12 +346,14 @@ public async Task GenerateFormScoresheetAsync(MappingSuggestionRequest r settings, settings.CompletionTokens, cancellationToken: cancellationToken), - AIProviderPayloadValidator.ValidateMappingSuggestionJson, + AIProviderPayloadValidator.ValidateFormMappingJson, "form scoresheet", cancellationToken); await _promptFileLogger.LogPromptOutputAsync(FormScoresheetPromptType, promptVersion, result.CaptureOutput, cancellationToken); - return result.Outcome == AIOperationOutcome.Success ? result.Content : "{}"; + return JsonSerializer.Deserialize( + result.Outcome == AIOperationOutcome.Success ? result.Content : "{}", + AIJsonDefaults.IndentedCamelCase) ?? new FormScoresheetResponse(); } catch (OperationCanceledException) { @@ -423,11 +362,11 @@ public async Task GenerateFormScoresheetAsync(MappingSuggestionRequest r catch (Exception ex) { _logger.LogError(ex, "Form scoresheet generation failed."); - return "{}"; + return new FormScoresheetResponse(); } } - private async Task GenerateMappingSuggestionAsync(MappingSuggestionRequest request, string promptType, CancellationToken cancellationToken = default) + private async Task GenerateFormMappingCoreAsync(FormMappingRequest request, string promptType, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(request); try @@ -440,7 +379,7 @@ private async Task GenerateMappingSuggestionAsync(Map var promptVersion = promptTemplate.PromptVersion; var dataJson = request.Data.GetRawText(); var systemPrompt = promptTemplate.SystemPrompt; - var content = AIPromptTemplateRenderer.BuildMappingSuggestionUserPrompt( + var content = AIPromptTemplateRenderer.BuildFormMappingUserPrompt( promptTemplate.UserPrompt, dataJson, promptTemplate.MetadataJson); @@ -453,17 +392,17 @@ private async Task GenerateMappingSuggestionAsync(Map settings, settings.CompletionTokens, cancellationToken: cancellationToken), - AIProviderPayloadValidator.ValidateMappingSuggestionJson, + AIProviderPayloadValidator.ValidateFormMappingJson, "mapping suggestion", cancellationToken); await _promptFileLogger.LogPromptOutputAsync(promptType, promptVersion, result.CaptureOutput, cancellationToken); if (result.Outcome != AIOperationOutcome.Success) { - return new MappingSuggestionResponse(); + return new FormMappingResponse(); } - return OpenAIResponseParser.ParseMappingSuggestionResponse(result.Content); + return OpenAIResponseParser.ParseFormMappingResponse(result.Content); } catch (OperationCanceledException) { @@ -472,12 +411,12 @@ private async Task GenerateMappingSuggestionAsync(Map catch (Exception ex) { _logger.LogError(ex, "Mapping suggestion generation failed."); - return new MappingSuggestionResponse(); + return new FormMappingResponse(); } } - public Task GenerateFormMappingAsync(MappingSuggestionRequest request, CancellationToken cancellationToken = default) => - GenerateMappingSuggestionAsync(request, MappingSuggestionPromptType, cancellationToken); + public Task GenerateFormMappingAsync(FormMappingRequest request, CancellationToken cancellationToken = default) => + GenerateFormMappingCoreAsync(request, FormMappingPromptType, cancellationToken); private async Task GenerateWithRetryAsync( Func> operation, diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/IApplicationFormVersionService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/IApplicationFormVersionService.cs index 838129221e..3cc7e220d4 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/IApplicationFormVersionService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/IApplicationFormVersionService.cs @@ -23,6 +23,6 @@ public interface IApplicationFormVersionAppService : ICrudAppService< Task GetByChefsFormVersionId(Guid chefsFormVersionId); Task GetFormVersionByApplicationIdAsync(Guid applicationId); Task DeleteWorkSheetMappingByFormName(string formName, Guid formVersionId); - Task GenerateMappingAsync(Guid id); + Task GenerateMappingAsync(Guid id); } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/Mapping/ApplicationFormMappingSuggestionDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/Mapping/ApplicationFormMappingDto.cs similarity index 60% rename from applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/Mapping/ApplicationFormMappingSuggestionDto.cs rename to applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/Mapping/ApplicationFormMappingDto.cs index c77bab6029..2c15bbf737 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/Mapping/ApplicationFormMappingSuggestionDto.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/Mapping/ApplicationFormMappingDto.cs @@ -3,11 +3,11 @@ namespace Unity.GrantManager.ApplicationForms.Mapping; -public class ApplicationFormMappingSuggestionDto +public class ApplicationFormMappingDto { public Guid ApplicationFormVersionId { get; set; } - public List CoreFieldMatches { get; set; } = []; - public List WorksheetMatches { get; set; } = []; + public List CoreFieldMatches { get; set; } = []; + public List WorksheetMatches { get; set; } = []; public List WorksheetCreationSuggestions { get; set; } = []; public List Issues { get; set; } = []; } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/Mapping/MappingSuggestionDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/Mapping/FormMappingDto.cs similarity index 89% rename from applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/Mapping/MappingSuggestionDto.cs rename to applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/Mapping/FormMappingDto.cs index e744c9ecbc..2e50566738 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/Mapping/MappingSuggestionDto.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/Mapping/FormMappingDto.cs @@ -1,6 +1,6 @@ namespace Unity.GrantManager.ApplicationForms.Mapping; -public class MappingSuggestionDto +public class FormMappingDto { public string SourceField { get; set; } = string.Empty; public string TargetField { get; set; } = string.Empty; diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/Mapping/WorksheetMappingSuggestionDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/Mapping/FormWorksheetDto.cs similarity index 57% rename from applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/Mapping/WorksheetMappingSuggestionDto.cs rename to applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/Mapping/FormWorksheetDto.cs index 03d0b68b64..33bf932d97 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/Mapping/WorksheetMappingSuggestionDto.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/Mapping/FormWorksheetDto.cs @@ -2,8 +2,8 @@ namespace Unity.GrantManager.ApplicationForms.Mapping; -public class WorksheetMappingSuggestionDto +public class FormWorksheetDto { public string WorksheetName { get; set; } = string.Empty; - public List FieldMatches { get; set; } = []; + public List FieldMatches { get; set; } = []; } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicationForms/ApplicationFormVersionAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicationForms/ApplicationFormVersionAppService.cs index bfdbfa0420..f63158d011 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicationForms/ApplicationFormVersionAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicationForms/ApplicationFormVersionAppService.cs @@ -321,32 +321,32 @@ public async Task DeleteWorkSheetMappingByFormName(string formName, Guid formVer await formVersionRepository.UpdateAsync(applicationFormVersion); } - public virtual async Task GenerateMappingAsync(Guid id) + public virtual async Task GenerateMappingAsync(Guid id) { var readModel = await _mappingReadService.GetAsync(id); - var response = await _aiService.GenerateFormMappingAsync(new MappingSuggestionRequest + var response = await _aiService.GenerateFormMappingAsync(new FormMappingRequest { Data = JsonSerializer.SerializeToElement(readModel) }); - var submissionHeaderMapping = MappingSuggestionResponseMapper.BuildSubmissionHeaderMapping(response); + var submissionHeaderMapping = FormMappingResponseMapper.BuildSubmissionHeaderMapping(response); var applicationFormVersion = await repository.GetAsync(id); applicationFormVersion.SubmissionHeaderMapping = JsonSerializer.Serialize(submissionHeaderMapping); await repository.UpdateAsync(applicationFormVersion, true); - return new ApplicationFormMappingSuggestionDto + return new ApplicationFormMappingDto { ApplicationFormVersionId = id, - CoreFieldMatches = response.CoreFieldMatches.Select(item => new MappingSuggestionDto + CoreFieldMatches = response.CoreFieldMatches.Select(item => new FormMappingDto { SourceField = item.SourceField, TargetField = item.TargetField, Reason = item.Reason, Confidence = item.Confidence }).ToList(), - WorksheetMatches = response.WorksheetMatches.Select(item => new WorksheetMappingSuggestionDto + WorksheetMatches = response.WorksheetMatches.Select(item => new FormWorksheetDto { WorksheetName = item.WorksheetName, - FieldMatches = item.FieldMatches.Select(match => new MappingSuggestionDto + FieldMatches = item.FieldMatches.Select(match => new FormMappingDto { SourceField = match.SourceField, TargetField = match.TargetField, diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicationForms/Mapping/MappingSuggestionResponseMapper.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicationForms/Mapping/FormMappingResponseMapper.cs similarity index 90% rename from applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicationForms/Mapping/MappingSuggestionResponseMapper.cs rename to applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicationForms/Mapping/FormMappingResponseMapper.cs index 7f8dd664f7..f103776e1c 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicationForms/Mapping/MappingSuggestionResponseMapper.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicationForms/Mapping/FormMappingResponseMapper.cs @@ -4,9 +4,9 @@ namespace Unity.GrantManager.ApplicationForms.Mapping; -internal static class MappingSuggestionResponseMapper +internal static class FormMappingResponseMapper { - internal static Dictionary BuildSubmissionHeaderMapping(MappingSuggestionResponse response) + internal static Dictionary BuildSubmissionHeaderMapping(FormMappingResponse response) { var mapping = new Dictionary(StringComparer.OrdinalIgnoreCase); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateFormMappingJob.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateFormMappingJob.cs index 5dc8e26172..5abcf3e990 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateFormMappingJob.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateFormMappingJob.cs @@ -52,13 +52,13 @@ await AIGenerationRequestJobHelper.MarkRunningInNewUowAsync( try { var readModel = await mappingReadService.GetAsync(args.ApplicationFormVersionId); - var response = await aiService.GenerateFormMappingAsync(new MappingSuggestionRequest + var response = await aiService.GenerateFormMappingAsync(new FormMappingRequest { Data = JsonSerializer.SerializeToElement(readModel), PromptVersion = args.PromptVersion }); - var submissionHeaderMapping = MappingSuggestionResponseMapper.BuildSubmissionHeaderMapping(response); + var submissionHeaderMapping = FormMappingResponseMapper.BuildSubmissionHeaderMapping(response); var applicationFormVersion = await applicationFormVersionRepository.GetAsync(args.ApplicationFormVersionId); applicationFormVersion.SubmissionHeaderMapping = JsonSerializer.Serialize(submissionHeaderMapping); await applicationFormVersionRepository.UpdateAsync(applicationFormVersion, true); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateFormScoresheetJob.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateFormScoresheetJob.cs index a880028201..57a3488a6c 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateFormScoresheetJob.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateFormScoresheetJob.cs @@ -104,12 +104,13 @@ await AIGenerationRequestJobHelper.MarkRunningInNewUowAsync( } }; - var scoresheetJson = await aiService.GenerateFormScoresheetAsync(new MappingSuggestionRequest + var scoresheetResponse = await aiService.GenerateFormScoresheetAsync(new FormScoresheetRequest { Data = JsonSerializer.SerializeToElement(promptData), PromptVersion = args.PromptVersion }); + var scoresheetJson = JsonSerializer.Serialize(scoresheetResponse); var importDto = ParseScoresheetDefinition(scoresheetJson); var scoresheet = existingScoresheet == null ? BuildScoresheet(importDto, scoresheetJson, scoresheetName) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateFormWorksheetJob.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateFormWorksheetJob.cs index 28ae6021c7..7f585881ce 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateFormWorksheetJob.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateFormWorksheetJob.cs @@ -105,12 +105,13 @@ await AIGenerationRequestJobHelper.MarkRunningInNewUowAsync( }) }; - var worksheetJson = await aiService.GenerateFormWorksheetAsync(new MappingSuggestionRequest + var worksheetResponse = await aiService.GenerateFormWorksheetAsync(new FormWorksheetRequest { Data = JsonSerializer.SerializeToElement(promptData), PromptVersion = args.PromptVersion }); + var worksheetJson = JsonSerializer.Serialize(worksheetResponse); var createDto = ParseWorksheetDefinition(worksheetJson); var worksheet = existingWorksheet == null ? BuildWorksheet(createDto, worksheetName) diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/Operations/AttachmentSummaryServiceTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/Operations/AttachmentSummaryServiceTests.cs index e4b75df95a..e3f9439e7c 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/Operations/AttachmentSummaryServiceTests.cs +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/Operations/AttachmentSummaryServiceTests.cs @@ -143,96 +143,6 @@ public async Task GenerateAndSaveAsync_Should_Not_Call_AI_When_Supported_File_Ex await aiService.DidNotReceive().GenerateAttachmentSummaryAsync(Arg.Any()); } - [Fact] - public async Task GenerateAndSaveAsync_Should_Use_Batch_Mode_When_Configured() - { - var firstAttachmentId = Guid.NewGuid(); - var secondAttachmentId = Guid.NewGuid(); - var applicationId = Guid.NewGuid(); - var submissionId = Guid.NewGuid(); - var fileId1 = Guid.NewGuid(); - var fileId2 = Guid.NewGuid(); - var stream1 = new MemoryStream([1, 2, 3]); - var stream2 = new MemoryStream([4, 5, 6]); - - var firstAttachment = new ApplicationChefsFileAttachment - { - ApplicationId = Guid.NewGuid(), - FileName = "first.txt", - ChefsSubmissionId = submissionId.ToString(), - ChefsFileId = fileId1.ToString() - }; - - var secondAttachment = new ApplicationChefsFileAttachment - { - ApplicationId = firstAttachment.ApplicationId, - FileName = "second.txt", - ChefsSubmissionId = submissionId.ToString(), - ChefsFileId = fileId2.ToString() - }; - - var persistence = Substitute.For(); - persistence.GetAttachmentAsync(firstAttachmentId).Returns(new AttachmentSummarySource( - firstAttachmentId, - firstAttachment.FileName, - firstAttachment.ChefsSubmissionId, - firstAttachment.ChefsFileId)); - persistence.GetAttachmentAsync(secondAttachmentId).Returns(new AttachmentSummarySource( - secondAttachmentId, - secondAttachment.FileName, - secondAttachment.ChefsSubmissionId, - secondAttachment.ChefsFileId)); - persistence.GetApplicationAttachmentIdsAsync(applicationId).Returns([firstAttachmentId, secondAttachmentId]); - - var streamProvider = Substitute.For(); - streamProvider.OpenAsync(submissionId, fileId1, "first.txt") - .Returns(new ChefsFileAttachmentStream(stream1, "text/plain")); - streamProvider.OpenAsync(submissionId, fileId2, "second.txt") - .Returns(new ChefsFileAttachmentStream(stream2, "text/plain")); - - var textExtractionService = Substitute.For(); - textExtractionService.ExtractTextAsync("first.txt", stream1, "text/plain", Arg.Any()) - .Returns("first extracted"); - textExtractionService.ExtractTextAsync("second.txt", stream2, "text/plain", Arg.Any()) - .Returns("second extracted"); - - var aiService = Substitute.For(); - aiService.GenerateAttachmentSummaryBatchAsync(Arg.Any()) - .Returns(new AttachmentSummaryBatchResponse - { - Attachments = - { - new AttachmentSummaryBatchItemResponse { AttachmentId = firstAttachmentId.ToString(), Summary = "first summary" }, - new AttachmentSummaryBatchItemResponse { AttachmentId = secondAttachmentId.ToString(), Summary = "second summary" } - } - }); - - var configuration = new ConfigurationBuilder() - .AddInMemoryCollection(new Dictionary - { - [$"Azure:Operations:{AIExecutionModeResolver.AttachmentSummaryOperation}:ExecutionMode"] = "Batch" - }) - .Build(); - - var service = new AttachmentSummaryService( - persistence, - streamProvider, - textExtractionService, - aiService, - Substitute.For(), - new AIExecutionModeResolver(configuration), - CreateUnitOfWorkManager(), - NullLogger.Instance, - Substitute.For>()); - - var summaries = await service.GenerateAndSaveAsync([firstAttachmentId, secondAttachmentId], "v1"); - - summaries.ShouldBe(["first summary", "second summary"]); - await aiService.Received(1).GenerateAttachmentSummaryBatchAsync(Arg.Any()); - await persistence.Received(1).UpdateAttachmentSummaryAsync(firstAttachmentId, "first summary"); - await persistence.Received(1).UpdateAttachmentSummaryAsync(secondAttachmentId, "second summary"); - } - [Fact] public async Task GenerateAndSaveAsync_Should_Pass_Extracted_Docx_Text_To_AI() { @@ -347,7 +257,6 @@ private static AttachmentSummaryService CreateService( textExtractionService, aiService, Substitute.For(), - new AIExecutionModeResolver(new ConfigurationBuilder().Build()), CreateUnitOfWorkManager(), NullLogger.Instance, Substitute.For>()); diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/Runtime/AIProviderPayloadValidatorTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/Runtime/AIProviderPayloadValidatorTests.cs index 87636a292d..c293e61011 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/Runtime/AIProviderPayloadValidatorTests.cs +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/Runtime/AIProviderPayloadValidatorTests.cs @@ -175,35 +175,4 @@ public void ValidateAttachmentSummaryText_Should_Return_InvalidOutput_For_Empty_ reason.ShouldContain("empty"); } - [Fact] - public void ValidateAttachmentSummaryBatchJson_Should_Return_Success_For_Valid_Items() - { - var result = AIProviderPayloadValidator.ValidateAttachmentSummaryBatchJson( - """ - { - "attachments": [ - { - "attachmentId": "a1", - "summary": "One" - }, - { - "attachmentId": "a2", - "summary": "Two" - } - ] - } - """); - - result.IsValid.ShouldBeTrue(); - } - - [Fact] - public void ValidateAttachmentSummaryBatchJson_Should_Return_InvalidOutput_When_Attachments_Are_Missing() - { - var result = AIProviderPayloadValidator.ValidateAttachmentSummaryBatchJson("{}"); - - result.IsValid.ShouldBeFalse(); - result.FailureCategory.ShouldBe(AIFailureCategory.InvalidOutput); - result.Reason.ShouldContain("attachments"); - } } diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/Runtime/OpenAIResponseParserTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/Runtime/OpenAIResponseParserTests.cs index ce8d01059f..0d8e29b153 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/Runtime/OpenAIResponseParserTests.cs +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/Runtime/OpenAIResponseParserTests.cs @@ -150,24 +150,4 @@ public void ParseApplicationScoringResponse_Should_Round_Decimal_Confidence_To_N result.Answers["q2"].Confidence.ShouldBe(90); } - [Fact] - public void ParseAttachmentSummaryBatchResponse_Should_Map_Attachment_Ids_To_Summaries() - { - var raw = """ - { - "attachments": [ - { "attachmentId": "a1", "summary": "One" }, - { "attachmentId": "a2", "summary": "Two" } - ] - } - """; - - var result = OpenAIResponseParser.ParseAttachmentSummaryBatchResponse(raw); - - result.Attachments.Count.ShouldBe(2); - result.Attachments[0].AttachmentId.ShouldBe("a1"); - result.Attachments[0].Summary.ShouldBe("One"); - result.Attachments[1].AttachmentId.ShouldBe("a2"); - result.Attachments[1].Summary.ShouldBe("Two"); - } } diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/ApplicationForms/ApplicationFormVersionAppServiceTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/ApplicationForms/ApplicationFormVersionAppServiceTests.cs index 87c6bdbc69..f926d1c97e 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/ApplicationForms/ApplicationFormVersionAppServiceTests.cs +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/ApplicationForms/ApplicationFormVersionAppServiceTests.cs @@ -30,7 +30,7 @@ public class ApplicationFormVersionAppServiceTests(ITestOutputHelper outputHelpe public async Task GenerateMappingAsync_Should_Save_SubmissionHeaderMapping_From_Ai_Response() { var formVersionId = Guid.NewGuid(); - MappingSuggestionRequest? capturedRequest = null; + FormMappingRequest? capturedRequest = null; var repository = Substitute.For>(); var formVersion = new ApplicationFormVersion { @@ -58,12 +58,12 @@ public async Task GenerateMappingAsync_Should_Save_SubmissionHeaderMapping_From_ }); var aiService = Substitute.For(); - aiService.GenerateFormMappingAsync(Arg.Do(request => capturedRequest = request), Arg.Any()) - .Returns(new MappingSuggestionResponse + aiService.GenerateFormMappingAsync(Arg.Do(request => capturedRequest = request), Arg.Any()) + .Returns(new FormMappingResponse { CoreFieldMatches = [ - new MappingSuggestionItemResponse + new FormMappingMatchResponse { SourceField = "ProjectName", TargetField = "ProjectName", From 8794a6bba83c5fd8fb4465fdeeb8ea49626fa7a0 Mon Sep 17 00:00:00 2001 From: Jacob Smith Date: Sat, 11 Jul 2026 13:28:35 -0700 Subject: [PATCH 062/223] AB#33569 add form generation operations and queue wiring --- .../IApplicationAIGenerationQueue.cs | 16 -- .../Generation/AIGenerationRequestDto.cs | 14 -- .../Generation/AIGenerationStatusDto.cs | 20 +- .../AIGenerationStatusRequestDto.cs | 24 --- .../Generation/FormMappingResultDto.cs | 6 - .../Generation/FormScoresheetResultDto.cs | 6 - .../Generation/FormWorksheetResultDto.cs | 6 - .../GenerateAttachmentSummariesInputDto.cs | 13 -- .../Generation/IAIGenerationAppService.cs | 14 +- .../AI/Runtime/AIPromptTemplateSnapshot.cs | 20 -- .../AI/Runtime/AIProviderPayloadValidator.cs | 30 +-- .../AI/Runtime/OpenAIResponseParser.cs | 120 ++--------- .../AI/Runtime/OpenAIRuntimeService.cs | 45 ++-- .../DataSeed/AIOperationDataSeeder.cs | 3 +- .../DataSeed/AIPromptDataSeeder.cs | 6 +- .../Domain/AIOperation.cs | 1 + .../Generation/AIGenerationAppService.cs | 68 +++--- .../Unity.AI.Application.csproj | 2 +- .../AIGenerationStatusDto.cs | 1 - .../ApplicationFormVersionAppService.cs | 45 +--- .../Mapping/FormMappingResponseMapper.cs | 32 +-- .../AIGenerationActivityProvider.cs | 4 +- .../ApplicationAIGenerationQueue.cs | 28 +-- .../AIGenerationRequestJobHelper.cs | 10 +- .../GenerateApplicationAnalysisJob.cs | 15 +- .../GenerateApplicationScoringJob.cs | 20 +- .../BackgroundJobs/GenerateFormMappingJob.cs | 12 +- .../GenerateFormScoresheetJob.cs | 12 +- .../GenerateFormWorksheetJob.cs | 12 +- ...ueApplicationAIPipelineOnProcessHandler.cs | 4 +- .../GrantApplicationAppService.cs | 21 +- .../AI/Operations/AIExecutionStrategyTests.cs | 2 +- .../ApplicationAnalysisServiceTests.cs | 82 ++++--- .../ApplicationScoringServiceTests.cs | 201 +++++++++++++----- .../Runtime/AIPromptTemplateProviderTests.cs | 6 +- .../OpenAIConfigurationResolverTests.cs | 1 + .../ApplicationFormVersionAppServiceTests.cs | 12 +- .../Automation/AIGenerationAppServiceTests.cs | 46 ++-- .../Automation/AIGenerationQueueTests.cs | 109 +++++----- 39 files changed, 465 insertions(+), 624 deletions(-) delete mode 100644 applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Automation/IApplicationAIGenerationQueue.cs delete mode 100644 applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Generation/AIGenerationRequestDto.cs delete mode 100644 applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Generation/AIGenerationStatusRequestDto.cs delete mode 100644 applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Generation/FormMappingResultDto.cs delete mode 100644 applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Generation/FormScoresheetResultDto.cs delete mode 100644 applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Generation/FormWorksheetResultDto.cs delete mode 100644 applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Generation/GenerateAttachmentSummariesInputDto.cs diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Automation/IApplicationAIGenerationQueue.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Automation/IApplicationAIGenerationQueue.cs deleted file mode 100644 index 58cc0fa925..0000000000 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Automation/IApplicationAIGenerationQueue.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Threading.Tasks; - -namespace Unity.AI.Automation; - -public interface IApplicationAIGenerationQueue -{ - Task QueueAttachmentSummaryAsync(Guid applicationId, Guid? tenantId, string? promptVersion = null, List? attachmentIds = null); - Task QueueApplicationAnalysisAsync(Guid applicationId, Guid? tenantId, string? promptVersion = null); - Task QueueApplicationScoringAsync(Guid applicationId, Guid? tenantId, string? promptVersion = null); - Task QueueFormMappingAsync(Guid applicationId, Guid? tenantId, Guid applicationFormVersionId, string? promptVersion = null); - Task QueueFormWorksheetAsync(Guid applicationId, Guid? tenantId, Guid applicationFormVersionId, string? promptVersion = null); - Task QueueFormScoresheetAsync(Guid applicationId, Guid? tenantId, Guid applicationFormVersionId, string? promptVersion = null); - Task QueueAllAIStagesAsync(Guid applicationId, Guid? tenantId, string? promptVersion = null); -} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Generation/AIGenerationRequestDto.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Generation/AIGenerationRequestDto.cs deleted file mode 100644 index 3a832df03c..0000000000 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Generation/AIGenerationRequestDto.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System; - -namespace Unity.AI.Generation; - -public class AIGenerationRequestDto -{ - public Guid ApplicationId { get; set; } - - public Guid OperationId { get; set; } - - public string OperationType { get; set; } = string.Empty; - - public string Status { get; set; } = string.Empty; -} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Generation/AIGenerationStatusDto.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Generation/AIGenerationStatusDto.cs index ca5684257c..6f6fba918c 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Generation/AIGenerationStatusDto.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Generation/AIGenerationStatusDto.cs @@ -1,12 +1,24 @@ +using System; + namespace Unity.AI.Generation; public class AIGenerationStatusDto { - public AIGenerationStatusRequestDto? GenerationRequest { get; set; } + public Guid Id { get; set; } - public string? FailureReason { get; set; } + public Guid? ApplicationId { get; set; } + + public Guid? OperationId { get; set; } + + public string OperationType { get; set; } = string.Empty; - public bool IsGenerating { get; set; } + public string Status { get; set; } = string.Empty; + + public DateTime? StartedAt { get; set; } + + public DateTime? CompletedAt { get; set; } + + public string? FailureReason { get; set; } - public int RetryAfterSeconds { get; set; } + public bool IsActive { get; set; } } diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Generation/AIGenerationStatusRequestDto.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Generation/AIGenerationStatusRequestDto.cs deleted file mode 100644 index b80e09f433..0000000000 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Generation/AIGenerationStatusRequestDto.cs +++ /dev/null @@ -1,24 +0,0 @@ -using System; - -namespace Unity.AI.Generation; - -public class AIGenerationStatusRequestDto -{ - public Guid Id { get; set; } - - public Guid? ApplicationId { get; set; } - - public Guid? OperationId { get; set; } - - public string OperationType { get; set; } = string.Empty; - - public string Status { get; set; } = string.Empty; - - public DateTime? StartedAt { get; set; } - - public DateTime? CompletedAt { get; set; } - - public string? FailureReason { get; set; } - - public bool IsActive { get; set; } -} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Generation/FormMappingResultDto.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Generation/FormMappingResultDto.cs deleted file mode 100644 index 8e3a54506c..0000000000 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Generation/FormMappingResultDto.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Unity.AI.Generation; - -public class FormMappingResultDto -{ - public bool Completed { get; set; } -} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Generation/FormScoresheetResultDto.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Generation/FormScoresheetResultDto.cs deleted file mode 100644 index cc526e5a27..0000000000 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Generation/FormScoresheetResultDto.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Unity.AI.Generation; - -public class FormScoresheetResultDto -{ - public bool Completed { get; set; } -} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Generation/FormWorksheetResultDto.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Generation/FormWorksheetResultDto.cs deleted file mode 100644 index 6b356d3bc6..0000000000 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Generation/FormWorksheetResultDto.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Unity.AI.Generation; - -public class FormWorksheetResultDto -{ - public bool Completed { get; set; } -} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Generation/GenerateAttachmentSummariesInputDto.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Generation/GenerateAttachmentSummariesInputDto.cs deleted file mode 100644 index 82956d14de..0000000000 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Generation/GenerateAttachmentSummariesInputDto.cs +++ /dev/null @@ -1,13 +0,0 @@ -using System; -using System.Collections.Generic; - -namespace Unity.AI.Generation; - -public class GenerateAttachmentSummariesInputDto -{ - public Guid ApplicationId { get; set; } - - public List AttachmentIds { get; set; } = []; - - public string? PromptVersion { get; set; } -} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Generation/IAIGenerationAppService.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Generation/IAIGenerationAppService.cs index bc8915c23b..7fd7ba1757 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Generation/IAIGenerationAppService.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Generation/IAIGenerationAppService.cs @@ -1,25 +1,23 @@ using System; using System.Collections.Generic; using System.Threading.Tasks; -using Unity.GrantManager.Attachments; -using Unity.GrantManager.GrantApplications; using Volo.Abp.Application.Services; namespace Unity.AI.Generation; public interface IAIGenerationAppService : IApplicationService { - Task> GenerateAttachmentSummariesAsync(GenerateAttachmentSummariesInputDto input); + Task GenerateApplicationAttachmentSummariesAsync(Guid applicationId, List attachmentIds, string? promptVersion = null); - Task GenerateApplicationAnalysisAsync(Guid applicationId, string? promptVersion = null); + Task GenerateApplicationAnalysisAsync(Guid applicationId, string? promptVersion = null); - Task GenerateApplicationScoringAsync(Guid applicationId, string? promptVersion = null); + Task GenerateApplicationScoringAsync(Guid applicationId, string? promptVersion = null); - Task GenerateFormMappingAsync(Guid applicationId, Guid applicationFormVersionId, string? promptVersion = null); + Task GenerateFormMappingAsync(Guid applicationId, Guid applicationFormVersionId, string? promptVersion = null); - Task GenerateFormWorksheetAsync(Guid applicationId, Guid applicationFormVersionId, string? promptVersion = null); + Task GenerateFormWorksheetAsync(Guid applicationId, Guid applicationFormVersionId, string? promptVersion = null); - Task GenerateFormScoresheetAsync(Guid applicationId, Guid applicationFormVersionId, string? promptVersion = null); + Task GenerateFormScoresheetAsync(Guid applicationId, Guid applicationFormVersionId, string? promptVersion = null); Task GetStatusAsync(Guid applicationId, string operationType); } diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/AIPromptTemplateSnapshot.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/AIPromptTemplateSnapshot.cs index 115cc229fe..7f78c2879f 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/AIPromptTemplateSnapshot.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/AIPromptTemplateSnapshot.cs @@ -1,6 +1,4 @@ using System; -using System.Text.Json; -using Unity.AI.Prompts; namespace Unity.AI.Runtime; @@ -10,22 +8,4 @@ public sealed record AIPromptTemplateSnapshot( string UserPrompt, string? MetadataJson) { - public UnityPromptAssetManifest? Manifest { get; } = ParseManifest(MetadataJson); - - private static UnityPromptAssetManifest? ParseManifest(string? metadataJson) - => string.IsNullOrWhiteSpace(metadataJson) - ? null - : TryDeserialize(metadataJson); - - private static UnityPromptAssetManifest? TryDeserialize(string metadataJson) - { - try - { - return JsonSerializer.Deserialize(metadataJson); - } - catch - { - return null; - } - } } diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/AIProviderPayloadValidator.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/AIProviderPayloadValidator.cs index b15b9e18c8..fec9af3f4e 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/AIProviderPayloadValidator.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/AIProviderPayloadValidator.cs @@ -21,48 +21,48 @@ public static AIResponseValidationResult ValidateApplicationAnalysisJson(string return AIResponseValidationResult.Invalid("Application analysis response was not valid JSON."); } - if (!root.TryGetProperty(AIJsonKeys.Decision, out var decision) || decision.ValueKind != JsonValueKind.String) + if (!root.TryGetProperty("decision", out var decision) || decision.ValueKind != JsonValueKind.String) { - return AIResponseValidationResult.Invalid($"Application analysis response is missing or invalid required field '{AIJsonKeys.Decision}' (expected string)."); + return AIResponseValidationResult.Invalid("Application analysis response is missing or invalid required field 'decision' (expected string)."); } var normalizedDecision = (decision.GetString() ?? string.Empty).Trim().ToUpperInvariant(); if (normalizedDecision != "PROCEED" && normalizedDecision != "HOLD") { return AIResponseValidationResult.Invalid( - $"Application analysis response has invalid '{AIJsonKeys.Decision}' value. Expected 'PROCEED' or 'HOLD'."); + "Application analysis response has invalid 'decision' value. Expected 'PROCEED' or 'HOLD'."); } - if (!root.TryGetProperty(AIJsonKeys.Errors, out var errors) || errors.ValueKind != JsonValueKind.Array) + if (!root.TryGetProperty("errors", out var errors) || errors.ValueKind != JsonValueKind.Array) { - return AIResponseValidationResult.Invalid($"Application analysis response is missing or invalid required field '{AIJsonKeys.Errors}' (expected array)."); + return AIResponseValidationResult.Invalid("Application analysis response is missing or invalid required field 'errors' (expected array)."); } - if (!root.TryGetProperty(AIJsonKeys.Warnings, out var warnings) || warnings.ValueKind != JsonValueKind.Array) + if (!root.TryGetProperty("warnings", out var warnings) || warnings.ValueKind != JsonValueKind.Array) { - return AIResponseValidationResult.Invalid($"Application analysis response is missing or invalid required field '{AIJsonKeys.Warnings}' (expected array)."); + return AIResponseValidationResult.Invalid("Application analysis response is missing or invalid required field 'warnings' (expected array)."); } - if (!root.TryGetProperty(AIJsonKeys.Summaries, out var summaries) || summaries.ValueKind != JsonValueKind.Array) + if (!root.TryGetProperty("summaries", out var summaries) || summaries.ValueKind != JsonValueKind.Array) { - return AIResponseValidationResult.Invalid($"Application analysis response is missing or invalid required field '{AIJsonKeys.Summaries}' (expected array)."); + return AIResponseValidationResult.Invalid("Application analysis response is missing or invalid required field 'summaries' (expected array)."); } - if (!root.TryGetProperty(AIJsonKeys.Recommendations, out var recommendations) || recommendations.ValueKind != JsonValueKind.Array) + if (!root.TryGetProperty("recommendations", out var recommendations) || recommendations.ValueKind != JsonValueKind.Array) { - return AIResponseValidationResult.Invalid($"Application analysis response is missing or invalid required field '{AIJsonKeys.Recommendations}' (expected array)."); + return AIResponseValidationResult.Invalid("Application analysis response is missing or invalid required field 'recommendations' (expected array)."); } if (summaries.GetArrayLength() == 0) { return AIResponseValidationResult.Invalid( - $"Application analysis response must include at least one item in '{AIJsonKeys.Summaries}'."); + "Application analysis response must include at least one item in 'summaries'."); } if (recommendations.GetArrayLength() == 0) { return AIResponseValidationResult.Invalid( - $"Application analysis response must include at least one item in '{AIJsonKeys.Recommendations}'."); + "Application analysis response must include at least one item in 'recommendations'."); } return AIResponseValidationResult.Success(); @@ -89,7 +89,7 @@ public static AIResponseValidationResult ValidateApplicationScoringJson(string r $"Application scoring response is missing required answer object for question id '{questionId}'."); } - if (!answerObject.TryGetProperty(AIJsonKeys.Answer, out var answerValue) + if (!answerObject.TryGetProperty("answer", out var answerValue) || answerValue.ValueKind == JsonValueKind.Null || answerValue.ValueKind == JsonValueKind.Object || answerValue.ValueKind == JsonValueKind.Array) @@ -98,7 +98,7 @@ public static AIResponseValidationResult ValidateApplicationScoringJson(string r $"Application scoring response is missing a valid answer for question id '{questionId}'."); } - if (!answerObject.TryGetProperty(AIJsonKeys.Confidence, out var confidenceValue) + if (!answerObject.TryGetProperty("confidence", out var confidenceValue) || confidenceValue.ValueKind != JsonValueKind.Number || !confidenceValue.TryGetDecimal(out var confidence) || confidence < 0m diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIResponseParser.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIResponseParser.cs index a8b08e237e..1cb90c3455 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIResponseParser.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIResponseParser.cs @@ -19,27 +19,27 @@ public static ApplicationAnalysisResponse ParseApplicationAnalysisResponse(strin return response; } - if (TryGetStringProperty(root, AIJsonKeys.Decision, out var decision)) + if (TryGetStringProperty(root, "decision", out var decision)) { response.Decision = decision.Trim().ToUpperInvariant(); } - if (TryGetArrayProperty(root, AIJsonKeys.Errors, out var errorsArray)) + if (TryGetArrayProperty(root, "errors", out var errorsArray)) { response.Errors = ParseFindings(errorsArray).ToList(); } - if (TryGetArrayProperty(root, AIJsonKeys.Warnings, out var warningsArray)) + if (TryGetArrayProperty(root, "warnings", out var warningsArray)) { response.Warnings = ParseFindings(warningsArray).ToList(); } - if (TryGetArrayProperty(root, AIJsonKeys.Summaries, out var summariesArray)) + if (TryGetArrayProperty(root, "summaries", out var summariesArray)) { response.Summaries = ParseFindings(summariesArray).ToList(); } - if (TryGetArrayProperty(root, AIJsonKeys.Recommendations, out var recommendationsArray)) + if (TryGetArrayProperty(root, "recommendations", out var recommendationsArray)) { response.Recommendations = ParseFindings(recommendationsArray).ToList(); } @@ -93,59 +93,15 @@ public static ApplicationScoringResponse ParseApplicationScoringResponse(string public static FormMappingResponse ParseFormMappingResponse(string raw) { - var response = new FormMappingResponse(); if (!TryParseJsonObjectFromResponse(raw, out var root)) { - return response; - } - - if (root.TryGetProperty("coreFieldMatches", out var coreFieldMatches) && coreFieldMatches.ValueKind == JsonValueKind.Array) - { - response.CoreFieldMatches = ParseFormMappingMatches(coreFieldMatches).ToList(); - } - - if (root.TryGetProperty("worksheetMatches", out var worksheetMatches) && worksheetMatches.ValueKind == JsonValueKind.Array) - { - response.WorksheetMatches = worksheetMatches.EnumerateArray() - .Where(item => item.ValueKind == JsonValueKind.Object) - .Select(item => new FormMappingWorksheetResponse - { - WorksheetName = item.TryGetProperty("worksheetName", out var name) && name.ValueKind == JsonValueKind.String ? name.GetString() ?? string.Empty : string.Empty, - FieldMatches = item.TryGetProperty("fieldMatches", out var matches) && matches.ValueKind == JsonValueKind.Array - ? ParseFormMappingMatches(matches).ToList() - : [] - }) - .ToList(); - } - - if (root.TryGetProperty("worksheetCreationSuggestions", out var worksheetCreationSuggestions) && worksheetCreationSuggestions.ValueKind == JsonValueKind.Array) - { - response.WorksheetCreationSuggestions = worksheetCreationSuggestions.EnumerateArray() - .Where(item => item.ValueKind == JsonValueKind.Object) - .Select(item => new FormWorksheetCreationResponse - { - WorksheetName = item.TryGetProperty("worksheetName", out var name) && name.ValueKind == JsonValueKind.String ? name.GetString() ?? string.Empty : string.Empty, - Reason = item.TryGetProperty("reason", out var reason) && reason.ValueKind == JsonValueKind.String ? reason.GetString() ?? string.Empty : string.Empty, - SuggestedFields = item.TryGetProperty("suggestedFields", out var fields) && fields.ValueKind == JsonValueKind.Array - ? ParseMappingFields(fields).ToList() - : [] - }) - .ToList(); + return new FormMappingResponse(); } - if (root.TryGetProperty("issues", out var issues) && issues.ValueKind == JsonValueKind.Array) + return new FormMappingResponse { - response.Issues = issues.EnumerateArray() - .Where(item => item.ValueKind == JsonValueKind.Object) - .Select(item => new FormMappingIssueResponse - { - Code = item.TryGetProperty("code", out var code) && code.ValueKind == JsonValueKind.String ? code.GetString() ?? string.Empty : string.Empty, - Message = item.TryGetProperty("message", out var message) && message.ValueKind == JsonValueKind.String ? message.GetString() ?? string.Empty : string.Empty - }) - .ToList(); - } - - return response; + Mapping = root.GetRawText() + }; } private static string AddIdsToAnalysisItems(string analysisJson) @@ -162,10 +118,10 @@ private static string AddIdsToAnalysisItems(string analysisJson) { var outputPropertyName = property.Name; - if (outputPropertyName == AIJsonKeys.Errors || - outputPropertyName == AIJsonKeys.Warnings || - outputPropertyName == AIJsonKeys.Summaries || - outputPropertyName == AIJsonKeys.Recommendations) + if (outputPropertyName == "errors" || + outputPropertyName == "warnings" || + outputPropertyName == "summaries" || + outputPropertyName == "recommendations") { writer.WritePropertyName(outputPropertyName); writer.WriteStartArray(); @@ -179,11 +135,11 @@ private static string AddIdsToAnalysisItems(string analysisJson) itemProperty.WriteTo(writer); } - if (!item.TryGetProperty(AIJsonKeys.Id, out var idProp) || + if (!item.TryGetProperty("id", out var idProp) || idProp.ValueKind != JsonValueKind.String || string.IsNullOrWhiteSpace(idProp.GetString())) { - writer.WriteString(AIJsonKeys.Id, Guid.NewGuid().ToString()); + writer.WriteString("id", Guid.NewGuid().ToString()); } writer.WriteEndObject(); @@ -218,23 +174,23 @@ private static IEnumerable ParseFindings(JsonElement } var id = Guid.NewGuid().ToString(); - if (item.TryGetProperty(AIJsonKeys.Id, out var idProp) && idProp.ValueKind == JsonValueKind.String) + if (item.TryGetProperty("id", out var idProp) && idProp.ValueKind == JsonValueKind.String) { id = idProp.GetString() ?? id; } - var dismissed = item.TryGetProperty(AIJsonKeys.Dismissed, out var dismissedProp) && + var dismissed = item.TryGetProperty("dismissed", out var dismissedProp) && (dismissedProp.ValueKind == JsonValueKind.True || dismissedProp.ValueKind == JsonValueKind.False) && dismissedProp.GetBoolean(); string? title = null; - if (item.TryGetProperty(AIJsonKeys.Title, out var titleProp) && titleProp.ValueKind == JsonValueKind.String) + if (item.TryGetProperty("title", out var titleProp) && titleProp.ValueKind == JsonValueKind.String) { title = titleProp.GetString(); } string? detail = null; - if (item.TryGetProperty(AIJsonKeys.Detail, out var detailProp) && detailProp.ValueKind == JsonValueKind.String) + if (item.TryGetProperty("detail", out var detailProp) && detailProp.ValueKind == JsonValueKind.String) { detail = detailProp.GetString(); } @@ -249,44 +205,6 @@ private static IEnumerable ParseFindings(JsonElement } } - private static IEnumerable ParseFormMappingMatches(JsonElement itemsArray) - { - foreach (var item in itemsArray.EnumerateArray()) - { - if (item.ValueKind != JsonValueKind.Object) - { - continue; - } - - yield return new FormMappingMatchResponse - { - SourceField = item.TryGetProperty("sourceField", out var sourceField) && sourceField.ValueKind == JsonValueKind.String ? sourceField.GetString() ?? string.Empty : string.Empty, - TargetField = item.TryGetProperty("targetField", out var targetField) && targetField.ValueKind == JsonValueKind.String ? targetField.GetString() ?? string.Empty : string.Empty, - Reason = item.TryGetProperty("reason", out var reason) && reason.ValueKind == JsonValueKind.String ? reason.GetString() ?? string.Empty : string.Empty, - Confidence = item.TryGetProperty("confidence", out var confidence) && confidence.ValueKind == JsonValueKind.Number && confidence.TryGetDecimal(out var parsedConfidence) ? parsedConfidence : 0m - }; - } - } - - private static IEnumerable ParseMappingFields(JsonElement itemsArray) - { - foreach (var item in itemsArray.EnumerateArray()) - { - if (item.ValueKind != JsonValueKind.Object) - { - continue; - } - - yield return new FormMappingFieldResponse - { - Name = item.TryGetProperty("name", out var name) && name.ValueKind == JsonValueKind.String ? name.GetString() ?? string.Empty : string.Empty, - Type = item.TryGetProperty("type", out var type) && type.ValueKind == JsonValueKind.String ? type.GetString() ?? string.Empty : string.Empty, - Label = item.TryGetProperty("label", out var label) && label.ValueKind == JsonValueKind.String ? label.GetString() ?? string.Empty : string.Empty, - IsCustom = item.TryGetProperty("isCustom", out var isCustom) && (isCustom.ValueKind == JsonValueKind.True || isCustom.ValueKind == JsonValueKind.False) && isCustom.GetBoolean() - }; - } - } - private static bool TryParseJsonObjectFromResponse(string response, out JsonElement objectElement) { objectElement = default; diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIRuntimeService.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIRuntimeService.cs index f51fcab9ca..971c88c909 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIRuntimeService.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIRuntimeService.cs @@ -6,6 +6,7 @@ using System.Threading; using System.Threading.Tasks; using Unity.AI.Models; +using Unity.AI.Operations; using Unity.AI.Prompts; using Unity.AI.Requests; using Unity.AI.Responses; @@ -14,7 +15,14 @@ namespace Unity.AI.Runtime { - [ExposeServices(typeof(IAIService))] + [ExposeServices( + typeof(IAIService), + typeof(IApplicationAnalysisService), + typeof(IApplicationScoringService), + typeof(IApplicationAttachmentSummaryService), + typeof(IFormMappingService), + typeof(IFormWorksheetService), + typeof(IFormScoresheetService))] public class OpenAIRuntimeService : IAIService, ITransientDependency { private readonly ILogger _logger; @@ -23,7 +31,7 @@ public class OpenAIRuntimeService : IAIService, ITransientDependency private readonly IAIPromptTemplateProvider _promptTemplateProvider; private readonly OpenAIPromptFileLogger _promptFileLogger; private const string ApplicationAnalysisPromptType = AIPromptTypes.ApplicationAnalysis; - private const string AttachmentSummaryPromptType = AIPromptTypes.AttachmentSummary; + private const string AttachmentSummaryPromptType = AIPromptTypes.ApplicationAttachmentSummary; private const string ApplicationScoringPromptType = AIPromptTypes.ApplicationScoring; private const string FormMappingPromptType = AIPromptTypes.FormMapping; private const string FormWorksheetPromptType = AIPromptTypes.FormWorksheet; @@ -130,7 +138,7 @@ public async Task GenerateApplicationAnalysisAsync( } } - public async Task GenerateAttachmentSummaryAsync(AttachmentSummaryRequest request, CancellationToken cancellationToken = default) + public async Task GenerateAttachmentSummaryAsync(ApplicationAttachmentSummaryRequest request, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(request); var fileName = request.FileName ?? string.Empty; @@ -178,13 +186,13 @@ public async Task GenerateAttachmentSummaryAsync(Atta if (result.Outcome != AIOperationOutcome.Success) { - return new AttachmentSummaryResponse + return new ApplicationAttachmentSummaryResponse { Summary = $"AI analysis not available for this attachment ({fileName})." }; } - return new AttachmentSummaryResponse + return new ApplicationAttachmentSummaryResponse { Summary = ExtractSummaryFromJson(result.Content) }; @@ -196,7 +204,7 @@ public async Task GenerateAttachmentSummaryAsync(Atta catch (Exception ex) { _logger.LogError(ex, "Attachment summary generation failed for {FileName}.", fileName); - return new AttachmentSummaryResponse + return new ApplicationAttachmentSummaryResponse { Summary = $"AI analysis not available for this attachment ({fileName})." }; @@ -305,9 +313,12 @@ public async Task GenerateFormWorksheetAsync(FormWorkshee cancellationToken); await _promptFileLogger.LogPromptOutputAsync(FormWorksheetPromptType, promptVersion, result.CaptureOutput, cancellationToken); - return JsonSerializer.Deserialize( - result.Outcome == AIOperationOutcome.Success ? result.Content : "{}", - AIJsonDefaults.IndentedCamelCase) ?? new FormWorksheetResponse(); + return new FormWorksheetResponse + { + Worksheet = result.Outcome == AIOperationOutcome.Success + ? AIResponseJson.CleanJsonResponse(result.Content) + : "{}" + }; } catch (OperationCanceledException) { @@ -351,9 +362,12 @@ public async Task GenerateFormScoresheetAsync(FormScores cancellationToken); await _promptFileLogger.LogPromptOutputAsync(FormScoresheetPromptType, promptVersion, result.CaptureOutput, cancellationToken); - return JsonSerializer.Deserialize( - result.Outcome == AIOperationOutcome.Success ? result.Content : "{}", - AIJsonDefaults.IndentedCamelCase) ?? new FormScoresheetResponse(); + return new FormScoresheetResponse + { + Scoresheet = result.Outcome == AIOperationOutcome.Success + ? AIResponseJson.CleanJsonResponse(result.Content) + : "{}" + }; } catch (OperationCanceledException) { @@ -402,7 +416,10 @@ private async Task GenerateFormMappingCoreAsync(FormMapping return new FormMappingResponse(); } - return OpenAIResponseParser.ParseFormMappingResponse(result.Content); + return new FormMappingResponse + { + Mapping = AIResponseJson.CleanJsonResponse(result.Content) + }; } catch (OperationCanceledException) { @@ -546,7 +563,7 @@ private static string ExtractSummaryFromJson(string output) return output?.Trim() ?? string.Empty; } - if (jsonObject.TryGetProperty(AIJsonKeys.Summary, out var summaryProp) && + if (jsonObject.TryGetProperty("summary", out var summaryProp) && summaryProp.ValueKind == JsonValueKind.String) { return summaryProp.GetString() ?? string.Empty; diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/DataSeed/AIOperationDataSeeder.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/DataSeed/AIOperationDataSeeder.cs index be3c374c56..c8ef2a316f 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/DataSeed/AIOperationDataSeeder.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/DataSeed/AIOperationDataSeeder.cs @@ -3,6 +3,7 @@ using System.Linq; using Microsoft.Extensions.Logging; using System.Threading.Tasks; +using Unity.AI.Execution; using Unity.AI.Domain; using Unity.AI.Operations; using Unity.AI.Prompts; @@ -26,7 +27,7 @@ public class AIOperationDataSeeder( private static readonly BuiltInOperationDefinition[] BuiltInOperations = [ new(AIPromptTypes.ApplicationAnalysis, AIPromptTypes.ApplicationAnalysis, 1, 4000), - new(AIPromptTypes.AttachmentSummary, AIPromptTypes.AttachmentSummary, 1, 2000), + new(AIPromptTypes.ApplicationAttachmentSummary, AIPromptTypes.ApplicationAttachmentSummary, 1, 2000), new(AIPromptTypes.ApplicationScoring, AIPromptTypes.ApplicationScoring, 1, 8000), new(AIPromptTypes.FormMapping, AIPromptTypes.FormMapping, 2, 2000), new(AIPromptTypes.FormWorksheet, AIPromptTypes.FormWorksheet, 2, 4000), diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/DataSeed/AIPromptDataSeeder.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/DataSeed/AIPromptDataSeeder.cs index 434a367265..ab44bfadbb 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/DataSeed/AIPromptDataSeeder.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/DataSeed/AIPromptDataSeeder.cs @@ -67,9 +67,9 @@ await EnsurePromptAsync( private async Task SeedAttachmentPromptAsync() { - await EnsurePromptAsync(AIPromptTypes.AttachmentSummary, 0, AttachmentSystemV0, AttachmentUserV0); + await EnsurePromptAsync(AIPromptTypes.ApplicationAttachmentSummary, 0, AttachmentSystemV0, AttachmentUserV0); await EnsurePromptAsync( - AIPromptTypes.AttachmentSummary, + AIPromptTypes.ApplicationAttachmentSummary, 1, AttachmentSystemV1, AttachmentUserV1, @@ -78,7 +78,7 @@ await EnsurePromptAsync( rules: AttachmentRules, commonRules: CommonRules)); await EnsurePromptAsync( - AIPromptTypes.AttachmentSummary, + AIPromptTypes.ApplicationAttachmentSummary, 2, AttachmentSystemV2, AttachmentUserV2, diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Domain/AIOperation.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Domain/AIOperation.cs index c4aa1f229f..a974508a0e 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Domain/AIOperation.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Domain/AIOperation.cs @@ -1,4 +1,5 @@ using System; +using Unity.AI.Execution; using Unity.AI.Operations; using Volo.Abp.Domain.Entities.Auditing; diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Generation/AIGenerationAppService.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Generation/AIGenerationAppService.cs index 524a51481b..4621dcac94 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Generation/AIGenerationAppService.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Generation/AIGenerationAppService.cs @@ -2,14 +2,12 @@ using Microsoft.AspNetCore.Mvc; using System; using System.Collections.Generic; -using System.Linq; using System.Threading.Tasks; using Unity.AI.Automation; using Unity.AI.Features; using Unity.AI.Localization; using Unity.AI.Operations; using Unity.AI.Permissions; -using Unity.AI.RateLimit; using Unity.AI.Settings; using Unity.GrantManager.Attachments; using Unity.GrantManager.GrantApplications; @@ -21,95 +19,85 @@ namespace Unity.AI.Generation; [Route("api/app/ai/generation")] public class AIGenerationAppService( - IApplicationAIGenerationQueue aiGenerationQueue, + IApplicationGenerationQueue aiGenerationQueue, IAIGenerationStatusAppService aiGenerationStatusAppService, - IAIRateLimiter aiRateLimiter, AIFeatureGuard featureGuard, ICurrentTenant currentTenant) : AIAppService, IAIGenerationAppService { [Authorize(AIPermissions.Analysis.GenerateAttachmentSummaries)] [HttpPost("attachment-summary")] - public virtual async Task> GenerateAttachmentSummariesAsync(GenerateAttachmentSummariesInputDto input) + public virtual async Task GenerateApplicationAttachmentSummariesAsync(Guid applicationId, List attachmentIds, string? promptVersion = null) { await featureGuard.EnsureEnabledAsync( AIFeatures.AttachmentSummaries, AILocalizationKeys.AttachmentSummariesDisabled); - if (input.AttachmentIds.Count == 0) + if (attachmentIds.Count == 0) { - return []; + return; } - await aiGenerationQueue.QueueAttachmentSummaryAsync( - input.ApplicationId, + await aiGenerationQueue.QueueApplicationAttachmentSummaryAsync( + applicationId, currentTenant.Id, - input.PromptVersion, - input.AttachmentIds); - - return input.AttachmentIds - .Select(_ => new AttachmentSummaryResultDto { Completed = false }) - .ToList(); + attachmentIds, + promptVersion); } [Authorize(AIPermissions.Analysis.GenerateApplicationAnalysis)] [HttpPost("application-analysis")] - public virtual async Task GenerateApplicationAnalysisAsync(Guid applicationId, string? promptVersion = null) + public virtual async Task GenerateApplicationAnalysisAsync(Guid applicationId, string? promptVersion = null) { await featureGuard.EnsureEnabledAsync( AIFeatures.ApplicationAnalysis, AILocalizationKeys.ApplicationAnalysisDisabled); await aiGenerationQueue.QueueApplicationAnalysisAsync(applicationId, currentTenant.Id, promptVersion); - return new ApplicationAnalysisResultDto { Completed = false }; } [Authorize(AIPermissions.Analysis.GenerateScoring)] [HttpPost("application-scoring")] - public virtual async Task GenerateApplicationScoringAsync(Guid applicationId, string? promptVersion = null) + public virtual async Task GenerateApplicationScoringAsync(Guid applicationId, string? promptVersion = null) { await featureGuard.EnsureEnabledAsync( AIFeatures.Scoring, AILocalizationKeys.ScoringDisabled); await aiGenerationQueue.QueueApplicationScoringAsync(applicationId, currentTenant.Id, promptVersion); - return new ApplicationScoringResultDto { Completed = false }; } [Authorize(AIPermissions.Analysis.GenerateFormMapping)] [HttpPost("form-mapping")] - public virtual async Task GenerateFormMappingAsync(Guid applicationId, Guid applicationFormVersionId, string? promptVersion = null) + public virtual async Task GenerateFormMappingAsync(Guid applicationId, Guid applicationFormVersionId, string? promptVersion = null) { await featureGuard.EnsureEnabledAsync( AIFeatures.FormMapping, AILocalizationKeys.FormMappingDisabled); await aiGenerationQueue.QueueFormMappingAsync(applicationId, currentTenant.Id, applicationFormVersionId, promptVersion); - return new FormMappingResultDto { Completed = false }; } [Authorize(AIPermissions.Analysis.GenerateFormWorksheet)] [HttpPost("form-worksheet")] - public virtual async Task GenerateFormWorksheetAsync(Guid applicationId, Guid applicationFormVersionId, string? promptVersion = null) + public virtual async Task GenerateFormWorksheetAsync(Guid applicationId, Guid applicationFormVersionId, string? promptVersion = null) { await featureGuard.EnsureEnabledAsync( AIFeatures.FormWorksheet, AILocalizationKeys.FormWorksheetDisabled); await aiGenerationQueue.QueueFormWorksheetAsync(applicationId, currentTenant.Id, applicationFormVersionId, promptVersion); - return new FormWorksheetResultDto { Completed = false }; } [Authorize(AIPermissions.Analysis.GenerateFormScoresheet)] [HttpPost("form-scoresheet")] - public virtual async Task GenerateFormScoresheetAsync(Guid applicationId, Guid applicationFormVersionId, string? promptVersion = null) + public virtual async Task GenerateFormScoresheetAsync(Guid applicationId, Guid applicationFormVersionId, string? promptVersion = null) { await featureGuard.EnsureEnabledAsync( AIFeatures.FormScoresheet, AILocalizationKeys.FormScoresheetDisabled); await aiGenerationQueue.QueueFormScoresheetAsync(applicationId, currentTenant.Id, applicationFormVersionId, promptVersion); - return new FormScoresheetResultDto { Completed = false }; } [Authorize] @@ -119,26 +107,22 @@ public virtual async Task GetStatusAsync(Guid application await EnsureStatusAccessAsync(operationType); var request = await aiGenerationStatusAppService.GetLatestAsync(applicationId, operationType, currentTenant.Id); - var state = await aiRateLimiter.GetStateAsync(); + if (request == null) + { + return new AIGenerationStatusDto(); + } return new AIGenerationStatusDto { - GenerationRequest = request == null - ? null - : new AIGenerationStatusRequestDto - { - Id = request.Id, - ApplicationId = request.ApplicationId, - OperationId = request.OperationId, - OperationType = operationType, - Status = request.Status.ToString(), - StartedAt = request.StartedAt, - CompletedAt = request.CompletedAt, - FailureReason = request.FailureReason, - IsActive = request.IsActive - }, - IsGenerating = state.IsGenerating, - RetryAfterSeconds = state.RetryAfterSeconds + Id = request.Id, + ApplicationId = request.ApplicationId, + OperationId = request.OperationId, + OperationType = operationType, + Status = request.Status.ToString(), + StartedAt = request.StartedAt, + CompletedAt = request.CompletedAt, + FailureReason = request.FailureReason, + IsActive = request.IsActive }; } diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Unity.AI.Application.csproj b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Unity.AI.Application.csproj index 83d79cee4d..2be3ad05e5 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Unity.AI.Application.csproj +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Unity.AI.Application.csproj @@ -36,7 +36,7 @@ - + PreserveNewest PreserveNewest diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/AIGenerationStatusDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/AIGenerationStatusDto.cs index f229885d76..6dc6843875 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/AIGenerationStatusDto.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/AIGenerationStatusDto.cs @@ -5,5 +5,4 @@ public class AIGenerationStatusDto public AIGenerationRequestDto? GenerationRequest { get; set; } public string? FailureReason { get; set; } public bool IsGenerating { get; set; } - public int RetryAfterSeconds { get; set; } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicationForms/ApplicationFormVersionAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicationForms/ApplicationFormVersionAppService.cs index f63158d011..a2adf74ec2 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicationForms/ApplicationFormVersionAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicationForms/ApplicationFormVersionAppService.cs @@ -7,7 +7,7 @@ using System.Collections.Generic; using System.Threading.Tasks; using Unity.GrantManager.Applications; -using Unity.AI; +using Unity.AI.Operations; using Unity.AI.Requests; using Unity.AI.Responses; using Unity.GrantManager.Forms; @@ -36,7 +36,7 @@ public class ApplicationFormVersionAppService( IReportingFieldsGeneratorService reportingFieldsGeneratorService, IFeatureChecker featureChecker, IApplicationFormVersionMappingReadService mappingReadService, - IAIService aiService) : + IFormMappingService aiService) : CrudAppService< ApplicationFormVersion, ApplicationFormVersionDto, @@ -46,7 +46,7 @@ public class ApplicationFormVersionAppService( IApplicationFormVersionAppService { private readonly IApplicationFormVersionMappingReadService _mappingReadService = mappingReadService; - private readonly IAIService _aiService = aiService; + private readonly IFormMappingService _aiService = aiService; public override async Task CreateAsync(CreateUpdateApplicationFormVersionDto input) => await base.CreateAsync(input); @@ -330,47 +330,12 @@ public virtual async Task GenerateMappingAsync(Guid i }); var submissionHeaderMapping = FormMappingResponseMapper.BuildSubmissionHeaderMapping(response); var applicationFormVersion = await repository.GetAsync(id); - applicationFormVersion.SubmissionHeaderMapping = JsonSerializer.Serialize(submissionHeaderMapping); + applicationFormVersion.SubmissionHeaderMapping = submissionHeaderMapping; await repository.UpdateAsync(applicationFormVersion, true); return new ApplicationFormMappingDto { - ApplicationFormVersionId = id, - CoreFieldMatches = response.CoreFieldMatches.Select(item => new FormMappingDto - { - SourceField = item.SourceField, - TargetField = item.TargetField, - Reason = item.Reason, - Confidence = item.Confidence - }).ToList(), - WorksheetMatches = response.WorksheetMatches.Select(item => new FormWorksheetDto - { - WorksheetName = item.WorksheetName, - FieldMatches = item.FieldMatches.Select(match => new FormMappingDto - { - SourceField = match.SourceField, - TargetField = match.TargetField, - Reason = match.Reason, - Confidence = match.Confidence - }).ToList() - }).ToList(), - WorksheetCreationSuggestions = response.WorksheetCreationSuggestions.Select(item => new WorksheetCreationSuggestionDto - { - WorksheetName = item.WorksheetName, - SuggestedFields = item.SuggestedFields.Select(field => new MappingFieldDto - { - Name = field.Name, - Type = field.Type, - Label = field.Label, - IsCustom = field.IsCustom - }).ToList(), - Reason = item.Reason - }).ToList(), - Issues = response.Issues.Select(item => new MappingIssueDto - { - Code = item.Code, - Message = item.Message - }).ToList() + ApplicationFormVersionId = id }; } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicationForms/Mapping/FormMappingResponseMapper.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicationForms/Mapping/FormMappingResponseMapper.cs index f103776e1c..a497eff15a 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicationForms/Mapping/FormMappingResponseMapper.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicationForms/Mapping/FormMappingResponseMapper.cs @@ -1,38 +1,14 @@ using System; -using System.Collections.Generic; using Unity.AI.Responses; namespace Unity.GrantManager.ApplicationForms.Mapping; internal static class FormMappingResponseMapper { - internal static Dictionary BuildSubmissionHeaderMapping(FormMappingResponse response) + internal static string BuildSubmissionHeaderMapping(FormMappingResponse response) { - var mapping = new Dictionary(StringComparer.OrdinalIgnoreCase); - - foreach (var match in response.CoreFieldMatches) - { - AddMapping(mapping, match.SourceField, match.TargetField); - } - - foreach (var worksheetMatch in response.WorksheetMatches) - { - foreach (var match in worksheetMatch.FieldMatches) - { - AddMapping(mapping, match.SourceField, match.TargetField); - } - } - - return mapping; - } - - private static void AddMapping(Dictionary mapping, string? sourceField, string? targetField) - { - if (string.IsNullOrWhiteSpace(sourceField) || string.IsNullOrWhiteSpace(targetField)) - { - return; - } - - mapping[sourceField] = targetField; + return string.IsNullOrWhiteSpace(response.Mapping) + ? "{}" + : response.Mapping; } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/AIGenerationActivityProvider.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/AIGenerationActivityProvider.cs index efac6a4120..33988d6ce5 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/AIGenerationActivityProvider.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/AIGenerationActivityProvider.cs @@ -1,7 +1,7 @@ using System; using System.Linq; using System.Threading.Tasks; -using Unity.AI.RateLimit; +using Unity.AI.Cooldown; using Volo.Abp.DependencyInjection; using Volo.Abp.Domain.Repositories; using Volo.Abp.Linq; @@ -14,7 +14,7 @@ public class AIGenerationActivityProvider( IRepository generationRequestRepository, ICurrentUser currentUser, ICurrentTenant currentTenant, - IAsyncQueryableExecuter asyncExecuter) : IAIGenerationActivityProvider, ITransientDependency + IAsyncQueryableExecuter asyncExecuter) : ITransientDependency { public async Task HasActiveGenerationAsync() { diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/ApplicationAIGenerationQueue.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/ApplicationAIGenerationQueue.cs index 707e5b8890..34e039d3e4 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/ApplicationAIGenerationQueue.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/ApplicationAIGenerationQueue.cs @@ -7,7 +7,8 @@ using Unity.AI.Features; using Unity.AI.Localization; using Unity.AI.Operations; -using Unity.AI.RateLimit; +using Unity.AI.Cooldown; +using Unity.AI.Validation; using Unity.GrantManager.GrantApplications; using Unity.GrantManager.GrantApplications.Automation.BackgroundJobs; using Medallion.Threading; @@ -20,25 +21,26 @@ using Volo.Abp.Features; using Volo.Abp.Linq; using Volo.Abp.Users; +using Unity.GrantManager.Applications; namespace Unity.GrantManager.GrantApplications.Automation; -public class ApplicationAIGenerationQueue( +public class ApplicationGenerationQueue( IBackgroundJobManager backgroundJobManager, IRepository generationRequestRepository, IRepository operationRepository, IDistributedLockProvider distributedLockProvider, - IAIGenerationPrerequisiteValidator aiGenerationPrerequisiteValidator, + IGenerationPrerequisiteValidator aiGenerationPrerequisiteValidator, IFeatureChecker featureChecker, - IAIRateLimiter aiRateLimiter, + IAICooldownAppService aiCooldownService, IAsyncQueryableExecuter asyncQueryableExecuter, ICurrentUser currentUser, - ILogger logger, + ILogger logger, IStringLocalizer localizer) - : IApplicationAIGenerationQueue, ITransientDependency + : IApplicationGenerationQueue, ITransientDependency { private readonly IAsyncQueryableExecuter _asyncQueryableExecuter = asyncQueryableExecuter; - public async Task QueueAttachmentSummaryAsync(Guid applicationId, Guid? tenantId, string? promptVersion = null, List? attachmentIds = null) + public async Task QueueApplicationAttachmentSummaryAsync(Guid applicationId, Guid? tenantId, List attachmentIds, string? promptVersion = null) { await EnsureRequestAndEnqueueAsync( tenantId, @@ -47,7 +49,7 @@ await EnsureRequestAndEnqueueAsync( () => aiGenerationPrerequisiteValidator.EnsureAttachmentSummaryAvailableAsync(applicationId), () => { - return backgroundJobManager.EnqueueAsync(new GenerateAttachmentSummaryBackgroundJobArgs + return backgroundJobManager.EnqueueAsync(new GenerateApplicationAttachmentSummaryBackgroundJobArgs { ApplicationId = applicationId, AttachmentIds = attachmentIds, @@ -159,7 +161,7 @@ await EnsureRequestAndEnqueueAsync( }); } - public async Task QueueAllAIStagesAsync(Guid applicationId, Guid? tenantId, string? promptVersion = null) + public async Task QueueApplicationIntakeAsync(Guid applicationId, Guid? tenantId, string? promptVersion = null) { var hasEnabledStage = false; var enqueuedStage = false; @@ -170,7 +172,7 @@ public async Task QueueAllAIStagesAsync(Guid applicationId, Guid? tenantId, stri hasEnabledStage = true; try { - await QueueAttachmentSummaryAsync(applicationId, tenantId, promptVersion); + await QueueApplicationAttachmentSummaryAsync(applicationId, tenantId, new List(), promptVersion: promptVersion); enqueuedStage = true; } catch (UserFriendlyException ex) @@ -193,7 +195,7 @@ public async Task QueueAllAIStagesAsync(Guid applicationId, Guid? tenantId, stri } } - if (await featureChecker.IsEnabledAsync(AIFeatures.Scoring)) + if (await featureChecker.IsEnabledAsync(AIFeatures.ApplicationScoring)) { hasEnabledStage = true; try @@ -209,7 +211,7 @@ public async Task QueueAllAIStagesAsync(Guid applicationId, Guid? tenantId, stri if (!hasEnabledStage) { - throw new UserFriendlyException(localizer[AILocalizationKeys.GenerateAllDisabled]); + throw new UserFriendlyException("No AI generation features are enabled."); } if (!enqueuedStage && lastStageException != null) @@ -251,7 +253,7 @@ private async Task EnsureRequestAndEnqueueAsync( // Single chokepoint for all AI generate flows (manual + auto). // The limiter is a no-op for system/background callers without an authenticated user. - await aiRateLimiter.EnsureAsync(); + await aiCooldownService.EnsureAsync(); var request = new AIGenerationRequest( Guid.NewGuid(), diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/AIGenerationRequestJobHelper.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/AIGenerationRequestJobHelper.cs index 7a28bd3c2c..ca487d41e7 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/AIGenerationRequestJobHelper.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/AIGenerationRequestJobHelper.cs @@ -4,7 +4,7 @@ using System.Threading.Tasks; using Microsoft.Extensions.Logging; using Unity.AI.Domain; -using Unity.AI.RateLimit; +using Unity.AI.Cooldown; using Unity.GrantManager.GrantApplications; using Volo.Abp.Domain.Repositories; using Volo.Abp.Uow; @@ -111,8 +111,8 @@ public static async Task MarkFailedInNewUowAsync( await uow.CompleteAsync(); } - public static async Task StampRateLimitBestEffortAsync( - IAIRateLimiter aiRateLimiter, + public static async Task StampCooldownBestEffortAsync( + IAICooldownAppService aiCooldownService, ILogger logger, Guid? requestedByUserId, Guid applicationId, @@ -120,13 +120,13 @@ public static async Task StampRateLimitBestEffortAsync( { try { - await aiRateLimiter.StampAsync(requestedByUserId); + await aiCooldownService.StampAsync(requestedByUserId); } catch (Exception ex) { logger.LogWarning( ex, - "AI rate-limit cooldown stamp failed after completed AI generation request for application {ApplicationId} and operation {OperationType}.", + "AI cooldown stamp failed after completed AI generation request for application {ApplicationId} and operation {OperationType}.", applicationId, operationType); } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateApplicationAnalysisJob.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateApplicationAnalysisJob.cs index fac08879b1..5e77bd7276 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateApplicationAnalysisJob.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateApplicationAnalysisJob.cs @@ -3,7 +3,7 @@ using System.Threading.Tasks; using Unity.AI.Domain; using Unity.AI.Operations; -using Unity.AI.RateLimit; +using Unity.AI.Cooldown; using Unity.GrantManager.Applications; using Unity.GrantManager.GrantApplications; using Volo.Abp.BackgroundJobs; @@ -11,20 +11,17 @@ using Volo.Abp.Domain.Repositories; using Volo.Abp.MultiTenancy; using Volo.Abp.Uow; -using Volo.Abp.ObjectMapping; namespace Unity.GrantManager.GrantApplications.Automation.BackgroundJobs; public class GenerateApplicationAnalysisJob( - IAIApplicationInputBuilder inputBuilder, - IApplicationAnalysisService applicationAnalysisService, + ApplicationAnalysisService applicationAnalysisService, IApplicationRepository applicationRepository, - IObjectMapper objectMapper, IRepository generationRequestRepository, IRepository operationRepository, ICurrentTenant currentTenant, IUnitOfWorkManager unitOfWorkManager, - IAIRateLimiter aiRateLimiter, + IAICooldownAppService aiCooldownService, ILogger logger) : AsyncBackgroundJob, ITransientDependency { public override async Task ExecuteAsync(GenerateApplicationAnalysisBackgroundJobArgs args) @@ -49,12 +46,10 @@ await AIGenerationRequestJobHelper.MarkRunningInNewUowAsync( try { var application = await applicationRepository.GetAsync(args.ApplicationId); - var applicationInput = objectMapper.Map(application); - var input = await inputBuilder.BuildApplicationAnalysisInputAsync(applicationInput, args.PromptVersion); - var analysisJson = await applicationAnalysisService.RegenerateAsync(input); + var analysisJson = await applicationAnalysisService.GenerateApplicationAnalysisAsync(application.Id, args.PromptVersion); application.AIAnalysis = analysisJson; await applicationRepository.UpdateAsync(application); - await AIGenerationRequestJobHelper.StampRateLimitBestEffortAsync(aiRateLimiter, logger, args.RequestedByUserId, args.ApplicationId, AIGenerationRequestKeyHelper.ApplicationAnalysisOperationType); + await AIGenerationRequestJobHelper.StampCooldownBestEffortAsync(aiCooldownService, logger, args.RequestedByUserId, args.ApplicationId, AIGenerationRequestKeyHelper.ApplicationAnalysisOperationType); await AIGenerationRequestJobHelper.MarkCompletedInNewUowAsync( unitOfWorkManager, generationRequestRepository, diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateApplicationScoringJob.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateApplicationScoringJob.cs index d171503d61..c2d7cd2540 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateApplicationScoringJob.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateApplicationScoringJob.cs @@ -2,8 +2,9 @@ using System; using System.Threading.Tasks; using Unity.AI.Domain; +using Unity.AI.Execution; using Unity.AI.Operations; -using Unity.AI.RateLimit; +using Unity.AI.Cooldown; using Unity.GrantManager.Applications; using Unity.GrantManager.GrantApplications.Automation.Events; using Volo.Abp.BackgroundJobs; @@ -12,21 +13,18 @@ using Volo.Abp.EventBus.Local; using Volo.Abp.MultiTenancy; using Volo.Abp.Uow; -using Volo.Abp.ObjectMapping; namespace Unity.GrantManager.GrantApplications.Automation.BackgroundJobs; public class GenerateApplicationScoringJob( - IAIApplicationInputBuilder inputBuilder, - IApplicationScoringService applicationScoringService, + ApplicationScoringService applicationScoringService, IApplicationRepository applicationRepository, - IObjectMapper objectMapper, IRepository generationRequestRepository, IRepository operationRepository, ICurrentTenant currentTenant, IUnitOfWorkManager unitOfWorkManager, ILocalEventBus localEventBus, - IAIRateLimiter aiRateLimiter, + IAICooldownAppService aiCooldownService, ILogger logger) : AsyncBackgroundJob, ITransientDependency { public override async Task ExecuteAsync(GenerateApplicationScoringBackgroundJobArgs args) @@ -51,16 +49,18 @@ await AIGenerationRequestJobHelper.MarkRunningInNewUowAsync( try { var application = await applicationRepository.GetAsync(args.ApplicationId); - var applicationInput = objectMapper.Map(application); - var input = await inputBuilder.BuildApplicationScoringInputAsync(applicationInput, args.PromptVersion); - var scoresheetAnswers = await applicationScoringService.RegenerateAsync(input); + var operation = await operationRepository.FirstOrDefaultAsync(item => item.Name == AIGenerationRequestKeyHelper.ResolveOperationName(AIGenerationRequestKeyHelper.ApplicationScoringOperationType)); + var scoresheetAnswers = await applicationScoringService.GenerateApplicationScoringAsync( + application.Id, + operation?.ExecutionMode ?? AIExecutionMode.Sequential, + args.PromptVersion); application.AIScoresheetAnswers = scoresheetAnswers; await applicationRepository.UpdateAsync(application); await localEventBus.PublishAsync(new ApplicationAIScoringGeneratedEvent { ApplicationId = args.ApplicationId }); - await AIGenerationRequestJobHelper.StampRateLimitBestEffortAsync(aiRateLimiter, logger, args.RequestedByUserId, args.ApplicationId, AIGenerationRequestKeyHelper.ApplicationScoringOperationType); + await AIGenerationRequestJobHelper.StampCooldownBestEffortAsync(aiCooldownService, logger, args.RequestedByUserId, args.ApplicationId, AIGenerationRequestKeyHelper.ApplicationScoringOperationType); await AIGenerationRequestJobHelper.MarkCompletedInNewUowAsync( unitOfWorkManager, generationRequestRepository, diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateFormMappingJob.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateFormMappingJob.cs index 5abcf3e990..d8f04f1a39 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateFormMappingJob.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateFormMappingJob.cs @@ -4,11 +4,11 @@ using System.Text.Json; using System.Threading.Tasks; using Unity.AI.Domain; -using Unity.AI; +using Unity.AI.Operations; using Unity.AI.Requests; using Unity.AI.Responses; using Unity.GrantManager.ApplicationForms; -using Unity.AI.RateLimit; +using Unity.AI.Cooldown; using Unity.GrantManager.ApplicationForms.Mapping; using Unity.GrantManager.Applications; using Volo.Abp.Domain.Repositories; @@ -21,13 +21,13 @@ namespace Unity.GrantManager.GrantApplications.Automation.BackgroundJobs; public class GenerateFormMappingJob( IApplicationFormVersionMappingReadService mappingReadService, - IAIService aiService, + IFormMappingService aiService, IRepository applicationFormVersionRepository, IRepository generationRequestRepository, IRepository operationRepository, ICurrentTenant currentTenant, IUnitOfWorkManager unitOfWorkManager, - IAIRateLimiter aiRateLimiter, + IAICooldownAppService aiCooldownService, ILogger logger) : AsyncBackgroundJob, ITransientDependency { public override async Task ExecuteAsync(GenerateFormMappingBackgroundJobArgs args) @@ -60,10 +60,10 @@ await AIGenerationRequestJobHelper.MarkRunningInNewUowAsync( var submissionHeaderMapping = FormMappingResponseMapper.BuildSubmissionHeaderMapping(response); var applicationFormVersion = await applicationFormVersionRepository.GetAsync(args.ApplicationFormVersionId); - applicationFormVersion.SubmissionHeaderMapping = JsonSerializer.Serialize(submissionHeaderMapping); + applicationFormVersion.SubmissionHeaderMapping = submissionHeaderMapping; await applicationFormVersionRepository.UpdateAsync(applicationFormVersion, true); - await AIGenerationRequestJobHelper.StampRateLimitBestEffortAsync(aiRateLimiter, logger, args.RequestedByUserId, args.ApplicationId, AIGenerationRequestKeyHelper.FormMappingOperationType); + await AIGenerationRequestJobHelper.StampCooldownBestEffortAsync(aiCooldownService, logger, args.RequestedByUserId, args.ApplicationId, AIGenerationRequestKeyHelper.FormMappingOperationType); await AIGenerationRequestJobHelper.MarkCompletedInNewUowAsync( unitOfWorkManager, generationRequestRepository, diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateFormScoresheetJob.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateFormScoresheetJob.cs index 57a3488a6c..1fd3f48ca2 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateFormScoresheetJob.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateFormScoresheetJob.cs @@ -3,12 +3,12 @@ using System.Linq; using System.Text.Json; using System.Threading.Tasks; -using Unity.AI; using Unity.AI.Domain; +using Unity.AI.Operations; using Unity.AI.Requests; using Unity.GrantManager.ApplicationForms; using Unity.GrantManager.Applications; -using Unity.AI.RateLimit; +using Unity.AI.Cooldown; using Unity.Flex.Domain.Scoresheets; using Unity.Flex.Scoresheets; using Volo.Abp.BackgroundJobs; @@ -23,12 +23,12 @@ public class GenerateFormScoresheetJob( IApplicationFormVersionRepository applicationFormVersionRepository, IApplicationFormRepository applicationFormRepository, IScoresheetRepository scoresheetRepository, - IAIService aiService, + IFormScoresheetService aiService, IRepository generationRequestRepository, IRepository operationRepository, ICurrentTenant currentTenant, IUnitOfWorkManager unitOfWorkManager, - IAIRateLimiter aiRateLimiter, + IAICooldownAppService aiCooldownService, ILogger logger) : AsyncBackgroundJob, ITransientDependency { private static readonly JsonSerializerOptions CaseInsensitiveJsonOptions = new() @@ -110,7 +110,7 @@ await AIGenerationRequestJobHelper.MarkRunningInNewUowAsync( PromptVersion = args.PromptVersion }); - var scoresheetJson = JsonSerializer.Serialize(scoresheetResponse); + var scoresheetJson = scoresheetResponse.Scoresheet; var importDto = ParseScoresheetDefinition(scoresheetJson); var scoresheet = existingScoresheet == null ? BuildScoresheet(importDto, scoresheetJson, scoresheetName) @@ -128,7 +128,7 @@ await AIGenerationRequestJobHelper.MarkRunningInNewUowAsync( applicationForm.ScoresheetId = scoresheet.Id; await applicationFormRepository.UpdateAsync(applicationForm); - await AIGenerationRequestJobHelper.StampRateLimitBestEffortAsync(aiRateLimiter, logger, args.RequestedByUserId, args.ApplicationId, AIGenerationRequestKeyHelper.FormScoresheetOperationType); + await AIGenerationRequestJobHelper.StampCooldownBestEffortAsync(aiCooldownService, logger, args.RequestedByUserId, args.ApplicationId, AIGenerationRequestKeyHelper.FormScoresheetOperationType); await AIGenerationRequestJobHelper.MarkCompletedInNewUowAsync( unitOfWorkManager, generationRequestRepository, diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateFormWorksheetJob.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateFormWorksheetJob.cs index 7f585881ce..f0d35c049b 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateFormWorksheetJob.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateFormWorksheetJob.cs @@ -4,8 +4,8 @@ using System.Linq; using System.Text.Json; using System.Threading.Tasks; -using Unity.AI; using Unity.AI.Domain; +using Unity.AI.Operations; using Unity.AI.Requests; using Unity.GrantManager.ApplicationForms; using Unity.GrantManager.Applications; @@ -14,7 +14,7 @@ using Unity.Flex.Domain.Worksheets; using Unity.Flex.Worksheets; using Unity.Modules.Shared.Correlation; -using Unity.AI.RateLimit; +using Unity.AI.Cooldown; using Volo.Abp.BackgroundJobs; using Volo.Abp.DependencyInjection; using Volo.Abp.Domain.Repositories; @@ -28,12 +28,12 @@ public class GenerateFormWorksheetJob( IApplicationFormRepository applicationFormRepository, IWorksheetRepository worksheetRepository, IWorksheetLinkRepository worksheetLinkRepository, - IAIService aiService, + IFormWorksheetService aiService, IRepository generationRequestRepository, IRepository operationRepository, ICurrentTenant currentTenant, IUnitOfWorkManager unitOfWorkManager, - IAIRateLimiter aiRateLimiter, + IAICooldownAppService aiCooldownService, ILogger logger) : AsyncBackgroundJob, ITransientDependency { private static readonly JsonSerializerOptions CaseInsensitiveJsonOptions = new() @@ -111,7 +111,7 @@ await AIGenerationRequestJobHelper.MarkRunningInNewUowAsync( PromptVersion = args.PromptVersion }); - var worksheetJson = JsonSerializer.Serialize(worksheetResponse); + var worksheetJson = worksheetResponse.Worksheet; var createDto = ParseWorksheetDefinition(worksheetJson); var worksheet = existingWorksheet == null ? BuildWorksheet(createDto, worksheetName) @@ -128,7 +128,7 @@ await AIGenerationRequestJobHelper.MarkRunningInNewUowAsync( await UpsertWorksheetLinkAsync(worksheet.Id, formVersion.Id); - await AIGenerationRequestJobHelper.StampRateLimitBestEffortAsync(aiRateLimiter, logger, args.RequestedByUserId, args.ApplicationId, AIGenerationRequestKeyHelper.FormWorksheetOperationType); + await AIGenerationRequestJobHelper.StampCooldownBestEffortAsync(aiCooldownService, logger, args.RequestedByUserId, args.ApplicationId, AIGenerationRequestKeyHelper.FormWorksheetOperationType); await AIGenerationRequestJobHelper.MarkCompletedInNewUowAsync( unitOfWorkManager, generationRequestRepository, diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/Handlers/QueueApplicationAIPipelineOnProcessHandler.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/Handlers/QueueApplicationAIPipelineOnProcessHandler.cs index d80d0b4b88..bd45da270b 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/Handlers/QueueApplicationAIPipelineOnProcessHandler.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/Handlers/QueueApplicationAIPipelineOnProcessHandler.cs @@ -13,7 +13,7 @@ namespace Unity.GrantManager.GrantApplications.Automation.Handlers; public class QueueApplicationAIPipelineOnProcessHandler( - IApplicationAIGenerationQueue aiGenerationQueue, + IApplicationGenerationQueue aiGenerationQueue, ISettingProvider settingProvider, IApplicationFormRepository applicationFormRepository, IFeatureChecker featureChecker, @@ -53,7 +53,7 @@ public async Task HandleEventAsync(ApplicationProcessEvent eventData) try { - await aiGenerationQueue.QueueAllAIStagesAsync(eventData.Application.Id, eventData.Application.TenantId); + await aiGenerationQueue.QueueApplicationIntakeAsync(eventData.Application.Id, eventData.Application.TenantId); logger.LogInformation("Queued AI pipeline for application {ApplicationId}.", eventData.Application.Id); } 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 52adccaaf0..1275dc4fff 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/GrantApplicationAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/GrantApplicationAppService.cs @@ -15,7 +15,7 @@ using Unity.AI.Automation; using Unity.AI.Models; using Unity.AI.Permissions; -using Unity.AI.RateLimit; +using Unity.AI.Cooldown; using Unity.AI.Responses; using Unity.Flex.WorksheetInstances; using Unity.Flex.Worksheets; @@ -59,7 +59,7 @@ public class GrantApplicationAppService( IApplicantSupplierAppService applicantSupplierService, IPaymentRequestAppService paymentRequestService, IAIGenerationStatusAppService aiGenerationStatusAppService, - IAIRateLimiter aiRateLimiter, + IAICooldownAppService aiCooldownService, IFeatureChecker featureChecker) : GrantManagerAppService, IGrantApplicationAppService #pragma warning restore S107 // Methods should not have too many parameters @@ -1207,18 +1207,18 @@ private async Task EnsureAIGenerationStatusAccessAsync(string operationType) switch (operationType) { case AIGenerationRequestKeyHelper.ApplicationAnalysisOperationType: - await AuthorizationService.CheckAsync(AIPermissions.Analysis.ViewApplicationAnalysis); + await AuthorizationService.CheckAsync(AIPermissions.ApplicationAnalysis.View); return; case AIGenerationRequestKeyHelper.AttachmentSummaryOperationType: - await AuthorizationService.CheckAsync(AIPermissions.Analysis.ViewAttachmentSummary); + await AuthorizationService.CheckAsync(AIPermissions.AttachmentSummaries.View); return; case AIGenerationRequestKeyHelper.ApplicationScoringOperationType: - await AuthorizationService.CheckAsync(AIPermissions.Analysis.ViewScoringResult); + await AuthorizationService.CheckAsync(AIPermissions.ApplicationScoring.View); return; case AIGenerationRequestKeyHelper.PipelineOperationType: - await AuthorizationService.CheckAsync(AIPermissions.Analysis.ViewApplicationAnalysis); - await AuthorizationService.CheckAsync(AIPermissions.Analysis.ViewAttachmentSummary); - await AuthorizationService.CheckAsync(AIPermissions.Analysis.ViewScoringResult); + await AuthorizationService.CheckAsync(AIPermissions.ApplicationAnalysis.View); + await AuthorizationService.CheckAsync(AIPermissions.AttachmentSummaries.View); + await AuthorizationService.CheckAsync(AIPermissions.ApplicationScoring.View); return; default: throw new UserFriendlyException("Unknown AI generation operation type."); @@ -1233,7 +1233,7 @@ private async Task EnsureAttachmentSummariesEnabledAsync() } } - private async Task> ResolveAttachmentSummaryIdsAsync(QueueAttachmentSummaryRequestDto input) + private async Task> ResolveAttachmentSummaryIdsAsync(QueueApplicationAttachmentSummaryRequestDto input) { if (input == null) { @@ -1367,13 +1367,10 @@ public async Task GetAIGenerationStatusAsync(Guid applica await EnsureAIGenerationStatusAccessAsync(operationType); var request = await aiGenerationStatusAppService.GetLatestAsync(applicationId, operationType, CurrentTenant.Id); - var state = await aiRateLimiter.GetStateAsync(); return new AIGenerationStatusDto { GenerationRequest = request, - IsGenerating = state.IsGenerating, - RetryAfterSeconds = state.RetryAfterSeconds, FailureReason = request?.FailureReason }; } diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/Operations/AIExecutionStrategyTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/Operations/AIExecutionStrategyTests.cs index 27343459b7..01b7cea7da 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/Operations/AIExecutionStrategyTests.cs +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/Operations/AIExecutionStrategyTests.cs @@ -1,7 +1,7 @@ using Shouldly; using System.Collections.Generic; using System.Threading.Tasks; -using Unity.AI.Operations; +using Unity.AI.Execution; using Xunit; namespace Unity.GrantManager.AI.Operations; diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/Operations/ApplicationAnalysisServiceTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/Operations/ApplicationAnalysisServiceTests.cs index c13a000025..ba1e1e667f 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/Operations/ApplicationAnalysisServiceTests.cs +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/Operations/ApplicationAnalysisServiceTests.cs @@ -1,12 +1,16 @@ +using Microsoft.Extensions.Logging.Abstractions; using NSubstitute; using Shouldly; using System; using System.Collections.Generic; +using System.Linq; using System.Text.Json; using System.Threading.Tasks; +using Unity.AI.Automation; using Unity.AI; -using Unity.AI.Models; using Unity.AI.Operations; +using Unity.AI.Models; +using Unity.AI.Validation; using Unity.AI.Requests; using Unity.AI.Responses; using Xunit; @@ -29,36 +33,52 @@ public async Task RegenerateAsync_Uses_Input_Dto_And_Returns_Serialized_Response var aiService = Substitute.For(); aiService.GenerateApplicationAnalysisAsync(Arg.Do(request => capturedRequest = request)) .Returns(new ApplicationAnalysisResponse { Decision = "ok" }); - var prerequisiteValidator = Substitute.For(); - - var service = new ApplicationAnalysisService( - aiService, - prerequisiteValidator); - - var result = await service.RegenerateAsync(new ApplicationAnalysisOperationInputDto + var prerequisiteValidator = Substitute.For(); + var dataProvider = Substitute.For(); + dataProvider.GetApplicationSubmissionAsync(applicationId).Returns(new ApplicationSubmissionSnapshot + { + ApplicationFormVersionId = Guid.NewGuid(), + Submission = JsonSerializer.Serialize(new + { + data = new + { + projectName = "Submitted project" + } + }) + }); + dataProvider.GetAttachmentSummariesAsync(applicationId).Returns([]); + dataProvider.GetApplicationFormVersionAsync(Arg.Any()).Returns(new ApplicationFormVersionSnapshot { - ApplicationId = applicationId, - Schema = JsonSerializer.SerializeToElement(new { projectName = "Project Name" }), - Data = JsonSerializer.SerializeToElement(new { projectName = "Submitted project" }), - Attachments = new List + FormSchema = JsonSerializer.Serialize(new { - new() + components = new[] { - Name = "summary.pdf", - Summary = "Summary text" + new + { + key = "projectName", + label = "Project Name", + type = "textfield", + input = true, + validate = new { required = true } + } } - }, - PromptVersion = "v1" + }) }); + var service = new ApplicationAnalysisService( + aiService, + prerequisiteValidator, + dataProvider, + NullLogger.Instance); + + var result = await service.GenerateApplicationAnalysisAsync(applicationId, "v1"); + result.ShouldContain("\"Decision\": \"ok\""); capturedRequest.ShouldNotBeNull(); capturedRequest.PromptVersion.ShouldBe("v1"); - capturedRequest.Attachments.Count.ShouldBe(1); - capturedRequest.Attachments[0].Name.ShouldBe("summary.pdf"); - capturedRequest.Attachments[0].Summary.ShouldBe("Summary text"); + capturedRequest.Attachments.ShouldBeEmpty(); capturedRequest.Data.GetProperty("projectName").GetString().ShouldBe("Submitted project"); - capturedRequest.Schema.GetProperty("projectName").GetString().ShouldBe("Project Name"); + capturedRequest.Schema.GetProperty("required_fields").EnumerateArray().First().GetString().ShouldBe("Project Name (projectName)"); await prerequisiteValidator.Received(1).EnsureApplicationAnalysisAvailableAsync(applicationId); await aiService.Received(1).GenerateApplicationAnalysisAsync(Arg.Any(), Arg.Any()); @@ -73,23 +93,21 @@ public async Task RegenerateAsync_Flows_Api_Request_To_The_Runtime_Without_Repos var aiService = Substitute.For(); aiService.GenerateApplicationAnalysisAsync(Arg.Do(request => capturedRequest = request)) .Returns(new ApplicationAnalysisResponse()); - var prerequisiteValidator = Substitute.For(); + var prerequisiteValidator = Substitute.For(); + var dataProvider = Substitute.For(); + dataProvider.GetApplicationSubmissionAsync(applicationId).Returns((ApplicationSubmissionSnapshot?)null); + dataProvider.GetAttachmentSummariesAsync(applicationId).Returns([]); var service = new ApplicationAnalysisService( aiService, - prerequisiteValidator); + prerequisiteValidator, + dataProvider, + NullLogger.Instance); - await service.RegenerateAsync(new ApplicationAnalysisOperationInputDto - { - ApplicationId = applicationId, - Schema = JsonSerializer.SerializeToElement(new { }), - Data = JsonSerializer.SerializeToElement(new { project_name = "Fallback project" }), - Attachments = [], - PromptVersion = null - }); + await service.GenerateApplicationAnalysisAsync(applicationId); capturedRequest.ShouldNotBeNull(); - capturedRequest.Data.GetProperty("project_name").GetString().ShouldBe("Fallback project"); + capturedRequest.Data.GetRawText().ShouldBe("{}"); capturedRequest.Attachments.ShouldBeEmpty(); capturedRequest.PromptVersion.ShouldBeNull(); } diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/Operations/ApplicationScoringServiceTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/Operations/ApplicationScoringServiceTests.cs index a2fed03287..9df1eef225 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/Operations/ApplicationScoringServiceTests.cs +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/Operations/ApplicationScoringServiceTests.cs @@ -1,18 +1,26 @@ -using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging.Abstractions; using NSubstitute; using Shouldly; using System; using System.Collections.Generic; +using System.Linq; using System.Text.Json; using System.Threading; using System.Threading.Tasks; +using Unity.AI.Automation; using Unity.AI; +using Unity.AI.Domain; +using Unity.AI.Execution; using Unity.AI.Models; using Unity.AI.Operations; +using Unity.AI.Validation; +using Unity.AI.Prompts; using Unity.AI.Requests; using Unity.AI.Responses; using Unity.AI.Runtime; +using Unity.Flex.Scoresheets.Enums; +using Volo.Abp; +using Volo.Abp.Domain.Repositories; using Xunit; namespace Unity.GrantManager.AI.Operations; @@ -37,24 +45,68 @@ public async Task RegenerateAsync_Sequential_Mode_Uses_Per_Section_Requests() } } }); - - var service = CreateService(aiService, "Sequential"); - - var result = await service.RegenerateAsync(new ApplicationScoringOperationInputDto + var dataProvider = Substitute.For(); + dataProvider.GetApplicationFormAsync(Arg.Any()).Returns(new ApplicationFormSnapshot + { + ScoresheetId = Guid.NewGuid() + }); + dataProvider.GetScoresheetAsync(Arg.Any()).Returns(new ScoresheetSnapshot { - ApplicationId = Guid.NewGuid(), - Data = JsonSerializer.SerializeToElement(new { project_name = "Project Alpha" }), - Attachments = [new AIAttachmentItem { Name = "summary.pdf", Summary = "Summary text" }], Sections = [ - CreateSection("Section A", "q1") + new ScoresheetSectionSnapshot + { + Name = "Section A", + Order = 1, + Fields = + [ + new ScoresheetFieldSnapshot + { + Id = Guid.NewGuid(), + Label = "Question 1", + Description = "Description", + Type = QuestionType.Text.ToString(), + Order = 1, + Definition = null + } + ] + } ] }); + dataProvider.GetApplicationSubmissionAsync(Arg.Any()).Returns(new ApplicationSubmissionSnapshot + { + ApplicationFormVersionId = Guid.NewGuid(), + Submission = JsonSerializer.Serialize(new { data = new { project_name = "Project Alpha" } }) + }); + dataProvider.GetAttachmentSummariesAsync(Arg.Any()).Returns([]); + dataProvider.GetApplicationFormVersionAsync(Arg.Any()).Returns(new ApplicationFormVersionSnapshot + { + FormSchema = JsonSerializer.Serialize(new + { + components = new[] + { + new + { + key = "project_name", + label = "Project Name", + type = "textfield", + input = true, + validate = new { required = true } + } + } + }) + }); + + var operationRepository = CreateOperationRepository(AIExecutionMode.Sequential); + var service = CreateService(aiService, operationRepository, dataProvider); + + var applicationId = Guid.NewGuid(); + var result = await service.GenerateApplicationScoringAsync(applicationId); result.ShouldContain("\"q1\""); capturedRequests.Count.ShouldBe(1); capturedRequests[0].SectionName.ShouldBe("Section A"); - capturedRequests[0].Attachments.Count.ShouldBe(1); + capturedRequests[0].Attachments.ShouldBeEmpty(); capturedRequests[0].PromptVersion.ShouldBeNull(); } @@ -76,20 +128,66 @@ public async Task RegenerateAsync_Batch_Mode_Uses_Aggregated_Section_Schema() } } }); - - var service = CreateService(aiService, "Batch"); - - var result = await service.RegenerateAsync(new ApplicationScoringOperationInputDto + var dataProvider = Substitute.For(); + dataProvider.GetApplicationFormAsync(Arg.Any()).Returns(new ApplicationFormSnapshot + { + ScoresheetId = Guid.NewGuid() + }); + dataProvider.GetScoresheetAsync(Arg.Any()).Returns(new ScoresheetSnapshot { - ApplicationId = Guid.NewGuid(), - Data = JsonSerializer.SerializeToElement(new { project_name = "Project Beta" }), - Attachments = [], Sections = [ - CreateSection("Section A", "q1"), - CreateSection("Section B", "q2") + new ScoresheetSectionSnapshot + { + Name = "Section A", + Order = 1, + Fields = + [ + new ScoresheetFieldSnapshot + { + Id = Guid.NewGuid(), + Label = "Question 1", + Description = "Description", + Type = QuestionType.Text.ToString(), + Order = 1, + Definition = null + } + ] + }, + new ScoresheetSectionSnapshot + { + Name = "Section B", + Order = 2, + Fields = + [ + new ScoresheetFieldSnapshot + { + Id = Guid.NewGuid(), + Label = "Question 2", + Description = "Description", + Type = QuestionType.Text.ToString(), + Order = 1, + Definition = null + } + ] + } ] }); + dataProvider.GetApplicationSubmissionAsync(Arg.Any()).Returns(new ApplicationSubmissionSnapshot + { + ApplicationFormVersionId = Guid.NewGuid(), + Submission = JsonSerializer.Serialize(new { data = new { project_name = "Project Beta" } }) + }); + dataProvider.GetAttachmentSummariesAsync(Arg.Any()).Returns([]); + dataProvider.GetApplicationFormVersionAsync(Arg.Any()).Returns(new ApplicationFormVersionSnapshot + { + FormSchema = JsonSerializer.Serialize(new { components = Array.Empty() }) + }); + + var operationRepository = CreateOperationRepository(AIExecutionMode.Batch); + var service = CreateService(aiService, operationRepository, dataProvider); + + var result = await service.GenerateApplicationScoringAsync(Guid.NewGuid()); result.ShouldContain("\"q1\""); capturedRequests.Count.ShouldBe(1); @@ -99,54 +197,51 @@ public async Task RegenerateAsync_Batch_Mode_Uses_Aggregated_Section_Schema() } [Fact] - public async Task RegenerateAsync_Batch_Mode_Rejects_Non_Array_Section_Schemas() + public async Task RegenerateAsync_Batch_Mode_Rejects_Missing_Scoresheet() { var aiService = Substitute.For(); - var service = CreateService(aiService, "Batch"); - - var result = await service.RegenerateAsync(new ApplicationScoringOperationInputDto + var dataProvider = Substitute.For(); + dataProvider.GetApplicationFormAsync(Arg.Any()).Returns(new ApplicationFormSnapshot { - ApplicationId = Guid.NewGuid(), - Data = JsonSerializer.SerializeToElement(new { project_name = "Project Gamma" }), - Attachments = [], - Sections = - [ - new ApplicationScoringSectionOperationInputDto - { - SectionName = "Broken Section", - SectionSchema = JsonSerializer.SerializeToElement(new { q1 = "bad" }) - } - ] + ScoresheetId = null }); + var operationRepository = CreateOperationRepository(AIExecutionMode.Batch); + var service = CreateService(aiService, operationRepository, dataProvider); - result.ShouldBe("{}"); - await aiService.DidNotReceive().GenerateApplicationScoringAsync(Arg.Any(), Arg.Any()); + await Should.ThrowAsync(() => service.GenerateApplicationScoringAsync(Guid.NewGuid())); } - private static ApplicationScoringService CreateService(IAIService aiService, string executionMode) + private static ApplicationScoringService CreateService( + IAIService aiService, + IRepository operationRepository, + IApplicationGenerationDataProvider dataProvider) { - var configuration = new ConfigurationBuilder() - .AddInMemoryCollection(new Dictionary - { - ["Azure:Operations:Defaults:ExecutionMode"] = executionMode - }) - .Build(); - return new ApplicationScoringService( aiService, - new AIExecutionModeResolver(configuration), + operationRepository, + dataProvider, NullLogger.Instance); } - private static ApplicationScoringSectionOperationInputDto CreateSection(string name, string questionId) + private static IRepository CreateOperationRepository(AIExecutionMode executionMode) { - return new ApplicationScoringSectionOperationInputDto - { - SectionName = name, - SectionSchema = JsonSerializer.SerializeToElement(new[] + var operationRepository = Substitute.For>(); + operationRepository.GetListAsync(Arg.Any>>(), Arg.Any(), Arg.Any()) + .Returns(callInfo => { - new { id = questionId } - }) - }; + var filter = callInfo.ArgAt>>(0).Compile(); + var operations = new[] + { + new AIOperation(Guid.NewGuid(), AIPromptTypes.ApplicationScoring, Guid.NewGuid(), Guid.NewGuid()) + { + ExecutionMode = executionMode, + IsActive = true + } + }; + + return Task.FromResult(operations.Where(filter).ToList()); + }); + + return operationRepository; } } diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/Runtime/AIPromptTemplateProviderTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/Runtime/AIPromptTemplateProviderTests.cs index d638562085..de946e6540 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/Runtime/AIPromptTemplateProviderTests.cs +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/Runtime/AIPromptTemplateProviderTests.cs @@ -55,11 +55,7 @@ public async Task GetRequiredPromptAsync_Should_Return_Prompt_Definition_From_St snapshot.SystemPrompt.ShouldBe("SYSTEM"); snapshot.UserPrompt.ShouldBe("USER"); snapshot.MetadataJson.ShouldContain("ApplicationAnalysis"); - snapshot.Manifest.ShouldNotBeNull(); - snapshot.Manifest!.OperationName.ShouldBe("ApplicationAnalysis"); - snapshot.Manifest.PromptVersion.ShouldBe("v1"); - snapshot.Manifest.InputContractName.ShouldBe("ApplicationAnalysisOperationInputDto"); - snapshot.Manifest.OutputContractName.ShouldBe("ApplicationAnalysisResponse"); + snapshot.MetadataJson.ShouldContain("ApplicationAnalysis"); } [Fact] diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/Runtime/OpenAIConfigurationResolverTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/Runtime/OpenAIConfigurationResolverTests.cs index c40c2f076e..b4b01e4f33 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/Runtime/OpenAIConfigurationResolverTests.cs +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/Runtime/OpenAIConfigurationResolverTests.cs @@ -9,6 +9,7 @@ using System.Threading; using System.Threading.Tasks; using Unity.AI.Domain; +using Unity.AI.Execution; using Unity.AI.Operations; using Unity.AI.Prompts; using Unity.AI.Runtime; diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/ApplicationForms/ApplicationFormVersionAppServiceTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/ApplicationForms/ApplicationFormVersionAppServiceTests.cs index f926d1c97e..d45854dce0 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/ApplicationForms/ApplicationFormVersionAppServiceTests.cs +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/ApplicationForms/ApplicationFormVersionAppServiceTests.cs @@ -61,16 +61,7 @@ public async Task GenerateMappingAsync_Should_Save_SubmissionHeaderMapping_From_ aiService.GenerateFormMappingAsync(Arg.Do(request => capturedRequest = request), Arg.Any()) .Returns(new FormMappingResponse { - CoreFieldMatches = - [ - new FormMappingMatchResponse - { - SourceField = "ProjectName", - TargetField = "ProjectName", - Reason = "same meaning", - Confidence = 0.99M - } - ] + Mapping = """{"ProjectName":"ProjectName"}""" }); var service = CreateService(repository, readService, aiService); @@ -78,7 +69,6 @@ public async Task GenerateMappingAsync_Should_Save_SubmissionHeaderMapping_From_ var result = await service.GenerateMappingAsync(formVersionId); result.ApplicationFormVersionId.ShouldBe(formVersionId); - result.CoreFieldMatches.Count.ShouldBe(1); capturedRequest.ShouldNotBeNull(); capturedRequest!.Data.GetProperty("ChefsFields").ValueKind.ShouldBe(System.Text.Json.JsonValueKind.Array); capturedRequest.Data.GetProperty("UnityCoreFields").ValueKind.ShouldBe(System.Text.Json.JsonValueKind.Array); diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantApplications/Automation/AIGenerationAppServiceTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantApplications/Automation/AIGenerationAppServiceTests.cs index 237df9ab86..8abcd8f10d 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantApplications/Automation/AIGenerationAppServiceTests.cs +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantApplications/Automation/AIGenerationAppServiceTests.cs @@ -9,7 +9,6 @@ using Unity.AI.Localization; using Unity.AI.Generation; using Unity.AI.Operations; -using Unity.AI.RateLimit; using Unity.AI.Settings; using Unity.GrantManager.GrantApplications; using Unity.GrantManager.GrantApplications.Automation.BackgroundJobs; @@ -37,22 +36,15 @@ public async Task GenerateAttachmentSummariesAsync_Should_Validate_Against_Appli var attachmentIds = new List { Guid.NewGuid(), Guid.NewGuid() }; var service = new AIGenerationAppService( - Substitute.For(), + Substitute.For(), Substitute.For(), - Substitute.For(), featureGuard, Substitute.For()); service.LazyServiceProvider = GetRequiredService(); - var result = await service.GenerateAttachmentSummariesAsync(new GenerateAttachmentSummariesInputDto - { - ApplicationId = applicationId, - AttachmentIds = attachmentIds, - PromptVersion = "v1" - }); + await service.GenerateApplicationAttachmentSummariesAsync(applicationId, attachmentIds, "v1"); - result.Count.ShouldBe(2); - result.ShouldAllBe(x => x.Completed == false); + await Task.CompletedTask; } [Fact] @@ -77,46 +69,34 @@ public async Task GetStatusAsync_Should_Map_Request_And_Rate_Limit_State() IsActive = true }); - var rateLimiter = Substitute.For(); - rateLimiter.GetStateAsync().Returns(new AIRateLimitStateDto - { - IsGenerating = true, - RetryAfterSeconds = 17 - }); - var currentTenant = Substitute.For(); currentTenant.Id.Returns(tenantId); var service = new AIGenerationAppService( - Substitute.For(), + Substitute.For(), statusService, - rateLimiter, CreateFeatureGuard(), currentTenant); service.LazyServiceProvider = GetRequiredService(); var result = await service.GetStatusAsync(applicationId, operationType); - result.GenerationRequest.ShouldNotBeNull(); - result.GenerationRequest!.Id.ShouldBe(requestId); - result.GenerationRequest.ApplicationId.ShouldBe(applicationId); - result.GenerationRequest.OperationId.ShouldBe(operationId); - result.GenerationRequest.OperationType.ShouldBe(operationType); - result.GenerationRequest.Status.ShouldBe(AIGenerationRequestStatus.Running.ToString()); - result.GenerationRequest.StartedAt.ShouldBe(new DateTime(2026, 7, 1, 12, 0, 0)); - result.GenerationRequest.FailureReason.ShouldBe("not used"); - result.GenerationRequest.IsActive.ShouldBeTrue(); - result.IsGenerating.ShouldBeTrue(); - result.RetryAfterSeconds.ShouldBe(17); + result.Id.ShouldBe(requestId); + result.ApplicationId.ShouldBe(applicationId); + result.OperationId.ShouldBe(operationId); + result.OperationType.ShouldBe(operationType); + result.Status.ShouldBe(AIGenerationRequestStatus.Running.ToString()); + result.StartedAt.ShouldBe(new DateTime(2026, 7, 1, 12, 0, 0)); + result.FailureReason.ShouldBe("not used"); + result.IsActive.ShouldBeTrue(); } [Fact] public async Task GetStatusAsync_Should_Reject_Unsupported_Operation_Type() { var service = new AIGenerationAppService( - Substitute.For(), + Substitute.For(), Substitute.For(), - Substitute.For(), CreateFeatureGuard(), Substitute.For()); service.LazyServiceProvider = GetRequiredService(); diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantApplications/Automation/AIGenerationQueueTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantApplications/Automation/AIGenerationQueueTests.cs index 924789b18c..6db2e3aa88 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantApplications/Automation/AIGenerationQueueTests.cs +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantApplications/Automation/AIGenerationQueueTests.cs @@ -12,7 +12,8 @@ using Unity.AI.Features; using Unity.AI.Localization; using Unity.AI.Operations; -using Unity.AI.RateLimit; +using Unity.AI.Cooldown; +using Unity.AI.Validation; using Unity.GrantManager.GrantApplications; using Unity.GrantManager.GrantApplications.Automation.BackgroundJobs; using Volo.Abp; @@ -30,24 +31,24 @@ namespace Unity.GrantManager.GrantApplications.Automation; public class AIGenerationQueueTests(ITestOutputHelper outputHelper) : GrantManagerApplicationTestBase(outputHelper) { - private static readonly Guid AttachmentSummaryOperationId = Guid.Parse("11111111-1111-1111-1111-111111111111"); + private static readonly Guid ApplicationAttachmentSummaryOperationId = Guid.Parse("11111111-1111-1111-1111-111111111111"); private static readonly Guid ApplicationAnalysisOperationId = Guid.Parse("22222222-2222-2222-2222-222222222222"); private static readonly Guid ApplicationScoringOperationId = Guid.Parse("33333333-3333-3333-3333-333333333333"); private static readonly Guid FormMappingOperationId = Guid.Parse("44444444-4444-4444-4444-444444444444"); private static readonly Guid FormWorksheetOperationId = Guid.Parse("55555555-5555-5555-5555-555555555555"); [Fact] - public async Task QueueAllAIStagesAsync_Should_Enqueue_Pipeline_Job_When_None_Exists() + public async Task QueueApplicationIntakeAsync_Should_Enqueue_Pipeline_Job_When_None_Exists() { var applicationId = Guid.NewGuid(); var tenantId = Guid.NewGuid(); var backgroundJobManager = Substitute.For(); - var attachmentJobs = new List(); + var attachmentJobs = new List(); var analysisJobs = new List(); var scoringJobs = new List(); - backgroundJobManager.EnqueueAsync(Arg.Any(), Arg.Any(), Arg.Any()) + backgroundJobManager.EnqueueAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(callInfo => { - attachmentJobs.Add(callInfo.Arg()); + attachmentJobs.Add(callInfo.Arg()); return Task.FromResult(string.Empty); }); backgroundJobManager.EnqueueAsync(Arg.Any(), Arg.Any(), Arg.Any()) @@ -65,7 +66,7 @@ public async Task QueueAllAIStagesAsync_Should_Enqueue_Pipeline_Job_When_None_Ex var queue = CreateQueue(backgroundJobManager); - await queue.QueueAllAIStagesAsync(applicationId, tenantId, "v1"); + await queue.QueueApplicationIntakeAsync(applicationId, tenantId, "v1"); attachmentJobs.Single().ApplicationId.ShouldBe(applicationId); analysisJobs.Single().ApplicationId.ShouldBe(applicationId); @@ -76,18 +77,18 @@ public async Task QueueAllAIStagesAsync_Should_Enqueue_Pipeline_Job_When_None_Ex } [Fact] - public async Task QueueAllAIStagesAsync_Should_Enqueue_When_Any_Enabled_Stage_Has_Required_Input() + public async Task QueueApplicationIntakeAsync_Should_Enqueue_When_Any_Enabled_Stage_Has_Required_Input() { var applicationId = Guid.NewGuid(); var tenantId = Guid.NewGuid(); var backgroundJobManager = Substitute.For(); - var attachmentJobs = new List(); + var attachmentJobs = new List(); var analysisJobs = new List(); var scoringJobs = new List(); - backgroundJobManager.EnqueueAsync(Arg.Any(), Arg.Any(), Arg.Any()) + backgroundJobManager.EnqueueAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(callInfo => { - attachmentJobs.Add(callInfo.Arg()); + attachmentJobs.Add(callInfo.Arg()); return Task.FromResult(string.Empty); }); backgroundJobManager.EnqueueAsync(Arg.Any(), Arg.Any(), Arg.Any()) @@ -105,7 +106,7 @@ public async Task QueueAllAIStagesAsync_Should_Enqueue_When_Any_Enabled_Stage_Ha var queue = CreateQueue(backgroundJobManager); - await queue.QueueAllAIStagesAsync(applicationId, tenantId); + await queue.QueueApplicationIntakeAsync(applicationId, tenantId); attachmentJobs.Count.ShouldBe(1); analysisJobs.Count.ShouldBe(1); @@ -113,14 +114,14 @@ public async Task QueueAllAIStagesAsync_Should_Enqueue_When_Any_Enabled_Stage_Ha } [Fact] - public async Task QueueAllAIStagesAsync_Should_Not_Enqueue_When_No_Enabled_Stage_Has_Required_Input() + public async Task QueueApplicationIntakeAsync_Should_Not_Enqueue_When_No_Enabled_Stage_Has_Required_Input() { var applicationId = Guid.NewGuid(); var tenantId = Guid.NewGuid(); var backgroundJobManager = Substitute.For(); - var rateLimiter = Substitute.For(); - rateLimiter.EnsureAsync().Returns(Task.CompletedTask); - var prerequisiteValidator = Substitute.For(); + var cooldownService = Substitute.For(); + cooldownService.EnsureAsync().Returns(Task.CompletedTask); + var prerequisiteValidator = Substitute.For(); prerequisiteValidator.EnsureAttachmentSummaryAvailableAsync(applicationId) .Returns(_ => throw new UserFriendlyException("No attachments are available to summarize.")); prerequisiteValidator.EnsureApplicationAnalysisAvailableAsync(applicationId) @@ -128,12 +129,12 @@ public async Task QueueAllAIStagesAsync_Should_Not_Enqueue_When_No_Enabled_Stage prerequisiteValidator.EnsureApplicationScoringAvailableAsync(applicationId) .Returns(_ => throw new UserFriendlyException("AI scoring requires a configured scoresheet.")); - var queue = CreateQueue(backgroundJobManager, rateLimiter: rateLimiter, prerequisiteValidator: prerequisiteValidator); + var queue = CreateQueue(backgroundJobManager, cooldownService: cooldownService, prerequisiteValidator: prerequisiteValidator); - await Should.ThrowAsync(() => queue.QueueAllAIStagesAsync(applicationId, tenantId)); + await Should.ThrowAsync(() => queue.QueueApplicationIntakeAsync(applicationId, tenantId)); - await rateLimiter.DidNotReceive().EnsureAsync(); - await backgroundJobManager.DidNotReceive().EnqueueAsync(Arg.Any(), Arg.Any(), Arg.Any()); + await cooldownService.DidNotReceive().EnsureAsync(); + await backgroundJobManager.DidNotReceive().EnqueueAsync(Arg.Any(), Arg.Any(), Arg.Any()); await backgroundJobManager.DidNotReceive().EnqueueAsync(Arg.Any(), Arg.Any(), Arg.Any()); await backgroundJobManager.DidNotReceive().EnqueueAsync(Arg.Any(), Arg.Any(), Arg.Any()); } @@ -184,13 +185,13 @@ public async Task QueueApplicationAnalysisAsync_Should_Check_Rate_Limit_Before_E .Returns(callInfo => Task.FromResult(callInfo.Arg())); var backgroundJobManager = Substitute.For(); - var rateLimiter = Substitute.For(); - rateLimiter.EnsureAsync().Returns(Task.CompletedTask); - var queue = CreateQueue(backgroundJobManager, repository, rateLimiter: rateLimiter); + var cooldownService = Substitute.For(); + cooldownService.EnsureAsync().Returns(Task.CompletedTask); + var queue = CreateQueue(backgroundJobManager, repository, cooldownService: cooldownService); await queue.QueueApplicationAnalysisAsync(applicationId, tenantId); - await rateLimiter.Received(1).EnsureAsync(); + await cooldownService.Received(1).EnsureAsync(); await repository.Received(1).InsertAsync(Arg.Any(), Arg.Any(), Arg.Any()); await backgroundJobManager.Received(1).EnqueueAsync(Arg.Any(), Arg.Any(), Arg.Any()); } @@ -204,19 +205,19 @@ public async Task QueueApplicationAnalysisAsync_Should_Not_Insert_Or_Enqueue_Whe repository.GetQueryableAsync().Returns(Task.FromResult>(Array.Empty().AsQueryable())); var backgroundJobManager = Substitute.For(); - var rateLimiter = Substitute.For(); - rateLimiter.EnsureAsync().Returns(_ => throw new InvalidOperationException("rate limited")); - var queue = CreateQueue(backgroundJobManager, repository, rateLimiter: rateLimiter); + var cooldownService = Substitute.For(); + cooldownService.EnsureAsync().Returns(_ => throw new InvalidOperationException("rate limited")); + var queue = CreateQueue(backgroundJobManager, repository, cooldownService: cooldownService); await Should.ThrowAsync(() => queue.QueueApplicationAnalysisAsync(applicationId, tenantId)); - await rateLimiter.Received(1).EnsureAsync(); + await cooldownService.Received(1).EnsureAsync(); await repository.DidNotReceive().InsertAsync(Arg.Any(), Arg.Any(), Arg.Any()); await backgroundJobManager.DidNotReceive().EnqueueAsync(Arg.Any(), Arg.Any(), Arg.Any()); } [Fact] - public async Task QueueAttachmentSummaryAsync_Should_Not_Insert_Or_Enqueue_When_No_Attachments_Are_Available() + public async Task QueueApplicationAttachmentSummaryAsync_Should_Not_Insert_Or_Enqueue_When_No_Attachments_Are_Available() { var applicationId = Guid.NewGuid(); var tenantId = Guid.NewGuid(); @@ -224,22 +225,22 @@ public async Task QueueAttachmentSummaryAsync_Should_Not_Insert_Or_Enqueue_When_ repository.GetQueryableAsync().Returns(Task.FromResult>(Array.Empty().AsQueryable())); var backgroundJobManager = Substitute.For(); - var rateLimiter = Substitute.For(); - rateLimiter.EnsureAsync().Returns(Task.CompletedTask); - var prerequisiteValidator = Substitute.For(); + var cooldownService = Substitute.For(); + cooldownService.EnsureAsync().Returns(Task.CompletedTask); + var prerequisiteValidator = Substitute.For(); prerequisiteValidator.EnsureAttachmentSummaryAvailableAsync(applicationId) .Returns(_ => throw new UserFriendlyException("No attachments are available to summarize.")); - var queue = CreateQueue(backgroundJobManager, repository, rateLimiter: rateLimiter, prerequisiteValidator: prerequisiteValidator); + var queue = CreateQueue(backgroundJobManager, repository, cooldownService: cooldownService, prerequisiteValidator: prerequisiteValidator); - await Should.ThrowAsync(() => queue.QueueAttachmentSummaryAsync(applicationId, tenantId)); + await Should.ThrowAsync(() => queue.QueueApplicationAttachmentSummaryAsync(applicationId, tenantId, [])); - await rateLimiter.DidNotReceive().EnsureAsync(); + await cooldownService.DidNotReceive().EnsureAsync(); await repository.DidNotReceive().InsertAsync(Arg.Any(), Arg.Any(), Arg.Any()); - await backgroundJobManager.DidNotReceive().EnqueueAsync(Arg.Any(), Arg.Any(), Arg.Any()); + await backgroundJobManager.DidNotReceive().EnqueueAsync(Arg.Any(), Arg.Any(), Arg.Any()); } [Fact] - public async Task QueueAttachmentSummaryAsync_Should_Enqueue_New_Request_When_None_Exists() + public async Task QueueApplicationAttachmentSummaryAsync_Should_Enqueue_New_Request_When_None_Exists() { var tenantId = Guid.NewGuid(); var applicationId = Guid.NewGuid(); @@ -249,18 +250,18 @@ public async Task QueueAttachmentSummaryAsync_Should_Enqueue_New_Request_When_No repository.InsertAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(callInfo => Task.FromResult(callInfo.Arg())); - GenerateAttachmentSummaryBackgroundJobArgs? capturedArgs = null; + GenerateApplicationAttachmentSummaryBackgroundJobArgs? capturedArgs = null; var backgroundJobManager = Substitute.For(); - backgroundJobManager.EnqueueAsync(Arg.Any(), Arg.Any(), Arg.Any()) + backgroundJobManager.EnqueueAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(callInfo => { - capturedArgs = callInfo.Arg(); + capturedArgs = callInfo.Arg(); return Task.FromResult(string.Empty); }); var queue = CreateQueue(backgroundJobManager, repository); - await queue.QueueAttachmentSummaryAsync(applicationId, tenantId, promptVersion); + await queue.QueueApplicationAttachmentSummaryAsync(applicationId, tenantId, [], promptVersion: promptVersion); capturedArgs.ShouldNotBeNull(); capturedArgs!.ApplicationId.ShouldBe(applicationId); @@ -330,7 +331,7 @@ public async Task QueueFormWorksheetAsync_Should_Enqueue_New_Request_When_None_E return Task.FromResult(string.Empty); }); - var prerequisiteValidator = Substitute.For(); + var prerequisiteValidator = Substitute.For(); prerequisiteValidator.EnsureFormWorksheetAvailableAsync(applicationFormVersionId).Returns(Task.CompletedTask); var queue = CreateQueue( @@ -391,11 +392,11 @@ public ValueTask DisposeAsync() } } - private static ApplicationAIGenerationQueue CreateQueue( + private static ApplicationGenerationQueue CreateQueue( IBackgroundJobManager backgroundJobManager, IRepository? repository = null, - IAIRateLimiter? rateLimiter = null, - IAIGenerationPrerequisiteValidator? prerequisiteValidator = null, + IAICooldownAppService? cooldownService = null, + IGenerationPrerequisiteValidator? prerequisiteValidator = null, IFeatureChecker? featureChecker = null, IRepository? operationRepository = null, IAsyncQueryableExecuter? asyncQueryableExecuter = null) @@ -403,15 +404,15 @@ private static ApplicationAIGenerationQueue CreateQueue( repository ??= Substitute.For>(); repository.GetQueryableAsync().Returns(Task.FromResult>(Array.Empty().AsQueryable())); - if (rateLimiter == null) + if (cooldownService == null) { - rateLimiter = Substitute.For(); - rateLimiter.EnsureAsync().Returns(Task.CompletedTask); + cooldownService = Substitute.For(); + cooldownService.EnsureAsync().Returns(Task.CompletedTask); } if (prerequisiteValidator == null) { - prerequisiteValidator = Substitute.For(); + prerequisiteValidator = Substitute.For(); prerequisiteValidator.EnsureAttachmentSummaryAvailableAsync(Arg.Any()).Returns(Task.CompletedTask); prerequisiteValidator.EnsureApplicationAnalysisAvailableAsync(Arg.Any()).Returns(Task.CompletedTask); prerequisiteValidator.EnsureApplicationScoringAvailableAsync(Arg.Any()).Returns(Task.CompletedTask); @@ -422,7 +423,7 @@ private static ApplicationAIGenerationQueue CreateQueue( featureChecker = Substitute.For(); featureChecker.IsEnabledAsync(AIFeatures.AttachmentSummaries).Returns(Task.FromResult(true)); featureChecker.IsEnabledAsync(AIFeatures.ApplicationAnalysis).Returns(Task.FromResult(true)); - featureChecker.IsEnabledAsync(AIFeatures.Scoring).Returns(Task.FromResult(true)); + featureChecker.IsEnabledAsync(AIFeatures.ApplicationScoring).Returns(Task.FromResult(true)); } asyncQueryableExecuter ??= Substitute.For(); @@ -433,17 +434,17 @@ private static ApplicationAIGenerationQueue CreateQueue( asyncQueryableExecuter.FirstOrDefaultAsync(Arg.Any>(), Arg.Any()) .Returns(callInfo => Task.FromResult(callInfo.Arg>().FirstOrDefault())); - return new ApplicationAIGenerationQueue( + return new ApplicationGenerationQueue( backgroundJobManager, repository, operationRepository ?? CreateOperationRepository(), new TestDistributedLockProvider(), prerequisiteValidator, featureChecker, - rateLimiter, + cooldownService, asyncQueryableExecuter, CreateCurrentUser(), - Substitute.For>(), + Substitute.For>(), Substitute.For>()); } @@ -453,7 +454,7 @@ private static IRepository CreateOperationRepository() { var operations = new List { - new(AttachmentSummaryOperationId, "AttachmentSummary", Guid.NewGuid(), Guid.NewGuid()) { IsActive = true }, + new(ApplicationAttachmentSummaryOperationId, "AttachmentSummary", Guid.NewGuid(), Guid.NewGuid()) { IsActive = true }, new(ApplicationAnalysisOperationId, "ApplicationAnalysis", Guid.NewGuid(), Guid.NewGuid()) { IsActive = true }, new(ApplicationScoringOperationId, "ApplicationScoring", Guid.NewGuid(), Guid.NewGuid()) { IsActive = true }, new(FormMappingOperationId, "FormMapping", Guid.NewGuid(), Guid.NewGuid()) { IsActive = true }, From 5416871792cc47c92b1cfcd7a03c486e8e268e07 Mon Sep 17 00:00:00 2001 From: Jacob Smith Date: Sat, 11 Jul 2026 20:09:16 -0700 Subject: [PATCH 063/223] AB#33569 align form mapping direction and AI runtime state --- .../AI/Runtime/OpenAIConfigurationResolver.cs | 75 ++++++++++++++++--- .../AIApplicationModule.cs | 4 +- .../DataSeed/AIPromptDataSeeder.cs | 56 +++----------- ...ateApplicationAnalysisBackgroundJobArgs.cs | 1 + ...rateApplicationScoringBackgroundJobArgs.cs | 1 + ...erateAttachmentSummaryBackgroundJobArgs.cs | 1 + .../GenerateFormMappingBackgroundJobArgs.cs | 1 + .../GenerateFormWorksheetBackgroundJobArgs.cs | 1 + .../ApplicationFormVersionAppService.cs | 23 +++++- .../ApplicationAIGenerationQueue.cs | 24 +++--- .../AIGenerationRequestJobHelper.cs | 35 ++------- .../GenerateApplicationAnalysisJob.cs | 14 ++-- .../GenerateApplicationScoringJob.cs | 16 ++-- .../GenerateAttachmentSummaryJob.cs | 17 ++--- .../BackgroundJobs/GenerateFormMappingJob.cs | 14 ++-- ...GenerateFormScoresheetBackgroundJobArgs.cs | 1 + .../GenerateFormScoresheetJob.cs | 14 ++-- .../GenerateFormWorksheetJob.cs | 14 ++-- .../Pages/ApplicationForms/Mapping.cshtml | 2 +- .../Pages/ApplicationForms/Mapping.css | 26 +++++-- .../Pages/GrantApplications/Details.css | 29 +++++-- .../AI/Operations/AIExecutionStrategyTests.cs | 39 ---------- .../ApplicationScoringServiceTests.cs | 8 +- .../AttachmentSummaryServiceTests.cs | 21 +++++- .../AIProviderPayloadValidatorTests.cs | 20 ++--- .../OpenAIConfigurationResolverTests.cs | 5 +- .../AI/Runtime/OpenAIRuntimeServiceTests.cs | 1 + .../Automation/AIGenerationAppServiceTests.cs | 13 +++- .../Automation/AIGenerationQueueTests.cs | 33 ++++---- 29 files changed, 264 insertions(+), 245 deletions(-) delete mode 100644 applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/Operations/AIExecutionStrategyTests.cs diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIConfigurationResolver.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIConfigurationResolver.cs index 7490142fbe..d5ef48ef06 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIConfigurationResolver.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIConfigurationResolver.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Caching.Memory; using System; using System.Linq; using System.Text.Json; @@ -17,9 +18,11 @@ public class OpenAIConfigurationResolver( IRepository modelRepository, IRepository operationRepository, IRepository promptRepository, + IMemoryCache memoryCache, IConfiguration configuration, IDataFilter multiTenantDataFilter) : ITransientDependency { + private static readonly TimeSpan SettingsCacheDuration = TimeSpan.FromMinutes(5); private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true @@ -28,6 +31,7 @@ public class OpenAIConfigurationResolver( private readonly IRepository _modelRepository = modelRepository; private readonly IRepository _operationRepository = operationRepository; private readonly IRepository _promptRepository = promptRepository; + private readonly IMemoryCache _memoryCache = memoryCache; private readonly IConfiguration _configuration = configuration; private readonly IDataFilter _multiTenantDataFilter = multiTenantDataFilter; @@ -43,6 +47,12 @@ public async Task ResolveOperationSettingsAsync( string operationName, CancellationToken cancellationToken = default) { + var cacheKey = BuildOperationSettingsCacheKey(operationName); + if (_memoryCache.TryGetValue(cacheKey, out OpenAIOperationSettings cachedSettings)) + { + return cachedSettings; + } + var operation = await ResolveOperationAsync(operationName, cancellationToken); if (operation == null) { @@ -74,7 +84,7 @@ public async Task ResolveOperationSettingsAsync( } var apiKey = Required($"Azure:{providerName}:ApiKey"); - return new OpenAIOperationSettings( + var settings = new OpenAIOperationSettings( providerName, model.Name, apiKey, @@ -84,6 +94,9 @@ public async Task ResolveOperationSettingsAsync( modelSettings.Temperature, operation.CompletionTokens, $"v{prompt.VersionNumber}"); + + _memoryCache.Set(cacheKey, settings, SettingsCacheDuration); + return settings; } public async Task ResolveConfiguredTemperatureAsync(string? modelName = null, CancellationToken cancellationToken = default) @@ -202,16 +215,6 @@ public async Task ResolveProfileNameAsync(string? modelName = null, Canc return (model, settings); } - private async Task ResolveOperationAsync(string operationName, CancellationToken cancellationToken) - { - var operations = await _operationRepository.GetListAsync( - operation => operation.IsActive, - cancellationToken: cancellationToken); - - return operations.FirstOrDefault(operation => - string.Equals(operation.Name, operationName, StringComparison.OrdinalIgnoreCase)); - } - private static AIModelSettings ResolveModelSettings(AIModel model) { var settings = JsonSerializer.Deserialize(model.SettingsJson, JsonOptions); @@ -279,6 +282,11 @@ private async Task ResolvePromptVersionAsyncCore(string operationName, C return $"v{prompt.VersionNumber}"; } + private static string BuildOperationSettingsCacheKey(string operationName) + { + return $"ai:operation-settings:{operationName.Trim().ToLowerInvariant()}"; + } + private async Task LoadPromptAsync(Guid promptId, CancellationToken cancellationToken) { using (_multiTenantDataFilter.Disable()) @@ -286,4 +294,49 @@ private async Task LoadPromptAsync(Guid promptId, CancellationToken ca return await _promptRepository.GetAsync(promptId, cancellationToken: cancellationToken); } } + + private async Task ResolveOperationAsync(string operationName, CancellationToken cancellationToken) + { + var cacheKey = BuildOperationSnapshotCacheKey(operationName); + if (_memoryCache.TryGetValue(cacheKey, out ResolvedOperationSnapshot cachedOperation)) + { + return cachedOperation; + } + + var operations = await _operationRepository.GetListAsync( + operation => operation.IsActive, + cancellationToken: cancellationToken); + + var operation = operations.FirstOrDefault(operation => + string.Equals(operation.Name, operationName, StringComparison.OrdinalIgnoreCase)); + + if (operation == null) + { + return null; + } + + var snapshot = new ResolvedOperationSnapshot( + operation.Id, + operation.Name, + operation.AIModelId, + operation.AIPromptId, + operation.CompletionTokens, + operation.IsActive); + + _memoryCache.Set(cacheKey, snapshot, SettingsCacheDuration); + return snapshot; + } + + private static string BuildOperationSnapshotCacheKey(string operationName) + { + return $"ai:operation-snapshot:{operationName.Trim().ToLowerInvariant()}"; + } + + private sealed record ResolvedOperationSnapshot( + Guid Id, + string Name, + Guid AIModelId, + Guid AIPromptId, + int CompletionTokens, + bool IsActive); } diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AIApplicationModule.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AIApplicationModule.cs index 6030c31218..1c1ed15bd7 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AIApplicationModule.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AIApplicationModule.cs @@ -31,6 +31,8 @@ public override void PreConfigureServices(ServiceConfigurationContext context) public override void ConfigureServices(ServiceConfigurationContext context) { + context.Services.AddMemoryCache(); + Configure(options => { options.IsEnabled = true; @@ -55,4 +57,4 @@ public override void ConfigureServices(ServiceConfigurationContext context) context.Services.AddAssemblyOf(); } -} \ No newline at end of file +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/DataSeed/AIPromptDataSeeder.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/DataSeed/AIPromptDataSeeder.cs index ab44bfadbb..ecb81c2054 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/DataSeed/AIPromptDataSeeder.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/DataSeed/AIPromptDataSeeder.cs @@ -803,7 +803,7 @@ 4. Choose the most conservative valid answer supported by that evidence. // ── v0/mapping-suggestion.system.txt ──────────────────────────────────── private const string FormMappingSystemV2 = """ You are a careful mapping assistant for human reviewers. - Return structured JSON for recommended CHEFS-to-Unity field mapping. + Return structured JSON for recommended Unity-to-CHEFS field mapping. Do not invent fields, persist changes, or add wrapper sections. Return only valid JSON in the exact mapping shape requested. """; @@ -815,55 +815,19 @@ Return only valid JSON in the exact mapping shape requested. OUTPUT { - "coreFieldMatches": [ - { - "sourceField": "", - "targetField": "", - "reason": "", - "confidence": - } - ], - "worksheetMatches": [ - { - "worksheetName": "", - "fieldMatches": [ - { - "sourceField": "", - "targetField": "", - "reason": "", - "confidence": - } - ] - } - ], - "worksheetCreationSuggestions": [ - { - "worksheetName": "", - "suggestedFields": [ - { - "name": "", - "type": "", - "label": "", - "isCustom": true - } - ], - "reason": "" - } - ], - "issues": [ - { - "code": "", - "message": "" - } - ] + "": "", + "": "" } Important: - Use only FORM MAPPING CONTEXT as evidence. - - Prefer existing Unity core intake fields when they already fit the source field. - - Only suggest worksheet fields or worksheet creation when the form genuinely needs them. - - Return only fields that are supported by the context. - - Keep reasons specific and concise. + - The context is grouped as chefsData and unityData. + - chefsData.fields contains the CHEFS source fields. + - unityData.coreFields contains Unity target fields. + - unityData.customFields contains worksheet-derived Unity target fields. + - The mapping is dynamic; do not hardcode or assume a fixed list of fields. + - Prefer existing Unity core intake fields when they already fit the CHEFS source field. + - Only use worksheet custom field targets when the form genuinely needs them. - Return valid plain JSON only in the exact OUTPUT shape. """; diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/Automation/BackgroundJobs/GenerateApplicationAnalysisBackgroundJobArgs.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/Automation/BackgroundJobs/GenerateApplicationAnalysisBackgroundJobArgs.cs index 819e281758..c9ee1cc7af 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/Automation/BackgroundJobs/GenerateApplicationAnalysisBackgroundJobArgs.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/Automation/BackgroundJobs/GenerateApplicationAnalysisBackgroundJobArgs.cs @@ -3,6 +3,7 @@ namespace Unity.GrantManager.GrantApplications.Automation.BackgroundJobs; public class GenerateApplicationAnalysisBackgroundJobArgs { public Guid ApplicationId { get; set; } + public Guid OperationId { get; set; } public Guid? TenantId { get; set; } public Guid? RequestedByUserId { get; set; } public string? PromptVersion { get; set; } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/Automation/BackgroundJobs/GenerateApplicationScoringBackgroundJobArgs.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/Automation/BackgroundJobs/GenerateApplicationScoringBackgroundJobArgs.cs index 6e59353451..d57e4378fa 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/Automation/BackgroundJobs/GenerateApplicationScoringBackgroundJobArgs.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/Automation/BackgroundJobs/GenerateApplicationScoringBackgroundJobArgs.cs @@ -3,6 +3,7 @@ namespace Unity.GrantManager.GrantApplications.Automation.BackgroundJobs; public class GenerateApplicationScoringBackgroundJobArgs { public Guid ApplicationId { get; set; } + public Guid OperationId { get; set; } public Guid? TenantId { get; set; } public Guid? RequestedByUserId { get; set; } public string? PromptVersion { get; set; } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/Automation/BackgroundJobs/GenerateAttachmentSummaryBackgroundJobArgs.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/Automation/BackgroundJobs/GenerateAttachmentSummaryBackgroundJobArgs.cs index 987a6037b8..746bc729b8 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/Automation/BackgroundJobs/GenerateAttachmentSummaryBackgroundJobArgs.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/Automation/BackgroundJobs/GenerateAttachmentSummaryBackgroundJobArgs.cs @@ -6,6 +6,7 @@ namespace Unity.GrantManager.GrantApplications.Automation.BackgroundJobs; public class GenerateAttachmentSummaryBackgroundJobArgs { public Guid ApplicationId { get; set; } + public Guid OperationId { get; set; } public Guid? TenantId { get; set; } public Guid? RequestedByUserId { get; set; } public List? AttachmentIds { get; set; } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/Automation/BackgroundJobs/GenerateFormMappingBackgroundJobArgs.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/Automation/BackgroundJobs/GenerateFormMappingBackgroundJobArgs.cs index 129dc0af6f..de81117c29 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/Automation/BackgroundJobs/GenerateFormMappingBackgroundJobArgs.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/Automation/BackgroundJobs/GenerateFormMappingBackgroundJobArgs.cs @@ -5,6 +5,7 @@ namespace Unity.GrantManager.GrantApplications.Automation.BackgroundJobs; public class GenerateFormMappingBackgroundJobArgs { public Guid ApplicationId { get; set; } + public Guid OperationId { get; set; } public Guid? TenantId { get; set; } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/Automation/BackgroundJobs/GenerateFormWorksheetBackgroundJobArgs.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/Automation/BackgroundJobs/GenerateFormWorksheetBackgroundJobArgs.cs index 27785fdbb1..db274be220 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/Automation/BackgroundJobs/GenerateFormWorksheetBackgroundJobArgs.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/Automation/BackgroundJobs/GenerateFormWorksheetBackgroundJobArgs.cs @@ -5,6 +5,7 @@ namespace Unity.GrantManager.GrantApplications.Automation.BackgroundJobs; public class GenerateFormWorksheetBackgroundJobArgs { public Guid ApplicationId { get; set; } + public Guid OperationId { get; set; } public Guid? TenantId { get; set; } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicationForms/ApplicationFormVersionAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicationForms/ApplicationFormVersionAppService.cs index a2adf74ec2..c265c13494 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicationForms/ApplicationFormVersionAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicationForms/ApplicationFormVersionAppService.cs @@ -10,6 +10,7 @@ using Unity.AI.Operations; using Unity.AI.Requests; using Unity.AI.Responses; +using Unity.AI.Runtime; using Unity.GrantManager.Forms; using Unity.GrantManager.Intakes; using Unity.GrantManager.Integrations.Chefs; @@ -324,9 +325,29 @@ public async Task DeleteWorkSheetMappingByFormName(string formName, Guid formVer public virtual async Task GenerateMappingAsync(Guid id) { var readModel = await _mappingReadService.GetAsync(id); + var promptData = new + { + chefsData = new + { + applicationFormId = readModel.ApplicationFormId, + applicationFormVersionId = readModel.ApplicationFormVersionId, + chefsApplicationFormGuid = readModel.ChefsApplicationFormGuid, + chefsFormVersionGuid = readModel.ChefsFormVersionGuid, + fields = readModel.ChefsFields + }, + unityData = new + { + coreFields = readModel.UnityCoreFields, + customFields = readModel.Worksheets + } + }; + var promptDataJsonOptions = new JsonSerializerOptions + { + WriteIndented = true + }; var response = await _aiService.GenerateFormMappingAsync(new FormMappingRequest { - Data = JsonSerializer.SerializeToElement(readModel) + Data = JsonSerializer.SerializeToElement(promptData, promptDataJsonOptions) }); var submissionHeaderMapping = FormMappingResponseMapper.BuildSubmissionHeaderMapping(response); var applicationFormVersion = await repository.GetAsync(id); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/ApplicationAIGenerationQueue.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/ApplicationAIGenerationQueue.cs index 34e039d3e4..efcef9625d 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/ApplicationAIGenerationQueue.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/ApplicationAIGenerationQueue.cs @@ -32,7 +32,7 @@ public class ApplicationGenerationQueue( IDistributedLockProvider distributedLockProvider, IGenerationPrerequisiteValidator aiGenerationPrerequisiteValidator, IFeatureChecker featureChecker, - IAICooldownAppService aiCooldownService, + ICooldownService aiCooldownService, IAsyncQueryableExecuter asyncQueryableExecuter, ICurrentUser currentUser, ILogger logger, @@ -47,11 +47,12 @@ await EnsureRequestAndEnqueueAsync( AIGenerationRequestKeyHelper.AttachmentSummaryOperationType, applicationId, () => aiGenerationPrerequisiteValidator.EnsureAttachmentSummaryAvailableAsync(applicationId), - () => + operationId => { return backgroundJobManager.EnqueueAsync(new GenerateApplicationAttachmentSummaryBackgroundJobArgs { ApplicationId = applicationId, + OperationId = operationId, AttachmentIds = attachmentIds, PromptVersion = promptVersion, RequestedByUserId = currentUser.Id, @@ -67,11 +68,12 @@ await EnsureRequestAndEnqueueAsync( AIGenerationRequestKeyHelper.ApplicationAnalysisOperationType, applicationId, () => aiGenerationPrerequisiteValidator.EnsureApplicationAnalysisAvailableAsync(applicationId), - () => + operationId => { return backgroundJobManager.EnqueueAsync(new GenerateApplicationAnalysisBackgroundJobArgs { ApplicationId = applicationId, + OperationId = operationId, PromptVersion = promptVersion, RequestedByUserId = currentUser.Id, TenantId = tenantId @@ -86,11 +88,12 @@ await EnsureRequestAndEnqueueAsync( AIGenerationRequestKeyHelper.ApplicationScoringOperationType, applicationId, () => aiGenerationPrerequisiteValidator.EnsureApplicationScoringAvailableAsync(applicationId), - () => + operationId => { return backgroundJobManager.EnqueueAsync(new GenerateApplicationScoringBackgroundJobArgs { ApplicationId = applicationId, + OperationId = operationId, PromptVersion = promptVersion, RequestedByUserId = currentUser.Id, TenantId = tenantId @@ -105,11 +108,12 @@ await EnsureRequestAndEnqueueAsync( AIGenerationRequestKeyHelper.FormMappingOperationType, applicationId, () => aiGenerationPrerequisiteValidator.EnsureFormMappingAvailableAsync(applicationFormVersionId), - () => + operationId => { return backgroundJobManager.EnqueueAsync(new GenerateFormMappingBackgroundJobArgs { ApplicationId = applicationId, + OperationId = operationId, ApplicationFormVersionId = applicationFormVersionId, PromptVersion = promptVersion, RequestedByUserId = currentUser.Id, @@ -125,11 +129,12 @@ await EnsureRequestAndEnqueueAsync( AIGenerationRequestKeyHelper.FormWorksheetOperationType, applicationId, () => aiGenerationPrerequisiteValidator.EnsureFormWorksheetAvailableAsync(applicationFormVersionId), - () => + operationId => { return backgroundJobManager.EnqueueAsync(new GenerateFormWorksheetBackgroundJobArgs { ApplicationId = applicationId, + OperationId = operationId, ApplicationFormVersionId = applicationFormVersionId, PromptVersion = promptVersion, RequestedByUserId = currentUser.Id, @@ -145,7 +150,7 @@ await EnsureRequestAndEnqueueAsync( AIGenerationRequestKeyHelper.FormScoresheetOperationType, applicationId, () => aiGenerationPrerequisiteValidator.EnsureFormScoresheetAvailableAsync(applicationFormVersionId), - () => + operationId => { var requestedByUserId = currentUser.Id ?? throw new UserFriendlyException("A logged-in user is required to generate a form scoresheet."); @@ -153,6 +158,7 @@ await EnsureRequestAndEnqueueAsync( return backgroundJobManager.EnqueueAsync(new GenerateFormScoresheetBackgroundJobArgs { ApplicationId = applicationId, + OperationId = operationId, ApplicationFormVersionId = applicationFormVersionId, PromptVersion = promptVersion, RequestedByUserId = requestedByUserId, @@ -225,7 +231,7 @@ private async Task EnsureRequestAndEnqueueAsync( string operationType, Guid applicationId, Func validateInput, - Func enqueue) + Func enqueue) { var operation = await ResolveOperationAsync(operationType); var requestLock = distributedLockProvider.CreateLock($"ai-generation:{tenantId}:{applicationId}:{operation.Id}"); @@ -265,7 +271,7 @@ private async Task EnsureRequestAndEnqueueAsync( try { - await enqueue(); + await enqueue(operation.Id); } catch (Exception ex) { diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/AIGenerationRequestJobHelper.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/AIGenerationRequestJobHelper.cs index ca487d41e7..1346753d66 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/AIGenerationRequestJobHelper.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/AIGenerationRequestJobHelper.cs @@ -56,18 +56,16 @@ public static async Task MarkFailedAsync( public static async Task MarkRunningInNewUowAsync( IUnitOfWorkManager unitOfWorkManager, IRepository generationRequestRepository, - IRepository operationRepository, Guid? tenantId, Guid applicationId, - string operationType) + Guid operationId) { using var uow = unitOfWorkManager.Begin(requiresNew: true, isTransactional: false); - var operation = await ResolveOperationAsync(operationRepository, operationType); var request = await GetLatestRequestAsync( generationRequestRepository, x => x.TenantId == tenantId && x.ApplicationId == applicationId - && x.OperationId == operation.Id); + && x.OperationId == operationId); await MarkRunningAsync(generationRequestRepository, request); await uow.CompleteAsync(); } @@ -75,18 +73,16 @@ public static async Task MarkRunningInNewUowAsync( public static async Task MarkCompletedInNewUowAsync( IUnitOfWorkManager unitOfWorkManager, IRepository generationRequestRepository, - IRepository operationRepository, Guid? tenantId, Guid applicationId, - string operationType) + Guid operationId) { using var uow = unitOfWorkManager.Begin(requiresNew: true, isTransactional: false); - var operation = await ResolveOperationAsync(operationRepository, operationType); var request = await GetLatestRequestAsync( generationRequestRepository, x => x.TenantId == tenantId && x.ApplicationId == applicationId - && x.OperationId == operation.Id); + && x.OperationId == operationId); await MarkCompletedAsync(generationRequestRepository, request); await uow.CompleteAsync(); } @@ -94,25 +90,23 @@ public static async Task MarkCompletedInNewUowAsync( public static async Task MarkFailedInNewUowAsync( IUnitOfWorkManager unitOfWorkManager, IRepository generationRequestRepository, - IRepository operationRepository, Guid? tenantId, Guid applicationId, - string operationType, + Guid operationId, string? failureReason) { using var uow = unitOfWorkManager.Begin(requiresNew: true, isTransactional: false); - var operation = await ResolveOperationAsync(operationRepository, operationType); var request = await GetLatestRequestAsync( generationRequestRepository, x => x.TenantId == tenantId && x.ApplicationId == applicationId - && x.OperationId == operation.Id); + && x.OperationId == operationId); await MarkFailedAsync(generationRequestRepository, request, failureReason); await uow.CompleteAsync(); } public static async Task StampCooldownBestEffortAsync( - IAICooldownAppService aiCooldownService, + ICooldownService aiCooldownService, ILogger logger, Guid? requestedByUserId, Guid applicationId, @@ -132,21 +126,6 @@ public static async Task StampCooldownBestEffortAsync( } } - private static async Task ResolveOperationAsync( - IRepository operationRepository, - string operationType) - { - var operationName = AIGenerationRequestKeyHelper.ResolveOperationName(operationType); - if (operationName == null) - { - throw new ArgumentException($"Unknown AI operation type '{operationType}'.", nameof(operationType)); - } - - var operation = await operationRepository.FirstOrDefaultAsync(item => item.Name == operationName); - - return operation ?? throw new InvalidOperationException($"AI operation '{operationType}' is not configured."); - } - public static async Task GetLatestRequestAsync( IRepository generationRequestRepository, Expression> predicate) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateApplicationAnalysisJob.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateApplicationAnalysisJob.cs index 5e77bd7276..c4ca80cbb8 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateApplicationAnalysisJob.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateApplicationAnalysisJob.cs @@ -2,8 +2,8 @@ using System; using System.Threading.Tasks; using Unity.AI.Domain; -using Unity.AI.Operations; using Unity.AI.Cooldown; +using Unity.AI.Operations; using Unity.GrantManager.Applications; using Unity.GrantManager.GrantApplications; using Volo.Abp.BackgroundJobs; @@ -18,10 +18,9 @@ public class GenerateApplicationAnalysisJob( ApplicationAnalysisService applicationAnalysisService, IApplicationRepository applicationRepository, IRepository generationRequestRepository, - IRepository operationRepository, ICurrentTenant currentTenant, IUnitOfWorkManager unitOfWorkManager, - IAICooldownAppService aiCooldownService, + ICooldownService aiCooldownService, ILogger logger) : AsyncBackgroundJob, ITransientDependency { public override async Task ExecuteAsync(GenerateApplicationAnalysisBackgroundJobArgs args) @@ -39,10 +38,9 @@ public override async Task ExecuteAsync(GenerateApplicationAnalysisBackgroundJob await AIGenerationRequestJobHelper.MarkRunningInNewUowAsync( unitOfWorkManager, generationRequestRepository, - operationRepository, args.TenantId, args.ApplicationId, - AIGenerationRequestKeyHelper.ApplicationAnalysisOperationType); + args.OperationId); try { var application = await applicationRepository.GetAsync(args.ApplicationId); @@ -53,20 +51,18 @@ await AIGenerationRequestJobHelper.MarkRunningInNewUowAsync( await AIGenerationRequestJobHelper.MarkCompletedInNewUowAsync( unitOfWorkManager, generationRequestRepository, - operationRepository, args.TenantId, args.ApplicationId, - AIGenerationRequestKeyHelper.ApplicationAnalysisOperationType); + args.OperationId); } catch (Exception ex) { await AIGenerationRequestJobHelper.MarkFailedInNewUowAsync( unitOfWorkManager, generationRequestRepository, - operationRepository, args.TenantId, args.ApplicationId, - AIGenerationRequestKeyHelper.ApplicationAnalysisOperationType, + args.OperationId, ex.Message); throw; } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateApplicationScoringJob.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateApplicationScoringJob.cs index c2d7cd2540..e4d68a3d9b 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateApplicationScoringJob.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateApplicationScoringJob.cs @@ -3,8 +3,8 @@ using System.Threading.Tasks; using Unity.AI.Domain; using Unity.AI.Execution; -using Unity.AI.Operations; using Unity.AI.Cooldown; +using Unity.AI.Operations; using Unity.GrantManager.Applications; using Unity.GrantManager.GrantApplications.Automation.Events; using Volo.Abp.BackgroundJobs; @@ -20,11 +20,10 @@ public class GenerateApplicationScoringJob( ApplicationScoringService applicationScoringService, IApplicationRepository applicationRepository, IRepository generationRequestRepository, - IRepository operationRepository, ICurrentTenant currentTenant, IUnitOfWorkManager unitOfWorkManager, ILocalEventBus localEventBus, - IAICooldownAppService aiCooldownService, + ICooldownService aiCooldownService, ILogger logger) : AsyncBackgroundJob, ITransientDependency { public override async Task ExecuteAsync(GenerateApplicationScoringBackgroundJobArgs args) @@ -42,17 +41,16 @@ public override async Task ExecuteAsync(GenerateApplicationScoringBackgroundJobA await AIGenerationRequestJobHelper.MarkRunningInNewUowAsync( unitOfWorkManager, generationRequestRepository, - operationRepository, args.TenantId, args.ApplicationId, - AIGenerationRequestKeyHelper.ApplicationScoringOperationType); + args.OperationId); try { var application = await applicationRepository.GetAsync(args.ApplicationId); var operation = await operationRepository.FirstOrDefaultAsync(item => item.Name == AIGenerationRequestKeyHelper.ResolveOperationName(AIGenerationRequestKeyHelper.ApplicationScoringOperationType)); var scoresheetAnswers = await applicationScoringService.GenerateApplicationScoringAsync( application.Id, - operation?.ExecutionMode ?? AIExecutionMode.Sequential, + ExecutionMode.Sequential, args.PromptVersion); application.AIScoresheetAnswers = scoresheetAnswers; await applicationRepository.UpdateAsync(application); @@ -64,20 +62,18 @@ await localEventBus.PublishAsync(new ApplicationAIScoringGeneratedEvent await AIGenerationRequestJobHelper.MarkCompletedInNewUowAsync( unitOfWorkManager, generationRequestRepository, - operationRepository, args.TenantId, args.ApplicationId, - AIGenerationRequestKeyHelper.ApplicationScoringOperationType); + args.OperationId); } catch (Exception ex) { await AIGenerationRequestJobHelper.MarkFailedInNewUowAsync( unitOfWorkManager, generationRequestRepository, - operationRepository, args.TenantId, args.ApplicationId, - AIGenerationRequestKeyHelper.ApplicationScoringOperationType, + args.OperationId, ex.Message); throw; } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateAttachmentSummaryJob.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateAttachmentSummaryJob.cs index 2ebab73bef..53be14628a 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateAttachmentSummaryJob.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateAttachmentSummaryJob.cs @@ -2,8 +2,9 @@ using System; using System.Threading.Tasks; using Unity.AI.Domain; +using Unity.AI.Execution; +using Unity.AI.Cooldown; using Unity.AI.Operations; -using Unity.AI.RateLimit; using Unity.GrantManager.GrantApplications; using Volo.Abp.BackgroundJobs; using Volo.Abp.DependencyInjection; @@ -16,11 +17,10 @@ namespace Unity.GrantManager.GrantApplications.Automation.BackgroundJobs; public class GenerateAttachmentSummaryJob( IAttachmentSummaryService attachmentSummaryService, IRepository generationRequestRepository, - IRepository operationRepository, ICurrentTenant currentTenant, IUnitOfWorkManager unitOfWorkManager, - IAIRateLimiter aiRateLimiter, - ILogger logger) : AsyncBackgroundJob, ITransientDependency + ICooldownService aiCooldownService, + ILogger logger) : AsyncBackgroundJob, ITransientDependency { public override async Task ExecuteAsync(GenerateAttachmentSummaryBackgroundJobArgs args) { @@ -37,10 +37,9 @@ public override async Task ExecuteAsync(GenerateAttachmentSummaryBackgroundJobAr await AIGenerationRequestJobHelper.MarkRunningInNewUowAsync( unitOfWorkManager, generationRequestRepository, - operationRepository, args.TenantId, args.ApplicationId, - AIGenerationRequestKeyHelper.AttachmentSummaryOperationType); + args.OperationId); try { await attachmentSummaryService.GenerateForApplicationAsync(args.ApplicationId, args.PromptVersion, args.AttachmentIds); @@ -49,20 +48,18 @@ await AIGenerationRequestJobHelper.MarkRunningInNewUowAsync( await AIGenerationRequestJobHelper.MarkCompletedInNewUowAsync( unitOfWorkManager, generationRequestRepository, - operationRepository, args.TenantId, args.ApplicationId, - AIGenerationRequestKeyHelper.AttachmentSummaryOperationType); + args.OperationId); } catch (Exception ex) { await AIGenerationRequestJobHelper.MarkFailedInNewUowAsync( unitOfWorkManager, generationRequestRepository, - operationRepository, args.TenantId, args.ApplicationId, - AIGenerationRequestKeyHelper.AttachmentSummaryOperationType, + args.OperationId, ex.Message); throw; } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateFormMappingJob.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateFormMappingJob.cs index d8f04f1a39..0b803403c9 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateFormMappingJob.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateFormMappingJob.cs @@ -4,11 +4,11 @@ using System.Text.Json; using System.Threading.Tasks; using Unity.AI.Domain; +using Unity.AI.Cooldown; using Unity.AI.Operations; using Unity.AI.Requests; using Unity.AI.Responses; using Unity.GrantManager.ApplicationForms; -using Unity.AI.Cooldown; using Unity.GrantManager.ApplicationForms.Mapping; using Unity.GrantManager.Applications; using Volo.Abp.Domain.Repositories; @@ -24,10 +24,9 @@ public class GenerateFormMappingJob( IFormMappingService aiService, IRepository applicationFormVersionRepository, IRepository generationRequestRepository, - IRepository operationRepository, ICurrentTenant currentTenant, IUnitOfWorkManager unitOfWorkManager, - IAICooldownAppService aiCooldownService, + ICooldownService aiCooldownService, ILogger logger) : AsyncBackgroundJob, ITransientDependency { public override async Task ExecuteAsync(GenerateFormMappingBackgroundJobArgs args) @@ -45,10 +44,9 @@ public override async Task ExecuteAsync(GenerateFormMappingBackgroundJobArgs arg await AIGenerationRequestJobHelper.MarkRunningInNewUowAsync( unitOfWorkManager, generationRequestRepository, - operationRepository, args.TenantId, args.ApplicationId, - AIGenerationRequestKeyHelper.FormMappingOperationType); + args.OperationId); try { var readModel = await mappingReadService.GetAsync(args.ApplicationFormVersionId); @@ -67,20 +65,18 @@ await AIGenerationRequestJobHelper.MarkRunningInNewUowAsync( await AIGenerationRequestJobHelper.MarkCompletedInNewUowAsync( unitOfWorkManager, generationRequestRepository, - operationRepository, args.TenantId, args.ApplicationId, - AIGenerationRequestKeyHelper.FormMappingOperationType); + args.OperationId); } catch (System.Exception ex) { await AIGenerationRequestJobHelper.MarkFailedInNewUowAsync( unitOfWorkManager, generationRequestRepository, - operationRepository, args.TenantId, args.ApplicationId, - AIGenerationRequestKeyHelper.FormMappingOperationType, + args.OperationId, ex.Message); throw; } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateFormScoresheetBackgroundJobArgs.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateFormScoresheetBackgroundJobArgs.cs index 704e15726a..02149419e3 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateFormScoresheetBackgroundJobArgs.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateFormScoresheetBackgroundJobArgs.cs @@ -6,6 +6,7 @@ public class GenerateFormScoresheetBackgroundJobArgs { public Guid ApplicationId { get; set; } public Guid ApplicationFormVersionId { get; set; } + public Guid OperationId { get; set; } public Guid? TenantId { get; set; } public Guid RequestedByUserId { get; set; } public string? PromptVersion { get; set; } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateFormScoresheetJob.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateFormScoresheetJob.cs index 1fd3f48ca2..d898ae3cc8 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateFormScoresheetJob.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateFormScoresheetJob.cs @@ -4,11 +4,11 @@ using System.Text.Json; using System.Threading.Tasks; using Unity.AI.Domain; +using Unity.AI.Cooldown; using Unity.AI.Operations; using Unity.AI.Requests; using Unity.GrantManager.ApplicationForms; using Unity.GrantManager.Applications; -using Unity.AI.Cooldown; using Unity.Flex.Domain.Scoresheets; using Unity.Flex.Scoresheets; using Volo.Abp.BackgroundJobs; @@ -25,10 +25,9 @@ public class GenerateFormScoresheetJob( IScoresheetRepository scoresheetRepository, IFormScoresheetService aiService, IRepository generationRequestRepository, - IRepository operationRepository, ICurrentTenant currentTenant, IUnitOfWorkManager unitOfWorkManager, - IAICooldownAppService aiCooldownService, + ICooldownService aiCooldownService, ILogger logger) : AsyncBackgroundJob, ITransientDependency { private static readonly JsonSerializerOptions CaseInsensitiveJsonOptions = new() @@ -51,10 +50,9 @@ public override async Task ExecuteAsync(GenerateFormScoresheetBackgroundJobArgs await AIGenerationRequestJobHelper.MarkRunningInNewUowAsync( unitOfWorkManager, generationRequestRepository, - operationRepository, args.TenantId, args.ApplicationId, - AIGenerationRequestKeyHelper.FormScoresheetOperationType); + args.OperationId); try { @@ -132,20 +130,18 @@ await AIGenerationRequestJobHelper.MarkRunningInNewUowAsync( await AIGenerationRequestJobHelper.MarkCompletedInNewUowAsync( unitOfWorkManager, generationRequestRepository, - operationRepository, args.TenantId, args.ApplicationId, - AIGenerationRequestKeyHelper.FormScoresheetOperationType); + args.OperationId); } catch (Exception ex) { await AIGenerationRequestJobHelper.MarkFailedInNewUowAsync( unitOfWorkManager, generationRequestRepository, - operationRepository, args.TenantId, args.ApplicationId, - AIGenerationRequestKeyHelper.FormScoresheetOperationType, + args.OperationId, ex.Message); throw; } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateFormWorksheetJob.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateFormWorksheetJob.cs index f0d35c049b..bbb030db84 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateFormWorksheetJob.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateFormWorksheetJob.cs @@ -5,6 +5,7 @@ using System.Text.Json; using System.Threading.Tasks; using Unity.AI.Domain; +using Unity.AI.Cooldown; using Unity.AI.Operations; using Unity.AI.Requests; using Unity.GrantManager.ApplicationForms; @@ -14,7 +15,6 @@ using Unity.Flex.Domain.Worksheets; using Unity.Flex.Worksheets; using Unity.Modules.Shared.Correlation; -using Unity.AI.Cooldown; using Volo.Abp.BackgroundJobs; using Volo.Abp.DependencyInjection; using Volo.Abp.Domain.Repositories; @@ -30,10 +30,9 @@ public class GenerateFormWorksheetJob( IWorksheetLinkRepository worksheetLinkRepository, IFormWorksheetService aiService, IRepository generationRequestRepository, - IRepository operationRepository, ICurrentTenant currentTenant, IUnitOfWorkManager unitOfWorkManager, - IAICooldownAppService aiCooldownService, + ICooldownService aiCooldownService, ILogger logger) : AsyncBackgroundJob, ITransientDependency { private static readonly JsonSerializerOptions CaseInsensitiveJsonOptions = new() @@ -56,10 +55,9 @@ public override async Task ExecuteAsync(GenerateFormWorksheetBackgroundJobArgs a await AIGenerationRequestJobHelper.MarkRunningInNewUowAsync( unitOfWorkManager, generationRequestRepository, - operationRepository, args.TenantId, args.ApplicationId, - AIGenerationRequestKeyHelper.FormWorksheetOperationType); + args.OperationId); try { var formVersion = await applicationFormVersionRepository.GetAsync(args.ApplicationFormVersionId); @@ -132,20 +130,18 @@ await AIGenerationRequestJobHelper.MarkRunningInNewUowAsync( await AIGenerationRequestJobHelper.MarkCompletedInNewUowAsync( unitOfWorkManager, generationRequestRepository, - operationRepository, args.TenantId, args.ApplicationId, - AIGenerationRequestKeyHelper.FormWorksheetOperationType); + args.OperationId); } catch (Exception ex) { await AIGenerationRequestJobHelper.MarkFailedInNewUowAsync( unitOfWorkManager, generationRequestRepository, - operationRepository, args.TenantId, args.ApplicationId, - AIGenerationRequestKeyHelper.FormWorksheetOperationType, + args.OperationId, ex.Message); throw; } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationForms/Mapping.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationForms/Mapping.cshtml index f3e9d9ed50..1321b1490f 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationForms/Mapping.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationForms/Mapping.cshtml @@ -104,7 +104,7 @@ ? batchItems = null; - - var results = await AIExecutionStrategy.RunAsync( - [1, 2, 3], - AIExecutionMode.Batch, - item => - { - itemCalls++; - return Task.FromResult(item); - }, - items => - { - batchCalls++; - batchItems = items; - return Task.FromResult(new List { 6 }); - }); - - itemCalls.ShouldBe(0); - batchCalls.ShouldBe(1); - batchItems.ShouldNotBeNull(); - batchItems.Count.ShouldBe(3); - results.ShouldBe([6]); - } -} diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/Operations/ApplicationScoringServiceTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/Operations/ApplicationScoringServiceTests.cs index 9df1eef225..e5576f14ba 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/Operations/ApplicationScoringServiceTests.cs +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/Operations/ApplicationScoringServiceTests.cs @@ -97,7 +97,7 @@ public async Task RegenerateAsync_Sequential_Mode_Uses_Per_Section_Requests() }) }); - var operationRepository = CreateOperationRepository(AIExecutionMode.Sequential); + var operationRepository = CreateOperationRepository(ExecutionMode.Sequential); var service = CreateService(aiService, operationRepository, dataProvider); var applicationId = Guid.NewGuid(); @@ -184,7 +184,7 @@ public async Task RegenerateAsync_Batch_Mode_Uses_Aggregated_Section_Schema() FormSchema = JsonSerializer.Serialize(new { components = Array.Empty() }) }); - var operationRepository = CreateOperationRepository(AIExecutionMode.Batch); + var operationRepository = CreateOperationRepository(ExecutionMode.Batch); var service = CreateService(aiService, operationRepository, dataProvider); var result = await service.GenerateApplicationScoringAsync(Guid.NewGuid()); @@ -205,7 +205,7 @@ public async Task RegenerateAsync_Batch_Mode_Rejects_Missing_Scoresheet() { ScoresheetId = null }); - var operationRepository = CreateOperationRepository(AIExecutionMode.Batch); + var operationRepository = CreateOperationRepository(ExecutionMode.Batch); var service = CreateService(aiService, operationRepository, dataProvider); await Should.ThrowAsync(() => service.GenerateApplicationScoringAsync(Guid.NewGuid())); @@ -223,7 +223,7 @@ private static ApplicationScoringService CreateService( NullLogger.Instance); } - private static IRepository CreateOperationRepository(AIExecutionMode executionMode) + private static IRepository CreateOperationRepository(ExecutionMode executionMode) { var operationRepository = Substitute.For>(); operationRepository.GetListAsync(Arg.Any>>(), Arg.Any(), Arg.Any()) diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/Operations/AttachmentSummaryServiceTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/Operations/AttachmentSummaryServiceTests.cs index e3f9439e7c..2060fcd36a 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/Operations/AttachmentSummaryServiceTests.cs +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/Operations/AttachmentSummaryServiceTests.cs @@ -262,7 +262,26 @@ private static AttachmentSummaryService CreateService( Substitute.For>()); } - private static IAttachmentSummaryDataProvider CreatePersistence( + private static IRepository CreateOperationRepository() + { + var operationRepository = Substitute.For>(); + operationRepository.GetListAsync(Arg.Any>>(), Arg.Any(), Arg.Any()) + .Returns(callInfo => + { + var filter = callInfo.ArgAt>>(0).Compile(); + var operation = new AIOperation(Guid.NewGuid(), AIPromptTypes.ApplicationAttachmentSummary, Guid.NewGuid(), Guid.NewGuid()) + { + ExecutionMode = ExecutionMode.Sequential, + IsActive = true + }; + + return Task.FromResult(filter(operation) ? new List { operation } : new List()); + }); + + return operationRepository; + } + + private static IApplicationAttachmentSummaryPersistence CreatePersistence( Guid attachmentId, string fileName, Guid submissionId, diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/Runtime/AIProviderPayloadValidatorTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/Runtime/AIProviderPayloadValidatorTests.cs index c293e61011..0b1e98b084 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/Runtime/AIProviderPayloadValidatorTests.cs +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/Runtime/AIProviderPayloadValidatorTests.cs @@ -5,12 +5,12 @@ namespace Unity.GrantManager.AI.Runtime; -public class AIProviderPayloadValidatorTests +public class PromptResponseValidatorTests { [Fact] public void ValidateApplicationAnalysisJson_Should_Return_InvalidOutput_For_InvalidJson() { - var result = AIProviderPayloadValidator.ValidateApplicationAnalysisJson("not-json"); + var result = PromptResponseValidator.ValidateApplicationAnalysisJson("not-json"); result.IsValid.ShouldBeFalse(); result.FailureCategory.ShouldBe(AIFailureCategory.InvalidOutput); @@ -22,7 +22,7 @@ public void ValidateApplicationAnalysisJson_Should_Return_InvalidOutput_For_Inva [Fact] public void ValidateApplicationAnalysisJson_Should_Return_InvalidOutput_When_Decision_Is_Missing() { - var result = AIProviderPayloadValidator.ValidateApplicationAnalysisJson( + var result = PromptResponseValidator.ValidateApplicationAnalysisJson( """ { "errors": [], @@ -47,7 +47,7 @@ public void ValidateApplicationScoringJson_Should_Return_InvalidOutput_When_Answ new { id = "q1" } }); - var result = AIProviderPayloadValidator.ValidateApplicationScoringJson("{}", sectionJson); + var result = PromptResponseValidator.ValidateApplicationScoringJson("{}", sectionJson); result.IsValid.ShouldBeFalse(); result.FailureCategory.ShouldBe(AIFailureCategory.InvalidOutput); @@ -62,7 +62,7 @@ public void ValidateApplicationScoringJson_Should_Return_Success_For_Decimal_Con new { id = "q1" } }); - var result = AIProviderPayloadValidator.ValidateApplicationScoringJson( + var result = PromptResponseValidator.ValidateApplicationScoringJson( """ { "q1": { @@ -80,7 +80,7 @@ public void ValidateApplicationScoringJson_Should_Return_Success_For_Decimal_Con [Fact] public void ValidateApplicationAnalysisJson_Should_Return_InvalidOutput_When_Decision_Is_Not_Proceed_Or_Hold() { - var result = AIProviderPayloadValidator.ValidateApplicationAnalysisJson( + var result = PromptResponseValidator.ValidateApplicationAnalysisJson( """ { "decision": "unknown", @@ -101,7 +101,7 @@ public void ValidateApplicationAnalysisJson_Should_Return_InvalidOutput_When_Dec [Fact] public void ValidateApplicationAnalysisJson_Should_Return_InvalidOutput_When_All_Findings_Are_Empty() { - var result = AIProviderPayloadValidator.ValidateApplicationAnalysisJson( + var result = PromptResponseValidator.ValidateApplicationAnalysisJson( """ { "decision": "PROCEED", @@ -122,7 +122,7 @@ public void ValidateApplicationAnalysisJson_Should_Return_InvalidOutput_When_All [Fact] public void ValidateApplicationAnalysisJson_Should_Return_InvalidOutput_When_Recommendations_Are_Empty() { - var result = AIProviderPayloadValidator.ValidateApplicationAnalysisJson( + var result = PromptResponseValidator.ValidateApplicationAnalysisJson( """ { "decision": "PROCEED", @@ -145,7 +145,7 @@ public void ValidateApplicationAnalysisJson_Should_Return_InvalidOutput_When_Rec [Fact] public void ValidateApplicationAnalysisJson_Should_Return_Success_For_Proceed_With_Findings() { - var result = AIProviderPayloadValidator.ValidateApplicationAnalysisJson( + var result = PromptResponseValidator.ValidateApplicationAnalysisJson( """ { "decision": "PROCEED", @@ -166,7 +166,7 @@ public void ValidateApplicationAnalysisJson_Should_Return_Success_For_Proceed_Wi [Fact] public void ValidateAttachmentSummaryText_Should_Return_InvalidOutput_For_Empty_Text() { - var result = AIProviderPayloadValidator.ValidateAttachmentSummaryText(string.Empty); + var result = PromptResponseValidator.ValidateAttachmentSummaryText(string.Empty); result.IsValid.ShouldBeFalse(); result.FailureCategory.ShouldBe(AIFailureCategory.InvalidOutput); diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/Runtime/OpenAIConfigurationResolverTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/Runtime/OpenAIConfigurationResolverTests.cs index b4b01e4f33..e44f1436e1 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/Runtime/OpenAIConfigurationResolverTests.cs +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/Runtime/OpenAIConfigurationResolverTests.cs @@ -62,7 +62,7 @@ public async Task Should_Resolve_Operation_Strictly_From_Database() { new(Guid.NewGuid(), AIPromptTypes.ApplicationAnalysis, modelId, promptId) { - ExecutionMode = AIExecutionMode.Sequential, + ExecutionMode = ExecutionMode.Sequential, CompletionTokens = 2222, IsActive = true } @@ -128,7 +128,7 @@ public async Task Should_Throw_When_Operation_Is_Missing() { new(Guid.NewGuid(), "Default", modelId, promptId) { - ExecutionMode = AIExecutionMode.Sequential, + ExecutionMode = ExecutionMode.Sequential, CompletionTokens = 2000, IsActive = true } @@ -320,6 +320,7 @@ private static OpenAIConfigurationResolver CreateResolver( modelRepository ?? CreateEmptyModelRepository(), operationRepository ?? CreateEmptyOperationRepository(), promptRepository ?? CreateEmptyPromptRepository(), + Substitute.For(), configuration, filter); } diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/Runtime/OpenAIRuntimeServiceTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/Runtime/OpenAIRuntimeServiceTests.cs index 758b057166..14bacd6321 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/Runtime/OpenAIRuntimeServiceTests.cs +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/Runtime/OpenAIRuntimeServiceTests.cs @@ -49,6 +49,7 @@ private static OpenAIConfigurationResolver CreateResolverWithNoOperations() modelRepository, operationRepository, promptRepository, + Substitute.For(), configuration, multiTenantDataFilter); } diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantApplications/Automation/AIGenerationAppServiceTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantApplications/Automation/AIGenerationAppServiceTests.cs index 8abcd8f10d..97856e9da8 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantApplications/Automation/AIGenerationAppServiceTests.cs +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantApplications/Automation/AIGenerationAppServiceTests.cs @@ -35,14 +35,19 @@ public async Task GenerateAttachmentSummariesAsync_Should_Validate_Against_Appli var applicationId = Guid.NewGuid(); var attachmentIds = new List { Guid.NewGuid(), Guid.NewGuid() }; - var service = new AIGenerationAppService( + var service = new GenerationAppService( Substitute.For(), Substitute.For(), featureGuard, Substitute.For()); service.LazyServiceProvider = GetRequiredService(); - await service.GenerateApplicationAttachmentSummariesAsync(applicationId, attachmentIds, "v1"); + await service.GenerateApplicationAttachmentSummariesAsync(new ApplicationAttachmentSummaryRequestDto + { + ApplicationId = applicationId, + AttachmentIds = attachmentIds, + PromptVersion = "v1" + }); await Task.CompletedTask; } @@ -72,7 +77,7 @@ public async Task GetStatusAsync_Should_Map_Request_And_Rate_Limit_State() var currentTenant = Substitute.For(); currentTenant.Id.Returns(tenantId); - var service = new AIGenerationAppService( + var service = new GenerationAppService( Substitute.For(), statusService, CreateFeatureGuard(), @@ -94,7 +99,7 @@ public async Task GetStatusAsync_Should_Map_Request_And_Rate_Limit_State() [Fact] public async Task GetStatusAsync_Should_Reject_Unsupported_Operation_Type() { - var service = new AIGenerationAppService( + var service = new GenerationAppService( Substitute.For(), Substitute.For(), CreateFeatureGuard(), diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantApplications/Automation/AIGenerationQueueTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantApplications/Automation/AIGenerationQueueTests.cs index 6db2e3aa88..3dc5be0af9 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantApplications/Automation/AIGenerationQueueTests.cs +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantApplications/Automation/AIGenerationQueueTests.cs @@ -119,8 +119,8 @@ public async Task QueueApplicationIntakeAsync_Should_Not_Enqueue_When_No_Enabled var applicationId = Guid.NewGuid(); var tenantId = Guid.NewGuid(); var backgroundJobManager = Substitute.For(); - var cooldownService = Substitute.For(); - cooldownService.EnsureAsync().Returns(Task.CompletedTask); + var cooldownService = Substitute.For(); + cooldownService.EnsureAsync(Arg.Any()).Returns(Task.CompletedTask); var prerequisiteValidator = Substitute.For(); prerequisiteValidator.EnsureAttachmentSummaryAvailableAsync(applicationId) .Returns(_ => throw new UserFriendlyException("No attachments are available to summarize.")); @@ -133,7 +133,7 @@ public async Task QueueApplicationIntakeAsync_Should_Not_Enqueue_When_No_Enabled await Should.ThrowAsync(() => queue.QueueApplicationIntakeAsync(applicationId, tenantId)); - await cooldownService.DidNotReceive().EnsureAsync(); + await cooldownService.DidNotReceive().EnsureAsync(Arg.Any()); await backgroundJobManager.DidNotReceive().EnqueueAsync(Arg.Any(), Arg.Any(), Arg.Any()); await backgroundJobManager.DidNotReceive().EnqueueAsync(Arg.Any(), Arg.Any(), Arg.Any()); await backgroundJobManager.DidNotReceive().EnqueueAsync(Arg.Any(), Arg.Any(), Arg.Any()); @@ -185,13 +185,13 @@ public async Task QueueApplicationAnalysisAsync_Should_Check_Rate_Limit_Before_E .Returns(callInfo => Task.FromResult(callInfo.Arg())); var backgroundJobManager = Substitute.For(); - var cooldownService = Substitute.For(); - cooldownService.EnsureAsync().Returns(Task.CompletedTask); + var cooldownService = Substitute.For(); + cooldownService.EnsureAsync(Arg.Any()).Returns(Task.CompletedTask); var queue = CreateQueue(backgroundJobManager, repository, cooldownService: cooldownService); await queue.QueueApplicationAnalysisAsync(applicationId, tenantId); - await cooldownService.Received(1).EnsureAsync(); + await cooldownService.Received(1).EnsureAsync(Arg.Any()); await repository.Received(1).InsertAsync(Arg.Any(), Arg.Any(), Arg.Any()); await backgroundJobManager.Received(1).EnqueueAsync(Arg.Any(), Arg.Any(), Arg.Any()); } @@ -205,13 +205,13 @@ public async Task QueueApplicationAnalysisAsync_Should_Not_Insert_Or_Enqueue_Whe repository.GetQueryableAsync().Returns(Task.FromResult>(Array.Empty().AsQueryable())); var backgroundJobManager = Substitute.For(); - var cooldownService = Substitute.For(); - cooldownService.EnsureAsync().Returns(_ => throw new InvalidOperationException("rate limited")); + var cooldownService = Substitute.For(); + cooldownService.EnsureAsync(Arg.Any()).Returns(_ => throw new InvalidOperationException("rate limited")); var queue = CreateQueue(backgroundJobManager, repository, cooldownService: cooldownService); await Should.ThrowAsync(() => queue.QueueApplicationAnalysisAsync(applicationId, tenantId)); - await cooldownService.Received(1).EnsureAsync(); + await cooldownService.Received(1).EnsureAsync(Arg.Any()); await repository.DidNotReceive().InsertAsync(Arg.Any(), Arg.Any(), Arg.Any()); await backgroundJobManager.DidNotReceive().EnqueueAsync(Arg.Any(), Arg.Any(), Arg.Any()); } @@ -225,8 +225,8 @@ public async Task QueueApplicationAttachmentSummaryAsync_Should_Not_Insert_Or_En repository.GetQueryableAsync().Returns(Task.FromResult>(Array.Empty().AsQueryable())); var backgroundJobManager = Substitute.For(); - var cooldownService = Substitute.For(); - cooldownService.EnsureAsync().Returns(Task.CompletedTask); + var cooldownService = Substitute.For(); + cooldownService.EnsureAsync(Arg.Any()).Returns(Task.CompletedTask); var prerequisiteValidator = Substitute.For(); prerequisiteValidator.EnsureAttachmentSummaryAvailableAsync(applicationId) .Returns(_ => throw new UserFriendlyException("No attachments are available to summarize.")); @@ -234,7 +234,7 @@ public async Task QueueApplicationAttachmentSummaryAsync_Should_Not_Insert_Or_En await Should.ThrowAsync(() => queue.QueueApplicationAttachmentSummaryAsync(applicationId, tenantId, [])); - await cooldownService.DidNotReceive().EnsureAsync(); + await cooldownService.DidNotReceive().EnsureAsync(Arg.Any()); await repository.DidNotReceive().InsertAsync(Arg.Any(), Arg.Any(), Arg.Any()); await backgroundJobManager.DidNotReceive().EnqueueAsync(Arg.Any(), Arg.Any(), Arg.Any()); } @@ -395,7 +395,7 @@ public ValueTask DisposeAsync() private static ApplicationGenerationQueue CreateQueue( IBackgroundJobManager backgroundJobManager, IRepository? repository = null, - IAICooldownAppService? cooldownService = null, + ICooldownService? cooldownService = null, IGenerationPrerequisiteValidator? prerequisiteValidator = null, IFeatureChecker? featureChecker = null, IRepository? operationRepository = null, @@ -406,8 +406,8 @@ private static ApplicationGenerationQueue CreateQueue( if (cooldownService == null) { - cooldownService = Substitute.For(); - cooldownService.EnsureAsync().Returns(Task.CompletedTask); + cooldownService = Substitute.For(); + cooldownService.EnsureAsync(Arg.Any()).Returns(Task.CompletedTask); } if (prerequisiteValidator == null) @@ -444,8 +444,7 @@ private static ApplicationGenerationQueue CreateQueue( cooldownService, asyncQueryableExecuter, CreateCurrentUser(), - Substitute.For>(), - Substitute.For>()); + Substitute.For>()); } private static readonly Guid CreateQueueCurrentUserId = Guid.NewGuid(); From 011d4da629045982bb771943ae864fb4d7412337 Mon Sep 17 00:00:00 2001 From: Jacob Smith Date: Sat, 11 Jul 2026 15:53:52 -0700 Subject: [PATCH 064/223] AB#33569 tighten AI generate button states --- .../Pages/ApplicationForms/Mapping.cshtml | 2 +- .../Pages/GrantApplications/Details.cshtml | 4 ++-- .../Pages/GrantApplications/Details.css | 8 -------- .../Pages/GrantApplications/ai-generation-api.js | 10 ++++++++-- .../GrantApplications/ai-generation-button-state.js | 4 ++-- .../Pages/GrantApplications/ai-rate-limit.js | 6 +++--- .../Components/ChefsAttachments/ChefsAttachments.js | 13 ++----------- .../Shared/Components/CustomFields/Default.cshtml | 7 ++----- 8 files changed, 20 insertions(+), 34 deletions(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationForms/Mapping.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationForms/Mapping.cshtml index 1321b1490f..e97ae78f30 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationForms/Mapping.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationForms/Mapping.cshtml @@ -110,7 +110,7 @@ icon="fl fl-edit" class="btn unt-btn-primary btn-primary" data-toggle="modal" style="pointer-events: all;" abp-tooltip="Edit the Mapping JSON Manually" button-type="Primary" /> - @if (await PermissionChecker.IsGrantedAsync(AIPermissions.Analysis.GenerateFormMapping)) + @if (await PermissionChecker.IsGrantedAsync(AIPermissions.FormMapping.Generate)) { } - @if (await PermissionChecker.IsGrantedAsync(AIPermissions.Analysis.GenerateFormWorksheet)) + @if (await PermissionChecker.IsGrantedAsync(AIPermissions.FormWorksheet.Generate)) {