diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/IAIService.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/IAIService.cs index 299f781c8..565dca95c 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/IAIService.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/IAIService.cs @@ -14,5 +14,6 @@ public interface IAIService Task GenerateApplicationScoringAsync(ApplicationScoringRequest request, CancellationToken cancellationToken = default); Task GenerateFormMappingAsync(FormMappingRequest request, CancellationToken cancellationToken = default); Task GenerateFormWorksheetAsync(FormWorksheetRequest request, CancellationToken cancellationToken = default); + Task GenerateFormScoresheetAsync(FormScoresheetRequest request, CancellationToken cancellationToken = default); } } diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Operations/IAIGenerationPrerequisiteValidator.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Operations/IAIGenerationPrerequisiteValidator.cs index 55d4feb1d..cbbfeae8e 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Operations/IAIGenerationPrerequisiteValidator.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Operations/IAIGenerationPrerequisiteValidator.cs @@ -14,4 +14,6 @@ public interface IAIGenerationPrerequisiteValidator Task EnsureFormMappingAvailableAsync(Guid applicationFormVersionId); Task EnsureFormWorksheetAvailableAsync(Guid applicationFormVersionId); + + Task EnsureFormScoresheetAvailableAsync(Guid applicationFormVersionId); } diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Requests/FormScoresheetRequest.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Requests/FormScoresheetRequest.cs new file mode 100644 index 000000000..64ba94b28 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Requests/FormScoresheetRequest.cs @@ -0,0 +1,13 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Unity.AI.Requests; + +public class FormScoresheetRequest +{ + [JsonPropertyName("data")] + public JsonElement Data { get; set; } + + [JsonPropertyName("promptVersion")] + public string? PromptVersion { get; set; } +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Responses/FormScoresheetResponse.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Responses/FormScoresheetResponse.cs new file mode 100644 index 000000000..3ed832122 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Responses/FormScoresheetResponse.cs @@ -0,0 +1,72 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Unity.AI.Responses; + +public class FormScoresheetResponse +{ + public string Scoresheet { get; set; } = string.Empty; + + [JsonPropertyName("title")] + public string Title { get; set; } = string.Empty; + + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + [JsonPropertyName("version")] + public uint Version { get; set; } = 1; + + [JsonPropertyName("order")] + public uint Order { get; set; } + + [JsonPropertyName("published")] + public bool Published { get; set; } + + [JsonPropertyName("reportColumns")] + public string ReportColumns { get; set; } = string.Empty; + + [JsonPropertyName("reportKeys")] + public string ReportKeys { get; set; } = string.Empty; + + [JsonPropertyName("reportViewName")] + public string ReportViewName { get; set; } = string.Empty; + + [JsonPropertyName("sections")] + public List Sections { get; set; } = []; +} + +public class FormScoresheetSectionResponse +{ + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + [JsonPropertyName("order")] + public uint Order { get; set; } + + [JsonPropertyName("fields")] + public List Fields { get; set; } = []; +} + +public class FormScoresheetFieldResponse +{ + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + [JsonPropertyName("label")] + public string Label { get; set; } = string.Empty; + + [JsonPropertyName("description")] + public string? Description { get; set; } + + [JsonPropertyName("order")] + public uint Order { get; set; } + + [JsonPropertyName("type")] + public int Type { get; set; } + + [JsonPropertyName("enabled")] + public bool Enabled { get; set; } = true; + + [JsonPropertyName("definition")] + public string? Definition { get; set; } +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Generation/IAIGenerationAppService.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Generation/IAIGenerationAppService.cs index 2823193cb..938dff743 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 @@ -16,5 +16,7 @@ public interface IAIGenerationAppService : IApplicationService Task GenerateFormWorksheetAsync(Guid applicationId, Guid applicationFormVersionId, string? promptVersion = null); + Task GenerateFormScoresheetAsync(Guid applicationId, Guid applicationFormVersionId, string? promptVersion = null); + Task GetStatusAsync(Guid applicationId, string operationType); } diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Permissions/AIPermissionDefinitionProvider.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Permissions/AIPermissionDefinitionProvider.cs index 44b410134..195bedf97 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 @@ -75,6 +75,16 @@ public override void Define(IPermissionDefinitionContext context) L("Permission:AI.GenerateFormWorksheet")) .RequireFeatures("Unity.AI.FormWorksheet"); + var viewFormScoresheet = aiPermissionsGroup.AddPermission( + AIPermissions.Analysis.ViewFormScoresheet, + L("Permission:AI.ViewFormScoresheet")) + .RequireFeatures("Unity.AI.FormScoresheet"); + + viewFormScoresheet.AddChild( + AIPermissions.Analysis.GenerateFormScoresheet, + L("Permission:AI.GenerateFormScoresheet")) + .RequireFeatures("Unity.AI.FormScoresheet"); + var settingManagement = context.GetGroup(SettingManagementPermissions.GroupName); var configureAI = settingManagement.AddPermission( AIPermissions.Configuration.ConfigureAI, @@ -84,7 +94,8 @@ public override void Define(IPermissionDefinitionContext context) "Unity.AI.AttachmentSummaries", "Unity.AI.ApplicationAnalysis", "Unity.AI.FormMapping", - "Unity.AI.FormWorksheet")); + "Unity.AI.FormWorksheet", + "Unity.AI.FormScoresheet")); } private static LocalizableString L(string name) diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Permissions/AIPermissions.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Permissions/AIPermissions.cs index 27dc36f64..0740d0978 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Permissions/AIPermissions.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Permissions/AIPermissions.cs @@ -15,16 +15,18 @@ public static class Reporting public static class Analysis { public const string ViewApplicationAnalysis = GroupName + ".ViewApplicationAnalysis"; - public const string ViewAttachmentSummary = GroupName + ".ViewAttachmentSummary"; - public const string ViewScoringResult = GroupName + ".ViewScoringResult"; - public const string ViewFormMapping = GroupName + ".ViewFormMapping"; - public const string ViewFormWorksheet = GroupName + ".ViewFormWorksheet"; + public const string ViewAttachmentSummary = GroupName + ".ViewAttachmentSummary"; + public const string ViewScoringResult = GroupName + ".ViewScoringResult"; + public const string ViewFormMapping = GroupName + ".ViewFormMapping"; + public const string ViewFormWorksheet = GroupName + ".ViewFormWorksheet"; + public const string ViewFormScoresheet = GroupName + ".ViewFormScoresheet"; public const string GenerateApplicationAnalysis = GroupName + ".GenerateApplicationAnalysis"; public const string GenerateAttachmentSummaries = GroupName + ".GenerateAttachmentSummaries"; - public const string GenerateScoring = GroupName + ".GenerateScoring"; - public const string GenerateFormMapping = GroupName + ".GenerateFormMapping"; - public const string GenerateFormWorksheet = GroupName + ".GenerateFormWorksheet"; + public const string GenerateScoring = GroupName + ".GenerateScoring"; + public const string GenerateFormMapping = GroupName + ".GenerateFormMapping"; + public const string GenerateFormWorksheet = GroupName + ".GenerateFormWorksheet"; + public const string GenerateFormScoresheet = GroupName + ".GenerateFormScoresheet"; } public static class ApplicationAnalysis @@ -57,6 +59,12 @@ public static class FormWorksheet public const string Generate = Analysis.GenerateFormWorksheet; } + public static class FormScoresheet + { + public const string View = Analysis.ViewFormScoresheet; + public const string Generate = Analysis.GenerateFormScoresheet; + } + public static class Configuration { public const string ConfigureAI = "SettingManagement.ConfigureAI"; diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/AIExecutionModeResolver.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/AIExecutionModeResolver.cs index 4a43caf10..5408e2ead 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/AIExecutionModeResolver.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/AIExecutionModeResolver.cs @@ -17,6 +17,7 @@ public class AIExecutionModeResolver(IConfiguration configuration) : ITransientD public const string ApplicationScoringOperation = AIPromptTypes.ApplicationScoring; public const string FormMappingOperation = AIPromptTypes.FormMapping; public const string FormWorksheetOperation = AIPromptTypes.FormWorksheet; + public const string FormScoresheetOperation = AIPromptTypes.FormScoresheet; public AIExecutionMode ResolveMode(string operationName) { diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/AIGenerationPrerequisiteValidator.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/AIGenerationPrerequisiteValidator.cs index 4c8e58a2a..26923afa8 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/AIGenerationPrerequisiteValidator.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/AIGenerationPrerequisiteValidator.cs @@ -74,4 +74,13 @@ public async Task EnsureFormWorksheetAvailableAsync(Guid applicationFormVersionI throw new UserFriendlyException(localizer[AILocalizationKeys.FormWorksheetRequiresFormVersion]); } } + + public async Task EnsureFormScoresheetAvailableAsync(Guid applicationFormVersionId) + { + var formVersion = await applicationFormVersionRepository.FindAsync(applicationFormVersionId); + if (formVersion == null) + { + throw new UserFriendlyException(localizer[AILocalizationKeys.FormScoresheetRequiresFormVersion]); + } + } } diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/IFormScoresheetService.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/IFormScoresheetService.cs new file mode 100644 index 000000000..1fcb26e49 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/IFormScoresheetService.cs @@ -0,0 +1,11 @@ +using System.Threading; +using System.Threading.Tasks; +using Unity.AI.Requests; +using Unity.AI.Responses; + +namespace Unity.AI.Operations; + +public interface IFormScoresheetService +{ + Task GenerateFormScoresheetAsync(FormScoresheetRequest request, CancellationToken cancellationToken = default); +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/AIPromptTypes.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/AIPromptTypes.cs index ae79716c4..081410e78 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/AIPromptTypes.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/AIPromptTypes.cs @@ -7,4 +7,5 @@ public static class AIPromptTypes public const string ApplicationScoring = "ApplicationScoring"; public const string FormMapping = "FormMapping"; public const string FormWorksheet = "FormWorksheet"; + public const string FormScoresheet = "FormScoresheet"; } diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/AIProviderPayloadValidator.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/AIProviderPayloadValidator.cs index 1f8727454..0f17bbd1c 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 @@ -146,6 +146,226 @@ public static AIResponseValidationResult ValidateFormWorksheetJson(string respon return AIResponseValidationResult.Success(); } + public static AIResponseValidationResult ValidateFormScoresheetJson(string response) + { + if (!TryParseRootObject(response, out var root)) + { + return AIResponseValidationResult.Invalid("Scoresheet response was not valid JSON."); + } + + foreach (var propertyName in new[] { "Title", "Name" }) + { + var result = ValidateRequiredStringProperty(root, propertyName, "scoresheet"); + if (!result.IsValid) + { + return result; + } + } + + foreach (var propertyName in new[] { "ReportColumns", "ReportKeys", "ReportViewName" }) + { + var result = ValidateRequiredStringProperty(root, propertyName, "scoresheet", allowEmpty: true); + if (!result.IsValid) + { + return result; + } + } + + foreach (var propertyName in new[] { "Version", "Order" }) + { + var result = ValidateRequiredUIntProperty(root, propertyName, "scoresheet"); + if (!result.IsValid) + { + return result; + } + } + + var publishedResult = ValidateRequiredBooleanProperty(root, "Published", "scoresheet"); + return !publishedResult.IsValid + ? publishedResult + : ValidateSections(root, "scoresheet"); + } + + private static AIResponseValidationResult ValidateSections(JsonElement root, string responseName) + { + if (!TryGetProperty(root, "Sections", out var sections) || sections.ValueKind != JsonValueKind.Array) + { + return AIResponseValidationResult.Invalid($"{responseName} response is missing or invalid required field 'Sections' (expected array)."); + } + + if (sections.GetArrayLength() == 0) + { + return AIResponseValidationResult.Invalid($"{responseName} response must include at least one section."); + } + + var sectionNames = new HashSet(StringComparer.OrdinalIgnoreCase); + var fieldNames = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var section in sections.EnumerateArray()) + { + if (section.ValueKind != JsonValueKind.Object) + { + return AIResponseValidationResult.Invalid($"{responseName} response contains an invalid section (expected object)."); + } + + var sectionNameResult = ValidateRequiredStringProperty(section, "Name", $"{responseName} section"); + if (!sectionNameResult.IsValid) + { + return sectionNameResult; + } + + if (!sectionNames.Add(section.GetProperty("Name").GetString()!)) + { + return AIResponseValidationResult.Invalid($"{responseName} response contains duplicate section names."); + } + + var sectionOrderResult = ValidateRequiredUIntProperty(section, "Order", $"{responseName} section"); + if (!sectionOrderResult.IsValid) + { + return sectionOrderResult; + } + + if (!TryGetProperty(section, "Fields", out var fields) || fields.ValueKind != JsonValueKind.Array) + { + return AIResponseValidationResult.Invalid($"{responseName} response contains a section without valid 'Fields' (expected array)."); + } + + if (fields.GetArrayLength() == 0) + { + return AIResponseValidationResult.Invalid($"{responseName} response contains a section without fields."); + } + + foreach (var field in fields.EnumerateArray()) + { + var result = ValidateField(field, responseName); + if (!result.IsValid) + { + return result; + } + + if (!fieldNames.Add(field.GetProperty("Name").GetString()!)) + { + return AIResponseValidationResult.Invalid($"{responseName} response contains duplicate field names."); + } + } + } + + return AIResponseValidationResult.Success(); + } + + private static AIResponseValidationResult ValidateField(JsonElement field, string responseName) + { + if (field.ValueKind != JsonValueKind.Object) + { + return AIResponseValidationResult.Invalid($"{responseName} response contains an invalid field (expected object)."); + } + + foreach (var propertyName in new[] { "Name", "Label" }) + { + var result = ValidateRequiredStringProperty(field, propertyName, $"{responseName} field"); + if (!result.IsValid) + { + return result; + } + } + + foreach (var propertyName in new[] { "Order", "Type" }) + { + var result = ValidateRequiredUIntProperty(field, propertyName, $"{responseName} field"); + if (!result.IsValid) + { + return result; + } + } + + var definitionResult = ValidateDefinitionProperty(field, responseName); + if (!definitionResult.IsValid) + { + return definitionResult; + } + + return AIResponseValidationResult.Success(); + } + + private static AIResponseValidationResult ValidateDefinitionProperty(JsonElement field, string responseName) + { + if (!TryGetProperty(field, "Definition", out var definition) || definition.ValueKind == JsonValueKind.Null) + { + return AIResponseValidationResult.Invalid($"{responseName} response contains a field without a Definition."); + } + + if (definition.ValueKind != JsonValueKind.String) + { + return AIResponseValidationResult.Invalid($"{responseName} response field Definition must be a JSON object encoded as a string."); + } + + var definitionText = definition.GetString(); + if (string.IsNullOrWhiteSpace(definitionText)) + { + return AIResponseValidationResult.Invalid($"{responseName} response field Definition cannot be empty."); + } + + try + { + using var definitionDocument = JsonDocument.Parse(definitionText); + if (definitionDocument.RootElement.ValueKind != JsonValueKind.Object) + { + return AIResponseValidationResult.Invalid($"{responseName} response field Definition must contain a JSON object."); + } + } + catch (JsonException) + { + return AIResponseValidationResult.Invalid($"{responseName} response field Definition must contain valid JSON."); + } + + return AIResponseValidationResult.Success(); + } + + private static AIResponseValidationResult ValidateRequiredStringProperty(JsonElement element, string propertyName, string sourceName, bool allowEmpty = false) + { + if (!TryGetProperty(element, propertyName, out var property) + || property.ValueKind != JsonValueKind.String + || (!allowEmpty && string.IsNullOrWhiteSpace(property.GetString()))) + { + var expectation = allowEmpty ? "string" : "non-empty string"; + return AIResponseValidationResult.Invalid($"{sourceName} response is missing or invalid required field '{propertyName}' (expected {expectation})."); + } + + return AIResponseValidationResult.Success(); + } + + private static AIResponseValidationResult ValidateRequiredUIntProperty(JsonElement element, string propertyName, string sourceName) + { + if (!TryGetProperty(element, propertyName, out var property) + || property.ValueKind != JsonValueKind.Number + || !property.TryGetUInt32(out _)) + { + return AIResponseValidationResult.Invalid($"{sourceName} response is missing or invalid required field '{propertyName}' (expected non-negative integer)."); + } + + return AIResponseValidationResult.Success(); + } + + private static AIResponseValidationResult ValidateRequiredBooleanProperty(JsonElement element, string propertyName, string sourceName) + { + if (!TryGetProperty(element, propertyName, out var property) || property.ValueKind is not JsonValueKind.True and not JsonValueKind.False) + { + return AIResponseValidationResult.Invalid($"{sourceName} response is missing or invalid required field '{propertyName}' (expected boolean)."); + } + + return AIResponseValidationResult.Success(); + } + + private static bool TryGetProperty(JsonElement element, string propertyName, out JsonElement property) + { + if (element.ValueKind == JsonValueKind.Object && element.TryGetProperty(propertyName, out property)) + { + return true; + } + + property = default; + return false; + } private static HashSet ExtractQuestionIds(string sectionJson) { var ids = new HashSet(StringComparer.OrdinalIgnoreCase); diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIRuntimeService.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIRuntimeService.cs index 6400d33c1..9da405ab7 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 @@ -15,8 +15,8 @@ namespace Unity.AI.Runtime { - [ExposeServices(typeof(IAIService), typeof(IFormMappingService), typeof(IFormWorksheetService))] - public class OpenAIRuntimeService : IAIService, IFormMappingService, IFormWorksheetService, ITransientDependency + [ExposeServices(typeof(IAIService), typeof(IFormMappingService), typeof(IFormWorksheetService), typeof(IFormScoresheetService))] + public class OpenAIRuntimeService : IAIService, IFormMappingService, IFormWorksheetService, IFormScoresheetService, ITransientDependency { private readonly ILogger _logger; private readonly OpenAITransportService _openAITransportService; @@ -28,6 +28,7 @@ public class OpenAIRuntimeService : IAIService, IFormMappingService, IFormWorksh private const string ApplicationScoringPromptType = AIPromptTypes.ApplicationScoring; private const string FormMappingPromptType = AIPromptTypes.FormMapping; private const string FormWorksheetPromptType = AIPromptTypes.FormWorksheet; + private const string FormScoresheetPromptType = AIPromptTypes.FormScoresheet; private const int MaxAiAttempts = 3; public OpenAIRuntimeService( @@ -323,6 +324,55 @@ public async Task GenerateFormWorksheetAsync(FormWorkshee } } + public async Task GenerateFormScoresheetAsync(FormScoresheetRequest request, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + try + { + var settings = await _openAIConfigurationResolver.ResolveOperationSettingsAsync(FormScoresheetPromptType, cancellationToken); + var promptTemplate = await _promptTemplateProvider.GetRequiredPromptAsync( + FormScoresheetPromptType, + request.PromptVersion ?? settings.PromptVersion, + cancellationToken); + var promptVersion = promptTemplate.PromptVersion; + var dataJson = request.Data.GetRawText(); + var systemPrompt = promptTemplate.SystemPrompt; + var content = AIPromptTemplateRenderer.BuildFormMappingUserPrompt( + promptTemplate.UserPrompt, + dataJson, + promptTemplate.MetadataJson); + + await _promptFileLogger.LogPromptInputAsync(FormScoresheetPromptType, promptVersion, systemPrompt, content, cancellationToken); + var result = await GenerateWithRetryAsync( + () => _openAITransportService.GenerateSummaryAsync( + content, + systemPrompt, + settings, + settings.CompletionTokens, + cancellationToken: cancellationToken), + AIProviderPayloadValidator.ValidateFormScoresheetJson, + "form scoresheet", + cancellationToken); + await _promptFileLogger.LogPromptOutputAsync(FormScoresheetPromptType, promptVersion, result.CaptureOutput, cancellationToken); + + return new FormScoresheetResponse + { + Scoresheet = result.Outcome == AIOperationOutcome.Success + ? AIResponseJson.CleanJsonResponse(result.Content) + : "{}" + }; + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + _logger.LogError(ex, "Form scoresheet generation failed."); + return new FormScoresheetResponse(); + } + } + private async Task GenerateFormMappingCoreAsync(FormMappingRequest request, string promptType, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(request); diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/DataSeed/AIOperationDataSeeder.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/DataSeed/AIOperationDataSeeder.cs index 4bed1a64c..1f0d8f438 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/DataSeed/AIOperationDataSeeder.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/DataSeed/AIOperationDataSeeder.cs @@ -29,7 +29,8 @@ public class AIOperationDataSeeder( new(AIPromptTypes.AttachmentSummary, AIPromptTypes.AttachmentSummary, 1, 2000), new(AIPromptTypes.ApplicationScoring, AIPromptTypes.ApplicationScoring, 1, 8000), new(AIPromptTypes.FormMapping, AIPromptTypes.FormMapping, 2, 2000), - new(AIPromptTypes.FormWorksheet, AIPromptTypes.FormWorksheet, 2, 4000) + new(AIPromptTypes.FormWorksheet, AIPromptTypes.FormWorksheet, 2, 4000), + new(AIPromptTypes.FormScoresheet, AIPromptTypes.FormScoresheet, 2, 4000) ]; public async Task SeedAsync(DataSeedContext context) diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/DataSeed/AIPromptDataSeeder.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/DataSeed/AIPromptDataSeeder.cs index fae1247a4..044da04dc 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 @@ -30,6 +30,7 @@ public async Task SeedAsync(DataSeedContext context) await SeedScoresheetPromptAsync(); await SeedFormMappingPromptAsync(); await SeedFormWorksheetPromptAsync(); + await SeedFormScoresheetPromptAsync(); } } @@ -124,6 +125,11 @@ private async Task SeedFormWorksheetPromptAsync() await EnsurePromptAsync(AIPromptTypes.FormWorksheet, 2, FormWorksheetSystemV2, FormWorksheetUserV2, FormWorksheetMetadataV2); } + private async Task SeedFormScoresheetPromptAsync() + { + await EnsurePromptAsync(AIPromptTypes.FormScoresheet, 2, FormScoresheetSystemV2, FormScoresheetUserV2, FormScoresheetMetadataV2); + } + // ─── HELPERS ────────────────────────────────────────────────────────────── private static string BuildSections( @@ -898,6 +904,66 @@ Return only valid JSON. } """; + // ── v2/form-scoresheet.system.txt ─────────────────────────────────────── + private const string FormScoresheetSystemV2 = """ + You are a scoresheet definition generator for Unity Grant Manager. + Generate a recommended scoresheet definition JSON that can be imported into Flex. + Return only valid JSON. + """; + + // ── v2/form-scoresheet.user.txt ────────────────────────────────────────── + private const string FormScoresheetUserV2 = """ + SCORESHEET CONTEXT: + {{DATA}} + + OUTPUT + { + "Title": "", + "Name": "", + "Version": , + "Order": 0, + "Published": false, + "ReportColumns": "", + "ReportKeys": "", + "ReportViewName": "", + "Sections": [ + { + "Name": "", + "Order": 0, + "Fields": [ + { + "Name": "", + "Label": "", + "Description": "", + "Order": 0, + "Type": , + "Enabled": true, + "Definition": "" + } + ] + } + ] + } + + Rules: + - Return one scoresheet definition JSON object only. + - The context contains CHEFS form fields, allowed Unity Flex question types, and a scoresheet template. + - Fill out the scoresheet template to generate the rubric assessors use to score submitted applications. + - Use CHEFS form fields as evidence for assessment criteria, but do not create one question per form field. + - Keep the generated scoresheet focused on reviewer criteria, scoring choices, and comments. + - Do not invent assessor workflow, compliance, declaration, approval, status, conflict-of-interest, or submission identifier questions unless the CHEFS fields explicitly contain content that should be scored for that topic. + - Use the template's Name, Version, Order, Published, ReportColumns, ReportKeys, and ReportViewName values. + - Use the numeric QuestionType values from Unity Flex. + - Do not copy or infer an existing scoresheet unless it is explicitly provided as part of the template. + - Return valid plain JSON only. + """; + + private const string FormScoresheetMetadataV2 = """ + { + "DATA": "Serialized JSON payload containing the form name, form version, CHEFS fields, allowed question types, and the scoresheet template to fill out." + } + """; + // ── v1/common.rules.txt ────────────────────────────────────────────────── private const string CommonRules = """ - Any narrative text response must be at least 12 words. 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 2e9385f7b..6c9fd867d 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 @@ -88,6 +88,17 @@ await featureGuard.EnsureEnabledAsync( await aiGenerationQueue.QueueFormWorksheetAsync(applicationId, currentTenant.Id, applicationFormVersionId, promptVersion); } + [Authorize(AIPermissions.Analysis.GenerateFormScoresheet)] + [HttpPost("form-scoresheet")] + public virtual async Task GenerateFormScoresheetAsync(Guid applicationId, Guid applicationFormVersionId, string? promptVersion = null) + { + await featureGuard.EnsureEnabledAsync( + AIFeatures.FormScoresheet, + AILocalizationKeys.FormScoresheetDisabled); + + await aiGenerationQueue.QueueFormScoresheetAsync(applicationId, currentTenant.Id, applicationFormVersionId, promptVersion); + } + [Authorize] [HttpGet("status")] public virtual async Task GetStatusAsync(Guid applicationId, string operationType) @@ -135,6 +146,7 @@ private async Task EnsureStatusAccessAsync(string operationType) AIGenerationRequestKeyHelper.ApplicationScoringOperationType => AIPermissions.Analysis.ViewScoringResult, AIGenerationRequestKeyHelper.FormMappingOperationType => AIPermissions.Analysis.ViewFormMapping, AIGenerationRequestKeyHelper.FormWorksheetOperationType => AIPermissions.Analysis.ViewFormWorksheet, + AIGenerationRequestKeyHelper.FormScoresheetOperationType => AIPermissions.Analysis.ViewFormScoresheet, _ => throw new UserFriendlyException($"Unsupported AI generation operation type: {operationType}") }; diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Generation/IApplicationGenerationQueue.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Generation/IApplicationGenerationQueue.cs index f7bcf4eb1..ac57e353b 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Generation/IApplicationGenerationQueue.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Generation/IApplicationGenerationQueue.cs @@ -16,5 +16,7 @@ public interface IApplicationGenerationQueue Task QueueFormWorksheetAsync(Guid applicationId, Guid? tenantId, Guid applicationFormVersionId, string? promptVersion = null); + Task QueueFormScoresheetAsync(Guid applicationId, Guid? tenantId, Guid applicationFormVersionId, string? promptVersion = null); + Task QueueApplicationIntakeAsync(Guid applicationId, Guid? tenantId, string? promptVersion = null); } diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/Features/AIFeatures.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/Features/AIFeatures.cs index 0b3720649..e6ace26df 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/Features/AIFeatures.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/Features/AIFeatures.cs @@ -8,4 +8,5 @@ public static class AIFeatures public const string Scoring = "Unity.AI.Scoring"; public const string FormMapping = "Unity.AI.FormMapping"; public const string FormWorksheet = "Unity.AI.FormWorksheet"; + public const string FormScoresheet = "Unity.AI.FormScoresheet"; } diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/Localization/AI/en.json b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/Localization/AI/en.json index ca66506e4..88d3d0272 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 @@ -9,11 +9,13 @@ "Permission:AI.ViewScoringResult": "View AI Scoring Result", "Permission:AI.ViewFormMapping": "View AI Form Mapping", "Permission:AI.ViewFormWorksheet": "View AI Form Worksheet", + "Permission:AI.ViewFormScoresheet": "View AI Form Scoresheet", "Permission:AI.GenerateApplicationAnalysis": "Generate AI Application Analysis", "Permission:AI.GenerateAttachmentSummaries": "Generate AI Attachment Summaries", "Permission:AI.GenerateScoring": "Generate AI Scoring", "Permission:AI.GenerateFormMapping": "Generate AI Form Mapping", "Permission:AI.GenerateFormWorksheet": "Generate AI Form Worksheet", + "Permission:AI.GenerateFormScoresheet": "Generate AI Form Scoresheet", "Permission:AI.ConfigureAI": "AI Configuration", "Permission:AI.Prompts": "AI Prompt Management", "Permission:AI.Prompts.Create": "Create Prompts", @@ -31,6 +33,8 @@ "AI:FormMappingDisabled": "AI form mapping is not enabled.", "AI:FormWorksheetRequiresFormVersion": "AI form worksheet requires a valid form version.", "AI:FormWorksheetDisabled": "AI form worksheet is not enabled.", + "AI:FormScoresheetRequiresFormVersion": "AI form scoresheet requires a valid form version.", + "AI:FormScoresheetDisabled": "AI form scoresheet is not enabled.", "AI:GenerateAllDisabled": "AI generation is not enabled.", "AI:NoAttachmentsAvailable": "No attachments are available to summarize.", "AI:ApplicationAnalysisRequiresSubmission": "AI application analysis requires application submission data.", diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/Localization/AILocalizationKeys.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/Localization/AILocalizationKeys.cs index 4b1b86d57..3af858cf4 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/Localization/AILocalizationKeys.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/Localization/AILocalizationKeys.cs @@ -9,6 +9,8 @@ public static class AILocalizationKeys public const string FormMappingDisabled = "AI:FormMappingDisabled"; public const string FormWorksheetRequiresFormVersion = "AI:FormWorksheetRequiresFormVersion"; public const string FormWorksheetDisabled = "AI:FormWorksheetDisabled"; + public const string FormScoresheetRequiresFormVersion = "AI:FormScoresheetRequiresFormVersion"; + public const string FormScoresheetDisabled = "AI:FormScoresheetDisabled"; public const string GenerateAllDisabled = "AI:GenerateAllDisabled"; public const string NoAttachmentsAvailable = "AI:NoAttachmentsAvailable"; public const string ApplicationAnalysisRequiresSubmission = "AI:ApplicationAnalysisRequiresSubmission"; diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/Automation/BackgroundJobs/GenerateFormScoresheetBackgroundJobArgs.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/Automation/BackgroundJobs/GenerateFormScoresheetBackgroundJobArgs.cs new file mode 100644 index 000000000..e1775fc3b --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/Automation/BackgroundJobs/GenerateFormScoresheetBackgroundJobArgs.cs @@ -0,0 +1,17 @@ +using System; + +namespace Unity.GrantManager.GrantApplications.Automation.BackgroundJobs; + +public class GenerateFormScoresheetBackgroundJobArgs +{ + public Guid ApplicationId { get; set; } + public Guid OperationId { get; set; } + + public Guid? TenantId { get; set; } + + public Guid? RequestedByUserId { get; set; } + + public Guid ApplicationFormVersionId { get; set; } + + public string? PromptVersion { get; set; } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantManagerFeaturesDefinitionProvider.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantManagerFeaturesDefinitionProvider.cs index d8d97d985..f30219975 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantManagerFeaturesDefinitionProvider.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantManagerFeaturesDefinitionProvider.cs @@ -76,6 +76,12 @@ public override void Define(IFeatureDefinitionContext context) .Create("AI Form Worksheet"), valueType: new ToggleStringValueType()); + myGroup.AddFeature("Unity.AI.FormScoresheet", + defaultValue: defaultValue, + displayName: LocalizableString + .Create("AI Form Scoresheet"), + valueType: new ToggleStringValueType()); + myGroup.AddFeature("Unity.Analytics", defaultValue: defaultValue, displayName: LocalizableString diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/ApplicationAIGenerationQueue.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/ApplicationAIGenerationQueue.cs index 8a5e0c853..2922a9c04 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/ApplicationAIGenerationQueue.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/ApplicationAIGenerationQueue.cs @@ -141,6 +141,27 @@ await EnsureRequestAndEnqueueAsync( }); } + public async Task QueueFormScoresheetAsync(Guid applicationId, Guid? tenantId, Guid applicationFormVersionId, string? promptVersion = null) + { + await EnsureRequestAndEnqueueAsync( + tenantId, + AIGenerationRequestKeyHelper.FormScoresheetOperationType, + applicationId, + () => aiGenerationPrerequisiteValidator.EnsureFormScoresheetAvailableAsync(applicationFormVersionId), + operationId => + { + return backgroundJobManager.EnqueueAsync(new GenerateFormScoresheetBackgroundJobArgs + { + ApplicationId = applicationId, + OperationId = operationId, + ApplicationFormVersionId = applicationFormVersionId, + PromptVersion = promptVersion, + RequestedByUserId = currentUser.Id, + TenantId = tenantId + }); + }); + } + public async Task QueueApplicationIntakeAsync(Guid applicationId, Guid? tenantId, string? promptVersion = null) { var hasEnabledStage = false; diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateFormScoresheetJob.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateFormScoresheetJob.cs new file mode 100644 index 000000000..335b80475 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateFormScoresheetJob.cs @@ -0,0 +1,323 @@ +using Microsoft.Extensions.Logging; +using System; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using Unity.AI.Domain; +using Unity.AI.Cooldown; +using Unity.AI.Operations; +using Unity.AI.Requests; +using Unity.GrantManager.ApplicationForms; +using Unity.GrantManager.Applications; +using Unity.Flex.Domain.Scoresheets; +using Unity.Flex.Scoresheets; +using Volo.Abp.BackgroundJobs; +using Volo.Abp.DependencyInjection; +using Volo.Abp.Domain.Repositories; +using Volo.Abp.MultiTenancy; +using Volo.Abp.Uow; + +namespace Unity.GrantManager.GrantApplications.Automation.BackgroundJobs; + +public class GenerateFormScoresheetJob( + IApplicationFormVersionRepository applicationFormVersionRepository, + IApplicationFormRepository applicationFormRepository, + IScoresheetRepository scoresheetRepository, + IFormScoresheetService aiService, + IRepository generationRequestRepository, + ICurrentTenant currentTenant, + IUnitOfWorkManager unitOfWorkManager, + IAICooldownService aiCooldownService, + ILogger logger) : AsyncBackgroundJob, ITransientDependency +{ + private static readonly JsonSerializerOptions CaseInsensitiveJsonOptions = new() + { + PropertyNameCaseInsensitive = true + }; + + public override async Task ExecuteAsync(GenerateFormScoresheetBackgroundJobArgs args) + { + using var logScope = AIGenerationLogScope.Begin( + logger, + AIGenerationRequestKeyHelper.FormScoresheetOperationType, + args.ApplicationId, + args.TenantId, + args.PromptVersion, + args.RequestedByUserId); + + using (currentTenant.Change(args.TenantId)) + { + await AIGenerationRequestJobHelper.MarkRunningInNewUowAsync( + unitOfWorkManager, + generationRequestRepository, + args.TenantId, + args.ApplicationId, + args.OperationId); + + try + { + var formVersion = await applicationFormVersionRepository.GetAsync(args.ApplicationFormVersionId); + var applicationForm = await applicationFormRepository.GetAsync(formVersion.ApplicationFormId); + var scoresheetName = BuildScoresheetName(formVersion.Id, applicationForm.Id); + var existingScoresheet = await scoresheetRepository.GetByNameAsync(scoresheetName, true) + ?? (applicationForm.ScoresheetId.HasValue + ? await scoresheetRepository.GetWithChildrenAsync(applicationForm.ScoresheetId.Value) + : null); + + var promptData = new + { + applicationFormVersionId = formVersion.Id, + chefsFormVersionGuid = formVersion.ChefsFormVersionGuid, + applicationFormId = applicationForm.Id, + formName = applicationForm.ApplicationFormName, + scoresheetId = applicationForm.ScoresheetId, + existingScoresheet = existingScoresheet == null + ? null + : new + { + existingScoresheet.Id, + existingScoresheet.Title, + existingScoresheet.Name, + existingScoresheet.Version, + existingScoresheet.Order, + existingScoresheet.Published, + existingScoresheet.ReportColumns, + existingScoresheet.ReportKeys, + existingScoresheet.ReportViewName, + sections = existingScoresheet.Sections.Select(section => new + { + section.Name, + section.Order, + fields = section.Fields.Select(field => new + { + field.Name, + field.Label, + field.Description, + field.Order, + field.Type, + field.Enabled, + field.Definition + }) + }) + } + }; + + var scoresheetResponse = await aiService.GenerateFormScoresheetAsync(new FormScoresheetRequest + { + Data = JsonSerializer.SerializeToElement(promptData), + PromptVersion = args.PromptVersion + }); + + var scoresheetJson = scoresheetResponse.Scoresheet; + var importDto = ParseScoresheetDefinition(scoresheetJson); + var scoresheet = existingScoresheet == null + ? BuildScoresheet(importDto, scoresheetJson, scoresheetName) + : RebuildScoresheet(existingScoresheet, importDto, scoresheetJson, scoresheetName); + scoresheet.Published = true; + if (existingScoresheet == null) + { + await scoresheetRepository.InsertAsync(scoresheet); + } + else + { + await scoresheetRepository.UpdateAsync(scoresheet); + } + + applicationForm.ScoresheetId = scoresheet.Id; + await applicationFormRepository.UpdateAsync(applicationForm); + + await AIGenerationRequestJobHelper.StampCooldownBestEffortAsync(aiCooldownService, logger, args.RequestedByUserId, args.ApplicationId, AIGenerationRequestKeyHelper.FormScoresheetOperationType); + await AIGenerationRequestJobHelper.MarkCompletedInNewUowAsync( + unitOfWorkManager, + generationRequestRepository, + args.TenantId, + args.ApplicationId, + args.OperationId); + } + catch (Exception ex) + { + await AIGenerationRequestJobHelper.MarkFailedInNewUowAsync( + unitOfWorkManager, + generationRequestRepository, + args.TenantId, + args.ApplicationId, + args.OperationId, + ex.Message); + throw; + } + } + } + + private static CreateScoresheetDto ParseScoresheetDefinition(string json) + { + if (string.IsNullOrWhiteSpace(json)) + { + throw new InvalidOperationException("Scoresheet generation returned empty content."); + } + + var dto = JsonSerializer.Deserialize(json, CaseInsensitiveJsonOptions); + + if (dto == null || string.IsNullOrWhiteSpace(dto.Title) || string.IsNullOrWhiteSpace(dto.Name)) + { + throw new InvalidOperationException("Scoresheet generation returned an unusable scoresheet definition."); + } + + return dto; + } + + private static string BuildScoresheetName(Guid formVersionId, Guid formId) + { + return $"ai-form-{formId}-version-{formVersionId}-scoresheet"; + } + + private static Scoresheet BuildScoresheet(CreateScoresheetDto dto, string json, string scoresheetName) + { + var scoresheet = new Scoresheet(Guid.NewGuid(), dto.Title, scoresheetName); + var parsed = ParseScoresheetElement(json); + if (!TryGetNumberProperty(parsed, "Version", out var version)) + { + throw new InvalidOperationException("Scoresheet generation returned a definition without a valid Version."); + } + + scoresheet.Version = version; + + if (!parsed.TryGetProperty("Sections", out var sectionsElement) || sectionsElement.ValueKind != JsonValueKind.Array) + { + throw new InvalidOperationException("Scoresheet generation returned a definition without Sections."); + } + + foreach (var section in sectionsElement.EnumerateArray()) + { + var sectionName = GetRequiredStringProperty(section, "Name", "section"); + var sectionOrder = GetRequiredNumberProperty(section, "Order", "section"); + var scoresheetSection = new ScoresheetSection(Guid.NewGuid(), sectionName, sectionOrder); + scoresheet.AddSection(scoresheetSection); + + if (!section.TryGetProperty("Fields", out var fieldsElement) || fieldsElement.ValueKind != JsonValueKind.Array) + { + throw new InvalidOperationException($"Scoresheet generation returned section '{sectionName}' without Fields."); + } + + foreach (var field in fieldsElement.EnumerateArray()) + { + var question = new Question( + Guid.NewGuid(), + GetRequiredStringProperty(field, "Name", "field"), + GetRequiredStringProperty(field, "Label", "field"), + (Unity.Flex.Scoresheets.Enums.QuestionType)GetRequiredNumberProperty(field, "Type", "field"), + GetRequiredNumberProperty(field, "Order", "field"), + field.TryGetProperty("Description", out var description) && description.ValueKind != JsonValueKind.Null + ? description.GetString() + : null, + field.TryGetProperty("Definition", out var definition) && definition.ValueKind != JsonValueKind.Null + ? definition.GetString() + : null); + question.SectionId = scoresheetSection.Id; + scoresheetSection.Fields.Add(question); + } + } + + scoresheet.SetReportingFields( + GetRequiredStringProperty(parsed, "ReportKeys", "scoresheet", allowEmpty: true), + GetRequiredStringProperty(parsed, "ReportColumns", "scoresheet", allowEmpty: true), + GetRequiredStringProperty(parsed, "ReportViewName", "scoresheet", allowEmpty: true)); + + return scoresheet; + } + + private static Scoresheet RebuildScoresheet(Scoresheet scoresheet, CreateScoresheetDto dto, string json, string scoresheetName) + { + var parsed = ParseScoresheetElement(json); + if (!TryGetNumberProperty(parsed, "Version", out var version)) + { + throw new InvalidOperationException("Scoresheet generation returned a definition without a valid Version."); + } + + scoresheet.SetName(scoresheetName); + scoresheet.Title = dto.Title; + scoresheet.Version = version; + scoresheet.SetReportingFields( + GetRequiredStringProperty(parsed, "ReportKeys", "scoresheet", allowEmpty: true), + GetRequiredStringProperty(parsed, "ReportColumns", "scoresheet", allowEmpty: true), + GetRequiredStringProperty(parsed, "ReportViewName", "scoresheet", allowEmpty: true)); + + scoresheet.Sections.Clear(); + + if (!parsed.TryGetProperty("Sections", out var sectionsElement) || sectionsElement.ValueKind != JsonValueKind.Array) + { + throw new InvalidOperationException("Scoresheet generation returned a definition without Sections."); + } + + foreach (var section in sectionsElement.EnumerateArray()) + { + var sectionName = GetRequiredStringProperty(section, "Name", "section"); + var sectionOrder = GetRequiredNumberProperty(section, "Order", "section"); + var scoresheetSection = new ScoresheetSection(Guid.NewGuid(), sectionName, sectionOrder); + scoresheet.AddSection(scoresheetSection); + + if (!section.TryGetProperty("Fields", out var fieldsElement) || fieldsElement.ValueKind != JsonValueKind.Array) + { + throw new InvalidOperationException($"Scoresheet generation returned section '{sectionName}' without Fields."); + } + + foreach (var field in fieldsElement.EnumerateArray()) + { + var question = new Question( + Guid.NewGuid(), + GetRequiredStringProperty(field, "Name", "field"), + GetRequiredStringProperty(field, "Label", "field"), + (Unity.Flex.Scoresheets.Enums.QuestionType)GetRequiredNumberProperty(field, "Type", "field"), + GetRequiredNumberProperty(field, "Order", "field"), + field.TryGetProperty("Description", out var description) && description.ValueKind != JsonValueKind.Null + ? description.GetString() + : null, + field.TryGetProperty("Definition", out var definition) && definition.ValueKind != JsonValueKind.Null + ? definition.GetString() + : null); + question.SectionId = scoresheetSection.Id; + scoresheetSection.Fields.Add(question); + } + } + + return scoresheet; + } + + private static JsonElement ParseScoresheetElement(string json) + { + return JsonSerializer.Deserialize(json, CaseInsensitiveJsonOptions); + } + + private static bool TryGetNumberProperty(JsonElement element, string propertyName, out uint value) + { + if (element.TryGetProperty(propertyName, out var property) && property.ValueKind == JsonValueKind.Number) + { + value = property.GetUInt32(); + return true; + } + + value = default; + return false; + } + + private static string GetRequiredStringProperty(JsonElement element, string propertyName, string sourceName, bool allowEmpty = false) + { + if (element.TryGetProperty(propertyName, out var property) + && property.ValueKind == JsonValueKind.String + && (allowEmpty || !string.IsNullOrWhiteSpace(property.GetString()))) + { + return property.GetString()!; + } + + throw new InvalidOperationException($"Scoresheet generation returned a {sourceName} without a valid {propertyName}."); + } + + private static uint GetRequiredNumberProperty(JsonElement element, string propertyName, string sourceName) + { + if (TryGetNumberProperty(element, propertyName, out var value)) + { + return value; + } + + throw new InvalidOperationException($"Scoresheet generation returned a {sourceName} without a valid {propertyName}."); + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/GrantApplications/AIGenerationRequestKeyHelper.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/GrantApplications/AIGenerationRequestKeyHelper.cs index 706a0bbbc..bcffaffee 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/GrantApplications/AIGenerationRequestKeyHelper.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/GrantApplications/AIGenerationRequestKeyHelper.cs @@ -10,6 +10,7 @@ public static class AIGenerationRequestKeyHelper public const string PipelineOperationType = "pipeline"; public const string FormMappingOperationType = "form-mapping"; public const string FormWorksheetOperationType = "form-worksheet"; + public const string FormScoresheetOperationType = "form-scoresheet"; public static string BuildRequestKey(Guid? tenantId, Guid applicationId, string operationType) { @@ -35,6 +36,7 @@ public static string BuildRequestKey(Guid? tenantId, Guid applicationId, string ApplicationScoringOperationType => "ApplicationScoring", FormMappingOperationType => "FormMapping", FormWorksheetOperationType => "FormWorksheet", + FormScoresheetOperationType => "FormScoresheet", PipelineOperationType => "Default", _ => null }; diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationForms/Mapping.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationForms/Mapping.js index c01d621bb..d722b2ce1 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationForms/Mapping.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationForms/Mapping.js @@ -59,6 +59,7 @@ btnEdit: $('#btn-edit'), btnGenerate: $('#btn-generate'), btnGenerateWorksheet: $('#btn-generate-worksheet'), + btnGenerateScoresheet: $('#btn-generate-scoresheet'), btnSync: $('#btn-sync'), btnReset: $('#btn-reset'), btnClose: $('.btn-close'), @@ -101,6 +102,7 @@ UIElements.btnEdit.on('click', handleEdit); UIElements.btnGenerate.on('click', queueFormMapping); UIElements.btnGenerateWorksheet.on('click', queueFormWorksheet); + UIElements.btnGenerateScoresheet.on('click', queueFormScoresheet); UIElements.btnReset.on('click', handleReset); UIElements.btnCancel.on('click', handleCancelMapping); UIElements.btnClose.on('click', handleCancelMapping); @@ -270,6 +272,71 @@ }); } + function queueFormScoresheet(triggerButton = null) { + const formVersion = String(document.getElementById('formVersionId')?.value ?? '').trim(); + const applicationId = String(document.getElementById('applicationFormId')?.value ?? '').trim(); + if (!validateGuid(formVersion) || !validateGuid(applicationId)) { + abp.notify.error('', 'The Form Version ID or Application ID is not in a GUID format'); + return; + } + + const buttonElement = triggerButton?.currentTarget || triggerButton?.target || triggerButton || UIElements.btnGenerateScoresheet?.get?.(0); + const $button = $(buttonElement); + const existingHtml = $button.html(); + + if ($button.prop('disabled')) { + return; + } + + globalThis.AIGenerationButtonState?.setGenerating($button); + + abp.ajax({ + url: `/api/app/ai/generation/form-scoresheet?applicationId=${encodeURIComponent(applicationId)}&applicationFormVersionId=${encodeURIComponent(formVersion)}`, + type: 'POST', + }) + .done(function (generationStatus) { + const request = generationStatus?.generationRequest; + const status = globalThis.AIGenerationButtonState?.resolveStatus(request?.status) ?? ''; + if (status === 'Completed') { + globalThis.AIGenerationButtonState?.restoreForCooldownCheck($button, existingHtml); + globalThis.AIGenerationButtonState?.applyStatusState(generationStatus); + refreshScoresheetAfterGeneration(); + return; + } + + monitorFormScoresheetGeneration(applicationId, $button, existingHtml); + }) + .fail(function (error) { + if (globalThis.AIGenerationButtonState?.handleQueueFailure(error)) { + return; + } + + abp.message.error('Failed to queue AI scoresheet generation. Please try again.'); + restoreGenerateScoresheetButton($button, existingHtml); + globalThis.syncAIRateLimitButtons?.(); + }); + } + + function monitorFormScoresheetGeneration(applicationId, $button, existingHtml) { + globalThis.AIGenerationButtonState?.monitor({ + $button, + originalHtml: existingHtml, + getStatus: () => abp.ajax({ + url: `/api/app/ai/generation/status?applicationId=${encodeURIComponent(applicationId)}&operationType=form-scoresheet`, + type: 'GET' + }), + onComplete: function () { + refreshScoresheetAfterGeneration(); + }, + onFailed: function (request) { + abp.message.error(request?.failureReason || 'AI scoresheet generation failed.'); + }, + onPollFailed: function () { + abp.message.error('Unable to load AI scoresheet generation status. Please try again.'); + } + }); + } + function refreshWorksheetAfterGeneration() { abp.notify.success('', 'Worksheet generated and assigned successfully. Reloading page.'); setTimeout(function () { @@ -277,6 +344,13 @@ }, 500); } + function refreshScoresheetAfterGeneration() { + abp.notify.success('', 'Scoresheet generated and assigned successfully. Reloading page.'); + setTimeout(function () { + globalThis.location.reload(); + }, 500); + } + function monitorFormMappingGeneration(applicationId, $button, existingHtml) { globalThis.AIGenerationButtonState?.monitor({ $button, diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/CustomFields/Default.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/CustomFields/Default.cshtml index 398f29fc6..2a4d1a37f 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/CustomFields/Default.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/CustomFields/Default.cshtml @@ -32,6 +32,17 @@ } + @if (await PermissionChecker.IsGrantedAsync(AIPermissions.FormScoresheet.Generate)) + { + + } -
+
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/CustomFields/Default.css b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/CustomFields/Default.css index 8f0b3510a..b401b9d45 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/CustomFields/Default.css +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/CustomFields/Default.css @@ -84,6 +84,16 @@ display: block; margin-top: 12px; } + +.scoresheet-selector { + min-width: 0; +} + +.scoresheet-selector .form-select, +.scoresheet-selector .form-control { + max-width: 100%; +} + .save-note { border: 2px solid; border-color: var(--bc-colors-blue-primary); @@ -100,4 +110,4 @@ .custom-fields-container { overflow: auto; height: calc(100vh - 250px); -} \ No newline at end of file +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/appsettings.json b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/appsettings.json index 91479fc7a..e6a993098 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/appsettings.json +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/appsettings.json @@ -154,6 +154,9 @@ "Generation": { "CooldownSeconds": 60 }, + "Logging": { + "EnablePromptFileLog": false + }, "Operations": { "Defaults": { "Provider": "OpenAI", @@ -190,9 +193,6 @@ } } }, - "Logging": { - "EnablePromptFileLog": false - }, "UNITY_GITHUB_PAT": "" } } diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/Runtime/AIProviderPayloadValidatorTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/Runtime/AIProviderPayloadValidatorTests.cs index 102250318..7f12a33ae 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/Runtime/AIProviderPayloadValidatorTests.cs +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/AI/Runtime/AIProviderPayloadValidatorTests.cs @@ -1,4 +1,5 @@ using Shouldly; +using System; using System.Text.Json; using Unity.AI.Runtime; using Xunit; @@ -202,4 +203,94 @@ public void ValidateFormWorksheetJson_Should_Return_Success_For_Complete_Workshe result.IsValid.ShouldBeTrue(); } + [Fact] + public void ValidateFormScoresheetJson_Should_Return_Success_For_Complete_Scoresheet() + { + var result = AIProviderPayloadValidator.ValidateFormScoresheetJson(ValidFormScoresheetJson); + + result.IsValid.ShouldBeTrue(); + } + + [Fact] + public void ValidateFormScoresheetJson_Should_Allow_Empty_Optional_Reporting_Fields() + { + var response = ValidFormScoresheetJson + .Replace("\"ReportColumns\": \"score\"", "\"ReportColumns\": \"\"", StringComparison.Ordinal) + .Replace("\"ReportKeys\": \"project_score\"", "\"ReportKeys\": \"\"", StringComparison.Ordinal) + .Replace("\"ReportViewName\": \"scoresheet_report\"", "\"ReportViewName\": \"\"", StringComparison.Ordinal); + + var result = AIProviderPayloadValidator.ValidateFormScoresheetJson(response); + + result.IsValid.ShouldBeTrue(); + } + + [Theory] + [InlineData("\"Title\": \"Generated scoresheet\"", "\"Title\": \"\"")] + [InlineData("\"Version\": 1", "\"Version\": \"1\"")] + [InlineData("\"Published\": true", "\"Published\": \"true\"")] + [InlineData("\"Definition\": \"{}\"", "\"Definition\": \"not-json\"")] + public void ValidateFormScoresheetJson_Should_Return_InvalidOutput_For_Invalid_Required_Value(string validValue, string invalidValue) + { + var result = AIProviderPayloadValidator.ValidateFormScoresheetJson(ValidFormScoresheetJson.Replace(validValue, invalidValue, StringComparison.Ordinal)); + + result.IsValid.ShouldBeFalse(); + result.FailureCategory.ShouldBe(AIFailureCategory.InvalidOutput); + } + + [Fact] + public void ValidateFormScoresheetJson_Should_Return_InvalidOutput_For_Duplicate_Question_Names() + { + var response = ValidFormScoresheetJson.Replace( + "\"Fields\": [", + "\"Fields\": [{ \"Name\": \"project_score\", \"Label\": \"Duplicate\", \"Order\": 1, \"Type\": 1, \"Definition\": \"{}\" },", + StringComparison.Ordinal); + + var result = AIProviderPayloadValidator.ValidateFormScoresheetJson(response); + + result.IsValid.ShouldBeFalse(); + result.Reason.ShouldContain("duplicate field names"); + } + + [Fact] + public void ValidateFormScoresheetJson_Should_Return_InvalidOutput_For_Duplicate_Section_Names() + { + var response = ValidFormScoresheetJson.Replace( + "\"Sections\": [", + "\"Sections\": [{ \"Name\": \"Review\", \"Order\": 1, \"Fields\": [{ \"Name\": \"second_score\", \"Label\": \"Second score\", \"Order\": 0, \"Type\": 1, \"Definition\": \"{}\" }] },", + StringComparison.Ordinal); + + var result = AIProviderPayloadValidator.ValidateFormScoresheetJson(response); + + result.IsValid.ShouldBeFalse(); + result.Reason.ShouldContain("duplicate section names"); + } + + private const string ValidFormScoresheetJson = """ + { + "Title": "Generated scoresheet", + "Name": "generated-scoresheet", + "Version": 1, + "Order": 0, + "Published": true, + "ReportColumns": "score", + "ReportKeys": "project_score", + "ReportViewName": "scoresheet_report", + "Sections": [ + { + "Name": "Review", + "Order": 0, + "Fields": [ + { + "Name": "project_score", + "Label": "Project score", + "Order": 0, + "Type": 1, + "Definition": "{}" + } + ] + } + ] + } + """; + }