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 580d7dc09a..adaf1696a0 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 @@ -14,6 +14,4 @@ public interface IAIGenerationAppService : IApplicationService Task GenerateApplicationAnalysisAsync(Guid applicationId, string? promptVersion = null); Task GenerateApplicationScoringAsync(Guid applicationId, string? promptVersion = null); - - Task GenerateContentAsync(Guid applicationId, string? promptVersion = null); } 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 e555345b1c..3e4a2a7b9d 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 @@ -10,61 +10,59 @@ public class AIPermissionDefinitionProvider : PermissionDefinitionProvider { public override void Define(IPermissionDefinitionContext context) { - // AI Permission Group - var aiPermissionsGroup = context.AddGroup( - AIPermissions.GroupName, - L("Permission:AI")); + // AI Permission Group + var aiPermissionsGroup = context.AddGroup( + AIPermissions.GroupName, + L("Permission:AI")); + var aiReporting = aiPermissionsGroup.AddPermission( + AIPermissions.Reporting.ReportingDefault, + L("Permission:AI.Reporting")) + .RequireFeatures("Unity.AIReporting"); - var aiReporting = aiPermissionsGroup.AddPermission( - AIPermissions.Reporting.ReportingDefault, - L("Permission:AI.Reporting")) - .RequireFeatures("Unity.AIReporting"); + aiReporting.AddChild( + AIPermissions.Reporting.CreateEditDataModel, + L("Permission:AI.Reporting.CreateEditDataModel")) + .RequireFeatures("Unity.AIReporting"); - aiReporting.AddChild( - AIPermissions.Reporting.CreateEditDataModel, - L("Permission:AI.Reporting.CreateEditDataModel")) - .RequireFeatures("Unity.AIReporting"); + var viewApplicationAnalysis = aiPermissionsGroup.AddPermission( + AIPermissions.Analysis.ViewApplicationAnalysis, + L("Permission:AI.ViewApplicationAnalysis")) + .RequireFeatures("Unity.AI.ApplicationAnalysis"); - var viewApplicationAnalysis = aiPermissionsGroup.AddPermission( - AIPermissions.Analysis.ViewApplicationAnalysis, - L("Permission:AI.ViewApplicationAnalysis")) - .RequireFeatures("Unity.AI.ApplicationAnalysis"); + viewApplicationAnalysis.AddChild( + AIPermissions.Analysis.GenerateApplicationAnalysis, + L("Permission:AI.GenerateApplicationAnalysis")) + .RequireFeatures("Unity.AI.ApplicationAnalysis"); - viewApplicationAnalysis.AddChild( - AIPermissions.Analysis.GenerateApplicationAnalysis, - L("Permission:AI.GenerateApplicationAnalysis")) - .RequireFeatures("Unity.AI.ApplicationAnalysis"); + var viewAttachmentSummary = aiPermissionsGroup.AddPermission( + AIPermissions.Analysis.ViewAttachmentSummary, + L("Permission:AI.ViewAttachmentSummary")) + .RequireFeatures("Unity.AI.AttachmentSummaries"); - var viewAttachmentSummary = aiPermissionsGroup.AddPermission( - AIPermissions.Analysis.ViewAttachmentSummary, - L("Permission:AI.ViewAttachmentSummary")) - .RequireFeatures("Unity.AI.AttachmentSummaries"); + viewAttachmentSummary.AddChild( + AIPermissions.Analysis.GenerateAttachmentSummaries, + L("Permission:AI.GenerateAttachmentSummaries")) + .RequireFeatures("Unity.AI.AttachmentSummaries"); - viewAttachmentSummary.AddChild( - AIPermissions.Analysis.GenerateAttachmentSummaries, - L("Permission:AI.GenerateAttachmentSummaries")) - .RequireFeatures("Unity.AI.AttachmentSummaries"); + var viewScoringResult = aiPermissionsGroup.AddPermission( + AIPermissions.Analysis.ViewScoringResult, + L("Permission:AI.ViewScoringResult")) + .RequireFeatures("Unity.AI.Scoring"); - var viewScoringResult = aiPermissionsGroup.AddPermission( - AIPermissions.Analysis.ViewScoringResult, - L("Permission:AI.ViewScoringResult")) - .RequireFeatures("Unity.AI.Scoring"); - - viewScoringResult.AddChild( - AIPermissions.Analysis.GenerateScoring, - L("Permission:AI.GenerateScoring")) - .RequireFeatures("Unity.AI.Scoring"); - - var settingManagement = context.GetGroup(SettingManagementPermissions.GroupName); - var configureAI = settingManagement.AddPermission( - AIPermissions.Configuration.ConfigureAI, - L("Permission:AI.ConfigureAI")); - configureAI.StateCheckers.Add(new AnyFeaturePermissionStateProvider( - "Unity.AI.Scoring", - "Unity.AI.AttachmentSummaries", - "Unity.AI.ApplicationAnalysis")); + viewScoringResult.AddChild( + AIPermissions.Analysis.GenerateScoring, + L("Permission:AI.GenerateScoring")) + .RequireFeatures("Unity.AI.Scoring"); + var settingManagement = context.GetGroup(SettingManagementPermissions.GroupName); + var configureAI = settingManagement.AddPermission( + AIPermissions.Configuration.ConfigureAI, + L("Permission:AI.ConfigureAI")); + configureAI.StateCheckers.Add(new AnyFeaturePermissionStateProvider( + "Unity.AI.Scoring", + "Unity.AI.AttachmentSummaries", + "Unity.AI.ApplicationAnalysis")); } private static LocalizableString L(string name) diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Prompts/AIPromptDto.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Prompts/AIPromptDto.cs index 0bdff63a08..d5b673b699 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Prompts/AIPromptDto.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Prompts/AIPromptDto.cs @@ -1,5 +1,4 @@ using System; -using System.Collections.Generic; using Volo.Abp.Application.Dtos; namespace Unity.AI.Prompts; @@ -7,8 +6,9 @@ namespace Unity.AI.Prompts; public class AIPromptDto : AuditedEntityDto { public string Name { get; set; } = string.Empty; - public string? Description { get; set; } - public PromptType Type { get; set; } + public int VersionNumber { get; set; } + public string SystemPrompt { get; set; } = string.Empty; + public string UserPrompt { get; set; } = string.Empty; + public string? MetadataJson { get; set; } public bool IsActive { get; set; } - public List Versions { get; set; } = new(); } diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Prompts/AIPromptVersionDto.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Prompts/AIPromptVersionDto.cs deleted file mode 100644 index a9ed4e50a8..0000000000 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Prompts/AIPromptVersionDto.cs +++ /dev/null @@ -1,20 +0,0 @@ -using System; -using Volo.Abp.Application.Dtos; - -namespace Unity.AI.Prompts; - -public class AIPromptVersionDto : AuditedEntityDto -{ - public Guid PromptId { get; set; } - public int VersionNumber { get; set; } - public string SystemPrompt { get; set; } = string.Empty; - public string UserPromptTemplate { get; set; } = string.Empty; - public string? DeveloperNotes { get; set; } - public string? TargetModel { get; set; } - public string? TargetProvider { get; set; } - public double Temperature { get; set; } - public int? MaxTokens { get; set; } - public bool IsPublished { get; set; } - public bool IsDeprecated { get; set; } - public string? MetadataJson { get; set; } -} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Prompts/CreateUpdateAIPromptDto.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Prompts/CreateUpdateAIPromptDto.cs index 6d361fd3ba..e26973030a 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Prompts/CreateUpdateAIPromptDto.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Prompts/CreateUpdateAIPromptDto.cs @@ -1,3 +1,4 @@ +using System; using System.ComponentModel; using System.ComponentModel.DataAnnotations; @@ -5,17 +6,21 @@ namespace Unity.AI.Prompts; public class CreateUpdateAIPromptDto { + public Guid PromptId { get; set; } + + [DisplayName("VersionNumber")] + public int VersionNumber { get; set; } + [Required] - [MaxLength(200)] - [DisplayName("PromptName")] - public string Name { get; set; } = string.Empty; + [DisplayName("SystemPrompt")] + public string SystemPrompt { get; set; } = string.Empty; - [MaxLength(2000)] - [DisplayName("PromptDescription")] - public string? Description { get; set; } + [Required] + [DisplayName("UserPrompt")] + public string UserPrompt { get; set; } = string.Empty; - [DisplayName("PromptType")] - public PromptType Type { get; set; } + [DisplayName("MetadataJson")] + public string? MetadataJson { get; set; } [DisplayName("PromptIsActive")] public bool IsActive { get; set; } = true; diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Prompts/CreateUpdateAIPromptVersionDto.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Prompts/CreateUpdateAIPromptVersionDto.cs deleted file mode 100644 index 8e3414943f..0000000000 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Prompts/CreateUpdateAIPromptVersionDto.cs +++ /dev/null @@ -1,47 +0,0 @@ -using System; -using System.ComponentModel; -using System.ComponentModel.DataAnnotations; - -namespace Unity.AI.Prompts; - -public class CreateUpdateAIPromptVersionDto -{ - public Guid PromptId { get; set; } - - [DisplayName("VersionNumber")] - public int VersionNumber { get; set; } - - [Required] - [DisplayName("SystemPrompt")] - public string SystemPrompt { get; set; } = string.Empty; - - [Required] - [DisplayName("UserPromptTemplate")] - public string UserPromptTemplate { get; set; } = string.Empty; - - [DisplayName("DeveloperNotes")] - public string? DeveloperNotes { get; set; } - - [MaxLength(100)] - [DisplayName("TargetModel")] - public string? TargetModel { get; set; } - - [MaxLength(100)] - [DisplayName("TargetProvider")] - public string? TargetProvider { get; set; } - - [DisplayName("Temperature")] - public double Temperature { get; set; } = 0.2; - - [DisplayName("MaxTokens")] - public int? MaxTokens { get; set; } - - [DisplayName("IsPublished")] - public bool IsPublished { get; set; } - - [DisplayName("IsDeprecated")] - public bool IsDeprecated { get; set; } - - [DisplayName("MetadataJson")] - public string? MetadataJson { get; set; } -} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Prompts/IAIPromptAppService.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Prompts/IAIPromptAppService.cs index c259996db0..7aca79853d 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Prompts/IAIPromptAppService.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Prompts/IAIPromptAppService.cs @@ -10,4 +10,5 @@ public interface IAIPromptAppService : ICrudAppService< PagedAndSortedResultRequestDto, CreateUpdateAIPromptDto> { + System.Threading.Tasks.Task> GetByPromptAsync(Guid promptId); } diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Prompts/IAIPromptVersionAppService.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Prompts/IAIPromptVersionAppService.cs deleted file mode 100644 index 269029e5ec..0000000000 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Prompts/IAIPromptVersionAppService.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System; -using Volo.Abp.Application.Dtos; -using Volo.Abp.Application.Services; - -namespace Unity.AI.Prompts; - -public interface IAIPromptVersionAppService : ICrudAppService< - AIPromptVersionDto, - Guid, - PagedAndSortedResultRequestDto, - CreateUpdateAIPromptVersionDto> -{ - System.Threading.Tasks.Task> GetByPromptAsync(Guid promptId); -} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Extraction/TextExtractionService.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Extraction/TextExtractionService.cs index 1da60206a2..b2a463f95f 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Extraction/TextExtractionService.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Extraction/TextExtractionService.cs @@ -37,7 +37,6 @@ public Task ExtractTextAsync(string fileName, Stream fileContent, string { if (fileContent == null) { - _logger.LogDebug("File content stream is null for {FileName}", fileName); return Task.FromResult(string.Empty); } @@ -49,7 +48,6 @@ public Task ExtractTextAsync(string fileName, Stream fileContent, string if (extension == ".doc") { - _logger.LogDebug("Legacy .doc extraction is not supported for {FileName}", fileName); return Task.FromResult(string.Empty); } @@ -63,12 +61,6 @@ public Task ExtractTextAsync(string fileName, Stream fileContent, string _ => ExtractByContentType(fileName, fileContent, normalizedContentType, cancellationToken) }; - if (string.IsNullOrEmpty(rawText)) - { - _logger.LogDebug("No text extraction available for content type {ContentType} with extension {Extension}", - contentType, extension); - } - return Task.FromResult(NormalizeAndLimitText(rawText, fileName)); } catch (OperationCanceledException) @@ -138,12 +130,9 @@ private string ExtractTextFromTextFile(Stream fileContent, CancellationToken can builder.Append(buffer, 0, Math.Min(read, remaining)); if (builder.Length >= MaxExtractedTextLength) { - _logger.LogDebug("Truncated text content to {MaxLength} characters", MaxExtractedTextLength); break; } } - - _logger.LogDebug("Extracted {CharacterCount} characters from text-based content.", builder.Length); return builder.ToString(); } catch (Exception ex) @@ -180,7 +169,6 @@ private string ExtractTextFromPdfFile(string fileName, Stream fileContent, Cance } } - _logger.LogDebug("Extracted PDF text from {ProcessedPageCount} pages for {FileName}", processedPageCount, fileName); return builder.ToString(); } catch (Exception ex) @@ -200,11 +188,6 @@ private string ExtractTextFromWordDocx(string fileName, Stream fileContent, Canc var processedParagraphCount = AppendDocxParagraphText(document, builder, cancellationToken); var processedTableRowCount = AppendDocxTableText(document, builder, cancellationToken); - _logger.LogDebug( - "Extracted Word text from {ProcessedParagraphCount} paragraphs and {ProcessedTableRowCount} table rows for {FileName}", - processedParagraphCount, - processedTableRowCount, - fileName); return builder.ToString(); } catch (Exception ex) @@ -324,11 +307,6 @@ private string ExtractTextFromExcelFile(string fileName, Stream fileContent, Can } } - _logger.LogDebug( - "Extracted Excel text from {ProcessedSheetCount} sheets and {ProcessedRowCount} rows for {FileName}", - processedSheetCount, - processedRowCount, - fileName); return builder.ToString(); } catch (Exception ex) @@ -371,7 +349,6 @@ private string ExtractTextFromPowerPointFile(string fileName, Stream fileContent } } - _logger.LogDebug("Extracted PowerPoint text from {ProcessedSlideCount} slides for {FileName}", processedSlideCount, fileName); return builder.ToString(); } catch (Exception ex) @@ -390,14 +367,12 @@ private IEnumerable GetOrderedPowerPointSlideEntries(ZipArchive if (slideEntriesByName.Count == 0) { - _logger.LogDebug("No slide entries found in PowerPoint archive."); return Enumerable.Empty(); } var orderedSlideNames = TryGetPowerPointSlideOrder(archive); if (orderedSlideNames.Count == 0) { - _logger.LogDebug("Using PowerPoint part-name order fallback for {SlideCount} slides.", slideEntriesByName.Count); return slideEntriesByName.Values .OrderBy(entry => GetPowerPointSlideNumber(entry.FullName)) .ToList(); @@ -417,8 +392,6 @@ private IEnumerable GetOrderedPowerPointSlideEntries(ZipArchive { orderedEntries.AddRange(slideEntriesByName.Values.OrderBy(entry => GetPowerPointSlideNumber(entry.FullName))); } - - _logger.LogDebug("Resolved PowerPoint presentation order for {SlideCount} slides.", orderedEntries.Count); return orderedEntries; } @@ -583,7 +556,7 @@ private List TryGetPowerPointSlideOrder(ZipArchive archive) } catch (Exception ex) { - _logger.LogDebug(ex, "Falling back to part-name slide order for PowerPoint extraction."); + _logger.LogDebug(ex, "Could not determine PowerPoint slide order from relationships; falling back to part-name order."); return new List(); } } @@ -691,7 +664,6 @@ private string NormalizeAndLimitText(string text, string fileName) if (normalized.Length > MaxExtractedTextLength) { normalized = normalized.Substring(0, MaxExtractedTextLength); - _logger.LogDebug("Truncated extracted content to {MaxLength} characters", MaxExtractedTextLength); } return normalized; diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/Versions/README.md b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/Versions/README.md deleted file mode 100644 index 0f6146d2ab..0000000000 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/Versions/README.md +++ /dev/null @@ -1,48 +0,0 @@ -# Runtime Prompt Templates - -Runtime prompts are now resolved from the database-backed `AIPrompts` and `AIPromptVersions` records seeded by the AI module. -These files are retained as prompt asset references and seed inputs, not as the runtime source of truth. - -Current prompt asset references: - -- `application-analysis.system.txt` -- `application-analysis.user.txt` -- `application-analysis.rubric.txt` (optional, when `{{RUBRIC}}` is used) -- `application-analysis.score.txt` (optional, when `{{SCORE}}` is used) -- `application-analysis.output.txt` (optional, when `{{OUTPUT}}` is used) -- `application-analysis.rules.txt` (optional, when `{{RULES}}` is used) -- `common.*.txt` (optional shared fragments for `{{COMMON_*}}` placeholders) -- `attachment-summary.system.txt` -- `attachment-summary.user.txt` -- `attachment-summary.output.txt` (optional, when `{{OUTPUT}}` is used) -- `attachment-summary.rules.txt` (optional, when `{{RULES}}` is used) -- `application-scoring.system.txt` -- `application-scoring.user.txt` -- `application-scoring.output.txt` (optional, when `{{OUTPUT}}` is used) -- `application-scoring.rules.txt` (optional, when `{{RULES}}` is used) - -Placeholders: - -- `{{SCHEMA}}` -- `{{DATA}}` -- `{{ATTACHMENTS}}` -- `{{RUBRIC}}` -- `{{SCORE}}` -- `{{OUTPUT}}` -- `{{RULES}}` -- `{{ATTACHMENT}}` -- `{{DATA}}` -- `{{ATTACHMENTS}}` -- `{{SECTION}}` -- `{{RESPONSE}}` - -Version selection: - -- Required: `Azure:Operations:Defaults:PromptVersion = v0|v1`, with optional overrides under `Azure:Operations::PromptVersion`. -- Unknown or missing version values fail at runtime. - -Template loading is strict: - -- Core prompt records are required for each version. -- Missing required prompt records fail fast at runtime with a configuration error. -- Runtime prompt rendering resolves placeholders from the stored template text plus the version metadata sections. diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/Versions/v1/application-analysis.rules.txt b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/Versions/v1/application-analysis.rules.txt index 5840d215c8..f1627fb26f 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/Versions/v1/application-analysis.rules.txt +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/Versions/v1/application-analysis.rules.txt @@ -22,7 +22,8 @@ - Summaries and recommendations must be concrete, distinct, reviewer-relevant, and specific to this application's evidence. - Avoid generic praise, checklist language, and repeated conclusions across lists. - Do not use a summary merely to say that supporting documents were provided; summarize the specific substantive evidence they add, or omit the finding. -- If no findings exist, return empty arrays. +- Errors and warnings may be empty. +- Summaries and recommendations must each include at least one item. - Decision must be PROCEED or HOLD. - Use summaries for overall application quality/readiness synthesis. - Use recommendations for concrete reviewer-facing next actions based on the provided evidence. diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/AIPromptTemplateProvider.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/AIPromptTemplateProvider.cs index de92cd64b7..c502fd5de6 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/AIPromptTemplateProvider.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/AIPromptTemplateProvider.cs @@ -3,6 +3,7 @@ using System.Threading.Tasks; using Unity.AI.Domain; using Volo.Abp.DependencyInjection; +using Volo.Abp.Data; using Volo.Abp.Domain.Repositories; using Volo.Abp.MultiTenancy; @@ -10,8 +11,7 @@ namespace Unity.AI.Runtime; public class AIPromptTemplateProvider( IRepository promptRepository, - IRepository promptVersionRepository, - ICurrentTenant currentTenant) : IAIPromptTemplateProvider, ITransientDependency + IDataFilter multiTenantDataFilter) : IAIPromptTemplateProvider, ITransientDependency { public async Task GetRequiredPromptAsync( string promptType, @@ -21,27 +21,21 @@ public async Task GetRequiredPromptAsync( var normalizedPromptVersion = OpenAIPromptRenderer.ResolvePromptVersion(promptVersion); var versionNumber = OpenAIPromptRenderer.ResolvePromptVersionNumber(normalizedPromptVersion); - using (currentTenant.Change(null)) + using (multiTenantDataFilter.Disable()) { - var prompt = await promptRepository.FindAsync(p => p.Name == promptType); + var prompt = await promptRepository.FindAsync(p => + p.TenantId == null && p.Name == promptType && p.VersionNumber == versionNumber); if (prompt == null || !prompt.IsActive) - { - throw new InvalidOperationException($"AI prompt '{promptType}' is not configured."); - } - - var version = await promptVersionRepository.FindAsync( - v => v.PromptId == prompt.Id && v.VersionNumber == versionNumber); - if (version == null || !version.IsPublished || version.IsDeprecated) { throw new InvalidOperationException( - $"AI prompt version '{normalizedPromptVersion}' for prompt '{promptType}' is not configured."); + $"AI prompt '{promptType}' version '{normalizedPromptVersion}' is not configured."); } return new AIPromptTemplateSnapshot( normalizedPromptVersion, - version.SystemPrompt, - version.UserPromptTemplate, - version.MetadataJson); + prompt.SystemPrompt, + prompt.UserPrompt, + prompt.MetadataJson); } } } 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 b6fc24cd60..da342fe04e 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 @@ -116,11 +116,6 @@ private static Dictionary ExtractMetadataSections(string? metada return new Dictionary(StringComparer.Ordinal); } - if (root.TryGetProperty("sections", out var sections) && sections.ValueKind == JsonValueKind.Object) - { - return ExtractStringProperties(sections); - } - return ExtractStringProperties(root); } catch (JsonException ex) 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 fb60ccdde6..89be54c5bf 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 @@ -3,5 +3,5 @@ namespace Unity.AI.Runtime; public sealed record AIPromptTemplateSnapshot( string PromptVersion, string SystemPrompt, - string UserPromptTemplate, + string UserPrompt, 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 b49d718e15..c9119b17c9 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 @@ -26,6 +26,13 @@ public static AIResponseValidationResult ValidateApplicationAnalysisJson(string return AIResponseValidationResult.Invalid($"Application analysis response is missing or invalid required field '{AIJsonKeys.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'."); + } + if (!root.TryGetProperty(AIJsonKeys.Errors, out var errors) || errors.ValueKind != JsonValueKind.Array) { return AIResponseValidationResult.Invalid($"Application analysis response is missing or invalid required field '{AIJsonKeys.Errors}' (expected array)."); @@ -46,6 +53,18 @@ public static AIResponseValidationResult ValidateApplicationAnalysisJson(string return AIResponseValidationResult.Invalid($"Application analysis response is missing or invalid required field '{AIJsonKeys.Recommendations}' (expected array)."); } + if (summaries.GetArrayLength() == 0) + { + return AIResponseValidationResult.Invalid( + $"Application analysis response must include at least one item in '{AIJsonKeys.Summaries}'."); + } + + if (recommendations.GetArrayLength() == 0) + { + return AIResponseValidationResult.Invalid( + $"Application analysis response must include at least one item in '{AIJsonKeys.Recommendations}'."); + } + return AIResponseValidationResult.Success(); } @@ -81,9 +100,9 @@ public static AIResponseValidationResult ValidateApplicationScoringJson(string r if (!answerObject.TryGetProperty(AIJsonKeys.Confidence, out var confidenceValue) || confidenceValue.ValueKind != JsonValueKind.Number - || !confidenceValue.TryGetInt32(out var confidence) - || confidence < 0 - || confidence > 100) + || !confidenceValue.TryGetDecimal(out var confidence) + || confidence < 0m + || confidence > 1m) { return AIResponseValidationResult.Invalid( $"Application scoring response is missing a valid confidence score for question id '{questionId}'."); 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 d608869f30..7490142fbe 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,168 +1,259 @@ using Microsoft.Extensions.Configuration; using System; -using System.Globalization; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Unity.AI.Domain; +using Unity.AI.Operations; +using Volo.Abp.Data; using Volo.Abp.DependencyInjection; +using Volo.Abp.Domain.Repositories; +using Volo.Abp.MultiTenancy; namespace Unity.AI.Runtime; -public class OpenAIConfigurationResolver(IConfiguration configuration) : ITransientDependency +public class OpenAIConfigurationResolver( + IRepository modelRepository, + IRepository operationRepository, + IRepository promptRepository, + IConfiguration configuration, + IDataFilter multiTenantDataFilter) : ITransientDependency { + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true + }; + + private readonly IRepository _modelRepository = modelRepository; + private readonly IRepository _operationRepository = operationRepository; + private readonly IRepository _promptRepository = promptRepository; private readonly IConfiguration _configuration = configuration; + private readonly IDataFilter _multiTenantDataFilter = multiTenantDataFilter; + + public string ResolveProviderName() => Required("Azure:Operations:Defaults:Provider"); - public string ResolveProviderName(string? operationName = null) + public Task ResolveApiKeyAsync(string? modelName = null, CancellationToken cancellationToken = default) { - if (!string.IsNullOrWhiteSpace(operationName)) + var providerName = Required("Azure:Operations:Defaults:Provider"); + return Task.FromResult(Required($"Azure:{providerName}:ApiKey")); + } + + public async Task ResolveOperationSettingsAsync( + string operationName, + CancellationToken cancellationToken = default) + { + var operation = await ResolveOperationAsync(operationName, cancellationToken); + if (operation == null) { - var operationProvider = Optional($"Azure:Operations:{operationName}:Provider"); - if (operationProvider != null) - { - return operationProvider; - } + throw new InvalidOperationException($"AI operation '{operationName}' is not configured."); } - return Required("Azure:Operations:Defaults:Provider"); - } + var model = await _modelRepository.GetAsync(operation.AIModelId, cancellationToken: cancellationToken); + if (!model.IsActive) + { + throw new InvalidOperationException($"AI model '{model.Name}' is inactive."); + } - public string ResolveApiKey(string? operationName = null) - { - var providerName = ResolveProviderName(operationName); - return Required($"Azure:{providerName}:ApiKey"); - } + var modelSettings = ResolveModelSettings(model); + var providerName = Required("Azure:Operations:Defaults:Provider"); + var endpoint = Required($"Azure:{providerName}:Endpoint"); + if (!Uri.TryCreate(endpoint, UriKind.Absolute, out _)) + { + throw new InvalidOperationException($"Azure:{providerName}:Endpoint must be a valid absolute URI."); + } + var prompt = await LoadPromptAsync(operation.AIPromptId, cancellationToken); + if (!prompt.IsActive) + { + throw new InvalidOperationException($"AI prompt '{prompt.Name}' v{prompt.VersionNumber} is not active."); + } - public OpenAIOperationSettings ResolveOperationSettings(string operationName) - { - var providerName = ResolveProviderName(operationName); - var profileName = ResolveProfileName(operationName); - var apiKey = Required($"Azure:{providerName}:ApiKey"); - var endpoint = ResolveEndpointUri(providerName); - var deploymentName = RequiredProfile(providerName, profileName, "DeploymentName"); - var promptVersion = Optional($"Azure:Operations:{operationName}:PromptVersion") - ?? Required("Azure:Operations:Defaults:PromptVersion"); + if (operation.CompletionTokens <= 0) + { + throw new InvalidOperationException($"AI operation '{operation.Name}' must define a positive CompletionTokens value."); + } + var apiKey = Required($"Azure:{providerName}:ApiKey"); return new OpenAIOperationSettings( providerName, - profileName, + model.Name, apiKey, - endpoint, - deploymentName, - ResolveMaxOutputTokenCountSupported(operationName), - ResolveConfiguredTemperature(operationName), - ResolveCompletionTokens(operationName), - promptVersion); + new Uri(endpoint), + Required($"Azure:{providerName}:Profiles:{model.Name}:DeploymentName"), + modelSettings.MaxOutputTokenCountSupported, + modelSettings.Temperature, + operation.CompletionTokens, + $"v{prompt.VersionNumber}"); } - public double? ResolveConfiguredTemperature(string? operationName = null) + public async Task ResolveConfiguredTemperatureAsync(string? modelName = null, CancellationToken cancellationToken = default) { - var providerName = ResolveProviderName(operationName); - var profileName = ResolveProfileName(operationName); - var profileTemperature = OptionalProfile(providerName, profileName, "Temperature"); - if (profileTemperature != null - && double.TryParse(profileTemperature, NumberStyles.Float, CultureInfo.InvariantCulture, out var parsedTemperature)) + var modelConfiguration = await ResolveModelConfigurationAsync(modelName, cancellationToken); + if (modelConfiguration != null) { - return parsedTemperature; + return modelConfiguration.Value.Settings.Temperature; } - return null; + throw new InvalidOperationException("AI model is not configured."); } - public bool ResolveMaxOutputTokenCountSupported(string? operationName = null) + public async Task ResolveMaxOutputTokenCountSupportedAsync(string? modelName = null, CancellationToken cancellationToken = default) { - var providerName = ResolveProviderName(operationName); - var profileName = ResolveProfileName(operationName); - var key = ProfileKey(providerName, profileName, "MaxOutputTokenCountSupported"); - var configuredValue = Optional(key); - if (configuredValue == null) + var modelConfiguration = await ResolveModelConfigurationAsync(modelName, cancellationToken); + if (modelConfiguration != null) { - return true; - } + var providerName = Required("Azure:Operations:Defaults:Provider"); + var profileName = modelConfiguration.Value.Model.Name; + var configuredValue = Optional($"Azure:{providerName}:Profiles:{profileName}:MaxOutputTokenCountSupported"); + if (configuredValue != null) + { + if (bool.TryParse(configuredValue, out var parsedValue)) + { + return parsedValue; + } - if (bool.TryParse(configuredValue, out var parsedValue)) - { - return parsedValue; + throw new InvalidOperationException($"Azure:{providerName}:Profiles:{profileName}:MaxOutputTokenCountSupported is not a valid boolean."); + } + + return modelConfiguration.Value.Settings.MaxOutputTokenCountSupported; } - throw new InvalidOperationException($"{key} must be 'true' or 'false'."); + throw new InvalidOperationException("AI model is not configured."); } - public int ResolveCompletionTokens(string operationName) + public async Task ResolveCompletionTokensAsync(string operationName, CancellationToken cancellationToken = default) { - var configuredValue = OptionalPositiveInt($"Azure:Operations:{operationName}:MaxCompletionTokens"); - if (configuredValue is > 0) + var operation = await ResolveOperationAsync(operationName, cancellationToken); + if (operation == null) { - return configuredValue.Value; + throw new InvalidOperationException($"AI operation '{operationName}' is not configured."); } - var defaultConfiguredValue = OptionalPositiveInt("Azure:Operations:Defaults:MaxCompletionTokens"); - if (defaultConfiguredValue is > 0) + var model = await _modelRepository.GetAsync(operation.AIModelId, cancellationToken: cancellationToken); + if (!model.IsActive) { - return defaultConfiguredValue.Value; + throw new InvalidOperationException($"AI model '{model.Name}' is inactive."); } - throw new InvalidOperationException($"AI max completion tokens are not configured for operation '{operationName}'."); + if (operation.CompletionTokens <= 0) + { + throw new InvalidOperationException($"AI operation '{operation.Name}' must define a positive CompletionTokens value."); + } + + return operation.CompletionTokens; } - public string ResolvePromptVersion(string operationName) + public Task ResolvePromptVersionAsync(string operationName, CancellationToken cancellationToken = default) { - return Optional($"Azure:Operations:{operationName}:PromptVersion") - ?? Required("Azure:Operations:Defaults:PromptVersion"); + return ResolvePromptVersionAsyncCore(operationName, cancellationToken); } - public Uri ResolveEndpoint(string? operationName = null) + public async Task ResolveEndpointAsync(string? modelName = null, CancellationToken cancellationToken = default) { - var providerName = ResolveProviderName(operationName); - return ResolveEndpointUri(providerName); + var modelConfiguration = await ResolveModelConfigurationAsync(modelName, cancellationToken); + if (modelConfiguration != null) + { + var providerName = Required("Azure:Operations:Defaults:Provider"); + return new Uri(Required($"Azure:{providerName}:Endpoint")); + } + + throw new InvalidOperationException("AI model is not configured."); } - public string ResolveDeploymentName(string? operationName = null) + public async Task ResolveDeploymentNameAsync(string? modelName = null, CancellationToken cancellationToken = default) { - var providerName = ResolveProviderName(operationName); - var profileName = ResolveProfileName(operationName); - return RequiredProfile(providerName, profileName, "DeploymentName"); + var modelConfiguration = await ResolveModelConfigurationAsync(modelName, cancellationToken); + if (modelConfiguration != null) + { + var providerName = Required("Azure:Operations:Defaults:Provider"); + return Required($"Azure:{providerName}:Profiles:{modelConfiguration.Value.Model.Name}:DeploymentName"); + } + + throw new InvalidOperationException("AI model is not configured."); } - private string ResolveProfileName(string? operationName) + public async Task ResolveProfileNameAsync(string? modelName = null, CancellationToken cancellationToken = default) { - if (!string.IsNullOrWhiteSpace(operationName)) + var modelConfiguration = await ResolveModelConfigurationAsync(modelName, cancellationToken); + if (modelConfiguration != null) { - var operationProfile = Optional($"Azure:Operations:{operationName}:Profile"); - if (operationProfile != null) - { - return operationProfile; - } + return modelConfiguration.Value.Model.Name; } - return Required("Azure:Operations:Defaults:Profile"); + throw new InvalidOperationException("AI model is not configured."); } - private string RequiredProfile(string providerName, string profileName, string settingName) + private async Task<(AIModel Model, AIModelSettings Settings)?> ResolveModelConfigurationAsync( + string? modelName, + CancellationToken cancellationToken) { - var key = ProfileKey(providerName, profileName, settingName); - return Required(key); + var model = await ResolveModelAsync(modelName, cancellationToken); + if (model == null) + { + return null; + } + + var settings = JsonSerializer.Deserialize(model.SettingsJson, JsonOptions); + if (settings == null) + { + throw new InvalidOperationException($"AI model '{model.Name}' has invalid settings JSON."); + } + + return (model, settings); } - private string? OptionalProfile(string providerName, string profileName, string settingName) + private async Task ResolveOperationAsync(string operationName, CancellationToken cancellationToken) { - return Optional(ProfileKey(providerName, profileName, settingName)); + var operations = await _operationRepository.GetListAsync( + operation => operation.IsActive, + cancellationToken: cancellationToken); + + return operations.FirstOrDefault(operation => + string.Equals(operation.Name, operationName, StringComparison.OrdinalIgnoreCase)); } - private string Required(string key) + private static AIModelSettings ResolveModelSettings(AIModel model) { - return Optional(key) ?? throw new InvalidOperationException($"{key} is not configured."); + var settings = JsonSerializer.Deserialize(model.SettingsJson, JsonOptions); + if (settings == null) + { + throw new InvalidOperationException($"AI model '{model.Name}' has invalid settings JSON."); + } + + return settings; } - private Uri ResolveEndpointUri(string providerName) + private async Task ResolveModelAsync(string? modelName, CancellationToken cancellationToken) { - var key = $"Azure:{providerName}:Endpoint"; - var endpoint = Required(key); + var activeModels = await _modelRepository.GetListAsync(model => model.IsActive, cancellationToken: cancellationToken); + if (activeModels.Count == 0) + { + return null; + } - try + if (!string.IsNullOrWhiteSpace(modelName)) { - return new Uri(endpoint); + return activeModels.FirstOrDefault(model => + string.Equals(model.Name, modelName, StringComparison.OrdinalIgnoreCase)); } - catch (UriFormatException ex) + + var configuredDefaultProfile = Optional("Azure:Operations:Defaults:Profile"); + if (!string.IsNullOrWhiteSpace(configuredDefaultProfile)) { - throw new InvalidOperationException($"{key} must be a valid absolute URI.", ex); + var configuredDefaultModel = activeModels.FirstOrDefault(model => + string.Equals(model.Name, configuredDefaultProfile, StringComparison.OrdinalIgnoreCase)); + if (configuredDefaultModel != null) + { + return configuredDefaultModel; + } } + + return null; + } + + private string Required(string key) + { + return Optional(key) ?? throw new InvalidOperationException($"{key} is not configured."); } private string? Optional(string key) @@ -171,14 +262,28 @@ private Uri ResolveEndpointUri(string providerName) return string.IsNullOrWhiteSpace(value) ? null : value.Trim(); } - private int? OptionalPositiveInt(string key) + private async Task ResolvePromptVersionAsyncCore(string operationName, CancellationToken cancellationToken) { - var value = _configuration.GetValue(key); - return value is > 0 ? value : null; + var operation = await ResolveOperationAsync(operationName, cancellationToken); + if (operation == null) + { + throw new InvalidOperationException($"AI operation '{operationName}' is not configured."); + } + + var prompt = await LoadPromptAsync(operation.AIPromptId, cancellationToken); + if (!prompt.IsActive) + { + throw new InvalidOperationException($"AI prompt '{prompt.Name}' v{prompt.VersionNumber} is not active."); + } + + return $"v{prompt.VersionNumber}"; } - private static string ProfileKey(string providerName, string profileName, string settingName) + private async Task LoadPromptAsync(Guid promptId, CancellationToken cancellationToken) { - return $"Azure:{providerName}:Profiles:{profileName}:{settingName}"; + using (_multiTenantDataFilter.Disable()) + { + return await _promptRepository.GetAsync(promptId, cancellationToken: cancellationToken); + } } } diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIPromptRenderer.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIPromptRenderer.cs index 64511fd9c9..9080271150 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIPromptRenderer.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIPromptRenderer.cs @@ -7,15 +7,6 @@ namespace Unity.AI.Runtime; public class OpenAIPromptRenderer : ITransientDependency { - private const string PromptVersionV0 = "v0"; - private const string PromptVersionV1 = "v1"; - private static readonly Dictionary PromptProfiles = - new(StringComparer.Ordinal) - { - [PromptVersionV0] = PromptVersionV0, - [PromptVersionV1] = PromptVersionV1 - }; - public static string BuildApplicationScoringResponseTemplate(string sectionPayloadJson) { try @@ -131,9 +122,12 @@ public static string ResolvePromptVersion(string? version) throw new InvalidOperationException("AI prompt version is not configured."); } - if (PromptProfiles.TryGetValue(version.Trim(), out var selectedVersion)) + var normalizedVersion = version.Trim(); + if (normalizedVersion.Length >= 2 && + normalizedVersion[0] == 'v' && + int.TryParse(normalizedVersion.AsSpan(1), out _)) { - return selectedVersion; + return normalizedVersion; } throw new InvalidOperationException($"AI prompt version '{version}' is not supported."); 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 1abacf8dda..382dfd0e13 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 @@ -131,7 +131,7 @@ public static ApplicationScoringResponse ParseApplicationScoringResponse(string : string.Empty; var confidence = property.Value.TryGetProperty("confidence", out var confidenceProp) && confidenceProp.ValueKind == JsonValueKind.Number && - confidenceProp.TryGetInt32(out var parsedConfidence) + confidenceProp.TryGetDecimal(out var parsedConfidence) ? NormalizeConfidence(parsedConfidence) : 0; @@ -242,10 +242,11 @@ private static bool TryGetArrayProperty(JsonElement element, string propertyName return true; } - private static int NormalizeConfidence(int confidence) + private static int NormalizeConfidence(decimal confidence) { - var clamped = Math.Clamp(confidence, 0, 100); - var rounded = (int)Math.Round(clamped / 5.0, MidpointRounding.AwayFromZero) * 5; + var clamped = Math.Clamp(confidence, 0m, 1m); + var percentage = clamped * 100m; + var rounded = (int)Math.Round(percentage / 10m, MidpointRounding.AwayFromZero) * 10; return Math.Clamp(rounded, 0, 100); } } 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 d7f6f35e64..0968c7dd03 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 @@ -9,6 +9,7 @@ using Unity.AI.Prompts; using Unity.AI.Requests; using Unity.AI.Responses; +using Volo.Abp; using Volo.Abp.DependencyInjection; namespace Unity.AI.Runtime @@ -42,16 +43,7 @@ public OpenAIRuntimeService( public Task IsAvailableAsync() { - try - { - _openAIConfigurationResolver.ResolveApiKey(); - return Task.FromResult(true); - } - catch (InvalidOperationException ex) - { - _logger.LogWarning(ex, "AI is unavailable because the OpenAI configuration could not be resolved."); - return Task.FromResult(false); - } + return IsAvailableCoreAsync(); } public async Task GenerateApplicationAnalysisAsync(ApplicationAnalysisRequest request, CancellationToken cancellationToken = default) @@ -59,7 +51,7 @@ public async Task GenerateApplicationAnalysisAsync( try { ArgumentNullException.ThrowIfNull(request); - var settings = _openAIConfigurationResolver.ResolveOperationSettings(ApplicationAnalysisPromptType); + var settings = await _openAIConfigurationResolver.ResolveOperationSettingsAsync(ApplicationAnalysisPromptType, cancellationToken); var promptTemplate = await _promptTemplateProvider.GetRequiredPromptAsync( ApplicationAnalysisPromptType, request.PromptVersion ?? settings.PromptVersion, @@ -79,7 +71,7 @@ public async Task GenerateApplicationAnalysisAsync( var attachments = JsonSerializer.Serialize(attachmentsPayload, AIJsonDefaults.Indented); var systemPrompt = promptTemplate.SystemPrompt; var applicationAnalysisContent = AIPromptTemplateRenderer.BuildApplicationAnalysisUserPrompt( - promptTemplate.UserPromptTemplate, + promptTemplate.UserPrompt, schema, data, attachments, @@ -101,7 +93,25 @@ public async Task GenerateApplicationAnalysisAsync( if (result.Outcome != AIOperationOutcome.Success) { - return new ApplicationAnalysisResponse(); + var providerDetails = result.Response?.RawResponse; + if (string.IsNullOrWhiteSpace(providerDetails)) + { + providerDetails = result.Response?.Content; + } + + if (!string.IsNullOrWhiteSpace(providerDetails) && providerDetails.Length > 400) + { + providerDetails = providerDetails[..400]; + } + + _logger.LogError( + "Application analysis generation failed with outcome {Outcome} and failure category {FailureCategory}. HTTP status {HttpStatusCode}. Provider details: {ProviderDetails}", + result.Outcome, + result.FailureCategory, + result.Response?.HttpStatusCode?.ToString() ?? "n/a", + providerDetails ?? "n/a"); + + throw new UserFriendlyException("Application analysis generation failed."); } return OpenAIResponseParser.ParseApplicationAnalysisResponse(result.Content); @@ -112,8 +122,8 @@ public async Task GenerateApplicationAnalysisAsync( } catch (Exception ex) { - _logger.LogError(ex, "Error generating application analysis."); - return new ApplicationAnalysisResponse(); + _logger.LogError(ex, "Application analysis generation failed."); + throw; } } @@ -125,7 +135,7 @@ public async Task GenerateAttachmentSummaryAsync(Atta try { - var settings = _openAIConfigurationResolver.ResolveOperationSettings(AttachmentSummaryPromptType); + var settings = await _openAIConfigurationResolver.ResolveOperationSettingsAsync(AttachmentSummaryPromptType, cancellationToken); var promptTemplate = await _promptTemplateProvider.GetRequiredPromptAsync( AttachmentSummaryPromptType, request.PromptVersion ?? settings.PromptVersion, @@ -135,15 +145,6 @@ public async Task GenerateAttachmentSummaryAsync(Atta var prompt = promptTemplate.SystemPrompt; var attachmentText = string.IsNullOrWhiteSpace(extractedText) ? null : extractedText; - if (attachmentText != null) - { - _logger.LogDebug("Received {TextLength} extracted characters for {FileName}", attachmentText.Length, fileName); - } - else - { - _logger.LogDebug("No text extracted from {FileName}, analyzing metadata only", fileName); - } - var attachmentPayload = new { name = fileName, @@ -152,7 +153,7 @@ public async Task GenerateAttachmentSummaryAsync(Atta }; var attachment = JsonSerializer.Serialize(attachmentPayload, AIJsonDefaults.Indented); var contentToAnalyze = AIPromptTemplateRenderer.BuildAttachmentSummaryUserPrompt( - promptTemplate.UserPromptTemplate, + promptTemplate.UserPrompt, attachment, promptTemplate.MetadataJson); @@ -188,7 +189,7 @@ public async Task GenerateAttachmentSummaryAsync(Atta } catch (Exception ex) { - _logger.LogError(ex, "Error generating attachment summary for {FileName}", fileName); + _logger.LogError(ex, "Attachment summary generation failed for {FileName}.", fileName); return new AttachmentSummaryResponse { Summary = $"AI analysis not available for this attachment ({fileName})." @@ -201,7 +202,7 @@ public async Task GenerateApplicationScoringAsync(Ap ArgumentNullException.ThrowIfNull(request); try { - var settings = _openAIConfigurationResolver.ResolveOperationSettings(ApplicationScoringPromptType); + var settings = await _openAIConfigurationResolver.ResolveOperationSettingsAsync(ApplicationScoringPromptType, cancellationToken); var promptTemplate = await _promptTemplateProvider.GetRequiredPromptAsync( ApplicationScoringPromptType, request.PromptVersion ?? settings.PromptVersion, @@ -228,7 +229,7 @@ public async Task GenerateApplicationScoringAsync(Ap } var applicationScoringContent = AIPromptTemplateRenderer.BuildApplicationScoringUserPrompt( - promptTemplate.UserPromptTemplate, + promptTemplate.UserPrompt, dataJson, attachments, section, @@ -262,7 +263,7 @@ public async Task GenerateApplicationScoringAsync(Ap } catch (Exception ex) { - _logger.LogError(ex, "Error generating application scoring answers for section {SectionName}", request.SectionName); + _logger.LogError(ex, "Application scoring generation failed for section {SectionName}.", request.SectionName); return new ApplicationScoringResponse(); } } @@ -337,6 +338,20 @@ private async Task GenerateWithRetryAsync( return lastResult; } + private async Task IsAvailableCoreAsync() + { + try + { + await _openAIConfigurationResolver.ResolveApiKeyAsync(); + return true; + } + catch (InvalidOperationException ex) + { + _logger.LogWarning(ex, "AI is unavailable because the OpenAI configuration could not be resolved."); + return false; + } + } + private static string ResolveNarrativeContent(AIOperationResult result) { return result.Outcome switch 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 db1cf6fdd2..769a538af1 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 @@ -37,23 +37,16 @@ public async Task GenerateSummaryAsync( ? "You are a professional grant analyst for the BC Government." : systemPrompt; - var options = new ChatCompletionOptions(); - if (settings.MaxOutputTokenCountSupported) + var messages = new List { - options.MaxOutputTokenCount = maxTokens; - } - - if (settings.Temperature.HasValue) - { - options.Temperature = (float)settings.Temperature.Value; - } - - var result = await _chatClientFactory.Create(settings).CompleteChatAsync( - [ - new SystemChatMessage(resolvedSystemPrompt), - new UserChatMessage(content ?? string.Empty) - ], - options, + new SystemChatMessage(resolvedSystemPrompt), + new UserChatMessage(content ?? string.Empty) + }; + + var result = await CompleteChatWithTemperatureFallbackAsync( + settings, + messages, + maxTokens, cancellationToken); var completion = result.Value; @@ -110,6 +103,70 @@ public async Task GenerateSummaryAsync( } } + private async Task> CompleteChatWithTemperatureFallbackAsync( + OpenAIOperationSettings settings, + IReadOnlyList messages, + int maxTokens, + CancellationToken cancellationToken) + { + try + { + return await _chatClientFactory.Create(settings).CompleteChatAsync( + messages, + BuildOptions(settings, maxTokens, includeTemperature: true), + cancellationToken); + } + catch (ClientResultException ex) + { + var responseContent = ex.GetRawResponse()?.Content?.ToString() ?? ex.Message; + if (!ShouldRetryWithoutTemperature(settings, ex.Status, responseContent)) + { + throw; + } + + _logger.LogWarning( + ex, + "Retrying OpenAI request without temperature after provider rejected the temperature parameter for profile {ProfileName}.", + settings.ProfileName); + + return await _chatClientFactory.Create(settings).CompleteChatAsync( + messages, + BuildOptions(settings, maxTokens, includeTemperature: false), + cancellationToken); + } + } + + private static ChatCompletionOptions BuildOptions(OpenAIOperationSettings settings, int maxTokens, bool includeTemperature) + { + var options = new ChatCompletionOptions(); + if (settings.MaxOutputTokenCountSupported) + { + options.MaxOutputTokenCount = maxTokens; + } + + if (includeTemperature && settings.Temperature.HasValue) + { + options.Temperature = (float)settings.Temperature.Value; + } + + return options; + } + + private static bool ShouldRetryWithoutTemperature(OpenAIOperationSettings settings, int statusCode, string responseContent) + { + if (!settings.Temperature.HasValue || statusCode != 400 || string.IsNullOrWhiteSpace(responseContent)) + { + return false; + } + + var lowered = responseContent.ToLowerInvariant(); + return lowered.Contains("temperature") + && (lowered.Contains("unsupported") + || lowered.Contains("not supported") + || lowered.Contains("not allowed") + || lowered.Contains("invalid")); + } + private static AIOperationResult MapFailureOutcome(HttpStatusCode statusCode, AIProviderResult response) { var statusCodeValue = (int)statusCode; diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AIApplicationMapperlyProfile.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AIApplicationMapperlyProfile.cs index 092ea879d1..b799fbd3ea 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AIApplicationMapperlyProfile.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AIApplicationMapperlyProfile.cs @@ -21,55 +21,22 @@ public partial class CreateUpdateAIPromptDtoToAIPromptMapper : MapperBase (AIPrompt)RuntimeHelpers.GetUninitializedObject(typeof(AIPrompt)); - [MapperIgnoreTarget(nameof(AIPrompt.Versions))] [MapperIgnoreTarget(nameof(AIPrompt.TenantId))] [MapperIgnoreTarget(nameof(AIPrompt.ConcurrencyStamp))] [MapperIgnoreTarget(nameof(AIPrompt.CreationTime))] [MapperIgnoreTarget(nameof(AIPrompt.CreatorId))] [MapperIgnoreTarget(nameof(AIPrompt.LastModificationTime))] [MapperIgnoreTarget(nameof(AIPrompt.LastModifierId))] + [MapperIgnoreTarget(nameof(AIPrompt.Name))] public override partial AIPrompt Map(CreateUpdateAIPromptDto source); - [MapperIgnoreTarget(nameof(AIPrompt.Versions))] [MapperIgnoreTarget(nameof(AIPrompt.TenantId))] [MapperIgnoreTarget(nameof(AIPrompt.ConcurrencyStamp))] [MapperIgnoreTarget(nameof(AIPrompt.CreationTime))] [MapperIgnoreTarget(nameof(AIPrompt.CreatorId))] [MapperIgnoreTarget(nameof(AIPrompt.LastModificationTime))] [MapperIgnoreTarget(nameof(AIPrompt.LastModifierId))] + [MapperIgnoreTarget(nameof(AIPrompt.Name))] public override partial void Map(CreateUpdateAIPromptDto source, AIPrompt destination); } -[Mapper] -public partial class AIPromptVersionToAIPromptVersionDtoMapper : MapperBase -{ - public override partial AIPromptVersionDto Map(AIPromptVersion source); - - public override partial void Map(AIPromptVersion source, AIPromptVersionDto destination); -} - -[Mapper] -public partial class CreateUpdateAIPromptVersionDtoToAIPromptVersionMapper : MapperBase -{ - [ObjectFactory] - private static AIPromptVersion CreateAIPromptVersion() => - (AIPromptVersion)RuntimeHelpers.GetUninitializedObject(typeof(AIPromptVersion)); - - [MapperIgnoreTarget(nameof(AIPromptVersion.Prompt))] - [MapperIgnoreTarget(nameof(AIPromptVersion.TenantId))] - [MapperIgnoreTarget(nameof(AIPromptVersion.ConcurrencyStamp))] - [MapperIgnoreTarget(nameof(AIPromptVersion.CreationTime))] - [MapperIgnoreTarget(nameof(AIPromptVersion.CreatorId))] - [MapperIgnoreTarget(nameof(AIPromptVersion.LastModificationTime))] - [MapperIgnoreTarget(nameof(AIPromptVersion.LastModifierId))] - public override partial AIPromptVersion Map(CreateUpdateAIPromptVersionDto source); - - [MapperIgnoreTarget(nameof(AIPromptVersion.Prompt))] - [MapperIgnoreTarget(nameof(AIPromptVersion.TenantId))] - [MapperIgnoreTarget(nameof(AIPromptVersion.ConcurrencyStamp))] - [MapperIgnoreTarget(nameof(AIPromptVersion.CreationTime))] - [MapperIgnoreTarget(nameof(AIPromptVersion.CreatorId))] - [MapperIgnoreTarget(nameof(AIPromptVersion.LastModificationTime))] - [MapperIgnoreTarget(nameof(AIPromptVersion.LastModifierId))] - public override partial void Map(CreateUpdateAIPromptVersionDto source, AIPromptVersion destination); -} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/DataSeed/AIDataSeeder.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/DataSeed/AIDataSeeder.cs new file mode 100644 index 0000000000..07b0a29f08 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/DataSeed/AIDataSeeder.cs @@ -0,0 +1,18 @@ +using System.Threading.Tasks; +using Volo.Abp.Data; +using Volo.Abp.DependencyInjection; + +namespace Unity.AI.DataSeed; + +public class AIDataSeeder( + AIPromptDataSeeder promptDataSeeder, + AIModelDataSeeder modelDataSeeder, + AIOperationDataSeeder operationDataSeeder) : IDataSeedContributor, ITransientDependency +{ + public async Task SeedAsync(DataSeedContext context) + { + await promptDataSeeder.SeedAsync(context); + await modelDataSeeder.SeedAsync(context); + await operationDataSeeder.SeedAsync(context); + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/DataSeed/AIModelDataSeeder.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/DataSeed/AIModelDataSeeder.cs new file mode 100644 index 0000000000..7839db8e01 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/DataSeed/AIModelDataSeeder.cs @@ -0,0 +1,65 @@ +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Threading.Tasks; +using Unity.AI.Domain; +using Volo.Abp.Data; +using Volo.Abp.DependencyInjection; +using Volo.Abp.Domain.Repositories; + +namespace Unity.AI.DataSeed; + +public class AIModelDataSeeder( + IRepository modelRepository) : ITransientDependency +{ + private static readonly BuiltInModelDefinition[] BuiltInModels = + [ + new("Gpt4oMini", true, 0.3d), + new("Gpt5Mini", false, null), + new("Gpt5Nano", false, null) + ]; + + public async Task SeedAsync(DataSeedContext context) + { + if (context.TenantId != null) + { + return; + } + + foreach (var model in BuiltInModels) + { + await EnsureModelAsync(model); + } + } + + private async Task EnsureModelAsync(BuiltInModelDefinition definition) + { + var settings = new AIModelSettings + { + MaxOutputTokenCountSupported = definition.MaxOutputTokenCountSupported, + Temperature = definition.Temperature + }; + + var existing = await modelRepository.FirstOrDefaultAsync(model => model.Name == definition.Name); + if (existing != null) + { + existing.IsActive = true; + existing.SettingsJson = JsonSerializer.Serialize(settings); + await modelRepository.UpdateAsync(existing, autoSave: true); + return; + } + + await modelRepository.InsertAsync( + new AIModel(Guid.CreateVersion7(), definition.Name) + { + IsActive = true, + SettingsJson = JsonSerializer.Serialize(settings) + }, + autoSave: true); + } + + private sealed record BuiltInModelDefinition( + string Name, + bool MaxOutputTokenCountSupported, + double? Temperature); +} 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 new file mode 100644 index 0000000000..ea6b298d20 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/DataSeed/AIOperationDataSeeder.cs @@ -0,0 +1,110 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.Extensions.Logging; +using System.Threading.Tasks; +using Unity.AI.Domain; +using Unity.AI.Operations; +using Unity.AI.Prompts; +using Unity.GrantManager.GrantApplications; +using Volo.Abp.Data; +using Volo.Abp.DependencyInjection; +using Volo.Abp.Domain.Repositories; +using Volo.Abp.MultiTenancy; + +namespace Unity.AI.DataSeed; + +public class AIOperationDataSeeder( + IRepository operationRepository, + IRepository modelRepository, + IRepository promptRepository, + ICurrentTenant currentTenant, + ILogger logger) : ITransientDependency +{ + private const string DefaultModelName = "Gpt5Mini"; + + private static readonly BuiltInOperationDefinition[] BuiltInOperations = + [ + new(AIPromptTypes.ApplicationAnalysis, AIPromptTypes.ApplicationAnalysis, 1, 4000), + new(AIPromptTypes.AttachmentSummary, AIPromptTypes.AttachmentSummary, 1, 2000), + new(AIPromptTypes.ApplicationScoring, AIPromptTypes.ApplicationScoring, 1, 8000) + ]; + + public async Task SeedAsync(DataSeedContext context) + { + if (context.TenantId != null) + { + return; + } + + using (currentTenant.Change(null)) + { + var model = await EnsureModelAsync(DefaultModelName); + if (model == null) + { + logger.LogWarning("AI operation seeding skipped: model '{ModelName}' is missing.", DefaultModelName); + return; + } + + foreach (var definition in BuiltInOperations) + { + await EnsureOperationAsync(definition, model); + } + } + } + + private async Task EnsureOperationAsync(BuiltInOperationDefinition definition, AIModel model) + { + var prompt = await ResolvePromptAsync(definition.PromptName, definition.PromptVersionNumber); + if (prompt == null) + { + logger.LogWarning( + "AI operation seeding skipped: no active prompt found for operation '{OperationName}' and prompt '{PromptName}' version '{PromptVersionNumber}'.", + definition.OperationName, + definition.PromptName, + definition.PromptVersionNumber); + return; + } + + var existing = await operationRepository.FirstOrDefaultAsync(op => op.Name == definition.OperationName); + if (existing != null) + { + existing.AIModelId = model.Id; + existing.AIPromptId = prompt.Id; + existing.ExecutionMode = AIExecutionMode.Sequential; + existing.CompletionTokens = definition.CompletionTokens; + existing.IsActive = true; + await operationRepository.UpdateAsync(existing, autoSave: true); + return; + } + + await operationRepository.InsertAsync( + new AIOperation(Guid.CreateVersion7(), definition.OperationName, model.Id, prompt.Id) + { + ExecutionMode = AIExecutionMode.Sequential, + CompletionTokens = definition.CompletionTokens, + IsActive = true + }, + autoSave: true); + } + + private async Task EnsureModelAsync(string modelName) + { + var models = await modelRepository.GetListAsync(model => model.Name == modelName && model.IsActive); + return models.FirstOrDefault(); + } + + private async Task ResolvePromptAsync(string promptName, int promptVersionNumber) + { + return await promptRepository.FirstOrDefaultAsync(item => + item.Name == promptName && + item.VersionNumber == promptVersionNumber && + item.IsActive); + } + + private sealed record BuiltInOperationDefinition( + string OperationName, + string PromptName, + int PromptVersionNumber, + int CompletionTokens); +} 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 52c13be7e3..64041c1d0b 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 @@ -13,20 +13,12 @@ namespace Unity.AI.DataSeed; /// /// Seeds the built-in AI prompts (application analysis, attachment summary, application scoring) into the host database. -/// Each prompt is seeded with two versions — v0 (original single-file prompts) and v1 (modular -/// prompts with separate rubric, score, output, and rules sections stored in MetadataJson). -/// The seeder is idempotent: it inserts fixed records when missing and does not overwrite existing records. +/// Each prompt family is represented as versioned rows in AIPrompts. /// public class AIPromptDataSeeder( IRepository promptRepository, - IRepository versionRepository, - ICurrentTenant currentTenant) : IDataSeedContributor, ITransientDependency + ICurrentTenant currentTenant) : ITransientDependency { - // Fixed deterministic GUIDs — never change these; they ensure idempotent re-seeding - private static readonly Guid AnalysisPromptId = new("4a100001-1000-4000-a000-000000000001"); - private static readonly Guid AttachmentPromptId = new("4a100001-1000-4000-a000-000000000002"); - private static readonly Guid ScoresheetPromptId = new("4a100001-1000-4000-a000-000000000003"); - public async Task SeedAsync(DataSeedContext context) { if (context.TenantId != null) return; // host database only @@ -43,86 +35,79 @@ public async Task SeedAsync(DataSeedContext context) private async Task SeedAnalysisPromptAsync() { + await EnsurePromptAsync(AIPromptTypes.ApplicationAnalysis, 0, AnalysisSystemV0, AnalysisUserV0); await EnsurePromptAsync( - AnalysisPromptId, AIPromptTypes.ApplicationAnalysis, - "Grant application analysis and review"); - - await EnsureVersionAsync( - AnalysisPromptId, - 0, - AnalysisSystemV0, - AnalysisUserV0, - "v0 — initial single-file analysis prompt"); - - await EnsureVersionAsync( - AnalysisPromptId, 1, AnalysisSystemV1, AnalysisUserV1, - "v1 — modular prompt with separate rubric, score, output, and rules sections", BuildSections( rubric: AnalysisRubric, score: AnalysisScore, output: AnalysisOutput, rules: AnalysisRules, commonRules: CommonRules)); + await EnsurePromptAsync( + AIPromptTypes.ApplicationAnalysis, + 2, + AnalysisSystemV2, + AnalysisUserV2, + BuildSections( + rubric: AnalysisRubricV2, + score: AnalysisScoreV2, + output: AnalysisOutputV2, + rules: AnalysisRulesV2, + commonRules: CommonRules)); } // ─── ATTACHMENT ─────────────────────────────────────────────────────────── private async Task SeedAttachmentPromptAsync() { + await EnsurePromptAsync(AIPromptTypes.AttachmentSummary, 0, AttachmentSystemV0, AttachmentUserV0); await EnsurePromptAsync( - AttachmentPromptId, AIPromptTypes.AttachmentSummary, - "Attachment summarization for grant review"); - - await EnsureVersionAsync( - AttachmentPromptId, - 0, - AttachmentSystemV0, - AttachmentUserV0, - "v0 — initial single-file attachment prompt"); - - await EnsureVersionAsync( - AttachmentPromptId, 1, AttachmentSystemV1, AttachmentUserV1, - "v1 — modular prompt with separate output and rules sections", BuildSections( output: AttachmentOutput, rules: AttachmentRules, commonRules: CommonRules)); + await EnsurePromptAsync( + AIPromptTypes.AttachmentSummary, + 2, + AttachmentSystemV2, + AttachmentUserV2, + BuildSections( + output: AttachmentOutputV2, + rules: AttachmentRulesV2, + commonRules: CommonRules)); } // ─── SCORESHEET ─────────────────────────────────────────────────────────── private async Task SeedScoresheetPromptAsync() { + await EnsurePromptAsync(AIPromptTypes.ApplicationScoring, 0, ScoresheetSystemV0, ScoresheetUserV0); await EnsurePromptAsync( - ScoresheetPromptId, AIPromptTypes.ApplicationScoring, - "Scoresheet section answering assistant"); - - await EnsureVersionAsync( - ScoresheetPromptId, - 0, - ScoresheetSystemV0, - ScoresheetUserV0, - "v0 — initial single-file scoresheet prompt"); - - await EnsureVersionAsync( - ScoresheetPromptId, 1, ScoresheetSystemV1, ScoresheetUserV1, - "v1 — modular prompt with separate output and rules sections", BuildSections( output: ScoresheetOutput, rules: ScoresheetRules, commonRules: CommonRules)); + await EnsurePromptAsync( + AIPromptTypes.ApplicationScoring, + 2, + ScoresheetSystemV2, + ScoresheetUserV2, + BuildSections( + output: ScoresheetOutputV2, + rules: ScoresheetRulesV2, + commonRules: CommonRules)); } // ─── HELPERS ────────────────────────────────────────────────────────────── @@ -137,50 +122,37 @@ private static string BuildSections( if (output != null) dict["OUTPUT"] = output; if (rules != null) dict["RULES"] = rules; if (commonRules != null) dict["COMMON_RULES"] = commonRules; - return JsonSerializer.Serialize(new { sections = dict }); + return JsonSerializer.Serialize(dict); } - private async Task EnsurePromptAsync(Guid promptId, string promptName, string? description) - { - var prompt = await promptRepository.FirstOrDefaultAsync(p => p.Id == promptId); - if (prompt != null) - { - return; - } - - await promptRepository.InsertAsync(new AIPrompt(promptId, promptName, PromptType.Skill) - { - Description = description, - IsActive = true - }); - } - - private async Task EnsureVersionAsync( - Guid promptId, + private async Task EnsurePromptAsync( + string promptName, int versionNumber, string systemPrompt, - string userPromptTemplate, - string developerNotes, + string userPrompt, string? metadataJson = null) { - var version = await versionRepository.FirstOrDefaultAsync( - v => v.PromptId == promptId && v.VersionNumber == versionNumber); - if (version != null) + var prompt = await promptRepository.FirstOrDefaultAsync( + p => p.Name == promptName && p.VersionNumber == versionNumber); + if (prompt != null) { + prompt.SystemPrompt = systemPrompt; + prompt.UserPrompt = userPrompt; + prompt.MetadataJson = metadataJson ?? "{}"; + prompt.IsActive = true; + await promptRepository.UpdateAsync(prompt, autoSave: true); return; } - await versionRepository.InsertAsync(new AIPromptVersion( + await promptRepository.InsertAsync(new AIPrompt( Guid.CreateVersion7(), - promptId, + promptName, versionNumber, systemPrompt, - userPromptTemplate) + userPrompt) { - DeveloperNotes = developerNotes, - IsPublished = true, - IsDeprecated = false, - MetadataJson = metadataJson + MetadataJson = metadataJson ?? "{}", + IsActive = true }); } @@ -393,26 +365,128 @@ 4. Return only the strongest evidence-backed reviewer conclusions. - Prefer, in order: direct evidence from DATA, specific supporting evidence from ATTACHMENTS, then broader context only when necessary. - Treat missing or empty values as findings only when they weaken rubric evidence. - Prefer material findings; avoid nitpicking. - - Do not restate basic application facts as findings unless they support a specific reviewer conclusion about readiness, feasibility, budget credibility, eligibility, or confidence in proceeding. - Prefer direct evidence from DATA over derivative statements in ATTACHMENTS when both address the same point. - If ATTACHMENTS evidence is used, cite the attachment by name in detail. - Each detail must cite concrete evidence from DATA or ATTACHMENTS. - Write reviewer-facing natural language. Do not refer to prompt section names, internal field keys, or schema labels such as DATA, ATTACHMENTS, ProjectSummary, CustomField1, or OrganizationType. - Refer to evidence by its plain-language meaning, quoted text, or attachment name rather than internal key names. - Only include warnings when the evidence shows a specific, concrete risk, inconsistency, or meaningful uncertainty; a stated risk label alone is not enough. - - Do not state that one amount exceeds, matches, or conflicts with another unless the comparison is directly supported by the provided values. - - Do not treat ordinary lack of detailed supporting explanation as a material gap unless the provided evidence creates real uncertainty about feasibility, eligibility, or budget credibility. - - Prefer neutral evidence descriptions over evaluative adjectives unless the evidence directly supports a strong conclusion. - - Do not describe capacity, feasibility, or justification as strong, detailed, or well-supported unless the evidence shows more than the existence of basic organizational, budget, or timeline information. - - Do not infer community support, established partnerships, or delivery capacity from a single partner reference, staff count, or basic organizational status alone. - - Do not describe a timeline as realistic or feasible based only on start and end dates unless additional evidence supports deliverability. - Use 3-6 words for title. - Summary titles should name the specific substantive reviewer conclusion, strength, or risk, not a generic evaluation label or abstract category. - Each detail must be 1-2 complete sentences. - Summaries and recommendations must be concrete, distinct, reviewer-relevant, and specific to this application's evidence. - Avoid generic praise, checklist language, and repeated conclusions across lists. - Do not use a summary merely to say that supporting documents were provided; summarize the specific substantive evidence they add, or omit the finding. - - If no findings exist, return empty arrays. + - Errors and warnings may be empty. + - Summaries and recommendations must each include at least one item. + - Decision must be PROCEED or HOLD. + - Use summaries for overall application quality/readiness synthesis. + - Use recommendations for concrete reviewer-facing next actions based on the provided evidence. + - Recommendations may include proceeding with the normal review process when the application appears ready for that step. + - When evidence shows a meaningful gap, inconsistency, or uncertainty, use recommendations for specific follow-up or verification actions. + - Return an empty array only when no concrete next action would help the reviewer. + """; + + // ── v2/analysis.system.txt ─────────────────────────────────────────────── + private const string AnalysisSystemV2 = """ + You are a careful grant review assistant for human reviewers. + Review the application and attachments for the strongest evidence-backed reviewer conclusions. + Do not fill gaps, assume compliance, or treat relevance as proof. + """; + + // ── v2/analysis.user.txt ───────────────────────────────────────────────── + private const string AnalysisUserV2 = """ + SCHEMA + {{SCHEMA}} + + DATA + {{DATA}} + + ATTACHMENTS + {{ATTACHMENTS}} + + RUBRIC + {{RUBRIC}} + + SCORE + {{SCORE}} + + RESPONSE + {{RESPONSE}} + + RULES + {{RULES}} + {{COMMON_RULES}} + """; + + // ── v2/analysis.rubric.txt ─────────────────────────────────────────────── + private const string AnalysisRubricV2 = """ + ELIGIBILITY REQUIREMENTS: Project aligns with program objectives; Applicant is an eligible entity; Budget is reasonable and justified; Timeline is realistic. + COMPLETENESS CHECKS: Required information is present; Supporting materials are provided where applicable; Description is clear. + FINANCIAL REVIEW: Requested amount is within limits; Budget matches scope; Matching funds or contributions are identified. + RISK ASSESSMENT: Applicant capacity; Feasibility; Compliance considerations; Delivery risks. + QUALITY INDICATORS: Clear objectives; Defined beneficiaries; Appropriate approach; Long-term sustainability. + """; + + // ── v2/analysis.score.txt ──────────────────────────────────────────────── + private const string AnalysisScoreV2 = """ + HIGH: Application demonstrates strong evidence across most rubric areas with few or no issues. + MEDIUM: Application has some gaps or weaknesses that require reviewer attention. + LOW: Application has significant gaps or risks across key rubric areas. + """; + + // ── v2/analysis.output.txt ─────────────────────────────────────────────── + private const string AnalysisOutputV2 = """ + { + "decision": "", + "errors": [ + { + "title": "", + "detail": "" + } + ], + "warnings": [ + { + "title": "", + "detail": "" + } + ], + "summaries": [ + { + "title": "", + "detail": "" + } + ], + "recommendations": [ + { + "title": "", + "detail": "" + } + ] + } + """; + + // ── v2/analysis.rules.txt ──────────────────────────────────────────────── + private const string AnalysisRulesV2 = """ + - Use only provided input sections as evidence. + - Do not invent fields, documents, requirements, or facts. + - Prefer, in order: direct evidence from DATA, specific supporting evidence from ATTACHMENTS, then broader context only when necessary. + - Treat missing or empty values as findings only when they weaken rubric evidence. + - Prefer material findings; avoid nitpicking. + - Prefer direct evidence from DATA over derivative statements in ATTACHMENTS when both address the same point. + - If ATTACHMENTS evidence is used, cite the attachment by name in detail. + - Each detail must cite concrete evidence from DATA or ATTACHMENTS. + - Write reviewer-facing natural language. Do not refer to prompt section names, internal field keys, or schema labels such as DATA, ATTACHMENTS, ProjectSummary, CustomField1, or OrganizationType. + - Refer to evidence by its plain-language meaning, quoted text, or attachment name rather than internal key names. + - Only include warnings when the evidence shows a specific, concrete risk, inconsistency, or meaningful uncertainty; a stated risk label alone is not enough. + - Use 3-6 words for title. + - Summary titles should name the specific substantive reviewer conclusion, strength, or risk, not a generic evaluation label or abstract category. + - Each detail must be 1-2 complete sentences. + - Summaries and recommendations must be concrete, distinct, reviewer-relevant, and specific to this application's evidence. + - Avoid generic praise, checklist language, and repeated conclusions across lists. + - Do not use a summary merely to say that supporting documents were provided; summarize the specific substantive evidence they add, or omit the finding. + - Errors and warnings may be empty. + - Summaries and recommendations must each include at least one item. - Decision must be PROCEED or HOLD. - Use summaries for overall application quality/readiness synthesis. - Use recommendations for concrete reviewer-facing next actions based on the provided evidence. @@ -490,6 +564,48 @@ 3. Return a concise reviewer-facing summary. - Return exactly one object with only the key: summary. """; + // ── v2/attachment.system.txt ───────────────────────────────────────────── + private const string AttachmentSystemV2 = """ + You are a careful grant review assistant for human reviewers. + Summarize the attachment itself, not the overall project. + Return a concise reviewer-facing summary. + """; + + // ── v2/attachment.user.txt ─────────────────────────────────────────────── + private const string AttachmentUserV2 = """ + ATTACHMENT + {{ATTACHMENT}} + + RESPONSE + {{RESPONSE}} + + RULES + {{RULES}} + {{COMMON_RULES}} + """; + + // ── v2/attachment.output.txt ───────────────────────────────────────────── + private const string AttachmentOutputV2 = """ + { + "summary": "" + } + """; + + // ── v2/attachment.rules.txt ────────────────────────────────────────────── + private const string AttachmentRulesV2 = """ + - Use only ATTACHMENT as evidence. + - Summarize actual content when ATTACHMENT.text is present; otherwise provide a conservative file-level summary. + - Describe the attachment itself rather than summarizing the overall project. + - Begin with what the attachment contains or provides, not the file name or file type, unless that metadata is necessary to describe the evidence. + - Do not invent missing details. + - Do not calculate or restate totals, sums, or aggregates unless they are explicitly present in ATTACHMENT.text. + - Write reviewer-facing natural language. Do not refer to prompt section names, internal field keys, or schema labels such as ATTACHMENT or ATTACHMENT.text. + - Refer to evidence by its plain-language meaning, quoted text, or file name rather than internal key names. + - Write 1-2 complete sentences. + - Summary must be grounded in concrete ATTACHMENT evidence. + - Return exactly one object with only the key: summary. + """; + // ── v0/scoresheet.system.txt ───────────────────────────────────────────── private const string ScoresheetSystemV0 = """ You are an expert grant application reviewer for the BC Government. @@ -518,14 +634,14 @@ Respond only with valid JSON in the exact format requested. For each question, provide: 1. The answer based on the application evidence 2. A brief rationale (1-2 complete sentences) citing concrete supporting evidence - 3. A confidence score from 0-100 (integer) indicating certainty in the selected answer + 3. A confidence score as a decimal fraction from 0.0 to 1.0. OUTPUT { - "": { + "": { "answer": "", "rationale": "", - "confidence": + "confidence": } } @@ -537,10 +653,64 @@ 2. A brief rationale (1-2 complete sentences) citing concrete supporting evidenc - answer type must match the question type. - For select list questions, return only the option number as a string, never label text. - rationale must be 1-2 complete sentences grounded in evidence. - - confidence must be an integer from 0 to 100 in increments of 5. + - confidence must be a decimal fraction from 0.0 to 1.0. - Return valid plain JSON only in the exact OUTPUT shape. """; + // ── v2/scoresheet.system.txt ───────────────────────────────────────────── + private const string ScoresheetSystemV2 = """ + You are a careful grant review assistant for human reviewers. + Answer each question in SECTION using only the provided DATA and ATTACHMENTS. + Choose the most conservative valid answer supported by the evidence. + If evidence is incomplete or indirect, explain the uncertainty in the rationale. + """; + + // ── v2/scoresheet.user.txt ─────────────────────────────────────────────── + private const string ScoresheetUserV2 = """ + DATA + {{DATA}} + + ATTACHMENTS + {{ATTACHMENTS}} + + SECTION + {{SECTION}} + + RESPONSE + {{RESPONSE}} + + RULES + {{RULES}} + {{COMMON_RULES}} + """; + + // ── v2/scoresheet.output.txt ───────────────────────────────────────────── + private const string ScoresheetOutputV2 = """ + { + "": { + "answer": "", + "rationale": "", + "confidence": + } + } + """; + + // ── v2/scoresheet.rules.txt ────────────────────────────────────────────── + private const string ScoresheetRulesV2 = """ + - Use only DATA and ATTACHMENTS as evidence. + - Do not invent missing application details. + - Prefer direct evidence of the exact condition asked. + - If evidence is insufficient, partial, indirect, missing, or non-specific, choose the most conservative valid answer and explain the uncertainty. + - Return exactly one answer object per question ID in SECTION.questions. + - Do not omit any question IDs from SECTION.questions. + - Do not add keys that are not question IDs from SECTION.questions. + - Use the exact question IDs from RESPONSE and SECTION.questions without alteration. + - Use RESPONSE as the output contract and fill every placeholder value. + - Each answer object must include: "answer", "rationale", and "confidence". + - Confidence is mandatory for every question and must always be a numeric decimal between 0.0 and 1.0. + - The "answer" value type must match question type: Number => numeric; YesNo/SelectList/Text/TextArea => string. + """; + // ── v1/scoresheet.system.txt ───────────────────────────────────────────── private const string ScoresheetSystemV1 = """ ROLE @@ -581,7 +751,7 @@ 4. Choose the most conservative valid answer supported by that evidence. "": { "answer": "", "rationale": "", - "confidence": + "confidence": } } """; diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Domain/AIModel.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Domain/AIModel.cs new file mode 100644 index 0000000000..511ba071ec --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Domain/AIModel.cs @@ -0,0 +1,24 @@ +using System; +using Volo.Abp.Domain.Entities.Auditing; + +namespace Unity.AI.Domain; + +public class AIModel : AuditedAggregateRoot +{ + public string Name { get; set; } = default!; + + public bool IsActive { get; set; } = true; + + /// Free-form model settings stored as JSON for dynamic runtime options. + public string SettingsJson { get; set; } = "{}"; + + protected AIModel() + { + } + + public AIModel(Guid id, string name) + { + Id = id; + Name = name; + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Domain/AIModelSettings.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Domain/AIModelSettings.cs new file mode 100644 index 0000000000..7a9c268eac --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Domain/AIModelSettings.cs @@ -0,0 +1,8 @@ +namespace Unity.AI.Domain; + +public class AIModelSettings +{ + public bool MaxOutputTokenCountSupported { get; set; } = true; + + public double? Temperature { get; set; } +} 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 new file mode 100644 index 0000000000..c4aa1f229f --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Domain/AIOperation.cs @@ -0,0 +1,36 @@ +using System; +using Unity.AI.Operations; +using Volo.Abp.Domain.Entities.Auditing; + +namespace Unity.AI.Domain; + +public class AIOperation : AuditedAggregateRoot +{ + public string Name { get; set; } = default!; + + public Guid AIModelId { get; set; } + + public AIModel AIModel { get; set; } = default!; + + public Guid AIPromptId { get; set; } + + public AIPrompt? AIPrompt { get; set; } + + public AIExecutionMode ExecutionMode { get; set; } = AIExecutionMode.Sequential; + + public int CompletionTokens { get; set; } + + public bool IsActive { get; set; } = true; + + protected AIOperation() + { + } + + public AIOperation(Guid id, string name, Guid aiModelId, Guid aiPromptId) + { + Id = id; + Name = name; + AIModelId = aiModelId; + AIPromptId = aiPromptId; + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Domain/AIPrompt.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Domain/AIPrompt.cs index 8aeb74d3d2..3ece069435 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Domain/AIPrompt.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Domain/AIPrompt.cs @@ -1,5 +1,4 @@ using System; -using System.Collections.Generic; using Volo.Abp.Domain.Entities.Auditing; using Volo.Abp.MultiTenancy; @@ -7,25 +6,36 @@ namespace Unity.AI.Domain; public class AIPrompt : AuditedAggregateRoot, IMultiTenant { - public virtual Guid? TenantId { get; protected set; } + public Guid? TenantId { get; protected set; } public string Name { get; set; } = default!; - public string? Description { get; set; } + public int VersionNumber { get; set; } - public PromptType Type { get; set; } + public string SystemPrompt { get; set; } = default!; - public bool IsActive { get; set; } = true; + public string UserPrompt { get; set; } = default!; + + public string MetadataJson { get; set; } = "{}"; - public ICollection Versions { get; set; } = new List(); + public bool IsActive { get; set; } = true; protected AIPrompt() { } - public AIPrompt(Guid id, string name, PromptType type, Guid? tenantId = null) + public AIPrompt( + Guid id, + string name, + int versionNumber, + string systemPrompt, + string userPrompt, + Guid? tenantId = null) { Id = id; Name = name; - Type = type; + VersionNumber = versionNumber; + SystemPrompt = systemPrompt; + UserPrompt = userPrompt; + MetadataJson = "{}"; TenantId = tenantId; IsActive = true; } diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Domain/AIPromptVersion.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Domain/AIPromptVersion.cs deleted file mode 100644 index 440aec6e0c..0000000000 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Domain/AIPromptVersion.cs +++ /dev/null @@ -1,50 +0,0 @@ -using System; -using Volo.Abp.Domain.Entities.Auditing; -using Volo.Abp.MultiTenancy; - -namespace Unity.AI.Domain; - -public class AIPromptVersion : AuditedAggregateRoot, IMultiTenant -{ - public virtual Guid? TenantId { get; protected set; } - - public Guid PromptId { get; set; } - public AIPrompt? Prompt { get; set; } - - public int VersionNumber { get; set; } - - public string SystemPrompt { get; set; } = default!; - public string UserPromptTemplate { get; set; } = default!; - public string? DeveloperNotes { get; set; } - - public string? TargetModel { get; set; } - public string? TargetProvider { get; set; } - - public double Temperature { get; set; } = 0.2; - public int? MaxTokens { get; set; } - - public bool IsPublished { get; set; } - public bool IsDeprecated { get; set; } - - /// Optional JSON metadata for extensibility (stored as Postgres jsonb). - public string? MetadataJson { get; set; } - - protected AIPromptVersion() { } - - public AIPromptVersion( - Guid id, - Guid promptId, - int versionNumber, - string systemPrompt, - string userPromptTemplate, - Guid? tenantId = null) - { - Id = id; - PromptId = promptId; - VersionNumber = versionNumber; - SystemPrompt = systemPrompt; - UserPromptTemplate = userPromptTemplate; - TenantId = tenantId; - Temperature = 0.2; - } -} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/EntityFrameworkCore/AIDbContextModelCreatingExtensions.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/EntityFrameworkCore/AIDbContextModelCreatingExtensions.cs index a23dae6f48..7184e09c74 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/EntityFrameworkCore/AIDbContextModelCreatingExtensions.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/EntityFrameworkCore/AIDbContextModelCreatingExtensions.cs @@ -21,41 +21,83 @@ public static void ConfigureAI(this ModelBuilder modelBuilder) .IsRequired() .HasMaxLength(200); - b.Property(x => x.Description) - .HasMaxLength(2000); + b.Property(x => x.VersionNumber) + .IsRequired(); + + b.Property(x => x.SystemPrompt) + .IsRequired() + .HasColumnType("text"); + + b.Property(x => x.UserPrompt) + .IsRequired() + .HasColumnType("text"); - b.Property(x => x.Type) + b.Property(x => x.MetadataJson) + .IsRequired() + .HasColumnType("jsonb") + .HasDefaultValue("{}"); + + b.Property(x => x.IsActive) .IsRequired(); - b.HasIndex(x => x.Name) + b.HasIndex(x => new { x.TenantId, x.Name, x.VersionNumber }) .IsUnique(); + }); + + modelBuilder.Entity(b => + { + b.ToTable(AIDbProperties.DbTablePrefix + "AIModels", AIDbProperties.DbSchema); + + b.ConfigureByConvention(); + + b.Property(x => x.Name) + .IsRequired() + .HasMaxLength(200); - b.HasMany(x => x.Versions) - .WithOne(x => x.Prompt) - .HasForeignKey(x => x.PromptId) - .OnDelete(DeleteBehavior.Cascade); + b.Property(x => x.IsActive) + .IsRequired(); + + b.Property(x => x.SettingsJson) + .IsRequired() + .HasColumnType("jsonb"); + + b.HasIndex(x => x.Name) + .IsUnique(); }); - modelBuilder.Entity(b => + modelBuilder.Entity(b => { - b.ToTable(AIDbProperties.DbTablePrefix + "AIPromptVersions", AIDbProperties.DbSchema); + b.ToTable(AIDbProperties.DbTablePrefix + "AIOperations", AIDbProperties.DbSchema); b.ConfigureByConvention(); - b.Property(x => x.SystemPrompt).IsRequired().HasColumnType("text"); - b.Property(x => x.UserPromptTemplate).IsRequired().HasColumnType("text"); + b.Property(x => x.Name) + .IsRequired() + .HasMaxLength(200); - b.Property(x => x.TargetModel) - .HasMaxLength(100); + b.Property(x => x.ExecutionMode) + .IsRequired() + .HasConversion() + .HasMaxLength(20); - b.Property(x => x.TargetProvider) - .HasMaxLength(100); + b.Property(x => x.CompletionTokens) + .IsRequired(); - b.Property(x => x.MetadataJson) - .HasColumnType("jsonb"); + b.Property(x => x.IsActive) + .IsRequired(); - b.HasIndex(x => new { x.PromptId, x.VersionNumber }) + b.HasIndex(x => x.Name) .IsUnique(); + + b.HasOne(x => x.AIModel) + .WithMany() + .HasForeignKey(x => x.AIModelId) + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne(x => x.AIPrompt) + .WithMany() + .HasForeignKey(x => x.AIPromptId) + .OnDelete(DeleteBehavior.Restrict); }); } } 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 d814818c6b..9645190884 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 @@ -13,12 +13,13 @@ using Unity.GrantManager.Attachments; using Unity.GrantManager.GrantApplications; using Volo.Abp.MultiTenancy; +using Volo.Abp; +using Volo.Abp.Features; namespace Unity.AI.Generation; [Route("api/app/ai/generation")] public class AIGenerationAppService( - IAttachmentSummaryService attachmentSummaryService, IApplicationAIGenerationQueue aiGenerationQueue, AIFeatureGuard featureGuard, ICurrentTenant currentTenant) @@ -37,12 +38,15 @@ await featureGuard.EnsureEnabledAsync( return []; } - var summaries = await attachmentSummaryService.GenerateForApplicationAsync( + await aiGenerationQueue.QueueAttachmentSummaryAsync( input.ApplicationId, + currentTenant.Id, input.PromptVersion, input.AttachmentIds); - return summaries.Select(_ => new AttachmentSummaryResultDto { Completed = true }).ToList(); + return input.AttachmentIds + .Select(_ => new AttachmentSummaryResultDto { Completed = false }) + .ToList(); } [Authorize(AIPermissions.Analysis.GenerateApplicationAnalysis)] @@ -68,19 +72,4 @@ await featureGuard.EnsureEnabledAsync( await aiGenerationQueue.QueueApplicationScoringAsync(applicationId, currentTenant.Id, promptVersion); return new ApplicationScoringResultDto { Completed = false }; } - - [Authorize(AIPermissions.Analysis.ViewAttachmentSummary)] - [Authorize(AIPermissions.Analysis.ViewApplicationAnalysis)] - [Authorize(AIPermissions.Analysis.ViewScoringResult)] - [HttpPost("all")] - public virtual async Task GenerateContentAsync(Guid applicationId, string? promptVersion = null) - { - await featureGuard.EnsureEnabledAsync(AIFeatures.AttachmentSummaries, AILocalizationKeys.GenerateAllDisabled); - await featureGuard.EnsureEnabledAsync(AIFeatures.ApplicationAnalysis, AILocalizationKeys.GenerateAllDisabled); - await featureGuard.EnsureEnabledAsync(AIFeatures.Scoring, AILocalizationKeys.GenerateAllDisabled); - - await aiGenerationQueue.QueueAllAIStagesAsync(applicationId, currentTenant.Id, promptVersion); - - return new ApplicationContentResultDto { Completed = true }; - } } diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Prompts/AIPromptAppService.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Prompts/AIPromptAppService.cs index e7f11e6c33..0d6c9b02f9 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Prompts/AIPromptAppService.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Prompts/AIPromptAppService.cs @@ -1,12 +1,17 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using System; +using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; using Unity.AI.Domain; using Unity.Modules.Shared.Permissions; +using Volo.Abp.Data; using Volo.Abp.Application.Dtos; using Volo.Abp.Application.Services; using Volo.Abp.Domain.Repositories; +using Volo.Abp.MultiTenancy; +using Volo.Abp; namespace Unity.AI.Prompts; @@ -21,9 +26,14 @@ public class AIPromptAppService : CreateUpdateAIPromptDto>, IAIPromptAppService { - public AIPromptAppService(IRepository repository) + private readonly IDataFilter _multiTenantDataFilter; + + public AIPromptAppService( + IRepository repository, + IDataFilter multiTenantDataFilter) : base(repository) { + _multiTenantDataFilter = multiTenantDataFilter; GetPolicyName = IdentityConsts.ITOperationsPolicyName; GetListPolicyName = IdentityConsts.ITOperationsPolicyName; CreatePolicyName = IdentityConsts.ITOperationsPolicyName; @@ -31,10 +41,23 @@ public AIPromptAppService(IRepository repository) DeletePolicyName = IdentityConsts.ITOperationsPolicyName; } + [HttpGet("by-prompt/{promptId}")] + public virtual async Task> GetByPromptAsync(Guid promptId) + { + using (_multiTenantDataFilter.Disable()) + { + var selected = await Repository.GetAsync(promptId); + var items = await Repository.GetListAsync(v => v.TenantId == selected.TenantId && v.Name == selected.Name); + var sorted = items.OrderBy(v => v.VersionNumber).ToList(); + return new ListResultDto( + ObjectMapper.Map, List>(sorted)); + } + } + [HttpGet("{id}")] public override async Task GetAsync(Guid id) { - using (CurrentTenant.Change(null)) + using (_multiTenantDataFilter.Disable()) { return await base.GetAsync(id); } @@ -43,7 +66,7 @@ public override async Task GetAsync(Guid id) [HttpGet] public override async Task> GetListAsync(PagedAndSortedResultRequestDto input) { - using (CurrentTenant.Change(null)) + using (_multiTenantDataFilter.Disable()) { return await base.GetListAsync(input); } @@ -52,25 +75,67 @@ public override async Task> GetListAsync(PagedAndSor [HttpPost] public override async Task CreateAsync(CreateUpdateAIPromptDto input) { - using (CurrentTenant.Change(null)) + using (_multiTenantDataFilter.Disable()) { - return await base.CreateAsync(input); + var prompt = await Repository.GetAsync(input.PromptId); + var existingVersion = await Repository.FirstOrDefaultAsync(p => + p.TenantId == prompt.TenantId && + p.Name == prompt.Name && + p.VersionNumber == input.VersionNumber); + if (existingVersion != null) + { + throw new UserFriendlyException( + $"AI prompt '{prompt.Name}' already has version {input.VersionNumber}."); + } + + var entity = await Repository.InsertAsync( + new AIPrompt( + Guid.CreateVersion7(), + prompt.Name, + input.VersionNumber, + input.SystemPrompt, + input.UserPrompt, + prompt.TenantId) + { + MetadataJson = string.IsNullOrWhiteSpace(input.MetadataJson) ? "{}" : input.MetadataJson, + IsActive = input.IsActive + }); + + return ObjectMapper.Map(entity); } } [HttpPut("{id}")] public override async Task UpdateAsync(Guid id, CreateUpdateAIPromptDto input) { - using (CurrentTenant.Change(null)) + using (_multiTenantDataFilter.Disable()) { - return await base.UpdateAsync(id, input); + var entity = await Repository.GetAsync(id); + var conflictingVersion = await Repository.FirstOrDefaultAsync(p => + p.Id != id && + p.TenantId == entity.TenantId && + p.Name == entity.Name && + p.VersionNumber == input.VersionNumber); + if (conflictingVersion != null) + { + throw new UserFriendlyException( + $"AI prompt '{entity.Name}' already has version {input.VersionNumber}."); + } + + entity.VersionNumber = input.VersionNumber; + entity.SystemPrompt = input.SystemPrompt; + entity.UserPrompt = input.UserPrompt; + entity.MetadataJson = string.IsNullOrWhiteSpace(input.MetadataJson) ? "{}" : input.MetadataJson; + entity.IsActive = input.IsActive; + entity = await Repository.UpdateAsync(entity); + return ObjectMapper.Map(entity); } } [HttpDelete("{id}")] public override async Task DeleteAsync(Guid id) { - using (CurrentTenant.Change(null)) + using (_multiTenantDataFilter.Disable()) { await base.DeleteAsync(id); } diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Prompts/AIPromptVersionAppService.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Prompts/AIPromptVersionAppService.cs deleted file mode 100644 index ff431bb7c6..0000000000 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Prompts/AIPromptVersionAppService.cs +++ /dev/null @@ -1,92 +0,0 @@ -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using Unity.AI.Domain; -using Unity.Modules.Shared.Permissions; -using Volo.Abp.Application.Dtos; -using Volo.Abp.Application.Services; -using Volo.Abp.Domain.Repositories; - -namespace Unity.AI.Prompts; - -[Authorize(IdentityConsts.ITOperationsPolicyName)] -[Route("api/app/ai/prompt-versions")] -public class AIPromptVersionAppService : - CrudAppService< - AIPromptVersion, - AIPromptVersionDto, - Guid, - PagedAndSortedResultRequestDto, - CreateUpdateAIPromptVersionDto>, - IAIPromptVersionAppService -{ - public AIPromptVersionAppService(IRepository repository) - : base(repository) - { - GetPolicyName = IdentityConsts.ITOperationsPolicyName; - GetListPolicyName = IdentityConsts.ITOperationsPolicyName; - CreatePolicyName = IdentityConsts.ITOperationsPolicyName; - UpdatePolicyName = IdentityConsts.ITOperationsPolicyName; - DeletePolicyName = IdentityConsts.ITOperationsPolicyName; - } - - [HttpGet("by-prompt/{promptId}")] - public async Task> GetByPromptAsync(Guid promptId) - { - using (CurrentTenant.Change(null)) - { - var items = await Repository.GetListAsync(v => v.PromptId == promptId); - var sorted = items.OrderBy(v => v.VersionNumber).ToList(); - return new ListResultDto( - ObjectMapper.Map, List>(sorted)); - } - } - - [HttpGet("{id}")] - public override async Task GetAsync(Guid id) - { - using (CurrentTenant.Change(null)) - { - return await base.GetAsync(id); - } - } - - [HttpGet] - public override async Task> GetListAsync(PagedAndSortedResultRequestDto input) - { - using (CurrentTenant.Change(null)) - { - return await base.GetListAsync(input); - } - } - - [HttpPost] - public override async Task CreateAsync(CreateUpdateAIPromptVersionDto input) - { - using (CurrentTenant.Change(null)) - { - return await base.CreateAsync(input); - } - } - - [HttpPut("{id}")] - public override async Task UpdateAsync(Guid id, CreateUpdateAIPromptVersionDto input) - { - using (CurrentTenant.Change(null)) - { - return await base.UpdateAsync(id, input); - } - } - - [HttpDelete("{id}")] - public override async Task DeleteAsync(Guid id) - { - using (CurrentTenant.Change(null)) - { - await base.DeleteAsync(id); - } - } -} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Settings/AIConfigurationAppService.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Settings/AIConfigurationAppService.cs index df2d32a605..f7bca0af75 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Settings/AIConfigurationAppService.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Settings/AIConfigurationAppService.cs @@ -18,6 +18,7 @@ public class AIConfigurationAppService( private readonly ISettingManager _settingManager = settingManager; private readonly ICurrentTenant _currentTenant = currentTenant; + [Authorize(AIPermissions.Configuration.ConfigureAI)] [HttpGet("tenant")] public virtual async Task GetTenantConfigurationAsync() { 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 aef66e58de..2c12bd19f1 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 @@ -34,19 +34,13 @@ "AIPrompt": "AI Prompt", "AIPromptVersion": "Prompt Version", "AIPromptVersions": "Prompt Versions", - "PromptType": "Type", "PromptName": "Name", - "PromptDescription": "Description", "PromptIsActive": "Active", "VersionNumber": "Version Number", "SystemPrompt": "System Prompt", - "UserPromptTemplate": "User Prompt Template", - "DeveloperNotes": "Developer Notes", - "TargetModel": "Target Model", - "TargetProvider": "Target Provider", + "UserPrompt": "User Prompt", + "MetadataJson": "Metadata JSON", "Temperature": "Temperature", - "MaxTokens": "Max Tokens", - "IsPublished": "Published", - "IsDeprecated": "Deprecated" + "MaxTokens": "Max Tokens" } } diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/Prompts/EditModal.cshtml.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/Prompts/EditModal.cshtml.cs index 1279b7a691..b99da8ecf8 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/Prompts/EditModal.cshtml.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/Prompts/EditModal.cshtml.cs @@ -27,9 +27,10 @@ public async Task OnGetAsync() var dto = await _promptAppService.GetAsync(Id); Prompt = new CreateUpdateAIPromptDto { - Name = dto.Name, - Description = dto.Description, - Type = dto.Type, + VersionNumber = dto.VersionNumber, + SystemPrompt = dto.SystemPrompt, + UserPrompt = dto.UserPrompt, + MetadataJson = dto.MetadataJson, IsActive = dto.IsActive }; } diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/Prompts/Versions/CreateVersionModal.cshtml b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/Prompts/Entries/CreateEntryModal.cshtml similarity index 70% rename from applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/Prompts/Versions/CreateVersionModal.cshtml rename to applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/Prompts/Entries/CreateEntryModal.cshtml index 38acee10e0..85da100816 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/Prompts/Versions/CreateVersionModal.cshtml +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/Prompts/Entries/CreateEntryModal.cshtml @@ -1,17 +1,17 @@ @page @using Unity.AI.Localization -@using Unity.AI.Web.Pages.Prompts.Versions +@using Unity.AI.Web.Pages.Prompts.Entries @using Microsoft.Extensions.Localization @using Volo.Abp.AspNetCore.Mvc.UI.Bootstrap.TagHelpers.Modal -@model CreateVersionModalModel +@model CreateEntryModalModel @inject IStringLocalizer L @{ Layout = null; } - + - + diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/Prompts/Entries/CreateEntryModal.cshtml.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/Prompts/Entries/CreateEntryModal.cshtml.cs new file mode 100644 index 0000000000..ee1e53654a --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/Prompts/Entries/CreateEntryModal.cshtml.cs @@ -0,0 +1,34 @@ +using Microsoft.AspNetCore.Mvc; +using System; +using System.Threading.Tasks; +using Unity.AI.Prompts; +using Volo.Abp.AspNetCore.Mvc.UI.RazorPages; + +namespace Unity.AI.Web.Pages.Prompts.Entries; + +public class CreateEntryModalModel : AbpPageModel +{ + [BindProperty] + public CreateUpdateAIPromptDto Prompt { get; set; } = new(); + + private readonly IAIPromptAppService _promptAppService; + + public CreateEntryModalModel(IAIPromptAppService promptAppService) + { + _promptAppService = promptAppService; + } + + public void OnGet(Guid promptId) + { + Prompt = new CreateUpdateAIPromptDto + { + PromptId = promptId + }; + } + + public async Task OnPostAsync() + { + await _promptAppService.CreateAsync(Prompt); + return NoContent(); + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/Prompts/Versions/EditVersionModal.cshtml b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/Prompts/Entries/EditEntryModal.cshtml similarity index 72% rename from applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/Prompts/Versions/EditVersionModal.cshtml rename to applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/Prompts/Entries/EditEntryModal.cshtml index 2f1e96ea4f..6ca56cf1c7 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/Prompts/Versions/EditVersionModal.cshtml +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/Prompts/Entries/EditEntryModal.cshtml @@ -1,17 +1,17 @@ @page @using Unity.AI.Localization -@using Unity.AI.Web.Pages.Prompts.Versions +@using Unity.AI.Web.Pages.Prompts.Entries @using Microsoft.Extensions.Localization @using Volo.Abp.AspNetCore.Mvc.UI.Bootstrap.TagHelpers.Modal -@model EditVersionModalModel +@model EditEntryModalModel @inject IStringLocalizer L @{ Layout = null; } - + - + diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/Prompts/Entries/EditEntryModal.cshtml.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/Prompts/Entries/EditEntryModal.cshtml.cs new file mode 100644 index 0000000000..b2f2a5dcc0 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/Prompts/Entries/EditEntryModal.cshtml.cs @@ -0,0 +1,44 @@ +using Microsoft.AspNetCore.Mvc; +using System; +using System.Threading.Tasks; +using Unity.AI.Prompts; +using Volo.Abp.AspNetCore.Mvc.UI.RazorPages; + +namespace Unity.AI.Web.Pages.Prompts.Entries; + +public class EditEntryModalModel : AbpPageModel +{ + [HiddenInput] + [BindProperty(SupportsGet = true)] + public Guid Id { get; set; } + + [BindProperty] + public CreateUpdateAIPromptDto Prompt { get; set; } = new(); + + private readonly IAIPromptAppService _promptAppService; + + public EditEntryModalModel(IAIPromptAppService promptAppService) + { + _promptAppService = promptAppService; + } + + public async Task OnGetAsync() + { + var dto = await _promptAppService.GetAsync(Id); + Prompt = new CreateUpdateAIPromptDto + { + PromptId = dto.Id, + VersionNumber = dto.VersionNumber, + SystemPrompt = dto.SystemPrompt, + UserPrompt = dto.UserPrompt, + MetadataJson = dto.MetadataJson, + IsActive = dto.IsActive + }; + } + + public async Task OnPostAsync() + { + await _promptAppService.UpdateAsync(Id, Prompt); + return NoContent(); + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/Prompts/Index.cshtml b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/Prompts/Index.cshtml index 0099f5cf0e..1b4d2bddd8 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/Prompts/Index.cshtml +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/Prompts/Index.cshtml @@ -67,36 +67,6 @@
-
- - -
-
- - -
- -
- - -
-
- - -
-
-
- - -
-
-
-
- - -
-
-
@@ -104,26 +74,28 @@
- - -
User Prompt Template is required.
+ + +
User Prompt is required.
-
- - +
+
+ + +
- + jsonb
- +
diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/Prompts/Index.js b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/Prompts/Index.js index 28062f58cf..07f1c714c1 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/Prompts/Index.js +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/Prompts/Index.js @@ -1,7 +1,7 @@ $(function () { const l = abp.localization.getResource('AI'); - // Prompt-level modals (create / edit prompt metadata only) + // Prompt-level modals (create / edit prompt rows) let createModal = new abp.ModalManager(abp.appPath + 'Prompts/CreateModal'); let editModal = new abp.ModalManager(abp.appPath + 'Prompts/EditModal'); @@ -19,27 +19,32 @@ $(function () { index: 0 }, { - title: l('PromptType'), - name: 'type', - data: 'type', + title: l('VersionNumber'), + name: 'versionNumber', + data: 'versionNumber', index: 1, - render: (data) => { - const types = ['Orchestrator', 'Skill', 'Instruction', 'Agent']; - return types[data] ?? data; - } }, { - title: l('PromptDescription'), - name: 'description', - data: 'description', + title: l('SystemPrompt'), + name: 'systemPrompt', + data: 'systemPrompt', index: 2, - defaultContent: '' + defaultContent: '', + render: (data) => (data ?? '').slice(0, 80) + }, + { + title: l('UserPrompt'), + name: 'userPrompt', + data: 'userPrompt', + index: 3, + defaultContent: '', + render: (data) => (data ?? '').slice(0, 80) }, { title: l('PromptIsActive'), name: 'isActive', data: 'isActive', - index: 3, + index: 4, render: (data) => data ? 'Active' : 'Inactive' @@ -50,7 +55,7 @@ $(function () { orderable: false, className: 'text-center', name: 'rowActions', - index: 4, + index: 5, rowAction: { items: [ { @@ -78,7 +83,7 @@ $(function () { } ]; - const defaultVisibleColumns = ['name', 'type', 'description', 'isActive', 'rowActions']; + const defaultVisibleColumns = ['name', 'versionNumber', 'systemPrompt', 'userPrompt', 'isActive', 'rowActions']; const dt = $('#AIPromptsTable'); const dataTable = initializeDataTable({ @@ -132,7 +137,7 @@ $(function () { } function loadVersions(promptId) { - unity.aI.prompts.aIPromptVersion.getByPrompt(promptId).then(function (result) { + unity.aI.prompts.aIPrompt.getByPrompt(promptId).then(function (result) { cachedVersions = result.items || []; const $select = $('#versionSelect'); $select.empty(); @@ -166,7 +171,7 @@ $(function () { populateVersionForm(v); } else { // fallback: fetch from server - unity.aI.prompts.aIPromptVersion.get(id).then(populateVersionForm); + unity.aI.prompts.aIPrompt.get(id).then(populateVersionForm); } }); @@ -177,15 +182,9 @@ $(function () { $('#versionId').val(v.id); $('#versionNumber').val(v.versionNumber); - $('#versionTargetModel').val(v.targetModel ?? ''); - $('#versionTargetProvider').val(v.targetProvider ?? ''); - $('#versionTemperature').val(v.temperature ?? 0.2); - $('#versionMaxTokens').val(v.maxTokens ?? ''); - $('#versionIsPublished').prop('checked', v.isPublished ?? false); - $('#versionIsDeprecated').prop('checked', v.isDeprecated ?? false); $('#versionSystemPrompt').val(v.systemPrompt ?? '').removeClass('is-invalid'); - $('#versionUserPromptTemplate').val(v.userPromptTemplate ?? '').removeClass('is-invalid'); - $('#versionDeveloperNotes').val(v.developerNotes ?? ''); + $('#versionUserPrompt').val(v.userPrompt ?? '').removeClass('is-invalid'); + $('#versionIsActive').prop('checked', v.isActive ?? true); // Pretty-print MetadataJson if valid let meta = v.metadataJson ?? ''; @@ -204,15 +203,9 @@ $(function () { currentVersionId = null; $('#versionId').val(''); - $('#versionTargetModel').val(''); - $('#versionTargetProvider').val(''); - $('#versionTemperature').val(0.2); - $('#versionMaxTokens').val(''); - $('#versionIsPublished').prop('checked', false); - $('#versionIsDeprecated').prop('checked', false); $('#versionSystemPrompt').val(''); - $('#versionUserPromptTemplate').val(''); - $('#versionDeveloperNotes').val(''); + $('#versionUserPrompt').val(''); + $('#versionIsActive').prop('checked', true); $('#versionMetadataJson').val(''); clearJsonError(); @@ -239,8 +232,8 @@ $(function () { if (!promptId) return; // Required-field validation - const systemPrompt = $('#versionSystemPrompt').val().trim(); - const userPromptTemplate = $('#versionUserPromptTemplate').val().trim(); + const systemPrompt = $('#versionSystemPrompt').val().trim(); + const userPrompt = $('#versionUserPrompt').val().trim(); let valid = true; if (systemPrompt) { $('#versionSystemPrompt').removeClass('is-invalid'); @@ -248,14 +241,14 @@ $(function () { $('#versionSystemPrompt').addClass('is-invalid'); valid = false; } - if (userPromptTemplate) { - $('#versionUserPromptTemplate').removeClass('is-invalid'); + if (userPrompt) { + $('#versionUserPrompt').removeClass('is-invalid'); } else { - $('#versionUserPromptTemplate').addClass('is-invalid'); + $('#versionUserPrompt').addClass('is-invalid'); valid = false; } if (!valid) { - $('#versionSystemPrompt.is-invalid, #versionUserPromptTemplate.is-invalid')[0]?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + $('#versionSystemPrompt.is-invalid, #versionUserPrompt.is-invalid')[0]?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); return; } @@ -263,25 +256,19 @@ $(function () { if (metaRaw && !validateJson(metaRaw)) return; const dto = { - promptId: promptId, - versionNumber: Number.parseInt($('#versionNumber').val()) || 0, - systemPrompt: systemPrompt, - userPromptTemplate: userPromptTemplate, - developerNotes: $('#versionDeveloperNotes').val() || null, - targetModel: $('#versionTargetModel').val() || null, - targetProvider: $('#versionTargetProvider').val() || null, - temperature: Number.parseFloat($('#versionTemperature').val()) || 0.2, - maxTokens: $('#versionMaxTokens').val() ? Number.parseInt($('#versionMaxTokens').val()) : null, - isPublished: $('#versionIsPublished').is(':checked'), - isDeprecated: $('#versionIsDeprecated').is(':checked'), - metadataJson: metaRaw || null + promptId: promptId, + versionNumber: Number.parseInt($('#versionNumber').val()) || 0, + systemPrompt: systemPrompt, + userPrompt: userPrompt, + metadataJson: metaRaw || null, + isActive: $('#versionIsActive').is(':checked') }; if (isNewVersion) { const newOpt = $('#versionSelect option[data-new]'); dto.versionNumber = newOpt.length ? Number.parseInt(newOpt.data('num')) : 0; - unity.aI.prompts.aIPromptVersion.create(dto) + unity.aI.prompts.aIPrompt.create(dto) .then(function () { abp.notify.success('Version created'); loadVersions(promptId); @@ -290,7 +277,7 @@ $(function () { abp.notify.error(err?.message || 'Failed to create version'); }); } else { - unity.aI.prompts.aIPromptVersion.update(currentVersionId, dto) + unity.aI.prompts.aIPrompt.update(currentVersionId, dto) .then(function () { abp.notify.success('Version saved'); loadVersions(promptId); diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/Prompts/Versions/CreateVersionModal.cshtml.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/Prompts/Versions/CreateVersionModal.cshtml.cs deleted file mode 100644 index ba49f50d0a..0000000000 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/Prompts/Versions/CreateVersionModal.cshtml.cs +++ /dev/null @@ -1,35 +0,0 @@ -using Microsoft.AspNetCore.Mvc; -using System; -using System.Threading.Tasks; -using Unity.AI.Prompts; -using Volo.Abp.AspNetCore.Mvc.UI.RazorPages; - -namespace Unity.AI.Web.Pages.Prompts.Versions; - -public class CreateVersionModalModel : AbpPageModel -{ - [BindProperty] - public CreateUpdateAIPromptVersionDto Version { get; set; } = new(); - - private readonly IAIPromptVersionAppService _versionAppService; - - public CreateVersionModalModel(IAIPromptVersionAppService versionAppService) - { - _versionAppService = versionAppService; - } - - public void OnGet(Guid promptId) - { - Version = new CreateUpdateAIPromptVersionDto - { - PromptId = promptId, - Temperature = 0.2 - }; - } - - public async Task OnPostAsync() - { - await _versionAppService.CreateAsync(Version); - return NoContent(); - } -} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/Prompts/Versions/EditVersionModal.cshtml.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/Prompts/Versions/EditVersionModal.cshtml.cs deleted file mode 100644 index 40390dd976..0000000000 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/Prompts/Versions/EditVersionModal.cshtml.cs +++ /dev/null @@ -1,50 +0,0 @@ -using Microsoft.AspNetCore.Mvc; -using System; -using System.Threading.Tasks; -using Unity.AI.Prompts; -using Volo.Abp.AspNetCore.Mvc.UI.RazorPages; - -namespace Unity.AI.Web.Pages.Prompts.Versions; - -public class EditVersionModalModel : AbpPageModel -{ - [HiddenInput] - [BindProperty(SupportsGet = true)] - public Guid Id { get; set; } - - [BindProperty] - public CreateUpdateAIPromptVersionDto Version { get; set; } = new(); - - private readonly IAIPromptVersionAppService _versionAppService; - - public EditVersionModalModel(IAIPromptVersionAppService versionAppService) - { - _versionAppService = versionAppService; - } - - public async Task OnGetAsync() - { - var dto = await _versionAppService.GetAsync(Id); - Version = new CreateUpdateAIPromptVersionDto - { - PromptId = dto.PromptId, - VersionNumber = dto.VersionNumber, - SystemPrompt = dto.SystemPrompt, - UserPromptTemplate = dto.UserPromptTemplate, - DeveloperNotes = dto.DeveloperNotes, - TargetModel = dto.TargetModel, - TargetProvider = dto.TargetProvider, - Temperature = dto.Temperature, - MaxTokens = dto.MaxTokens, - IsPublished = dto.IsPublished, - IsDeprecated = dto.IsDeprecated, - MetadataJson = dto.MetadataJson - }; - } - - public async Task OnPostAsync() - { - await _versionAppService.UpdateAsync(Id, Version); - return NoContent(); - } -} diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Pages/Notifications/Index.cshtml b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Pages/Notifications/Index.cshtml index 86f77e3eaf..4f20953652 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Pages/Notifications/Index.cshtml +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Pages/Notifications/Index.cshtml @@ -10,10 +10,6 @@ } -@section styles { - -} -
diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Pages/Notifications/Index.css b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Pages/Notifications/Index.css deleted file mode 100644 index 6a1faf3a05..0000000000 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Pages/Notifications/Index.css +++ /dev/null @@ -1,30 +0,0 @@ -/* The shared initializeDataTable helper enables scrollX, and layout.css forces - a permanent vertical scrollbar on the scroll body. Together DataTables bakes - ~25px of vertical-scrollbar reservation into the computed column widths, so - the inner table ends up wider than its container and renders a phantom - horizontal scrollbar even when there is free space. - - `width: 100%` alone does nothing here because the table is table-layout: auto, - where the columns' content widths win over the width hint. Switching the - inner head/body tables to table-layout: fixed makes width: 100% authoritative, - so the table matches the container exactly; over-long values (full email - addresses) wrap inside their column instead of forcing a horizontal scroll. - Restoring overflow-y: auto lets the vertical scrollbar appear only when rows - actually overflow, removing the reserved gutter when they fit. - - Scoped to this table so the intentionally wide, horizontally scrolling tables - elsewhere are unaffected. */ -#NotificationListTable_wrapper .dt-scroll-head .dt-scroll-headInner { - width: 100% !important; -} - -#NotificationListTable_wrapper .dt-scroll-body { - overflow-y: auto !important; -} - -#NotificationListTable_wrapper .dt-scroll-head .dt-scroll-headInner table, -#NotificationListTable_wrapper .dt-scroll-body table, -#NotificationListTable { - table-layout: fixed !important; - width: 100% !important; -} diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application.Contracts/Enums/PaymentRequestStatus.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application.Contracts/Enums/PaymentRequestStatus.cs index 9d0bd15462..63d6e4e348 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application.Contracts/Enums/PaymentRequestStatus.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application.Contracts/Enums/PaymentRequestStatus.cs @@ -19,5 +19,6 @@ public enum PaymentRequestStatus Failed = 11, FSB = 12, // Financial Services Branch - Prevent CAS Payment HistoricalPayment = 13, + Cancelled = 14, } } diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application.Contracts/PaymentRequests/IPaymentRequestAppService.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application.Contracts/PaymentRequests/IPaymentRequestAppService.cs index d4b8dd0de1..3040e3e8ba 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application.Contracts/PaymentRequests/IPaymentRequestAppService.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application.Contracts/PaymentRequests/IPaymentRequestAppService.cs @@ -25,5 +25,6 @@ public interface IPaymentRequestAppService : IApplicationService Task> GetPaymentPendingListByCorrelationIdsAsync(IEnumerable correlationIds); Task GetApplicationPaymentRollupAsync(Guid applicationId); Task> GetApplicationPaymentRollupBatchAsync(List applicationIds); + Task CancelAsync(Guid paymentRequestId); } } diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application.Contracts/PaymentRequests/PaymentRequestDto.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application.Contracts/PaymentRequests/PaymentRequestDto.cs index c959af41f9..ac0498a57f 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application.Contracts/PaymentRequests/PaymentRequestDto.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application.Contracts/PaymentRequests/PaymentRequestDto.cs @@ -51,6 +51,11 @@ public class PaymentRequestDto : AuditedEntityDto, IMultiTenant public string? Category { get; set; } public Guid? TenantId { get; set; } + // Cancellation tracking + public DateTime? CancelledOn { get; set; } + public Guid? CancelledById { get; set; } + public string? CancelledBy { get; set; } + public static explicit operator PaymentRequestDto(CreatePaymentRequestDto v) { throw new NotImplementedException(); diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/PaymentRequests/PaymentRequest.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/PaymentRequests/PaymentRequest.cs index 8a2f4d4314..4742e2178b 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/PaymentRequests/PaymentRequest.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/PaymentRequests/PaymentRequest.cs @@ -64,6 +64,11 @@ public class PaymentRequest : FullAuditedAggregateRoot, IMultiTenant, ICor public virtual DateTime? FsbNotificationSentDate { get; private set; } public virtual string? FsbApNotified { get; private set; } + // Cancellation tracking + public virtual DateTime? CancelledOn { get; private set; } + public virtual Guid? CancelledById { get; private set; } + public virtual string? CancelledBy { get; private set; } + protected PaymentRequest() { ExpenseApprovals = []; @@ -222,6 +227,14 @@ public PaymentRequest ClearFsbNotificationEmailLog() return this; } + public PaymentRequest SetCancellation(DateTime cancelledOn, Guid cancelledById, string cancelledBy) + { + CancelledOn = cancelledOn; + CancelledById = cancelledById; + CancelledBy = cancelledBy; + return this; + } + public PaymentRequest ValidatePaymentRequest() { if (Amount <= 0) diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Services/IPaymentsManager.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Services/IPaymentsManager.cs index bccb0910c3..a69beb5b69 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Services/IPaymentsManager.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Services/IPaymentsManager.cs @@ -1,5 +1,6 @@ using System; using System.Threading.Tasks; +using Unity.Payments.Domain.PaymentRequests; using Unity.Payments.Domain.Shared; namespace Unity.Payments.Domain.Services @@ -7,7 +8,8 @@ namespace Unity.Payments.Domain.Services public interface IPaymentsManager { Task UpdatePaymentStatusAsync(Guid paymentRequestId, PaymentApprovalAction triggerAction); + Task CancelPaymentAsync(Guid paymentRequestId); Task GetFormPreventPaymentStatusByPaymentRequestId(Guid paymentRequestId); - Task GetFormPreventPaymentStatusByApplicationId(Guid applicationId); + Task GetFormPreventPaymentStatusByApplicationId(Guid applicationId); } } diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Services/PaymentsManager.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Services/PaymentsManager.cs index b7413f3c9d..ddefff28db 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Services/PaymentsManager.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Services/PaymentsManager.cs @@ -49,10 +49,20 @@ private void ConfigureWorkflow(StateMachine HasPermissionAsync(PaymentsPermissions.Payments.L3ApproveOrDecline).GetAwaiter().GetResult()) - .PermitIf(PaymentApprovalAction.L3Decline, PaymentRequestStatus.L3Declined, () => HasPermissionAsync(PaymentsPermissions.Payments.L3ApproveOrDecline).GetAwaiter().GetResult()); + .PermitIf(PaymentApprovalAction.L3Decline, PaymentRequestStatus.L3Declined, () => HasPermissionAsync(PaymentsPermissions.Payments.L3ApproveOrDecline).GetAwaiter().GetResult()) + .PermitIf(PaymentApprovalAction.Cancel, PaymentRequestStatus.Cancelled, () => HasPermissionAsync(PaymentsPermissions.Payments.CancelPayment).GetAwaiter().GetResult()); paymentStateMachine.Configure(PaymentRequestStatus.L2Declined) .PermitIf(PaymentApprovalAction.Submit, PaymentRequestStatus.Submitted, () => HasPermissionAsync(PaymentsPermissions.Payments.L2ApproveOrDecline).GetAwaiter().GetResult()); + + paymentStateMachine.Configure(PaymentRequestStatus.L1Pending) + .PermitIf(PaymentApprovalAction.Cancel, PaymentRequestStatus.Cancelled, () => HasPermissionAsync(PaymentsPermissions.Payments.CancelPayment).GetAwaiter().GetResult()); + + paymentStateMachine.Configure(PaymentRequestStatus.L2Pending) + .PermitIf(PaymentApprovalAction.Cancel, PaymentRequestStatus.Cancelled, () => HasPermissionAsync(PaymentsPermissions.Payments.CancelPayment).GetAwaiter().GetResult()); + + paymentStateMachine.Configure(PaymentRequestStatus.HistoricalPayment) + .PermitIf(PaymentApprovalAction.Cancel, PaymentRequestStatus.Cancelled, () => HasPermissionAsync(PaymentsPermissions.Payments.CancelPayment).GetAwaiter().GetResult()); } private async Task HasPermissionAsync(string permission) @@ -194,5 +204,32 @@ public async Task UpdatePaymentStatusAsync(Guid paymentRequestId, PaymentApprova await uow.SaveChangesAsync(); } + + [Volo.Abp.Uow.UnitOfWork] + public virtual async Task CancelPaymentAsync(Guid paymentRequestId) + { + var paymentRequest = await paymentRequestRepository.GetAsync(paymentRequestId, true); + var isHistoricalPayment = paymentRequest.Status == PaymentRequestStatus.HistoricalPayment; + var statusChange = paymentRequest.Status; + + var workflow = new PaymentsWorkflow( + () => statusChange, s => statusChange = s, ConfigureWorkflow); + + await workflow.ExecuteActionAsync(PaymentApprovalAction.Cancel); + + paymentRequest.SetPaymentRequestStatus(PaymentRequestStatus.Cancelled); + paymentRequest.SetCancellation( + Clock.Now, + currentUser.GetId(), + $"{currentUser.Name} {currentUser.SurName}".Trim()); + + if (isHistoricalPayment) + { + paymentRequest.SetInvoiceStatus(CasPaymentRequestStatus.Cancelled); + paymentRequest.SetPaymentStatus(CasPaymentRequestStatus.NotPaid); + } + + return await paymentRequestRepository.UpdateAsync(paymentRequest); + } } } diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Shared/PaymentsAction.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Shared/PaymentsAction.cs index c449f1402e..26ad4fc611 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Shared/PaymentsAction.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Shared/PaymentsAction.cs @@ -24,5 +24,6 @@ public enum PaymentApprovalAction L2Decline, L3Approve, L3Decline, - Submit + Submit, + Cancel } diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/EntityFrameworkCore/PaymentsDbContextModelCreatingExtensions.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/EntityFrameworkCore/PaymentsDbContextModelCreatingExtensions.cs index 1953c6caf4..2594d6aef9 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/EntityFrameworkCore/PaymentsDbContextModelCreatingExtensions.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/EntityFrameworkCore/PaymentsDbContextModelCreatingExtensions.cs @@ -55,6 +55,11 @@ public static void ConfigurePayments( b.Property(x => x.FsbNotificationSentDate).IsRequired(false); b.Property(x => x.FsbApNotified).IsRequired(false).HasMaxLength(10); b.HasIndex(x => x.FsbNotificationEmailLogId); + + // Cancellation tracking + b.Property(x => x.CancelledOn).HasColumnName("CancelledOn").IsRequired(false); + b.Property(x => x.CancelledById).HasColumnName("CancelledById").IsRequired(false); + b.Property(x => x.CancelledBy).HasColumnName("CancelledBy").HasMaxLength(256).IsRequired(false); }); diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/EntityFrameworkCore/Repositories/PaymentRequestRepository.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/EntityFrameworkCore/Repositories/PaymentRequestRepository.cs index 98ac8546ee..4910b5e003 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/EntityFrameworkCore/Repositories/PaymentRequestRepository.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/EntityFrameworkCore/Repositories/PaymentRequestRepository.cs @@ -58,6 +58,7 @@ public async Task GetTotalPaymentRequestAmountByCorrelationIdAsync(Guid .Where(p => p.Status != PaymentRequestStatus.L1Declined && p.Status != PaymentRequestStatus.L2Declined && p.Status != PaymentRequestStatus.L3Declined + && p.Status != PaymentRequestStatus.Cancelled && p.InvoiceStatus != CasPaymentRequestStatus.Cancelled && p.InvoiceStatus != CasPaymentRequestStatus.NotFound && p.InvoiceStatus != CasPaymentRequestStatus.ErrorFromCas) diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/PaymentRequestAppService.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/PaymentRequestAppService.cs index 58a7ae9520..df3bde3fee 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/PaymentRequestAppService.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/PaymentRequestAppService.cs @@ -143,7 +143,10 @@ private static PaymentRequestDto MapToPaymentRequestDto(PaymentRequest result) CreationTime = result.CreationTime, Status = result.Status, ReferenceNumber = result.ReferenceNumber, - SubmissionConfirmationCode = result.SubmissionConfirmationCode + SubmissionConfirmationCode = result.SubmissionConfirmationCode, + CancelledOn = result.CancelledOn, + CancelledById = result.CancelledById, + CancelledBy = result.CancelledBy }; } @@ -425,5 +428,28 @@ public async Task> GetApplicationP var childApplicationIdsByParent = await applicationLinksService.Value.GetChildApplicationIdsByParentIdsAsync(applicationIds); return await paymentRequestQueryManager.GetApplicationPaymentRollupBatchAsync(applicationIds, childApplicationIdsByParent); } + + [Authorize(PaymentsPermissions.Payments.CancelPayment)] + public virtual async Task CancelAsync(Guid paymentRequestId) + { + var payment = await paymentRequestQueryManager.GetPaymentRequestByIdAsync(paymentRequestId) + ?? throw new BusinessException("Payments:PaymentRequestNotFound") + .WithData("Id", paymentRequestId); + + PaymentRequestStatus[] eligibleStatuses = + [ + PaymentRequestStatus.HistoricalPayment, + PaymentRequestStatus.L1Pending, + PaymentRequestStatus.L2Pending, + PaymentRequestStatus.L3Pending + ]; + + if (!eligibleStatuses.Contains(payment.Status)) + throw new BusinessException("Payments:CancellationNotAllowed") + .WithData("Status", payment.Status.ToString()); + + var result = await paymentsManager.CancelPaymentAsync(paymentRequestId); + return MapToPaymentRequestDto(result); + } } } diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Permissions/PaymentsPermissionDefinitionProvider.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Permissions/PaymentsPermissionDefinitionProvider.cs index e4186e3b76..9dd29990d0 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Permissions/PaymentsPermissionDefinitionProvider.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Permissions/PaymentsPermissionDefinitionProvider.cs @@ -20,6 +20,7 @@ public override void Define(IPermissionDefinitionContext context) paymentsPermissions.AddChild(PaymentsPermissions.Payments.RequestPayment, L("Permission:Payments.RequestPayment")); paymentsPermissions.AddChild(PaymentsPermissions.Payments.AccountCodingOverride, L("Permission:Payments.AccountCodingOverride")); paymentsPermissions.AddChild(PaymentsPermissions.Payments.AddHistoricalPayment, L("Permission:Payments.AddHistoricalPayment")); + paymentsPermissions.AddChild(PaymentsPermissions.Payments.CancelPayment, L("Permission:Payments.CancelPayment")); //-- PAYMENT INFO PERMISSIONS grantApplicationPermissionsGroup.Add_PaymentInfo_Permissions(); diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Shared/Localization/Payments/en.json b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Shared/Localization/Payments/en.json index 4dcdee5d03..ff25b18a72 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Shared/Localization/Payments/en.json +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Shared/Localization/Payments/en.json @@ -130,6 +130,8 @@ "Permission:Payments.AccountCodingOverride": "Override Account Coding", "Permission:Payments.EditFormPaymentConfiguration": "Edit Form Payment Configuration", "Permission:Payments.AddHistoricalPayment": "Add Historical Payment", + "Permission:Payments.CancelPayment": "Cancel Payment", + "Enum:PaymentRequestStatus.Cancelled": "Cancelled", "Enum:PaymentRequestStatus.L1Pending": "L1 Pending", "Enum:PaymentRequestStatus.L1Approved": "L1 Approved", "Enum:PaymentRequestStatus.L1Declined": "L1 Declined", diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Shared/Permissions/PaymentsPermissions.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Shared/Permissions/PaymentsPermissions.cs index a0b470a799..efd2fb0af4 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Shared/Permissions/PaymentsPermissions.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Shared/Permissions/PaymentsPermissions.cs @@ -18,6 +18,7 @@ public static class Payments public const string EditSupplierInfo = Default + ".EditSupplierInfo"; public const string EditFormPaymentConfiguration = Default + ".EditFormPaymentConfiguration"; public const string AddHistoricalPayment = Default + ".AddHistoricalPayment"; + public const string CancelPayment = Default + ".CancelPayment"; } public static string[] GetAll() diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/Index.js b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/Index.js index f07a18be78..ab6f6489c7 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/Index.js +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/Index.js @@ -28,8 +28,7 @@ $(function () { 'l2ApprovalDate', 'l3ApprovalDate', 'CASResponse', - 'accountCodingDisplay', - 'category' + 'accountCodingDisplay' ]; let initialLoad = true; let isRestoringState = false; @@ -91,6 +90,7 @@ $(function () { payment_approve_buttons.disable(); payment_check_status_buttons.disable(); history_button.disable(); + if (cancel_button) cancel_button.disable(); selectedPaymentIds = []; PubSub.publish("deselect_batchpayment_application", "reset_data"); }) @@ -100,6 +100,9 @@ $(function () { { text: 'Approve', className: 'custom-table-btn flex-none btn btn-secondary payment-status', + attr: { + 'data-selector': 'batch-payment-table-actions' + }, action: function (e, dt, node, config) { // Store payment IDs in distributed cache to avoid URL length limits unity.payments.paymentRequests.paymentBulkActions @@ -120,6 +123,9 @@ $(function () { { text: 'Decline', className: 'custom-table-btn flex-none btn btn-secondary payment-status', + attr: { + 'data-selector': 'batch-payment-table-actions' + }, action: function (e, dt, node, config) { // Store payment IDs in distributed cache to avoid URL length limits unity.payments.paymentRequests.paymentBulkActions @@ -137,9 +143,44 @@ $(function () { }); } }, + ...(abp.auth.isGranted('PaymentsPermissions.Payments.CancelPayment') ? [{ + text: 'Cancel', + className: 'custom-table-btn flex-none btn btn-secondary payment-cancel', + action: function (e, dt, node, config) { + if (selectedPaymentIds?.length !== 1) return; + const rowData = dt.rows({ selected: true }).data().toArray()[0]; + abp.message.confirm( + `Are you sure you want to cancel the payment: "${rowData.referenceNumber}"?`, + 'Cancel Payment', + function (confirmed) { + if (!confirmed) return; + unity.payments.paymentRequests.paymentRequest + .cancel(selectedPaymentIds[0]) + .then(function () { + abp.notify.success('Payment has been cancelled successfully.'); + $(".select-all-payments").prop("checked", false); + payment_approve_buttons.disable(); + payment_check_status_buttons.disable(); + history_button.disable(); + if (cancel_button) cancel_button.disable(); + selectedPaymentIds = []; + PubSub.publish("deselect_batchpayment_application", "reset_data"); + dataTable.ajax.reload(null, false); + }) + .catch(function (err) { + abp.notify.error('Failed to cancel payment. Please try again.'); + console.warn('Cancel payment error:', err); + }); + } + ); + } + }] : []), { text: 'History', className: 'custom-table-btn flex-none btn btn-secondary history', + attr: { + 'data-selector': 'batch-payment-table-actions' + }, action: function (e, dt, node, config) { location.href = '/PaymentHistory/Details?PaymentId=' + selectedPaymentIds[0]; } @@ -323,7 +364,9 @@ $(function () { dtApi.ajax.reload(null, false); } initialLoad = false; - } + }, + enableContextMenu: true, + contextMenuActionsSelector: '[data-selector="batch-payment-table-actions"]' }); $('.grp-savedStates').text('Save View'); @@ -366,10 +409,14 @@ $(function () { let payment_approve_buttons = dataTable.buttons(['.payment-status']); let payment_check_status_buttons = dataTable.buttons(['.payment-check-status']); let history_button = dataTable.buttons(['.history']); + let cancel_button = abp.auth.isGranted('PaymentsPermissions.Payments.CancelPayment') + ? dataTable.buttons(['.payment-cancel']) + : null; payment_approve_buttons.disable(); payment_check_status_buttons.disable(); history_button.disable(); + if (cancel_button) cancel_button.disable(); dataTable.on('search.dt', () => handleSearch()); function checkAllRowsHaveState(states) { @@ -452,21 +499,32 @@ $(function () { payment_check_status_buttons.disable(); } let hasHistoricalPayment = dataTable.rows('.selected').data().toArray().some(row => row.status === 'HistoricalPayment'); - if (dataTable.rows({ selected: true }).indexes().length > 0 && !isInSentState && !hasHistoricalPayment) { - if (abp.auth.isGranted('PaymentsPermissions.Payments.L1ApproveOrDecline') + let hasCancelledPayment = dataTable.rows('.selected').data().toArray().some(row => row.status === 'Cancelled'); + const hasSelection = dataTable.rows({ selected: true }).indexes().length > 0; + const canApprove = hasSelection && !isInSentState && !hasHistoricalPayment && !hasCancelledPayment + && (abp.auth.isGranted('PaymentsPermissions.Payments.L1ApproveOrDecline') || abp.auth.isGranted('PaymentsPermissions.Payments.L2ApproveOrDecline') - || abp.auth.isGranted('PaymentsPermissions.Payments.L3ApproveOrDecline')) { - payment_approve_buttons.enable(); - + || abp.auth.isGranted('PaymentsPermissions.Payments.L3ApproveOrDecline')); + if (canApprove) { + payment_approve_buttons.enable(); + } else { + payment_approve_buttons.disable(); + } + checkEnableHistoryButton(dataTable, history_button); + + if (cancel_button) { + const eligibleCancelStatuses = ['HistoricalPayment', 'L1Pending', 'L2Pending', 'L3Pending']; + const selectedCount = dataTable.rows({ selected: true }).indexes().length; + if (selectedCount === 1) { + const rowData = dataTable.rows({ selected: true }).data().toArray()[0]; + if (eligibleCancelStatuses.includes(rowData.status)) { + cancel_button.enable(); + } else { + cancel_button.disable(); + } } else { - payment_approve_buttons.disable(); + cancel_button.disable(); } - - checkEnableHistoryButton(dataTable, history_button); - } - else { - payment_approve_buttons.disable(); - checkEnableHistoryButton(dataTable, history_button); } } @@ -517,6 +575,9 @@ $(function () { getNoteColumn(columnIndex++), getAccountDistributionColumn(columnIndex++), getFsbNotifiedColumn(columnIndex++), + getCancelledColumn(columnIndex++), + getCancelledByColumn(columnIndex++), + getCancelledOnColumn(columnIndex++), ] return columns.map((column) => ({ ...column, targets: [column.index], orderData: [column.index, 0] })); @@ -994,6 +1055,7 @@ $(function () { payment_approve_buttons.disable(); payment_check_status_buttons.disable(); history_button.disable(); + if (cancel_button) cancel_button.disable(); selectedPaymentIds = []; PubSub.publish("deselect_batchpayment_application", "reset_data"); }); @@ -1028,6 +1090,9 @@ $(function () { case "Failed": return "#CE3E39"; + case "Cancelled": + return "#6c757d"; + default: return "#053662"; } @@ -1050,6 +1115,7 @@ $(function () { payment_approve_buttons.disable(); payment_check_status_buttons.disable(); history_button.disable(); + if (cancel_button) cancel_button.disable(); selectedPaymentIds = []; PubSub.publish("deselect_batchpayment_application", "reset_data"); PubSub.publish('clear_selected_payment'); @@ -1058,6 +1124,45 @@ $(function () { }); +function getCancelledColumn(columnIndex) { + return { + title: 'Cancelled', + name: 'cancelled', + data: null, + className: 'data-table-header', + index: columnIndex, + render: function (data, type, row) { + return row.status === 'Cancelled' ? 'Cancelled' : ''; + } + }; +} + +function getCancelledByColumn(columnIndex) { + return { + title: 'Cancelled By', + name: 'cancelledBy', + data: 'cancelledBy', + className: 'data-table-header', + index: columnIndex, + render: function (data) { + return data ?? ''; + } + }; +} + +function getCancelledOnColumn(columnIndex) { + return { + title: 'Cancelled On', + name: 'cancelledOn', + data: 'cancelledOn', + className: 'data-table-header', + index: columnIndex, + render: function (data, type) { + return DateUtils.formatUtcDateToLocal(data, type); + } + }; +} + let casPaymentResponseModal = new abp.ModalManager({ viewUrl: '../PaymentRequests/CasPaymentRequestResponse' }); diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/TenantAppService.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/TenantAppService.cs index 752f20b807..3e5bed3287 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/TenantAppService.cs +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/TenantAppService.cs @@ -91,10 +91,11 @@ public virtual async Task> GetListAsync(GetTenantsInpu } // In-memory path: needed when filtering on ExtraProperties or sorting on ExtraProperties + // Keep native name filtering in SQL and only layer ExtraProperties matching on top. var dbSorting = dbSortFields.Contains(sortField) ? input.Sorting : nameof(Tenant.Name); - var allTenants = await tenantRepository.GetListAsync(dbSorting, int.MaxValue, 0, null); + var filteredTenants = await tenantRepository.GetListAsync(dbSorting, int.MaxValue, 0, input.Filter); - IEnumerable result = allTenants; + IEnumerable result = filteredTenants; // Apply ExtraProperties filter if (hasFilter) diff --git a/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/Bundling/UnityThemeUX2GlobalScriptContributor.cs b/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/Bundling/UnityThemeUX2GlobalScriptContributor.cs index ac4c3a644e..836c089f1f 100644 --- a/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/Bundling/UnityThemeUX2GlobalScriptContributor.cs +++ b/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/Bundling/UnityThemeUX2GlobalScriptContributor.cs @@ -48,7 +48,8 @@ public override void ConfigureBundle(BundleConfigurationContext context) context.Files.Add("/themes/ux2/layout.js"); context.Files.Add("/themes/ux2/plugins/filterRow.js"); context.Files.Add("/themes/ux2/plugins/scrollResize.js"); - context.Files.Add("/themes/ux2/plugins/colvisAlpha.js"); + context.Files.Add("/themes/ux2/plugins/colvisAlpha.js"); + context.Files.Add("/themes/ux2/plugins/tableContextMenu.js"); context.Files.Add("/themes/ux2/table-utils.js"); context.Files.Add("/themes/ux2/json-editor.js"); context.Files.Add("/js/DateUtils.js"); diff --git a/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/Bundling/UnityThemeUX2GlobalStyleContributor.cs b/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/Bundling/UnityThemeUX2GlobalStyleContributor.cs index a1beefb47f..8564213d1f 100644 --- a/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/Bundling/UnityThemeUX2GlobalStyleContributor.cs +++ b/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/Bundling/UnityThemeUX2GlobalStyleContributor.cs @@ -12,6 +12,7 @@ public override void ConfigureBundle(BundleConfigurationContext context) context.Files.Add("/themes/ux2/fluenticons.min.css"); context.Files.Add("/themes/ux2/layout.css"); context.Files.Add("/themes/ux2/unity-styles.css"); + context.Files.Add("/themes/ux2/plugins/tableContextMenu.css"); context.Files.Add("/themes/ux2/json-editor.css"); context.Files.AddIfNotContains("/libs/datatables.net-bs5/css/dataTables.bootstrap5.min.css"); diff --git a/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/wwwroot/themes/ux2/plugins/tableContextMenu.css b/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/wwwroot/themes/ux2/plugins/tableContextMenu.css new file mode 100644 index 0000000000..b018118eb9 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/wwwroot/themes/ux2/plugins/tableContextMenu.css @@ -0,0 +1,54 @@ +/* DataTable Context Menu Styling */ + +#dt-context-menu { + position: fixed; + min-width: 200px; + max-width: calc(100vw - 1rem); + max-height: calc(100vh - 1rem); + overflow-y: auto; + padding: 0.5rem; + margin: 0; + list-style: none; + background-color: var(--bs-body-bg, #fff); + border: var(--bs-border-width, 1px) solid var(--bs-border-color, #dee2e6); + border-radius: var(--bs-border-radius, 0.5rem); + box-shadow: var(--bs-box-shadow, 0 0.5rem 1rem rgba(0, 0, 0, 0.15)); + z-index: 10000; +} + +.dt-context-menu-item { + padding: 0; + margin: 0; +} + +.dt-context-menu-link { + display: block; + padding: 0.5rem 1rem; + text-decoration: none; + white-space: nowrap; + cursor: pointer; + user-select: none; + font-size: 0.875rem; + border: 2px solid transparent; + border-radius: var(--bs-border-radius, 0.5rem); + transition: background-color 0.15s ease-in-out, color 0.15s ease-in-out, border-color 0.15s ease-in-out; +} + +.dt-context-menu-link:hover, +.dt-context-menu-link:focus-visible { + color: var(--bc-colors-blue-primary, #2E5DD7); + outline: none; + border: 2px solid var(--bc-colors-blue-primary, #2E5DD7); +} + +.dt-context-menu-link:active { + color: var(--bs-body-bg, #fff); + background-color: var(--bc-colors-blue-primary, #2E5DD7); +} + +.dt-context-menu-separator { + height: 0; + margin: 0.5rem 0; + padding: 0; + border-top: var(--bs-border-width, 1px) solid var(--bs-border-color, #dee2e6); +} diff --git a/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/wwwroot/themes/ux2/plugins/tableContextMenu.js b/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/wwwroot/themes/ux2/plugins/tableContextMenu.js new file mode 100644 index 0000000000..7325d43fc2 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/wwwroot/themes/ux2/plugins/tableContextMenu.js @@ -0,0 +1,593 @@ +(function ($) { + 'use strict'; + + // Constants + const MENU_Z_INDEX = 10000; + const MENU_VIEWPORT_PADDING = 8; + const OFFSCREEN_OFFSET = '-9999px'; + const MENU_ID = 'dt-context-menu'; + + function getTableSettings(dtApi) { + return dtApi?.settings?.()?.[0] ?? null; + } + + function getFilterRowPlugin(dtApi) { + return getTableSettings(dtApi)?._filterRow ?? null; + } + + function getFilterRowElement(dtApi) { + return getFilterRowPlugin(dtApi)?.dom?.filterRow ?? $(); + } + + function getFilterInputs(dtApi) { + return getFilterRowElement(dtApi).find('input.custom-filter-input'); + } + + function getButtonsForTable(dtApi) { + return $(dtApi?.buttons?.().nodes?.() ?? []); + } + + function getFilterButton(dtApi) { + // Try to find filter button through table's button API first + const $tableButtons = getButtonsForTable(dtApi); + if ($tableButtons.length > 0) { + const $filterFromAPI = $tableButtons.filter('[id="btn-toggle-filter"]'); + if ($filterFromAPI.length > 0) { + return $filterFromAPI; + } + } + + // Fallback: search the table's scope or page-wide + const $scopeRoot = getScopeRoot(dtApi); + const $filterScoped = $scopeRoot.find('#btn-toggle-filter'); + if ($filterScoped.length > 0) { + return $filterScoped; + } + + // Final fallback: page-wide search + return $('#btn-toggle-filter'); + } + + function getScopeRoot(dtApi) { + const $container = $(dtApi?.table?.().container?.() ?? []); + return $container.closest('.tab-pane, .modal, .card, .content, body').first(); + } + + function findScopedElements(dtApi, selector) { + const $scopeRoot = getScopeRoot(dtApi); + const $scopedMatches = $scopeRoot.find(selector); + return $scopedMatches.length > 0 ? $scopedMatches : $(selector); + } + + function getMenuContainer() { + return $('#' + MENU_ID); + } + + function getMenuItems($menuContainer) { + return $menuContainer.find('.dt-context-menu-link:visible'); + } + + function focusMenuItem($menuContainer, index) { + const $items = getMenuItems($menuContainer); + if ($items.length === 0) { + return; + } + + const normalizedIndex = ((index % $items.length) + $items.length) % $items.length; + $items.attr('tabindex', '-1'); + + const $target = $items.eq(normalizedIndex); + $target.attr('tabindex', '0').trigger('focus'); + $menuContainer.data('activeIndex', normalizedIndex); + } + + function focusFirstMenuItem($menuContainer) { + focusMenuItem($menuContainer, 0); + } + + function rememberFocusTarget(element) { + const $menuContainer = getMenuContainer(); + $menuContainer.data('returnFocus', element ?? null); + } + + function restoreFocus() { + const $menuContainer = getMenuContainer(); + const returnFocus = $menuContainer.data('returnFocus'); + if (!returnFocus || typeof returnFocus.focus !== 'function') { + return; + } + + const hadTabIndex = returnFocus.hasAttribute('tabindex'); + if (!hadTabIndex) { + returnFocus.setAttribute('tabindex', '-1'); + } + + returnFocus.focus(); + + if (!hadTabIndex) { + returnFocus.addEventListener('blur', function cleanupFocusTarget() { + returnFocus.removeAttribute('tabindex'); + }, { once: true }); + } + } + + function handleMenuKeydown(e) { + const $menuContainer = getMenuContainer(); + const $items = getMenuItems($menuContainer); + const currentIndex = $items.index(globalThis.document.activeElement); + + if ($items.length === 0) { + return; + } + + switch (e.key) { + case 'ArrowDown': + e.preventDefault(); + focusMenuItem($menuContainer, currentIndex + 1); + break; + case 'ArrowUp': + e.preventDefault(); + focusMenuItem($menuContainer, currentIndex - 1); + break; + case 'Home': + e.preventDefault(); + focusMenuItem($menuContainer, 0); + break; + case 'End': + e.preventDefault(); + focusMenuItem($menuContainer, $items.length - 1); + break; + case 'Tab': + hideMenu(); + break; + case ' ': + if (currentIndex > -1) { + e.preventDefault(); + $items.eq(currentIndex).trigger('click'); + } + break; + case 'Escape': + e.preventDefault(); + hideMenu(); + break; + default: + break; + } + } + + function appendMenuAction($menuContainer, label, handler) { + $menuContainer.append( + $('
  • ').append( + $('') + .text(label) + .on('click', handler) + .on('mouseover', function () { + getMenuItems($menuContainer).blur(); // When mouse enters an item, remove focus from all items so :hover takes precedence + }) + ) + ); + } + + function positionMenu($menuContainer, clientX, clientY) { + $menuContainer.css({ + position: 'fixed', + display: 'block', + visibility: 'hidden', + zIndex: MENU_Z_INDEX + }); + + const menuWidth = $menuContainer.outerWidth() ?? 0; + const menuHeight = $menuContainer.outerHeight() ?? 0; + const maxLeft = Math.max(MENU_VIEWPORT_PADDING, globalThis.innerWidth - menuWidth - MENU_VIEWPORT_PADDING); + const maxTop = Math.max(MENU_VIEWPORT_PADDING, globalThis.innerHeight - menuHeight - MENU_VIEWPORT_PADDING); + const left = Math.min(Math.max(MENU_VIEWPORT_PADDING, clientX), maxLeft); + const top = Math.min(Math.max(MENU_VIEWPORT_PADDING, clientY), maxTop); + + $menuContainer.css({ + left: left + 'px', + top: top + 'px', + visibility: 'visible' + }); + } + + function showMenu($menuContainer, clientX, clientY, focusTarget) { + rememberFocusTarget(focusTarget); + positionMenu($menuContainer, clientX, clientY); + $menuContainer.attr('aria-hidden', 'false'); + focusFirstMenuItem($menuContainer); + } + + /** + * Handle filter column lookup and auto-population. + */ + function handleFilterAction(e, $cell, dtApi) { + e.preventDefault(); + hideMenu(); + + const cellText = ($cell.text() ?? '').trim(); + const filterRow = getFilterRowPlugin(dtApi); + + // Show the filter row if not already visible + filterRow?.show?.(); + + // Resolve the column name for the clicked cell + try { + const cellInfo = dtApi.cell($cell[0])?.index?.(); + if (!cellInfo) { + return; + } + + const colIdx = cellInfo.column; + const settings = dtApi.settings?.(); + const aoColumns = settings?.[0]?.aoColumns; + if (!aoColumns?.[colIdx]) { + return; + } + + const colName = aoColumns[colIdx].name; + if (!colName) { + return; + } + + // Find the filter input for this column and set the value + const filterRowElement = getFilterRowElement(dtApi); + const $input = filterRowElement.find('input.custom-filter-input').filter(function () { + return $(this).data('column-name') === colName; + }); + + if ($input.length > 0) { + $input.val(cellText).trigger('keyup'); + } + } catch (err) { + console.debug('Filter action error:', err); + } + } + + /** + * Handle clear filter action. + */ + function handleClearFilterAction(e, dtApi) { + e.preventDefault(); + hideMenu(); + + const filterRow = getFilterRowPlugin(dtApi); + filterRow?.clearFilters?.(); + } + + /** + * Handle generic toolbar button click. + */ + function handleToolbarButtonClick(e, $btn) { + e.preventDefault(); + hideMenu(); + $btn?.[0]?.click?.(); + } + + /** + * Check if a button is enabled (not hidden/disabled). + */ + function isButtonEnabled($btn) { + return !$btn.hasClass('action-bar-btn-unavailable') + && !$btn.prop('disabled') + && !$btn.hasClass('d-none') + && !$btn.hasClass('dt-button-disabled'); + } + + /** + * Handle dismiss on outside click. + */ + function dismissClickHandler(e) { + if (!$(e.target).closest('#' + MENU_ID).length) { + hideMenu(); + } + } + + /** + * Handle dismiss on scroll. + */ + function dismissScrollHandler() { + hideMenu(); + } + + /** + * Handle row selection with callback. + */ + function handleRowSelection(dtApi, rowIndex, callback) { + requestAnimationFrame(function () { + const selectedRows = dtApi.rows({ selected: true }).indexes(); + if (!selectedRows.includes(rowIndex)) { + dtApi.rows({ selected: true }).deselect(); + dtApi.row(rowIndex).select(); + } + requestAnimationFrame(callback); + }); + } + + /** + * Hide the context menu and clean up event handlers. + */ + function hideMenu() { + const $menuContainer = getMenuContainer(); + if ($menuContainer.length > 0) { + $menuContainer + .hide() + .attr('aria-hidden', 'true') + .off('keydown.dt-context-menu') + .removeData('activeIndex'); + } + + $(document).off('click.dt-context-menu'); + $(globalThis).off('scroll.dt-context-menu resize.dt-context-menu'); + + restoreFocus(); + } + + /** + * Copy text to clipboard with fallback. + */ + function copyToClipboard(text) { + if (navigator.clipboard?.writeText) { + navigator.clipboard.writeText(text) + .then(() => { + abp.notify.success((abp.localization.getResource('GrantManager')('DataTable:ContextMenu:CopiedToClipboard') ?? 'Copied to clipboard'),'Success'); + }) + .catch(() => { + fallbackCopy(text); + }); + } else { + fallbackCopy(text); + } + } + + /** + * Fallback copy using textarea hack (legacy support). + */ + function fallbackCopy(text) { + const $textarea = $('