diff --git a/.github/workflows/cypress-dev.yml b/.github/workflows/cypress-dev.yml new file mode 100644 index 0000000000..9aaf565207 --- /dev/null +++ b/.github/workflows/cypress-dev.yml @@ -0,0 +1,34 @@ +name: Cypress E2E — Dev + +# Auto-triggers after a successful dev deployment. +# Can also be run manually — supply base_url to target a feature branch deployment +# instead of the default dev environment URL. +# +# Runs Cypress inside OpenShift (d18498-tools) rather than on the GitHub-hosted +# runner, since the app's Route is IP-allowlisted to the BC Gov network. +# Test credentials come from Vault (GH_UGM_CYPRESS_CONFIG) via External Secrets, +# not GitHub Actions secrets — see cypress-e2e-runner.yml. + +on: + workflow_run: + workflows: ["Dev - Build & Push docker images"] + types: [completed] + workflow_dispatch: + inputs: + base_url: + description: "Override baseUrl (e.g. https://feature-branch.apps.silver.devops.gov.bc.ca/)" + required: false + type: string + +permissions: + contents: read + +jobs: + cypress: + if: ${{ github.event.workflow_run.conclusion == 'success' || github.event_name == 'workflow_dispatch' }} + uses: ./.github/workflows/cypress-e2e-runner.yml + with: + env_name: dev + cypress_config_key: CYPRESS_CONFIG_DEV + base_url: ${{ inputs.base_url || '' }} + secrets: inherit diff --git a/.github/workflows/cypress-e2e-runner.yml b/.github/workflows/cypress-e2e-runner.yml new file mode 100644 index 0000000000..77d7f6fa06 --- /dev/null +++ b/.github/workflows/cypress-e2e-runner.yml @@ -0,0 +1,130 @@ +name: Cypress E2E (runner) + +# Shared job called by cypress-dev/test/uat/prod.yml. +# +# Unlike Grants, this doesn't run Cypress on the GitHub-hosted runner directly — +# the Unity Grant Manager Route is IP-allowlisted to the BC Gov network, which +# GitHub-hosted runners can't reach. Instead this job launches an OpenShift Job +# (from the unity-cypress-job Template in d18498-tools) that runs the existing +# Unity.AutoUI Cypress suite from inside the cluster, waits for it to finish, +# and pulls the logs/screenshots back into this Actions run. +# +# Test credentials are NOT GitHub secrets — the Job pulls them from the +# unity-cypress-config Secret in d18498-tools, synced from Vault +# (GH_UGM_CYPRESS_CONFIG) via External Secrets. +# +# OpenShift auth reuses the same oc-login pattern already used by +# docker-build-dev.yml/docker-build-test.yml/docker-build-main.yml. + +on: + workflow_call: + inputs: + env_name: + description: "Cypress environment name: dev | test | uat | prod" + required: true + type: string + cypress_config_key: + description: "Key in the unity-cypress-config Secret for this env (e.g. CYPRESS_CONFIG_DEV)" + required: true + type: string + base_url: + description: "Optional baseUrl override — use to target a feature branch deployment instead of the default env URL" + required: false + type: string + default: "" + gh_environment: + description: "GitHub Environment to source OpenShift credentials from (defaults to env_name — dev/test have their own; uat/prod reuse 'main')" + required: false + type: string + default: "" + +permissions: + contents: read + +env: + TOOLS_NAMESPACE: d18498-tools + JOB_TIMEOUT: 20m + +jobs: + cypress: + name: Cypress E2E — ${{ inputs.env_name }} + runs-on: ubuntu-latest + environment: ${{ inputs.gh_environment || inputs.env_name }} + env: + OC_CLUSTER: ${{ vars.OPENSHIFT_CLUSTER }} + OC_AUTH_TOKEN: ${{ secrets.OPENSHIFT_TOKEN }} + GH_TOKEN: ${{ secrets.GH_API_TOKEN }} + + steps: + - name: Install OpenShift CLI + run: | + curl -LO https://mirror.openshift.com/pub/openshift-v4/clients/oc/latest/linux/oc.tar.gz + tar -xvf oc.tar.gz + sudo mv oc /usr/local/bin + + - name: Connect to OpenShift API + run: oc login --token=$OC_AUTH_TOKEN --server=$OC_CLUSTER + + - name: Launch Cypress Job + id: launch + run: | + # The unity-cypress-job Template lives in the tenant-gitops-d18498 repo + # and is synced into the cluster by ArgoCD — process it by name directly + # from d18498-tools rather than checking out that repo here. + JOB_REF=$(oc process unity-cypress-job \ + -p ENV=${{ inputs.env_name }} \ + -p CYPRESS_CONFIG_KEY=${{ inputs.cypress_config_key }} \ + -p GIT_REF=$GITHUB_SHA \ + -p GIT_TOKEN=$GH_TOKEN \ + -p BASE_URL="${{ inputs.base_url }}" \ + -n $TOOLS_NAMESPACE \ + | oc create -f - -o name -n $TOOLS_NAMESPACE) + echo "Created $JOB_REF" + echo "job_ref=$JOB_REF" >> "$GITHUB_OUTPUT" + + - name: Wait for Cypress Job to finish + id: wait + run: | + JOB_REF="${{ steps.launch.outputs.job_ref }}" + oc wait --for=condition=complete --timeout=$JOB_TIMEOUT "$JOB_REF" -n $TOOLS_NAMESPACE & + COMPLETE_PID=$! + oc wait --for=condition=failed --timeout=$JOB_TIMEOUT "$JOB_REF" -n $TOOLS_NAMESPACE & + FAILED_PID=$! + wait -n $COMPLETE_PID $FAILED_PID || true + kill $COMPLETE_PID $FAILED_PID 2>/dev/null || true + + SUCCEEDED=$(oc get "$JOB_REF" -n $TOOLS_NAMESPACE -o jsonpath='{.status.succeeded}') + echo "succeeded=${SUCCEEDED:-0}" >> "$GITHUB_OUTPUT" + + - name: Show Cypress logs + if: always() + run: | + JOB_REF="${{ steps.launch.outputs.job_ref }}" + oc logs "$JOB_REF" -n $TOOLS_NAMESPACE -c cypress --tail=-1 || true + + - name: Collect screenshots on failure + if: steps.wait.outputs.succeeded != '1' + run: | + JOB_NAME="${{ steps.launch.outputs.job_ref }}" + JOB_NAME="${JOB_NAME#job.batch/}" + POD=$(oc get pods -n $TOOLS_NAMESPACE -l job-name="$JOB_NAME" -o jsonpath='{.items[0].metadata.name}') + mkdir -p cypress-screenshots + oc cp "$TOOLS_NAMESPACE/$POD:/workspace/applications/Unity.AutoUI/cypress/screenshots" ./cypress-screenshots -c cypress || true + + - name: Upload screenshots on failure + if: steps.wait.outputs.succeeded != '1' + uses: actions/upload-artifact@v5 + with: + name: cypress-screenshots-${{ inputs.env_name }}-${{ github.run_number }} + path: cypress-screenshots + if-no-files-found: ignore + + - name: Clean up Cypress Job + if: always() + run: | + JOB_REF="${{ steps.launch.outputs.job_ref }}" + oc delete "$JOB_REF" -n $TOOLS_NAMESPACE --ignore-not-found + + - name: Fail workflow if Cypress did not succeed + if: steps.wait.outputs.succeeded != '1' + run: exit 1 diff --git a/.github/workflows/cypress-prod.yml b/.github/workflows/cypress-prod.yml new file mode 100644 index 0000000000..03ee93b499 --- /dev/null +++ b/.github/workflows/cypress-prod.yml @@ -0,0 +1,20 @@ +name: Cypress E2E — Prod + +# Manual-only — triggered after UAT sign-off, not automatically after deployment. +# +# Runs Cypress inside OpenShift (d18498-tools) — see cypress-e2e-runner.yml. + +on: + workflow_dispatch: + +permissions: + contents: read + +jobs: + cypress: + uses: ./.github/workflows/cypress-e2e-runner.yml + with: + env_name: prod + cypress_config_key: CYPRESS_CONFIG_PROD + gh_environment: main + secrets: inherit diff --git a/.github/workflows/cypress-test.yml b/.github/workflows/cypress-test.yml new file mode 100644 index 0000000000..9a907f33d9 --- /dev/null +++ b/.github/workflows/cypress-test.yml @@ -0,0 +1,24 @@ +name: Cypress E2E — Test + +# Runs against the test environment after a successful test deployment, +# or manually via workflow_dispatch. +# +# Runs Cypress inside OpenShift (d18498-tools) — see cypress-e2e-runner.yml. + +on: + workflow_run: + workflows: ["Test - Build & Push docker images"] + types: [completed] + workflow_dispatch: + +permissions: + contents: read + +jobs: + cypress: + if: ${{ github.event.workflow_run.conclusion == 'success' || github.event_name == 'workflow_dispatch' }} + uses: ./.github/workflows/cypress-e2e-runner.yml + with: + env_name: test + cypress_config_key: CYPRESS_CONFIG_TEST + secrets: inherit diff --git a/.github/workflows/cypress-uat.yml b/.github/workflows/cypress-uat.yml new file mode 100644 index 0000000000..4e3236b8f1 --- /dev/null +++ b/.github/workflows/cypress-uat.yml @@ -0,0 +1,30 @@ +name: Cypress E2E — UAT + +# Runs against the UAT environment after a successful main deployment, +# or manually via workflow_dispatch. +# +# Unity has no separate UAT build workflow — UAT deploys off the main build, +# so this triggers off the same workflow_run as prod's build pipeline. +# There's also no "uat" GitHub Environment today, so oc-login reuses the +# "main" Environment's OpenShift credentials (same cluster-wide access). +# +# Runs Cypress inside OpenShift (d18498-tools) — see cypress-e2e-runner.yml. + +on: + workflow_run: + workflows: ["Main - Build & Push docker images"] + types: [completed] + workflow_dispatch: + +permissions: + contents: read + +jobs: + cypress: + if: ${{ github.event.workflow_run.conclusion == 'success' || github.event_name == 'workflow_dispatch' }} + uses: ./.github/workflows/cypress-e2e-runner.yml + with: + env_name: uat + cypress_config_key: CYPRESS_CONFIG_UAT + gh_environment: main + secrets: inherit diff --git a/.gitignore b/.gitignore index cf56f6bd31..8cae5022ff 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,7 @@ bld/ # Visual Studio cache/options directory .vs/ .vscode/ +*.lscache # Uncomment if you have tasks that create the project's static files in wwwroot **/wwwroot/lib/ diff --git a/applications/Unity.AutoUI/.gitignore b/applications/Unity.AutoUI/.gitignore index 2a269a8917..7fa35447c1 100644 --- a/applications/Unity.AutoUI/.gitignore +++ b/applications/Unity.AutoUI/.gitignore @@ -1,8 +1,14 @@ # Cypress TypeScript project gitignore -# Comment cypress.env.json to update build pipeline settings +# Comment cypress.env.json to update build pipeline settings cypress.env.json +# Local environment config files — create from the .example files in cypress/config/ +cypress/config/dev.json +cypress/config/test.json +cypress/config/uat.json +cypress/config/prod.json + # Dependency directories node_modules/ jspm_packages/ @@ -10,6 +16,9 @@ cypress/videos cypress/screenshots cypress/report +cypress/scripts/last-submission-id.json +cypress/scripts/bulk-submission-ids.json + # Optional npm cache directory .npm @@ -33,38 +42,3 @@ pids *.seed *.pid.lock -## Ignore Visual Studio temporary files, build results, and -## files generated by popular Visual Studio add-ons. -.vs/ -.vscode/ - -# User-specific files -*.suo -*.user -*.rsuser -*.userosscache -*.sln.docstates - -# Build results -[Dd]ebug/ -[Dd]ebugPublic/ -[Rr]elease/ -[Rr]eleases/ -x64/ -x86/ -bld/ -[Bb]in/ -[Oo]bj/ -[Ll]og/ -[Ll]ogs/ - -# MSTest test Results -[Tt]est[Rr]esult*/ -[Bb]uild[Ll]og.* - -# Visual Studio cache files -# files ending in .cache can be ignored -*.[Cc]ache -# but keep track of directories ending in .cache -!*.[Cc]ache/ - diff --git a/applications/Unity.AutoUI/cypress/config/README-gitignore-config-json-files.md b/applications/Unity.AutoUI/cypress/config/README-gitignore-config-json-files.md new file mode 100644 index 0000000000..355f5f08c9 --- /dev/null +++ b/applications/Unity.AutoUI/cypress/config/README-gitignore-config-json-files.md @@ -0,0 +1,19 @@ +# Cypress Environment Config Files + +The `*.json` config files in this folder are **excluded from git** (via `.gitignore`) because they contain credentials. +Each developer must create their own local copies from the `.example` files provided. + +In CI, `dev.json`/`test.json`/`uat.json`/`prod.json` are written at runtime from the `unity-cypress-config` Secret +(synced from Vault key `GH_UGM_CYPRESS_CONFIG`) by the Cypress Job — see `cypress-job-template.yaml` in +`tenant-gitops-d18498`. + +## Setup + +Copy the example file(s) for the environment(s) you need and fill in your credentials: + +```bash +Copy-Item cypress/config/dev.json.example cypress/config/dev.json +Copy-Item cypress/config/test.json.example cypress/config/test.json +Copy-Item cypress/config/uat.json.example cypress/config/uat.json +Copy-Item cypress/config/prod.json.example cypress/config/prod.json +``` diff --git a/applications/Unity.AutoUI/cypress/config/dev.json.example b/applications/Unity.AutoUI/cypress/config/dev.json.example new file mode 100644 index 0000000000..184c864869 --- /dev/null +++ b/applications/Unity.AutoUI/cypress/config/dev.json.example @@ -0,0 +1,11 @@ +{ + "webapp.url": "https://dev-unity.apps.silver.devops.gov.bc.ca/", + "environment": "DEV", + "test1username": "", + "test1password": "", + "test2username": "", + "test2password": "", + "TEST_EMAIL_TO": "", + "TEST_EMAIL_CC": "", + "TEST_EMAIL_BCC": "" +} diff --git a/applications/Unity.AutoUI/cypress/config/dev2.json.example b/applications/Unity.AutoUI/cypress/config/dev2.json.example new file mode 100644 index 0000000000..cf9669d754 --- /dev/null +++ b/applications/Unity.AutoUI/cypress/config/dev2.json.example @@ -0,0 +1,11 @@ +{ + "webapp.url": "https://dev2-unity.apps.silver.devops.gov.bc.ca/", + "environment": "DEV2", + "test1username": "", + "test1password": "", + "test2username": "", + "test2password": "", + "TEST_EMAIL_TO": "", + "TEST_EMAIL_CC": "", + "TEST_EMAIL_BCC": "" +} diff --git a/applications/Unity.AutoUI/cypress/config/prod.json.example b/applications/Unity.AutoUI/cypress/config/prod.json.example new file mode 100644 index 0000000000..8daeac885c --- /dev/null +++ b/applications/Unity.AutoUI/cypress/config/prod.json.example @@ -0,0 +1,11 @@ +{ + "webapp.url": "https://prod-unity.apps.silver.devops.gov.bc.ca/", + "environment": "PROD", + "test1username": "", + "test1password": "", + "test2username": "", + "test2password": "", + "TEST_EMAIL_TO": "", + "TEST_EMAIL_CC": "", + "TEST_EMAIL_BCC": "" +} diff --git a/applications/Unity.AutoUI/cypress/config/test.json.example b/applications/Unity.AutoUI/cypress/config/test.json.example new file mode 100644 index 0000000000..0d72840645 --- /dev/null +++ b/applications/Unity.AutoUI/cypress/config/test.json.example @@ -0,0 +1,11 @@ +{ + "webapp.url": "https://test-unity.apps.silver.devops.gov.bc.ca/", + "environment": "TEST", + "test1username": "", + "test1password": "", + "test2username": "", + "test2password": "", + "TEST_EMAIL_TO": "", + "TEST_EMAIL_CC": "", + "TEST_EMAIL_BCC": "" +} diff --git a/applications/Unity.AutoUI/cypress/config/uat.json.example b/applications/Unity.AutoUI/cypress/config/uat.json.example new file mode 100644 index 0000000000..3f82b0d2b3 --- /dev/null +++ b/applications/Unity.AutoUI/cypress/config/uat.json.example @@ -0,0 +1,11 @@ +{ + "webapp.url": "https://uat-unity.apps.silver.devops.gov.bc.ca/", + "environment": "UAT", + "test1username": "", + "test1password": "", + "test2username": "", + "test2password": "", + "TEST_EMAIL_TO": "", + "TEST_EMAIL_CC": "", + "TEST_EMAIL_BCC": "" +} diff --git a/applications/Unity.AutoUI/package-lock.json b/applications/Unity.AutoUI/package-lock.json index badb1ccea8..2aa71448bb 100644 --- a/applications/Unity.AutoUI/package-lock.json +++ b/applications/Unity.AutoUI/package-lock.json @@ -4,18 +4,20 @@ "requires": true, "packages": { "": { + "name": "Unity.AutoUI", "dependencies": { "form-data": "^4.0.5", "typescript": "^5.8.3" }, "devDependencies": { - "cypress": "^15.8.1" + "@types/node": "^25.4.0", + "cypress": "^15.17.0" } }, "node_modules/@cypress/request": { - "version": "3.0.10", - "resolved": "https://registry.npmjs.org/@cypress/request/-/request-3.0.10.tgz", - "integrity": "sha512-hauBrOdvu08vOsagkZ/Aju5XuiZx6ldsLfByg1htFeldhex+PeMrYauANzFsMJeAA0+dyPLbDoX2OYuvVoLDkQ==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@cypress/request/-/request-4.0.1.tgz", + "integrity": "sha512-y20e+e6dFYkOUUJLVUZTsJRuTiXZaUQ32WD+R/ux/HBybbTx4ge7cNINcua0pU8+SNkKuRbOF12mBmzuzM8n5w==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -32,14 +34,13 @@ "json-stringify-safe": "~5.0.1", "mime-types": "~2.1.19", "performance-now": "^2.1.0", - "qs": "~6.14.1", + "qs": "^6.15.2", "safe-buffer": "^5.1.2", "tough-cookie": "^5.0.0", - "tunnel-agent": "^0.6.0", - "uuid": "^8.3.2" + "tunnel-agent": "^0.6.0" }, "engines": { - "node": ">= 6" + "node": ">= 14.17.0" } }, "node_modules/@cypress/xvfb": { @@ -64,14 +65,13 @@ } }, "node_modules/@types/node": { - "version": "25.0.9", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.9.tgz", - "integrity": "sha512-/rpCXHlCWeqClNBwUhDcusJxXYDjZTyE8v5oTO7WbL8eij2nKhUeU89/6xgjU7N4/Vh3He0BtyhJdQbDyhiXAw==", + "version": "25.9.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.3.tgz", + "integrity": "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==", "dev": true, "license": "MIT", - "optional": true, "dependencies": { - "undici-types": "~7.16.0" + "undici-types": ">=7.24.0 <7.24.7" } }, "node_modules/@types/sinonjs__fake-timers": { @@ -95,65 +95,17 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/yauzl": { - "version": "2.10.3", - "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", - "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/aggregate-error": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", - "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", - "dev": true, - "license": "MIT", - "dependencies": { - "clean-stack": "^2.0.0", - "indent-string": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/ansi-colors": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", - "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/ansi-escapes": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", - "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", + "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", "dev": true, "license": "MIT", "dependencies": { - "type-fest": "^0.21.3" + "environment": "^1.0.0" }, "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ansi-escapes/node_modules/type-fest": { - "version": "0.21.3", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", - "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=10" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -226,16 +178,6 @@ "node": ">=0.8" } }, - "node_modules/astral-regex": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", - "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -339,16 +281,6 @@ "ieee754": "^1.1.13" } }, - "node_modules/buffer-crc32": { - "version": "0.2.13", - "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", - "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "*" - } - }, "node_modules/cachedir": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/cachedir/-/cachedir-2.4.0.tgz", @@ -442,27 +374,20 @@ "node": ">=8" } }, - "node_modules/clean-stack": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", - "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/cli-cursor": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", - "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", + "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", "dev": true, "license": "MIT", "dependencies": { - "restore-cursor": "^3.1.0" + "restore-cursor": "^5.0.0" }, "engines": { - "node": ">=8" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/cli-table3": { @@ -482,22 +407,68 @@ } }, "node_modules/cli-truncate": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-2.1.0.tgz", - "integrity": "sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==", + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.2.0.tgz", + "integrity": "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==", "dev": true, "license": "MIT", "dependencies": { - "slice-ansi": "^3.0.0", - "string-width": "^4.2.0" + "slice-ansi": "^8.0.0", + "string-width": "^8.2.0" }, "engines": { - "node": ">=8" + "node": ">=20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/cli-truncate/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/cli-truncate/node_modules/string-width": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.1.tgz", + "integrity": "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -591,14 +562,14 @@ } }, "node_modules/cypress": { - "version": "15.9.0", - "resolved": "https://registry.npmjs.org/cypress/-/cypress-15.9.0.tgz", - "integrity": "sha512-Ks6Bdilz3TtkLZtTQyqYaqtL/WT3X3APKaSLhTV96TmTyudzSjc6EJsJCHmBb7DxO+3R12q3Jkbjgm/iPgmwfg==", + "version": "15.17.0", + "resolved": "https://registry.npmjs.org/cypress/-/cypress-15.17.0.tgz", + "integrity": "sha512-WL5Gcqi1GaDWozBwXmkSAtOPafTsVSRS764iX6xvuz3DPzvBAxbkRyEi4BreVdVWxLDpiYRgZCyJUafBw44njw==", "dev": true, "hasInstallScript": true, "license": "MIT", "dependencies": { - "@cypress/request": "^3.0.10", + "@cypress/request": "^4.0.0", "@cypress/xvfb": "^1.2.4", "@types/sinonjs__fake-timers": "8.1.1", "@types/sizzle": "^2.3.2", @@ -607,25 +578,21 @@ "blob-util": "^2.0.2", "bluebird": "^3.7.2", "buffer": "^5.7.1", - "cachedir": "^2.3.0", + "cachedir": "^2.4.0", "chalk": "^4.1.0", "ci-info": "^4.1.0", - "cli-cursor": "^3.1.0", "cli-table3": "0.6.1", "commander": "^6.2.1", "common-tags": "^1.8.0", "dayjs": "^1.10.4", "debug": "^4.3.4", - "enquirer": "^2.3.6", "eventemitter2": "6.4.7", "execa": "4.1.0", "executable": "^4.1.1", - "extract-zip": "2.0.1", - "figures": "^3.2.0", "fs-extra": "^9.1.0", "hasha": "5.2.2", "is-installed-globally": "~0.4.0", - "listr2": "^3.8.3", + "listr2": "^9.0.5", "lodash": "^4.17.23", "log-symbols": "^4.0.0", "minimist": "^1.2.8", @@ -635,11 +602,12 @@ "proxy-from-env": "1.0.0", "request-progress": "^3.0.0", "supports-color": "^8.1.1", - "systeminformation": "^5.27.14", + "systeminformation": "^5.31.1", "tmp": "~0.2.4", "tree-kill": "1.2.2", + "tslib": "1.14.1", "untildify": "^4.0.0", - "yauzl": "^2.10.0" + "yauzl": "^3.3.1" }, "bin": { "cypress": "bin/cypress" @@ -648,6 +616,13 @@ "node": "^20.1.0 || ^22.0.0 || >=24.0.0" } }, + "node_modules/cypress/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "dev": true, + "license": "0BSD" + }, "node_modules/dashdash": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", @@ -737,19 +712,17 @@ "once": "^1.4.0" } }, - "node_modules/enquirer": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.4.1.tgz", - "integrity": "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==", + "node_modules/environment": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", + "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", "dev": true, "license": "MIT", - "peer": true, - "dependencies": { - "ansi-colors": "^4.1.1", - "strip-ansi": "^6.0.1" - }, "engines": { - "node": ">=8.6" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/es-define-property": { @@ -797,16 +770,6 @@ "node": ">= 0.4" } }, - "node_modules/escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8.0" - } - }, "node_modules/eventemitter2": { "version": "6.4.7", "resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-6.4.7.tgz", @@ -814,6 +777,13 @@ "dev": true, "license": "MIT" }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "dev": true, + "license": "MIT" + }, "node_modules/execa": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/execa/-/execa-4.1.0.tgz", @@ -858,27 +828,6 @@ "dev": true, "license": "MIT" }, - "node_modules/extract-zip": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", - "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "debug": "^4.1.1", - "get-stream": "^5.1.0", - "yauzl": "^2.10.0" - }, - "bin": { - "extract-zip": "cli.js" - }, - "engines": { - "node": ">= 10.17.0" - }, - "optionalDependencies": { - "@types/yauzl": "^2.9.1" - } - }, "node_modules/extsprintf": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz", @@ -889,32 +838,6 @@ ], "license": "MIT" }, - "node_modules/fd-slicer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", - "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", - "dev": true, - "license": "MIT", - "dependencies": { - "pend": "~1.2.0" - } - }, - "node_modules/figures": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", - "integrity": "sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==", - "dev": true, - "license": "MIT", - "dependencies": { - "escape-string-regexp": "^1.0.5" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/forever-agent": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", @@ -926,16 +849,16 @@ } }, "node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" + "hasown": "^2.0.4", + "mime-types": "^2.1.35" }, "engines": { "node": ">= 6" @@ -966,6 +889,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -1119,9 +1055,9 @@ } }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -1176,16 +1112,6 @@ ], "license": "BSD-3-Clause" }, - "node_modules/indent-string": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", - "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/ini": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ini/-/ini-2.0.0.tgz", @@ -1331,37 +1257,27 @@ } }, "node_modules/listr2": { - "version": "3.14.0", - "resolved": "https://registry.npmjs.org/listr2/-/listr2-3.14.0.tgz", - "integrity": "sha512-TyWI8G99GX9GjE54cJ+RrNMcIFBfwMPxc3XTFiAYGN4s10hWROGtOg7+O6u6LE3mNkyld7RSLE6nrKBvTfcs3g==", + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/listr2/-/listr2-9.0.5.tgz", + "integrity": "sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==", "dev": true, "license": "MIT", "dependencies": { - "cli-truncate": "^2.1.0", - "colorette": "^2.0.16", - "log-update": "^4.0.0", - "p-map": "^4.0.0", - "rfdc": "^1.3.0", - "rxjs": "^7.5.1", - "through": "^2.3.8", - "wrap-ansi": "^7.0.0" + "cli-truncate": "^5.0.0", + "colorette": "^2.0.20", + "eventemitter3": "^5.0.1", + "log-update": "^6.1.0", + "rfdc": "^1.4.1", + "wrap-ansi": "^9.0.0" }, "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "enquirer": ">= 2.3.0 < 3" - }, - "peerDependenciesMeta": { - "enquirer": { - "optional": true - } + "node": ">=20.0.0" } }, "node_modules/lodash": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", - "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "dev": true, "license": "MIT" }, @@ -1390,55 +1306,98 @@ } }, "node_modules/log-update": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/log-update/-/log-update-4.0.0.tgz", - "integrity": "sha512-9fkkDevMefjg0mmzWFBW8YkFP91OrizzkW3diF7CpG+S2EYdy4+TVfGwz1zeF8x7hCx1ovSPTOE9Ngib74qqUg==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", + "integrity": "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==", "dev": true, "license": "MIT", "dependencies": { - "ansi-escapes": "^4.3.0", - "cli-cursor": "^3.1.0", - "slice-ansi": "^4.0.0", - "wrap-ansi": "^6.2.0" + "ansi-escapes": "^7.0.0", + "cli-cursor": "^5.0.0", + "slice-ansi": "^7.1.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" }, "engines": { - "node": ">=10" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/log-update/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/log-update/node_modules/is-fullwidth-code-point": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.3.1" + }, + "engines": { + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/log-update/node_modules/slice-ansi": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz", - "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==", + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", + "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==", "dev": true, "license": "MIT", "dependencies": { - "ansi-styles": "^4.0.0", - "astral-regex": "^2.0.0", - "is-fullwidth-code-point": "^3.0.0" + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^5.0.0" }, "engines": { - "node": ">=10" + "node": ">=18" }, "funding": { "url": "https://github.com/chalk/slice-ansi?sponsor=1" } }, - "node_modules/log-update/node_modules/wrap-ansi": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", - "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "node_modules/log-update/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", "dev": true, "license": "MIT", "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" + "ansi-regex": "^6.2.2" }, "engines": { - "node": ">=8" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, "node_modules/math-intrinsics": { @@ -1488,6 +1447,19 @@ "node": ">=6" } }, + "node_modules/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/minimist": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", @@ -1564,22 +1536,6 @@ "dev": true, "license": "MIT" }, - "node_modules/p-map": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz", - "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "aggregate-error": "^3.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -1656,9 +1612,9 @@ } }, "node_modules/qs": { - "version": "6.14.1", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", - "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -1682,17 +1638,49 @@ } }, "node_modules/restore-cursor": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", - "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", + "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", "dev": true, "license": "MIT", "dependencies": { - "onetime": "^5.1.0", - "signal-exit": "^3.0.2" + "onetime": "^7.0.0", + "signal-exit": "^4.1.0" }, "engines": { - "node": ">=8" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/restore-cursor/node_modules/onetime": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", + "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-function": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/restore-cursor/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/rfdc": { @@ -1702,16 +1690,6 @@ "dev": true, "license": "MIT" }, - "node_modules/rxjs": { - "version": "7.8.2", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", - "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.1.0" - } - }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -1764,15 +1742,15 @@ } }, "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" }, @@ -1784,14 +1762,14 @@ } }, "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" + "object-inspect": "^1.13.4" }, "engines": { "node": ">= 0.4" @@ -1847,18 +1825,49 @@ "license": "ISC" }, "node_modules/slice-ansi": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-3.0.0.tgz", - "integrity": "sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-8.0.0.tgz", + "integrity": "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==", "dev": true, "license": "MIT", "dependencies": { - "ansi-styles": "^4.0.0", - "astral-regex": "^2.0.0", - "is-fullwidth-code-point": "^3.0.0" + "ansi-styles": "^6.2.3", + "is-fullwidth-code-point": "^5.1.0" }, "engines": { - "node": ">=8" + "node": ">=20" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/slice-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/slice-ansi/node_modules/is-fullwidth-code-point": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.3.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/sshpk": { @@ -1942,9 +1951,9 @@ } }, "node_modules/systeminformation": { - "version": "5.30.5", - "resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.30.5.tgz", - "integrity": "sha512-DpWmpCckhwR3hG+6udb6/aQB7PpiqVnvSljrjbKxNSvTRsGsg7NVE3/vouoYf96xgwMxXFKcS4Ux+cnkFwYM7A==", + "version": "5.31.7", + "resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.31.7.tgz", + "integrity": "sha512-/8NC53e5nP9nmhn42/ncdOkyJnOoue/Vy+tJOyUGd1Yv66G069wK4rrziwhrqDETgk78CudTQupw5z19S5uoZw==", "dev": true, "license": "MIT", "os": [ @@ -1978,13 +1987,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/through": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", - "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", - "dev": true, - "license": "MIT" - }, "node_modules/tldts": { "version": "6.1.86", "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", @@ -2006,9 +2008,9 @@ "license": "MIT" }, "node_modules/tmp": { - "version": "0.2.5", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", - "integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==", + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", + "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==", "dev": true, "license": "MIT", "engines": { @@ -2038,13 +2040,6 @@ "tree-kill": "cli.js" } }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "license": "0BSD" - }, "node_modules/tunnel-agent": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", @@ -2089,12 +2084,11 @@ } }, "node_modules/undici-types": { - "version": "7.16.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", - "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", "dev": true, - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/universalify": { "version": "2.0.1", @@ -2116,16 +2110,6 @@ "node": ">=8" } }, - "node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", - "dev": true, - "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" - } - }, "node_modules/verror": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", @@ -2158,23 +2142,90 @@ } }, "node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", "dev": true, "license": "MIT", "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" }, "engines": { - "node": ">=10" + "node": ">=18" }, "funding": { "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/wrap-ansi/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/wrap-ansi/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -2183,14 +2234,16 @@ "license": "ISC" }, "node_modules/yauzl": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", - "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-3.4.0.tgz", + "integrity": "sha512-jIH9yLR9wqr0wOS0TpBvo/g/2UgZH5qePVbjgRliiF0BYvOZyaBknKsF+x9Iht0O6sqgnB93rCICdOZFecJuDw==", "dev": true, "license": "MIT", "dependencies": { - "buffer-crc32": "~0.2.3", - "fd-slicer": "~1.1.0" + "pend": "~1.2.0" + }, + "engines": { + "node": ">=12" } } } diff --git a/applications/Unity.AutoUI/package.json b/applications/Unity.AutoUI/package.json index d275c5ea12..cbdade5aeb 100644 --- a/applications/Unity.AutoUI/package.json +++ b/applications/Unity.AutoUI/package.json @@ -15,6 +15,6 @@ }, "devDependencies": { "@types/node": "^25.4.0", - "cypress": "15.12.0" + "cypress": "^15.17.0" } -} \ No newline at end of file +} diff --git a/applications/Unity.GrantManager/.dockerignore b/applications/Unity.GrantManager/.dockerignore new file mode 100644 index 0000000000..c73fffa5e9 --- /dev/null +++ b/applications/Unity.GrantManager/.dockerignore @@ -0,0 +1,16 @@ +# Local secrets must never end up in a Docker build context / image, +# even for a locally-run `docker build` outside of CI (which never has +# these files in the first place, since they are gitignored). +**/appsettings.secrets.json +**/appsettings.Development.json +**/.env +**/.env.* +**/*.env + +# Build artifacts and local tooling state - not needed in the build +# context and only bloat it / risk copying stale output. +**/bin/ +**/obj/ +**/node_modules/ +.git/ +.vs/ diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/IAIService.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/IAIService.cs index 0606899c8e..8caf69258d 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/IAIService.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/IAIService.cs @@ -10,6 +10,7 @@ public interface IAIService Task IsAvailableAsync(); Task GenerateAttachmentSummaryAsync(AttachmentSummaryRequest request, CancellationToken cancellationToken = default); + Task GenerateAttachmentSummaryBatchAsync(AttachmentSummaryBatchRequest request, CancellationToken cancellationToken = default); Task GenerateApplicationAnalysisAsync(ApplicationAnalysisRequest request, CancellationToken cancellationToken = default); Task GenerateApplicationScoringAsync(ApplicationScoringRequest request, CancellationToken cancellationToken = default); } diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Operations/AIApplicationPromptDataDto.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Operations/AIApplicationPromptDataDto.cs new file mode 100644 index 0000000000..d54d2b7897 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Operations/AIApplicationPromptDataDto.cs @@ -0,0 +1,32 @@ +using System; + +namespace Unity.AI.Operations; + +public class AIApplicationPromptDataDto +{ + public Guid ApplicationId { get; set; } + + public Guid ApplicationFormId { get; set; } + + public string ProjectName { get; set; } = string.Empty; + + public string ReferenceNo { get; set; } = string.Empty; + + public decimal RequestedAmount { get; set; } + + public decimal TotalProjectBudget { get; set; } + + public string? EconomicRegion { get; set; } + + public string? City { get; set; } + + public DateTime SubmissionDate { get; set; } + + public string? ProjectSummary { get; set; } + + public DateTime? ProjectStartDate { get; set; } + + public DateTime? ProjectEndDate { get; set; } + + public string? Community { get; set; } +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Operations/ApplicationAnalysisOperationInputDto.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Operations/ApplicationAnalysisOperationInputDto.cs new file mode 100644 index 0000000000..17deda9348 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Operations/ApplicationAnalysisOperationInputDto.cs @@ -0,0 +1,26 @@ +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Serialization; +using Unity.AI.Models; + +namespace Unity.AI.Operations +{ + public class ApplicationAnalysisOperationInputDto + { + [JsonPropertyName("applicationId")] + public Guid ApplicationId { get; set; } + + [JsonPropertyName("schema")] + public JsonElement Schema { get; set; } + + [JsonPropertyName("data")] + public JsonElement Data { get; set; } + + [JsonPropertyName("attachments")] + public List Attachments { get; set; } = new(); + + [JsonPropertyName("promptVersion")] + public string? PromptVersion { get; set; } + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Operations/ApplicationScoringOperationInputDto.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Operations/ApplicationScoringOperationInputDto.cs new file mode 100644 index 0000000000..d2c5946dcf --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Operations/ApplicationScoringOperationInputDto.cs @@ -0,0 +1,35 @@ +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Serialization; +using Unity.AI.Models; + +namespace Unity.AI.Operations +{ + public class ApplicationScoringOperationInputDto + { + [JsonPropertyName("applicationId")] + public Guid ApplicationId { get; set; } + + [JsonPropertyName("data")] + public JsonElement Data { get; set; } + + [JsonPropertyName("attachments")] + public List Attachments { get; set; } = new(); + + [JsonPropertyName("sections")] + public List Sections { get; set; } = new(); + + [JsonPropertyName("promptVersion")] + public string? PromptVersion { get; set; } + } + + public class ApplicationScoringSectionOperationInputDto + { + [JsonPropertyName("sectionName")] + public string SectionName { get; set; } = string.Empty; + + [JsonPropertyName("sectionSchema")] + public JsonElement SectionSchema { get; set; } + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Operations/IAIApplicationInputBuilder.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Operations/IAIApplicationInputBuilder.cs new file mode 100644 index 0000000000..6236bffbdd --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Operations/IAIApplicationInputBuilder.cs @@ -0,0 +1,10 @@ +using System; +using System.Threading.Tasks; + +namespace Unity.AI.Operations; + +public interface IAIApplicationInputBuilder +{ + Task BuildApplicationAnalysisInputAsync(AIApplicationPromptDataDto application, string? promptVersion); + Task BuildApplicationScoringInputAsync(AIApplicationPromptDataDto application, string? promptVersion); +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Operations/IAIApplicationInputDataProvider.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Operations/IAIApplicationInputDataProvider.cs new file mode 100644 index 0000000000..69b3220545 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Operations/IAIApplicationInputDataProvider.cs @@ -0,0 +1,73 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Unity.AI.Models; + +namespace Unity.AI.Operations; + +public interface IAIApplicationInputDataProvider +{ + Task GetApplicationFormAsync(Guid applicationId); + + Task GetApplicationSubmissionAsync(Guid applicationId); + + Task GetApplicationFormVersionAsync(Guid? formVersionId); + + Task> GetAttachmentSummariesAsync(Guid applicationId); + + Task GetScoresheetAsync(Guid scoresheetId); + + Task HasAttachmentsAsync(Guid applicationId); + + Task HasSubmissionAsync(Guid applicationId); +} + +public sealed class ApplicationFormSnapshot +{ + public Guid? ScoresheetId { get; set; } +} + +public sealed class ApplicationSubmissionSnapshot +{ + public Guid? ApplicationFormVersionId { get; set; } + + public string? Submission { get; set; } +} + +public sealed class ApplicationFormVersionSnapshot +{ + public string? FormSchema { get; set; } +} + +public sealed record AttachmentSummarySnapshot( + string? FileName, + string? Summary); + +public sealed class ScoresheetSnapshot +{ + public List Sections { get; set; } = []; +} + +public sealed class ScoresheetSectionSnapshot +{ + public string Name { get; set; } = string.Empty; + + public int Order { get; set; } + + public List Fields { get; set; } = []; +} + +public sealed class ScoresheetFieldSnapshot +{ + public Guid Id { get; set; } + + public string Label { get; set; } = string.Empty; + + public string? Description { get; set; } + + public string Type { get; set; } = string.Empty; + + public int Order { get; set; } + + public string? Definition { get; set; } +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Operations/IAIGenerationPrerequisiteValidator.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Operations/IAIGenerationPrerequisiteValidator.cs new file mode 100644 index 0000000000..67df324548 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Operations/IAIGenerationPrerequisiteValidator.cs @@ -0,0 +1,13 @@ +using System; +using System.Threading.Tasks; + +namespace Unity.AI.Operations; + +public interface IAIGenerationPrerequisiteValidator +{ + Task EnsureAttachmentSummaryAvailableAsync(Guid applicationId); + + Task EnsureApplicationAnalysisAvailableAsync(Guid applicationId); + + Task EnsureApplicationScoringAvailableAsync(Guid applicationId); +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Operations/IAttachmentSummaryDataProvider.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Operations/IAttachmentSummaryDataProvider.cs new file mode 100644 index 0000000000..aeddf77d69 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Operations/IAttachmentSummaryDataProvider.cs @@ -0,0 +1,20 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Unity.AI.Operations; + +public interface IAttachmentSummaryDataProvider +{ + Task GetAttachmentAsync(Guid attachmentId); + + Task UpdateAttachmentSummaryAsync(Guid attachmentId, string summary); + + Task> GetApplicationAttachmentIdsAsync(Guid applicationId); +} + +public sealed record AttachmentSummarySource( + Guid Id, + string? FileName, + string? ChefsSubmissionId, + string? ChefsFileId); diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Operations/IAttachmentSummaryPersistence.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Operations/IAttachmentSummaryPersistence.cs new file mode 100644 index 0000000000..e2a803d1d7 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Operations/IAttachmentSummaryPersistence.cs @@ -0,0 +1,14 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Unity.AI.Operations; + +public interface IAttachmentSummaryPersistence +{ + Task LoadAsync(Guid attachmentId); + + Task SaveSummaryAsync(Guid attachmentId, string summary); + + Task> LoadApplicationAttachmentIdsAsync(Guid applicationId); +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Prompts/UnityPromptAssetManifest.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Prompts/UnityPromptAssetManifest.cs new file mode 100644 index 0000000000..93bdb236cf --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Prompts/UnityPromptAssetManifest.cs @@ -0,0 +1,11 @@ +using System.Text.Json.Serialization; + +namespace Unity.AI.Prompts; + +public sealed record UnityPromptAssetManifest( + [property: JsonPropertyName("operationName")] string OperationName, + [property: JsonPropertyName("promptVersion")] string PromptVersion, + [property: JsonPropertyName("inputContractName")] string InputContractName, + [property: JsonPropertyName("outputContractName")] string OutputContractName, + [property: JsonPropertyName("modelHint")] string? ModelHint = null, + [property: JsonPropertyName("profileHint")] string? ProfileHint = null); diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Requests/AttachmentSummaryBatchRequest.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Requests/AttachmentSummaryBatchRequest.cs new file mode 100644 index 0000000000..031acd1937 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Requests/AttachmentSummaryBatchRequest.cs @@ -0,0 +1,28 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Unity.AI.Requests; + +public sealed class AttachmentSummaryBatchRequest +{ + [JsonPropertyName("attachments")] + public List Attachments { get; set; } = []; + + [JsonPropertyName("promptVersion")] + public string? PromptVersion { get; set; } +} + +public sealed class AttachmentSummaryBatchItemRequest +{ + [JsonPropertyName("attachmentId")] + public string AttachmentId { get; set; } = string.Empty; + + [JsonPropertyName("fileName")] + public string FileName { get; set; } = string.Empty; + + [JsonPropertyName("contentType")] + public string ContentType { get; set; } = "application/octet-stream"; + + [JsonPropertyName("extractedText")] + public string? ExtractedText { get; set; } +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Responses/AttachmentSummaryBatchResponse.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Responses/AttachmentSummaryBatchResponse.cs new file mode 100644 index 0000000000..4751fe9122 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Responses/AttachmentSummaryBatchResponse.cs @@ -0,0 +1,19 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Unity.AI.Responses; + +public sealed class AttachmentSummaryBatchResponse +{ + [JsonPropertyName("attachments")] + public List Attachments { get; set; } = []; +} + +public sealed class AttachmentSummaryBatchItemResponse +{ + [JsonPropertyName("attachmentId")] + public string AttachmentId { get; set; } = string.Empty; + + [JsonPropertyName("summary")] + public string Summary { get; set; } = string.Empty; +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Attachments/IAttachmentSummaryAppService.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Attachments/IAttachmentSummaryAppService.cs deleted file mode 100644 index e82a6f7669..0000000000 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Attachments/IAttachmentSummaryAppService.cs +++ /dev/null @@ -1,11 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Threading.Tasks; -using Volo.Abp.Application.Services; - -namespace Unity.GrantManager.Attachments; - -public interface IAttachmentSummaryAppService : IApplicationService -{ - Task> GenerateAttachmentSummariesForPipelineAsync(List attachmentIds, string? promptVersion = null); -} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Generation/AIGenerationRequestDto.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Generation/AIGenerationRequestDto.cs new file mode 100644 index 0000000000..3a832df03c --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Generation/AIGenerationRequestDto.cs @@ -0,0 +1,14 @@ +using System; + +namespace Unity.AI.Generation; + +public class AIGenerationRequestDto +{ + public Guid ApplicationId { get; set; } + + public Guid OperationId { get; set; } + + public string OperationType { get; set; } = string.Empty; + + public string Status { get; set; } = string.Empty; +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Generation/AIGenerationStatusDto.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Generation/AIGenerationStatusDto.cs new file mode 100644 index 0000000000..ca5684257c --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Generation/AIGenerationStatusDto.cs @@ -0,0 +1,12 @@ +namespace Unity.AI.Generation; + +public class AIGenerationStatusDto +{ + public AIGenerationStatusRequestDto? GenerationRequest { get; set; } + + public string? FailureReason { get; set; } + + public bool IsGenerating { get; set; } + + public int RetryAfterSeconds { get; set; } +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Generation/AIGenerationStatusRequestDto.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Generation/AIGenerationStatusRequestDto.cs new file mode 100644 index 0000000000..b80e09f433 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Generation/AIGenerationStatusRequestDto.cs @@ -0,0 +1,24 @@ +using System; + +namespace Unity.AI.Generation; + +public class AIGenerationStatusRequestDto +{ + public Guid Id { get; set; } + + public Guid? ApplicationId { get; set; } + + public Guid? OperationId { get; set; } + + public string OperationType { get; set; } = string.Empty; + + public string Status { get; set; } = string.Empty; + + public DateTime? StartedAt { get; set; } + + public DateTime? CompletedAt { get; set; } + + public string? FailureReason { get; set; } + + public bool IsActive { get; set; } +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Generation/GenerateAttachmentSummariesInputDto.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Generation/GenerateAttachmentSummariesInputDto.cs new file mode 100644 index 0000000000..82956d14de --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Generation/GenerateAttachmentSummariesInputDto.cs @@ -0,0 +1,13 @@ +using System; +using System.Collections.Generic; + +namespace Unity.AI.Generation; + +public class GenerateAttachmentSummariesInputDto +{ + public Guid ApplicationId { get; set; } + + public List AttachmentIds { get; set; } = []; + + public string? PromptVersion { get; set; } +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Generation/IAIGenerationAppService.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Generation/IAIGenerationAppService.cs new file mode 100644 index 0000000000..c22118ed39 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Generation/IAIGenerationAppService.cs @@ -0,0 +1,19 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Unity.GrantManager.Attachments; +using Unity.GrantManager.GrantApplications; +using Volo.Abp.Application.Services; + +namespace Unity.AI.Generation; + +public interface IAIGenerationAppService : IApplicationService +{ + Task> GenerateAttachmentSummariesAsync(GenerateAttachmentSummariesInputDto input); + + Task GenerateApplicationAnalysisAsync(Guid applicationId, string? promptVersion = null); + + Task GenerateApplicationScoringAsync(Guid applicationId, string? promptVersion = null); + + Task GetStatusAsync(Guid applicationId, string operationType); +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/GrantApplications/IApplicationAnalysisAppService.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/GrantApplications/IApplicationAnalysisAppService.cs deleted file mode 100644 index 9796792385..0000000000 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/GrantApplications/IApplicationAnalysisAppService.cs +++ /dev/null @@ -1,12 +0,0 @@ -using System; -using System.Threading.Tasks; -using Volo.Abp.Application.Services; - -namespace Unity.GrantManager.GrantApplications -{ - public interface IApplicationAnalysisAppService : IApplicationService - { - Task GenerateApplicationAnalysisAsync(Guid applicationId, string? promptVersion = null); - Task GenerateApplicationAnalysisForPipelineAsync(Guid applicationId, string? promptVersion = null); - } -} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/GrantApplications/IApplicationContentAppService.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/GrantApplications/IApplicationContentAppService.cs deleted file mode 100644 index 1ce45e8a99..0000000000 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/GrantApplications/IApplicationContentAppService.cs +++ /dev/null @@ -1,10 +0,0 @@ -using System; -using System.Threading.Tasks; -using Volo.Abp.Application.Services; - -namespace Unity.GrantManager.GrantApplications; - -public interface IApplicationContentAppService : IApplicationService -{ - Task GenerateContentAsync(Guid applicationId, string? promptVersion = null); -} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/GrantApplications/IApplicationScoringAppService.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/GrantApplications/IApplicationScoringAppService.cs deleted file mode 100644 index 158f04b607..0000000000 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/GrantApplications/IApplicationScoringAppService.cs +++ /dev/null @@ -1,12 +0,0 @@ -using System; -using System.Threading.Tasks; -using Volo.Abp.Application.Services; - -namespace Unity.GrantManager.GrantApplications -{ - public interface IApplicationScoringAppService : IApplicationService - { - Task GenerateApplicationScoringAsync(Guid applicationId, string? promptVersion = null); - Task GenerateApplicationScoringForPipelineAsync(Guid applicationId, string? promptVersion = null); - } -} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Permissions/AIPermissionDefinitionProvider.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Permissions/AIPermissionDefinitionProvider.cs index 6c0a363c86..3e4a2a7b9d 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Permissions/AIPermissionDefinitionProvider.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Permissions/AIPermissionDefinitionProvider.cs @@ -10,56 +10,59 @@ public class AIPermissionDefinitionProvider : PermissionDefinitionProvider { public override void Define(IPermissionDefinitionContext context) { - // AI Permission Group - var aiPermissionsGroup = context.AddGroup( - AIPermissions.GroupName, - L("Permission:AI")); + // AI Permission Group + var aiPermissionsGroup = context.AddGroup( + AIPermissions.GroupName, + L("Permission:AI")); + var aiReporting = aiPermissionsGroup.AddPermission( + AIPermissions.Reporting.ReportingDefault, + L("Permission:AI.Reporting")) + .RequireFeatures("Unity.AIReporting"); - aiPermissionsGroup.AddPermission( - AIPermissions.Reporting.ReportingDefault, - L("Permission:AI.Reporting")) - .RequireFeatures("Unity.AIReporting"); + aiReporting.AddChild( + AIPermissions.Reporting.CreateEditDataModel, + L("Permission:AI.Reporting.CreateEditDataModel")) + .RequireFeatures("Unity.AIReporting"); - var viewApplicationAnalysis = aiPermissionsGroup.AddPermission( - AIPermissions.Analysis.ViewApplicationAnalysis, - L("Permission:AI.ViewApplicationAnalysis")) - .RequireFeatures("Unity.AI.ApplicationAnalysis"); + var viewApplicationAnalysis = aiPermissionsGroup.AddPermission( + AIPermissions.Analysis.ViewApplicationAnalysis, + L("Permission:AI.ViewApplicationAnalysis")) + .RequireFeatures("Unity.AI.ApplicationAnalysis"); - viewApplicationAnalysis.AddChild( - AIPermissions.Analysis.GenerateApplicationAnalysis, - L("Permission:AI.GenerateApplicationAnalysis")) - .RequireFeatures("Unity.AI.ApplicationAnalysis"); + viewApplicationAnalysis.AddChild( + AIPermissions.Analysis.GenerateApplicationAnalysis, + L("Permission:AI.GenerateApplicationAnalysis")) + .RequireFeatures("Unity.AI.ApplicationAnalysis"); - var viewAttachmentSummary = aiPermissionsGroup.AddPermission( - AIPermissions.Analysis.ViewAttachmentSummary, - L("Permission:AI.ViewAttachmentSummary")) - .RequireFeatures("Unity.AI.AttachmentSummaries"); + var viewAttachmentSummary = aiPermissionsGroup.AddPermission( + AIPermissions.Analysis.ViewAttachmentSummary, + L("Permission:AI.ViewAttachmentSummary")) + .RequireFeatures("Unity.AI.AttachmentSummaries"); - viewAttachmentSummary.AddChild( - AIPermissions.Analysis.GenerateAttachmentSummaries, - L("Permission:AI.GenerateAttachmentSummaries")) - .RequireFeatures("Unity.AI.AttachmentSummaries"); + viewAttachmentSummary.AddChild( + AIPermissions.Analysis.GenerateAttachmentSummaries, + L("Permission:AI.GenerateAttachmentSummaries")) + .RequireFeatures("Unity.AI.AttachmentSummaries"); - var viewScoringResult = aiPermissionsGroup.AddPermission( - AIPermissions.Analysis.ViewScoringResult, - L("Permission:AI.ViewScoringResult")) - .RequireFeatures("Unity.AI.Scoring"); + var viewScoringResult = aiPermissionsGroup.AddPermission( + AIPermissions.Analysis.ViewScoringResult, + L("Permission:AI.ViewScoringResult")) + .RequireFeatures("Unity.AI.Scoring"); - viewScoringResult.AddChild( - AIPermissions.Analysis.GenerateScoring, - L("Permission:AI.GenerateScoring")) - .RequireFeatures("Unity.AI.Scoring"); - - var settingManagement = context.GetGroup(SettingManagementPermissions.GroupName); - var configureAI = settingManagement.AddPermission( - AIPermissions.Configuration.ConfigureAI, - L("Permission:AI.ConfigureAI")); - configureAI.StateCheckers.Add(new AnyFeaturePermissionStateProvider( - "Unity.AI.Scoring", - "Unity.AI.AttachmentSummaries", - "Unity.AI.ApplicationAnalysis")); + viewScoringResult.AddChild( + AIPermissions.Analysis.GenerateScoring, + L("Permission:AI.GenerateScoring")) + .RequireFeatures("Unity.AI.Scoring"); + var settingManagement = context.GetGroup(SettingManagementPermissions.GroupName); + var configureAI = settingManagement.AddPermission( + AIPermissions.Configuration.ConfigureAI, + L("Permission:AI.ConfigureAI")); + configureAI.StateCheckers.Add(new AnyFeaturePermissionStateProvider( + "Unity.AI.Scoring", + "Unity.AI.AttachmentSummaries", + "Unity.AI.ApplicationAnalysis")); } private static LocalizableString L(string name) diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Permissions/AIPermissions.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Permissions/AIPermissions.cs index b9d561deca..b9ea59607f 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Permissions/AIPermissions.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Permissions/AIPermissions.cs @@ -9,6 +9,7 @@ public static class AIPermissions public static class Reporting { public const string ReportingDefault = GroupName + ".Reporting"; + public const string CreateEditDataModel = GroupName + ".Reporting.CreateEditDataModel"; } public static class Analysis diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Prompts/AIPromptDto.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Prompts/AIPromptDto.cs index 0bdff63a08..d5b673b699 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Prompts/AIPromptDto.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Prompts/AIPromptDto.cs @@ -1,5 +1,4 @@ using System; -using System.Collections.Generic; using Volo.Abp.Application.Dtos; namespace Unity.AI.Prompts; @@ -7,8 +6,9 @@ namespace Unity.AI.Prompts; public class AIPromptDto : AuditedEntityDto { public string Name { get; set; } = string.Empty; - public string? Description { get; set; } - public PromptType Type { get; set; } + public int VersionNumber { get; set; } + public string SystemPrompt { get; set; } = string.Empty; + public string UserPrompt { get; set; } = string.Empty; + public string? MetadataJson { get; set; } public bool IsActive { get; set; } - public List Versions { get; set; } = new(); } diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Prompts/AIPromptVersionDto.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Prompts/AIPromptVersionDto.cs deleted file mode 100644 index a9ed4e50a8..0000000000 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Prompts/AIPromptVersionDto.cs +++ /dev/null @@ -1,20 +0,0 @@ -using System; -using Volo.Abp.Application.Dtos; - -namespace Unity.AI.Prompts; - -public class AIPromptVersionDto : AuditedEntityDto -{ - public Guid PromptId { get; set; } - public int VersionNumber { get; set; } - public string SystemPrompt { get; set; } = string.Empty; - public string UserPromptTemplate { get; set; } = string.Empty; - public string? DeveloperNotes { get; set; } - public string? TargetModel { get; set; } - public string? TargetProvider { get; set; } - public double Temperature { get; set; } - public int? MaxTokens { get; set; } - public bool IsPublished { get; set; } - public bool IsDeprecated { get; set; } - public string? MetadataJson { get; set; } -} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Prompts/CreateUpdateAIPromptDto.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Prompts/CreateUpdateAIPromptDto.cs index 6d361fd3ba..e26973030a 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Prompts/CreateUpdateAIPromptDto.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Prompts/CreateUpdateAIPromptDto.cs @@ -1,3 +1,4 @@ +using System; using System.ComponentModel; using System.ComponentModel.DataAnnotations; @@ -5,17 +6,21 @@ namespace Unity.AI.Prompts; public class CreateUpdateAIPromptDto { + public Guid PromptId { get; set; } + + [DisplayName("VersionNumber")] + public int VersionNumber { get; set; } + [Required] - [MaxLength(200)] - [DisplayName("PromptName")] - public string Name { get; set; } = string.Empty; + [DisplayName("SystemPrompt")] + public string SystemPrompt { get; set; } = string.Empty; - [MaxLength(2000)] - [DisplayName("PromptDescription")] - public string? Description { get; set; } + [Required] + [DisplayName("UserPrompt")] + public string UserPrompt { get; set; } = string.Empty; - [DisplayName("PromptType")] - public PromptType Type { get; set; } + [DisplayName("MetadataJson")] + public string? MetadataJson { get; set; } [DisplayName("PromptIsActive")] public bool IsActive { get; set; } = true; diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Prompts/CreateUpdateAIPromptVersionDto.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Prompts/CreateUpdateAIPromptVersionDto.cs deleted file mode 100644 index 8e3414943f..0000000000 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Prompts/CreateUpdateAIPromptVersionDto.cs +++ /dev/null @@ -1,47 +0,0 @@ -using System; -using System.ComponentModel; -using System.ComponentModel.DataAnnotations; - -namespace Unity.AI.Prompts; - -public class CreateUpdateAIPromptVersionDto -{ - public Guid PromptId { get; set; } - - [DisplayName("VersionNumber")] - public int VersionNumber { get; set; } - - [Required] - [DisplayName("SystemPrompt")] - public string SystemPrompt { get; set; } = string.Empty; - - [Required] - [DisplayName("UserPromptTemplate")] - public string UserPromptTemplate { get; set; } = string.Empty; - - [DisplayName("DeveloperNotes")] - public string? DeveloperNotes { get; set; } - - [MaxLength(100)] - [DisplayName("TargetModel")] - public string? TargetModel { get; set; } - - [MaxLength(100)] - [DisplayName("TargetProvider")] - public string? TargetProvider { get; set; } - - [DisplayName("Temperature")] - public double Temperature { get; set; } = 0.2; - - [DisplayName("MaxTokens")] - public int? MaxTokens { get; set; } - - [DisplayName("IsPublished")] - public bool IsPublished { get; set; } - - [DisplayName("IsDeprecated")] - public bool IsDeprecated { get; set; } - - [DisplayName("MetadataJson")] - public string? MetadataJson { get; set; } -} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Prompts/IAIPromptAppService.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Prompts/IAIPromptAppService.cs index c259996db0..7aca79853d 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Prompts/IAIPromptAppService.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Prompts/IAIPromptAppService.cs @@ -10,4 +10,5 @@ public interface IAIPromptAppService : ICrudAppService< PagedAndSortedResultRequestDto, CreateUpdateAIPromptDto> { + System.Threading.Tasks.Task> GetByPromptAsync(Guid promptId); } diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Prompts/IAIPromptVersionAppService.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Prompts/IAIPromptVersionAppService.cs deleted file mode 100644 index 269029e5ec..0000000000 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Prompts/IAIPromptVersionAppService.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System; -using Volo.Abp.Application.Dtos; -using Volo.Abp.Application.Services; - -namespace Unity.AI.Prompts; - -public interface IAIPromptVersionAppService : ICrudAppService< - AIPromptVersionDto, - Guid, - PagedAndSortedResultRequestDto, - CreateUpdateAIPromptVersionDto> -{ - System.Threading.Tasks.Task> GetByPromptAsync(Guid promptId); -} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Extraction/TextExtractionService.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Extraction/TextExtractionService.cs index 1da60206a2..4733c05dc5 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Extraction/TextExtractionService.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Extraction/TextExtractionService.cs @@ -37,7 +37,6 @@ public Task ExtractTextAsync(string fileName, Stream fileContent, string { if (fileContent == null) { - _logger.LogDebug("File content stream is null for {FileName}", fileName); return Task.FromResult(string.Empty); } @@ -49,7 +48,6 @@ public Task ExtractTextAsync(string fileName, Stream fileContent, string if (extension == ".doc") { - _logger.LogDebug("Legacy .doc extraction is not supported for {FileName}", fileName); return Task.FromResult(string.Empty); } @@ -63,13 +61,7 @@ public Task ExtractTextAsync(string fileName, Stream fileContent, string _ => ExtractByContentType(fileName, fileContent, normalizedContentType, cancellationToken) }; - if (string.IsNullOrEmpty(rawText)) - { - _logger.LogDebug("No text extraction available for content type {ContentType} with extension {Extension}", - contentType, extension); - } - - return Task.FromResult(NormalizeAndLimitText(rawText, fileName)); + return Task.FromResult(NormalizeAndLimitText(rawText)); } catch (OperationCanceledException) { @@ -138,12 +130,9 @@ private string ExtractTextFromTextFile(Stream fileContent, CancellationToken can builder.Append(buffer, 0, Math.Min(read, remaining)); if (builder.Length >= MaxExtractedTextLength) { - _logger.LogDebug("Truncated text content to {MaxLength} characters", MaxExtractedTextLength); break; } } - - _logger.LogDebug("Extracted {CharacterCount} characters from text-based content.", builder.Length); return builder.ToString(); } catch (Exception ex) @@ -180,7 +169,6 @@ private string ExtractTextFromPdfFile(string fileName, Stream fileContent, Cance } } - _logger.LogDebug("Extracted PDF text from {ProcessedPageCount} pages for {FileName}", processedPageCount, fileName); return builder.ToString(); } catch (Exception ex) @@ -200,11 +188,6 @@ private string ExtractTextFromWordDocx(string fileName, Stream fileContent, Canc var processedParagraphCount = AppendDocxParagraphText(document, builder, cancellationToken); var processedTableRowCount = AppendDocxTableText(document, builder, cancellationToken); - _logger.LogDebug( - "Extracted Word text from {ProcessedParagraphCount} paragraphs and {ProcessedTableRowCount} table rows for {FileName}", - processedParagraphCount, - processedTableRowCount, - fileName); return builder.ToString(); } catch (Exception ex) @@ -324,11 +307,6 @@ private string ExtractTextFromExcelFile(string fileName, Stream fileContent, Can } } - _logger.LogDebug( - "Extracted Excel text from {ProcessedSheetCount} sheets and {ProcessedRowCount} rows for {FileName}", - processedSheetCount, - processedRowCount, - fileName); return builder.ToString(); } catch (Exception ex) @@ -371,7 +349,6 @@ private string ExtractTextFromPowerPointFile(string fileName, Stream fileContent } } - _logger.LogDebug("Extracted PowerPoint text from {ProcessedSlideCount} slides for {FileName}", processedSlideCount, fileName); return builder.ToString(); } catch (Exception ex) @@ -390,14 +367,12 @@ private IEnumerable GetOrderedPowerPointSlideEntries(ZipArchive if (slideEntriesByName.Count == 0) { - _logger.LogDebug("No slide entries found in PowerPoint archive."); return Enumerable.Empty(); } var orderedSlideNames = TryGetPowerPointSlideOrder(archive); if (orderedSlideNames.Count == 0) { - _logger.LogDebug("Using PowerPoint part-name order fallback for {SlideCount} slides.", slideEntriesByName.Count); return slideEntriesByName.Values .OrderBy(entry => GetPowerPointSlideNumber(entry.FullName)) .ToList(); @@ -417,8 +392,6 @@ private IEnumerable GetOrderedPowerPointSlideEntries(ZipArchive { orderedEntries.AddRange(slideEntriesByName.Values.OrderBy(entry => GetPowerPointSlideNumber(entry.FullName))); } - - _logger.LogDebug("Resolved PowerPoint presentation order for {SlideCount} slides.", orderedEntries.Count); return orderedEntries; } @@ -583,7 +556,7 @@ private List TryGetPowerPointSlideOrder(ZipArchive archive) } catch (Exception ex) { - _logger.LogDebug(ex, "Falling back to part-name slide order for PowerPoint extraction."); + _logger.LogDebug(ex, "Could not determine PowerPoint slide order from relationships; falling back to part-name order."); return new List(); } } @@ -683,15 +656,13 @@ private static string GetCellText(NPOI.SS.UserModel.ICell cell) }) ?? string.Empty; } - private string NormalizeAndLimitText(string text, string fileName) + private string NormalizeAndLimitText(string text) { var normalized = NormalizeExtractedText(text); - normalized = RemoveLeadingFileNameArtifact(normalized, fileName); if (normalized.Length > MaxExtractedTextLength) { normalized = normalized.Substring(0, MaxExtractedTextLength); - _logger.LogDebug("Truncated extracted content to {MaxLength} characters", MaxExtractedTextLength); } return normalized; @@ -709,11 +680,6 @@ private static string NormalizeExtractedText(string text) .Replace("\r\n", "\n") .Replace('\r', '\n'); - normalized = LowerToUpperWordBoundaryRegex().Replace(normalized, " "); - normalized = PunctuationToWordBoundaryRegex().Replace(normalized, " "); - normalized = ColonDashSpacingRegex().Replace(normalized, ": - "); - normalized = HyphenSpacingRegex().Replace(normalized, " - "); - normalized = KeywordBoundaryRegex().Replace(normalized, " "); normalized = MultipleSpacesRegex().Replace(normalized, " "); normalized = NewlineWhitespaceRegex().Replace(normalized, "\n"); normalized = MultipleNewlinesRegex().Replace(normalized, "\n"); @@ -721,55 +687,6 @@ private static string NormalizeExtractedText(string text) return normalized.Trim(); } - private static string RemoveLeadingFileNameArtifact(string text, string fileName) - { - if (string.IsNullOrWhiteSpace(text) || string.IsNullOrWhiteSpace(fileName)) - { - return text; - } - - var rawStem = Path.GetFileNameWithoutExtension(fileName)?.Trim(); - if (string.IsNullOrWhiteSpace(rawStem)) - { - return text; - } - - var decodedStem = Uri.UnescapeDataString(rawStem); - foreach (var candidate in new[] { rawStem, decodedStem }) - { - if (string.IsNullOrWhiteSpace(candidate)) - { - continue; - } - - if (text.StartsWith(candidate, StringComparison.OrdinalIgnoreCase)) - { - var stripped = text.Substring(candidate.Length).TrimStart(' ', '-', ':', '.', '\t'); - if (!string.IsNullOrWhiteSpace(stripped)) - { - return stripped; - } - } - } - - return text; - } - - [GeneratedRegex(@"(?<=[a-z])(?=[A-Z])")] - private static partial Regex LowerToUpperWordBoundaryRegex(); - - [GeneratedRegex(@"(?<=[\.\,\:\;\)])(?=[A-Za-z0-9])")] - private static partial Regex PunctuationToWordBoundaryRegex(); - - [GeneratedRegex(@":-")] - private static partial Regex ColonDashSpacingRegex(); - - [GeneratedRegex(@"(?<=\S)- (?=[A-Za-z])")] - private static partial Regex HyphenSpacingRegex(); - - [GeneratedRegex(@"(?<=[a-z])(?=(project|funding|budget|community|summary|notes|details|planning|outcomes|background|services)\b)", RegexOptions.IgnoreCase)] - private static partial Regex KeywordBoundaryRegex(); - [GeneratedRegex(@"[ \t]+")] private static partial Regex MultipleSpacesRegex(); diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/AIApplicationInputBuilder.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/AIApplicationInputBuilder.cs new file mode 100644 index 0000000000..1378b5bbd5 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/AIApplicationInputBuilder.cs @@ -0,0 +1,154 @@ +using Microsoft.Extensions.Logging; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using Unity.AI.Models; +using Unity.AI.Prompts; +using Volo.Abp; +using Volo.Abp.DependencyInjection; + +namespace Unity.AI.Operations; + +public class AIApplicationInputBuilder( + IAIApplicationInputDataProvider dataProvider, + ILogger logger) : IAIApplicationInputBuilder, ITransientDependency +{ + public async Task BuildApplicationAnalysisInputAsync(AIApplicationPromptDataDto application, string? promptVersion) + { + var formSubmission = await dataProvider.GetApplicationSubmissionAsync(application.ApplicationId); + var attachments = PromptDataPayloadBuilder.BuildAttachmentSummaries(await dataProvider.GetAttachmentSummariesAsync(application.ApplicationId)); + var formSchema = await GetFormSchemaAsync(formSubmission?.ApplicationFormVersionId); + + return new ApplicationAnalysisOperationInputDto + { + ApplicationId = application.ApplicationId, + Schema = JsonSerializer.SerializeToElement(PromptDataPayloadBuilder.BuildFormFieldConfiguration(formSchema, logger)), + Data = PromptDataPayloadBuilder.BuildPromptDataPayload(application, formSubmission?.Submission, formSchema, logger), + Attachments = attachments, + PromptVersion = promptVersion + }; + } + + public async Task BuildApplicationScoringInputAsync(AIApplicationPromptDataDto application, string? promptVersion) + { + var applicationForm = await dataProvider.GetApplicationFormAsync(application.ApplicationId); + if (applicationForm?.ScoresheetId == null) + { + throw new UserFriendlyException("Scoring requires a configured scoresheet."); + } + + var scoresheet = await dataProvider.GetScoresheetAsync(applicationForm.ScoresheetId.Value); + if (scoresheet == null || !scoresheet.Sections.Any() || !scoresheet.Sections.SelectMany(s => s.Fields).Any()) + { + throw new UserFriendlyException("Scoring requires a scoresheet with fields."); + } + + var attachments = await dataProvider.GetAttachmentSummariesAsync(application.ApplicationId); + var attachmentSummaries = PromptDataPayloadBuilder.BuildAttachmentSummaries(attachments); + + var formSubmission = await dataProvider.GetApplicationSubmissionAsync(application.ApplicationId); + var formSchema = await GetFormSchemaAsync(formSubmission?.ApplicationFormVersionId); + var promptData = PromptDataPayloadBuilder.BuildPromptDataPayload(application, formSubmission?.Submission, formSchema, logger); + + var sections = scoresheet.Sections + .OrderBy(s => s.Order) + .Select(section => new ApplicationScoringSectionOperationInputDto + { + SectionName = section.Name, + SectionSchema = JsonSerializer.SerializeToElement(BuildSectionQuestionsData(section)) + }) + .ToList(); + + return new ApplicationScoringOperationInputDto + { + ApplicationId = application.ApplicationId, + Data = promptData, + Attachments = attachmentSummaries, + Sections = sections, + PromptVersion = promptVersion + }; + } + + private async Task GetFormSchemaAsync(Guid? formVersionId) + { + if (formVersionId == null) + { + return null; + } + + try + { + var formVersion = await dataProvider.GetApplicationFormVersionAsync(formVersionId); + return string.IsNullOrWhiteSpace(formVersion?.FormSchema) ? null : formVersion.FormSchema; + } + catch (Exception ex) + { + logger.LogWarning(ex, "Unable to load form schema for AI input generation for form version {FormVersionId}.", formVersionId); + return null; + } + } + + private static List BuildSectionQuestionsData(ScoresheetSectionSnapshot section) + { + var sectionQuestionsData = new List(); + foreach (var field in section.Fields.OrderBy(f => f.Order)) + { + var options = ExtractSelectListOptions(field); + sectionQuestionsData.Add(new + { + id = field.Id.ToString(), + section = section.Name, + question = field.Label, + description = field.Description, + type = field.Type.ToString(), + options, + allowed_answers = ExtractSelectListOptionNumbers(options) + }); + } + + return sectionQuestionsData; + } + + private static object[]? ExtractSelectListOptions(ScoresheetFieldSnapshot field) + { + if (field.Type != Unity.Flex.Scoresheets.Enums.QuestionType.SelectList.ToString() || 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) => + (object)new + { + number = index + 1, + value = option.Value + }) + .ToArray(); + } + } + catch (JsonException) + { + } + + return null; + } + + private static string[]? ExtractSelectListOptionNumbers(object[]? options) + { + if (options == null || options.Length == 0) + { + return null; + } + + return options + .Select((_, index) => (index + 1).ToString()) + .ToArray(); + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/AIExecutionStrategy.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/AIExecutionStrategy.cs index 96078d8970..c6465ac29b 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/AIExecutionStrategy.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/AIExecutionStrategy.cs @@ -28,19 +28,21 @@ public static async Task> RunAsync( switch (mode) { - case AIExecutionMode.Parallel: - return [.. await Task.WhenAll(items.Select(operation))]; - - case AIExecutionMode.Batch: - return await batchOperation(items); - - default: + case AIExecutionMode.Sequential: var sequential = new List(items.Count); foreach (var item in items) { sequential.Add(await operation(item)); } return sequential; + + case AIExecutionMode.Batch: + return await batchOperation(items); + + case AIExecutionMode.Parallel: + return [.. await Task.WhenAll(items.Select(operation))]; } + + throw new ArgumentOutOfRangeException(nameof(mode), mode, "Unsupported AI execution mode."); } } diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/AIGenerationPrerequisiteValidator.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/AIGenerationPrerequisiteValidator.cs new file mode 100644 index 0000000000..eb882c8cae --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/AIGenerationPrerequisiteValidator.cs @@ -0,0 +1,45 @@ +using Microsoft.Extensions.Localization; +using System; +using System.Linq; +using System.Threading.Tasks; +using Unity.AI.Localization; +using Volo.Abp; +using Volo.Abp.DependencyInjection; + +namespace Unity.AI.Operations; + +public class AIGenerationPrerequisiteValidator( + IAIApplicationInputDataProvider dataProvider, + IStringLocalizer localizer) : IAIGenerationPrerequisiteValidator, ITransientDependency +{ + public async Task EnsureAttachmentSummaryAvailableAsync(Guid applicationId) + { + if (!await dataProvider.HasAttachmentsAsync(applicationId)) + { + throw new UserFriendlyException(localizer[AILocalizationKeys.NoAttachmentsAvailable]); + } + } + + public async Task EnsureApplicationAnalysisAvailableAsync(Guid applicationId) + { + if (!await dataProvider.HasSubmissionAsync(applicationId)) + { + throw new UserFriendlyException(localizer[AILocalizationKeys.ApplicationAnalysisRequiresSubmission]); + } + } + + public async Task EnsureApplicationScoringAvailableAsync(Guid applicationId) + { + var applicationForm = await dataProvider.GetApplicationFormAsync(applicationId); + if (applicationForm?.ScoresheetId == null) + { + throw new UserFriendlyException(localizer[AILocalizationKeys.ScoringRequiresScoresheet]); + } + + var scoresheet = await dataProvider.GetScoresheetAsync(applicationForm.ScoresheetId.Value); + if (scoresheet == null || !scoresheet.Sections.Any() || !scoresheet.Sections.SelectMany(s => s.Fields).Any()) + { + throw new UserFriendlyException(localizer[AILocalizationKeys.ScoringRequiresScoresheetFields]); + } + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/ApplicationAnalysisService.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/ApplicationAnalysisService.cs index 12b9b24342..b2ce118946 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/ApplicationAnalysisService.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/ApplicationAnalysisService.cs @@ -1,198 +1,33 @@ -using Microsoft.Extensions.Logging; -using Newtonsoft.Json.Linq; -using System; -using System.Collections.Generic; -using System.Linq; using System.Text.Json; using System.Threading; using System.Threading.Tasks; +using Unity.AI; using Unity.AI.Models; -using Unity.AI.Prompts; using Unity.AI.Requests; using Unity.AI.Runtime; -using Unity.GrantManager.Applications; using Volo.Abp.DependencyInjection; namespace Unity.AI.Operations { public class ApplicationAnalysisService( - IApplicationRepository applicationRepository, - IApplicationFormSubmissionRepository applicationFormSubmissionRepository, - IApplicationFormVersionRepository applicationFormVersionRepository, - IApplicationChefsFileAttachmentRepository applicationChefsFileAttachmentRepository, IAIService aiService, - ILogger logger) : IApplicationAnalysisService, ITransientDependency + IAIGenerationPrerequisiteValidator aiGenerationPrerequisiteValidator) : IApplicationAnalysisService, ITransientDependency { - private const string ComponentsKey = "components"; - private static readonly HashSet ExcludedSchemaKeys = new(StringComparer.OrdinalIgnoreCase) + public async Task RegenerateAsync(ApplicationAnalysisOperationInputDto input, CancellationToken cancellationToken = default) { - "applicantAgent" - }; - - public async Task RegenerateAndSaveAsync(Guid applicationId, string? promptVersion = null, CancellationToken cancellationToken = default) - { - var application = await applicationRepository.GetAsync(applicationId); - var formSubmission = await applicationFormSubmissionRepository.GetByApplicationAsync(applicationId); - var attachments = await applicationChefsFileAttachmentRepository.GetListAsync(a => a.ApplicationId == applicationId); - var formSchema = await GetFormSchemaAsync(formSubmission?.ApplicationFormVersionId); - - 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(); - - object formFieldConfiguration = new { message = "Form configuration not available." }; - if (formSubmission?.ApplicationFormVersionId != null) - { - formFieldConfiguration = await ExtractFormFieldConfigurationAsync(formSubmission.ApplicationFormVersionId.Value); - } + await aiGenerationPrerequisiteValidator.EnsureApplicationAnalysisAvailableAsync(input.ApplicationId); var analysis = await aiService.GenerateApplicationAnalysisAsync(new ApplicationAnalysisRequest { - Schema = JsonSerializer.SerializeToElement(formFieldConfiguration), - Data = PromptDataPayloadBuilder.BuildPromptDataPayload(application, formSubmission, formSchema, logger), - Attachments = attachmentSummaries, - PromptVersion = promptVersion, + Schema = input.Schema, + Data = input.Data, + Attachments = input.Attachments, + PromptVersion = input.PromptVersion, }, cancellationToken); var analysisJson = JsonSerializer.Serialize(analysis, AIJsonDefaults.Indented); - application.AIAnalysis = analysisJson; - await applicationRepository.UpdateAsync(application); return analysisJson; } - private async Task GetFormSchemaAsync(Guid? formVersionId) - { - if (formVersionId == null) - { - return null; - } - - try - { - var formVersion = await applicationFormVersionRepository.GetAsync(formVersionId.Value); - return string.IsNullOrWhiteSpace(formVersion?.FormSchema) ? null : formVersion.FormSchema; - } - catch (Exception ex) - { - logger.LogWarning(ex, "Unable to load form schema for prompt data generation for form version {FormVersionId}.", formVersionId); - return null; - } - } - - 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) || ExcludedSchemaKeys.Contains(key)) - { - 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/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/ApplicationScoringService.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/ApplicationScoringService.cs index f6188f689b..ec6bb55e8d 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/ApplicationScoringService.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/ApplicationScoringService.cs @@ -5,64 +5,29 @@ using System.Text.Json; using System.Threading; using System.Threading.Tasks; -using Unity.Flex.Domain.Scoresheets; +using Unity.AI; using Unity.AI.Models; -using Unity.AI.Prompts; using Unity.AI.Requests; using Unity.AI.Runtime; -using Unity.GrantManager.Applications; using Volo.Abp.DependencyInjection; namespace Unity.AI.Operations { public class ApplicationScoringService( - IApplicationRepository applicationRepository, - IApplicationFormRepository applicationFormRepository, - IApplicationFormSubmissionRepository applicationFormSubmissionRepository, - IApplicationFormVersionRepository applicationFormVersionRepository, - IApplicationChefsFileAttachmentRepository applicationChefsFileAttachmentRepository, - IScoresheetRepository scoresheetRepository, IAIService aiService, AIExecutionModeResolver executionModeResolver, ILogger logger) : IApplicationScoringService, ITransientDependency { - public async Task RegenerateAndSaveAsync(Guid applicationId, string? promptVersion = null, CancellationToken cancellationToken = default) + public async Task RegenerateAsync(ApplicationScoringOperationInputDto input, CancellationToken cancellationToken = default) { - 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 formSchema = await GetFormSchemaAsync(formSubmission?.ApplicationFormVersionId); - var promptData = PromptDataPayloadBuilder.BuildPromptDataPayload(application, formSubmission, formSchema, logger); - - var sections = scoresheet.Sections.OrderBy(s => s.Order).ToList(); + var sections = input.Sections; var mode = executionModeResolver.ResolveMode(AIExecutionModeResolver.ApplicationScoringOperation); var perSectionResults = await AIExecutionStrategy.RunAsync( sections, mode, - section => ProcessSectionAsync(applicationId, section, promptData, attachmentSummaries, promptVersion, cancellationToken), - batch => ProcessSectionsAsync(applicationId, batch, promptData, attachmentSummaries, promptVersion, cancellationToken)); + section => ProcessSectionAsync(input.ApplicationId, section, input.Data, input.Attachments, input.PromptVersion, cancellationToken), + batch => ProcessSectionsAsync(input.ApplicationId, batch, input.Data, input.Attachments, input.PromptVersion, cancellationToken)); var allSectionResults = new Dictionary(); foreach (var sectionResult in perSectionResults) @@ -75,14 +40,12 @@ public async Task RegenerateAndSaveAsync(Guid applicationId, string? pro var combinedResults = JsonSerializer.Serialize(allSectionResults, AIJsonDefaults.Indented); var validatedJson = ValidateApplicationScoringJson(combinedResults); - application.AIScoresheetAnswers = validatedJson; - await applicationRepository.UpdateAsync(application); return validatedJson; } private async Task> ProcessSectionAsync( Guid applicationId, - ScoresheetSection section, + ApplicationScoringSectionOperationInputDto section, JsonElement promptData, List attachmentSummaries, string? promptVersion, @@ -95,8 +58,8 @@ private async Task> ProcessSectionAsync( { Data = promptData, Attachments = attachmentSummaries, - SectionName = section.Name, - SectionSchema = JsonSerializer.SerializeToElement(BuildSectionQuestionsData(section), AIJsonDefaults.IndentedCamelCase), + SectionName = section.SectionName, + SectionSchema = section.SectionSchema, PromptVersion = promptVersion, }; var applicationScoringResponse = await aiService.GenerateApplicationScoringAsync(applicationScoringRequest, cancellationToken); @@ -112,14 +75,15 @@ private async Task> ProcessSectionAsync( } catch (Exception ex) { - logger.LogError(ex, "Error processing AI application scoring section {SectionName} for application {ApplicationId}", section.Name, applicationId); + logger.LogError(ex, "Error processing AI application scoring section {SectionName} for application {ApplicationId}", section.SectionName, applicationId); } + return sectionResults; } private async Task>> ProcessSectionsAsync( Guid applicationId, - IReadOnlyCollection sections, + IReadOnlyCollection sections, JsonElement promptData, List attachmentSummaries, string? promptVersion, @@ -128,17 +92,14 @@ private async Task>> ProcessSectionsAsync( var sectionResults = new Dictionary(); try { - var questions = sections - .OrderBy(s => s.Order) - .SelectMany(BuildSectionQuestionsData) - .ToList(); + var questions = BuildBatchSectionSchema(sections); var applicationScoringRequest = new ApplicationScoringRequest { Data = promptData, Attachments = attachmentSummaries, SectionName = "All Sections", - SectionSchema = JsonSerializer.SerializeToElement(questions, AIJsonDefaults.IndentedCamelCase), + SectionSchema = questions, PromptVersion = promptVersion, }; var applicationScoringResponse = await aiService.GenerateApplicationScoringAsync(applicationScoringRequest, cancellationToken); @@ -160,25 +121,24 @@ private async Task>> ProcessSectionsAsync( return [sectionResults]; } - private static List BuildSectionQuestionsData(ScoresheetSection section) + private static JsonElement BuildBatchSectionSchema(IReadOnlyCollection sections) { - var sectionQuestionsData = new List(); - foreach (var field in section.Fields.OrderBy(f => f.Order)) + var questions = new List(); + foreach (var section in sections) { - var options = ExtractSelectListOptions(field); - sectionQuestionsData.Add(new + if (section.SectionSchema.ValueKind != JsonValueKind.Array) { - id = field.Id.ToString(), - section = section.Name, - question = field.Label, - description = field.Description, - type = field.Type.ToString(), - options, - allowed_answers = ExtractSelectListOptionNumbers(options) - }); + throw new InvalidOperationException( + $"Section schema for '{section.SectionName}' must be a JSON array."); + } + + foreach (var question in section.SectionSchema.EnumerateArray()) + { + questions.Add(question.Clone()); + } } - return sectionQuestionsData; + return JsonSerializer.SerializeToElement(questions, AIJsonDefaults.IndentedCamelCase); } private void CopyAnswers(Dictionary answers, Dictionary results) @@ -191,25 +151,6 @@ private void CopyAnswers(Dictionary answers, D } } - private async Task GetFormSchemaAsync(Guid? formVersionId) - { - if (formVersionId == null) - { - return null; - } - - try - { - var formVersion = await applicationFormVersionRepository.GetAsync(formVersionId.Value); - return string.IsNullOrWhiteSpace(formVersion?.FormSchema) ? null : formVersion.FormSchema; - } - catch (Exception ex) - { - logger.LogWarning(ex, "Unable to load form schema for application scoring prompt data generation for form version {FormVersionId}.", formVersionId); - return null; - } - } - private static string ValidateApplicationScoringJson(string scoresheetAnswers) { try @@ -227,45 +168,5 @@ private static string ValidateApplicationScoringJson(string scoresheetAnswers) return "{}"; } - - private static object[]? 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) => - (object)new - { - number = index + 1, - value = option.Value - }) - .ToArray(); - } - } - catch (JsonException) - { - // Ignore malformed definition and return null options. - } - - return null; - } - - private static string[]? ExtractSelectListOptionNumbers(object[]? options) - { - if (options == null || options.Length == 0) - { - return null; - } - - return options - .Select((_, index) => (index + 1).ToString()) - .ToArray(); - } } } diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/AttachmentSummaryPersistence.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/AttachmentSummaryPersistence.cs new file mode 100644 index 0000000000..5b7cfa2bae --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/AttachmentSummaryPersistence.cs @@ -0,0 +1,46 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Unity.GrantManager.Applications; +using Volo.Abp.DependencyInjection; +using Volo.Abp.Uow; + +namespace Unity.AI.Operations; + +public class AttachmentSummaryPersistence( + IApplicationChefsFileAttachmentRepository applicationChefsFileAttachmentRepository, + IUnitOfWorkManager unitOfWorkManager) : IAttachmentSummaryPersistence, ITransientDependency +{ + public async Task LoadAsync(Guid attachmentId) + { + using var uow = unitOfWorkManager.Begin(requiresNew: true, isTransactional: false); + var attachment = await applicationChefsFileAttachmentRepository.GetAsync(attachmentId); + var source = new AttachmentSummarySource( + attachment.Id, + attachment.FileName, + attachment.ChefsSubmissionId, + attachment.ChefsFileId); + await uow.CompleteAsync(); + return source; + } + + public async Task SaveSummaryAsync(Guid attachmentId, string summary) + { + using var uow = unitOfWorkManager.Begin(requiresNew: true); + var attachment = await applicationChefsFileAttachmentRepository.GetAsync(attachmentId); + attachment.AISummary = summary; + await applicationChefsFileAttachmentRepository.UpdateAsync(attachment); + await uow.CompleteAsync(); + } + + public async Task> LoadApplicationAttachmentIdsAsync(Guid applicationId) + { + using var uow = unitOfWorkManager.Begin(requiresNew: true, isTransactional: false); + var ids = (await applicationChefsFileAttachmentRepository.GetListAsync(a => a.ApplicationId == applicationId)) + .Select(a => a.Id) + .ToList(); + await uow.CompleteAsync(); + return ids; + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/AttachmentSummaryService.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/AttachmentSummaryService.cs index 78762cea48..0f5a437289 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/AttachmentSummaryService.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/AttachmentSummaryService.cs @@ -1,34 +1,48 @@ +using Microsoft.Extensions.Localization; using Microsoft.Extensions.Logging; using System; using System.Collections.Generic; +using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; using Unity.AI.Extraction; +using Unity.AI.Localization; using Unity.AI.Requests; -using Unity.GrantManager.Applications; using Unity.GrantManager.Intakes; +using Volo.Abp; using Volo.Abp.DependencyInjection; +using Volo.Abp.Uow; namespace Unity.AI.Operations; public class AttachmentSummaryService( - IApplicationChefsFileAttachmentRepository applicationChefsFileAttachmentRepository, + IAttachmentSummaryDataProvider attachmentSummaryDataProvider, IChefsFileAttachmentStreamProvider chefsFileAttachmentStreamProvider, ITextExtractionService textExtractionService, IAIService aiService, + IAIGenerationPrerequisiteValidator aiGenerationPrerequisiteValidator, AIExecutionModeResolver executionModeResolver, - ILogger logger) : IAttachmentSummaryService, ITransientDependency + IUnitOfWorkManager unitOfWorkManager, + ILogger logger, + IStringLocalizer localizer) : IAttachmentSummaryService, ITransientDependency { private const string SummaryGenerationFailedMessage = "AI summary generation failed."; + private const string TextExtractionFailedSummary = "Attachment text could not be extracted for AI summary generation."; public async Task GenerateAndSaveAsync(Guid attachmentId, string? promptVersion = null, CancellationToken cancellationToken = default) { - var attachment = await applicationChefsFileAttachmentRepository.GetAsync(attachmentId); + var attachment = await LoadAttachmentAsync(attachmentId); var fileName = string.IsNullOrWhiteSpace(attachment.FileName) ? "unknown" : attachment.FileName; await using var attachmentStream = await OpenAttachmentStreamAsync(attachment, fileName, cancellationToken); var extractedText = await textExtractionService.ExtractTextAsync(fileName, attachmentStream.Content, attachmentStream.ContentType, cancellationToken); + if (ShouldStopOnEmptyExtraction(fileName, extractedText)) + { + LogEmptyExtraction(attachmentId, fileName, attachmentStream); + await SaveSummaryAsync(attachmentId, TextExtractionFailedSummary); + return TextExtractionFailedSummary; + } var summaryResponse = await aiService.GenerateAttachmentSummaryAsync(new AttachmentSummaryRequest { @@ -38,8 +52,7 @@ public async Task GenerateAndSaveAsync(Guid attachmentId, string? prompt PromptVersion = promptVersion, }, cancellationToken); - attachment.AISummary = summaryResponse.Summary; - await applicationChefsFileAttachmentRepository.UpdateAsync(attachment); + await SaveSummaryAsync(attachmentId, summaryResponse.Summary); return summaryResponse.Summary; } @@ -47,7 +60,17 @@ public async Task GenerateAndSaveAsync(Guid attachmentId, string? prompt public async Task> GenerateAndSaveAsync(IEnumerable attachmentIds, string? promptVersion = null, CancellationToken cancellationToken = default) { var ids = attachmentIds as IReadOnlyCollection ?? attachmentIds.ToList(); + if (ids.Count == 0) + { + throw new UserFriendlyException(localizer[AILocalizationKeys.SelectAttachmentForSummaries]); + } + var mode = executionModeResolver.ResolveMode(AIExecutionModeResolver.AttachmentSummaryOperation); + if (mode == AIExecutionMode.Batch) + { + return await GenerateBatchAsync(ids, promptVersion, cancellationToken); + } + if (mode != AIExecutionMode.Sequential) { logger.LogWarning( @@ -63,6 +86,91 @@ public async Task> GenerateAndSaveAsync(IEnumerable attachmen batch => GenerateSequentiallyAsync(batch, promptVersion, cancellationToken)); } + private async Task> GenerateBatchAsync( + IReadOnlyCollection attachmentIds, + string? promptVersion, + CancellationToken cancellationToken) + { + var attachments = new List<(Guid Id, AttachmentSummarySource Source, string ContentType, string ExtractedText)>(attachmentIds.Count); + var failures = new Dictionary(); + + foreach (var attachmentId in attachmentIds) + { + try + { + var attachment = await LoadAttachmentAsync(attachmentId); + var fileName = string.IsNullOrWhiteSpace(attachment.FileName) ? "unknown" : attachment.FileName; + await using var attachmentStream = await OpenAttachmentStreamAsync(attachment, fileName, cancellationToken); + var extractedText = await textExtractionService.ExtractTextAsync(fileName, attachmentStream.Content, attachmentStream.ContentType, cancellationToken); + if (ShouldStopOnEmptyExtraction(fileName, extractedText)) + { + LogEmptyExtraction(attachmentId, fileName, attachmentStream); + failures[attachmentId] = TextExtractionFailedSummary; + continue; + } + + attachments.Add((attachmentId, attachment, attachmentStream.ContentType, extractedText)); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + logger.LogError(ex, "Error preparing AI summary batch item {AttachmentId}", attachmentId); + failures[attachmentId] = SummaryGenerationFailedMessage; + } + } + + if (attachments.Count == 0) + { + return attachmentIds.Select(id => failures.TryGetValue(id, out var failure) ? failure : SummaryGenerationFailedMessage).ToList(); + } + + var batchRequest = new AttachmentSummaryBatchRequest + { + PromptVersion = promptVersion, + Attachments = attachments.Select(item => new AttachmentSummaryBatchItemRequest + { + AttachmentId = item.Id.ToString(), + FileName = string.IsNullOrWhiteSpace(item.Source.FileName) ? "unknown" : item.Source.FileName!, + ContentType = item.ContentType, + ExtractedText = item.ExtractedText + }).ToList() + }; + + var batchResponse = await aiService.GenerateAttachmentSummaryBatchAsync(batchRequest, cancellationToken); + var responseMap = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var item in batchResponse.Attachments) + { + if (!string.IsNullOrWhiteSpace(item.AttachmentId)) + { + responseMap[item.AttachmentId] = item.Summary; + } + } + + var results = new List(attachmentIds.Count); + foreach (var attachmentId in attachmentIds) + { + if (failures.TryGetValue(attachmentId, out var failure)) + { + results.Add(failure); + continue; + } + + if (responseMap.TryGetValue(attachmentId.ToString(), out var summary)) + { + await SaveSummaryAsync(attachmentId, summary); + results.Add(summary); + continue; + } + + results.Add(SummaryGenerationFailedMessage); + } + + return results; + } + private async Task> GenerateSequentiallyAsync( IReadOnlyCollection attachmentIds, string? promptVersion, @@ -103,9 +211,9 @@ public async Task> GenerateForApplicationAsync( IReadOnlyCollection? attachmentIds = null, CancellationToken cancellationToken = default) { - var applicationAttachmentIds = (await applicationChefsFileAttachmentRepository.GetListAsync(a => a.ApplicationId == applicationId)) - .Select(a => a.Id) - .ToList(); + await WithUnitOfWorkAsync(() => aiGenerationPrerequisiteValidator.EnsureAttachmentSummaryAvailableAsync(applicationId)); + + var applicationAttachmentIds = await LoadApplicationAttachmentIdsAsync(applicationId); if (attachmentIds is not { Count: > 0 }) { @@ -123,8 +231,31 @@ public async Task> GenerateForApplicationAsync( return await GenerateAndSaveAsync(selectedIds, promptVersion, cancellationToken); } + private async Task LoadAttachmentAsync(Guid attachmentId) + { + var attachment = await attachmentSummaryDataProvider.GetAttachmentAsync(attachmentId); + return attachment ?? throw new UserFriendlyException(localizer[AILocalizationKeys.AttachmentNotFound]); + } + + private async Task SaveSummaryAsync(Guid attachmentId, string summary) + { + await attachmentSummaryDataProvider.UpdateAttachmentSummaryAsync(attachmentId, summary); + } + + private async Task> LoadApplicationAttachmentIdsAsync(Guid applicationId) + { + return await attachmentSummaryDataProvider.GetApplicationAttachmentIdsAsync(applicationId); + } + + private async Task WithUnitOfWorkAsync(Func operation) + { + using var uow = unitOfWorkManager.Begin(requiresNew: true, isTransactional: false); + await operation(); + await uow.CompleteAsync(); + } + private async Task OpenAttachmentStreamAsync( - ApplicationChefsFileAttachment attachment, + AttachmentSummarySource attachment, string fileName, CancellationToken cancellationToken) { @@ -156,4 +287,46 @@ private async Task OpenAttachmentStreamAsync( return ChefsFileAttachmentStream.Empty; } } + + private static bool ShouldStopOnEmptyExtraction(string fileName, string extractedText) + { + return string.IsNullOrWhiteSpace(extractedText) && IsSupportedOfficeOrPdf(fileName); + } + + private static bool IsSupportedOfficeOrPdf(string fileName) + { + var extension = Path.GetExtension(fileName)?.ToLowerInvariant(); + return extension is ".pdf" or ".docx" or ".xlsx" or ".xls" or ".pptx"; + } + + private void LogEmptyExtraction( + Guid attachmentId, + string fileName, + ChefsFileAttachmentStream attachmentStream) + { + logger.LogWarning( + "No text extracted for supported attachment {AttachmentId} ({FileName}). Skipping AI summary generation. ContentType: {ContentType}; StreamCanSeek: {StreamCanSeek}; StreamLength: {StreamLength}.", + attachmentId, + fileName, + attachmentStream.ContentType, + attachmentStream.Content.CanSeek, + TryGetStreamLength(attachmentStream.Content)); + } + + private static long? TryGetStreamLength(Stream stream) + { + if (!stream.CanSeek) + { + return null; + } + + try + { + return stream.Length; + } + catch + { + return null; + } + } } diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/IApplicationAnalysisService.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/IApplicationAnalysisService.cs index ac02842761..ddff668f03 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/IApplicationAnalysisService.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/IApplicationAnalysisService.cs @@ -6,6 +6,6 @@ namespace Unity.AI.Operations { public interface IApplicationAnalysisService { - Task RegenerateAndSaveAsync(Guid applicationId, string? promptVersion = null, CancellationToken cancellationToken = default); + Task RegenerateAsync(ApplicationAnalysisOperationInputDto input, CancellationToken cancellationToken = default); } } diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/IApplicationScoringService.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/IApplicationScoringService.cs index 2b2bc1796f..a7890b76a3 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/IApplicationScoringService.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/IApplicationScoringService.cs @@ -6,6 +6,6 @@ namespace Unity.AI.Operations { public interface IApplicationScoringService { - Task RegenerateAndSaveAsync(Guid applicationId, string? promptVersion = null, CancellationToken cancellationToken = default); + Task RegenerateAsync(ApplicationScoringOperationInputDto input, CancellationToken cancellationToken = default); } } diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/PromptDataPayloadBuilder.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/PromptDataPayloadBuilder.cs index 315a26fc92..cb769ece99 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/PromptDataPayloadBuilder.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/PromptDataPayloadBuilder.cs @@ -4,7 +4,8 @@ using System.Collections.Generic; using System.Linq; using System.Text.Json; -using Unity.GrantManager.Applications; +using Unity.AI.Operations; +using Unity.AI.Models; namespace Unity.AI.Prompts { @@ -33,14 +34,31 @@ internal static class PromptDataPayloadBuilder "simpleseparator" }; + private static readonly HashSet NonFieldRequirementComponentTypes = new(StringComparer.OrdinalIgnoreCase) + { + "button", + "simplebuttonadvanced", + "html", + "htmlelement", + "content", + "simpleseparator" + }; + + private const string ComponentsKey = "components"; + + private static readonly HashSet ExcludedSchemaKeys = new(StringComparer.OrdinalIgnoreCase) + { + "applicantAgent" + }; + public static JsonElement BuildPromptDataPayload( - Application application, - ApplicationFormSubmission? formSubmission, + AIApplicationPromptDataDto application, + string? submissionJson, string? formSchema, ILogger logger) { var fallbackPayload = BuildFallbackPromptDataPayload(application); - if (TryBuildPromptDataValues(formSubmission?.Submission, formSchema, out var values, out var exception)) + if (TryBuildPromptDataValues(submissionJson, formSchema, out var values, out var exception)) { return JsonSerializer.SerializeToElement(values); } @@ -50,13 +68,61 @@ public static JsonElement BuildPromptDataPayload( logger.LogWarning( exception, "Failed to parse form submission JSON for prompt payload generation for application {ApplicationId}.", - application.Id); + application.ApplicationId); } return JsonSerializer.SerializeToElement(fallbackPayload); } - private static object BuildFallbackPromptDataPayload(Application application) + public static List BuildAttachmentSummaries( + IEnumerable attachments) + { + return attachments + .Where(a => !string.IsNullOrWhiteSpace(a.Summary)) + .Select(a => new AIAttachmentItem + { + Name = string.IsNullOrWhiteSpace(a.FileName) ? "attachment" : a.FileName.Trim(), + Summary = a.Summary!.Trim() + }) + .ToList(); + } + + public static object BuildFormFieldConfiguration( + string? formSchema, + ILogger logger) + { + if (string.IsNullOrWhiteSpace(formSchema)) + { + return new { message = "Form configuration not available." }; + } + + try + { + var schema = JObject.Parse(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 from form schema."); + return new { message = "Form configuration could not be extracted." }; + } + } + + private static object BuildFallbackPromptDataPayload(AIApplicationPromptDataDto application) { var notSpecified = "Not specified"; return new @@ -164,7 +230,7 @@ private static HashSet ExtractAllowedSchemaKeys(string? formSchema) try { var schema = JObject.Parse(formSchema); - if (schema["components"] is not JArray components) + if (schema[ComponentsKey] is not JArray components) { return new HashSet(StringComparer.OrdinalIgnoreCase); } @@ -217,5 +283,81 @@ private static void ProcessNestedSchemaComponents(JObject component, HashSet 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(); + + if (string.IsNullOrEmpty(key) || string.IsNullOrEmpty(type) || NonFieldRequirementComponentTypes.Contains(type) || ExcludedSchemaKeys.Contains(key)) + { + 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/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/Versions/README.md b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/Versions/README.md deleted file mode 100644 index 21255c7dff..0000000000 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/Versions/README.md +++ /dev/null @@ -1,54 +0,0 @@ -# Runtime Prompt Templates - -These files are the source of truth for runtime prompts. -`OpenAIRuntimeService` resolves templates from: - -- `AI/Prompts/Versions//