diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000000..7380956107 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,44 @@ +# Unity Portal – Copilot Instructions (Repository Root) + +> This file is used by **Copilot on GitHub.com** (PR reviews, web chat). For local IDE instructions, see `applications/Unity.GrantManager/.github/copilot-instructions.md`. + +## Project Summary + +Unity is a **grant management portal** for the Province of British Columbia, built on **ABP Framework 9.1.3** with **.NET 9.0**, targeting **PostgreSQL 17**. The primary application code lives in `applications/Unity.GrantManager/`. + +**Key stack:** .NET 9 · ABP 9.1.3 · EF Core 9.0 · PostgreSQL 17 · Redis · RabbitMQ · xUnit · Shouldly · NSubstitute · AutoMapper · Cypress (E2E) + +## Repository Structure + +``` +applications/Unity.GrantManager/ ← Main .NET solution (developers open this) +applications/Unity.AutoUI/ ← Cypress E2E tests (TypeScript) +database/scripts/ ← SQL seed/migration scripts +documentation/ ← Technical docs +.github/workflows/ ← GitHub Actions CI/CD +``` + +## Key Conventions + +- **ABP Framework** modular monolith with DDD layered architecture +- **AutoMapper** for DTO mapping (not Mapperly) +- **Razor Pages** UI with custom ABP theme (Unity.Theme.UX2) +- Multi-tenant architecture with separate host/tenant database contexts +- `dev` → `test` → `main` branch promotion flow +- PRs to `dev` from `feature/*`, `hotfix/*`, `bugfix/*`; PRs to `main` from `test` or `hotfix/*` only + +## Build & Test (from `applications/Unity.GrantManager/`) + +```bash +dotnet restore Unity.GrantManager.sln +dotnet build Unity.GrantManager.sln --no-restore +dotnet test Unity.GrantManager.sln --no-build +``` + +All PRs must pass `dotnet build` and `dotnet test` before merge. The CI runs all `*Tests.csproj` in a parallel matrix. + +## Do NOT + +- Use Mapperly patterns — this project uses AutoMapper +- Create repositories for child entities — only aggregate roots get repositories +- Put business logic in application services — use domain entities/services diff --git a/.github/workflows/docker-build-dev.yml b/.github/workflows/docker-build-dev.yml index 5f33222ea5..e1968299ec 100644 --- a/.github/workflows/docker-build-dev.yml +++ b/.github/workflows/docker-build-dev.yml @@ -63,7 +63,7 @@ jobs: environment: dev steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: '1' - name: Get short commitId @@ -93,7 +93,7 @@ jobs: actions: write steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: '1' - name: Set repository version variables @@ -112,7 +112,7 @@ jobs: runs-on: ubuntu-latest environment: dev steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Build Docker images run: | rm -f ./docker-compose.override.yml diff --git a/.github/workflows/docker-build-main.yml b/.github/workflows/docker-build-main.yml index c0294fe062..892ddbf95d 100644 --- a/.github/workflows/docker-build-main.yml +++ b/.github/workflows/docker-build-main.yml @@ -63,7 +63,7 @@ jobs: environment: main steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: '1' - name: Get short commitId @@ -93,7 +93,7 @@ jobs: contents: write steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: '1' - name: Generate Next Git Tag @@ -146,7 +146,7 @@ jobs: actions: write steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: '1' - name: Set repository version variables @@ -169,7 +169,7 @@ jobs: runs-on: ubuntu-latest environment: main steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Build Docker images run: | rm -f ./docker-compose.override.yml diff --git a/.github/workflows/docker-build-test.yml b/.github/workflows/docker-build-test.yml index 3b7e9d91f0..96a43e594d 100644 --- a/.github/workflows/docker-build-test.yml +++ b/.github/workflows/docker-build-test.yml @@ -63,7 +63,7 @@ jobs: environment: test steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: '1' - name: Get short commitId @@ -91,7 +91,7 @@ jobs: environment: test steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: '1' - name: Generate Next Git Tag @@ -118,7 +118,7 @@ jobs: environment: test steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: '1' - name: Set repository version variables @@ -145,7 +145,7 @@ jobs: runs-on: ubuntu-latest environment: test steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Build Docker images run: | rm -f ./docker-compose.override.yml diff --git a/.github/workflows/manual-trigger.yml b/.github/workflows/manual-trigger.yml index 31415a5681..8737a62c80 100644 --- a/.github/workflows/manual-trigger.yml +++ b/.github/workflows/manual-trigger.yml @@ -59,7 +59,7 @@ jobs: environment: ${{ inputs.name }} steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: '1' - name: Get short commitId @@ -88,7 +88,7 @@ jobs: actions: write steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: '1' - name: Set repository version variables @@ -107,7 +107,7 @@ jobs: runs-on: ubuntu-latest environment: ${{ inputs.name }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Build Docker images run: | rm -f ./docker-compose.override.yml diff --git a/.github/workflows/pr-check-dev-branch.yml b/.github/workflows/pr-check-dev-branch.yml index aaadd97269..80f6731abd 100644 --- a/.github/workflows/pr-check-dev-branch.yml +++ b/.github/workflows/pr-check-dev-branch.yml @@ -44,7 +44,7 @@ jobs: outputs: matrix: ${{ steps.discover.outputs.matrix }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - id: discover run: | @@ -67,7 +67,7 @@ jobs: project: ${{ fromJson(needs.discover-test-projects.outputs.matrix) }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - uses: actions/setup-dotnet@v4 with: diff --git a/.github/workflows/pr-check-main-branch.yml b/.github/workflows/pr-check-main-branch.yml index 205a2c9d8c..cbb297ba79 100644 --- a/.github/workflows/pr-check-main-branch.yml +++ b/.github/workflows/pr-check-main-branch.yml @@ -40,7 +40,7 @@ jobs: outputs: matrix: ${{ steps.discover.outputs.matrix }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - id: discover run: | @@ -63,7 +63,7 @@ jobs: project: ${{ fromJson(needs.discover-test-projects.outputs.matrix) }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - uses: actions/setup-dotnet@v4 with: diff --git a/.github/workflows/pr-check-test-branch.yml b/.github/workflows/pr-check-test-branch.yml index e900094d21..7deb972735 100644 --- a/.github/workflows/pr-check-test-branch.yml +++ b/.github/workflows/pr-check-test-branch.yml @@ -42,7 +42,7 @@ jobs: outputs: matrix: ${{ steps.discover.outputs.matrix }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - id: discover run: | @@ -65,7 +65,7 @@ jobs: project: ${{ fromJson(needs.discover-test-projects.outputs.matrix) }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - uses: actions/setup-dotnet@v4 with: diff --git a/applications/Unity.GrantManager/.github/AGENTS.md b/applications/Unity.GrantManager/.github/AGENTS.md new file mode 100644 index 0000000000..649e86322f --- /dev/null +++ b/applications/Unity.GrantManager/.github/AGENTS.md @@ -0,0 +1,24 @@ +# ABP Workflow Agents + +This folder contains reusable Copilot agent definitions tailored to Unity Grant Manager ABP workflows. + +## Agent Catalog + +- `feature-planner.agent.md` - Breaks a feature request into ABP-aligned implementation steps. +- `ddd-modeler.agent.md` - Designs aggregate boundaries, invariants, repositories, and domain managers. +- `application-service-designer.agent.md` - Produces application contract and service design with DTO and mapping plans. +- `efcore-migration-planner.agent.md` - Plans host vs tenant EF Core changes and migration steps. +- `permissions-localization-auditor.agent.md` - Audits changes for missing permissions and localization compliance. +- `test-strategy.agent.md` - Generates ABP test plans with unit/integration split and scenario coverage. +- `test-triage.agent.md` - Diagnoses failing tests and proposes minimal-risk fix sequences. +- `pr-readiness.agent.md` - Runs a final ABP policy and quality gate before PR creation. + +## Usage + +Pick the agent that matches your workflow stage and provide: + +1. Feature or bug context. +2. Target module(s) and files. +3. Constraints (tenant scope, security, deadline, non-functional requirements). + +Each agent enforces ABP layering, AutoMapper usage, localization, and test conventions from repository instructions and skills. \ No newline at end of file diff --git a/applications/Unity.GrantManager/.github/agents/application-service-designer.agent.md b/applications/Unity.GrantManager/.github/agents/application-service-designer.agent.md new file mode 100644 index 0000000000..0126a8087a --- /dev/null +++ b/applications/Unity.GrantManager/.github/agents/application-service-designer.agent.md @@ -0,0 +1,43 @@ +--- +name: application-service-designer +description: Designs application contracts, DTOs, authorization, and AutoMapper mapping plans. +--- + +# ABP Application Service Designer Agent + +You are the application-layer design specialist for Unity Grant Manager. + +## Mission + +Produce ABP-compliant service contracts and implementation plans using DTO-first design. + +## Inputs + +- Use cases and API behavior. +- Existing service interfaces and DTOs. +- Target module and permissions. + +## Process + +1. Propose or update `I*AppService` method signatures. +2. Define DTOs per method intent (create, update, get, list). +3. Identify authorization requirements and permission constants. +4. Define AutoMapper profile changes. +5. Define validation and business-exception boundaries. + +## Output Format + +1. Contract changes. +2. DTO matrix. +3. Authorization matrix. +4. Mapping profile changes. +5. Service implementation checklist. +6. Test targets. + +## Guardrails + +- Apply `.github/skills/unity-application-layer/SKILL.md`. +- Follow `.github/instructions/csharp.instructions.md`. +- Methods must be async and end with `Async`. +- Accept/return DTOs only, never entities. +- Use AutoMapper with `ObjectMapper.Map<>()`, never Mapperly. diff --git a/applications/Unity.GrantManager/.github/agents/ddd-modeler.agent.md b/applications/Unity.GrantManager/.github/agents/ddd-modeler.agent.md new file mode 100644 index 0000000000..5e24457414 --- /dev/null +++ b/applications/Unity.GrantManager/.github/agents/ddd-modeler.agent.md @@ -0,0 +1,44 @@ +--- +name: ddd-modeler +description: Designs and reviews ABP DDD models, aggregates, repositories, and domain managers. +--- + +# ABP DDD Modeler Agent + +You are the DDD modeling specialist for Unity Grant Manager. + +## Mission + +Design or review domain models so business invariants are enforced in the correct ABP layer. + +## Inputs + +- Business rules and scenarios. +- Existing entities and repository interfaces. +- Target module. + +## Process + +1. Define aggregate boundaries and ownership rules. +2. Identify entity/value object responsibilities. +3. Propose behavior methods that enforce invariants. +4. Define repository contract additions only for aggregate roots. +5. Define domain service responsibilities (`*Manager`) where orchestration is needed. +6. Propose business error codes and exception points. + +## Output Format + +1. Aggregate model proposal. +2. Invariants and rule enforcement table. +3. Repository contract changes. +4. Domain manager methods. +5. Error code list. +6. Anti-pattern checks. + +## Guardrails + +- Apply `.github/skills/unity-domain-driven-design/SKILL.md`. +- Follow `.github/instructions/csharp.instructions.md`. +- Do not generate GUIDs in entity constructors. +- Reference external aggregates by Id only. +- Keep app-service logic out of the domain model design. diff --git a/applications/Unity.GrantManager/.github/agents/efcore-migration-planner.agent.md b/applications/Unity.GrantManager/.github/agents/efcore-migration-planner.agent.md new file mode 100644 index 0000000000..6605be3cec --- /dev/null +++ b/applications/Unity.GrantManager/.github/agents/efcore-migration-planner.agent.md @@ -0,0 +1,43 @@ +--- +name: efcore-migration-planner +description: Plans EF Core model updates and host versus tenant migrations safely. +--- + +# ABP EF Core Migration Planner Agent + +You are the EF Core migration planning specialist for Unity Grant Manager. + +## Mission + +Plan schema changes, mapping updates, and migration execution for the correct database context. + +## Inputs + +- Proposed entity/model changes. +- Whether data is host-wide or tenant-scoped. +- Existing migrations and repository code. + +## Process + +1. Classify each change as host, tenant, or both. +2. Propose `ModelBuilder` mapping updates. +3. Verify repository impact and query behavior. +4. Produce migration commands and ordering. +5. Identify rollback and data backfill considerations. + +## Output Format + +1. Context classification. +2. Mapping change checklist. +3. Migration command plan. +4. Data safety notes. +5. Repository update checklist. +6. Validation tests. + +## Guardrails + +- Apply `.github/skills/unity-ef-core/SKILL.md`. +- Follow `.github/instructions/efcore.instructions.md`. +- Always call `ConfigureByConvention()` for mapped entities. +- Do not use `includeAllEntities: true` with default repositories. +- Always specify context for migration commands. diff --git a/applications/Unity.GrantManager/.github/agents/feature-planner.agent.md b/applications/Unity.GrantManager/.github/agents/feature-planner.agent.md new file mode 100644 index 0000000000..adb12769d6 --- /dev/null +++ b/applications/Unity.GrantManager/.github/agents/feature-planner.agent.md @@ -0,0 +1,52 @@ +--- +name: feature-planner +description: Plans feature implementation across Domain, Application, EF Core, Web, and tests. +tools: ['fetch', 'githubRepo', 'problems', 'usages', 'search', 'todos', 'runSubagent'] +--- + +# ABP Feature Planner Agent + +You are the feature planning specialist for Unity Grant Manager. + +## Mission + +Convert a feature request into an implementation plan that respects ABP modular layering and delivery flow. + +## Inputs + +- Feature or bug statement. +- Acceptance criteria. +- Target module(s). +- Any constraints (timeline, migration risk, tenant scope, security requirements). + +## Process + +1. Identify module ownership and whether the change is host, tenant, or both. +2. Split work by layer: + - Domain.Shared + - Domain + - Application.Contracts + - Application + - EntityFrameworkCore + - HttpApi/Web + - Tests +3. List dependencies and ordering constraints. +4. Flag cross-module impacts and permission/localization requirements. + +## Output Format + +Return sections in this order: + +1. Scope summary. +2. Layer-by-layer implementation tasks. +3. Migration and data impact. +4. Test plan summary. +5. Risks and mitigations. +6. Definition of done checklist. + +## Guardrails + +- Enforce module dependency direction from `.github/skills/unity-module-structure/SKILL.md`. +- Enforce ABP app/domain rules from `.github/instructions/csharp.instructions.md`. +- Do not use Mapperly. Use AutoMapper. +- Do not place business rules in controllers or app services. diff --git a/applications/Unity.GrantManager/.github/agents/permissions-localization-auditor.agent.md b/applications/Unity.GrantManager/.github/agents/permissions-localization-auditor.agent.md new file mode 100644 index 0000000000..7d8738b3a8 --- /dev/null +++ b/applications/Unity.GrantManager/.github/agents/permissions-localization-auditor.agent.md @@ -0,0 +1,40 @@ +--- +name: permissions-localization-auditor +description: Audits ABP changes for permission coverage, localization correctness, and policy compliance. +--- + +# ABP Permissions and Localization Auditor Agent + +You are the ABP compliance auditing specialist for Unity Grant Manager. + +## Mission + +Review code changes for missing permissions, hardcoded strings, and user-facing policy gaps. + +## Inputs + +- Diff or list of changed files. +- Affected user flows and roles. + +## Process + +1. Check service methods and endpoints for authorization attributes/policies. +2. Verify permission constants and definition provider coverage. +3. Scan for hardcoded user-facing text. +4. Verify localization key usage and resource updates. +5. Identify likely regressions and required tests. + +## Output Format + +1. Findings by severity. +2. Missing permissions list. +3. Localization findings list. +4. Required code changes. +5. Validation checklist. + +## Guardrails + +- Follow `.github/copilot-instructions.md` and `.github/instructions/csharp.instructions.md`. +- All user-facing text must be localized. +- Permissions must be defined in Application.Contracts permission providers. +- Do not propose hardcoded strings in services, controllers, or UI code. diff --git a/applications/Unity.GrantManager/.github/agents/pr-readiness.agent.md b/applications/Unity.GrantManager/.github/agents/pr-readiness.agent.md new file mode 100644 index 0000000000..88bd2466a0 --- /dev/null +++ b/applications/Unity.GrantManager/.github/agents/pr-readiness.agent.md @@ -0,0 +1,41 @@ +--- +name: pr-readiness +description: Performs pre-PR quality gate checks for build, tests, layering, and policy compliance. +--- + +# ABP PR Readiness Agent + +You are the final quality gate specialist for Unity Grant Manager pull requests. + +## Mission + +Evaluate if a branch is ready for PR against ABP architecture, policy, and CI expectations. + +## Inputs + +- Branch diff. +- Build and test status. +- Target branch. + +## Process + +1. Verify branch policy and PR source/target compatibility. +2. Check layering boundaries and module dependency direction. +3. Check mapping, DTO boundaries, localization, and permissions. +4. Check migration context correctness when EF changes exist. +5. Confirm test coverage and CI command readiness. + +## Output Format + +1. Go/No-go recommendation. +2. Blocking issues. +3. Non-blocking improvements. +4. Required validation commands. +5. PR description checklist. + +## Guardrails + +- Follow `.github/copilot-instructions.md`. +- Require `dotnet build Unity.GrantManager.sln --no-restore` and `dotnet test Unity.GrantManager.sln --no-build` readiness. +- Enforce ABP module layering rules from `.github/skills/unity-module-structure/SKILL.md`. +- Enforce AutoMapper, localization, and permissions conventions. diff --git a/applications/Unity.GrantManager/.github/agents/test-strategy.agent.md b/applications/Unity.GrantManager/.github/agents/test-strategy.agent.md new file mode 100644 index 0000000000..50c706d34f --- /dev/null +++ b/applications/Unity.GrantManager/.github/agents/test-strategy.agent.md @@ -0,0 +1,43 @@ +--- +name: test-strategy +description: Builds test strategy with xUnit, Shouldly, NSubstitute, and layered coverage. +tools: ['codebase', 'problems', 'usages', 'runTests', 'githubRepo'] +--- + +# ABP Test Strategy Agent + +You are the testing strategy specialist for Unity Grant Manager. + +## Mission + +Create a practical, risk-focused test plan for new features or bug fixes across ABP layers. + +## Inputs + +- Feature scope or code diff. +- Changed modules and layers. +- Known edge cases. + +## Process + +1. Identify impacted behavior per layer. +2. Split test coverage into unit, integration, and optional web tests. +3. Propose fixtures and test data setup. +4. Map scenarios to concrete test cases. +5. Prioritize tests for fastest feedback. + +## Output Format + +1. Coverage scope summary. +2. Unit test cases. +3. Integration test cases. +4. Test data and fixture requirements. +5. Execution order and commands. + +## Guardrails + +- Apply `.github/skills/unity-testing/SKILL.md`. +- Follow `.github/instructions/testing.instructions.md`. +- Use xUnit with Shouldly and NSubstitute. +- Avoid `Assert.*` and Moq patterns. +- Keep tests deterministic and isolated. diff --git a/applications/Unity.GrantManager/.github/agents/test-triage.agent.md b/applications/Unity.GrantManager/.github/agents/test-triage.agent.md new file mode 100644 index 0000000000..a0903abe7a --- /dev/null +++ b/applications/Unity.GrantManager/.github/agents/test-triage.agent.md @@ -0,0 +1,42 @@ +--- +name: test-triage +description: Diagnoses failing tests, isolates root cause, and proposes minimal-risk fixes. +tools: ['codebase', 'problems', 'usages', 'runTests', 'githubRepo'] +--- + +# ABP Test Triage Agent + +You are the failure triage specialist for Unity Grant Manager tests. + +## Mission + +Analyze failing tests and identify the smallest reliable fix path while minimizing regressions. + +## Inputs + +- Test output logs. +- Recent code diff. +- Affected project/module. + +## Process + +1. Classify failure type (assertion mismatch, setup, infrastructure, async timing, mapping, auth). +2. Correlate failing tests with changed code paths. +3. Identify probable root cause and confidence level. +4. Propose minimum fix sequence with verification steps. +5. Identify regression tests that must be added or updated. + +## Output Format + +1. Failure summary. +2. Root-cause hypotheses ranked by probability. +3. Recommended fix path. +4. Verification command checklist. +5. Regression prevention tests. + +## Guardrails + +- Use module/layer rules from `.github/skills/unity-module-structure/SKILL.md`. +- Use testing conventions from `.github/skills/unity-testing/SKILL.md`. +- Prefer minimal changes over broad refactors during triage. +- Do not bypass failing tests by weakening assertions without justification. diff --git a/applications/Unity.GrantManager/.github/commit-message-instructions.md b/applications/Unity.GrantManager/.github/commit-message-instructions.md new file mode 100644 index 0000000000..f716456f33 --- /dev/null +++ b/applications/Unity.GrantManager/.github/commit-message-instructions.md @@ -0,0 +1 @@ +Format commit message starting with [AB#] where is extracted from the branch name (e.g., from 'feature/AB#32037-...' extract '32037' as ID), followed by a short description. Aim for 50 characters after the prefix when possible but prioritize clarity. \ No newline at end of file diff --git a/applications/Unity.GrantManager/.github/copilot-instructions.md b/applications/Unity.GrantManager/.github/copilot-instructions.md new file mode 100644 index 0000000000..b54ee4fc9b --- /dev/null +++ b/applications/Unity.GrantManager/.github/copilot-instructions.md @@ -0,0 +1,204 @@ +# Unity Grant Manager – Copilot Instructions + +> **Trust these instructions first.** Only search the codebase when information here is incomplete or incorrect. +> This is NOT the Unity game engine. Do not suggest UnityEngine APIs. + +## Project Overview + +Unity Grant Manager is a **grant management portal** for the Province of British Columbia, built on **ABP Framework 9.1.3** with **.NET 9.0**, targeting **PostgreSQL 17**. The UI uses **Razor Pages** with a custom ABP theme (Unity.Theme.UX2). The architecture follows ABP's **Domain-Driven Design (DDD)** layered module pattern. + +**Key stack:** .NET 9 · ABP 9.1.3 · EF Core 9.0 · PostgreSQL 17 · Redis · RabbitMQ · xUnit · Shouldly · NSubstitute · AutoMapper · Cypress (E2E) + +## Repository Layout + +``` +Unity.GrantManager.sln ← Solution file (63 projects) +common.props ← Shared MSBuild properties +Directory.Build.props ← Global build props (suppresses NU1701, MSB3277) +NuGet.Config ← NuGet package sources +.env.example ← Environment variable template +docker-compose.yml ← Docker dev environment +src/ + Unity.GrantManager.Web/ ← Razor Pages web app (entry point) + Unity.GrantManager.Application/ ← App services, AutoMapper profiles + Unity.GrantManager.Application.Contracts/ ← DTOs, interfaces + Unity.GrantManager.Domain/ ← Entities, repositories, domain services + Unity.GrantManager.Domain.Shared/ ← Enums, constants, localization (en.json) + Unity.GrantManager.EntityFrameworkCore/ ← DbContext, migrations, EF config + Unity.GrantManager.HttpApi/ ← REST controllers + Unity.GrantManager.HttpApi.Client/ ← Remote service client proxies + Unity.GrantManager.DbMigrator/ ← Database migration console app +test/ + Unity.GrantManager.TestBase/ ← Shared test fixtures + Unity.GrantManager.Application.Tests/ ← App service tests + Unity.GrantManager.Domain.Tests/ ← Domain logic tests + Unity.GrantManager.EntityFrameworkCore.Tests/ + Unity.GrantManager.Web.Tests/ +modules/ ← ABP modules (each with src/ and test/) + Unity.Flex/ ← Dynamic forms/worksheets + Unity.Notifications/ ← Email/messaging (full-stack module) + Unity.Payments/ ← Financial transactions + Unity.Reporting/ ← Analytics & reports + Unity.AI/ ← AI-powered analysis (OpenAI) + Unity.TenantManagement/ ← Multi-tenant admin + Unity.Identity.Web/ ← OIDC authentication + Unity.Theme.UX2/ ← Custom Razor Pages theme + Unity.SharedKernel/ ← Cross-cutting utilities +``` + +## Build & Test Commands + +All commands run from this directory (`applications/Unity.GrantManager/`). + +### Restore & Build + +```bash +dotnet restore Unity.GrantManager.sln +dotnet build Unity.GrantManager.sln --no-restore +``` + +- Build takes ~3 minutes. The solution has 63 projects. +- There is **1 expected warning** in `Unity.GrantManager.Web/Pages/Dashboard/Index.cshtml.cs` (CS8604 null reference). Do not try to fix it unless explicitly asked. +- `Directory.Build.props` suppresses NU1701 and MSB3277 warnings globally; do not re-add these suppressions in individual projects. +- `common.props` sets `latest` and suppresses CS1591. + +### Run All Tests + +```bash +dotnet test Unity.GrantManager.sln --no-build +``` + +- ~470 tests across 15 test projects. All use **xUnit** with **Shouldly** assertions and **NSubstitute** mocks. +- Tests use in-memory database providers (**SQLite** for most projects; **EFCore.InMemory** for `Unity.GrantManager.Web.Tests`) instead of PostgreSQL. No database setup required. +- Test run takes ~1–2 minutes after build. +- To run a single test project: `dotnet test test/Unity.GrantManager.Application.Tests/ --no-build` + +### EF Core Migrations + +The solution has **two database contexts** — always specify which context: + +```bash +cd src/Unity.GrantManager.EntityFrameworkCore + +# Host migrations (shared/system tables) +dotnet ef migrations add --context GrantManagerDbContext --output-dir Migrations/HostMigrations + +# Tenant migrations (per-tenant isolated data) +dotnet ef migrations add --context GrantTenantDbContext --output-dir Migrations/TenantMigrations +``` + +## CI Pipeline (PR Checks) + +Every PR triggers branch-specific GitHub Actions workflows (in the repo root `.github/workflows/`) that: + +1. **Validate source branch** — PRs to `dev` must come from `feature/*`, `hotfix/*`, `bugfix/*`, `test`, or `main`. PRs to `main` must come from `test` or `hotfix/*` only. +2. **Discover test projects** — Finds all `*Tests.csproj` files automatically. +3. **Run tests in parallel matrix** — Each test project runs independently with `dotnet test` using .NET 9.0.x. +4. **Aggregate results** — Posts pass/fail badge as PR comment. + +Always ensure `dotnet build` and `dotnet test` pass before submitting changes. + +## Architecture Rules + +### ABP Layering (Current Wiring + Preferred Usage) + +Current solution references are broader than a strict textbook layer graph: + +``` +Web → Application + HttpApi + HttpApi.Client + EntityFrameworkCore +HttpApi → Application.Contracts + Domain +Application → Domain + Application.Contracts +Domain → Domain.Shared +EntityFrameworkCore → Domain +``` + +Preferred code-usage boundaries (guidance for new code): + +- Prefer keeping web/UI concerns out of Application and Domain. +- Prefer keeping business rules in Domain entities/managers, not in controllers or pages. +- Prefer controllers/endpoints to use Application.Contracts surfaces for use-case orchestration. +- Avoid introducing new cross-layer dependencies unless there is a clear ABP/module-composition reason. +- Do not call other application services within the same module; move shared logic to Domain services/helpers. + +### Entity & Domain Conventions + +- Entities use **rich domain model**: private/protected setters, behavior via methods. +- Include `protected` parameterless constructor for EF Core. +- Do **not** generate `Guid` keys inside constructors; accept `id` from `IGuidGenerator`. +- Reference other aggregate roots **by Id only**, not navigation properties. +- Domain services use `*Manager` suffix. +- Throw `BusinessException` with namespaced error codes for rule violations. + +### Application Layer + +- Interface naming: `I*AppService` inheriting `IApplicationService`. +- All methods `async`, end with `Async`. +- Accept/return **DTOs only**, never entities. Define DTOs in `*.Application.Contracts`. +- Make all public methods `virtual` for extensibility. +- This project uses **AutoMapper** (not Mapperly). Mapping profiles are `*AutoMapperProfile.cs` inheriting `Profile`. +- `ObjectMapper.Map<>()` is used for DTO mapping, not Mapperly partials. + +### EF Core + +- Entity configuration uses extension methods (`ConfigureMyProject()` on `ModelBuilder`), not inline in `OnModelCreating`. +- Always call `b.ConfigureByConvention()` for every entity mapping. +- Use `options.AddDefaultRepositories()` without `includeAllEntities: true`. +- Repository implementations inherit `EfCoreRepository`. + +### Testing Conventions + +- Test class naming: `*Tests.cs` +- Base class hierarchy: `AbpIntegratedTest` → `GrantManagerTestBase` → domain-specific bases. +- Use `[Fact]` for single tests, `[Theory]` with `[InlineData]` for parameterized. +- Assertions: `Shouldly` (`result.ShouldBe(expected)`, `result.ShouldNotBeNull()`). +- Mocking: `NSubstitute` (`Substitute.For()`). +- JSON test fixtures loaded from `AppDomain.CurrentDomain.BaseDirectory`. + +### Code Style + +- **Prettier** for JS/CSS: single quotes, 4-space tabs, no tabs. +- **C#**: Latest language version, nullable enabled in most projects. +- Localization: English strings in `*/Localization/*/en.json`. All user-facing text must use localization; no hardcoded English in code. +- Permissions defined in `*PermissionDefinitionProvider` in Application.Contracts. + +### Branching + +- `dev` → `test` → `main` promotion flow. +- Feature work: `feature/*`, bug fixes: `bugfix/*`, urgent fixes: `hotfix/*`. + +## Skills + +For detailed ABP patterns (DDD, application services, EF Core, testing, module architecture), refer to `.github/skills/` for domain-specific guidance. + +## Agents + +Use the ABP workflow agents in `.github/agents/` to accelerate planning, development, and testing. + +### When to Use Each Agent + +- `feature-planner`: Convert a feature request or bug into a layered ABP implementation plan. +- `ddd-modeler`: Design or review aggregates, invariants, repositories, and domain managers. +- `application-service-designer`: Define app service contracts, DTOs, authorization, and AutoMapper changes. +- `efcore-migration-planner`: Plan host vs tenant EF Core model updates and migration steps safely. +- `permissions-localization-auditor`: Audit diffs for permission coverage, localization keys, and hardcoded text. +- `test-strategy`: Build risk-based unit/integration test plans for ABP features. +- `test-triage`: Diagnose failing tests and propose minimal-risk fixes with verification steps. +- `pr-readiness`: Run ABP-focused pre-PR quality checks (layering, mapping, tests, migration correctness). + +### Usage Guidance + +Provide these inputs when invoking an agent: + +1. Feature or bug context. +2. Target module(s) and affected layers. +3. Tenant scope (host, tenant, or both). +4. Constraints (security, timeline, performance, backward compatibility). + +Use multiple agents in sequence for larger work: + +1. `feature-planner` +2. `ddd-modeler` and/or `abp-application-service-designer` +3. `efcore-migration-planner` (if schema changes exist) +4. `test-strategy` or `abp-test-triage` +5. `permissions-localization-auditor` +6. `pr-readiness` diff --git a/applications/Unity.GrantManager/.github/instructions/csharp.instructions.md b/applications/Unity.GrantManager/.github/instructions/csharp.instructions.md new file mode 100644 index 0000000000..aa9978fb26 --- /dev/null +++ b/applications/Unity.GrantManager/.github/instructions/csharp.instructions.md @@ -0,0 +1,120 @@ +--- +applyTo: "**/*.cs" +description: "C# and .NET 9 development standards for ABP Framework 9.1.3" +--- + +# C# Conventions for Unity Grant Manager + +- Target framework: .NET 9.0 with `latest`. +- Nullable reference types are enabled in most projects. +- This is an ABP Framework project. Use ABP base classes, not raw ASP.NET Core. +- This is NOT the Unity game engine. Do not suggest UnityEngine APIs. + +## ABP Base Classes + +- Application Services: Inherit `ApplicationService`, implement interface from Application.Contracts +- Domain Services: Inherit `DomainService`, use `Manager` suffix +- Entities: Inherit `FullAuditedAggregateRoot` or `AuditedAggregateRoot` +- API Controllers: Inherit `AbpController` +- Repositories: Use `IRepository` by default; custom only when needed + +### Injected Properties Available in Base Classes + +These properties are pre-injected in `ApplicationService`, `DomainService`, and `AbpController`: + +| Property | Purpose | +|---|---| +| `GuidGenerator` | Create new entity IDs — never use `Guid.NewGuid()` | +| `Clock` | Use `Clock.Now` — never use `DateTime.Now` or `DateTime.UtcNow` | +| `CurrentUser` | Access authenticated user (Id, Name, Email, Roles) | +| `CurrentTenant` | Access current tenant context (Id, Name) | +| `L` / `L["Key"]` | Localization shortcut | +| `ObjectMapper` | AutoMapper-based mapping | +| `Logger` | Structured logging via `ILogger` | +| `AuthorizationService` | Programmatic authorization checks | +| `UnitOfWorkManager` | Manual unit-of-work control | + +## Dependency Injection + +- ABP auto-registers services using marker interfaces — do NOT manually call `services.AddScoped<>()` +- `ITransientDependency` — new instance per injection +- `ISingletonDependency` — single shared instance +- `IScopedDependency` — one per request +- Application services, domain services, and repositories are auto-registered by ABP + +## Entities & Domain + +- Entities use rich domain model: private/protected setters, behaviour via methods. +- Include `protected` parameterless constructor for EF Core deserialization. +- Do not generate `Guid` keys inside constructors; accept `id` from `IGuidGenerator`. +- Reference other aggregate roots by Id only, not navigation properties. +- Domain services use `*Manager` suffix. +- Throw `BusinessException` with namespaced error codes for rule violations. + +## Application Services + +- Interface naming: `I*AppService` inheriting `IApplicationService`. +- All methods `async`, name ends with `Async`. +- Accept/return DTOs only, never entities. Define DTOs in `*.Application.Contracts`. +- Make all public methods `virtual`. +- Use **AutoMapper** (`ObjectMapper.Map<>()`) for DTO mapping. Do NOT use Mapperly. +- Mapping profiles: `*AutoMapperProfile.cs` inheriting `Profile`. + +## Code Style + +- 4 spaces indentation, no tabs +- No emojis in comments +- Always use braces, even for single-line statements +- Use `nameof` instead of string literals when referring to member names +- Prefer pattern matching and switch expressions where appropriate +- All user-facing text must be localized via `L["Key"]`. No hardcoded English strings. +- Permissions defined in `*PermissionDefinitionProvider` in Application.Contracts. +- Do not call other application services within the same module; push shared logic to domain services. + +## Naming Conventions + +- Follow PascalCase for public members, types, and methods +- Use camelCase for private fields and local variables +- Prefix interface names with `I` +- Domain Services: `*Manager` suffix (e.g., `AssessmentManager`) +- Application Services: `*AppService` suffix (e.g., `ApplicationAppService`) +- DTOs: Descriptive suffixes (`CreateApplicationDto`, `UpdateApplicationDto`, `ApplicationDto`) +- Event Transfer Objects: `*Eto` suffix for distributed events + + +## DTOs vs Entities + +- Application services MUST accept and return DTOs only, never entities +- Use `ObjectMapper` (AutoMapper) to map between entities and DTOs +- Define mapping profiles in `*AutoMapperProfile` class in Application project + +## Authorization + +- Apply `[Authorize(PermissionName)]` attributes on application service methods +- Define permissions in `*Permissions` static class in Domain.Shared project + +## Multi-Tenancy + +- Tenant entities MUST implement `IMultiTenant` interface +- NEVER manually filter by `TenantId` — ABP handles this automatically +- Use `GrantTenantDbContext` for tenant data, `GrantManagerDbContext` for host data + +## Error Handling + +- Use `BusinessException` for domain-level errors with namespaced error codes (e.g., `"GrantManager:ApplicationNotFound"`) +- Map error codes to localization keys for user-friendly messages +- Use `.WithData("key", value)` for localized message interpolation +- Catch specific exception types, not generic `Exception` + +## Common Mistakes to Avoid + +- Don't expose entities from application services — always return DTOs +- Don't put business logic in application services — use domain services +- Don't create custom repositories unnecessarily — use generic `IRepository` first +- Don't mix host and tenant data in same DbContext +- Don't ignore nullable warnings — fix them properly +- Don't use `DateTime.Now` — use `Clock.Now` or inject `IClock` +- Don't use `Guid.NewGuid()` — use `GuidGenerator.Create()` +- Don't use `services.AddScoped<>()` for ABP services — use marker interfaces +- Don't call application services from within the same module — extract shared logic to a domain service +- Don't embed entity name in app service methods — use `GetAsync`, not `GetApplicationAsync` diff --git a/applications/Unity.GrantManager/.github/instructions/efcore.instructions.md b/applications/Unity.GrantManager/.github/instructions/efcore.instructions.md new file mode 100644 index 0000000000..d308722720 --- /dev/null +++ b/applications/Unity.GrantManager/.github/instructions/efcore.instructions.md @@ -0,0 +1,25 @@ +--- +applyTo: "**/EntityFrameworkCore/**/*.cs" +--- + +# EF Core Conventions for Unity Grant Manager + +- Provider: **Npgsql** (PostgreSQL 17). +- Two database contexts: `GrantManagerDbContext` (host) and `GrantTenantDbContext` (tenant). +- Entity configuration is done inline in `OnModelCreating` of `GrantManagerDbContext` and `GrantTenantDbContext`. +- When configuring entities, follow ABP conventions (e.g., table naming, key configuration) consistently. +- Use `options.AddDefaultRepositories(includeAllEntities: true)` in `GrantManagerEntityFrameworkCoreModule`. +- Prefer ABP's generated default repositories; add custom repositories only when additional behavior is required. +- Tests use **SQLite in-memory** databases, not PostgreSQL. + +## Migrations + +Always specify the context when adding migrations: + +```bash +# Host migrations +dotnet ef migrations add --context GrantManagerDbContext --output-dir Migrations/HostMigrations + +# Tenant migrations +dotnet ef migrations add --context GrantTenantDbContext --output-dir Migrations/TenantMigrations +``` diff --git a/applications/Unity.GrantManager/.github/instructions/javascript.instructions.md b/applications/Unity.GrantManager/.github/instructions/javascript.instructions.md new file mode 100644 index 0000000000..075bffde01 --- /dev/null +++ b/applications/Unity.GrantManager/.github/instructions/javascript.instructions.md @@ -0,0 +1,54 @@ +--- +applyTo: "**/*.js" +description: "JavaScript development standards for ABP Framework frontend patterns" +--- + +# JavaScript Development Standards + +- Variables should be declared with "let" or "const" instead of "var" + +## General Patterns + +- Wrap all page scripts in IIFE: `(function ($) { ... })(jQuery);` +- Never create global JavaScript variables +- Use `var l = abp.localization.getResource('GrantManager');` for all user-facing text +- Use ABP's dynamic JavaScript API client proxies instead of manual AJAX + +## ABP JavaScript Utilities + +- Notifications: `abp.notify.success()`, `.error()`, `.warn()`, `.info()` +- Confirmation: `abp.message.confirm()` for destructive actions +- Authorization: `abp.auth.isGranted()` for permission checks +- Busy indicators: `abp.ui.setBusy()` / `abp.ui.clearBusy()` +- Localization: `l('LocalizationKey')` — never hardcode user-facing strings + +## DataTables Integration + +- Use DataTables.net 2.x with Bootstrap 5 integration (`datatables.net-bs5`) +- Always wrap configuration with `abp.libs.datatables.normalizeConfiguration()` +- Use `abp.libs.datatables.createAjax()` for server-side pagination +- Use `rowAction` for action buttons with `abp.auth.isGranted()` visibility checks +- Use `dataFormat` property for automatic date/boolean formatting +- Always call `dataTable.ajax.reload()` after CRUD operations + +## Modal Manager + +- Use `abp.ModalManager` for all modal dialogs +- Configure with `viewUrl`, `scriptUrl`, and `modalClass` +- Implement `onResult()` callback to reload DataTable after save +- Modal script classes: register in `abp.modals.*` namespace +- Return `NoContent()` from Razor Page handler to close modal + +## DOM Auto-Initialization + +- ABP auto-initializes: tooltips, popovers, datepickers, AJAX forms, autocomplete selects +- Use `data-bs-toggle="tooltip"` for tooltips +- Use `class="auto-complete-select"` with `data-autocomplete-*` attributes for lookups +- Use `data-ajaxForm="true"` for AJAX form submission + +## Client-Side Package Management + +- Add NPM packages to `package.json`, prefer `@abp/*` packages +- Configure `abp.resourcemapping.js` to map from `node_modules` to `wwwroot/libs` +- Run `abp install-libs` to copy resources +- Add to bundle contributor in `Unity.Theme.UX2` module \ No newline at end of file diff --git a/applications/Unity.GrantManager/.github/instructions/security.instructions.md b/applications/Unity.GrantManager/.github/instructions/security.instructions.md new file mode 100644 index 0000000000..bf95dea629 --- /dev/null +++ b/applications/Unity.GrantManager/.github/instructions/security.instructions.md @@ -0,0 +1,48 @@ +--- +applyTo: "**/*.cs,**/*.cshtml,**/*.js" +description: "Security best practices for Unity Grant Manager" +--- + +# Security Standards + +## Authorization + +- Apply `[Authorize(PermissionName)]` attributes on all application service methods +- Define permissions in `*Permissions` static class in Domain.Shared project +- Use `abp.auth.isGranted()` in JavaScript for UI permission checks +- Never rely solely on UI-level permission hiding — always enforce server-side + +## Multi-Tenancy Security + +- Never manually filter by `TenantId` — ABP handles tenant isolation automatically +- Ensure tenant-scoped entities implement `IMultiTenant` +- Test cross-tenant data isolation explicitly +- Use `GrantTenantDbContext` for tenant data, `GrantManagerDbContext` for host data +- Be cautious with `[IgnoreMultiTenancy]` — understand the security implications + +## Input Validation + +- Validate all inputs at the application service boundary using data annotations or FluentValidation +- Use ABP's `Check.*` methods for domain-level validation (e.g., `Check.NotNullOrWhiteSpace`) +- Sanitize user inputs before storage — prevent XSS and injection attacks +- Use parameterized queries — never concatenate user input into SQL + +## Secrets Management + +- Never commit secrets, connection strings, or API keys to source code +- Use environment variables or secure configuration providers +- Reference `.env.example` for required environment variables +- Sensitive configuration is stored in OpenShift secrects and Hashicorp Vault when deployed + +## Authentication + +- Authentication is handled via Keycloak (OpenID Connect) +- Do not implement custom authentication — use ABP's identity infrastructure +- Ensure all API endpoints require authentication unless explicitly public + +## Data Protection + +- Use Redis-backed data protection for key storage in distributed deployments +- Encrypt sensitive data at rest when required by compliance +- Follow government security standards (BC Government policies) +- Audit logging is enabled via ABP — ensure sensitive operations are captured \ No newline at end of file diff --git a/applications/Unity.GrantManager/.github/instructions/testing.instructions.md b/applications/Unity.GrantManager/.github/instructions/testing.instructions.md new file mode 100644 index 0000000000..5117256fcd --- /dev/null +++ b/applications/Unity.GrantManager/.github/instructions/testing.instructions.md @@ -0,0 +1,30 @@ +--- +applyTo: "**/test/**/*.cs" +--- + +# Testing Conventions for Unity Grant Manager + +- Framework: **xUnit 2.9.3** with **Shouldly 4.3.0** assertions and **NSubstitute 5.3.0** mocks. +- Tests use in-memory database providers (SQLite in-memory for most test projects; `Unity.GrantManager.Web.Tests` uses `Microsoft.EntityFrameworkCore.InMemory`). No external PostgreSQL/database setup is required. +- Test class naming: `*Tests.cs`. +- Base class hierarchy: `AbpIntegratedTest` → `GrantManagerTestBase` → domain-specific bases. +- Use `[Fact]` for single tests, `[Theory]` with `[InlineData]` for parameterized. +- Assertions: Shouldly (`result.ShouldBe(expected)`, `result.ShouldNotBeNull()`). Do NOT use `Assert.*`. +- Mocking: NSubstitute (`Substitute.For()`). Do NOT use Moq. +- JSON test fixtures loaded from `AppDomain.CurrentDomain.BaseDirectory`. +- Run all tests: `dotnet test Unity.GrantManager.sln --no-build` +- Test method naming: `Should_[Expected]_[Scenario]` +- Follow Arrange-Act-Assert pattern consistently +- Do not emit "Arrange", "Act", or "Assert" comments in generated tests + +## Multi-Tenancy Testing + +- Test tenant data isolation using `CurrentTenant.Change(tenantId)` +- Verify that data created in one tenant is not visible in another +- Test both host-level and tenant-level operations + +## Test Data Management + +- Use helper methods for test data creation (e.g., `CreateTestApplicationAsync()`) +- Use static test data constants for well-known IDs +- Keep test data self-contained — each test should set up its own state diff --git a/applications/Unity.GrantManager/.github/skills/abp-cli/SKILL.md b/applications/Unity.GrantManager/.github/skills/abp-cli/SKILL.md new file mode 100644 index 0000000000..df1fd7b055 --- /dev/null +++ b/applications/Unity.GrantManager/.github/skills/abp-cli/SKILL.md @@ -0,0 +1,78 @@ +--- +name: abp-cli +description: ABP CLI commands - generate-proxy, install-libs, add-package-ref, new-module, install-module, abp update, abp clean, abp suite generate. Use when the user asks how to run ABP CLI commands, generate proxies, install libraries, or use ABP Suite. +--- + +# ABP CLI Commands + +> **Full documentation**: https://abp.io/docs/latest/cli +> Use `abp help [command]` for detailed options. + +## Generate Client Proxies + +```bash +# URL flag: `-u` (short) or `--url` (long). Use whichever your team prefers, but keep it consistent. +# +# Angular (host must be running) +abp generate-proxy -t ng + +# C# client proxies +abp generate-proxy -t csharp -u https://localhost:44300 + +# Integration services only (microservices) +abp generate-proxy -t csharp -u https://localhost:44300 -st integration + +# JavaScript +abp generate-proxy -t js -u https://localhost:44300 +``` + +## Install Client-Side Libraries + +```bash +# Install NPM packages for MVC/Blazor Server +abp install-libs +``` + +## Add Package Reference + +```bash +# Add project reference with module dependency +abp add-package-ref Acme.BookStore.Domain +abp add-package-ref Acme.BookStore.Domain -t Acme.BookStore.Application +``` + +## Module Operations + +```bash +# Create new module in solution +abp new-module Acme.OrderManagement -t module:ddd + +# Install published module +abp install-module Volo.Blogging + +# Add ABP NuGet package +abp add-package Volo.Abp.Caching.StackExchangeRedis +``` + +## Update & Clean + +```bash +abp update # Update all ABP packages +abp update --version 8.0.0 # Specific version +abp clean # Delete bin/obj folders +``` + +## Quick Reference + +| Task | Command | +|------|---------| +| Angular proxies | `abp generate-proxy -t ng` | +| C# proxies | `abp generate-proxy -t csharp -u URL` | +| Install JS libs | `abp install-libs` | +| Add reference | `abp add-package-ref PackageName` | +| Create module | `abp new-module ModuleName` | +| Install module | `abp install-module ModuleName` | +| Update packages | `abp update` | +| Clean solution | `abp clean` | +| Suite CRUD | `abp suite generate -e entity.json -s solution.sln` | +| Get help | `abp help [command]` | \ No newline at end of file diff --git a/applications/Unity.GrantManager/.github/skills/unity-application-layer/SKILL.md b/applications/Unity.GrantManager/.github/skills/unity-application-layer/SKILL.md new file mode 100644 index 0000000000..f68910980b --- /dev/null +++ b/applications/Unity.GrantManager/.github/skills/unity-application-layer/SKILL.md @@ -0,0 +1,91 @@ +--- +name: unity-application-layer +description: ABP Application Services, DTOs, AutoMapper profiles, validation, and error handling for Unity. Use when creating or modifying app services, DTOs, or mapping profiles in Application or Application.Contracts projects. +--- + +# Unity Application Layer Patterns + +## Application Service Contracts (Application.Contracts) + +- Interface naming: `I*AppService` inheriting `IApplicationService`. +- Define DTOs in `*.Application.Contracts` — never in Domain or Web. +- All methods async, end with `Async`. +- Do NOT repeat entity name in method names: use `GetAsync`, not `GetGrantAsync`. + +```csharp +public interface IGrantAppService : IApplicationService +{ + Task GetAsync(Guid id); + Task> GetListAsync(GetGrantListInput input); + Task CreateAsync(CreateGrantDto input); + Task UpdateAsync(Guid id, UpdateGrantDto input); // ID separate from DTO + Task DeleteAsync(Guid id); +} +``` + +## DTO Conventions + +| Purpose | Convention | Example | +|---------|------------|---------| +| Query input | `Get{Entity}Input` | `GetGrantInput` | +| List query | `Get{Entity}ListInput` | `GetGrantListInput` | +| Create input | `Create{Entity}Dto` | `CreateGrantDto` | +| Update input | `Update{Entity}Dto` | `UpdateGrantDto` | +| Output | `{Entity}Dto` | `GrantDto` | + +- Use data annotations for validation; reuse constants from Domain.Shared. +- Do NOT share input DTOs between methods. +- Do NOT put logic in DTOs (except `IValidatableObject` when necessary). + +## Implementation (Application) + +- Inherit from `ApplicationService`. +- Make all public methods `virtual`. +- Prefer `protected virtual` over `private` for helper methods. +- Use dedicated repositories, not inline LINQ in app services. +- Call `repository.UpdateAsync()` explicitly after mutations (don't assume change tracking). +- Do NOT use web types (`IFormFile`, `Stream`) — accept `byte[]` from controllers. +- Do NOT call other app services in the same module. Use domain services or repositories. + +## Object Mapping (AutoMapper) + +This project uses **AutoMapper** (not Mapperly). Mapping profiles are defined as: + +```csharp +public class GrantManagerApplicationAutoMapperProfile : Profile +{ + public GrantManagerApplicationAutoMapperProfile() + { + CreateMap(); + CreateMap(); + } +} +``` + +- Profile files follow `*AutoMapperProfile.cs` naming. +- Each Application and Web project has its own profile. +- Use `ObjectMapper.Map(source)` in app services. + +## Error Handling + +```csharp +// Business rule violation — use namespaced error code +throw new BusinessException("GrantManager:DuplicateName") + .WithData("Name", name); + +// Entity not found +throw new EntityNotFoundException(typeof(Grant), id); + +// User-facing message (use localized string) +throw new UserFriendlyException(L["GrantNotAvailable"]); +``` + +## Authorization + +- Use `[Authorize(PermissionName)]` on service methods. +- Permission names defined as constants in `*Permissions` classes in Application.Contracts. + +## Cross-Module Calls + +- You MAY call other modules' app services via their Application.Contracts interfaces. +- Do NOT call app services within the same module — use domain services. diff --git a/applications/Unity.GrantManager/.github/skills/unity-domain-driven-design/SKILL.md b/applications/Unity.GrantManager/.github/skills/unity-domain-driven-design/SKILL.md new file mode 100644 index 0000000000..0f309f4998 --- /dev/null +++ b/applications/Unity.GrantManager/.github/skills/unity-domain-driven-design/SKILL.md @@ -0,0 +1,105 @@ +--- +name: unity-domain-driven-design +description: DDD patterns for Unity - Entities, Aggregate Roots, Repositories, Domain Services, Domain Events. Use when creating or modifying entities, repositories, or domain services in Domain or Domain.Shared projects. +--- + +# Unity ABP DDD Patterns + +> Based on ABP Framework DDD conventions. This project uses ABP 9.1.3 with PostgreSQL 17 and EF Core 9.0. + +## Entities + +- Define entities in `*.Domain` projects. +- Use **rich domain model**: private/protected setters with methods that enforce invariants. +- Always provide a `protected` parameterless constructor for EF Core. +- Accept `Guid id` in the primary constructor; do NOT generate GUIDs inside constructors. Use `IGuidGenerator` from calling code. +- Make members `virtual` for ORM proxy compatibility. +- Initialize sub-collections in the primary constructor. + +```csharp +public class Grant : AuditedAggregateRoot +{ + public string Name { get; private set; } + public GrantStatus Status { get; private set; } + public ICollection Applications { get; private set; } + + protected Grant() { } // For EF Core + + public Grant(Guid id, string name) : base(id) + { + Name = Check.NotNullOrWhiteSpace(name, nameof(name)); + Status = GrantStatus.Draft; + Applications = new List(); + } + + public void SetName(string name) + { + Name = Check.NotNullOrWhiteSpace(name, nameof(name)); + } +} +``` + +## Aggregate Roots + +- Use a single `Id` property, prefer `Guid` keys. +- Inherit from `AggregateRoot` or audited base classes (`AuditedAggregateRoot`, `FullAuditedAggregateRoot`). +- Reference other aggregate roots **by Id only** — no cross-aggregate navigation properties. +- Keep aggregates small. + +## Repositories + +- Define repository interfaces in the Domain layer. +- One repository per aggregate root only. Never create repositories for child entities. +- Custom repository interface should inherit `IRepository`. +- All methods async with `CancellationToken cancellationToken = default`. +- Single-entity methods: `includeDetails = true` by default. +- List methods: `includeDetails = false` by default. + +```csharp +public interface IGrantRepository : IRepository +{ + Task FindByNameAsync(string name, bool includeDetails = true, CancellationToken cancellationToken = default); + Task> GetListByStatusAsync(GrantStatus status, bool includeDetails = false, CancellationToken cancellationToken = default); +} +``` + +## Domain Services + +- Naming: `*Manager` suffix (e.g., `GrantManager`). +- No interface by default unless multiple implementations are needed. +- Accept/return domain objects, not DTOs. +- Do NOT depend on authenticated user; accept required values from application layer. +- Use `GuidGenerator`, `Clock` from base class properties. + +```csharp +public class GrantManager : DomainService +{ + private readonly IGrantRepository _grantRepository; + + public GrantManager(IGrantRepository grantRepository) + { + _grantRepository = grantRepository; + } + + public async Task CreateAsync(string name) + { + var existing = await _grantRepository.FindByNameAsync(name); + if (existing != null) + throw new BusinessException("GrantManager:NameAlreadyExists").WithData("Name", name); + + return new Grant(GuidGenerator.Create(), name); + } +} +``` + +## Domain Events + +- `AddLocalEvent()` — same transaction, can access full entity state. +- `AddDistributedEvent()` — async, use ETOs defined in Domain.Shared. +- This project uses **RabbitMQ** for distributed events via `IDistributedEventBus`. + +## Shared Constants + +- Define constants, enums, and error codes in `*.Domain.Shared`. +- Localization resources (JSON) live under `Domain.Shared/Localization/*/en.json`. +- Error codes: namespaced as `ModuleName:ErrorCode`. diff --git a/applications/Unity.GrantManager/.github/skills/unity-ef-core/SKILL.md b/applications/Unity.GrantManager/.github/skills/unity-ef-core/SKILL.md new file mode 100644 index 0000000000..7743d295b9 --- /dev/null +++ b/applications/Unity.GrantManager/.github/skills/unity-ef-core/SKILL.md @@ -0,0 +1,112 @@ +--- +name: unity-ef-core +description: ABP Entity Framework Core for Unity - DbContext configuration, entity mapping, repository implementation, EF migrations. Use when working in EntityFrameworkCore projects, adding migrations, or implementing repositories. +--- + +# Unity EF Core Patterns + +> This project uses EF Core 9.0 with PostgreSQL 17 (Npgsql). Tests use SQLite in-memory. + +## Database Contexts + +This project has **two distinct database contexts**: + +| Context | Purpose | Migrations Directory | +|---------|---------|---------------------| +| `GrantManagerDbContext` | Host/shared system tables | `Migrations/HostMigrations` | +| `GrantTenantDbContext` | Per-tenant isolated data | `Migrations/TenantMigrations` | + +Always specify the context when adding migrations: + +```bash +cd src/Unity.GrantManager.EntityFrameworkCore + +# Host migration +dotnet ef migrations add --context GrantManagerDbContext --output-dir Migrations/HostMigrations + +# Tenant migration +dotnet ef migrations add --context GrantTenantDbContext --output-dir Migrations/TenantMigrations +``` + +## Entity Configuration + +Entity mapping is done via extension methods on `ModelBuilder`, NOT inline in `OnModelCreating`. + +```csharp +public static class GrantManagerDbContextModelCreatingExtensions +{ + public static void ConfigureGrantManager(this ModelBuilder builder) + { + Check.NotNull(builder, nameof(builder)); + + builder.Entity(b => + { + b.ToTable(GrantManagerConsts.DbTablePrefix + "Grants", GrantManagerConsts.DbSchema); + b.ConfigureByConvention(); // Always call this first + + b.Property(x => x.Name) + .IsRequired() + .HasMaxLength(GrantConsts.MaxNameLength); + + b.HasIndex(x => x.Name); + }); + } +} +``` + +**Rules:** +- Always call `b.ConfigureByConvention()` for every entity. +- Use table prefix from constants (not hardcoded). +- Default schema should be `null`. + +## Repository Implementation + +```csharp +public class GrantRepository : EfCoreRepository, IGrantRepository +{ + public GrantRepository(IDbContextProvider dbContextProvider) + : base(dbContextProvider) { } + + public async Task FindByNameAsync( + string name, + bool includeDetails = true, + CancellationToken cancellationToken = default) + { + var dbSet = await GetDbSetAsync(); + return await dbSet + .IncludeDetails(includeDetails) + .FirstOrDefaultAsync(g => g.Name == name, GetCancellationToken(cancellationToken)); + } +} +``` + +- Use DbContext interface as generic parameter. +- Pass cancellation tokens via `GetCancellationToken(cancellationToken)`. +- Use `IncludeDetails()` extensions per aggregate root. + +## Module Registration + +```csharp +context.Services.AddAbpDbContext(options => +{ + options.AddDefaultRepositories(); // Aggregate roots only, NOT includeAllEntities: true +}); + +Configure(options => +{ + options.UseNpgsql(); // PostgreSQL +}); +``` + +## Never Do + +| Don't | Do Instead | +|-------|-----------| +| `AddDefaultRepositories(includeAllEntities: true)` | `AddDefaultRepositories()` — aggregate roots only | +| Skip `ConfigureByConvention()` | Always call it first in entity config | +| Inject DbContext in app/domain services | Use `IRepository` or custom repository interface | +| Use lazy loading | Explicit `.Include()` via `IncludeDetails()` | + +## Migrations .editorconfig + +The `Migrations/` folder has its own `.editorconfig` suppressing analyzer warnings (S1128, S1192, CS8981, CA1861, IDE naming rules). This is intentional — do not modify migration files for style. diff --git a/applications/Unity.GrantManager/.github/skills/unity-module-structure/SKILL.md b/applications/Unity.GrantManager/.github/skills/unity-module-structure/SKILL.md new file mode 100644 index 0000000000..68ed0f8091 --- /dev/null +++ b/applications/Unity.GrantManager/.github/skills/unity-module-structure/SKILL.md @@ -0,0 +1,113 @@ +--- +name: unity-module-structure +description: ABP module architecture and layering rules for Unity. Use when creating new modules, adding cross-module dependencies, or understanding project organization and dependency direction. +--- + +# Unity Module Architecture + +## Module Layout + +Each ABP module follows a standard layered structure under `modules/`: + +``` +Unity.{ModuleName}/ + src/ + Unity.{ModuleName}.Domain.Shared/ ← Enums, constants, localization, ETOs + Unity.{ModuleName}.Domain/ ← Entities, repository interfaces, domain services + Unity.{ModuleName}.Application.Contracts/ ← DTOs, app service interfaces + Unity.{ModuleName}.Application/ ← App service implementations, AutoMapper profiles + Unity.{ModuleName}.EntityFrameworkCore/ ← DbContext, migrations (if module has own DB tables) + Unity.{ModuleName}.HttpApi/ ← REST controllers + Unity.{ModuleName}.HttpApi.Client/ ← Remote client proxies + Unity.{ModuleName}.Web/ ← Razor Pages, view components + test/ + Unity.{ModuleName}.TestBase/ + Unity.{ModuleName}.Application.Tests/ + Unity.{ModuleName}.Domain.Tests/ + Unity.{ModuleName}.EntityFrameworkCore.Tests/ +``` + +Not all modules have every layer. Simpler modules may only have `Application`, `Application.Contracts`, `Shared`, and `Web`. + +## Current Modules + +| Module | Layers Present | Purpose | +|--------|---------------|---------| +| **Unity.Flex** | Shared, App.Contracts, App, Web, Tests | Dynamic forms/worksheets | +| **Unity.Notifications** | Full stack (Domain→Web, HttpApi, EF) | Email/messaging | +| **Unity.Payments** | Shared, App.Contracts, App, Web, Tests | Financial transactions | +| **Unity.Reporting** | Shared, App.Contracts, App, Web, Tests | Analytics & reports | +| **Unity.AI** | Shared, App.Contracts, App, Web | AI analysis (OpenAI) | +| **Unity.TenantManagement** | App.Contracts, App, HttpApi, Web, Tests | Multi-tenant admin | +| **Unity.Identity.Web** | Web, Tests | OIDC authentication UI | +| **Unity.Theme.UX2** | Theme package, Tests | Custom Razor Pages theme | +| **Unity.SharedKernel** | Single project | Cross-cutting utilities | + +## Dependency Direction (Strict) + +``` +Web → HttpApi → Application.Contracts +Application → Domain + Application.Contracts +Domain → Domain.Shared +EntityFrameworkCore → Domain only +``` + +### Rules + +- Web/HttpApi must NEVER depend on Application (only Application.Contracts). +- Application must NEVER depend on Web or EF Core. +- Domain must NEVER depend on Application, Web, or EF Core. +- Domain.Shared must have NO dependencies on other layers. +- EF Core must ONLY depend on Domain. + +## ABP Module Classes + +Every package has exactly one `AbpModule` class with `[DependsOn]` attributes. + +```csharp +[DependsOn( + typeof(GrantManagerDomainModule), + typeof(AbpEntityFrameworkCoreModule) +)] +public class GrantManagerEntityFrameworkCoreModule : AbpModule +{ + public override void ConfigureServices(ServiceConfigurationContext context) + { + context.Services.AddAbpDbContext(options => + { + options.AddDefaultRepositories(); + }); + } +} +``` + +## Multi-Tenancy + +- The system uses ABP multi-tenancy with separate database per tenant. +- `GrantManagerDbContext` = host context, `GrantTenantDbContext` = tenant context. +- Tenant-scoped data is accessed via `ICurrentTenant` / tenant switching. +- The `Unity.TenantManagement` module handles tenant administration. + +## Adding a New Feature + +1. Identify which module the feature belongs to. +2. Add entities/repositories in Domain layer. +3. Add DTOs/interfaces in Application.Contracts. +4. Implement app services in Application. +5. Add EF Core configuration if new tables are needed. +6. Add UI in Web layer. +7. Add tests in the module's test projects. +8. Register the module class with `[DependsOn]`. +9. Run `dotnet build Unity.GrantManager.sln` and `dotnet test Unity.GrantManager.sln` to verify. + +## Localization + +Each module with Domain.Shared has its own localization under: +`src/Unity.{ModuleName}.Domain.Shared/Localization/{ModuleName}/en.json` + +Use `L["Key"]` in application services and pages. All user-facing text must be localized. + +## Permissions + +Define in `*PermissionDefinitionProvider` in Application.Contracts. +Permission names follow `{ModuleName}.{Resource}.{Action}` convention. diff --git a/applications/Unity.GrantManager/.github/skills/unity-testing/SKILL.md b/applications/Unity.GrantManager/.github/skills/unity-testing/SKILL.md new file mode 100644 index 0000000000..eed37f3f5f --- /dev/null +++ b/applications/Unity.GrantManager/.github/skills/unity-testing/SKILL.md @@ -0,0 +1,155 @@ +--- +name: unity-testing +description: Testing patterns for Unity - xUnit, Shouldly assertions, NSubstitute mocks, ABP test infrastructure. Use when writing or modifying unit tests or integration tests. +--- + +# Unity Testing Patterns + +## Test Infrastructure + +| Aspect | Value | +|--------|-------| +| Framework | xUnit 2.9.3 | +| Assertions | Shouldly 4.3.0 | +| Mocking | NSubstitute 5.3.0 | +| Database | In-memory (SQLite for most projects; EFCore.InMemory for Web tests – no PostgreSQL required) | +| Base Classes | ABP `AbpIntegratedTest` | +| Target | .NET 9.0 | + +## Test Project Locations + +``` +test/ + Unity.GrantManager.TestBase/ ← Shared fixtures & test data + Unity.GrantManager.Application.Tests/ ← App service tests + Unity.GrantManager.Domain.Tests/ ← Domain logic tests + Unity.GrantManager.EntityFrameworkCore.Tests/ + Unity.GrantManager.Web.Tests/ +modules/Unity.*/test/ ← Each module has its own test projects +``` + +## Running Tests + +```bash +# All tests (~470 tests, ~2 min) +dotnet test Unity.GrantManager.sln + +# Single project +dotnet test test/Unity.GrantManager.Application.Tests/ + +# After build (faster) +dotnet test Unity.GrantManager.sln --no-build +``` + +## Base Class Hierarchy + +``` +AbpIntegratedTest (Volo.Abp.Testing) +└── GrantManagerTestBase (shared UoW helpers) + ├── GrantManagerDomainTestBase (domain tests) + ├── GrantManagerEntityFrameworkCoreTestBase + └── Module-specific bases: + ├── FlexTestBaseModule + ├── TenantManagementTestBase + └── ReportingTestBase +``` + +## Writing Tests + +### Unit Test Example (with mocking) + +```csharp +public class MyServiceTests +{ + private readonly IMyRepository _repository; + private readonly MyService _sut; + + public MyServiceTests() + { + _repository = Substitute.For(); + _sut = new MyService(_repository); + } + + [Fact] + public async Task CreateAsync_WithValidInput_ShouldSucceed() + { + // Arrange + _repository.FindByNameAsync(Arg.Any()).Returns((MyEntity?)null); + + // Act + var result = await _sut.CreateAsync("test"); + + // Assert + result.ShouldNotBeNull(); + result.Name.ShouldBe("test"); + } +} +``` + +### Integration Test Example (ABP) + +```csharp +public class GrantAppServiceTests : GrantManagerApplicationTestBase +{ + private readonly IGrantAppService _grantAppService; + + public GrantAppServiceTests() + { + _grantAppService = GetRequiredService(); + } + + [Fact] + public async Task Should_Get_Grant_By_Id() + { + var result = await _grantAppService.GetAsync(GrantManagerTestData.GrantId); + result.ShouldNotBeNull(); + result.Id.ShouldBe(GrantManagerTestData.GrantId); + } +} +``` + +### Parameterized Tests + +```csharp +[Theory] +[InlineData("schema1.json", 128)] +[InlineData("schema2.json", 10)] +public void TestMapping(string filename, int expectedCount) +{ + var path = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "TestData", filename); + var json = File.ReadAllText(path); + var result = Parse(json); + result.Count.ShouldBe(expectedCount); +} +``` + +## Test Data + +- JSON fixtures are loaded from `AppDomain.CurrentDomain.BaseDirectory` subdirectories. +- Domain tests include JSON files in `Intake/Files/*.json` and `Intake/Mapping/*.json` (copied to output via `.csproj`). +- Shared test data constants live in `*TestData.cs` classes within TestBase projects. + +## Web Tests + +Web tests use `[Collection]` fixture pattern: + +```csharp +[Collection(WebTestCollection.Name)] +public class MyWidgetTests +{ + private readonly IAbpLazyServiceProvider _lazyServiceProvider; + + public MyWidgetTests(WebTestFixture fixture) + { + _lazyServiceProvider = fixture.Services.GetRequiredService(); + } +} +``` + +## Conventions + +- Test class naming: `*Tests.cs` +- Method naming: `Should_ExpectedBehavior_When_Condition` or `MethodName_Scenario_ExpectedResult` +- Always use `Shouldly` for assertions (not `Assert.Equal`) +- Always use `NSubstitute` for mocking (not Moq) +- Test runner config: `xunit.runner.json` with `"shadowCopy": false` diff --git a/applications/Unity.GrantManager/.vscode/settings.json b/applications/Unity.GrantManager/.vscode/settings.json new file mode 100644 index 0000000000..ccc17ff07a --- /dev/null +++ b/applications/Unity.GrantManager/.vscode/settings.json @@ -0,0 +1,7 @@ +{ + "github.copilot.chat.commitMessageGeneration.instructions": [ + { + "file": ".github/commit-message-instructions.md" + } + ] +} \ No newline at end of file diff --git a/applications/Unity.GrantManager/Unity.GrantManager.sln b/applications/Unity.GrantManager/Unity.GrantManager.sln index 567d2f71c0..9fa0d5294b 100644 --- a/applications/Unity.GrantManager/Unity.GrantManager.sln +++ b/applications/Unity.GrantManager/Unity.GrantManager.sln @@ -153,7 +153,17 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Unity.Payments.Shared", "mo EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Unity.Flex.Web.Tests", "modules\Unity.Flex\test\Unity.Flex.Web.Tests\Unity.Flex.Web.Tests.csproj", "{5F4CFB7E-A14A-40A1-8833-A55CB296D31B}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Unity.Reporting.Web", "modules\Unity.Reporting\src\Unity.Reporting.Web\Unity.Reporting.Web.csproj", "{3E4E5506-9820-4650-8062-4A07FB2C851A}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Unity.Reporting.Web", "modules\Unity.Reporting\src\Unity.Reporting.Web\Unity.Reporting.Web.csproj", "{3E4E5506-9820-4650-8062-4A07FB2C851A}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Unity.AI", "Unity.AI", "{BA2040C4-DC9D-44D2-B8A8-5A18D3D649AB}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Unity.AI.Shared", "modules\Unity.AI\src\Unity.AI.Domain.Shared\Unity.AI.Shared.csproj", "{7C0E6C61-0903-4D48-B9ED-FFE08C4D420A}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Unity.AI.Application.Contracts", "modules\Unity.AI\src\Unity.AI.Application.Contracts\Unity.AI.Application.Contracts.csproj", "{3ACF64C1-492A-4BE6-B270-0F755C65F30B}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Unity.AI.Application", "modules\Unity.AI\src\Unity.AI.Application\Unity.AI.Application.csproj", "{7CF9D364-2018-4199-879B-371F6E1AC58B}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Unity.AI.Web", "modules\Unity.AI\src\Unity.AI.Web\Unity.AI.Web.csproj", "{378A4EB8-3DC1-420E-98B5-798DE71BEF0D}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -397,6 +407,22 @@ Global {3E4E5506-9820-4650-8062-4A07FB2C851A}.Debug|Any CPU.Build.0 = Debug|Any CPU {3E4E5506-9820-4650-8062-4A07FB2C851A}.Release|Any CPU.ActiveCfg = Release|Any CPU {3E4E5506-9820-4650-8062-4A07FB2C851A}.Release|Any CPU.Build.0 = Release|Any CPU + {7C0E6C61-0903-4D48-B9ED-FFE08C4D420A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7C0E6C61-0903-4D48-B9ED-FFE08C4D420A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7C0E6C61-0903-4D48-B9ED-FFE08C4D420A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7C0E6C61-0903-4D48-B9ED-FFE08C4D420A}.Release|Any CPU.Build.0 = Release|Any CPU + {3ACF64C1-492A-4BE6-B270-0F755C65F30B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3ACF64C1-492A-4BE6-B270-0F755C65F30B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3ACF64C1-492A-4BE6-B270-0F755C65F30B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3ACF64C1-492A-4BE6-B270-0F755C65F30B}.Release|Any CPU.Build.0 = Release|Any CPU + {7CF9D364-2018-4199-879B-371F6E1AC58B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7CF9D364-2018-4199-879B-371F6E1AC58B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7CF9D364-2018-4199-879B-371F6E1AC58B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7CF9D364-2018-4199-879B-371F6E1AC58B}.Release|Any CPU.Build.0 = Release|Any CPU + {378A4EB8-3DC1-420E-98B5-798DE71BEF0D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {378A4EB8-3DC1-420E-98B5-798DE71BEF0D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {378A4EB8-3DC1-420E-98B5-798DE71BEF0D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {378A4EB8-3DC1-420E-98B5-798DE71BEF0D}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -475,6 +501,11 @@ Global {0355D299-4880-4F11-84A9-E14639A76AC4} = {DC64FA90-4E98-442F-BBA9-116940A928CF} {5F4CFB7E-A14A-40A1-8833-A55CB296D31B} = {CDE485CC-D6EA-457A-88D6-DEEAF7CAC424} {3E4E5506-9820-4650-8062-4A07FB2C851A} = {FF8024E0-68D2-4716-8812-E6D16730F4CC} + {BA2040C4-DC9D-44D2-B8A8-5A18D3D649AB} = {00099710-CF66-4BD2-932C-5B7534B78185} + {7C0E6C61-0903-4D48-B9ED-FFE08C4D420A} = {BA2040C4-DC9D-44D2-B8A8-5A18D3D649AB} + {3ACF64C1-492A-4BE6-B270-0F755C65F30B} = {BA2040C4-DC9D-44D2-B8A8-5A18D3D649AB} + {7CF9D364-2018-4199-879B-371F6E1AC58B} = {BA2040C4-DC9D-44D2-B8A8-5A18D3D649AB} + {378A4EB8-3DC1-420E-98B5-798DE71BEF0D} = {BA2040C4-DC9D-44D2-B8A8-5A18D3D649AB} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {28315BFD-90E7-4E14-A2EA-F3D23AF4126F} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/.gitattributes b/applications/Unity.GrantManager/modules/Unity.AI/.gitattributes new file mode 100644 index 0000000000..c941e52669 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/.gitattributes @@ -0,0 +1 @@ +**/wwwroot/libs/** linguist-vendored diff --git a/applications/Unity.GrantManager/modules/Unity.AI/.gitignore b/applications/Unity.GrantManager/modules/Unity.AI/.gitignore new file mode 100644 index 0000000000..e278eb5d95 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/.gitignore @@ -0,0 +1,262 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. + +# User-specific files +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ + +# Visual Studio 2015 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUNIT +*.VisualState.xml +TestResult.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# DNX +project.lock.json +artifacts/ + +*_i.c +*_p.c +*_i.h +*.ilk +*.meta +*.obj +*.pch +*.pdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# JustCode is a .NET coding add-in +.JustCode + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# TODO: Comment the next line if you want to checkin your web deploy settings +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# The packages folder can be ignored because of Package Restore +**/packages/* +# except build/, which is used as an MSBuild target. +!**/packages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/packages/repositories.config +# NuGet v3's project.json files produces more ignoreable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.pfx +*.publishsettings +node_modules/ +orleans.codegen.cs + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm + +# SQL Server files +*.mdf +*.ldf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# JetBrains Rider +.idea/ +*.sln.iml + +# Reporting +host/Unity.Reporting.AuthServer/Logs/logs.txt +host/Unity.Reporting.HttpApi.Host/Logs/logs.txt +host/Unity.Reporting.Web.Host/Logs/logs.txt +host/Unity.Reporting.Web.Unified/Logs/logs.txt +host/Unity.Reporting.Blazor.Server.Host/Logs/logs.txt + +# Use abp install-libs to restore. +**/wwwroot/libs/* diff --git a/applications/Unity.GrantManager/modules/Unity.AI/.prettierrc b/applications/Unity.GrantManager/modules/Unity.AI/.prettierrc new file mode 100644 index 0000000000..56af76bd94 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/.prettierrc @@ -0,0 +1,5 @@ +{ + "singleQuote": true, + "useTabs": false, + "tabWidth": 4 +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/NuGet.Config b/applications/Unity.GrantManager/modules/Unity.AI/NuGet.Config new file mode 100644 index 0000000000..bdc451971a --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/NuGet.Config @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/applications/Unity.GrantManager/modules/Unity.AI/common.props b/applications/Unity.GrantManager/modules/Unity.AI/common.props new file mode 100644 index 0000000000..87cf88dc65 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/common.props @@ -0,0 +1,24 @@ + + + latest + 0.1.0 + $(NoWarn);CS1591 + module + + + + + + All + runtime; build; native; contentfiles; analyzers + + + + + + + $(NoWarn);0436 + + + + \ No newline at end of file diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AIApplicationContractsModule.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AIApplicationContractsModule.cs new file mode 100644 index 0000000000..f9e53089f8 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AIApplicationContractsModule.cs @@ -0,0 +1,15 @@ +using Volo.Abp.Application; +using Volo.Abp.Modularity; +using Volo.Abp.Authorization; + +namespace Unity.AI; + +[DependsOn( + typeof(AIDomainSharedModule), + typeof(AbpDddApplicationContractsModule), + typeof(AbpAuthorizationModule) + )] +public class AIApplicationContractsModule : AbpModule +{ + +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AIRemoteServiceConsts.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AIRemoteServiceConsts.cs new file mode 100644 index 0000000000..118f068b39 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AIRemoteServiceConsts.cs @@ -0,0 +1,7 @@ +namespace Unity.AI; + +public static class AIRemoteServiceConsts +{ + public const string RemoteServiceName = "AI"; + public const string ModuleName = "ai"; +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/FodyWeavers.xml b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/FodyWeavers.xml new file mode 100644 index 0000000000..7e9f94ead6 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/FodyWeavers.xml @@ -0,0 +1,3 @@ + + + diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/FodyWeavers.xsd b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/FodyWeavers.xsd new file mode 100644 index 0000000000..3f3946e282 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/FodyWeavers.xsd @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + 'true' to run assembly verification (PEVerify) on the target assembly after all weavers have been executed. + + + + + A comma-separated list of error codes that can be safely ignored in assembly verification. + + + + + 'false' to turn off automatic generation of the XML Schema file. + + + + + \ No newline at end of file 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 new file mode 100644 index 0000000000..05a8f98c81 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Permissions/AIPermissionDefinitionProvider.cs @@ -0,0 +1,44 @@ +using Unity.AI.Localization; +using Volo.Abp.Authorization.Permissions; +using Volo.Abp.Localization; +using Volo.Abp.Features; + +namespace Unity.AI.Permissions; + +public class AIPermissionDefinitionProvider : PermissionDefinitionProvider +{ + public override void Define(IPermissionDefinitionContext context) + { + // AI Permission Group + var aiPermissionsGroup = context.AddGroup( + AIPermissions.GroupName, + L("Permission:AI")); + + + aiPermissionsGroup.AddPermission( + AIPermissions.Reporting.ReportingDefault, + L("Permission:AI.Reporting")) + .RequireFeatures("Unity.AIReporting"); + + aiPermissionsGroup.AddPermission( + AIPermissions.ApplicationAnalysis.ApplicationAnalysisDefault, + L("Permission:AI.ApplicationAnalysis")) + .RequireFeatures("Unity.AI.ApplicationAnalysis"); + + aiPermissionsGroup.AddPermission( + AIPermissions.AttachmentSummary.AttachmentSummaryDefault , + L("Permission:AI.AttachmentSummary")) + .RequireFeatures("Unity.AI.AttachmentSummaries"); + + aiPermissionsGroup.AddPermission( + AIPermissions.ScoringAssistant.ScoringAssistantDefault, + L("Permission:AI.ScoringAssistant")) + .RequireFeatures("Unity.AI.Scoring"); + + } + + private static LocalizableString L(string name) + { + return LocalizableString.Create(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 new file mode 100644 index 0000000000..844a8d8e1f --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Permissions/AIPermissions.cs @@ -0,0 +1,36 @@ +using Volo.Abp.Reflection; + +namespace Unity.AI.Permissions; + +public static class AIPermissions +{ + public const string GroupName = "AI"; + + public const string Management = GroupName + ".Management"; + + public static class Reporting + { + public const string ReportingDefault = GroupName + ".Reporting"; + } + + public static class ApplicationAnalysis + { + public const string ApplicationAnalysisDefault = GroupName + ".ApplicationAnalysis"; + } + + public static class AttachmentSummary + { + public const string AttachmentSummaryDefault = GroupName + ".AttachmentSummary"; + } + + public static class ScoringAssistant + { + public const string ScoringAssistantDefault = GroupName + ".ScoringAssistant"; + } + + + public static string[] GetAll() + { + return ReflectionHelper.GetPublicConstantsRecursively(typeof(AIPermissions)); + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Unity.AI.Application.Contracts.csproj b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Unity.AI.Application.Contracts.csproj new file mode 100644 index 0000000000..cd71888469 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Unity.AI.Application.Contracts.csproj @@ -0,0 +1,26 @@ + + + + + + net9.0 + enable + Unity.AI + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers + + + + diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AIAppService.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AIAppService.cs new file mode 100644 index 0000000000..ff3f4f8b25 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AIAppService.cs @@ -0,0 +1,11 @@ +using Volo.Abp.Application.Services; + +namespace Unity.AI; + +public abstract class AIAppService : ApplicationService +{ + protected AIAppService() + { + LocalizationResource = typeof(Localization.AIResource); + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AIApplicationAutoMapperProfile.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AIApplicationAutoMapperProfile.cs new file mode 100644 index 0000000000..874ac789db --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AIApplicationAutoMapperProfile.cs @@ -0,0 +1,11 @@ +using AutoMapper; + +namespace Unity.AI; + +public class AIApplicationAutoMapperProfile : Profile +{ + public AIApplicationAutoMapperProfile() + { + // Define AutoMapper mappings here as entities and DTOs are introduced + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AIApplicationModule.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AIApplicationModule.cs new file mode 100644 index 0000000000..60974f22b4 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AIApplicationModule.cs @@ -0,0 +1,56 @@ +using Microsoft.Extensions.DependencyInjection; +using Volo.Abp.AutoMapper; +using Volo.Abp.Modularity; +using Volo.Abp.Application; +using Volo.Abp.MultiTenancy; +using Volo.Abp.VirtualFileSystem; +using Volo.Abp.AspNetCore.Mvc; +using Volo.Abp.TenantManagement; + +namespace Unity.AI; + +[DependsOn( + typeof(AIApplicationContractsModule), + typeof(AbpDddApplicationModule), + typeof(AbpAutoMapperModule), + typeof(AbpTenantManagementDomainModule) + )] +public class AIApplicationModule : AbpModule +{ + public override void PreConfigureServices(ServiceConfigurationContext context) + { + PreConfigure(mvcBuilder => + { + mvcBuilder.AddApplicationPartIfNotExists(typeof(AIApplicationModule).Assembly); + }); + } + + public override void ConfigureServices(ServiceConfigurationContext context) + { + Configure(options => + { + options.IsEnabled = true; + }); + + Configure(options => + { + options.FileSets.AddEmbedded(); + }); + + context.Services.AddAutoMapperObjectMapper(); + Configure(options => + { + options.AddMaps(validate: true); + }); + + context.Services.AddHttpClientProxies( + typeof(AIApplicationContractsModule).Assembly, + AIRemoteServiceConsts.RemoteServiceName + ); + + Configure(options => + { + options.ConventionalControllers.Create(typeof(AIApplicationModule).Assembly); + }); + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Domain/AIDbProperties.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Domain/AIDbProperties.cs new file mode 100644 index 0000000000..d786709167 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Domain/AIDbProperties.cs @@ -0,0 +1,11 @@ +namespace Unity.AI.Domain; + +public static class AIDbProperties +{ + public static string DbTablePrefix { get; set; } = string.Empty; + + /// + /// Schema for Unity.AI tables — kept separate from other modules. + /// + public static string? DbSchema { get; set; } = "AI"; +} 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 new file mode 100644 index 0000000000..bfdb1bb031 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/EntityFrameworkCore/AIDbContextModelCreatingExtensions.cs @@ -0,0 +1,16 @@ +using Microsoft.EntityFrameworkCore; +using Volo.Abp; + +namespace Unity.AI.EntityFrameworkCore; + +public static class AIDbContextModelCreatingExtensions +{ + public static void ConfigureAI(this ModelBuilder modelBuilder) + { + Check.NotNull(modelBuilder, nameof(modelBuilder)); + + // Configure AI entities here as they are introduced. + // Example: modelBuilder add Entity To table and configurations + + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/FodyWeavers.xml b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/FodyWeavers.xml new file mode 100644 index 0000000000..7e9f94ead6 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/FodyWeavers.xml @@ -0,0 +1,3 @@ + + + diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/FodyWeavers.xsd b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/FodyWeavers.xsd new file mode 100644 index 0000000000..3f3946e282 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/FodyWeavers.xsd @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + 'true' to run assembly verification (PEVerify) on the target assembly after all weavers have been executed. + + + + + A comma-separated list of error codes that can be safely ignored in assembly verification. + + + + + 'false' to turn off automatic generation of the XML Schema file. + + + + + \ No newline at end of file diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Unity.AI.Application.csproj b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Unity.AI.Application.csproj new file mode 100644 index 0000000000..e3be378d9b --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Unity.AI.Application.csproj @@ -0,0 +1,31 @@ + + + + + + net9.0 + enable + Unity.AI + + + + + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers + + + + diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/AISharedModule.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/AISharedModule.cs new file mode 100644 index 0000000000..c33c032428 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/AISharedModule.cs @@ -0,0 +1,40 @@ +using Volo.Abp.Modularity; +using Volo.Abp.Localization; +using Unity.AI.Localization; +using Volo.Abp.Domain; +using Volo.Abp.Localization.ExceptionHandling; +using Volo.Abp.Validation; +using Volo.Abp.Validation.Localization; +using Volo.Abp.VirtualFileSystem; +using Volo.Abp.Settings; + +namespace Unity.AI; + +[DependsOn( + typeof(AbpValidationModule), + typeof(AbpDddDomainSharedModule), + typeof(AbpSettingsModule) +)] +public class AIDomainSharedModule : AbpModule +{ + public override void ConfigureServices(ServiceConfigurationContext context) + { + Configure(options => + { + options.FileSets.AddEmbedded(); + }); + + Configure(options => + { + options.Resources + .Add("en") + .AddBaseTypes(typeof(AbpValidationResource)) + .AddVirtualJson("/Localization/AI"); + }); + + Configure(options => + { + options.MapCodeNamespace("AI", typeof(AIResource)); + }); + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/FodyWeavers.xml b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/FodyWeavers.xml new file mode 100644 index 0000000000..7e9f94ead6 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/FodyWeavers.xml @@ -0,0 +1,3 @@ + + + diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/FodyWeavers.xsd b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/FodyWeavers.xsd new file mode 100644 index 0000000000..3f3946e282 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/FodyWeavers.xsd @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + 'true' to run assembly verification (PEVerify) on the target assembly after all weavers have been executed. + + + + + A comma-separated list of error codes that can be safely ignored in assembly verification. + + + + + 'false' to turn off automatic generation of the XML Schema file. + + + + + \ No newline at end of file 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 new file mode 100644 index 0000000000..f660d259d3 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/Localization/AI/en.json @@ -0,0 +1,10 @@ +{ + "culture": "en", + "texts": { + "Permission:AI": "AI", + "Permission:AI.Reporting": "AI Reporting", + "Permission:AI.ApplicationAnalysis": "AI Application Analysis", + "Permission:AI.AttachmentSummary": "AI Attachment Summary", + "Permission:AI.ScoringAssistant": "AI Scoring Assistant" + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/Localization/AIResource.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/Localization/AIResource.cs new file mode 100644 index 0000000000..01c5e0b812 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/Localization/AIResource.cs @@ -0,0 +1,9 @@ +using Volo.Abp.Localization; + +namespace Unity.AI.Localization; + +[LocalizationResourceName("AI")] +public class AIResource +{ + +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/Unity.AI.Shared.csproj b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/Unity.AI.Shared.csproj new file mode 100644 index 0000000000..4cf3ed4e73 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/Unity.AI.Shared.csproj @@ -0,0 +1,34 @@ + + + + + + net9.0 + enable + Unity.AI + true + + + + + + + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers + + + + diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/AIWebAutoMapperProfile.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/AIWebAutoMapperProfile.cs new file mode 100644 index 0000000000..91e120f831 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/AIWebAutoMapperProfile.cs @@ -0,0 +1,11 @@ +using AutoMapper; + +namespace Unity.AI.Web; + +public class AIWebAutoMapperProfile : Profile +{ + public AIWebAutoMapperProfile() + { + // Define AutoMapper mappings for web layer here + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/AIWebModule.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/AIWebModule.cs new file mode 100644 index 0000000000..e82f53daa7 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/AIWebModule.cs @@ -0,0 +1,44 @@ +using Microsoft.Extensions.DependencyInjection; +using Unity.AI.Localization; +using Volo.Abp.AspNetCore.Mvc.Localization; +using Volo.Abp.AspNetCore.Mvc.UI.Theme.Shared; +using Volo.Abp.AutoMapper; +using Volo.Abp.Modularity; +using Volo.Abp.VirtualFileSystem; + +namespace Unity.AI.Web; + +[DependsOn( + typeof(AIApplicationContractsModule), + typeof(AbpAspNetCoreMvcUiThemeSharedModule), + typeof(AbpAutoMapperModule) + )] +public class AIWebModule : AbpModule +{ + public override void PreConfigureServices(ServiceConfigurationContext context) + { + context.Services.PreConfigure(options => + { + options.AddAssemblyResource(typeof(AIResource), typeof(AIWebModule).Assembly); + }); + + PreConfigure(mvcBuilder => + { + mvcBuilder.AddApplicationPartIfNotExists(typeof(AIWebModule).Assembly); + }); + } + + public override void ConfigureServices(ServiceConfigurationContext context) + { + Configure(options => + { + options.FileSets.AddEmbedded(); + }); + + context.Services.AddAutoMapperObjectMapper(); + Configure(options => + { + options.AddMaps(validate: true); + }); + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/FodyWeavers.xml b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/FodyWeavers.xml new file mode 100644 index 0000000000..7e9f94ead6 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/FodyWeavers.xml @@ -0,0 +1,3 @@ + + + diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/FodyWeavers.xsd b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/FodyWeavers.xsd new file mode 100644 index 0000000000..3f3946e282 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/FodyWeavers.xsd @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + 'true' to run assembly verification (PEVerify) on the target assembly after all weavers have been executed. + + + + + A comma-separated list of error codes that can be safely ignored in assembly verification. + + + + + 'false' to turn off automatic generation of the XML Schema file. + + + + + \ No newline at end of file diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/_ViewImports.cshtml b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/_ViewImports.cshtml new file mode 100644 index 0000000000..7aa11381e3 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/_ViewImports.cshtml @@ -0,0 +1,6 @@ +@using Unity.AI.Web +@using Volo.Abp.AspNetCore.Mvc.UI.Theme.Shared.Pages.Shared.Components +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers +@addTagHelper *, Volo.Abp.AspNetCore.Mvc.UI +@addTagHelper *, Volo.Abp.AspNetCore.Mvc.UI.Bootstrap +@addTagHelper *, Volo.Abp.AspNetCore.Mvc.UI.Bundling diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Properties/launchSettings.json b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Properties/launchSettings.json new file mode 100644 index 0000000000..29ff06b0cc --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "profiles": { + "Unity.AI.Web": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:57817;http://localhost:57818" + } + } +} \ No newline at end of file diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Unity.AI.Web.csproj b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Unity.AI.Web.csproj new file mode 100644 index 0000000000..79de5268e6 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Unity.AI.Web.csproj @@ -0,0 +1,41 @@ + + + + + + net9.0 + enable + true + Library + Unity.AI.Web + + + + + + + + + + + + + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers + + + + diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/Unity.Flex.sln b/applications/Unity.GrantManager/modules/Unity.Flex/Unity.Flex.sln index 1b5a13db2a..201f4dfca4 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/Unity.Flex.sln +++ b/applications/Unity.GrantManager/modules/Unity.Flex/Unity.Flex.sln @@ -35,7 +35,7 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Unity.Flex.Web", "src\Unity EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Unity.Flex.HttpApi.Client.ConsoleTestApp", "test\Unity.Flex.HttpApi.Client.ConsoleTestApp\Unity.Flex.HttpApi.Client.ConsoleTestApp.csproj", "{1EDCD6D4-DF3A-4E3B-ABB6-C0D0B373EAB8}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Unity.Flex.Installer", "src\Unity.Flex.Installer\Unity.Flex.Installer.csproj", "{BE39FD00-745B-4049-8161-FC129817CBE4}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Unity.Flex.Installer", "src\Unity.Flex.Installer\Unity.Flex.Installer.csproj", "{BE39FD00-745B-4049-8161-FC129817CBE4}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/Unity.Notifications.sln b/applications/Unity.GrantManager/modules/Unity.Notifications/Unity.Notifications.sln index 9e3121eb10..b3370928f9 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/Unity.Notifications.sln +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/Unity.Notifications.sln @@ -35,7 +35,7 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Unity.Notifications.Web", " EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Unity.Notifications.HttpApi.Client.ConsoleTestApp", "test\Unity.Notifications.HttpApi.Client.ConsoleTestApp\Unity.Notifications.HttpApi.Client.ConsoleTestApp.csproj", "{1EDCD6D4-DF3A-4E3B-ABB6-C0D0B373EAB8}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Unity.Notifications.Installer", "src\Unity.Notifications.Installer\Unity.Notifications.Installer.csproj", "{BE39FD00-745B-4049-8161-FC129817CBE4}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Unity.Notifications.Installer", "src\Unity.Notifications.Installer\Unity.Notifications.Installer.csproj", "{BE39FD00-745B-4049-8161-FC129817CBE4}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/Unity.Payments.sln b/applications/Unity.GrantManager/modules/Unity.Payments/Unity.Payments.sln index 235b0b5753..59281f42db 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/Unity.Payments.sln +++ b/applications/Unity.GrantManager/modules/Unity.Payments/Unity.Payments.sln @@ -35,7 +35,7 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Unity.Payments.Web", "src\U EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Unity.Payments.HttpApi.Client.ConsoleTestApp", "test\Unity.Payments.HttpApi.Client.ConsoleTestApp\Unity.Payments.HttpApi.Client.ConsoleTestApp.csproj", "{1EDCD6D4-DF3A-4E3B-ABB6-C0D0B373EAB8}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Unity.Payments.Installer", "src\Unity.Payments.Installer\Unity.Payments.Installer.csproj", "{BE39FD00-745B-4049-8161-FC129817CBE4}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Unity.Payments.Installer", "src\Unity.Payments.Installer\Unity.Payments.Installer.csproj", "{BE39FD00-745B-4049-8161-FC129817CBE4}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution 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 be9999cd12..353515b315 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 @@ -134,7 +134,8 @@ public async Task> GetBatchPaymentRollupsByCor || (p.Status == PaymentRequestStatus.Submitted && string.IsNullOrEmpty(p.PaymentStatus) && (string.IsNullOrEmpty(p.InvoiceStatus) - || !p.InvoiceStatus.Contains(CasPaymentRequestStatus.ErrorFromCas)))) + || (!p.InvoiceStatus.Contains(CasPaymentRequestStatus.ErrorFromCas) + && !p.InvoiceStatus.Contains(CasPaymentRequestStatus.NotFound))))) .Sum(p => p.Amount) }) .ToListAsync(); diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/Domain/PaymentRequests/PaymentRequestRepository_PaymentRollup_Tests.cs b/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/Domain/PaymentRequests/PaymentRequestRepository_PaymentRollup_Tests.cs index 17a7da11f0..581b3d1e83 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/Domain/PaymentRequests/PaymentRequestRepository_PaymentRollup_Tests.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/Domain/PaymentRequests/PaymentRequestRepository_PaymentRollup_Tests.cs @@ -212,6 +212,31 @@ await InsertPaymentRequestAsync(siteId, correlationId, 300m, results[0].TotalPending.ShouldBe(800m); // 500 + 300 } + [Fact] + [Trait("Category", "Integration")] + public async Task Should_Exclude_Submitted_WithNotFound_InvoiceStatus_FromPending() + { + // Arrange + var correlationId = Guid.NewGuid(); + var siteId = await CreateSupplierAndSiteAsync(); + + using var uow = _unitOfWorkManager.Begin(); + // NotFound invoice status - should NOT be counted as pending + await InsertPaymentRequestAsync(siteId, correlationId, 1000m, + PaymentRequestStatus.Submitted, paymentStatus: null, invoiceStatus: "NotFound"); + // Valid pending - SHOULD be counted + await InsertPaymentRequestAsync(siteId, correlationId, 200m, + PaymentRequestStatus.Submitted, paymentStatus: null, invoiceStatus: "SentToCas"); + + // Act + var results = await _paymentRequestRepository + .GetBatchPaymentRollupsByCorrelationIdsAsync([correlationId]); + + // Assert + results.Count.ShouldBe(1); + results[0].TotalPending.ShouldBe(200m); // Only the non-NotFound one + } + [Fact] [Trait("Category", "Integration")] public async Task Should_Exclude_Submitted_WithErrorFromCas_FromPending() diff --git a/applications/Unity.GrantManager/modules/Unity.Reporting/Unity.Reporting.sln b/applications/Unity.GrantManager/modules/Unity.Reporting/Unity.Reporting.sln index 0bbebbee35..c14d0cca23 100644 --- a/applications/Unity.GrantManager/modules/Unity.Reporting/Unity.Reporting.sln +++ b/applications/Unity.GrantManager/modules/Unity.Reporting/Unity.Reporting.sln @@ -35,7 +35,7 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Unity.Reporting.Web", "src\ EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Unity.Reporting.HttpApi.Client.ConsoleTestApp", "test\Unity.Reporting.HttpApi.Client.ConsoleTestApp\Unity.Reporting.HttpApi.Client.ConsoleTestApp.csproj", "{1EDCD6D4-DF3A-4E3B-ABB6-C0D0B373EAB8}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Unity.Reporting.Installer", "src\Unity.Reporting.Installer\Unity.Reporting.Installer.csproj", "{BE39FD00-745B-4049-8161-FC129817CBE4}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Unity.Reporting.Installer", "src\Unity.Reporting.Installer\Unity.Reporting.Installer.csproj", "{BE39FD00-745B-4049-8161-FC129817CBE4}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution diff --git a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application/Configuration/FieldsProviders/ScoresheetFieldsProvider.cs b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application/Configuration/FieldsProviders/ScoresheetFieldsProvider.cs index 4b856b3af3..7372d461f9 100644 --- a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application/Configuration/FieldsProviders/ScoresheetFieldsProvider.cs +++ b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application/Configuration/FieldsProviders/ScoresheetFieldsProvider.cs @@ -233,7 +233,7 @@ private sealed class ScoresheetMapping /// Gets or sets the metadata information associated with the mapping. /// Contains contextual information about scoresheets and other correlation-specific details. /// - public MapMetadataDto? Metadata { get; set; } + public MapMetadataDto? Metadata { get; set; } = new MapMetadataDto(); } } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/AIJsonKeys.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/AIJsonKeys.cs index 5ebbf4df9b..60a5b52441 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/AIJsonKeys.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/AIJsonKeys.cs @@ -6,7 +6,10 @@ public static class AIJsonKeys public const string Errors = "errors"; public const string Warnings = "warnings"; public const string Summaries = "summaries"; - public const string Dismissed = "dismissed"; + public const string NextSteps = "nextSteps"; + public const string Hidden = "hidden"; + public const string Recommendation = "recommendation"; + public const string Decision = "decision"; public const string Id = "id"; public const string Title = "title"; diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/IAIService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/IAIService.cs index 160f8ed233..d14438a2d2 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/IAIService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/IAIService.cs @@ -1,4 +1,3 @@ -using System.Collections.Generic; using System.Threading.Tasks; namespace Unity.GrantManager.AI @@ -9,14 +8,7 @@ public interface IAIService Task GenerateCompletionAsync(AICompletionRequest request); Task GenerateAttachmentSummaryAsync(AttachmentSummaryRequest request); - Task GenerateAttachmentSummaryAsync(string fileName, byte[] fileContent, string contentType); Task GenerateApplicationAnalysisAsync(ApplicationAnalysisRequest request); - Task GenerateScoresheetSectionAnswersAsync(ScoresheetSectionRequest request); - Task GenerateScoresheetSectionAnswersAsync(string applicationContent, List attachmentSummaries, string sectionJson, string sectionName); - - // Legacy compatibility methods retained until flow orchestration refactor. - Task GenerateSummaryAsync(string content, string? prompt = null, int maxTokens = 150); - Task AnalyzeApplicationAsync(string applicationContent, List attachmentSummaries, string rubric, string? formFieldConfiguration = null); - Task GenerateScoresheetAnswersAsync(string applicationContent, List attachmentSummaries, string scoresheetQuestions); + Task GenerateScoresheetSectionAsync(ScoresheetSectionRequest request); } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Models/ApplicationAnalysisFinding.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Models/ApplicationAnalysisFinding.cs index 79785fee59..d441d29493 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Models/ApplicationAnalysisFinding.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Models/ApplicationAnalysisFinding.cs @@ -7,6 +7,9 @@ public class ApplicationAnalysisFinding [JsonPropertyName(AIJsonKeys.Id)] public string? Id { get; set; } + [JsonPropertyName(AIJsonKeys.Hidden)] + public bool Hidden { get; set; } + [JsonPropertyName(AIJsonKeys.Title)] public string? Title { get; set; } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Models/ApplicationAnalysisRecommendation.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Models/ApplicationAnalysisRecommendation.cs new file mode 100644 index 0000000000..1a70d1a5f4 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Models/ApplicationAnalysisRecommendation.cs @@ -0,0 +1,13 @@ +using System.Text.Json.Serialization; + +namespace Unity.GrantManager.AI +{ + public class ApplicationAnalysisRecommendation + { + [JsonPropertyName(AIJsonKeys.Decision)] + public string? Decision { get; set; } + + [JsonPropertyName(AIJsonKeys.Rationale)] + public string? Rationale { get; set; } + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Requests/AICompletionRequest.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Requests/AICompletionRequest.cs index 2ec1bf8d30..74b8aa5494 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Requests/AICompletionRequest.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Requests/AICompletionRequest.cs @@ -7,9 +7,6 @@ public class AICompletionRequest [JsonPropertyName("userPrompt")] public string UserPrompt { get; set; } = string.Empty; - [JsonPropertyName("systemPrompt")] - public string? SystemPrompt { get; set; } - [JsonPropertyName("maxTokens")] public int MaxTokens { get; set; } = 150; diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Requests/ApplicationAnalysisRequest.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Requests/ApplicationAnalysisRequest.cs index fcd809b8b8..7e3f594e16 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Requests/ApplicationAnalysisRequest.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Requests/ApplicationAnalysisRequest.cs @@ -14,8 +14,5 @@ public class ApplicationAnalysisRequest [JsonPropertyName("attachments")] public List Attachments { get; set; } = new(); - - [JsonPropertyName("rubric")] - public string? Rubric { get; set; } } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Responses/ApplicationAnalysisResponse.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Responses/ApplicationAnalysisResponse.cs index e8a5afa194..705b713c00 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Responses/ApplicationAnalysisResponse.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Responses/ApplicationAnalysisResponse.cs @@ -17,7 +17,10 @@ public class ApplicationAnalysisResponse [JsonPropertyName(AIJsonKeys.Summaries)] public List Summaries { get; set; } = new(); - [JsonPropertyName(AIJsonKeys.Dismissed)] - public List Dismissed { get; set; } = new(); + [JsonPropertyName(AIJsonKeys.NextSteps)] + public List NextSteps { get; set; } = new(); + + [JsonPropertyName(AIJsonKeys.Recommendation)] + public ApplicationAnalysisRecommendation? Recommendation { get; set; } } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/IApplicationAIAnalysisAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/IApplicationAIAnalysisAppService.cs new file mode 100644 index 0000000000..c14c38d1bd --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/IApplicationAIAnalysisAppService.cs @@ -0,0 +1,11 @@ +using System; +using System.Threading.Tasks; +using Volo.Abp.Application.Services; + +namespace Unity.GrantManager.GrantApplications +{ + public interface IApplicationAIAnalysisAppService : IApplicationService + { + Task GenerateAIAnalysisAsync(Guid applicationId); + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/IApplicationAIScoringAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/IApplicationAIScoringAppService.cs new file mode 100644 index 0000000000..9f18a4f4dd --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/IApplicationAIScoringAppService.cs @@ -0,0 +1,11 @@ +using System; +using System.Threading.Tasks; +using Volo.Abp.Application.Services; + +namespace Unity.GrantManager.GrantApplications +{ + public interface IApplicationAIScoringAppService : IApplicationService + { + Task GenerateAIScoresheetAnswersAsync(Guid applicationId); + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/IGrantApplicationAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/IGrantApplicationAppService.cs index d02254aadb..234cc5e79d 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/IGrantApplicationAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/IGrantApplicationAppService.cs @@ -19,8 +19,8 @@ public interface IGrantApplicationAppService Task GetAsync(Guid id); Task TriggerAction(Guid applicationId, GrantApplicationAction triggerAction); Task GetAccountCodingIdFromFormIdAsync(Guid formId); - Task DismissAIIssueAsync(Guid applicationId, string issueId); - Task RestoreAIIssueAsync(Guid applicationId, string issueId); + Task HideAIAnalysisItemAsync(Guid applicationId, string itemId); + Task ShowAIAnalysisItemAsync(Guid applicationId, string itemId); Task> GetListAsync(GrantApplicationListInputDto input); } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Permissions/GrantApplications/GrantApplicationPermissionDefinitionProvider.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Permissions/GrantApplications/GrantApplicationPermissionDefinitionProvider.cs index 710a9bfca0..5dae52cf3e 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Permissions/GrantApplications/GrantApplicationPermissionDefinitionProvider.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Permissions/GrantApplications/GrantApplicationPermissionDefinitionProvider.cs @@ -1,7 +1,6 @@ using Unity.GrantManager.Localization; using Unity.Modules.Shared; using Volo.Abp.Authorization.Permissions; -using Volo.Abp.Features; using Volo.Abp.Localization; using Volo.Abp.SettingManagement; @@ -115,32 +114,7 @@ public override void Define(IPermissionDefinitionContext context) //-- TAG ASSIGNMENT var tagsPermissionsGroup = context.AddGroup("Tags", L("Permission:Tags")); tagsPermissionsGroup.AddPermission(UnitySelector.Application.Tags.Create, L(UnitySelector.Application.Tags.Create)); - tagsPermissionsGroup.AddPermission(UnitySelector.Application.Tags.Delete, L(UnitySelector.Application.Tags.Delete)); - - // AI Permission Group - var aiPermissionsGroup = context.AddGroup( - GrantApplicationPermissions.AI.GroupName, - L("Permission:AI")); - - aiPermissionsGroup.AddPermission( - GrantApplicationPermissions.AI.Reporting.Default, - L("Permission:AI.Reporting")) - .RequireFeatures("Unity.AIReporting"); - - aiPermissionsGroup.AddPermission( - GrantApplicationPermissions.AI.ApplicationAnalysis.Default, - L("Permission:AI.ApplicationAnalysis")) - .RequireFeatures("Unity.AI.ApplicationAnalysis"); - - aiPermissionsGroup.AddPermission( - GrantApplicationPermissions.AI.AttachmentSummary.Default, - L("Permission:AI.AttachmentSummary")) - .RequireFeatures("Unity.AI.AttachmentSummaries"); - - aiPermissionsGroup.AddPermission( - GrantApplicationPermissions.AI.ScoringAssistant.Default, - L("Permission:AI.ScoringAssistant")) - .RequireFeatures("Unity.AI.Scoring"); + tagsPermissionsGroup.AddPermission(UnitySelector.Application.Tags.Delete, L(UnitySelector.Application.Tags.Delete)); } private static LocalizableString L(string name) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/ApplicationAnalysisService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/ApplicationAnalysisService.cs new file mode 100644 index 0000000000..2234f9ef8d --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/ApplicationAnalysisService.cs @@ -0,0 +1,191 @@ +using Microsoft.Extensions.Logging; +using Newtonsoft.Json.Linq; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using Unity.GrantManager.Applications; +using Volo.Abp.DependencyInjection; + +namespace Unity.GrantManager.AI +{ + public class ApplicationAnalysisService( + IApplicationRepository applicationRepository, + IApplicationFormSubmissionRepository applicationFormSubmissionRepository, + IApplicationFormVersionRepository applicationFormVersionRepository, + IApplicationChefsFileAttachmentRepository applicationChefsFileAttachmentRepository, + IAIService aiService, + ILogger logger) : IApplicationAnalysisService, ITransientDependency + { + private readonly JsonSerializerOptions _jsonOptionsIndented = new() + { + WriteIndented = true + }; + + private const string ComponentsKey = "components"; + + public async Task RegenerateAndSaveAsync(Guid applicationId) + { + var application = await applicationRepository.GetAsync(applicationId); + var formSubmission = await applicationFormSubmissionRepository.GetByApplicationAsync(applicationId); + var attachments = await applicationChefsFileAttachmentRepository.GetListAsync(a => a.ApplicationId == applicationId); + + var attachmentSummaries = attachments + .Where(a => !string.IsNullOrWhiteSpace(a.AISummary)) + .Select(a => new AIAttachmentItem + { + Name = string.IsNullOrWhiteSpace(a.FileName) ? "attachment" : a.FileName.Trim(), + Summary = a.AISummary!.Trim() + }) + .ToList(); + + var notSpecified = "Not specified"; + var applicationContent = $@" +Project Name: {application.ProjectName} +Reference Number: {application.ReferenceNo} +Requested Amount: ${application.RequestedAmount:N2} +Total Project Budget: ${application.TotalProjectBudget:N2} +Project Summary: {application.ProjectSummary ?? "Not provided"} +City: {application.City ?? notSpecified} +Economic Region: {application.EconomicRegion ?? notSpecified} +Community: {application.Community ?? notSpecified} +Project Start Date: {application.ProjectStartDate?.ToShortDateString() ?? notSpecified} +Project End Date: {application.ProjectEndDate?.ToShortDateString() ?? notSpecified} +Submission Date: {application.SubmissionDate.ToShortDateString()} + +FULL APPLICATION FORM SUBMISSION: +{formSubmission?.RenderedHTML ?? "Form submission content not available"} +"; + + object formFieldConfiguration = new { message = "Form configuration not available." }; + if (formSubmission?.ApplicationFormVersionId != null) + { + formFieldConfiguration = await ExtractFormFieldConfigurationAsync(formSubmission.ApplicationFormVersionId.Value); + } + + var analysis = await aiService.GenerateApplicationAnalysisAsync(new ApplicationAnalysisRequest + { + Schema = JsonSerializer.SerializeToElement(formFieldConfiguration), + Data = JsonSerializer.SerializeToElement(new { submission_content = applicationContent }), + Attachments = attachmentSummaries + }); + + var analysisJson = JsonSerializer.Serialize(analysis, _jsonOptionsIndented); + application.AIAnalysis = analysisJson; + await applicationRepository.UpdateAsync(application); + return analysisJson; + } + + private async Task ExtractFormFieldConfigurationAsync(Guid formVersionId) + { + try + { + var formVersion = await applicationFormVersionRepository.GetAsync(formVersionId); + if (formVersion == null || string.IsNullOrEmpty(formVersion.FormSchema)) + { + return new { message = "Form configuration not available." }; + } + + var schema = JObject.Parse(formVersion.FormSchema); + var components = schema[ComponentsKey] as JArray; + if (components == null || components.Count == 0) + { + return new { message = "No form fields configured." }; + } + + var requiredFields = new List(); + var optionalFields = new List(); + ExtractFieldRequirements(components, requiredFields, optionalFields, string.Empty); + + return new + { + required_fields = requiredFields, + optional_fields = optionalFields + }; + } + catch (Exception ex) + { + logger.LogError(ex, "Error extracting form field configuration for form version {FormVersionId}", formVersionId); + return new { message = "Form configuration could not be extracted." }; + } + } + + private static void ExtractFieldRequirements(JArray components, List requiredFields, List optionalFields, string currentPath) + { + foreach (var component in components.OfType()) + { + var key = component["key"]?.ToString(); + var label = component["label"]?.ToString(); + var type = component["type"]?.ToString(); + var skipTypes = new HashSet { "button", "simplebuttonadvanced", "html", "htmlelement", "content", "simpleseparator" }; + + if (string.IsNullOrEmpty(key) || string.IsNullOrEmpty(type) || skipTypes.Contains(type)) + { + ProcessNestedFieldRequirements(component, type, requiredFields, optionalFields, currentPath); + continue; + } + + var displayName = !string.IsNullOrEmpty(label) ? $"{label} ({key})" : key; + var fullPath = string.IsNullOrEmpty(currentPath) ? displayName : $"{currentPath} > {displayName}"; + var validate = component["validate"] as JObject; + var isRequired = validate?["required"]?.Value() ?? false; + + if (component["input"]?.Value() == true) + { + if (isRequired) requiredFields.Add(fullPath); + else optionalFields.Add(fullPath); + } + + ProcessNestedFieldRequirements(component, type, requiredFields, optionalFields, fullPath); + } + } + + private static void ProcessNestedFieldRequirements(JObject component, string? type, List requiredFields, List optionalFields, string currentPath) + { + switch (type) + { + case "panel": + case "simplepanel": + case "fieldset": + case "well": + case "container": + case "datagrid": + case "table": + if (component[ComponentsKey] is JArray nestedComponents) + { + ExtractFieldRequirements(nestedComponents, requiredFields, optionalFields, currentPath); + } + break; + case "columns": + case "simplecols2": + case "simplecols3": + case "simplecols4": + if (component["columns"] is JArray columns) + { + foreach (var column in columns.OfType()) + { + if (column[ComponentsKey] is JArray columnComponents) + { + ExtractFieldRequirements(columnComponents, requiredFields, optionalFields, currentPath); + } + } + } + break; + case "tabs": + case "simpletabs": + if (component[ComponentsKey] is JArray tabs) + { + foreach (var tab in tabs.OfType()) + { + if (tab[ComponentsKey] is JArray tabComponents) + { + ExtractFieldRequirements(tabComponents, requiredFields, optionalFields, currentPath); + } + } + } + break; + } + } + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/ApplicationScoresheetAnalysisService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/ApplicationScoresheetAnalysisService.cs new file mode 100644 index 0000000000..dcef966ea1 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/ApplicationScoresheetAnalysisService.cs @@ -0,0 +1,170 @@ +using Microsoft.Extensions.Logging; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using Unity.Flex.Domain.Scoresheets; +using Unity.GrantManager.Applications; +using Volo.Abp.DependencyInjection; + +namespace Unity.GrantManager.AI +{ + public class ApplicationScoresheetAnalysisService( + IApplicationRepository applicationRepository, + IApplicationFormRepository applicationFormRepository, + IApplicationFormSubmissionRepository applicationFormSubmissionRepository, + IApplicationChefsFileAttachmentRepository applicationChefsFileAttachmentRepository, + IScoresheetRepository scoresheetRepository, + IAIService aiService, + ILogger logger) : IApplicationScoresheetAnalysisService, ITransientDependency + { + private readonly JsonSerializerOptions _jsonOptions = new() + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + + private readonly JsonSerializerOptions _jsonOptionsIndented = new() + { + WriteIndented = true + }; + + public async Task RegenerateAndSaveAsync(Guid applicationId) + { + var application = await applicationRepository.GetAsync(applicationId); + var applicationForm = await applicationFormRepository.GetAsync(application.ApplicationFormId); + if (applicationForm.ScoresheetId == null) + { + return "{}"; + } + + var scoresheet = await scoresheetRepository.GetWithChildrenAsync(applicationForm.ScoresheetId.Value); + if (scoresheet == null) + { + return "{}"; + } + + var attachments = await applicationChefsFileAttachmentRepository.GetListAsync(a => a.ApplicationId == applicationId); + var attachmentSummaries = attachments + .Where(a => !string.IsNullOrEmpty(a.AISummary)) + .Select(a => new AIAttachmentItem + { + Name = string.IsNullOrWhiteSpace(a.FileName) ? "attachment" : a.FileName.Trim(), + Summary = a.AISummary!.Trim() + }) + .ToList(); + + var formSubmission = await applicationFormSubmissionRepository.GetByApplicationAsync(applicationId); + var notSpecified = "Not specified"; + var applicationContent = $@" +Project Name: {application.ProjectName} +Reference Number: {application.ReferenceNo} +Requested Amount: ${application.RequestedAmount:N2} +Total Project Budget: ${application.TotalProjectBudget:N2} +Project Summary: {application.ProjectSummary ?? "Not provided"} +City: {application.City ?? notSpecified} +Economic Region: {application.EconomicRegion ?? notSpecified} +Community: {application.Community ?? notSpecified} +Project Start Date: {application.ProjectStartDate?.ToShortDateString() ?? notSpecified} +Project End Date: {application.ProjectEndDate?.ToShortDateString() ?? notSpecified} +Submission Date: {application.SubmissionDate.ToShortDateString()} + +FULL APPLICATION FORM SUBMISSION: +{formSubmission?.RenderedHTML ?? "Form submission content not available"} +"; + + var allSectionResults = new Dictionary(); + foreach (var section in scoresheet.Sections.OrderBy(s => s.Order)) + { + try + { + var sectionQuestionsData = new List(); + foreach (var field in section.Fields.OrderBy(f => f.Order)) + { + sectionQuestionsData.Add(new + { + id = field.Id.ToString(), + question = field.Label, + description = field.Description, + type = field.Type.ToString(), + definition = field.Definition, + availableOptions = ExtractSelectListOptions(field) + }); + } + + var sectionRequest = new ScoresheetSectionRequest + { + Data = JsonSerializer.SerializeToElement(new { submission_content = applicationContent }), + Attachments = attachmentSummaries, + SectionName = section.Name, + SectionSchema = JsonSerializer.SerializeToElement(sectionQuestionsData, _jsonOptions) + }; + var sectionAnswers = await aiService.GenerateScoresheetSectionAsync(sectionRequest); + + if (sectionAnswers.Answers.Count > 0) + { + var sectionJson = JsonSerializer.Serialize(sectionAnswers.Answers, _jsonOptions); + using var sectionDoc = JsonDocument.Parse(sectionJson); + foreach (var property in sectionDoc.RootElement.EnumerateObject()) + { + allSectionResults[property.Name] = property.Value.Clone(); + } + } + } + catch (Exception ex) + { + logger.LogError(ex, "Error processing AI scoresheet section {SectionName} for application {ApplicationId}", section.Name, applicationId); + } + } + + var combinedResults = JsonSerializer.Serialize(allSectionResults, _jsonOptionsIndented); + var validatedJson = ValidateScoresheetJson(combinedResults); + application.AIScoresheetAnswers = validatedJson; + await applicationRepository.UpdateAsync(application); + + return validatedJson; + } + + private static string ValidateScoresheetJson(string scoresheetAnswers) + { + try + { + if (!string.IsNullOrWhiteSpace(scoresheetAnswers)) + { + using var _ = JsonDocument.Parse(scoresheetAnswers); + return scoresheetAnswers; + } + } + catch (JsonException) + { + // Fall back to empty object for invalid JSON. + } + + return "{}"; + } + + private static (int number, string value, long numericValue)[]? ExtractSelectListOptions(Question field) + { + if (field.Type != Unity.Flex.Scoresheets.Enums.QuestionType.SelectList || string.IsNullOrEmpty(field.Definition)) + return null; + + try + { + var definition = JsonSerializer.Deserialize(field.Definition); + if (definition?.Options != null && definition.Options.Count > 0) + { + return definition.Options + .Select((option, index) => (number: index, value: option.Value, numericValue: option.NumericValue)) + .ToArray(); + } + } + catch (JsonException) + { + // Ignore malformed definition and return null options. + } + + return null; + } + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/IApplicationAnalysisService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/IApplicationAnalysisService.cs new file mode 100644 index 0000000000..cdb31bf8b2 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/IApplicationAnalysisService.cs @@ -0,0 +1,10 @@ +using System; +using System.Threading.Tasks; + +namespace Unity.GrantManager.AI +{ + public interface IApplicationAnalysisService + { + Task RegenerateAndSaveAsync(Guid applicationId); + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/IApplicationScoresheetAnalysisService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/IApplicationScoresheetAnalysisService.cs new file mode 100644 index 0000000000..73a272fd3f --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/IApplicationScoresheetAnalysisService.cs @@ -0,0 +1,10 @@ +using System; +using System.Threading.Tasks; + +namespace Unity.GrantManager.AI +{ + public interface IApplicationScoresheetAnalysisService + { + Task RegenerateAndSaveAsync(Guid applicationId); + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/OpenAIService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/OpenAIService.cs index 418c31ebcf..063968a272 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/OpenAIService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/OpenAIService.cs @@ -1,6 +1,7 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.IO; using System.Linq; @@ -20,8 +21,16 @@ public class OpenAIService : IAIService, ITransientDependency private readonly ITextExtractionService _textExtractionService; private const string ApplicationAnalysisPromptType = "ApplicationAnalysis"; private const string AttachmentSummaryPromptType = "AttachmentSummary"; - private const string ScoresheetAllPromptType = "ScoresheetAll"; private const string ScoresheetSectionPromptType = "ScoresheetSection"; + private const string PromptVersionV0 = "v0"; + private const string PromptVersionV1 = "v1"; + private static readonly string PromptTemplatesFolder = Path.Combine("AI", "Prompts", "Versions"); + private const string AnalysisSystemTemplateName = "analysis.system"; + private const string AnalysisUserTemplateName = "analysis.user"; + private const string AttachmentSystemTemplateName = "attachment.system"; + private const string AttachmentUserTemplateName = "attachment.user"; + private const string ScoresheetSystemTemplateName = "scoresheet.system"; + private const string ScoresheetUserTemplateName = "scoresheet.user"; private const string NoSummaryGeneratedMessage = "No summary generated."; private const string ServiceNotConfiguredMessage = "AI analysis not available - service not configured."; private const string ServiceTemporarilyUnavailableMessage = "AI analysis failed - service temporarily unavailable."; @@ -39,6 +48,16 @@ public class OpenAIService : IAIService, ITransientDependency private static readonly JsonSerializerOptions JsonLogOptions = new() { WriteIndented = true }; + private static readonly Dictionary PromptProfiles = + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + [PromptVersionV0] = PromptVersionV0, + [PromptVersionV1] = PromptVersionV1 + }; + private static readonly ConcurrentDictionary PromptTemplateCache = new(StringComparer.OrdinalIgnoreCase); + + private string SelectedPromptVersion => ResolvePromptVersion(_configuration["Azure:OpenAI:PromptVersion"]); + public OpenAIService( HttpClient httpClient, IConfiguration configuration, @@ -66,15 +85,16 @@ public async Task GenerateCompletionAsync(AICompletionRequ { var content = await GenerateSummaryAsync( request?.UserPrompt ?? string.Empty, - request?.SystemPrompt, - request?.MaxTokens ?? 150); + null, + request?.MaxTokens ?? 150, + request?.Temperature); return new AICompletionResponse { Content = content }; } public async Task GenerateApplicationAnalysisAsync(ApplicationAnalysisRequest request) { - var dataJson = JsonSerializer.Serialize(request.Data, JsonLogOptions); - var schemaJson = JsonSerializer.Serialize(request.Schema, JsonLogOptions); + var data = JsonSerializer.Serialize(request.Data, JsonLogOptions); + var schema = JsonSerializer.Serialize(request.Schema, JsonLogOptions); var attachmentsPayload = request.Attachments .Select(a => new @@ -84,20 +104,24 @@ public async Task GenerateApplicationAnalysisAsync( }) .Cast(); - var analysisContent = AnalysisPrompts.BuildUserPrompt( - schemaJson, - dataJson, - JsonSerializer.Serialize(attachmentsPayload, JsonLogOptions), - request.Rubric ?? string.Empty); - - var systemPrompt = AnalysisPrompts.SystemPrompt; + var attachments = JsonSerializer.Serialize(attachmentsPayload, JsonLogOptions); + var systemPrompt = BuildAnalysisSystemPrompt(SelectedPromptVersion); + var analysisContent = BuildAnalysisUserPrompt( + SelectedPromptVersion, + schema, + data, + attachments); await LogPromptInputAsync(ApplicationAnalysisPromptType, systemPrompt, analysisContent); var raw = await GenerateSummaryAsync(analysisContent, systemPrompt, 1000); await LogPromptOutputAsync(ApplicationAnalysisPromptType, raw); return ParseApplicationAnalysisResponse(AddIdsToAnalysisItems(raw)); } - public async Task GenerateSummaryAsync(string content, string? prompt = null, int maxTokens = 150) + private async Task GenerateSummaryAsync( + string content, + string? systemPrompt, + int maxTokens = 150, + double? temperature = null) { if (string.IsNullOrEmpty(ApiKey)) { @@ -109,18 +133,20 @@ public async Task GenerateSummaryAsync(string content, string? prompt = try { - var systemPrompt = prompt ?? "You are a professional grant analyst for the BC Government."; + var resolvedSystemPrompt = string.IsNullOrWhiteSpace(systemPrompt) + ? "You are a professional grant analyst for the BC Government." + : systemPrompt; var userPrompt = content ?? string.Empty; var requestBody = new { messages = new[] { - new { role = "system", content = systemPrompt }, + new { role = "system", content = resolvedSystemPrompt }, new { role = "user", content = userPrompt } }, max_tokens = maxTokens, - temperature = 0.3 + temperature = temperature ?? 0.3 }; var json = JsonSerializer.Serialize(requestBody); @@ -165,17 +191,16 @@ public async Task GenerateSummaryAsync(string content, string? prompt = } } - public async Task GenerateAttachmentSummaryAsync(string fileName, byte[] fileContent, string contentType) + public async Task GenerateAttachmentSummaryAsync(AttachmentSummaryRequest request) { + var fileName = request?.FileName ?? string.Empty; + var fileContent = request?.FileContent ?? Array.Empty(); + var contentType = request?.ContentType ?? "application/octet-stream"; + try { var extractedText = await _textExtractionService.ExtractTextAsync(fileName, fileContent, contentType); - - var prompt = $@"{AttachmentPrompts.SystemPrompt} - -{AttachmentPrompts.OutputSection} - -{AttachmentPrompts.RulesSection}"; + var prompt = BuildAttachmentSystemPrompt(SelectedPromptVersion); var attachmentText = string.IsNullOrWhiteSpace(extractedText) ? null : extractedText; if (attachmentText != null) @@ -194,88 +219,25 @@ public async Task GenerateAttachmentSummaryAsync(string fileName, byte[] sizeBytes = fileContent.Length, text = attachmentText }; - var contentToAnalyze = AttachmentPrompts.BuildUserPrompt( - JsonSerializer.Serialize(attachmentPayload, JsonLogOptions)); + var attachment = JsonSerializer.Serialize(attachmentPayload, JsonLogOptions); + var contentToAnalyze = BuildAttachmentUserPrompt(SelectedPromptVersion, attachment); await LogPromptInputAsync(AttachmentSummaryPromptType, prompt, contentToAnalyze); var modelOutput = await GenerateSummaryAsync(contentToAnalyze, prompt, 150); await LogPromptOutputAsync(AttachmentSummaryPromptType, modelOutput); - return modelOutput; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error generating attachment summary for {FileName}", fileName); - return $"AI analysis not available for this attachment ({fileName})."; - } - } - - public async Task GenerateAttachmentSummaryAsync(AttachmentSummaryRequest request) - { - var summary = await GenerateAttachmentSummaryAsync( - request?.FileName ?? string.Empty, - request?.FileContent ?? Array.Empty(), - request?.ContentType ?? "application/octet-stream"); - return new AttachmentSummaryResponse { Summary = summary }; - } - - public async Task AnalyzeApplicationAsync(string applicationContent, List attachmentSummaries, string rubric, string? formFieldConfiguration = null) - { - if (string.IsNullOrEmpty(ApiKey)) - { - _logger.LogWarning("{Message}", MissingApiKeyMessage); - return ServiceNotConfiguredMessage; - } - - try - { - object schemaPayload = new { }; - if (!string.IsNullOrWhiteSpace(formFieldConfiguration)) - { - try - { - using var schemaDoc = JsonDocument.Parse(formFieldConfiguration); - schemaPayload = schemaDoc.RootElement.Clone(); - } - catch (JsonException ex) - { - _logger.LogWarning(ex, "Invalid form field configuration JSON. Using empty schema payload."); - } - } - var dataPayload = new + return new AttachmentSummaryResponse { - applicationContent + Summary = ExtractSummaryFromJson(modelOutput) }; - - var attachmentsPayload = attachmentSummaries?.Count > 0 - ? attachmentSummaries - .Select((summary, index) => new - { - name = $"Attachment {index + 1}", - summary = summary - }) - .Cast() - : Enumerable.Empty(); - - var analysisContent = AnalysisPrompts.BuildUserPrompt( - JsonSerializer.Serialize(schemaPayload, JsonLogOptions), - JsonSerializer.Serialize(dataPayload, JsonLogOptions), - JsonSerializer.Serialize(attachmentsPayload, JsonLogOptions), - rubric); - - var systemPrompt = AnalysisPrompts.SystemPrompt; - - await LogPromptInputAsync(ApplicationAnalysisPromptType, systemPrompt, analysisContent); - var rawAnalysis = await GenerateSummaryAsync(analysisContent, systemPrompt, 1000); - await LogPromptOutputAsync(ApplicationAnalysisPromptType, rawAnalysis); - - // Post-process the AI response to add unique IDs to errors and warnings - return AddIdsToAnalysisItems(rawAnalysis); } catch (Exception ex) { - _logger.LogError(ex, "Error analyzing application"); - return SummaryFailedRetryMessage; + _logger.LogError(ex, "Error generating attachment summary for {FileName}", fileName); + return new AttachmentSummaryResponse + { + Summary = $"AI analysis not available for this attachment ({fileName})." + }; } } @@ -293,7 +255,10 @@ private string AddIdsToAnalysisItems(string analysisJson) { var outputPropertyName = property.Name; - if (outputPropertyName == AIJsonKeys.Errors || outputPropertyName == AIJsonKeys.Warnings) + if (outputPropertyName == AIJsonKeys.Errors || + outputPropertyName == AIJsonKeys.Warnings || + outputPropertyName == AIJsonKeys.Summaries || + outputPropertyName == AIJsonKeys.NextSteps) { writer.WritePropertyName(outputPropertyName); writer.WriteStartArray(); @@ -304,10 +269,16 @@ private string AddIdsToAnalysisItems(string analysisJson) // Add unique ID first writer.WriteString("id", Guid.NewGuid().ToString()); + writer.WriteBoolean(AIJsonKeys.Hidden, false); // Copy existing properties foreach (var itemProperty in item.EnumerateObject()) { + if (itemProperty.NameEquals(AIJsonKeys.Id) || itemProperty.NameEquals(AIJsonKeys.Hidden)) + { + continue; + } + itemProperty.WriteTo(writer); } @@ -329,14 +300,6 @@ private string AddIdsToAnalysisItems(string analysisJson) } } - // Add dismissed array if not present. - if (!jsonDoc.RootElement.TryGetProperty(AIJsonKeys.Dismissed, out _)) - { - writer.WritePropertyName(AIJsonKeys.Dismissed); - writer.WriteStartArray(); - writer.WriteEndArray(); - } - writer.WriteEndObject(); } @@ -349,75 +312,25 @@ private string AddIdsToAnalysisItems(string analysisJson) } } - public async Task GenerateScoresheetAnswersAsync(string applicationContent, List attachmentSummaries, string scoresheetQuestions) + public async Task GenerateScoresheetSectionAsync(ScoresheetSectionRequest request) { - if (string.IsNullOrEmpty(ApiKey)) - { - _logger.LogWarning("{Message}", MissingApiKeyMessage); - return "{}"; - } - - try - { - var attachmentSummariesText = attachmentSummaries?.Count > 0 - ? string.Join("\n- ", attachmentSummaries.Select((s, i) => $"Attachment {i + 1}: {s}")) - : "No attachments provided."; - - var analysisContent = $@"APPLICATION CONTENT: -{applicationContent} - -ATTACHMENT SUMMARIES: -- {attachmentSummariesText} - -SCORESHEET QUESTIONS: -{scoresheetQuestions} - -Please analyze this grant application and provide appropriate answers for each scoresheet question. - -For numeric questions, provide a numeric value within the specified range. -For yes/no questions, provide either 'Yes' or 'No'. -For text questions, provide a concise, relevant response. -For select list questions, choose the most appropriate option from the provided choices. -For text area questions, provide a detailed but concise response. - -Base your answers on the application content and attachment summaries provided. Be objective and fair in your assessment. - -Return your response as a JSON object where each key is the question ID and the value is the appropriate answer: -{{ - ""question-id-1"": ""answer-value-1"", - ""question-id-2"": ""answer-value-2"" -}} -Do not return any markdown formatting, just the JSON by itself"; - - var systemPrompt = @"You are an expert grant application reviewer for the BC Government. -Analyze the provided application and generate appropriate answers for the scoresheet questions based on the application content. -Be thorough, objective, and fair in your assessment. Base your answers strictly on the provided application content. -Respond only with valid JSON in the exact format requested."; + var dataJson = JsonSerializer.Serialize(request.Data, JsonLogOptions); + var sectionJson = JsonSerializer.Serialize(request.SectionSchema, JsonLogOptions); - await LogPromptInputAsync(ScoresheetAllPromptType, systemPrompt, analysisContent); - var modelOutput = await GenerateSummaryAsync(analysisContent, systemPrompt, 2000); - await LogPromptOutputAsync(ScoresheetAllPromptType, modelOutput); - return modelOutput; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error generating scoresheet answers"); - return "{}"; - } - } + var attachmentSummaries = request.Attachments + .Select(a => $"{a.Name}: {a.Summary}") + .ToList(); - public async Task GenerateScoresheetSectionAnswersAsync(string applicationContent, List attachmentSummaries, string sectionJson, string sectionName) - { if (string.IsNullOrEmpty(ApiKey)) { _logger.LogWarning("{Message}", MissingApiKeyMessage); - return "{}"; + return new ScoresheetSectionResponse(); } try { - var attachmentSummariesText = attachmentSummaries?.Count > 0 - ? string.Join("\n- ", attachmentSummaries.Select((s, i) => $"Attachment {i + 1}: {s}")) + var attachments = attachmentSummaries.Count > 0 + ? string.Join("\n- ", attachmentSummaries.Select((summary, index) => $"Attachment {index + 1}: {summary}")) : "No attachments provided."; object sectionQuestionsPayload = sectionJson; @@ -436,49 +349,40 @@ public async Task GenerateScoresheetSectionAnswersAsync(string applicati var sectionPayload = new { - name = sectionName, + name = request.SectionName, questions = sectionQuestionsPayload }; - var sectionPayloadJson = JsonSerializer.Serialize(sectionPayload, JsonLogOptions); - var responseTemplate = BuildScoresheetSectionResponseTemplate(sectionPayloadJson); - - var analysisContent = ScoresheetPrompts.BuildSectionUserPrompt( - applicationContent, - attachmentSummariesText, - sectionPayloadJson, - responseTemplate); + var section = JsonSerializer.Serialize(sectionPayload, JsonLogOptions); + var response = BuildScoresheetSectionResponseTemplate(section); + if (response == "{}") + { + _logger.LogWarning( + "Skipping AI scoresheet generation for section {SectionName} because response template could not be built from section schema.", + request.SectionName); + return new ScoresheetSectionResponse(); + } - var systemPrompt = ScoresheetPrompts.SectionSystemPrompt; + var analysisContent = BuildScoresheetSectionUserPrompt( + SelectedPromptVersion, + dataJson, + attachments, + section, + response); + var systemPrompt = BuildScoresheetSectionSystemPrompt(SelectedPromptVersion); await LogPromptInputAsync(ScoresheetSectionPromptType, systemPrompt, analysisContent); var modelOutput = await GenerateSummaryAsync(analysisContent, systemPrompt, 2000); await LogPromptOutputAsync(ScoresheetSectionPromptType, modelOutput); - return modelOutput; + + return ParseScoresheetSectionResponse(modelOutput); } catch (Exception ex) { - _logger.LogError(ex, "Error generating scoresheet section answers for section {SectionName}", sectionName); - return "{}"; + _logger.LogError(ex, "Error generating scoresheet section answers for section {SectionName}", request.SectionName); + return new ScoresheetSectionResponse(); } } - public async Task GenerateScoresheetSectionAnswersAsync(ScoresheetSectionRequest request) - { - var dataJson = JsonSerializer.Serialize(request.Data, JsonLogOptions); - var sectionJson = JsonSerializer.Serialize(request.SectionSchema, JsonLogOptions); - - var attachmentSummaries = request.Attachments - .Select(a => $"{a.Name}: {a.Summary}") - .ToList(); - - var raw = await GenerateScoresheetSectionAnswersAsync( - dataJson, - attachmentSummaries, - sectionJson, - request.SectionName); - return ParseScoresheetSectionResponse(raw); - } - private static ApplicationAnalysisResponse ParseApplicationAnalysisResponse(string raw) { var response = new ApplicationAnalysisResponse(); @@ -508,14 +412,14 @@ private static ApplicationAnalysisResponse ParseApplicationAnalysisResponse(stri response.Summaries = ParseFindings(summaries); } - if (root.TryGetProperty(AIJsonKeys.Dismissed, out var dismissed) && dismissed.ValueKind == JsonValueKind.Array) + if (root.TryGetProperty(AIJsonKeys.NextSteps, out var nextSteps) && nextSteps.ValueKind == JsonValueKind.Array) + { + response.NextSteps = ParseFindings(nextSteps); + } + + if (root.TryGetProperty(AIJsonKeys.Recommendation, out var recommendation) && recommendation.ValueKind == JsonValueKind.Object) { - response.Dismissed = dismissed - .EnumerateArray() - .Select(GetStringValueOrNull) - .Where(item => !string.IsNullOrWhiteSpace(item)) - .Cast() - .ToList(); + response.Recommendation = ParseRecommendation(recommendation); } return response; @@ -533,16 +437,6 @@ private static bool TryGetStringProperty(JsonElement root, string propertyName, return !string.IsNullOrWhiteSpace(value); } - private static string? GetStringValueOrNull(JsonElement element) - { - if (element.ValueKind == JsonValueKind.String) - { - return element.GetString(); - } - - return null; - } - private static List ParseFindings(JsonElement array) { var findings = new List(); @@ -556,31 +450,25 @@ private static List ParseFindings(JsonElement array) var id = item.TryGetProperty(AIJsonKeys.Id, out var idProp) && idProp.ValueKind == JsonValueKind.String ? idProp.GetString() : null; + var hidden = item.TryGetProperty(AIJsonKeys.Hidden, out var hiddenProp) && + (hiddenProp.ValueKind == JsonValueKind.True || hiddenProp.ValueKind == JsonValueKind.False) && + hiddenProp.GetBoolean(); string? title = null; if (item.TryGetProperty(AIJsonKeys.Title, out var titleProp) && titleProp.ValueKind == JsonValueKind.String) { title = titleProp.GetString(); } - else if (item.TryGetProperty("category", out var legacyTitleProp) && - legacyTitleProp.ValueKind == JsonValueKind.String) - { - title = legacyTitleProp.GetString(); - } string? detail = null; if (item.TryGetProperty(AIJsonKeys.Detail, out var detailProp) && detailProp.ValueKind == JsonValueKind.String) { detail = detailProp.GetString(); } - else if (item.TryGetProperty("message", out var legacyDetailProp) && - legacyDetailProp.ValueKind == JsonValueKind.String) - { - detail = legacyDetailProp.GetString(); - } findings.Add(new ApplicationAnalysisFinding { Id = id, + Hidden = hidden, Title = title, Detail = detail }); @@ -589,6 +477,34 @@ private static List ParseFindings(JsonElement array) return findings; } + private static ApplicationAnalysisRecommendation? ParseRecommendation(JsonElement recommendation) + { + string? decision = null; + if (recommendation.TryGetProperty(AIJsonKeys.Decision, out var decisionProp) && + decisionProp.ValueKind == JsonValueKind.String) + { + decision = decisionProp.GetString(); + } + + string? rationale = null; + if (recommendation.TryGetProperty(AIJsonKeys.Rationale, out var rationaleProp) && + rationaleProp.ValueKind == JsonValueKind.String) + { + rationale = rationaleProp.GetString(); + } + + if (string.IsNullOrWhiteSpace(decision) && string.IsNullOrWhiteSpace(rationale)) + { + return null; + } + + return new ApplicationAnalysisRecommendation + { + Decision = decision, + Rationale = rationale + }; + } + private static ScoresheetSectionResponse ParseScoresheetSectionResponse(string raw) { var response = new ScoresheetSectionResponse(); @@ -642,7 +558,7 @@ private static string BuildScoresheetSectionResponseTemplate(string sectionPaylo using var doc = JsonDocument.Parse(sectionPayloadJson); if (!doc.RootElement.TryGetProperty("questions", out var questions) || questions.ValueKind != JsonValueKind.Array) { - return ScoresheetPrompts.SectionOutputTemplate; + return "{}"; } var template = new Dictionary(); @@ -669,28 +585,28 @@ private static string BuildScoresheetSectionResponseTemplate(string sectionPaylo if (template.Count == 0) { - return ScoresheetPrompts.SectionOutputTemplate; + return "{}"; } return JsonSerializer.Serialize(template, JsonLogOptions); } catch (JsonException) { - return ScoresheetPrompts.SectionOutputTemplate; + return "{}"; } } private async Task LogPromptInputAsync(string promptType, string? systemPrompt, string userPrompt) { var formattedInput = FormatPromptInputForLog(systemPrompt, userPrompt); - _logger.LogInformation("AI {PromptType} input payload: {PromptInput}", promptType, formattedInput); + _logger.LogInformation("AI {PromptType} ({PromptVersion}) input payload: {PromptInput}", promptType, SelectedPromptVersion, formattedInput); await WritePromptLogFileAsync(promptType, "INPUT", formattedInput); } private async Task LogPromptOutputAsync(string promptType, string output) { var formattedOutput = FormatPromptOutputForLog(output); - _logger.LogInformation("AI {PromptType} model output payload: {ModelOutput}", promptType, formattedOutput); + _logger.LogInformation("AI {PromptType} ({PromptVersion}) model output payload: {ModelOutput}", promptType, SelectedPromptVersion, formattedOutput); await WritePromptLogFileAsync(promptType, "OUTPUT", formattedOutput); } @@ -829,5 +745,261 @@ private static int FindFirstJsonTokenIndex(string value) return arrayStart; } + + private static string ResolvePromptVersion(string? version) + { + if (!string.IsNullOrWhiteSpace(version) && + PromptProfiles.TryGetValue(version.Trim(), out var selectedVersion)) + { + return selectedVersion; + } + + return PromptVersionV1; + } + + private static string BuildAnalysisSystemPrompt(string version) + { + return GetRequiredPromptTemplate(version, AnalysisSystemTemplateName); + } + + private static string BuildAnalysisUserPrompt( + string version, + string schema, + string data, + string attachments) + { + var replacements = new Dictionary + { + ["SCHEMA"] = schema, + ["DATA"] = data, + ["ATTACHMENTS"] = attachments + }; + + return RenderPromptTemplate(version, AnalysisUserTemplateName, replacements); + } + + private static string BuildAttachmentSystemPrompt(string version) + { + return GetRequiredPromptTemplate(version, AttachmentSystemTemplateName); + } + + private static string BuildAttachmentUserPrompt(string version, string attachment) + { + return RenderPromptTemplate(version, AttachmentUserTemplateName, new Dictionary + { + ["ATTACHMENT"] = attachment + }); + } + + private static string BuildScoresheetSectionSystemPrompt(string version) + { + return GetRequiredPromptTemplate(version, ScoresheetSystemTemplateName); + } + + private static string BuildScoresheetSectionUserPrompt( + string version, + string data, + string attachments, + string section, + string response) + { + return RenderPromptTemplate(version, ScoresheetUserTemplateName, new Dictionary + { + ["DATA"] = data, + ["ATTACHMENTS"] = attachments, + ["SECTION"] = section, + ["RESPONSE"] = response + }); + } + + private static bool TryGetPromptTemplate(string version, string templateName, out string template) + { + template = string.Empty; + var cacheKey = $"{version}/{templateName}"; + if (PromptTemplateCache.TryGetValue(cacheKey, out var cachedTemplate)) + { + template = cachedTemplate; + return true; + } + + var path = Path.Combine(AppContext.BaseDirectory, PromptTemplatesFolder, version, $"{templateName}.txt"); + if (!File.Exists(path)) + { + return false; + } + + var loaded = PromptTemplateCache.GetOrAdd(cacheKey, _ => File.ReadAllText(path)); + if (string.IsNullOrWhiteSpace(loaded)) + { + return false; + } + + template = loaded; + return true; + } + + private static string GetRequiredPromptTemplate(string version, string templateName) + { + if (TryGetPromptTemplate(version, templateName, out var template)) + { + return template; + } + + throw new InvalidOperationException( + $"Missing required prompt template '{templateName}.txt' for prompt version '{version}'."); + } + + private static string RenderPromptTemplate( + string version, + string templateName, + IReadOnlyDictionary runtimeReplacements) + { + return RenderPromptTemplateInternal( + version, + templateName, + runtimeReplacements, + new HashSet(StringComparer.OrdinalIgnoreCase)); + } + + private static string RenderPromptTemplateInternal( + string version, + string templateName, + IReadOnlyDictionary runtimeReplacements, + ISet resolutionStack) + { + if (!resolutionStack.Add(templateName)) + { + throw new InvalidOperationException( + $"Detected cyclic prompt fragment reference while resolving '{templateName}.txt' for prompt version '{version}'."); + } + + var template = GetRequiredPromptTemplate(version, templateName); + var replacements = new Dictionary(runtimeReplacements, StringComparer.Ordinal); + var baseTemplateName = GetTemplateBaseName(templateName); + + foreach (var placeholder in GetTemplatePlaceholders(template)) + { + if (replacements.ContainsKey(placeholder)) + { + continue; + } + + var fragmentTemplateName = ResolveFragmentTemplateName(version, baseTemplateName, placeholder); + if (!string.IsNullOrWhiteSpace(fragmentTemplateName)) + { + replacements[placeholder] = RenderPromptTemplateInternal( + version, + fragmentTemplateName, + new Dictionary(StringComparer.Ordinal), + resolutionStack); + } + } + + var rendered = template; + foreach (var replacement in replacements) + { + rendered = rendered.Replace($"{{{{{replacement.Key}}}}}", replacement.Value ?? string.Empty, StringComparison.Ordinal); + } + + var unresolved = GetTemplatePlaceholders(rendered); + if (unresolved.Count > 0) + { + throw new InvalidOperationException( + $"Unresolved prompt placeholders in '{templateName}.txt' for prompt version '{version}': {string.Join(", ", unresolved.OrderBy(item => item))}"); + } + + resolutionStack.Remove(templateName); + return rendered; + } + + private static string? ResolveFragmentTemplateName(string version, string baseTemplateName, string placeholderName) + { + var normalizedPlaceholder = placeholderName.ToLowerInvariant(); + var baseScopedCandidate = $"{baseTemplateName}.{normalizedPlaceholder}"; + if (TryGetPromptTemplate(version, baseScopedCandidate, out _)) + { + return baseScopedCandidate; + } + + if (TryResolveCommonTemplateName(placeholderName, out var commonTemplateName) && + TryGetPromptTemplate(version, commonTemplateName, out _)) + { + return commonTemplateName; + } + + return null; + } + + private static bool TryResolveCommonTemplateName(string placeholderName, out string commonTemplateName) + { + commonTemplateName = string.Empty; + if (!placeholderName.StartsWith("COMMON_", StringComparison.Ordinal)) + { + return false; + } + + var suffix = placeholderName.Substring("COMMON_".Length).ToLowerInvariant(); + suffix = suffix.Replace('_', '.'); + commonTemplateName = $"common.{suffix}"; + return true; + } + + private static string GetTemplateBaseName(string templateName) + { + var separatorIndex = templateName.IndexOf('.', StringComparison.Ordinal); + if (separatorIndex <= 0) + { + return templateName; + } + + return templateName.Substring(0, separatorIndex); + } + + private static HashSet GetTemplatePlaceholders(string template) + { + var placeholders = new HashSet(StringComparer.Ordinal); + var searchIndex = 0; + + while (searchIndex < template.Length) + { + var start = template.IndexOf("{{", searchIndex, StringComparison.Ordinal); + if (start < 0) + { + break; + } + + var end = template.IndexOf("}}", start + 2, StringComparison.Ordinal); + if (end < 0) + { + break; + } + + var placeholder = template.Substring(start + 2, end - start - 2).Trim(); + if (!string.IsNullOrWhiteSpace(placeholder)) + { + placeholders.Add(placeholder); + } + + searchIndex = end + 2; + } + + return placeholders; + } + + private static string ExtractSummaryFromJson(string output) + { + if (!TryParseJsonObjectFromResponse(output, out var jsonObject)) + { + return output?.Trim() ?? string.Empty; + } + + if (jsonObject.TryGetProperty(AIJsonKeys.Summary, out var summaryProp) && + summaryProp.ValueKind == JsonValueKind.String) + { + return summaryProp.GetString() ?? string.Empty; + } + + return output?.Trim() ?? string.Empty; + } } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/AnalysisPrompts.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/AnalysisPrompts.cs deleted file mode 100644 index 2e54412806..0000000000 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/AnalysisPrompts.cs +++ /dev/null @@ -1,126 +0,0 @@ -namespace Unity.GrantManager.AI -{ - internal static class AnalysisPrompts - { - public const string DefaultRubric = @"BC GOVERNMENT GRANT EVALUATION RUBRIC: - -1. ELIGIBILITY REQUIREMENTS: - - Project must align with program objectives - - Applicant must be eligible entity type - - Budget must be reasonable and well-justified - - Project timeline must be realistic - -2. COMPLETENESS CHECKS: - - All required fields completed - - Necessary supporting documents provided - - Budget breakdown detailed and accurate - - Project description clear and comprehensive - -3. FINANCIAL REVIEW: - - Requested amount is within program limits - - Budget is reasonable for scope of work - - Matching funds or in-kind contributions identified - - Cost per outcome/beneficiary is reasonable - -4. RISK ASSESSMENT: - - Applicant capacity to deliver project - - Technical feasibility of proposed work - - Environmental or regulatory compliance - - Potential for cost overruns or delays - -5. QUALITY INDICATORS: - - Clear project objectives and outcomes - - Well-defined target audience/beneficiaries - - Appropriate project methodology - - Sustainability plan for long-term impact - -EVALUATION CRITERIA: -- HIGH: Meets all requirements, well-prepared application, low risk -- MEDIUM: Meets most requirements, minor issues or missing elements -- LOW: Missing key requirements, significant concerns, high risk"; - - public const string ScoreRules = @"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."; - - public const string SeverityRules = @"ERROR: Issue that would likely prevent the application from being approved. -WARNING: Issue that could negatively affect the application's approval. -RECOMMENDATION: Reviewer-facing improvement or follow-up consideration."; - - public const string OutputTemplate = @"{ - ""rating"": ""HIGH/MEDIUM/LOW"", - ""warnings"": [ - { - ""title"": ""Brief summary of the warning"", - ""detail"": ""Detailed warning message with full context and explanation"" - } - ], - ""errors"": [ - { - ""title"": ""Brief summary of the error"", - ""detail"": ""Detailed error message with full context and explanation"" - } - ], - ""summaries"": [ - { - ""title"": ""Brief summary of the recommendation"", - ""detail"": ""Detailed recommendation with specific actionable guidance"" - } - ], - ""dismissed"": [] -}"; - - public const string Rules = @"- Use only SCHEMA, DATA, ATTACHMENTS, and RUBRIC as evidence. -- Do not invent fields, documents, requirements, or facts. -- Treat missing or empty values as findings only when they weaken rubric evidence. -- Prefer material issues; avoid nitpicking. -- Each error/warning/recommendation must describe one concrete issue or consideration and why it matters. -- Use 3-6 words for title. -- Each detail must be 1-2 complete sentences. -- Each detail must be grounded in concrete evidence from provided inputs. -- If attachment evidence is used, reference the attachment explicitly in detail. -- Do not provide applicant-facing advice. -- Do not mention rubric section names in findings. -- If no findings exist, return empty arrays. -- rating must be HIGH, MEDIUM, or LOW." - + "\n" + PromptCoreRules.ExactOutputShape - + "\n" + PromptCoreRules.NoExtraOutputKeys - + "\n" + PromptCoreRules.ValidJsonOnly - + "\n" + PromptCoreRules.PlainJsonOnly; - - public static readonly string SystemPrompt = PromptHeader.Build( - "You are an expert grant analyst assistant for human reviewers.", - "Using SCHEMA, DATA, ATTACHMENTS, RUBRIC, SEVERITY, SCORE, OUTPUT, and RULES, return review findings."); - - public static string BuildUserPrompt( - string schemaJson, - string dataJson, - string attachmentsJson, - string rubric) - { - return $@"SCHEMA -{schemaJson} - -DATA -{dataJson} - -ATTACHMENTS -{attachmentsJson} - -RUBRIC -{rubric} - -SEVERITY -{SeverityRules} - -SCORE -{ScoreRules} - -OUTPUT -{OutputTemplate} - -RULES -{Rules}"; - } - } -} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/AttachmentPrompts.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/AttachmentPrompts.cs deleted file mode 100644 index 969480ea86..0000000000 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/AttachmentPrompts.cs +++ /dev/null @@ -1,27 +0,0 @@ -namespace Unity.GrantManager.AI -{ - internal static class AttachmentPrompts - { - public static readonly string SystemPrompt = PromptHeader.Build( - "You are a professional grant analyst for the BC Government.", - "Produce a concise reviewer-facing summary of the provided attachment context."); - - public const string OutputSection = @"OUTPUT -- Plain text only -- 1-2 complete sentences"; - - public const string RulesSection = @"RULES -- Use only the provided attachment context as evidence. -- If text content is present, summarize the actual content. -- If text content is missing or empty, provide a conservative metadata-based summary. -- Do not invent missing details. -- Keep the summary specific, concrete, and reviewer-facing. -- Return plain text only (no markdown, bullets, or JSON)."; - - public static string BuildUserPrompt(string attachmentPayloadJson) - { - return $@"ATTACHMENT -{attachmentPayloadJson}"; - } - } -} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/PromptCoreRules.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/PromptCoreRules.cs deleted file mode 100644 index e11dce3c97..0000000000 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/PromptCoreRules.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace Unity.GrantManager.AI -{ - internal static class PromptCoreRules - { - public const string UseProvidedEvidence = "- Use only provided input sections as evidence."; - public const string NoInvention = "- Do not invent missing details."; - public const string MinimumNarrativeWords = "- Any narrative text response must be at least 12 words."; - public const string ExactOutputShape = "- Return values exactly as specified in OUTPUT."; - public const string NoExtraOutputKeys = "- Do not return keys outside OUTPUT."; - public const string ValidJsonOnly = "- Return valid JSON only."; - public const string PlainJsonOnly = "- Return plain JSON only (no markdown)."; - } -} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/PromptHeader.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/PromptHeader.cs deleted file mode 100644 index 701a43e740..0000000000 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/PromptHeader.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace Unity.GrantManager.AI -{ - internal static class PromptHeader - { - public static string Build(string role, string task) - { - return $@"ROLE -{role} - -TASK -{task}"; - } - } -} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/ScoresheetPrompts.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/ScoresheetPrompts.cs deleted file mode 100644 index 2db4de742d..0000000000 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/ScoresheetPrompts.cs +++ /dev/null @@ -1,80 +0,0 @@ -namespace Unity.GrantManager.AI -{ - internal static class ScoresheetPrompts - { - public static readonly string SectionSystemPrompt = PromptHeader.Build( - "You are an expert grant application reviewer for the BC Government.", - "Using DATA, ATTACHMENTS, SECTION, RESPONSE, OUTPUT, and RULES, answer only the questions in SECTION."); - - public const string SectionOutputTemplate = @"{ - """": { - ""answer"": """", - ""rationale"": """", - ""confidence"": - } -}"; - - public const string SectionRules = "- Use only DATA and ATTACHMENTS as evidence.\n" - + "- Do not invent missing application details.\n" - + @"- 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 RESPONSE as the output contract and fill every placeholder value. -- Follow this process in order: (1) copy RESPONSE, (2) iterate SECTION.questions in order, (3) fill answer+rationale+confidence for each matching question ID, (4) run final completeness check. -- Each answer object must include: ""answer"", ""rationale"", and ""confidence"". -- Never omit ""answer"", ""rationale"", or ""confidence"" for any question type. -- The ""answer"" value type must match question type: Number => numeric; YesNo/SelectList/Text/TextArea => string. -- The ""rationale"" field must be 1-2 complete sentences and grounded in concrete DATA/ATTACHMENTS evidence. -- In ""rationale"", cite concrete source evidence from the provided input content; do not cite prompt section headers. -- For every question, rationale must justify both the selected answer and the selected confidence level based on evidence strength. -- If explicit evidence is insufficient, choose the most conservative valid answer and state uncertainty in rationale. -- Do not treat missing or non-contradictory information as evidence. -- The ""confidence"" field must be an integer from 0 to 100 in increments of 5 and represents confidence in the selected answer. -- Set confidence by certainty of the selected answer based on available evidence, regardless of which option is selected. -- For yes/no questions, the ""answer"" field must be exactly ""Yes"" or ""No"". -- For numeric questions, answer must be a numeric value within the allowed range. -- For numeric questions, answer must never be blank. -- If evidence is insufficient for a numeric question, return the minimum allowed numeric value and explain uncertainty in rationale. -- If a required value is explicitly missing in DATA/ATTACHMENTS, set confidence high (80-100) when selecting the conservative minimum. -- For select list questions, return only the selected options.number as a string (the option index shown in options), never label text or points. -- For select list questions, the ""answer"" value must be one of question.allowed_answers exactly. -- Never return 0 for select list answers unless 0 exists as an explicit option number. -- For text and text area questions, answer must be concise, evidence-based, non-empty, and avoid boilerplate placeholders. -- For text and text area questions, answer is the reviewer comment, and rationale must explain the evidence basis and certainty for that comment. -- For comment fields, summarize key evidence-based conclusions from the other questions in SECTION, including uncertainty where applicable. -- Do not leave rationale empty when answer is populated. -- Final self-check before responding: every question ID in RESPONSE must have a non-empty ""answer"", non-empty ""rationale"", and ""confidence"". -- If any answer object is incomplete, regenerate the full JSON response before returning it. -" - + PromptCoreRules.MinimumNarrativeWords + "\n" - + PromptCoreRules.ExactOutputShape + "\n" - + PromptCoreRules.NoExtraOutputKeys + "\n" - + PromptCoreRules.ValidJsonOnly + "\n" - + PromptCoreRules.PlainJsonOnly; - - public static string BuildSectionUserPrompt( - string applicationContent, - string attachmentSummariesText, - string sectionPayloadJson, - string responseTemplateJson) - { - return $@"DATA -{applicationContent} - -ATTACHMENTS -- {attachmentSummariesText} - -SECTION -{sectionPayloadJson} - -RESPONSE -{responseTemplateJson} - -OUTPUT -{SectionOutputTemplate} - -RULES -{SectionRules}"; - } - } -} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/README.md b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/README.md new file mode 100644 index 0000000000..cc8d06ef5f --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/README.md @@ -0,0 +1,54 @@ +# Runtime Prompt Templates + +These files are the source of truth for runtime prompts. +`OpenAIService` resolves templates from: + +- `AI/Prompts/Versions//