diff --git a/applications/Unity.AutoUI/cypress/e2e/basicEmail.cy.ts b/applications/Unity.AutoUI/cypress/e2e/basicEmail.cy.ts index 6ae85878e2..4eff6302d0 100644 --- a/applications/Unity.AutoUI/cypress/e2e/basicEmail.cy.ts +++ b/applications/Unity.AutoUI/cypress/e2e/basicEmail.cy.ts @@ -1,4 +1,5 @@ // cypress/e2e/basicEmail.cy.ts +import { LoginPageInstance, NavigationPageInstance } from "../utilities"; describe("Send an email", () => { const TEST_EMAIL_TO = Cypress.env("TEST_EMAIL_TO") as string; @@ -32,123 +33,8 @@ describe("Send an email", () => { String(now.getSeconds()).padStart(2, "0"); const TEST_EMAIL_SUBJECT = `Smoke Test Email ${timestamp}`; - - function ensureLoggedInToGrantApplications() { - // Headless runs specs sequentially in the same browser process. - // Do not assume logged-out or logged-in. Detect UI state like chefsdata.cy.ts does. - cy.visit(Cypress.env("webapp.url")); - - cy.get("body", { timeout: STANDARD_TIMEOUT }).then(($body) => { - // Already authenticated - if ($body.find('button:contains("VIEW APPLICATIONS")').length > 0) { - cy.contains("VIEW APPLICATIONS", { timeout: STANDARD_TIMEOUT }).click({ - force: true, - }); - return; - } - - // Not authenticated - if ($body.find('button:contains("LOGIN")').length > 0) { - cy.contains("LOGIN", { timeout: STANDARD_TIMEOUT }) - .should("exist") - .click({ force: true }); - - cy.get("body", { timeout: STANDARD_TIMEOUT }).then(($loginBody) => { - // IDIR chooser may or may not appear - if ($loginBody.find(':contains("IDIR")').length > 0) { - cy.contains("IDIR", { timeout: STANDARD_TIMEOUT }).click({ - force: true, - }); - } - - cy.get("body", { timeout: STANDARD_TIMEOUT }).then(($authBody) => { - // Only type creds if the login form is actually present - if ($authBody.find("#user").length > 0) { - cy.get("#user", { timeout: STANDARD_TIMEOUT }).type( - Cypress.env("test1username"), - ); - cy.get("#password", { timeout: STANDARD_TIMEOUT }).type( - Cypress.env("test1password"), - ); - cy.contains("Continue", { timeout: STANDARD_TIMEOUT }).click({ - force: true, - }); - } else { - cy.log("Already authenticated"); - } - }); - }); - - return; - } - - throw new Error("Unable to determine authentication state"); - }); - - cy.location("pathname", { timeout: 30000 }).should( - "include", - "/GrantApplications", - ); - } - - function switchToDefaultGrantsProgramIfAvailable() { - cy.get("body").then(($body) => { - const hasUserInitials = $body.find(".unity-user-initials").length > 0; - - if (!hasUserInitials) { - cy.log("Skipping tenant: no user initials menu found"); - return; - } - - cy.get(".unity-user-initials").click(); - - cy.get("body").then(($body2) => { - const switchLink = $body2 - .find("#user-dropdown a.dropdown-item") - .filter((_, el) => { - return (el.textContent || "").trim() === "Switch Grant Programs"; - }); - - if (switchLink.length === 0) { - cy.log( - 'Skipping tenant: "Switch Grant Programs" not present for this user/session', - ); - cy.get("body").click(0, 0); - return; - } - - cy.wrap(switchLink.first()).click(); - - cy.url({ timeout: STANDARD_TIMEOUT }).should( - "include", - "/GrantPrograms", - ); - - cy.get("#search-grant-programs", { timeout: STANDARD_TIMEOUT }) - .should("be.visible") - .clear() - .type("Default Grants Program"); - - cy.get("#UserGrantProgramsTable", { timeout: STANDARD_TIMEOUT }) - .should("be.visible") - .within(() => { - cy.contains("tbody tr", "Default Grants Program", { - timeout: STANDARD_TIMEOUT, - }) - .should("exist") - .within(() => { - cy.contains("button", "Select").should("be.enabled").click(); - }); - }); - - cy.location("pathname", { timeout: STANDARD_TIMEOUT }).should((p) => { - expect( - p.indexOf("/GrantApplications") >= 0 || p.indexOf("/auth/") >= 0, - ).to.eq(true); - }); - }); - }); - } + const loginPage = LoginPageInstance(); + const navPage = NavigationPageInstance(); function openSavedEmailFromHistoryBySubject(subject: string) { cy.get("body", { timeout: STANDARD_TIMEOUT }).then(($body) => { @@ -231,11 +117,12 @@ describe("Send an email", () => { } it("Login", () => { - ensureLoggedInToGrantApplications(); + loginPage.login(); + loginPage.verifyOnGrantApplications(); }); it("Switch to Default Grants Program if available", () => { - switchToDefaultGrantsProgramIfAvailable(); + navPage.switchToDefaultGrantsProgramIfAvailable(); }); it("Handle IDIR if required", () => { @@ -368,6 +255,7 @@ describe("Send an email", () => { it("Save the email", () => { cy.get("#btn-save", { timeout: STANDARD_TIMEOUT }) .should("exist") + .scrollIntoView() .should("be.visible") .click(); @@ -391,6 +279,7 @@ describe("Send an email", () => { it("Send the email", () => { cy.get("#btn-send", { timeout: STANDARD_TIMEOUT }) .should("exist") + .scrollIntoView() .should("be.visible") .should("not.be.disabled") .click(); diff --git a/applications/Unity.AutoUI/cypress/e2e/chefsdata.cy.ts b/applications/Unity.AutoUI/cypress/e2e/chefsdata.cy.ts index 05fe3e5a54..76f4d4154f 100644 --- a/applications/Unity.AutoUI/cypress/e2e/chefsdata.cy.ts +++ b/applications/Unity.AutoUI/cypress/e2e/chefsdata.cy.ts @@ -1,9 +1,12 @@ /// // cypress/e2e/chefsdata.cy.ts +import { LoginPageInstance, NavigationPageInstance } from "../utilities"; describe('Unity Login and check data from CHEFS', () => { const STANDARD_TIMEOUT = 20000 + const loginPage = LoginPageInstance() + const navPage = NavigationPageInstance() // TEST renders the Submission tab inside an open shadow root (Form.io). // Enabling this makes cy.get / cy.contains pierce shadow DOM consistently across envs. @@ -12,42 +15,12 @@ describe('Unity Login and check data from CHEFS', () => { }) it('Verify Login', () => { - // 1.) Always start from the base URL - cy.visit(Cypress.env('webapp.url')) - - // 2.) Decide auth path based on visible UI - cy.get('body', { timeout: STANDARD_TIMEOUT }).then(($body) => { - // Already authenticated - if ($body.find('button:contains("VIEW APPLICATIONS")').length > 0) { - cy.contains('VIEW APPLICATIONS', { timeout: STANDARD_TIMEOUT }).click({ force: true }) - return - } - - // Not authenticated - if ($body.find('button:contains("LOGIN")').length > 0) { - cy.contains('LOGIN', { timeout: STANDARD_TIMEOUT }).should('exist').click({ force: true }) - cy.contains('IDIR', { timeout: STANDARD_TIMEOUT }).should('exist').click({ force: true }) - - cy.get('body', { timeout: STANDARD_TIMEOUT }).then(($loginBody) => { - // Perform IDIR login only if prompted - if ($loginBody.find('#user').length > 0) { - cy.get('#user', { timeout: STANDARD_TIMEOUT }).type(Cypress.env('test1username')) - cy.get('#password', { timeout: STANDARD_TIMEOUT }).type(Cypress.env('test1password')) - cy.contains('Continue', { timeout: STANDARD_TIMEOUT }).should('exist').click({ force: true }) - } else { - cy.log('Already logged in') - } - }) - - return - } - - // Fail loudly if neither state is detectable - throw new Error('Unable to determine authentication state') - }) + loginPage.login() + loginPage.verifyOnGrantApplications() + }) - // 3.) Post-condition check - cy.url({ timeout: STANDARD_TIMEOUT }).should('include', '/GrantApplications') + it('Switch to Default Grants Program if available', () => { + navPage.switchToDefaultGrantsProgramIfAvailable() }) // Verify that the details panel populates with mapped data @@ -228,6 +201,6 @@ describe('Unity Login and check data from CHEFS', () => { }) it('Verify Logout', () => { - cy.logout() + loginPage.quickLogout() }) }) diff --git a/applications/Unity.AutoUI/cypress/e2e/lists.cy.ts b/applications/Unity.AutoUI/cypress/e2e/lists.cy.ts index 1654cf556f..4ef0eda373 100644 --- a/applications/Unity.AutoUI/cypress/e2e/lists.cy.ts +++ b/applications/Unity.AutoUI/cypress/e2e/lists.cy.ts @@ -1,56 +1,13 @@ -describe('Grant Manager Login and List Navigation', () => { - - function switchToDefaultGrantsProgramIfAvailable() { - cy.get('body').then(($body) => { - const hasUserInitials = $body.find('.unity-user-initials').length > 0 - - if (!hasUserInitials) { - cy.log('Skipping tenant switch: no user initials menu found') - return - } - - cy.get('.unity-user-initials').click() - - cy.get('body').then(($body2) => { - const switchLink = $body2.find('#user-dropdown a.dropdown-item').filter((_, el) => { - return (el.textContent || '').trim() === 'Switch Grant Programs' - }) +import { + ApplicationsPageInstance, + LoginPageInstance, + NavigationPageInstance, +} from '../utilities' - if (switchLink.length === 0) { - cy.log('Skipping tenant switch: "Switch Grant Programs" not present') - cy.get('body').click(0, 0) - return - } - - cy.wrap(switchLink.first()).click() - cy.url({ timeout: 20000 }).should('include', '/GrantPrograms') - - cy.get('#search-grant-programs', { timeout: 20000 }) - .should('be.visible') - .clear() - .type('Default Grants Program') - - cy.get('#UserGrantProgramsTable', { timeout: 20000 }) - .should('be.visible') - .within(() => { - cy.contains('tbody tr', 'Default Grants Program', { timeout: 20000 }) - .should('exist') - .within(() => { - cy.contains('button', 'Select') - .should('be.enabled') - .click() - }) - }) - - cy.location('pathname', { timeout: 20000 }).should((p) => { - expect( - p.indexOf('/GrantApplications') >= 0 || - p.indexOf('/auth/') >= 0 - ).to.eq(true) - }) - }) - }) - } +describe('Grant Manager Login and List Navigation', () => { + const loginPage = LoginPageInstance() + const navPage = NavigationPageInstance() + const appsPage = ApplicationsPageInstance() function setDashboardIntakeToTestIfAvailable() { const btnSel = 'button[data-id="dashboardIntakeId"]' @@ -91,46 +48,12 @@ describe('Grant Manager Login and List Navigation', () => { } it('Verify Login', () => { - - cy.visit(Cypress.env('webapp.url')) - - cy.get('body').then(($body) => { - - if ($body.find('button:contains("VIEW APPLICATIONS")').length > 0) { - cy.contains('VIEW APPLICATIONS').click() - return - } - - if ($body.find('button:contains("LOGIN")').length > 0) { - cy.contains('LOGIN').click() - - cy.get('body').then(($loginBody) => { - if ($loginBody.find(':contains("IDIR")').length > 0) { - cy.contains('IDIR').click() - } - - cy.get('body').then(($authBody) => { - if ($authBody.find('#user').length > 0) { - cy.get('#user').type(Cypress.env('test1username')) - cy.get('#password').type(Cypress.env('test1password')) - cy.contains('Continue').click() - } else { - cy.log('Already authenticated') - } - }) - }) - - return - } - - throw new Error('Unable to determine authentication state') - }) - - cy.location('pathname', { timeout: 30000 }).should('include', '/GrantApplications') + loginPage.login() + loginPage.verifyOnGrantApplications() }) it('Switch to Default Grants Program if available', () => { - switchToDefaultGrantsProgramIfAvailable() + navPage.switchToDefaultGrantsProgramIfAvailable() }) it('Handle IDIR if required', () => { @@ -145,26 +68,24 @@ describe('Grant Manager Login and List Navigation', () => { it('Verify Applications, Roles, Users, Intakes, Forms, Dashboard lists are populated', () => { - cy.get('.unity-user-initials').should('exist').click() - cy.get('#user-dropdown .btn-dropdown span') - .should('contain', 'Default Grants Program') + navPage.verifyCurrentTenant('Default Grants Program') - cy.contains('Applications').click() - cy.get('tbody tr').should('have.length.at.least', 1) + navPage.goToApplications() + appsPage.verifyListHasData() - cy.contains('Roles').click() + navPage.goToRoles() cy.get('tbody tr').should('have.length.at.least', 1) - cy.contains('Users').click() + navPage.goToUsers() cy.get('tbody tr').should('have.length.at.least', 1) - cy.contains('Intakes').click() + navPage.goToIntakes() cy.get('tbody tr').should('have.length.at.least', 1) - cy.contains('Forms').click() + navPage.goToForms() cy.get('tbody tr').should('have.length.at.least', 1) - cy.contains('Dashboard').click() + navPage.goToDashboard() cy.location('pathname', { timeout: 30000 }).should('include', '/Dashboard') setDashboardIntakeToTestIfAvailable() @@ -178,6 +99,6 @@ describe('Grant Manager Login and List Navigation', () => { }) it('Verify Logout', () => { - cy.logout() + loginPage.quickLogout() }) -}) \ No newline at end of file +}) diff --git a/applications/Unity.AutoUI/cypress/e2e/login.cy.ts b/applications/Unity.AutoUI/cypress/e2e/login.cy.ts index 274e68ffaf..3735041179 100644 --- a/applications/Unity.AutoUI/cypress/e2e/login.cy.ts +++ b/applications/Unity.AutoUI/cypress/e2e/login.cy.ts @@ -1,55 +1,17 @@ +import { LoginPageInstance, NavigationPageInstance } from "../utilities"; + describe('Grant Manager Login and Logout', () => { + const loginPage = LoginPageInstance() + const navPage = NavigationPageInstance() it('Verify Default Grant Program tenant is selected.', () => { - - // Always start from the base URL - cy.visit(Cypress.env('webapp.url')) - - // Determine authentication state from UI - cy.get('body').then(($body) => { - - // Already authenticated - if ($body.find('button:contains("VIEW APPLICATIONS")').length > 0) { - cy.contains('VIEW APPLICATIONS').click() - return - } - - // Not authenticated - if ($body.find('button:contains("LOGIN")').length > 0) { - cy.contains('LOGIN').click() - - cy.get('body').then(($loginBody) => { - - // IDIR chooser may or may not appear - if ($loginBody.find(':contains("IDIR")').length > 0) { - cy.contains('IDIR').click() - } - - cy.get('body').then(($authBody) => { - if ($authBody.find('#user').length > 0) { - cy.get('#user').type(Cypress.env('test1username')) - cy.get('#password').type(Cypress.env('test1password')) - cy.contains('Continue').click() - } - }) - }) - - return - } - - throw new Error('Unable to determine authentication state') - }) - - // Verify we landed in the authenticated app - cy.location('pathname', { timeout: 30000 }) - .should('include', '/GrantApplications') + loginPage.login() + loginPage.verifyOnGrantApplications() // Verify Default Grant Program tenant is selected - cy.get('.unity-user-initials').should('exist').click() - cy.get('#user-dropdown .btn-dropdown span') - .should('contain', 'Default Grants Program') + navPage.verifyCurrentTenant('Default Grants Program') // Logout (terminal action) - cy.logout() + loginPage.quickLogout() }) }) diff --git a/applications/Unity.AutoUI/cypress/e2e/navigation.cy.ts b/applications/Unity.AutoUI/cypress/e2e/navigation.cy.ts index 0cb046669f..6bcf9a65df 100644 --- a/applications/Unity.AutoUI/cypress/e2e/navigation.cy.ts +++ b/applications/Unity.AutoUI/cypress/e2e/navigation.cy.ts @@ -1,83 +1,48 @@ +import { LoginPageInstance, NavigationPageInstance } from "../utilities"; + describe('Grant Manager Login and Top Navigation', () => { + const loginPage = LoginPageInstance() + const navPage = NavigationPageInstance() it('Verify Login', () => { - - // Always start from the base URL - cy.visit(Cypress.env('webapp.url')) - - cy.get('body').then(($body) => { - - // Already authenticated - if ($body.find('button:contains("VIEW APPLICATIONS")').length > 0) { - cy.contains('VIEW APPLICATIONS').click() - return - } - - // Not authenticated - if ($body.find('button:contains("LOGIN")').length > 0) { - cy.contains('LOGIN').click() - - cy.get('body').then(($loginBody) => { - - // IDIR chooser may or may not appear - if ($loginBody.find(':contains("IDIR")').length > 0) { - cy.contains('IDIR').click() - } - - cy.get('body').then(($authBody) => { - if ($authBody.find('#user').length > 0) { - cy.get('#user').type(Cypress.env('test1username')) - cy.get('#password').type(Cypress.env('test1password')) - cy.contains('Continue').click() - } - }) - }) - - return - } - - throw new Error('Unable to determine authentication state') - }) - - cy.location('pathname', { timeout: 30000 }) - .should('include', '/GrantApplications') + loginPage.login() + loginPage.verifyOnGrantApplications() }) it('Verify navigation options in the top banner', () => { // 3.) Verify Default Grant Program tenant is selected. - cy.get('.unity-user-initials').should('exist').click() - cy.get('#user-dropdown .btn-dropdown span') - .should('contain', 'Default Grants Program') + navPage.verifyCurrentTenant('Default Grants Program') // 4.) Ensure all expected headings are present. + navPage.verifyAllNavItemsExist() // 5.) Applications - cy.contains('Applications').should('exist').click() + navPage.goToApplications() // 6.) Roles - cy.contains('Roles').should('exist').click() + navPage.goToRoles() // 7.) Users - cy.contains('Users').should('exist').click() + navPage.goToUsers() // 8.) Intakes - cy.contains('Intakes').should('exist').click() + navPage.goToIntakes() // 9.) Forms - cy.contains('Forms').should('exist').click() + navPage.goToForms() // 10.) Dashboard - cy.contains('Dashboard').should('exist').click() + navPage.goToDashboard() // 11.) Payments - cy.contains('Payments').should('exist').click() + navPage.goToPayments() // Return to top cy.visit(Cypress.env('webapp.url')) }) it('Verify Logout', () => { - cy.logout() + loginPage.quickLogout() }) }) diff --git a/applications/Unity.AutoUI/cypress/pages/ApplicationDetailsPage.ts b/applications/Unity.AutoUI/cypress/pages/ApplicationDetailsPage.ts index 550c95ff22..14e171027c 100644 --- a/applications/Unity.AutoUI/cypress/pages/ApplicationDetailsPage.ts +++ b/applications/Unity.AutoUI/cypress/pages/ApplicationDetailsPage.ts @@ -110,11 +110,19 @@ export class ApplicationDetailsPage extends BasePage { super(); } + private activateTab(tabSelector: string): void { + cy.get(tabSelector, { timeout: 20000 }) + .scrollIntoView() + .should("be.visible") + .click({ force: true }) + .should("have.class", "active"); + } + /** * Navigate to Submission tab */ goToSubmissionTab(): this { - this.clickElement(this.tabs.submission); + this.activateTab(this.tabs.submission); return this; } @@ -123,7 +131,7 @@ export class ApplicationDetailsPage extends BasePage { */ goToReviewAssessmentTab(): this { this.dismissErrorModalIfPresent(); - this.clickElement(this.tabs.reviewAssessment); + this.activateTab(this.tabs.reviewAssessment); return this; } @@ -131,7 +139,7 @@ export class ApplicationDetailsPage extends BasePage { * Navigate to Project Info tab */ goToProjectInfoTab(): this { - this.clickElement(this.tabs.projectInfo); + this.activateTab(this.tabs.projectInfo); return this; } @@ -139,7 +147,7 @@ export class ApplicationDetailsPage extends BasePage { * Navigate to Applicant Info tab */ goToApplicantInfoTab(): this { - this.clickElement(this.tabs.applicantInfo); + this.activateTab(this.tabs.applicantInfo); return this; } @@ -147,7 +155,7 @@ export class ApplicationDetailsPage extends BasePage { * Navigate to Funding Agreement tab */ goToFundingAgreementTab(): this { - this.clickElement(this.tabs.fundingAgreement); + this.activateTab(this.tabs.fundingAgreement); return this; } @@ -155,7 +163,7 @@ export class ApplicationDetailsPage extends BasePage { * Navigate to Payment Info tab */ goToPaymentInfoTab(): this { - this.clickElement(this.tabs.paymentInfo); + this.activateTab(this.tabs.paymentInfo); return this; } @@ -597,12 +605,30 @@ export class ApplicationDetailsPage extends BasePage { */ dismissErrorModalIfPresent(): this { cy.get("body").then(($body) => { - // Only dismiss if it is specifically an error modal (swal2-error icon) - if ($body.find(".swal2-container .swal2-icon.swal2-error").length > 0) { - cy.get(".swal2-container") - .find(".swal2-confirm") - .first() - .click({ force: true }); + const hasSwalError = + $body.find(".swal2-container .swal2-icon.swal2-error").length > 0; + const hasTokenErrorText = + $body.text().includes("GetAuthTokenAsync") || + $body.text().includes("Error retrieving Token"); + + if (hasSwalError || hasTokenErrorText) { + if ($body.find(".swal2-container .swal2-confirm").length > 0) { + cy.get(".swal2-container") + .find(".swal2-confirm") + .first() + .click({ force: true }); + } else if ($body.find(".modal.show button").length > 0) { + cy.get(".modal.show") + .contains("button", /^ok$/i) + .click({ force: true }); + } + + cy.wait(500); + } + + if ($body.find(".modal.show .btn-close").length > 0) { + cy.get(".modal.show .btn-close").first().click({ force: true }); + cy.get(".swal2-container").should("not.exist"); cy.wait(500); } }); diff --git a/applications/Unity.AutoUI/cypress/pages/ApplicationsListPage.ts b/applications/Unity.AutoUI/cypress/pages/ApplicationsListPage.ts index 2128650c00..52c129791a 100644 --- a/applications/Unity.AutoUI/cypress/pages/ApplicationsListPage.ts +++ b/applications/Unity.AutoUI/cypress/pages/ApplicationsListPage.ts @@ -37,9 +37,8 @@ export class ApplicationsListPage extends ApplicationsPage { private readonly extendedActionBar = { customButtons: "#app_custom_buttons", dynamicButtonContainer: "#dynamicButtonContainerId", - // Export can be rendered as button/span or anchor depending on DataTables build. exportButton: - "#dynamicButtonContainerId .dt-buttons button, #dynamicButtonContainerId .dt-buttons a, #dynamicButtonContainerId .dt-buttons span", + "#dynamicButtonContainerId .dt-buttons button, #dynamicButtonContainerId .dt-buttons button span", saveViewButton: "button.grp-savedStates", }; @@ -64,6 +63,11 @@ export class ApplicationsListPage extends ApplicationsPage { cancelButton: "#payment-modal .modal-footer button", }; + private readonly blockingUi = { + bootstrapModal: ".modal.show", + swalContainer: ".swal2-container", + }; + // Grant program selectors private readonly grantProgram = { userInitials: ".unity-user-initials", @@ -98,8 +102,11 @@ export class ApplicationsListPage extends ApplicationsPage { | "alltime" | "custom", ): this { + this.waitForNoBlockingOverlay(); cy.get(this.dateFilters.quickDateRange, { timeout: this.STANDARD_TIMEOUT }) + .scrollIntoView() .should("be.visible") + .and("not.be.disabled") .select(range); return this; } @@ -145,11 +152,13 @@ export class ApplicationsListPage extends ApplicationsPage { cy.get(this.dateFilters.submittedFromDate, { timeout: this.STANDARD_TIMEOUT, }) - .click({ force: true }) - .clear({ force: true }) - .type(date, { force: true }) - .trigger("change", { force: true }) - .blur({ force: true }) + .scrollIntoView() + .should("be.visible") + .click() + .clear() + .type(date) + .trigger("change") + .blur() .should("have.value", date); return this; } @@ -159,11 +168,13 @@ export class ApplicationsListPage extends ApplicationsPage { */ setSubmittedToDate(date: string): this { cy.get(this.dateFilters.submittedToDate, { timeout: this.STANDARD_TIMEOUT }) - .click({ force: true }) - .clear({ force: true }) - .type(date, { force: true }) - .trigger("change", { force: true }) - .blur({ force: true }) + .scrollIntoView() + .should("be.visible") + .click() + .clear() + .type(date) + .trigger("change") + .blur() .should("have.value", date); return this; } @@ -208,9 +219,12 @@ export class ApplicationsListPage extends ApplicationsPage { * Select a row by matching text content */ selectRowByText(text: string): this { + this.waitForNoBlockingOverlay(); cy.contains("tr", text, { timeout: this.STANDARD_TIMEOUT }) + .scrollIntoView() .find(".checkbox-select") - .click({ force: true }); + .should("be.visible") + .click(); return this; } @@ -220,7 +234,8 @@ export class ApplicationsListPage extends ApplicationsPage { clickOpenButton(): this { cy.get("#externalLink", { timeout: this.STANDARD_TIMEOUT }) .should("exist") - .click({ force: true }); + .should("be.visible") + .click(); return this; } @@ -236,17 +251,61 @@ export class ApplicationsListPage extends ApplicationsPage { return this; } + /** + * Dispatch a native mouse click sequence against the current target cell. + * This avoids Cypress holding a stale DOM reference while DataTables redraws. + */ + private clickRowCellNative(rowIndex: number, withCtrl = false): this { + this.waitForNoBlockingOverlay(); + + cy.window({ log: false }).then((win: Cypress.AUTWindow) => { + cy.get(this.scrollTable.tableRows, { + timeout: this.STANDARD_TIMEOUT, + }) + .should("have.length.greaterThan", rowIndex) + .eq(rowIndex) + .should("exist") + .then(($row: JQuery) => { + const $cell = $row.find("td").not(":has(a)").first(); + + expect( + $cell.length, + `row ${rowIndex} should contain at least one non-link cell`, + ).to.be.greaterThan(0); + + const cell = $cell[0] as HTMLElement; + + cell.scrollIntoView({ + block: "center", + inline: "nearest", + }); + + const eventInit: MouseEventInit = { + bubbles: true, + cancelable: true, + view: win, + ctrlKey: withCtrl, + metaKey: withCtrl, + button: 0, + buttons: 1, + }; + + cell.dispatchEvent(new win.MouseEvent("mouseover", eventInit)); + cell.dispatchEvent(new win.MouseEvent("mousemove", eventInit)); + cell.dispatchEvent(new win.MouseEvent("mousedown", eventInit)); + cell.dispatchEvent(new win.MouseEvent("mouseup", eventInit)); + cell.dispatchEvent(new win.MouseEvent("click", eventInit)); + }); + }); + + return this; + } + /** * Select a row by index (clicks on a non-link cell) */ selectRowByIndex(rowIndex: number, withCtrl = false): this { - cy.get(this.scrollTable.tableRows, { timeout: this.STANDARD_TIMEOUT }) - .eq(rowIndex) - .find("td") - .not(":has(a)") - .first() - .click({ force: true, ctrlKey: withCtrl }); - return this; + return this.clickRowCellNative(rowIndex, withCtrl); } /** @@ -319,10 +378,11 @@ export class ApplicationsListPage extends ApplicationsPage { * Click the Payment button (extended with visibility checks) */ clickPaymentButtonWithWait(): this { + this.waitForNoBlockingOverlay(); cy.get("#applicationPaymentRequest", { timeout: this.BUTTON_TIMEOUT }) .should("be.visible") .and("not.be.disabled") - .click({ force: true }); + .click(); return this; } @@ -333,7 +393,9 @@ export class ApplicationsListPage extends ApplicationsPage { cy.contains(this.extendedActionBar.exportButton, "Export", { timeout: this.STANDARD_TIMEOUT, matchCase: false, - }).should("be.visible"); + }) + .scrollIntoView() + .should("be.visible"); return this; } @@ -354,10 +416,12 @@ export class ApplicationsListPage extends ApplicationsPage { */ verifyColumnsButtonVisible(): this { cy.contains( - "#dynamicButtonContainerId .dt-buttons button, #dynamicButtonContainerId .dt-buttons a, #dynamicButtonContainerId .dt-buttons span", + "#dynamicButtonContainerId .dt-buttons button, #dynamicButtonContainerId .dt-buttons button span", "Columns", - { timeout: this.STANDARD_TIMEOUT }, - ).should("be.visible"); + { timeout: this.STANDARD_TIMEOUT, matchCase: false }, + ) + .scrollIntoView() + .should("be.visible"); return this; } @@ -382,6 +446,27 @@ export class ApplicationsListPage extends ApplicationsPage { cy.get(this.paymentModal.modal, { timeout: this.STANDARD_TIMEOUT }) .should("be.visible") .and("have.class", "show"); + cy.get(this.paymentModal.backdrop, { + timeout: this.STANDARD_TIMEOUT, + }).should("exist"); + return this; + } + + /** + * Wait for blocking modal overlays to clear before interacting with list controls. + */ + waitForNoBlockingOverlay(): this { + cy.get(this.blockingUi.swalContainer, { + timeout: this.STANDARD_TIMEOUT, + }).should("not.exist"); + + cy.get(this.blockingUi.bootstrapModal, { + timeout: this.STANDARD_TIMEOUT, + }).should("not.exist"); + + cy.get(this.paymentModal.backdrop, { + timeout: this.STANDARD_TIMEOUT, + }).should("not.exist"); return this; } @@ -389,58 +474,71 @@ export class ApplicationsListPage extends ApplicationsPage { * Close payment modal using multiple strategies */ closePaymentModal(): this { - // Attempt ESC key - cy.get("body").type("{esc}", { force: true }); - - // Click backdrop if present (check existence first to avoid timeout) cy.get("body").then(($body: JQuery) => { - if ($body.find(this.paymentModal.backdrop).length > 0) { - cy.get(this.paymentModal.backdrop).click("topLeft", { force: true }); + if ($body.find(`${this.paymentModal.modal}.show`).length === 0) { + return; } - }); - // Try Cancel button if available (check existence first to avoid timeout) - cy.get("body").then(($body: JQuery) => { const $cancelBtn = $body .find(this.paymentModal.cancelButton) .filter((_: number, el: HTMLElement) => (el.textContent || "").includes("Cancel"), ); + if ($cancelBtn.length > 0) { cy.wrap($cancelBtn.first()).scrollIntoView().click({ force: true }); - } else { - cy.log("Cancel button not present, proceeding to hard-close fallback"); } }); - // Hard close fallback using jQuery - cy.window().then((win: Cypress.AUTWindow) => { - try { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const windowWithModal = win as any; - if (typeof windowWithModal.closePaymentModal === "function") { - windowWithModal.closePaymentModal(); - } - } catch { - /* ignore */ + cy.get("body").then(($body: JQuery) => { + if ($body.find(`${this.paymentModal.modal}.show`).length > 0) { + cy.get("body").type("{esc}", { force: true }); } + }); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const $ = (win as any).jQuery || (win as any).$; - if ($) { - try { - $("#payment-modal") - .removeClass("show") - .attr("aria-hidden", "true") - .css("display", "none"); - $(".modal-backdrop").remove(); - $("body").removeClass("modal-open").css("overflow", ""); - } catch { - /* ignore */ - } + cy.get("body").then(($body: JQuery) => { + if ( + $body.find(`${this.paymentModal.modal}.show`).length > 0 && + $body.find(this.paymentModal.backdrop).length > 0 + ) { + cy.get(this.paymentModal.backdrop).first().click("topLeft", { + force: true, + }); } }); - return this; + + cy.get("body").then(($body: JQuery) => { + if ($body.find(`${this.paymentModal.modal}.show`).length > 0) { + cy.window().then((win: Cypress.AUTWindow) => { + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const windowWithModal = win as any; + if (typeof windowWithModal.closePaymentModal === "function") { + windowWithModal.closePaymentModal(); + } + } catch { + /* ignore */ + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const $ = (win as any).jQuery || (win as any).$; + if ($) { + try { + $(this.paymentModal.modal) + .removeClass("show") + .attr("aria-hidden", "true") + .css("display", "none"); + $(this.paymentModal.backdrop).remove(); + $("body").removeClass("modal-open").css("overflow", ""); + } catch { + /* ignore */ + } + } + }); + } + }); + + return this.verifyPaymentModalClosed(); } /** @@ -453,14 +551,17 @@ export class ApplicationsListPage extends ApplicationsPage { expect(isHidden, "payment-modal hidden or not shown").to.eq(true); }, ); + cy.get(this.paymentModal.backdrop, { timeout: this.STANDARD_TIMEOUT, }).should("not.exist"); + + cy.get(this.blockingUi.bootstrapModal, { + timeout: this.STANDARD_TIMEOUT, + }).should("not.exist"); return this; } - // ============ Columns Menu Methods ============ - /** * Close any open dropdowns or modals */ @@ -485,8 +586,8 @@ export class ApplicationsListPage extends ApplicationsPage { cy.contains(this.saveView.resetOption, "Reset to Default View", { timeout: this.STANDARD_TIMEOUT, }) - .should("exist") - .click({ force: true }); + .should("be.visible") + .click(); // Wait for table to rebuild cy.get(this.scrollTable.columnTitles, { @@ -500,10 +601,11 @@ export class ApplicationsListPage extends ApplicationsPage { */ openColumnsMenu(): this { cy.contains( - "#dynamicButtonContainerId .dt-buttons button, #dynamicButtonContainerId .dt-buttons a, #dynamicButtonContainerId .dt-buttons span", - /^Columns$/i, - { timeout: this.STANDARD_TIMEOUT }, + "#dynamicButtonContainerId .dt-buttons button, #dynamicButtonContainerId .dt-buttons button span", + "Columns", + { timeout: this.STANDARD_TIMEOUT, matchCase: false }, ) + .scrollIntoView() .should("be.visible") .click(); @@ -522,9 +624,16 @@ export class ApplicationsListPage extends ApplicationsPage { timeout: this.STANDARD_TIMEOUT, matchCase: false, }) - .should("exist") .scrollIntoView() - .click({ force: true }); + .should("exist") + .then(($item) => { + if (Cypress.dom.isVisible($item)) { + cy.wrap($item).click(); + } else { + // Some dropdown items are clipped by overflow containers; native click still triggers menu toggle reliably. + ($item[0] as HTMLElement).click(); + } + }); return this; } diff --git a/applications/Unity.AutoUI/cypress/pages/LoginPage.ts b/applications/Unity.AutoUI/cypress/pages/LoginPage.ts index 71a836e1d8..ea146d5c07 100644 --- a/applications/Unity.AutoUI/cypress/pages/LoginPage.ts +++ b/applications/Unity.AutoUI/cypress/pages/LoginPage.ts @@ -1,4 +1,5 @@ import { BasePage } from "./BasePage"; +import { loginIfNeeded } from "../support/auth"; /** * LoginPage - Page Object for Login/Authentication functionality @@ -24,27 +25,24 @@ export class LoginPage extends BasePage { * Perform login with credentials */ login(username?: string, password?: string): void { - this.visit(); - this.clickByText(this.selectors.loginButton); - this.wait(1000); - this.clickByText(this.selectors.idirButton); - this.wait(1000); - - // Check if already logged in - cy.get("body").then(($body) => { - if ($body.find(this.selectors.usernameField).length) { - const user = username || Cypress.env("test1username"); - const pass = password || Cypress.env("test1password"); - - this.typeText(this.selectors.usernameField, user); - this.typeText(this.selectors.passwordField, pass); - this.clickByText(this.selectors.continueButton); - } else { - cy.log("Already logged in"); - } + loginIfNeeded({ + username, + password, + timeout: 30000, + baseUrl: Cypress.env("webapp.url"), }); } + /** + * Verify we are on the authenticated applications page + */ + verifyOnGrantApplications(): void { + cy.location("pathname", { timeout: 30000 }).should( + "include", + "/GrantApplications", + ); + } + /** * Perform logout */ @@ -81,7 +79,7 @@ export class LoginPage extends BasePage { * Quick login using custom command (backward compatibility) */ quickLogin(): void { - cy.login(); + this.login(); } /** diff --git a/applications/Unity.AutoUI/cypress/pages/NavigationPage.ts b/applications/Unity.AutoUI/cypress/pages/NavigationPage.ts index 84da532ae6..1ea703ba93 100644 --- a/applications/Unity.AutoUI/cypress/pages/NavigationPage.ts +++ b/applications/Unity.AutoUI/cypress/pages/NavigationPage.ts @@ -31,6 +31,7 @@ export class NavigationPage extends BasePage { * Verify current tenant name */ verifyCurrentTenant(tenantName: string): void { + this.clickUserMenu(); cy.get(this.tenantDropdown).should("contain", tenantName); } @@ -101,6 +102,13 @@ export class NavigationPage extends BasePage { }); } + /** + * Switch to the default grant program if available for the current session + */ + switchToDefaultGrantsProgramIfAvailable(): void { + this.switchToTenantIfAvailable("Default Grants Program"); + } + /** * Navigate to Applications page */ diff --git a/applications/Unity.AutoUI/cypress/regression/ApprovalFlow.cy.ts b/applications/Unity.AutoUI/cypress/regression/ApprovalFlow.cy.ts index 5b480b2aab..67e9a4e6cf 100644 --- a/applications/Unity.AutoUI/cypress/regression/ApprovalFlow.cy.ts +++ b/applications/Unity.AutoUI/cypress/regression/ApprovalFlow.cy.ts @@ -35,6 +35,7 @@ const TEST_CONFIG = { submissionId: null as string | null, grantProgram: "Default Grants Program", approvedAmount: "5000", + assignOwner: "Unity User1", supplierNumber: Cypress.env("environment") === "TEST" ? "2002712" : "2009366", paymentGroup: "Cheque" as const, testComment: "Test comment from automated regression test", @@ -48,6 +49,24 @@ const TEST_CONFIG = { }, }; +const STATUS_ACTIONS = { + menuButton: "#ApplicationActionDropdown .dropdown-toggle", + menu: "#ApplicationActionDropdown .dropdown-menu", + startReview: "#Application_StartReviewButton", + completeReview: "#Application_CompleteReviewButton", + startAssessment: "#Application_StartAssessmentButton", + completeAssessment: "#Application_CompleteAssessmentButton", + approve: "#Application_ApproveButton", +}; + +const ADJUDICATION_ACTIONS = { + completeAssessment: "#AdjudicationTeamLeadActionBar #CompleteButton", +}; + +const BREADCRUMB_STATUS_SELECTOR = + ".application-details-breadcrumb .application-status"; +const APPLICATIONS_PATH = "GrantApplications"; + (isProd ? describe.skip : describe)("Approval Flow Regression Test", () => { // Page object instances reused across all tests const listPage = new ApplicationsListPage(); @@ -63,6 +82,7 @@ const TEST_CONFIG = { /** Navigate to the Payments tab and filter the table by submissionId. */ function navigateToPaymentsAndSearch(): void { + listPage.waitForNoBlockingOverlay(); cy.reload(); navPage.goToPayments(); cy.location("pathname", { timeout: 20000 }).should("include", "Payment"); @@ -73,9 +93,338 @@ const TEST_CONFIG = { cy.contains("tr", submissionId, { timeout: 20000 }).should("exist"); } + function navigateToApplicationsList(): void { + cy.location("pathname", { timeout: 20000 }).then((pathname) => { + if (!pathname.includes(`/${APPLICATIONS_PATH}`)) { + cy.visit(`${Cypress.env("webapp.url")}${APPLICATIONS_PATH}`); + } + }); + listPage.waitForNoBlockingOverlay(); + } + + /** + * Seeded submissions can take time to appear in the Grant Applications list. + * Poll the list with a refresh until the row becomes searchable. + */ + function waitForSubmissionToAppearInList( + attempt = 1, + maxAttempts = 8, + ): Cypress.Chainable { + navigateToApplicationsList(); + dismissBlockingModalIfPresent(); + + listPage + .selectQuickDateRange("alltime") + .waitForTableRefresh() + .searchForSubmission(submissionId); + + return cy.get("body").then(($body) => { + const hasRow = $body.find(`tr:contains("${submissionId}")`).length > 0; + + if (hasRow) { + cy.log( + `Submission ${submissionId} found in applications list on attempt ${attempt}`, + ); + return; + } + + if (attempt >= maxAttempts) { + throw new Error( + `Submission ${submissionId} was not visible in the applications list after ${maxAttempts} attempts`, + ); + } + + cy.log( + `Submission ${submissionId} not visible yet. Refreshing applications list (attempt ${attempt} of ${maxAttempts})`, + ); + cy.wait(5000); + cy.reload(); + return waitForSubmissionToAppearInList(attempt + 1, maxAttempts); + }); + } + + function ensureSiteInfoReady( + attempt = 1, + maxAttempts = 4, + ): Cypress.Chainable { + detailsPage.dismissErrorModalIfPresent(); + detailsPage.goToPaymentInfoTab(); + cy.wait(1000); + + return cy.get("body").then(($body) => { + const hasTokenError = + $body.text().includes("GetAuthTokenAsync") || + $body.text().includes("Error retrieving Token"); + const rows = $body.find("#SiteInfoTable tbody tr"); + const firstRowText = rows.first().text().replace(/\s+/g, " ").trim(); + const hasData = + rows.length > 0 && !/no data available/i.test(firstRowText); + + if (!hasTokenError && hasData) { + cy.log(`Site info ready on attempt ${attempt}`); + return; + } + + if (attempt >= maxAttempts) { + throw new Error( + `Site info was not ready after ${maxAttempts} attempts`, + ); + } + + cy.log( + `Site info not ready yet. Re-activating payment info content (attempt ${attempt} of ${maxAttempts})`, + ); + detailsPage.dismissErrorModalIfPresent(); + detailsPage.goToFundingAgreementTab(); + cy.wait(1000); + detailsPage.goToPaymentInfoTab(); + cy.wait(3000); + return ensureSiteInfoReady(attempt + 1, maxAttempts); + }); + } + + function waitForBlockingUiToClear(): void { + cy.get(".swal2-container", { timeout: 20000 }).should("not.exist"); + cy.get(".modal.show", { timeout: 20000 }).should("not.exist"); + cy.get(".modal-backdrop", { timeout: 20000 }).should("not.exist"); + } + + function openStatusActionsMenu(): void { + waitForBlockingUiToClear(); + detailsPage.dismissErrorModalIfPresent(); + cy.get(STATUS_ACTIONS.menuButton, { timeout: 20000 }) + .filter(":visible") + .first() + .scrollIntoView() + .should("be.visible") + .and("not.contain.text", "Processing...") + .click({ force: true }); + cy.get(STATUS_ACTIONS.menu, { timeout: 20000 }).should("be.visible"); + } + + function clickStatusAction(actionSelector: string): void { + openStatusActionsMenu(); + cy.get(actionSelector, { timeout: 20000 }) + .filter(":visible") + .first() + .should("be.visible") + .and("not.be.disabled") + .click({ force: true }); + } + + function clickStatusActionIfEnabled( + actionSelector: string, + actionName: string, + ): void { + openStatusActionsMenu(); + cy.get("body").then(($body) => { + const $action = $body.find(actionSelector).filter(":visible"); + if ($action.length === 0) { + cy.log(`${actionName} not present in dropdown; likely already progressed`); + cy.get("body").click(0, 0); // close menu + return; + } + if ($action.is(":disabled")) { + cy.log(`${actionName} is disabled; likely already progressed`); + cy.get("body").click(0, 0); // close menu + return; + } + cy.wrap($action.first()).click({ force: true }); + confirmStatusActionIfNeeded(); + }); + } + + function confirmStatusActionIfNeeded(): void { + cy.wait(500); + cy.get("body").then(($body) => { + if ($body.find(".swal2-popup .swal2-confirm").length > 0) { + cy.get(".swal2-popup .swal2-confirm", { timeout: 20000 }) + .should("be.visible") + .click({ force: true }); + cy.get(".swal2-container", { timeout: 20000 }).should("not.exist"); + return; + } + + if ( + $body.find(".modal.show .modal-content:contains('Confirm Action')") + .length > 0 + ) { + cy.get(".modal.show", { timeout: 20000 }) + .should("be.visible") + .within(() => { + cy.contains("button", /^Confirm$/i, { timeout: 20000 }) + .should("be.visible") + .click({ force: true }); + }); + cy.contains(".modal.show .modal-content", "Confirm Action", { + timeout: 20000, + }).should("not.exist"); + cy.get(".modal-backdrop", { timeout: 20000 }).should("not.exist"); + } + }); + } + + function dismissBlockingModalIfPresent(): void { + cy.get("body").then(($body) => { + if ($body.find(".swal2-container .swal2-confirm").length > 0) { + cy.get(".swal2-container .swal2-confirm", { timeout: 20000 }) + .should("be.visible") + .click({ force: true }); + cy.get(".swal2-container", { timeout: 20000 }).should("not.exist"); + } + + if ($body.find(".modal.show").length > 0) { + cy.get(".modal.show", { timeout: 20000 }).should("not.exist"); + cy.get(".modal-backdrop", { timeout: 20000 }).should("not.exist"); + } + }); + } + + function selectFirstAvailableOptionByLabel(labelText: string): void { + cy.get("body").then(($body) => { + if ($body.find(`label:contains('${labelText}'):visible`).length === 0) { + cy.log(`Label not found (skip): ${labelText}`); + return; + } + + cy.contains("label", labelText, { timeout: 10000 }) + .first() + .then(($label) => { + const fieldId = ($label.attr("for") || "").trim(); + if (!fieldId) { + cy.log(`No 'for' attribute on label (skip): ${labelText}`); + return; + } + + const selector = `#${fieldId}`; + if ($body.find(selector).length === 0) { + cy.log(`Field not found by id (skip): ${selector}`); + return; + } + + cy.get(selector, { timeout: 10000 }).then(($select) => { + const options = $select.find("option").toArray(); + const candidate = options.find((opt) => { + const value = (opt.getAttribute("value") || "").trim(); + const text = (opt.textContent || "").trim().toLowerCase(); + return value !== "" && text !== "please choose..."; + }); + + const value = candidate?.getAttribute("value"); + if (value) { + cy.wrap($select).select(value, { force: true }); + } + }); + }); + }); + } + + function populateReviewFieldsRequiredForCompleteReview(): void { + detailsPage.goToReviewAssessmentTab(); + + reviewPage.verifyFormioLoaded(); + cy.get("#ApprovalView_ApprovedAmount", { timeout: 30000 }) + .should("be.visible") + .and("not.be.disabled"); + + cy.get("body").then(($body) => { + if ($body.find("#ApprovalView_ApprovedAmount").length > 0) { + reviewPage.enterApprovedAmount(TEST_CONFIG.approvedAmount); + } else { + cy.log("Approved amount field not present yet; skipping amount entry"); + } + + if ($body.find("#ApprovalView_FinalDecisionDate").length > 0) { + reviewPage.setDecisionDateToToday(); + } else { + cy.log("Decision date field not present yet; skipping date entry"); + } + }); + + selectFirstAvailableOptionByLabel("Likelihood of Funding"); + selectFirstAvailableOptionByLabel("Due Diligence Status"); + selectFirstAvailableOptionByLabel("Assessment Result"); + reviewPage.clickSave(); + } + + function assignSubmissionFromList(ownerName: string): void { + dismissBlockingModalIfPresent(); + waitForSubmissionToAppearInList(); + + listPage + .waitForNoBlockingOverlay() + .selectQuickDateRange("alltime") + .waitForTableRefresh() + .searchForSubmission(submissionId) + .selectRowByText(submissionId); + + cy.get("#assignApplication", { timeout: 20000 }) + .should("exist") + .and("not.have.class", "action-bar-btn-unavailable") + .and("be.visible") + .click({ force: true }); + + cy.contains(".modal-title", "Assessment Users", { timeout: 20000 }).should( + "be.visible", + ); + + cy.get("#AssigneeId", { timeout: 20000 }) + .should("be.visible") + .select(ownerName); + + cy.get("#user-tags-input", { timeout: 20000 }) + .should("be.visible") + .clear() + .type(ownerName, { delay: 0 }); + + cy.get("body").then(($body) => { + if ( + $body.find(".tags-suggestion-container .tags-suggestion-element") + .length > 0 + ) { + cy.get(".tags-suggestion-container .tags-suggestion-element", { + timeout: 10000, + }) + .contains(ownerName) + .click({ force: true }); + } else { + cy.get("#user-tags-input").type("{enter}"); + } + }); + + cy.contains(".modal-footer button", "Save", { timeout: 20000 }) + .should("be.visible") + .and("not.be.disabled") + .click({ force: true }); + + cy.contains(".modal-title", "Assessment Users", { timeout: 20000 }).should( + "not.exist", + ); + listPage.waitForNoBlockingOverlay(); + } + + function clickAdjudicationActionIfEnabled(actionSelector: string): void { + cy.get("body").then(($body) => { + if ($body.find(actionSelector).length > 0) { + cy.get(actionSelector, { timeout: 20000 }).then(($button) => { + if (!$button.is(":disabled")) { + cy.wrap($button).click({ force: true }); + confirmStatusActionIfNeeded(); + } else { + cy.log(`Action is disabled: ${actionSelector}`); + } + }); + } else { + cy.log(`Action not present: ${actionSelector}`); + } + }); + } + /** Select the submissionId row and open the Approve Payments modal. */ function selectRowAndOpenApproveModal(): void { + waitForBlockingUiToClear(); cy.contains("tr", submissionId, { timeout: 20000 }) + .scrollIntoView() .find(".checkbox-select") .click({ force: true }); cy.contains("button", "Approve", { timeout: 20000 }) @@ -168,54 +517,90 @@ const TEST_CONFIG = { .searchForSubmission(submissionId); }); + it("Assign submission on application list", () => { + assignSubmissionFromList(TEST_CONFIG.assignOwner); + }); + it("Select submission and open details", () => { - listPage.selectRowByText(submissionId).clickOpenButton(); + dismissBlockingModalIfPresent(); + waitForSubmissionToAppearInList(); + listPage.waitForNoBlockingOverlay(); + + cy.location("pathname", { timeout: 20000 }).then((pathname) => { + if (pathname.includes("/GrantApplications/Details")) { + cy.log("Already on details page after assignment"); + } else { + listPage + .selectQuickDateRange("alltime") + .waitForTableRefresh() + .searchForSubmission(submissionId) + .selectRowByText(submissionId) + .clickOpenButton(); + } + }); + + cy.get(BREADCRUMB_STATUS_SELECTOR, { timeout: 20000 }) + .should("be.visible") + .invoke("text") + .then((statusText) => { + const normalized = statusText.trim().toLowerCase(); + expect( + ["submitted", "assigned"], + "Expected initial status to be Submitted or Assigned", + ).to.include(normalized); + }); }); // ============ Review & Assessment ============ - it("Navigate to Review and Assessment tab", () => { - detailsPage.goToReviewAssessmentTab().verifyActiveTab("reviewAssessment"); + it("Start review from Status Actions", () => { + clickStatusActionIfEnabled(STATUS_ACTIONS.startReview, "Start Review"); }); - it("Enter approval details and save", () => { - reviewPage - .verifyFormioLoaded() - .enterApprovedAmount(TEST_CONFIG.approvedAmount) - .setDecisionDateToToday() - .clickSave(); + it("Complete review from Status Actions", () => { + populateReviewFieldsRequiredForCompleteReview(); + clickStatusActionIfEnabled( + STATUS_ACTIONS.completeReview, + "Complete Review", + ); + }); + + it("Start assessment from Status Actions", () => { + clickStatusActionIfEnabled( + STATUS_ACTIONS.startAssessment, + "Start Assessment", + ); + }); + + it("Navigate to Review and Assessment tab", () => { + detailsPage.goToReviewAssessmentTab().verifyActiveTab("reviewAssessment"); }); - it("Create and complete assessment", () => { + it("Create assessment", () => { cy.wait(2000); // Allow assessment section to fully load reviewPage.scrollToAssessmentList(); cy.get("body").then(($body) => { if ($body.find("#CreateButton").length > 0) { cy.get("#CreateButton").click({ force: true }); - cy.wait(1000); + // Give the new assessment row time to render before subsequent actions. + cy.wait(1000); // Needed because row creation animation can delay DOM readiness. } else { cy.log("Create Assessment button not found - may already be created"); } }); + }); - cy.get("body").then(($body) => { - if ($body.find("#CompleteButton").length > 0) { - cy.get("#CompleteButton").click({ force: true }); - cy.wait(1000); - } else { - cy.log( - "Complete Assessment button not found - may already be completed", - ); - } - }); + it("Complete assessment from adjudication action bar", () => { + clickAdjudicationActionIfEnabled(ADJUDICATION_ACTIONS.completeAssessment); }); // ============ Payment Info ============ it("Configure payment info", () => { cy.reload(); // Reload to get fresh data and avoid concurrency issues - cy.wait(2000); + // Wait briefly for async payment tab dependencies to stabilize after reload. + cy.wait(2000); // Prevents save attempts before payment controls are initialized. detailsPage .goToPaymentInfoTab() .enterSupplierNumber(TEST_CONFIG.supplierNumber) @@ -224,6 +609,7 @@ const TEST_CONFIG = { }); it("Validate and edit site info", () => { + ensureSiteInfoReady(); detailsPage .verifySiteInfoTablePopulated() .verifySiteInfoTableHasData() @@ -269,18 +655,37 @@ const TEST_CONFIG = { }); }); + it("Enter approval details and save", () => { + detailsPage.goToReviewAssessmentTab().verifyActiveTab("reviewAssessment"); + reviewPage + .verifyFormioLoaded() + .enterApprovedAmount(TEST_CONFIG.approvedAmount) + .setDecisionDateToToday() + .clickSave(); + }); + // ============ Application Approval ============ it("Test approval workflow (confirm)", () => { cy.reload(); // Refresh to ensure all changes are reflected before approval detailsPage.dismissErrorModalIfPresent(); - detailsPage.clickApprove().waitForConfirmModal().clickConfirm(); + clickStatusAction(STATUS_ACTIONS.completeAssessment); + confirmStatusActionIfNeeded(); + + cy.get(STATUS_ACTIONS.menuButton, { timeout: 20000 }) + .filter(":visible") + .first() + .should("be.visible") + .and("not.contain.text", "Processing..."); + + clickStatusAction(STATUS_ACTIONS.approve); + detailsPage.waitForConfirmModal().clickConfirm(); }); // ============ Post-Approval Verification ============ it("Navigate back to applications list", () => { - cy.visit(`${Cypress.env("webapp.url")}GrantApplications`); + cy.visit(`${Cypress.env("webapp.url")}${APPLICATIONS_PATH}`); listPage.switchToGrantProgram(TEST_CONFIG.grantProgram); }); @@ -300,7 +705,10 @@ const TEST_CONFIG = { // ============ Payment Request ============ it("Select approved application and submit payment request", () => { - listPage.selectRowByText(submissionId).clickPaymentButtonWithWait(); + listPage + .waitForNoBlockingOverlay() + .selectRowByText(submissionId) + .clickPaymentButtonWithWait(); listPage.waitForPaymentModalVisible(); // Description field has a max length of 40 chars @@ -317,7 +725,7 @@ const TEST_CONFIG = { .and("not.be.disabled") .click(); - cy.get("#payment-modal", { timeout: 20000 }).should("not.be.visible"); + listPage.verifyPaymentModalClosed(); cy.log(`✅ Payment request submitted: ${paymentDescription}`); }); diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Models/AIJsonKeys.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Models/AIJsonKeys.cs index 501de0c856..6e83686ff0 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Models/AIJsonKeys.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Models/AIJsonKeys.cs @@ -2,14 +2,12 @@ namespace Unity.AI.Models { public static class AIJsonKeys { - public const string Rating = "rating"; public const string Errors = "errors"; public const string Warnings = "warnings"; public const string Summaries = "summaries"; - public const string NextSteps = "nextSteps"; - public const string Hidden = "hidden"; - public const string Recommendation = "recommendation"; + public const string Recommendations = "recommendations"; public const string Decision = "decision"; + public const string Dismissed = "dismissed"; public const string Id = "id"; public const string Title = "title"; diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Models/ApplicationAnalysisFinding.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Models/ApplicationAnalysisFinding.cs index 181168009e..f386f45d1f 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Models/ApplicationAnalysisFinding.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Models/ApplicationAnalysisFinding.cs @@ -7,8 +7,8 @@ public class ApplicationAnalysisFinding [JsonPropertyName(AIJsonKeys.Id)] public string? Id { get; set; } - [JsonPropertyName(AIJsonKeys.Hidden)] - public bool Hidden { get; set; } + [JsonPropertyName(AIJsonKeys.Dismissed)] + public bool Dismissed { get; set; } [JsonPropertyName(AIJsonKeys.Title)] public string? Title { get; set; } diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Models/ApplicationAnalysisRecommendation.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Models/ApplicationAnalysisRecommendation.cs deleted file mode 100644 index 6b082f5295..0000000000 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Models/ApplicationAnalysisRecommendation.cs +++ /dev/null @@ -1,13 +0,0 @@ -using System.Text.Json.Serialization; - -namespace Unity.AI.Models -{ - public class ApplicationAnalysisRecommendation - { - [JsonPropertyName(AIJsonKeys.Decision)] - public string? Decision { get; set; } - - [JsonPropertyName(AIJsonKeys.Rationale)] - public string? Rationale { get; set; } - } -} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Responses/ApplicationAnalysisResponse.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Responses/ApplicationAnalysisResponse.cs index 12f3532f4e..68231cd763 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Responses/ApplicationAnalysisResponse.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Responses/ApplicationAnalysisResponse.cs @@ -6,8 +6,8 @@ namespace Unity.AI.Responses { public class ApplicationAnalysisResponse { - [JsonPropertyName(AIJsonKeys.Rating)] - public string? Rating { get; set; } + [JsonPropertyName(AIJsonKeys.Decision)] + public string? Decision { get; set; } [JsonPropertyName(AIJsonKeys.Errors)] public List Errors { get; set; } = new(); @@ -18,10 +18,7 @@ public class ApplicationAnalysisResponse [JsonPropertyName(AIJsonKeys.Summaries)] public List Summaries { get; set; } = new(); - [JsonPropertyName(AIJsonKeys.NextSteps)] - public List NextSteps { get; set; } = new(); - - [JsonPropertyName(AIJsonKeys.Recommendation)] - public ApplicationAnalysisRecommendation? Recommendation { get; set; } + [JsonPropertyName(AIJsonKeys.Recommendations)] + public List Recommendations { get; set; } = new(); } } diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Attachments/AttachmentSummaryResultDto.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Attachments/AttachmentSummaryResultDto.cs new file mode 100644 index 0000000000..b7100305b0 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Attachments/AttachmentSummaryResultDto.cs @@ -0,0 +1,6 @@ +namespace Unity.GrantManager.Attachments; + +public class AttachmentSummaryResultDto +{ + public bool Completed { get; set; } +} 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 index 65589ed840..b9eb70ae3a 100644 --- 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 @@ -7,6 +7,6 @@ namespace Unity.GrantManager.Attachments; public interface IAttachmentSummaryAppService : IApplicationService { - Task GenerateAttachmentSummaryAsync(Guid attachmentId, string? promptVersion = null); - Task> GenerateAttachmentSummariesAsync(List attachmentIds, string? promptVersion = null); + Task GenerateAttachmentSummaryAsync(Guid attachmentId, string? promptVersion = null); + Task> GenerateAttachmentSummariesAsync(List attachmentIds, string? promptVersion = null); } diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/GrantApplications/ApplicationAnalysisResultDto.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/GrantApplications/ApplicationAnalysisResultDto.cs new file mode 100644 index 0000000000..19c829756e --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/GrantApplications/ApplicationAnalysisResultDto.cs @@ -0,0 +1,6 @@ +namespace Unity.GrantManager.GrantApplications; + +public class ApplicationAnalysisResultDto +{ + public bool Completed { get; set; } +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/GrantApplications/ApplicationContentResultDto.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/GrantApplications/ApplicationContentResultDto.cs new file mode 100644 index 0000000000..0349127194 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/GrantApplications/ApplicationContentResultDto.cs @@ -0,0 +1,6 @@ +namespace Unity.GrantManager.GrantApplications; + +public class ApplicationContentResultDto +{ + public bool Completed { get; set; } +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/GrantApplications/ApplicationScoringResultDto.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/GrantApplications/ApplicationScoringResultDto.cs new file mode 100644 index 0000000000..b4e4e86214 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/GrantApplications/ApplicationScoringResultDto.cs @@ -0,0 +1,6 @@ +namespace Unity.GrantManager.GrantApplications; + +public class ApplicationScoringResultDto +{ + public bool Completed { get; set; } +} 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 index 493eb82c56..21bf7c7867 100644 --- 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 @@ -6,6 +6,6 @@ namespace Unity.GrantManager.GrantApplications { public interface IApplicationAnalysisAppService : IApplicationService { - Task GenerateApplicationAnalysisAsync(Guid applicationId, string? promptVersion = null); + Task GenerateApplicationAnalysisAsync(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 index c2d27129a6..1ce45e8a99 100644 --- 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 @@ -6,5 +6,5 @@ namespace Unity.GrantManager.GrantApplications; public interface IApplicationContentAppService : IApplicationService { - Task GenerateContentAsync(Guid applicationId, string? promptVersion = null); + Task GenerateContentAsync(Guid applicationId, string? promptVersion = null); } diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/GrantApplications/IApplicationScoringAppService.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/GrantApplications/IApplicationScoringAppService.cs index dae0bc4fb7..c9a7fbab08 100644 --- 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 @@ -6,6 +6,6 @@ namespace Unity.GrantManager.GrantApplications { public interface IApplicationScoringAppService : IApplicationService { - Task GenerateApplicationScoringAsync(Guid applicationId, string? promptVersion = null); + Task GenerateApplicationScoringAsync(Guid applicationId, string? promptVersion = null); } } diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/Versions/v0/application-analysis.user.txt b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/Versions/v0/application-analysis.user.txt index 67a6fde997..8d7c84c90e 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/Versions/v0/application-analysis.user.txt +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/Versions/v0/application-analysis.user.txt @@ -57,7 +57,7 @@ Analyze this grant application comprehensively across all five rubric categories OUTPUT { - "rating": "", + "decision": "", "warnings": [ { "title": "", @@ -76,23 +76,18 @@ OUTPUT "detail": "" } ], - "nextSteps": [ + "recommendations": [ { "title": "", "detail": "" } - ], - "recommendation": { - "decision": "", - "rationale": "" - } + ] } Important: - Use only APPLICATION CONTENT, ATTACHMENT SUMMARIES, FORM FIELD CONFIGURATION, and EVALUATION RUBRIC as evidence. +- decision must be PROCEED or HOLD. - Use summaries for overall application quality/readiness synthesis. -- Use nextSteps for reviewer-facing follow-up actions or considerations before scoring or decision-making. -- recommendation.decision must be PROCEED or HOLD. -- recommendation.rationale must explain the high-level recommendation in 1-2 complete sentences using provided evidence. +- Use recommendations for reviewer-facing follow-up actions or considerations before scoring or decision-making. - Use "title" and "detail" keys for all finding objects. - Return valid plain JSON only in the exact OUTPUT shape. diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/Versions/v1/application-analysis.output.txt b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/Versions/v1/application-analysis.output.txt index a80671676b..44093f50be 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/Versions/v1/application-analysis.output.txt +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/Versions/v1/application-analysis.output.txt @@ -1,5 +1,5 @@ { - "rating": "", + "decision": "", "errors": [ { "title": "", @@ -18,14 +18,10 @@ "detail": "" } ], - "nextSteps": [ + "recommendations": [ { "title": "", "detail": "" } - ], - "recommendation": { - "decision": "", - "rationale": "" - } + ] } diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/Versions/v1/application-analysis.rules.txt b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/Versions/v1/application-analysis.rules.txt index a250310372..5840d215c8 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/Versions/v1/application-analysis.rules.txt +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/Versions/v1/application-analysis.rules.txt @@ -19,17 +19,13 @@ - Use 3-6 words for title. - Summary titles should name the specific substantive reviewer conclusion, strength, or risk, not a generic evaluation label or abstract category. - Each detail must be 1-2 complete sentences. -- Summaries and nextSteps must be concrete, distinct, reviewer-relevant, and specific to this application's evidence. +- Summaries and recommendations must be concrete, distinct, reviewer-relevant, and specific to this application's evidence. - Avoid generic praise, checklist language, and repeated conclusions across lists. - Do not use a summary merely to say that supporting documents were provided; summarize the specific substantive evidence they add, or omit the finding. - If no findings exist, return empty arrays. -- Rating must be HIGH, MEDIUM, or LOW. +- Decision must be PROCEED or HOLD. - Use summaries for overall application quality/readiness synthesis. -- Use nextSteps for concrete reviewer-facing next actions based on the provided evidence. -- nextSteps may include proceeding with the normal review process when the application appears ready for that step. -- When evidence shows a meaningful gap, inconsistency, or uncertainty, use nextSteps for specific follow-up or verification actions. +- Use recommendations for concrete reviewer-facing next actions based on the provided evidence. +- Recommendations may include proceeding with the normal review process when the application appears ready for that step. +- When evidence shows a meaningful gap, inconsistency, or uncertainty, use recommendations for specific follow-up or verification actions. - Return an empty array only when no concrete next action would help the reviewer. -- recommendation.decision must be PROCEED or HOLD. -- Use HOLD only when provided evidence shows a material eligibility, feasibility, budget, or readiness concern that would reasonably block scoring or decision-making. -- recommendation.rationale must explain the high-level recommendation in 1-2 complete sentences using provided evidence. -- recommendation.rationale should name the 1-3 strongest evidence-based reasons for the recommendation. diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/AIOperationResult.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/AIOperationResult.cs index fa3753de30..d0c2a2ceec 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/AIOperationResult.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/AIOperationResult.cs @@ -1,6 +1,6 @@ namespace Unity.AI.Runtime { - internal enum AIOperationOutcome + public enum AIOperationOutcome { Success, TransientFailure, @@ -8,7 +8,7 @@ internal enum AIOperationOutcome InvalidOutput } - internal sealed record AIOperationResult( + public sealed record AIOperationResult( AIOperationOutcome Outcome, AIProviderResult Response) { diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/AIProviderPayloadValidator.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/AIProviderPayloadValidator.cs index d317618f32..f34cb95092 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/AIProviderPayloadValidator.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/AIProviderPayloadValidator.cs @@ -19,16 +19,16 @@ public static bool IsValidApplicationAnalysisJson(string response) return false; } - return root.TryGetProperty(AIJsonKeys.Rating, out var rating) - && rating.ValueKind == JsonValueKind.String + return root.TryGetProperty(AIJsonKeys.Decision, out var decision) + && decision.ValueKind == JsonValueKind.String && root.TryGetProperty(AIJsonKeys.Errors, out var errors) && errors.ValueKind == JsonValueKind.Array && root.TryGetProperty(AIJsonKeys.Warnings, out var warnings) && warnings.ValueKind == JsonValueKind.Array && root.TryGetProperty(AIJsonKeys.Summaries, out var summaries) && summaries.ValueKind == JsonValueKind.Array - && root.TryGetProperty(AIJsonKeys.NextSteps, out var nextSteps) - && nextSteps.ValueKind == JsonValueKind.Array; + && root.TryGetProperty(AIJsonKeys.Recommendations, out var recommendations) + && recommendations.ValueKind == JsonValueKind.Array; } public static bool IsValidApplicationScoringJson(string response, string sectionJson) diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/AIProviderResponseMetadata.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/AIProviderResponseMetadata.cs index 6974af22b3..8144c43e4e 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/AIProviderResponseMetadata.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/AIProviderResponseMetadata.cs @@ -1,6 +1,6 @@ namespace Unity.AI.Runtime { - internal sealed record AIProviderResponseMetadata( + public sealed record AIProviderResponseMetadata( string? Model, string? FinishReason, int? PromptTokens, diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/AIProviderResult.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/AIProviderResult.cs index 6d9254b781..f36b165279 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/AIProviderResult.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/AIProviderResult.cs @@ -1,6 +1,6 @@ namespace Unity.AI.Runtime { - internal sealed record AIProviderResult( + public sealed record AIProviderResult( string Content, string RawResponse = "", string? Model = null, diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIConfigurationResolver.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIConfigurationResolver.cs new file mode 100644 index 0000000000..7d999907aa --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIConfigurationResolver.cs @@ -0,0 +1,178 @@ +using Microsoft.Extensions.Configuration; +using System; +using System.Globalization; +using Volo.Abp.DependencyInjection; + +namespace Unity.AI.Runtime; + +public class OpenAIConfigurationResolver(IConfiguration configuration) : ITransientDependency +{ + private const string DefaultMaxTokensParameterName = "max_completion_tokens"; + private const string LegacyMaxTokensParameterName = "max_tokens"; + private const string DefaultProviderName = "OpenAI"; + private const string OpenAiApiKeyEnvironmentVariableName = "AZURE_OPENAI_API_KEY"; + private const string OpenAiEndpointEnvironmentVariableName = "AZURE_OPENAI_ENDPOINT"; + + private readonly IConfiguration _configuration = configuration; + + public string ResolveProviderName(string? operationName = null) + { + if (!string.IsNullOrWhiteSpace(operationName)) + { + var configuredProvider = _configuration[$"Azure:Operations:{operationName}:Provider"]; + if (!string.IsNullOrWhiteSpace(configuredProvider)) + { + return configuredProvider.Trim(); + } + } + + var defaultProvider = _configuration["Azure:Operations:Defaults:Provider"]; + return string.IsNullOrWhiteSpace(defaultProvider) ? DefaultProviderName : defaultProvider.Trim(); + } + + public string ResolveApiKey(string? operationName = null) + { + var providerName = ResolveProviderName(operationName); + if (string.Equals(providerName, DefaultProviderName, StringComparison.Ordinal)) + { + var injectedApiKey = _configuration[OpenAiApiKeyEnvironmentVariableName]; + if (!string.IsNullOrWhiteSpace(injectedApiKey)) + { + return injectedApiKey; + } + } + + return _configuration[$"Azure:{providerName}:ApiKey"] ?? string.Empty; + } + + public string ResolveMaxTokensParameterNameForOperation(string? operationName = null) + { + var providerName = ResolveProviderName(operationName); + var profileName = ResolveProfileName(operationName); + var profileParameterName = ResolveProfileSetting(providerName, profileName, "MaxTokensParameter"); + return ResolveMaxTokensParameterName(profileParameterName); + } + + public double? ResolveConfiguredTemperature(string? operationName = null) + { + var providerName = ResolveProviderName(operationName); + var profileName = ResolveProfileName(operationName); + var profileTemperature = ResolveProfileSetting(providerName, profileName, "Temperature"); + if (profileTemperature != null + && double.TryParse(profileTemperature, NumberStyles.Float, CultureInfo.InvariantCulture, out var parsedTemperature)) + { + return parsedTemperature; + } + + return null; + } + + public int ResolveCompletionTokens(string operationName, int defaultValue) + { + var configuredValue = _configuration.GetValue($"Azure:Operations:{operationName}:MaxCompletionTokens"); + if (configuredValue is > 0) + { + return configuredValue.Value; + } + + var defaultConfiguredValue = _configuration.GetValue("Azure:Operations:Defaults:MaxCompletionTokens"); + return defaultConfiguredValue is > 0 ? defaultConfiguredValue.Value : defaultValue; + } + + public string ResolveApiUrl(string? operationName = null) + { + var providerName = ResolveProviderName(operationName); + var profileName = ResolveProfileName(operationName); + var profileApiUrl = ResolveProfileSetting(providerName, profileName, "ApiUrl"); + var injectedEndpoint = ResolveInjectedEndpoint(providerName); + var legacyOpenAiApiUrl = _configuration["Azure:OpenAI:ApiUrl"]; + + if (!string.IsNullOrWhiteSpace(injectedEndpoint) && !string.IsNullOrWhiteSpace(profileApiUrl)) + { + return CombineEndpointAndPath(injectedEndpoint, profileApiUrl); + } + + if (!string.IsNullOrWhiteSpace(profileApiUrl)) + { + return profileApiUrl; + } + + if (!string.IsNullOrWhiteSpace(legacyOpenAiApiUrl)) + { + return legacyOpenAiApiUrl; + } + + throw new InvalidOperationException($"AI API URL is not configured for provider '{providerName}'."); + } + + private static string ResolveMaxTokensParameterName(string? configuredParameterName) + { + if (string.Equals(configuredParameterName, LegacyMaxTokensParameterName, StringComparison.Ordinal)) + { + return LegacyMaxTokensParameterName; + } + + return DefaultMaxTokensParameterName; + } + + private string? ResolveInjectedEndpoint(string providerName) + { + if (!string.Equals(providerName, DefaultProviderName, StringComparison.Ordinal)) + { + return _configuration[$"Azure:{providerName}:Endpoint"]; + } + + var injectedEndpoint = _configuration[OpenAiEndpointEnvironmentVariableName]; + if (!string.IsNullOrWhiteSpace(injectedEndpoint)) + { + return injectedEndpoint; + } + + return _configuration["Azure:OpenAI:Endpoint"]; + } + + private string? ResolveProfileName(string? operationName) + { + if (!string.IsNullOrWhiteSpace(operationName)) + { + var operationProfile = _configuration[$"Azure:Operations:{operationName}:Profile"]; + if (!string.IsNullOrWhiteSpace(operationProfile)) + { + return operationProfile.Trim(); + } + } + + var defaultProfile = _configuration["Azure:Operations:Defaults:Profile"]; + return string.IsNullOrWhiteSpace(defaultProfile) ? null : defaultProfile.Trim(); + } + + private string? ResolveProfileSetting(string providerName, string? profileName, string settingName) + { + if (string.IsNullOrWhiteSpace(profileName)) + { + return null; + } + + var profileSetting = _configuration[$"Azure:{providerName}:Profiles:{profileName}:{settingName}"]; + return string.IsNullOrWhiteSpace(profileSetting) ? null : profileSetting; + } + + private static string CombineEndpointAndPath(string endpoint, string profilePath) + { + const char UrlPathSeparator = '/'; + + if (Uri.TryCreate(profilePath, UriKind.Absolute, out var absoluteUri)) + { + return absoluteUri.ToString(); + } + + var trimmedEndpoint = endpoint.Trim().TrimEnd(UrlPathSeparator); + var trimmedPath = profilePath.Trim(); + if (!trimmedPath.StartsWith(UrlPathSeparator)) + { + trimmedPath = string.Concat(UrlPathSeparator, trimmedPath); + } + + return trimmedEndpoint + trimmedPath; + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIPromptRenderer.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIPromptRenderer.cs new file mode 100644 index 0000000000..a56d56f6af --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIPromptRenderer.cs @@ -0,0 +1,378 @@ +using System; +using System.Collections.Generic; +using System.Collections.Concurrent; +using System.IO; +using System.Linq; +using System.Text.Json; +using Volo.Abp.DependencyInjection; + +namespace Unity.AI.Runtime; + +public class OpenAIPromptRenderer : ITransientDependency +{ + private const string PromptVersionV0 = "v0"; + private const string PromptVersionV1 = "v1"; + private static readonly string PromptTemplatesFolder = Path.Combine("AI", "Prompts", "Versions"); + private const string ApplicationAnalysisSystemTemplateName = "application-analysis.system"; + private const string ApplicationAnalysisUserTemplateName = "application-analysis.user"; + private const string AttachmentSummarySystemTemplateName = "attachment-summary.system"; + private const string AttachmentSummaryUserTemplateName = "attachment-summary.user"; + private const string ApplicationScoringSystemTemplateName = "application-scoring.system"; + private const string ApplicationScoringUserTemplateName = "application-scoring.user"; + private static readonly Dictionary PromptProfiles = + new(StringComparer.OrdinalIgnoreCase) + { + [PromptVersionV0] = PromptVersionV0, + [PromptVersionV1] = PromptVersionV1 + }; + private static readonly ConcurrentDictionary PromptTemplateCache = new(StringComparer.OrdinalIgnoreCase); + private static readonly JsonSerializerOptions JsonLogOptions = new() { WriteIndented = true }; + + public static string BuildApplicationAnalysisSystemPrompt(string version) + { + return GetRequiredPromptTemplate(version, ApplicationAnalysisSystemTemplateName); + } + + public static string BuildApplicationAnalysisUserPrompt(string version, string schema, string data, string attachments) + { + var replacements = new Dictionary + { + ["SCHEMA"] = schema, + ["DATA"] = data, + ["ATTACHMENTS"] = attachments + }; + + return RenderPromptTemplate(version, ApplicationAnalysisUserTemplateName, replacements); + } + + public static string BuildAttachmentSummarySystemPrompt(string version) + { + return GetRequiredPromptTemplate(version, AttachmentSummarySystemTemplateName); + } + + public static string BuildAttachmentSummaryUserPrompt(string version, string attachment) + { + return RenderPromptTemplate(version, AttachmentSummaryUserTemplateName, new Dictionary + { + ["ATTACHMENT"] = attachment + }); + } + + public static string BuildApplicationScoringSystemPrompt(string version) + { + return GetRequiredPromptTemplate(version, ApplicationScoringSystemTemplateName); + } + + public static string BuildApplicationScoringUserPrompt(string version, string data, string attachments, string section, string response) + { + return RenderPromptTemplate(version, ApplicationScoringUserTemplateName, new Dictionary + { + ["DATA"] = data, + ["ATTACHMENTS"] = attachments, + ["SECTION"] = section, + ["RESPONSE"] = response + }); + } + + public static string BuildApplicationScoringResponseTemplate(string sectionPayloadJson) + { + try + { + using var doc = JsonDocument.Parse(sectionPayloadJson); + if (!doc.RootElement.TryGetProperty("questions", out var questions) || questions.ValueKind != JsonValueKind.Array) + { + return "{}"; + } + + var template = new Dictionary(); + foreach (var question in questions.EnumerateArray()) + { + if (!question.TryGetProperty("id", out var idProp) || idProp.ValueKind != JsonValueKind.String) + { + continue; + } + + var questionId = idProp.GetString(); + if (string.IsNullOrWhiteSpace(questionId)) + { + continue; + } + + template[questionId] = new + { + answer = string.Empty, + rationale = string.Empty, + confidence = 0 + }; + } + + if (template.Count == 0) + { + return "{}"; + } + + return JsonSerializer.Serialize(template, JsonLogOptions); + } + catch (JsonException) + { + return "{}"; + } + } + + public static string BuildAliasedApplicationScoringSection(string? sectionName, string sectionJson, out IReadOnlyDictionary questionIdAliasMap) + { + questionIdAliasMap = new Dictionary(StringComparer.Ordinal); + + if (string.IsNullOrWhiteSpace(sectionJson)) + { + return JsonSerializer.Serialize(new { name = sectionName, questions = sectionJson }, JsonLogOptions); + } + + try + { + using var sectionDoc = JsonDocument.Parse(sectionJson); + if (sectionDoc.RootElement.ValueKind != JsonValueKind.Array) + { + return JsonSerializer.Serialize(new { name = sectionName, questions = sectionDoc.RootElement.Clone() }, JsonLogOptions); + } + + var aliasedQuestions = new List>(); + var aliasMap = new Dictionary(StringComparer.Ordinal); + var index = 1; + + foreach (var question in sectionDoc.RootElement.EnumerateArray()) + { + if (question.ValueKind != JsonValueKind.Object) + { + continue; + } + + var aliasedQuestion = new Dictionary(StringComparer.Ordinal); + string? questionAlias = null; + + foreach (var property in question.EnumerateObject()) + { + if (property.NameEquals("id") && property.Value.ValueKind == JsonValueKind.String) + { + var originalQuestionId = property.Value.GetString(); + if (!string.IsNullOrWhiteSpace(originalQuestionId)) + { + questionAlias = $"q{index++}"; + aliasMap[questionAlias] = originalQuestionId; + aliasedQuestion[property.Name] = questionAlias; + continue; + } + } + + aliasedQuestion[property.Name] = property.Value.Clone(); + } + + if (!string.IsNullOrWhiteSpace(questionAlias)) + { + aliasedQuestions.Add(aliasedQuestion); + } + } + + questionIdAliasMap = aliasMap; + return JsonSerializer.Serialize(new { name = sectionName, questions = aliasedQuestions }, JsonLogOptions); + } + catch (JsonException) + { + return JsonSerializer.Serialize(new { name = sectionName, questions = sectionJson }, JsonLogOptions); + } + } + + public static string ResolvePromptVersion(string? version) + { + if (!string.IsNullOrWhiteSpace(version) && + PromptProfiles.TryGetValue(version.Trim(), out var selectedVersion)) + { + return selectedVersion; + } + + return PromptVersionV1; + } + + private static bool TryGetPromptTemplate(string version, string templateName, out string template) + { + template = string.Empty; + var cacheKey = $"{version}/{templateName}"; + if (PromptTemplateCache.TryGetValue(cacheKey, out var cachedTemplate)) + { + template = cachedTemplate; + return true; + } + + var path = Path.Combine(AppContext.BaseDirectory, PromptTemplatesFolder, version, $"{templateName}.txt"); + if (!File.Exists(path)) + { + return false; + } + + var loaded = PromptTemplateCache.GetOrAdd(cacheKey, _ => File.ReadAllText(path)); + if (string.IsNullOrWhiteSpace(loaded)) + { + return false; + } + + template = loaded; + return true; + } + + private static string GetRequiredPromptTemplate(string version, string templateName) + { + if (TryGetPromptTemplate(version, templateName, out var template)) + { + return template; + } + + throw new InvalidOperationException( + $"Missing required prompt template '{templateName}.txt' for prompt version '{version}'."); + } + + private static string RenderPromptTemplate( + string version, + string templateName, + IReadOnlyDictionary runtimeReplacements) + { + return RenderPromptTemplateInternal( + version, + templateName, + runtimeReplacements, + new HashSet(StringComparer.OrdinalIgnoreCase)); + } + + private static string RenderPromptTemplateInternal( + string version, + string templateName, + IReadOnlyDictionary runtimeReplacements, + ISet resolutionStack) + { + if (!resolutionStack.Add(templateName)) + { + throw new InvalidOperationException( + $"Detected cyclic prompt fragment reference while resolving '{templateName}.txt' for prompt version '{version}'."); + } + + var template = GetRequiredPromptTemplate(version, templateName); + var replacements = new Dictionary(runtimeReplacements, StringComparer.Ordinal); + var baseTemplateName = GetTemplateBaseName(templateName); + + foreach (var placeholder in GetTemplatePlaceholders(template)) + { + if (replacements.ContainsKey(placeholder)) + { + continue; + } + + var fragmentTemplateName = ResolveFragmentTemplateName(version, baseTemplateName, placeholder); + if (!string.IsNullOrWhiteSpace(fragmentTemplateName)) + { + replacements[placeholder] = RenderPromptTemplateInternal( + version, + fragmentTemplateName, + new Dictionary(StringComparer.Ordinal), + resolutionStack).TrimEnd(); + } + } + + var rendered = template; + foreach (var replacement in replacements) + { + rendered = rendered.Replace($"{{{{{replacement.Key}}}}}", replacement.Value ?? string.Empty, StringComparison.Ordinal); + } + + var unresolved = GetTemplatePlaceholders(rendered); + if (unresolved.Count > 0) + { + throw new InvalidOperationException( + $"Unresolved prompt placeholders in '{templateName}.txt' for prompt version '{version}': {string.Join(", ", unresolved.OrderBy(item => item))}"); + } + + resolutionStack.Remove(templateName); + return rendered; + } + + private static string? ResolveFragmentTemplateName(string version, string baseTemplateName, string placeholderName) + { + var normalizedPlaceholder = placeholderName.ToLowerInvariant(); + var baseScopedCandidate = $"{baseTemplateName}.{normalizedPlaceholder}"; + if (TryGetPromptTemplate(version, baseScopedCandidate, out _)) + { + return baseScopedCandidate; + } + + if (string.Equals(placeholderName, "RESPONSE", StringComparison.Ordinal)) + { + var outputCandidate = $"{baseTemplateName}.output"; + if (TryGetPromptTemplate(version, outputCandidate, out _)) + { + return outputCandidate; + } + } + + if (TryResolveCommonTemplateName(placeholderName, out var commonTemplateName) && + TryGetPromptTemplate(version, commonTemplateName, out _)) + { + return commonTemplateName; + } + + return null; + } + + private static bool TryResolveCommonTemplateName(string placeholderName, out string commonTemplateName) + { + commonTemplateName = string.Empty; + if (!placeholderName.StartsWith("COMMON_", StringComparison.Ordinal)) + { + return false; + } + + var suffix = placeholderName.Substring("COMMON_".Length).ToLowerInvariant(); + suffix = suffix.Replace('_', '.'); + commonTemplateName = $"common.{suffix}"; + return true; + } + + private static string GetTemplateBaseName(string templateName) + { + var separatorIndex = templateName.IndexOf('.', StringComparison.Ordinal); + if (separatorIndex <= 0) + { + return templateName; + } + + return templateName.Substring(0, separatorIndex); + } + + private static HashSet GetTemplatePlaceholders(string template) + { + var placeholders = new HashSet(StringComparer.Ordinal); + var searchIndex = 0; + + while (searchIndex < template.Length) + { + var start = template.IndexOf("{{", searchIndex, StringComparison.Ordinal); + if (start < 0) + { + break; + } + + var end = template.IndexOf("}}", start + 2, StringComparison.Ordinal); + if (end < 0) + { + break; + } + + var placeholder = template.Substring(start + 2, end - start - 2).Trim(); + if (!string.IsNullOrWhiteSpace(placeholder)) + { + placeholders.Add(placeholder); + } + + searchIndex = end + 2; + } + + return placeholders; + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIResponseParser.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIResponseParser.cs new file mode 100644 index 0000000000..1abacf8dda --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIResponseParser.cs @@ -0,0 +1,251 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.Json; +using Unity.AI.Models; +using Unity.AI.Responses; +using Volo.Abp.DependencyInjection; + +namespace Unity.AI.Runtime; + +public class OpenAIResponseParser : ITransientDependency +{ + public static ApplicationAnalysisResponse ParseApplicationAnalysisResponse(string raw) + { + var response = new ApplicationAnalysisResponse(); + if (!TryParseJsonObjectFromResponse(AddIdsToAnalysisItems(raw), out var root)) + { + return response; + } + + if (TryGetStringProperty(root, AIJsonKeys.Decision, out var decision)) + { + response.Decision = decision.Trim().ToUpperInvariant(); + } + + if (TryGetArrayProperty(root, AIJsonKeys.Errors, out var errorsArray)) + { + response.Errors = ParseFindings(errorsArray).ToList(); + } + + if (TryGetArrayProperty(root, AIJsonKeys.Warnings, out var warningsArray)) + { + response.Warnings = ParseFindings(warningsArray).ToList(); + } + + if (TryGetArrayProperty(root, AIJsonKeys.Summaries, out var summariesArray)) + { + response.Summaries = ParseFindings(summariesArray).ToList(); + } + + if (TryGetArrayProperty(root, AIJsonKeys.Recommendations, out var recommendationsArray)) + { + response.Recommendations = ParseFindings(recommendationsArray).ToList(); + } + + return response; + } + + private static string AddIdsToAnalysisItems(string analysisJson) + { + try + { + using var jsonDoc = JsonDocument.Parse(analysisJson); + using var memoryStream = new System.IO.MemoryStream(); + using (var writer = new Utf8JsonWriter(memoryStream, new JsonWriterOptions { Indented = true })) + { + writer.WriteStartObject(); + + foreach (var property in jsonDoc.RootElement.EnumerateObject()) + { + var outputPropertyName = property.Name; + + if (outputPropertyName == AIJsonKeys.Errors || + outputPropertyName == AIJsonKeys.Warnings || + outputPropertyName == AIJsonKeys.Summaries || + outputPropertyName == AIJsonKeys.Recommendations) + { + writer.WritePropertyName(outputPropertyName); + writer.WriteStartArray(); + + foreach (var item in property.Value.EnumerateArray()) + { + writer.WriteStartObject(); + + foreach (var itemProperty in item.EnumerateObject()) + { + itemProperty.WriteTo(writer); + } + + if (!item.TryGetProperty(AIJsonKeys.Id, out var idProp) || + idProp.ValueKind != JsonValueKind.String || + string.IsNullOrWhiteSpace(idProp.GetString())) + { + writer.WriteString(AIJsonKeys.Id, Guid.NewGuid().ToString()); + } + + writer.WriteEndObject(); + } + + writer.WriteEndArray(); + continue; + } + + property.WriteTo(writer); + } + + writer.WriteEndObject(); + writer.Flush(); + } + + return Encoding.UTF8.GetString(memoryStream.ToArray()); + } + catch + { + return analysisJson; + } + } + + public static ApplicationScoringResponse ParseApplicationScoringResponse(string raw, IReadOnlyDictionary? questionIdAliasMap = null) + { + var response = new ApplicationScoringResponse(); + if (!TryParseJsonObjectFromResponse(raw, out var root)) + { + return response; + } + + foreach (var property in root.EnumerateObject()) + { + if (property.Value.ValueKind != JsonValueKind.Object) + { + continue; + } + + var answer = property.Value.TryGetProperty("answer", out var answerProp) + ? answerProp.Clone() + : default; + var rationale = property.Value.TryGetProperty("rationale", out var rationaleProp) && + rationaleProp.ValueKind == JsonValueKind.String + ? rationaleProp.GetString() ?? string.Empty + : string.Empty; + var confidence = property.Value.TryGetProperty("confidence", out var confidenceProp) && + confidenceProp.ValueKind == JsonValueKind.Number && + confidenceProp.TryGetInt32(out var parsedConfidence) + ? NormalizeConfidence(parsedConfidence) + : 0; + + var questionId = questionIdAliasMap != null && + questionIdAliasMap.TryGetValue(property.Name, out var originalQuestionId) + ? originalQuestionId + : property.Name; + + response.Answers[questionId] = new ApplicationScoringAnswer + { + Answer = answer, + Rationale = rationale, + Confidence = confidence + }; + } + + return response; + } + + private static IEnumerable ParseFindings(JsonElement findingsArray) + { + foreach (var item in findingsArray.EnumerateArray()) + { + if (item.ValueKind != JsonValueKind.Object) + { + continue; + } + + var id = Guid.NewGuid().ToString(); + if (item.TryGetProperty(AIJsonKeys.Id, out var idProp) && idProp.ValueKind == JsonValueKind.String) + { + id = idProp.GetString() ?? id; + } + + var dismissed = item.TryGetProperty(AIJsonKeys.Dismissed, out var dismissedProp) && + (dismissedProp.ValueKind == JsonValueKind.True || dismissedProp.ValueKind == JsonValueKind.False) && + dismissedProp.GetBoolean(); + + string? title = null; + if (item.TryGetProperty(AIJsonKeys.Title, out var titleProp) && titleProp.ValueKind == JsonValueKind.String) + { + title = titleProp.GetString(); + } + + string? detail = null; + if (item.TryGetProperty(AIJsonKeys.Detail, out var detailProp) && detailProp.ValueKind == JsonValueKind.String) + { + detail = detailProp.GetString(); + } + + yield return new ApplicationAnalysisFinding + { + Id = id, + Dismissed = dismissed, + Title = title, + Detail = detail + }; + } + } + + private static bool TryParseJsonObjectFromResponse(string response, out JsonElement objectElement) + { + objectElement = default; + var cleaned = AIResponseJson.CleanJsonResponse(response); + if (string.IsNullOrWhiteSpace(cleaned)) + { + return false; + } + + try + { + using var doc = JsonDocument.Parse(cleaned); + if (doc.RootElement.ValueKind != JsonValueKind.Object) + { + return false; + } + + objectElement = doc.RootElement.Clone(); + return true; + } + catch (JsonException) + { + return false; + } + } + + private static bool TryGetStringProperty(JsonElement element, string propertyName, out string value) + { + value = string.Empty; + if (!element.TryGetProperty(propertyName, out var prop) || prop.ValueKind != JsonValueKind.String) + { + return false; + } + + value = prop.GetString() ?? string.Empty; + return true; + } + + private static bool TryGetArrayProperty(JsonElement element, string propertyName, out JsonElement value) + { + value = default; + if (!element.TryGetProperty(propertyName, out var prop) || prop.ValueKind != JsonValueKind.Array) + { + return false; + } + + value = prop.Clone(); + return true; + } + + private static int NormalizeConfidence(int confidence) + { + var clamped = Math.Clamp(confidence, 0, 100); + var rounded = (int)Math.Round(clamped / 5.0, MidpointRounding.AwayFromZero) * 5; + return Math.Clamp(rounded, 0, 100); + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIRuntimeService.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIRuntimeService.cs index 22137b9193..d9886d1f97 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIRuntimeService.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIRuntimeService.cs @@ -1,14 +1,9 @@ using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using System; -using System.Collections.Concurrent; using System.Collections.Generic; using System.IO; using System.Linq; -using System.Net; -using System.Net.Http; -using System.Text; using System.Text.Json; using System.Threading.Tasks; using Unity.AI.Extraction; @@ -17,48 +12,32 @@ using Unity.AI.Requests; using Unity.AI.Responses; using Volo.Abp.DependencyInjection; -using Volo.Abp.MultiTenancy; namespace Unity.AI.Runtime { [ExposeServices(typeof(IAIService))] public class OpenAIRuntimeService : IAIService, ITransientDependency { - private readonly HttpClient _httpClient; private readonly IConfiguration _configuration; private readonly ILogger _logger; private readonly ITextExtractionService _textExtractionService; - private readonly ICurrentTenant _currentTenant; - private readonly IHostEnvironment _hostEnvironment; + private readonly OpenAITransportService _openAITransportService; + private readonly OpenAIConfigurationResolver _openAIConfigurationResolver; private const string ApplicationAnalysisPromptType = AIPromptTypes.ApplicationAnalysis; private const string AttachmentSummaryPromptType = AIPromptTypes.AttachmentSummary; private const string ApplicationScoringPromptType = AIPromptTypes.ApplicationScoring; - private const string PromptVersionV0 = "v0"; - private const string PromptVersionV1 = "v1"; - private static readonly string PromptTemplatesFolder = Path.Combine("AI", "Prompts", "Versions"); - private const string ApplicationAnalysisSystemTemplateName = "application-analysis.system"; - private const string ApplicationAnalysisUserTemplateName = "application-analysis.user"; - private const string AttachmentSummarySystemTemplateName = "attachment-summary.system"; - private const string AttachmentSummaryUserTemplateName = "attachment-summary.user"; - private const string ApplicationScoringSystemTemplateName = "application-scoring.system"; - private const string ApplicationScoringUserTemplateName = "application-scoring.user"; private const string AIServiceNotConfiguredMessage = "AI service not available - service not configured."; private const string AIServiceTemporarilyUnavailableMessage = "AI request failed - service temporarily unavailable."; private const string AIRequestFailedRetryMessage = "AI request failed - please try again later."; private const int MaxAiAttempts = 3; - private const string DefaultMaxTokensParameterName = "max_completion_tokens"; - private const string LegacyMaxTokensParameterName = "max_tokens"; - private const string DefaultProviderName = "OpenAI"; - private const string OpenAiApiKeyEnvironmentVariableName = "AZURE_OPENAI_API_KEY"; - private const string OpenAiEndpointEnvironmentVariableName = "AZURE_OPENAI_ENDPOINT"; private const int DefaultCompletionTokens = 2000; private const int DefaultAttachmentSummaryCompletionTokens = 2000; private const int DefaultApplicationAnalysisCompletionTokens = 4000; private const int DefaultApplicationScoringCompletionTokens = 8000; - private int AttachmentSummaryCompletionTokens => ResolveCompletionTokens(AttachmentSummaryPromptType, DefaultAttachmentSummaryCompletionTokens); - private int ApplicationAnalysisCompletionTokens => ResolveCompletionTokens(ApplicationAnalysisPromptType, DefaultApplicationAnalysisCompletionTokens); - private int ApplicationScoringCompletionTokens => ResolveCompletionTokens(ApplicationScoringPromptType, DefaultApplicationScoringCompletionTokens); + private int AttachmentSummaryCompletionTokens => _openAIConfigurationResolver.ResolveCompletionTokens(AttachmentSummaryPromptType, DefaultAttachmentSummaryCompletionTokens); + private int ApplicationAnalysisCompletionTokens => _openAIConfigurationResolver.ResolveCompletionTokens(ApplicationAnalysisPromptType, DefaultApplicationAnalysisCompletionTokens); + private int ApplicationScoringCompletionTokens => _openAIConfigurationResolver.ResolveCompletionTokens(ApplicationScoringPromptType, DefaultApplicationScoringCompletionTokens); private readonly string MissingApiKeyMessage = "OpenAI API key is not configured"; // Optional local debugging sink for prompt payload logs to a local file. @@ -69,33 +48,23 @@ public class OpenAIRuntimeService : IAIService, ITransientDependency private static readonly JsonSerializerOptions JsonLogOptions = new() { WriteIndented = true }; - private static readonly Dictionary PromptProfiles = - new Dictionary(StringComparer.OrdinalIgnoreCase) - { - [PromptVersionV0] = PromptVersionV0, - [PromptVersionV1] = PromptVersionV1 - }; - private static readonly ConcurrentDictionary PromptTemplateCache = new(StringComparer.OrdinalIgnoreCase); - public OpenAIRuntimeService( - HttpClient httpClient, IConfiguration configuration, ILogger logger, ITextExtractionService textExtractionService, - ICurrentTenant currentTenant, - IHostEnvironment hostEnvironment) + OpenAITransportService openAITransportService, + OpenAIConfigurationResolver openAIConfigurationResolver) { - _httpClient = httpClient; _configuration = configuration; _logger = logger; _textExtractionService = textExtractionService; - _currentTenant = currentTenant; - _hostEnvironment = hostEnvironment; + _openAITransportService = openAITransportService; + _openAIConfigurationResolver = openAIConfigurationResolver; } public Task IsAvailableAsync() { - if (string.IsNullOrEmpty(ResolveApiKey())) + if (string.IsNullOrEmpty(_openAIConfigurationResolver.ResolveApiKey())) { _logger.LogWarning("Error: {Message}", MissingApiKeyMessage); return Task.FromResult(false); @@ -107,7 +76,7 @@ public Task IsAvailableAsync() public async Task GenerateCompletionAsync(AICompletionRequest request) { var result = await GenerateWithRetryAsync( - () => GenerateSummaryAsync( + () => _openAITransportService.GenerateSummaryAsync( request?.UserPrompt ?? string.Empty, null, request?.MaxTokens ?? DefaultCompletionTokens, @@ -120,7 +89,7 @@ public async Task GenerateCompletionAsync(AICompletionRequ public async Task GenerateApplicationAnalysisAsync(ApplicationAnalysisRequest request) { ArgumentNullException.ThrowIfNull(request); - var promptVersion = ResolvePromptVersion(request.PromptVersion ?? ResolvePromptVersionSetting(ApplicationAnalysisPromptType)); + var promptVersion = OpenAIPromptRenderer.ResolvePromptVersion(request.PromptVersion ?? ResolvePromptVersionSetting(ApplicationAnalysisPromptType)); var data = JsonSerializer.Serialize(request.Data, JsonLogOptions); var schema = JsonSerializer.Serialize(request.Schema, JsonLogOptions); @@ -133,15 +102,15 @@ public async Task GenerateApplicationAnalysisAsync( .Cast(); var attachments = JsonSerializer.Serialize(attachmentsPayload, JsonLogOptions); - var systemPrompt = BuildApplicationAnalysisSystemPrompt(promptVersion); - var applicationAnalysisContent = BuildApplicationAnalysisUserPrompt( + var systemPrompt = OpenAIPromptRenderer.BuildApplicationAnalysisSystemPrompt(promptVersion); + var applicationAnalysisContent = OpenAIPromptRenderer.BuildApplicationAnalysisUserPrompt( promptVersion, schema, data, attachments); await LogPromptInputAsync(ApplicationAnalysisPromptType, promptVersion, systemPrompt, applicationAnalysisContent); var result = await GenerateWithRetryAsync( - () => GenerateSummaryAsync( + () => _openAITransportService.GenerateSummaryAsync( applicationAnalysisContent, systemPrompt, ApplicationAnalysisCompletionTokens, @@ -156,124 +125,7 @@ public async Task GenerateApplicationAnalysisAsync( return new ApplicationAnalysisResponse(); } - return ParseApplicationAnalysisResponse(AddIdsToAnalysisItems(result.Content)); - } - - private async Task GenerateSummaryAsync( - string content, - string? systemPrompt, - int maxTokens = 150, - double? temperature = null, - string? operationName = null, - string? promptVersion = null, - string? fileName = null) - { - var providerName = ResolveProviderName(operationName); - if (!string.Equals(providerName, DefaultProviderName, StringComparison.Ordinal)) - { - _logger.LogWarning("Provider {ProviderName} is not supported by OpenAIRuntimeService.", providerName); - return AIOperationResult.PermanentFailure(new AIProviderResult($"Unsupported provider: {providerName}")); - } - - var apiKey = ResolveApiKey(operationName); - if (string.IsNullOrEmpty(apiKey)) - { - _logger.LogWarning("Error: {Message}", MissingApiKeyMessage); - return AIOperationResult.PermanentFailure(new AIProviderResult(MissingApiKeyMessage)); - } - - _logger.LogDebug("Calling OpenAI chat completions. PromptLength: {PromptLength}, MaxTokens: {MaxTokens}", content?.Length ?? 0, maxTokens); - - try - { - var resolvedSystemPrompt = string.IsNullOrWhiteSpace(systemPrompt) - ? "You are a professional grant analyst for the BC Government." - : systemPrompt; - var userPrompt = content ?? string.Empty; - - var requestBody = new - { - messages = new[] - { - new { role = "system", content = resolvedSystemPrompt }, - new { role = "user", content = userPrompt } - } - }; - - var requestPayload = new Dictionary - { - ["messages"] = requestBody.messages, - [ResolveMaxTokensParameterNameForOperation(operationName)] = maxTokens - }; - - var resolvedTemperature = temperature ?? ResolveConfiguredTemperature(operationName); - if (resolvedTemperature.HasValue) - { - requestPayload["temperature"] = resolvedTemperature.Value; - } - - var json = JsonSerializer.Serialize(requestPayload); - var httpContent = new StringContent(json, Encoding.UTF8, "application/json"); - - _httpClient.DefaultRequestHeaders.Clear(); - _httpClient.DefaultRequestHeaders.Add("Authorization", apiKey); - - var response = await _httpClient.PostAsync(ResolveApiUrl(operationName), httpContent); - var responseContent = await response.Content.ReadAsStringAsync(); - var metadata = TryExtractProviderMetadata(responseContent); - var providerResponse = BuildProviderResponseFromMetadata( - string.Empty, - responseContent, - metadata, - (int)response.StatusCode); - - _logger.LogDebug( - "OpenAI chat completions response received. StatusCode: {StatusCode}, ResponseLength: {ResponseLength}", - response.StatusCode, - responseContent?.Length ?? 0); - LogProviderMetadata(operationName, promptVersion, fileName, providerResponse, response.IsSuccessStatusCode); - - if (!response.IsSuccessStatusCode) - { - _logger.LogError("OpenAI API request failed: {StatusCode} - {Content}", response.StatusCode, responseContent); - return MapFailureOutcome(response.StatusCode, providerResponse); - } - - if (string.IsNullOrWhiteSpace(responseContent)) - { - return AIOperationResult.InvalidOutput(providerResponse); - } - - try - { - using var jsonDoc = JsonDocument.Parse(responseContent); - var choices = jsonDoc.RootElement.GetProperty("choices"); - if (choices.GetArrayLength() > 0) - { - var message = choices[0].GetProperty("message"); - var modelOutput = message.GetProperty("content").GetString(); - return string.IsNullOrWhiteSpace(modelOutput) - ? AIOperationResult.InvalidOutput(providerResponse) - : AIOperationResult.Success(BuildProviderResponseFromMetadata( - modelOutput, - responseContent, - metadata, - (int)response.StatusCode)); - } - - return AIOperationResult.InvalidOutput(providerResponse); - } - catch (Exception ex) when (ex is JsonException || ex is KeyNotFoundException || ex is InvalidOperationException) - { - _logger.LogWarning(ex, "AI response payload had an invalid output shape"); - return AIOperationResult.InvalidOutput(providerResponse); - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Error generating AI summary"); - return AIOperationResult.TransientFailure(new AIProviderResult(ex.Message)); - } + return OpenAIResponseParser.ParseApplicationAnalysisResponse(result.Content); } public async Task GenerateAttachmentSummaryAsync(AttachmentSummaryRequest request) @@ -282,12 +134,12 @@ public async Task GenerateAttachmentSummaryAsync(Atta var fileName = request.FileName ?? string.Empty; var fileContent = request.FileContent ?? Array.Empty(); var contentType = request.ContentType ?? "application/octet-stream"; - var promptVersion = ResolvePromptVersion(request.PromptVersion ?? ResolvePromptVersionSetting(AttachmentSummaryPromptType)); + var promptVersion = OpenAIPromptRenderer.ResolvePromptVersion(request.PromptVersion ?? ResolvePromptVersionSetting(AttachmentSummaryPromptType)); try { var extractedText = await _textExtractionService.ExtractTextAsync(fileName, fileContent, contentType); - var prompt = BuildAttachmentSummarySystemPrompt(promptVersion); + var prompt = OpenAIPromptRenderer.BuildAttachmentSummarySystemPrompt(promptVersion); var attachmentText = string.IsNullOrWhiteSpace(extractedText) ? null : extractedText; if (attachmentText != null) @@ -307,11 +159,11 @@ public async Task GenerateAttachmentSummaryAsync(Atta text = attachmentText }; var attachment = JsonSerializer.Serialize(attachmentPayload, JsonLogOptions); - var contentToAnalyze = BuildAttachmentSummaryUserPrompt(promptVersion, attachment); + var contentToAnalyze = OpenAIPromptRenderer.BuildAttachmentSummaryUserPrompt(promptVersion, attachment); await LogPromptInputAsync(AttachmentSummaryPromptType, promptVersion, prompt, contentToAnalyze); - var result = await GenerateWithRetryAsync( - () => GenerateSummaryAsync( + var result = await GenerateWithRetryAsync( + () => _openAITransportService.GenerateSummaryAsync( contentToAnalyze, prompt, AttachmentSummaryCompletionTokens, @@ -345,88 +197,17 @@ public async Task GenerateAttachmentSummaryAsync(Atta } } - private string AddIdsToAnalysisItems(string analysisJson) - { - try - { - using var jsonDoc = JsonDocument.Parse(analysisJson); - using var memoryStream = new System.IO.MemoryStream(); - using (var writer = new Utf8JsonWriter(memoryStream, new JsonWriterOptions { Indented = true })) - { - writer.WriteStartObject(); - - foreach (var property in jsonDoc.RootElement.EnumerateObject()) - { - var outputPropertyName = property.Name; - - if (outputPropertyName == AIJsonKeys.Errors || - outputPropertyName == AIJsonKeys.Warnings || - outputPropertyName == AIJsonKeys.Summaries || - outputPropertyName == AIJsonKeys.NextSteps) - { - writer.WritePropertyName(outputPropertyName); - writer.WriteStartArray(); - - foreach (var item in property.Value.EnumerateArray()) - { - writer.WriteStartObject(); - - // Add unique ID first - writer.WriteString("id", Guid.NewGuid().ToString()); - writer.WriteBoolean(AIJsonKeys.Hidden, false); - - // Copy existing properties - foreach (var itemProperty in item.EnumerateObject()) - { - if (itemProperty.NameEquals(AIJsonKeys.Id) || itemProperty.NameEquals(AIJsonKeys.Hidden)) - { - continue; - } - - itemProperty.WriteTo(writer); - } - - writer.WriteEndObject(); - } - - writer.WriteEndArray(); - } - else - { - if (outputPropertyName != property.Name) - { - writer.WritePropertyName(outputPropertyName); - property.Value.WriteTo(writer); - continue; - } - - property.WriteTo(writer); - } - } - - writer.WriteEndObject(); - } - - return Encoding.UTF8.GetString(memoryStream.ToArray()); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error adding IDs to analysis items, returning original JSON"); - return analysisJson; // Return original if processing fails - } - } - public async Task GenerateApplicationScoringAsync(ApplicationScoringRequest request) { ArgumentNullException.ThrowIfNull(request); - var promptVersion = ResolvePromptVersion(request.PromptVersion ?? ResolvePromptVersionSetting(ApplicationScoringPromptType)); + var promptVersion = OpenAIPromptRenderer.ResolvePromptVersion(request.PromptVersion ?? ResolvePromptVersionSetting(ApplicationScoringPromptType)); var dataJson = JsonSerializer.Serialize(request.Data, JsonLogOptions); var sectionJson = JsonSerializer.Serialize(request.SectionSchema, JsonLogOptions); var attachmentSummaries = request.Attachments .Select(a => $"{a.Name}: {a.Summary}") .ToList(); - if (string.IsNullOrEmpty(ResolveApiKey(ApplicationScoringPromptType))) + if (string.IsNullOrEmpty(_openAIConfigurationResolver.ResolveApiKey(ApplicationScoringPromptType))) { _logger.LogWarning("{Message}", MissingApiKeyMessage); return new ApplicationScoringResponse(); @@ -438,8 +219,8 @@ public async Task GenerateApplicationScoringAsync(Ap ? string.Join("\n- ", attachmentSummaries.Select((summary, index) => $"Attachment {index + 1}: {summary}")) : "No attachments provided."; - var section = BuildAliasedApplicationScoringSection(request.SectionName, sectionJson, out var questionIdAliasMap); - var response = BuildApplicationScoringResponseTemplate(section); + var section = OpenAIPromptRenderer.BuildAliasedApplicationScoringSection(request.SectionName, sectionJson, out var questionIdAliasMap); + var response = OpenAIPromptRenderer.BuildApplicationScoringResponseTemplate(section); if (response == "{}") { _logger.LogWarning( @@ -448,17 +229,17 @@ public async Task GenerateApplicationScoringAsync(Ap return new ApplicationScoringResponse(); } - var applicationScoringContent = BuildApplicationScoringUserPrompt( + var applicationScoringContent = OpenAIPromptRenderer.BuildApplicationScoringUserPrompt( promptVersion, dataJson, attachments, section, response); - var systemPrompt = BuildApplicationScoringSystemPrompt(promptVersion); + var systemPrompt = OpenAIPromptRenderer.BuildApplicationScoringSystemPrompt(promptVersion); await LogPromptInputAsync(ApplicationScoringPromptType, promptVersion, systemPrompt, applicationScoringContent); var result = await GenerateWithRetryAsync( - () => GenerateSummaryAsync( + () => _openAITransportService.GenerateSummaryAsync( applicationScoringContent, systemPrompt, ApplicationScoringCompletionTokens, @@ -473,7 +254,7 @@ public async Task GenerateApplicationScoringAsync(Ap return new ApplicationScoringResponse(); } - return ParseApplicationScoringResponse(result.Content, questionIdAliasMap); + return OpenAIResponseParser.ParseApplicationScoringResponse(result.Content, questionIdAliasMap); } catch (Exception ex) { @@ -547,137 +328,6 @@ private static string ResolveNarrativeContent(AIOperationResult result) }; } - private static AIOperationResult MapFailureOutcome(HttpStatusCode statusCode, AIProviderResult response) - { - var statusCodeValue = (int)statusCode; - - if (statusCode == HttpStatusCode.RequestTimeout - || statusCode == (HttpStatusCode)429 - || statusCodeValue >= 500) - { - return AIOperationResult.TransientFailure(response); - } - - return AIOperationResult.PermanentFailure(response); - } - - private static AIProviderResult BuildProviderResponseFromMetadata( - string content, - string? rawResponse, - AIProviderResponseMetadata? metadata, - int? httpStatusCode = null) - { - return new AIProviderResult( - content, - rawResponse ?? string.Empty, - metadata?.Model, - metadata?.FinishReason, - httpStatusCode, - metadata?.PromptTokens, - metadata?.CompletionTokens, - metadata?.TotalTokens, - metadata?.ReasoningTokens); - } - - private static AIProviderResponseMetadata? TryExtractProviderMetadata(string? responseContent) - { - if (string.IsNullOrWhiteSpace(responseContent)) - { - return null; - } - - try - { - using var jsonDoc = JsonDocument.Parse(responseContent); - var root = jsonDoc.RootElement; - var model = root.TryGetProperty("model", out var modelProp) && modelProp.ValueKind == JsonValueKind.String - ? modelProp.GetString() - : null; - - string? finishReason = null; - if (root.TryGetProperty("choices", out var choices) - && choices.ValueKind == JsonValueKind.Array - && choices.GetArrayLength() > 0) - { - var firstChoice = choices[0]; - if (firstChoice.TryGetProperty("finish_reason", out var finishReasonProp) && finishReasonProp.ValueKind == JsonValueKind.String) - { - finishReason = finishReasonProp.GetString(); - } - } - - int? promptTokens = null; - int? completionTokens = null; - int? totalTokens = null; - int? reasoningTokens = null; - if (root.TryGetProperty("usage", out var usage) && usage.ValueKind == JsonValueKind.Object) - { - promptTokens = TryGetInt32(usage, "prompt_tokens"); - completionTokens = TryGetInt32(usage, "completion_tokens"); - totalTokens = TryGetInt32(usage, "total_tokens"); - - if (usage.TryGetProperty("completion_tokens_details", out var completionTokenDetails) - && completionTokenDetails.ValueKind == JsonValueKind.Object) - { - reasoningTokens = TryGetInt32(completionTokenDetails, "reasoning_tokens"); - } - } - - return new AIProviderResponseMetadata(model, finishReason, promptTokens, completionTokens, totalTokens, reasoningTokens); - } - catch (JsonException) - { - return null; - } - } - - private void LogProviderMetadata( - string? operationName, - string? promptVersion, - string? fileName, - AIProviderResult response, - bool success) - { - if (string.IsNullOrWhiteSpace(response.Model) - && string.IsNullOrWhiteSpace(response.FinishReason) - && response.HttpStatusCode == null - && response.PromptTokens == null - && response.CompletionTokens == null - && response.TotalTokens == null - && response.ReasoningTokens == null) - { - return; - } - - if (response.PromptTokens != null || response.CompletionTokens != null || response.TotalTokens != null) - { - _logger.LogInformation( - "AI token usage. OperationName={OperationName}, InputTokens={InputTokens}, CompletionTokens={CompletionTokens}, TotalTokens={TotalTokens}, Environment={Environment}, TenantId={TenantId}, Status={Status}, PromptVersion={PromptVersion}, Model={Model}, HttpStatusCode={HttpStatusCode}, FileName={FileName}", - operationName ?? "completion", - response.PromptTokens, - response.CompletionTokens, - response.TotalTokens, - _hostEnvironment.EnvironmentName, - _currentTenant.Id, - success ? "success" : "failed", - promptVersion, - response.Model, - response.HttpStatusCode, - fileName); - } - - _logger.LogDebug( - "AI provider response metadata for {OperationName}: Model={Model}, FinishReason={FinishReason}, HttpStatusCode={HttpStatusCode}, PromptTokens={PromptTokens}, CompletionTokens={CompletionTokens}, TotalTokens={TotalTokens}, ReasoningTokens={ReasoningTokens}", - operationName ?? "completion", - response.Model, - response.FinishReason, - response.HttpStatusCode, - response.PromptTokens, - response.CompletionTokens, - response.TotalTokens, - response.ReasoningTokens); - } - private static int? TryGetInt32(JsonElement element, string propertyName) { return element.TryGetProperty(propertyName, out var property) @@ -687,28 +337,6 @@ private void LogProviderMetadata( : null; } - private static string ResolveMaxTokensParameterName(string? configuredParameterName) - { - if (string.Equals(configuredParameterName, LegacyMaxTokensParameterName, StringComparison.Ordinal)) - { - return LegacyMaxTokensParameterName; - } - - return DefaultMaxTokensParameterName; - } - - private int ResolveCompletionTokens(string operationName, int defaultValue) - { - var configuredValue = _configuration.GetValue($"Azure:Operations:{operationName}:MaxCompletionTokens"); - if (configuredValue is > 0) - { - return configuredValue.Value; - } - - var defaultConfiguredValue = _configuration.GetValue("Azure:Operations:Defaults:MaxCompletionTokens"); - return defaultConfiguredValue is > 0 ? defaultConfiguredValue.Value : defaultValue; - } - private string? ResolvePromptVersionSetting(string operationName) { var operationPromptVersion = _configuration[$"Azure:Operations:{operationName}:PromptVersion"]; @@ -726,431 +354,6 @@ private int ResolveCompletionTokens(string operationName, int defaultValue) return _configuration["Azure:OpenAI:PromptVersion"]; } - private string ResolveProviderName(string? operationName = null) - { - if (!string.IsNullOrWhiteSpace(operationName)) - { - var configuredProvider = _configuration[$"Azure:Operations:{operationName}:Provider"]; - if (!string.IsNullOrWhiteSpace(configuredProvider)) - { - return configuredProvider.Trim(); - } - } - - var defaultProvider = _configuration["Azure:Operations:Defaults:Provider"]; - return string.IsNullOrWhiteSpace(defaultProvider) ? DefaultProviderName : defaultProvider.Trim(); - } - - private string? ResolveApiKey(string? operationName = null) - { - var providerName = ResolveProviderName(operationName); - if (string.Equals(providerName, DefaultProviderName, StringComparison.Ordinal)) - { - var injectedApiKey = _configuration[OpenAiApiKeyEnvironmentVariableName]; - if (!string.IsNullOrWhiteSpace(injectedApiKey)) - { - return injectedApiKey; - } - } - - return _configuration[$"Azure:{providerName}:ApiKey"]; - } - - private string ResolveMaxTokensParameterNameForOperation(string? operationName = null) - { - var providerName = ResolveProviderName(operationName); - var profileName = ResolveProfileName(operationName); - var profileParameterName = ResolveProfileSetting(providerName, profileName, "MaxTokensParameter"); - return ResolveMaxTokensParameterName(profileParameterName); - } - - private double? ResolveConfiguredTemperature(string? operationName = null) - { - var providerName = ResolveProviderName(operationName); - var profileName = ResolveProfileName(operationName); - var profileTemperature = ResolveProfileSetting(providerName, profileName, "Temperature"); - if (profileTemperature != null - && double.TryParse(profileTemperature, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var parsedTemperature)) - { - return parsedTemperature; - } - - return null; - } - - private string ResolveApiUrl(string? operationName) - { - var providerName = ResolveProviderName(operationName); - var profileName = ResolveProfileName(operationName); - var profileApiUrl = ResolveProfileSetting(providerName, profileName, "ApiUrl"); - var injectedEndpoint = ResolveInjectedEndpoint(providerName); - var legacyOpenAiApiUrl = _configuration["Azure:OpenAI:ApiUrl"]; - - if (!string.IsNullOrWhiteSpace(injectedEndpoint) && !string.IsNullOrWhiteSpace(profileApiUrl)) - { - return CombineEndpointAndPath(injectedEndpoint, profileApiUrl); - } - - if (!string.IsNullOrWhiteSpace(profileApiUrl)) - { - return profileApiUrl; - } - - if (!string.IsNullOrWhiteSpace(legacyOpenAiApiUrl)) - { - return legacyOpenAiApiUrl; - } - - throw new InvalidOperationException($"AI API URL is not configured for provider '{providerName}'."); - } - - private string? ResolveInjectedEndpoint(string providerName) - { - if (!string.Equals(providerName, DefaultProviderName, StringComparison.Ordinal)) - { - return _configuration[$"Azure:{providerName}:Endpoint"]; - } - - var injectedEndpoint = _configuration[OpenAiEndpointEnvironmentVariableName]; - if (!string.IsNullOrWhiteSpace(injectedEndpoint)) - { - return injectedEndpoint; - } - - return _configuration["Azure:OpenAI:Endpoint"]; - } - - private string? ResolveProfileName(string? operationName) - { - if (!string.IsNullOrWhiteSpace(operationName)) - { - var operationProfile = _configuration[$"Azure:Operations:{operationName}:Profile"]; - if (!string.IsNullOrWhiteSpace(operationProfile)) - { - return operationProfile.Trim(); - } - } - - var defaultProfile = _configuration["Azure:Operations:Defaults:Profile"]; - return string.IsNullOrWhiteSpace(defaultProfile) ? null : defaultProfile.Trim(); - } - - private string? ResolveProfileSetting(string providerName, string? profileName, string settingName) - { - if (string.IsNullOrWhiteSpace(profileName)) - { - return null; - } - - var profileSetting = _configuration[$"Azure:{providerName}:Profiles:{profileName}:{settingName}"]; - return string.IsNullOrWhiteSpace(profileSetting) ? null : profileSetting; - } - - private static string CombineEndpointAndPath(string endpoint, string profilePath) - { - const char UrlPathSeparator = '/'; - - if (Uri.TryCreate(profilePath, UriKind.Absolute, out var absoluteUri)) - { - return absoluteUri.ToString(); - } - - var trimmedEndpoint = endpoint.Trim().TrimEnd(UrlPathSeparator); - var trimmedPath = profilePath.Trim(); - if (!trimmedPath.StartsWith(UrlPathSeparator)) - { - trimmedPath = string.Concat(UrlPathSeparator, trimmedPath); - } - - return trimmedEndpoint + trimmedPath; - } - - private static ApplicationAnalysisResponse ParseApplicationAnalysisResponse(string raw) - { - var response = new ApplicationAnalysisResponse(); - - if (!TryParseJsonObjectFromResponse(raw, out var root)) - { - return response; - } - - if (TryGetStringProperty(root, AIJsonKeys.Rating, out var rating)) - { - response.Rating = rating; - } - - if (root.TryGetProperty("errors", out var errors) && errors.ValueKind == JsonValueKind.Array) - { - response.Errors = ParseFindings(errors); - } - - if (root.TryGetProperty("warnings", out var warnings) && warnings.ValueKind == JsonValueKind.Array) - { - response.Warnings = ParseFindings(warnings); - } - - if (root.TryGetProperty(AIJsonKeys.Summaries, out var summaries) && summaries.ValueKind == JsonValueKind.Array) - { - response.Summaries = ParseFindings(summaries); - } - - if (root.TryGetProperty(AIJsonKeys.NextSteps, out var nextSteps) && nextSteps.ValueKind == JsonValueKind.Array) - { - response.NextSteps = ParseFindings(nextSteps); - } - - if (root.TryGetProperty(AIJsonKeys.Recommendation, out var recommendation) && recommendation.ValueKind == JsonValueKind.Object) - { - response.Recommendation = ParseRecommendation(recommendation); - } - - return response; - } - - private static bool TryGetStringProperty(JsonElement root, string propertyName, out string? value) - { - value = null; - if (!root.TryGetProperty(propertyName, out var property) || property.ValueKind != JsonValueKind.String) - { - return false; - } - - value = property.GetString(); - return !string.IsNullOrWhiteSpace(value); - } - - private static List ParseFindings(JsonElement array) - { - var findings = new List(); - foreach (var item in array.EnumerateArray()) - { - if (item.ValueKind != JsonValueKind.Object) - { - continue; - } - - var id = item.TryGetProperty(AIJsonKeys.Id, out var idProp) && idProp.ValueKind == JsonValueKind.String - ? idProp.GetString() - : null; - var hidden = item.TryGetProperty(AIJsonKeys.Hidden, out var hiddenProp) && - (hiddenProp.ValueKind == JsonValueKind.True || hiddenProp.ValueKind == JsonValueKind.False) && - hiddenProp.GetBoolean(); - string? title = null; - if (item.TryGetProperty(AIJsonKeys.Title, out var titleProp) && titleProp.ValueKind == JsonValueKind.String) - { - title = titleProp.GetString(); - } - - string? detail = null; - if (item.TryGetProperty(AIJsonKeys.Detail, out var detailProp) && detailProp.ValueKind == JsonValueKind.String) - { - detail = detailProp.GetString(); - } - - findings.Add(new ApplicationAnalysisFinding - { - Id = id, - Hidden = hidden, - Title = title, - Detail = detail - }); - } - - return findings; - } - - private static ApplicationAnalysisRecommendation? ParseRecommendation(JsonElement recommendation) - { - string? decision = null; - if (recommendation.TryGetProperty(AIJsonKeys.Decision, out var decisionProp) && - decisionProp.ValueKind == JsonValueKind.String) - { - decision = decisionProp.GetString(); - } - - string? rationale = null; - if (recommendation.TryGetProperty(AIJsonKeys.Rationale, out var rationaleProp) && - rationaleProp.ValueKind == JsonValueKind.String) - { - rationale = rationaleProp.GetString(); - } - - if (string.IsNullOrWhiteSpace(decision) && string.IsNullOrWhiteSpace(rationale)) - { - return null; - } - - return new ApplicationAnalysisRecommendation - { - Decision = decision, - Rationale = rationale - }; - } - - private static ApplicationScoringResponse ParseApplicationScoringResponse( - string raw, - IReadOnlyDictionary? questionIdAliasMap = null) - { - var response = new ApplicationScoringResponse(); - if (!TryParseJsonObjectFromResponse(raw, out var root)) - { - return response; - } - - foreach (var property in root.EnumerateObject()) - { - if (property.Value.ValueKind != JsonValueKind.Object) - { - continue; - } - - var answer = property.Value.TryGetProperty("answer", out var answerProp) - ? answerProp.Clone() - : default; - var rationale = property.Value.TryGetProperty("rationale", out var rationaleProp) && - rationaleProp.ValueKind == JsonValueKind.String - ? rationaleProp.GetString() ?? string.Empty - : string.Empty; - var confidence = property.Value.TryGetProperty("confidence", out var confidenceProp) && - confidenceProp.ValueKind == JsonValueKind.Number && - confidenceProp.TryGetInt32(out var parsedConfidence) - ? NormalizeConfidence(parsedConfidence) - : 0; - - var questionId = questionIdAliasMap != null && - questionIdAliasMap.TryGetValue(property.Name, out var originalQuestionId) - ? originalQuestionId - : property.Name; - - response.Answers[questionId] = new ApplicationScoringAnswer - { - Answer = answer, - Rationale = rationale, - Confidence = confidence - }; - } - - return response; - } - - private static int NormalizeConfidence(int confidence) - { - var clamped = Math.Clamp(confidence, 0, 100); - var rounded = (int)Math.Round(clamped / 5.0, MidpointRounding.AwayFromZero) * 5; - return Math.Clamp(rounded, 0, 100); - } - - private static string BuildApplicationScoringResponseTemplate(string sectionPayloadJson) - { - try - { - using var doc = JsonDocument.Parse(sectionPayloadJson); - if (!doc.RootElement.TryGetProperty("questions", out var questions) || questions.ValueKind != JsonValueKind.Array) - { - return "{}"; - } - - var template = new Dictionary(); - foreach (var question in questions.EnumerateArray()) - { - if (!question.TryGetProperty("id", out var idProp) || idProp.ValueKind != JsonValueKind.String) - { - continue; - } - - var questionId = idProp.GetString(); - if (string.IsNullOrWhiteSpace(questionId)) - { - continue; - } - - template[questionId] = new - { - answer = string.Empty, - rationale = string.Empty, - confidence = 0 - }; - } - - if (template.Count == 0) - { - return "{}"; - } - - return JsonSerializer.Serialize(template, JsonLogOptions); - } - catch (JsonException) - { - return "{}"; - } - } - - private static string BuildAliasedApplicationScoringSection( - string? sectionName, - string sectionJson, - out IReadOnlyDictionary questionIdAliasMap) - { - questionIdAliasMap = new Dictionary(StringComparer.Ordinal); - - if (string.IsNullOrWhiteSpace(sectionJson)) - { - return JsonSerializer.Serialize(new { name = sectionName, questions = sectionJson }, JsonLogOptions); - } - - try - { - using var sectionDoc = JsonDocument.Parse(sectionJson); - if (sectionDoc.RootElement.ValueKind != JsonValueKind.Array) - { - return JsonSerializer.Serialize(new { name = sectionName, questions = sectionDoc.RootElement.Clone() }, JsonLogOptions); - } - - var aliasedQuestions = new List>(); - var aliasMap = new Dictionary(StringComparer.Ordinal); - var index = 1; - - foreach (var question in sectionDoc.RootElement.EnumerateArray()) - { - if (question.ValueKind != JsonValueKind.Object) - { - continue; - } - - var aliasedQuestion = new Dictionary(StringComparer.Ordinal); - string? questionAlias = null; - - foreach (var property in question.EnumerateObject()) - { - if (property.NameEquals("id") && property.Value.ValueKind == JsonValueKind.String) - { - var originalQuestionId = property.Value.GetString(); - if (!string.IsNullOrWhiteSpace(originalQuestionId)) - { - questionAlias = $"q{index++}"; - aliasMap[questionAlias] = originalQuestionId; - aliasedQuestion[property.Name] = questionAlias; - continue; - } - } - - aliasedQuestion[property.Name] = property.Value.Clone(); - } - - if (!string.IsNullOrWhiteSpace(questionAlias)) - { - aliasedQuestions.Add(aliasedQuestion); - } - } - - questionIdAliasMap = aliasMap; - return JsonSerializer.Serialize(new { name = sectionName, questions = aliasedQuestions }, JsonLogOptions); - } - catch (JsonException) - { - return JsonSerializer.Serialize(new { name = sectionName, questions = sectionJson }, JsonLogOptions); - } - } - private async Task LogPromptInputAsync(string promptType, string promptVersion, string? systemPrompt, string userPrompt) { var formattedInput = FormatPromptInputForLog(systemPrompt, userPrompt); @@ -1335,255 +538,6 @@ private static bool TryParseJsonObjectFromResponse(string response, out JsonElem } } - private static string ResolvePromptVersion(string? version) - { - if (!string.IsNullOrWhiteSpace(version) && - PromptProfiles.TryGetValue(version.Trim(), out var selectedVersion)) - { - return selectedVersion; - } - - return PromptVersionV1; - } - - private static string BuildApplicationAnalysisSystemPrompt(string version) - { - return GetRequiredPromptTemplate(version, ApplicationAnalysisSystemTemplateName); - } - - private static string BuildApplicationAnalysisUserPrompt( - string version, - string schema, - string data, - string attachments) - { - var replacements = new Dictionary - { - ["SCHEMA"] = schema, - ["DATA"] = data, - ["ATTACHMENTS"] = attachments - }; - - return RenderPromptTemplate(version, ApplicationAnalysisUserTemplateName, replacements); - } - - private static string BuildAttachmentSummarySystemPrompt(string version) - { - return GetRequiredPromptTemplate(version, AttachmentSummarySystemTemplateName); - } - - private static string BuildAttachmentSummaryUserPrompt(string version, string attachment) - { - return RenderPromptTemplate(version, AttachmentSummaryUserTemplateName, new Dictionary - { - ["ATTACHMENT"] = attachment - }); - } - - private static string BuildApplicationScoringSystemPrompt(string version) - { - return GetRequiredPromptTemplate(version, ApplicationScoringSystemTemplateName); - } - - private static string BuildApplicationScoringUserPrompt( - string version, - string data, - string attachments, - string section, - string response) - { - return RenderPromptTemplate(version, ApplicationScoringUserTemplateName, new Dictionary - { - ["DATA"] = data, - ["ATTACHMENTS"] = attachments, - ["SECTION"] = section, - ["RESPONSE"] = response - }); - } - - private static bool TryGetPromptTemplate(string version, string templateName, out string template) - { - template = string.Empty; - var cacheKey = $"{version}/{templateName}"; - if (PromptTemplateCache.TryGetValue(cacheKey, out var cachedTemplate)) - { - template = cachedTemplate; - return true; - } - - var path = Path.Combine(AppContext.BaseDirectory, PromptTemplatesFolder, version, $"{templateName}.txt"); - if (!File.Exists(path)) - { - return false; - } - - var loaded = PromptTemplateCache.GetOrAdd(cacheKey, _ => File.ReadAllText(path)); - if (string.IsNullOrWhiteSpace(loaded)) - { - return false; - } - - template = loaded; - return true; - } - - private static string GetRequiredPromptTemplate(string version, string templateName) - { - if (TryGetPromptTemplate(version, templateName, out var template)) - { - return template; - } - - throw new InvalidOperationException( - $"Missing required prompt template '{templateName}.txt' for prompt version '{version}'."); - } - - private static string RenderPromptTemplate( - string version, - string templateName, - IReadOnlyDictionary runtimeReplacements) - { - return RenderPromptTemplateInternal( - version, - templateName, - runtimeReplacements, - new HashSet(StringComparer.OrdinalIgnoreCase)); - } - - private static string RenderPromptTemplateInternal( - string version, - string templateName, - IReadOnlyDictionary runtimeReplacements, - ISet resolutionStack) - { - if (!resolutionStack.Add(templateName)) - { - throw new InvalidOperationException( - $"Detected cyclic prompt fragment reference while resolving '{templateName}.txt' for prompt version '{version}'."); - } - - var template = GetRequiredPromptTemplate(version, templateName); - var replacements = new Dictionary(runtimeReplacements, StringComparer.Ordinal); - var baseTemplateName = GetTemplateBaseName(templateName); - - foreach (var placeholder in GetTemplatePlaceholders(template)) - { - if (replacements.ContainsKey(placeholder)) - { - continue; - } - - var fragmentTemplateName = ResolveFragmentTemplateName(version, baseTemplateName, placeholder); - if (!string.IsNullOrWhiteSpace(fragmentTemplateName)) - { - replacements[placeholder] = RenderPromptTemplateInternal( - version, - fragmentTemplateName, - new Dictionary(StringComparer.Ordinal), - resolutionStack).TrimEnd(); - } - } - - var rendered = template; - foreach (var replacement in replacements) - { - rendered = rendered.Replace($"{{{{{replacement.Key}}}}}", replacement.Value ?? string.Empty, StringComparison.Ordinal); - } - - var unresolved = GetTemplatePlaceholders(rendered); - if (unresolved.Count > 0) - { - throw new InvalidOperationException( - $"Unresolved prompt placeholders in '{templateName}.txt' for prompt version '{version}': {string.Join(", ", unresolved.OrderBy(item => item))}"); - } - - resolutionStack.Remove(templateName); - return rendered; - } - - private static string? ResolveFragmentTemplateName(string version, string baseTemplateName, string placeholderName) - { - var normalizedPlaceholder = placeholderName.ToLowerInvariant(); - var baseScopedCandidate = $"{baseTemplateName}.{normalizedPlaceholder}"; - if (TryGetPromptTemplate(version, baseScopedCandidate, out _)) - { - return baseScopedCandidate; - } - - if (string.Equals(placeholderName, "RESPONSE", StringComparison.Ordinal)) - { - var outputCandidate = $"{baseTemplateName}.output"; - if (TryGetPromptTemplate(version, outputCandidate, out _)) - { - return outputCandidate; - } - } - - if (TryResolveCommonTemplateName(placeholderName, out var commonTemplateName) && - TryGetPromptTemplate(version, commonTemplateName, out _)) - { - return commonTemplateName; - } - - return null; - } - - private static bool TryResolveCommonTemplateName(string placeholderName, out string commonTemplateName) - { - commonTemplateName = string.Empty; - if (!placeholderName.StartsWith("COMMON_", StringComparison.Ordinal)) - { - return false; - } - - var suffix = placeholderName.Substring("COMMON_".Length).ToLowerInvariant(); - suffix = suffix.Replace('_', '.'); - commonTemplateName = $"common.{suffix}"; - return true; - } - - private static string GetTemplateBaseName(string templateName) - { - var separatorIndex = templateName.IndexOf('.', StringComparison.Ordinal); - if (separatorIndex <= 0) - { - return templateName; - } - - return templateName.Substring(0, separatorIndex); - } - - private static HashSet GetTemplatePlaceholders(string template) - { - var placeholders = new HashSet(StringComparer.Ordinal); - var searchIndex = 0; - - while (searchIndex < template.Length) - { - var start = template.IndexOf("{{", searchIndex, StringComparison.Ordinal); - if (start < 0) - { - break; - } - - var end = template.IndexOf("}}", start + 2, StringComparison.Ordinal); - if (end < 0) - { - break; - } - - var placeholder = template.Substring(start + 2, end - start - 2).Trim(); - if (!string.IsNullOrWhiteSpace(placeholder)) - { - placeholders.Add(placeholder); - } - - searchIndex = end + 2; - } - - return placeholders; - } - private static string ExtractSummaryFromJson(string output) { if (!TryParseJsonObjectFromResponse(output, out var jsonObject)) diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAITransportService.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAITransportService.cs new file mode 100644 index 0000000000..374d0755e8 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAITransportService.cs @@ -0,0 +1,217 @@ +using Microsoft.Extensions.Logging; +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; +using Volo.Abp.DependencyInjection; + +namespace Unity.AI.Runtime; + +public class OpenAITransportService( + HttpClient httpClient, + OpenAIConfigurationResolver configurationResolver, + ILogger logger) : ITransientDependency +{ + private readonly HttpClient _httpClient = httpClient; + private readonly OpenAIConfigurationResolver _configurationResolver = configurationResolver; + private readonly ILogger _logger = logger; + + public async Task GenerateSummaryAsync( + string content, + string? systemPrompt, + int maxTokens = 150, + double? temperature = null, + string? operationName = null, + string? promptVersion = null, + string? fileName = null) + { + var providerName = _configurationResolver.ResolveProviderName(operationName); + if (!string.Equals(providerName, "OpenAI", StringComparison.Ordinal)) + { + _logger.LogWarning("Provider {ProviderName} is not supported by OpenAI transport.", providerName); + return AIOperationResult.PermanentFailure(new AIProviderResult($"Unsupported provider: {providerName}")); + } + + var apiKey = _configurationResolver.ResolveApiKey(operationName); + if (string.IsNullOrEmpty(apiKey)) + { + _logger.LogWarning("Error: OpenAI API key is not configured"); + return AIOperationResult.PermanentFailure(new AIProviderResult("OpenAI API key is not configured")); + } + + try + { + var resolvedSystemPrompt = string.IsNullOrWhiteSpace(systemPrompt) + ? "You are a professional grant analyst for the BC Government." + : systemPrompt; + + var requestPayload = new Dictionary + { + ["messages"] = new[] + { + new { role = "system", content = resolvedSystemPrompt }, + new { role = "user", content = content ?? string.Empty } + }, + [_configurationResolver.ResolveMaxTokensParameterNameForOperation(operationName)] = maxTokens + }; + + var resolvedTemperature = temperature ?? _configurationResolver.ResolveConfiguredTemperature(operationName); + if (resolvedTemperature.HasValue) + { + requestPayload["temperature"] = resolvedTemperature.Value; + } + + var json = JsonSerializer.Serialize(requestPayload); + var httpContent = new StringContent(json, Encoding.UTF8, "application/json"); + + using var request = new HttpRequestMessage(HttpMethod.Post, _configurationResolver.ResolveApiUrl(operationName)) + { + Content = httpContent + }; + request.Headers.TryAddWithoutValidation("Authorization", apiKey); + + var response = await _httpClient.SendAsync(request); + var responseContent = await response.Content.ReadAsStringAsync(); + var metadata = TryExtractProviderMetadata(responseContent); + var providerResponse = BuildProviderResponseFromMetadata( + string.Empty, + responseContent, + metadata, + (int)response.StatusCode); + + if (!response.IsSuccessStatusCode) + { + _logger.LogError("OpenAI API request failed: {StatusCode} - {Content}", response.StatusCode, responseContent); + return MapFailureOutcome(response.StatusCode, providerResponse); + } + + if (string.IsNullOrWhiteSpace(responseContent)) + { + return AIOperationResult.InvalidOutput(providerResponse); + } + + try + { + using var jsonDoc = JsonDocument.Parse(responseContent); + var choices = jsonDoc.RootElement.GetProperty("choices"); + if (choices.GetArrayLength() > 0) + { + var message = choices[0].GetProperty("message"); + var modelOutput = message.GetProperty("content").GetString(); + return string.IsNullOrWhiteSpace(modelOutput) + ? AIOperationResult.InvalidOutput(providerResponse) + : AIOperationResult.Success(BuildProviderResponseFromMetadata( + modelOutput, + responseContent, + metadata, + (int)response.StatusCode)); + } + + return AIOperationResult.InvalidOutput(providerResponse); + } + catch (Exception ex) when (ex is JsonException || ex is KeyNotFoundException || ex is InvalidOperationException) + { + _logger.LogWarning(ex, "AI response payload had an invalid output shape"); + return AIOperationResult.InvalidOutput(providerResponse); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error generating AI summary"); + return AIOperationResult.TransientFailure(new AIProviderResult(ex.Message)); + } + } + + private static AIOperationResult MapFailureOutcome(HttpStatusCode statusCode, AIProviderResult response) + { + var statusCodeValue = (int)statusCode; + if (statusCode == HttpStatusCode.RequestTimeout || statusCode == (HttpStatusCode)429 || statusCodeValue >= 500) + { + return AIOperationResult.TransientFailure(response); + } + + return AIOperationResult.PermanentFailure(response); + } + + private static AIProviderResult BuildProviderResponseFromMetadata( + string content, + string? rawResponse, + AIProviderResponseMetadata? metadata, + int? httpStatusCode = null) + { + return new AIProviderResult( + content, + rawResponse ?? string.Empty, + metadata?.Model, + metadata?.FinishReason, + httpStatusCode, + metadata?.PromptTokens, + metadata?.CompletionTokens, + metadata?.TotalTokens, + metadata?.ReasoningTokens); + } + + private static AIProviderResponseMetadata? TryExtractProviderMetadata(string? responseContent) + { + if (string.IsNullOrWhiteSpace(responseContent)) + { + return null; + } + + try + { + using var jsonDoc = JsonDocument.Parse(responseContent); + var root = jsonDoc.RootElement; + var model = root.TryGetProperty("model", out var modelProp) && modelProp.ValueKind == JsonValueKind.String + ? modelProp.GetString() + : null; + + string? finishReason = null; + if (root.TryGetProperty("choices", out var choices) + && choices.ValueKind == JsonValueKind.Array + && choices.GetArrayLength() > 0) + { + var firstChoice = choices[0]; + if (firstChoice.TryGetProperty("finish_reason", out var finishReasonProp) && finishReasonProp.ValueKind == JsonValueKind.String) + { + finishReason = finishReasonProp.GetString(); + } + } + + int? promptTokens = null; + int? completionTokens = null; + int? totalTokens = null; + int? reasoningTokens = null; + if (root.TryGetProperty("usage", out var usage) && usage.ValueKind == JsonValueKind.Object) + { + promptTokens = TryGetInt32(usage, "prompt_tokens"); + completionTokens = TryGetInt32(usage, "completion_tokens"); + totalTokens = TryGetInt32(usage, "total_tokens"); + + if (usage.TryGetProperty("completion_tokens_details", out var completionTokenDetails) + && completionTokenDetails.ValueKind == JsonValueKind.Object) + { + reasoningTokens = TryGetInt32(completionTokenDetails, "reasoning_tokens"); + } + } + + return new AIProviderResponseMetadata(model, finishReason, promptTokens, completionTokens, totalTokens, reasoningTokens); + } + catch (JsonException) + { + return null; + } + } + + private static int? TryGetInt32(JsonElement element, string propertyName) + { + return element.TryGetProperty(propertyName, out var prop) + && prop.ValueKind == JsonValueKind.Number + && prop.TryGetInt32(out var value) + ? value + : null; + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Attachments/AttachmentSummaryAppService.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Attachments/AttachmentSummaryAppService.cs index 681d7e55da..511669df41 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Attachments/AttachmentSummaryAppService.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Attachments/AttachmentSummaryAppService.cs @@ -1,10 +1,8 @@ using Microsoft.AspNetCore.Authorization; -using System; using System.Collections.Generic; -using System.Linq; using System.Threading.Tasks; using Unity.AI; -using Unity.AI.Automation; +using Unity.AI.Operations; using Unity.AI.Permissions; using Volo.Abp; using Volo.Abp.DependencyInjection; @@ -13,27 +11,23 @@ namespace Unity.GrantManager.Attachments; [Authorize(AIPermissions.Analysis.ViewAttachmentSummary)] -[Dependency(ReplaceServices = true)] [ExposeServices(typeof(AttachmentSummaryAppService), typeof(IAttachmentSummaryAppService))] public class AttachmentSummaryAppService( - IApplicationAIGenerationQueue aiGenerationQueue, + IAttachmentSummaryService attachmentSummaryService, IFeatureChecker featureChecker) : AIAppService, IAttachmentSummaryAppService { - private const string SummaryGenerationQueuedMessage = "AI summary generation queued."; - - public async Task GenerateAttachmentSummaryAsync(Guid attachmentId, string? promptVersion = null) + public async Task GenerateAttachmentSummaryAsync(System.Guid attachmentId, string? promptVersion = null) { if (!await featureChecker.IsEnabledAsync("Unity.AI.AttachmentSummaries")) { throw new UserFriendlyException("AI attachment summaries are not enabled."); } - await aiGenerationQueue.QueueAttachmentSummariesAsync([attachmentId], CurrentTenant.Id, promptVersion); - - return SummaryGenerationQueuedMessage; + await attachmentSummaryService.GenerateAndSaveAsync(attachmentId, promptVersion); + return new AttachmentSummaryResultDto { Completed = true }; } - public async Task> GenerateAttachmentSummariesAsync(List attachmentIds, string? promptVersion = null) + public async Task> GenerateAttachmentSummariesAsync(List attachmentIds, string? promptVersion = null) { if (!await featureChecker.IsEnabledAsync("Unity.AI.AttachmentSummaries")) { @@ -45,8 +39,13 @@ public async Task> GenerateAttachmentSummariesAsync(List atta return []; } - await aiGenerationQueue.QueueAttachmentSummariesAsync(attachmentIds, CurrentTenant.Id, promptVersion); + var results = new List(); + foreach (var attachmentId in attachmentIds) + { + await attachmentSummaryService.GenerateAndSaveAsync(attachmentId, promptVersion); + results.Add(new AttachmentSummaryResultDto { Completed = true }); + } - return attachmentIds.Select(_ => SummaryGenerationQueuedMessage).ToList(); + return results; } } diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/DataSeed/AIPromptDataSeeder.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/DataSeed/AIPromptDataSeeder.cs index 05f5a784eb..58859a5e83 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/DataSeed/AIPromptDataSeeder.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/DataSeed/AIPromptDataSeeder.cs @@ -235,7 +235,7 @@ OPTIONAL FIELDS (may be left blank): OUTPUT { - "rating": "", + "decision": "", "warnings": [ { "title": "", @@ -254,24 +254,19 @@ OPTIONAL FIELDS (may be left blank): "detail": "" } ], - "nextSteps": [ + "recommendations": [ { "title": "", "detail": "" } - ], - "recommendation": { - "decision": "", - "rationale": "" - } + ] } Important: - Use only APPLICATION CONTENT, ATTACHMENT SUMMARIES, FORM FIELD CONFIGURATION, and EVALUATION RUBRIC as evidence. + - decision must be PROCEED or HOLD. - Use summaries for overall application quality/readiness synthesis. - - Use nextSteps for reviewer-facing follow-up actions or considerations before scoring or decision-making. - - recommendation.decision must be PROCEED or HOLD. - - recommendation.rationale must explain the high-level recommendation in 1-2 complete sentences using provided evidence. + - Use recommendations for reviewer-facing follow-up actions or considerations before scoring or decision-making. - Use "title" and "detail" keys for all finding objects. - Return valid plain JSON only in the exact OUTPUT shape. """; @@ -333,7 +328,7 @@ 4. Return only the strongest evidence-backed reviewer conclusions. // ── v1/analysis.output.txt ─────────────────────────────────────────────── private const string AnalysisOutput = """ { - "rating": "", + "decision": "", "errors": [ { "title": "", @@ -352,16 +347,12 @@ 4. Return only the strongest evidence-backed reviewer conclusions. "detail": "" } ], - "nextSteps": [ + "recommendations": [ { "title": "", "detail": "" } - ], - "recommendation": { - "decision": "", - "rationale": "" - } + ] } """; @@ -388,20 +379,16 @@ 4. Return only the strongest evidence-backed reviewer conclusions. - Use 3-6 words for title. - Summary titles should name the specific substantive reviewer conclusion, strength, or risk, not a generic evaluation label or abstract category. - Each detail must be 1-2 complete sentences. - - Summaries and nextSteps must be concrete, distinct, reviewer-relevant, and specific to this application's evidence. + - Summaries and recommendations must be concrete, distinct, reviewer-relevant, and specific to this application's evidence. - Avoid generic praise, checklist language, and repeated conclusions across lists. - Do not use a summary merely to say that supporting documents were provided; summarize the specific substantive evidence they add, or omit the finding. - If no findings exist, return empty arrays. - - Rating must be HIGH, MEDIUM, or LOW. + - Decision must be PROCEED or HOLD. - Use summaries for overall application quality/readiness synthesis. - - Use nextSteps for concrete reviewer-facing next actions based on the provided evidence. - - nextSteps may include proceeding with the normal review process when the application appears ready for that step. - - When evidence shows a meaningful gap, inconsistency, or uncertainty, use nextSteps for specific follow-up or verification actions. + - Use recommendations for concrete reviewer-facing next actions based on the provided evidence. + - Recommendations may include proceeding with the normal review process when the application appears ready for that step. + - When evidence shows a meaningful gap, inconsistency, or uncertainty, use recommendations for specific follow-up or verification actions. - Return an empty array only when no concrete next action would help the reviewer. - - recommendation.decision must be PROCEED or HOLD. - - Use HOLD only when provided evidence shows a material eligibility, feasibility, budget, or readiness concern that would reasonably block scoring or decision-making. - - recommendation.rationale must explain the high-level recommendation in 1-2 complete sentences using provided evidence. - - recommendation.rationale should name the 1-3 strongest evidence-based reasons for the recommendation. """; // ── v0/attachment.system.txt ───────────────────────────────────────────── diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/GrantApplications/ApplicationAnalysisAppService.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/GrantApplications/ApplicationAnalysisAppService.cs index 483a30c8df..84a88ff8ed 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/GrantApplications/ApplicationAnalysisAppService.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/GrantApplications/ApplicationAnalysisAppService.cs @@ -1,10 +1,9 @@ using Microsoft.AspNetCore.Authorization; -using Microsoft.Extensions.Logging; using System; using System.Threading.Tasks; using Unity.AI; +using Unity.AI.Operations; using Unity.AI.Permissions; -using Unity.AI.Automation; using Volo.Abp; using Volo.Abp.Features; @@ -12,27 +11,18 @@ namespace Unity.GrantManager.GrantApplications; [Authorize(AIPermissions.Analysis.ViewApplicationAnalysis)] public class ApplicationAnalysisAppService( - IApplicationAIGenerationQueue aiGenerationQueue, + IApplicationAnalysisService applicationAnalysisService, IFeatureChecker featureChecker) : AIAppService, IApplicationAnalysisAppService { - public async Task GenerateApplicationAnalysisAsync(Guid applicationId, string? promptVersion = null) + public async Task GenerateApplicationAnalysisAsync(Guid applicationId, string? promptVersion = null) { - try + if (!await featureChecker.IsEnabledAsync("Unity.AI.ApplicationAnalysis")) { - if (!await featureChecker.IsEnabledAsync("Unity.AI.ApplicationAnalysis")) - { - throw new UserFriendlyException("AI application analysis is not enabled."); - } - - await aiGenerationQueue.QueueApplicationAnalysisAsync(applicationId, CurrentTenant.Id, promptVersion); - - return "{}"; - } - catch (Exception ex) - { - Logger.LogError(ex, "Error queueing AI analysis for application {ApplicationId}", applicationId); - throw new UserFriendlyException("Failed to queue AI analysis. Please try again."); + throw new UserFriendlyException("AI application analysis is not enabled."); } + + await applicationAnalysisService.RegenerateAndSaveAsync(applicationId, promptVersion); + return new ApplicationAnalysisResultDto { Completed = true }; } } diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/GrantApplications/ApplicationContentAppService.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/GrantApplications/ApplicationContentAppService.cs index d19f82767d..cea61ecee7 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/GrantApplications/ApplicationContentAppService.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/GrantApplications/ApplicationContentAppService.cs @@ -1,5 +1,4 @@ using Microsoft.AspNetCore.Authorization; -using Microsoft.Extensions.Logging; using System; using System.Threading.Tasks; using Unity.AI; @@ -7,6 +6,7 @@ using Unity.AI.Permissions; using Volo.Abp; using Volo.Abp.Features; +using Volo.Abp.MultiTenancy; namespace Unity.GrantManager.GrantApplications; @@ -15,30 +15,26 @@ namespace Unity.GrantManager.GrantApplications; [Authorize(AIPermissions.Analysis.ViewScoringResult)] public class ApplicationContentAppService( IApplicationAIGenerationQueue aiGenerationQueue, - IFeatureChecker featureChecker) + IFeatureChecker featureChecker, + ICurrentTenant currentTenant) : AIAppService, IApplicationContentAppService { - public async Task GenerateContentAsync(Guid applicationId, string? promptVersion = null) + public async Task GenerateContentAsync(Guid applicationId, string? promptVersion = null) { - try - { - var attachmentSummariesEnabled = await featureChecker.IsEnabledAsync("Unity.AI.AttachmentSummaries"); - var applicationAnalysisEnabled = await featureChecker.IsEnabledAsync("Unity.AI.ApplicationAnalysis"); - var scoringEnabled = await featureChecker.IsEnabledAsync("Unity.AI.Scoring"); + var attachmentSummariesEnabled = await featureChecker.IsEnabledAsync("Unity.AI.AttachmentSummaries"); + var applicationAnalysisEnabled = await featureChecker.IsEnabledAsync("Unity.AI.ApplicationAnalysis"); + var scoringEnabled = await featureChecker.IsEnabledAsync("Unity.AI.Scoring"); - if (!attachmentSummariesEnabled || !applicationAnalysisEnabled || !scoringEnabled) - { - throw new UserFriendlyException("AI generate all is not enabled."); - } + if (!attachmentSummariesEnabled || !applicationAnalysisEnabled || !scoringEnabled) + { + throw new UserFriendlyException("AI generate all is not enabled."); + } - await aiGenerationQueue.QueueApplicationPipelineAsync(applicationId, CurrentTenant.Id, promptVersion); + await aiGenerationQueue.QueueApplicationPipelineAsync(applicationId, currentTenant.Id, promptVersion); - return "{}"; - } - catch (Exception ex) + return new ApplicationContentResultDto { - Logger.LogError(ex, "Error queueing full AI content pipeline for application {ApplicationId}", applicationId); - throw new UserFriendlyException("Failed to queue AI generate all. Please try again."); - } + Completed = true + }; } } diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/GrantApplications/ApplicationScoringAppService.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/GrantApplications/ApplicationScoringAppService.cs index d42a95f206..811295d4dd 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/GrantApplications/ApplicationScoringAppService.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/GrantApplications/ApplicationScoringAppService.cs @@ -1,9 +1,8 @@ using Microsoft.AspNetCore.Authorization; -using Microsoft.Extensions.Logging; using System; using System.Threading.Tasks; using Unity.AI; -using Unity.AI.Automation; +using Unity.AI.Operations; using Unity.AI.Permissions; using Volo.Abp; using Volo.Abp.Features; @@ -12,27 +11,21 @@ namespace Unity.GrantManager.GrantApplications; [Authorize(AIPermissions.Analysis.ViewScoringResult)] public class ApplicationScoringAppService( - IApplicationAIGenerationQueue aiGenerationQueue, + IApplicationScoringService applicationScoringService, IFeatureChecker featureChecker) : AIAppService, IApplicationScoringAppService { - public async Task GenerateApplicationScoringAsync(Guid applicationId, string? promptVersion = null) + public async Task GenerateApplicationScoringAsync(Guid applicationId, string? promptVersion = null) { - try + if (!await featureChecker.IsEnabledAsync("Unity.AI.Scoring")) { - if (!await featureChecker.IsEnabledAsync("Unity.AI.Scoring")) - { - throw new UserFriendlyException("AI scoring is not enabled."); - } - - await aiGenerationQueue.QueueApplicationScoringAsync(applicationId, CurrentTenant.Id, promptVersion); - - return "{}"; + throw new UserFriendlyException("AI scoring is not enabled."); } - catch (Exception ex) + + await applicationScoringService.RegenerateAndSaveAsync(applicationId, promptVersion); + return new ApplicationScoringResultDto { - Logger.LogError(ex, "Error queueing AI application scoring generation for application {ApplicationId}", applicationId); - throw new UserFriendlyException("Failed to queue AI application scoring generation. Please try again."); - } + Completed = true + }; } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/IGrantApplicationAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/IGrantApplicationAppService.cs index 2491deb1c9..953a10a65d 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/IGrantApplicationAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/IGrantApplicationAppService.cs @@ -19,8 +19,8 @@ public interface IGrantApplicationAppService Task GetAsync(Guid id); Task TriggerAction(Guid applicationId, GrantApplicationAction triggerAction); Task GetAccountCodingIdFromFormIdAsync(Guid formId); - Task HideAIAnalysisItemAsync(Guid applicationId, string itemId); - Task ShowAIAnalysisItemAsync(Guid applicationId, string itemId); + Task DismissAIAnalysisItemAsync(Guid applicationId, string itemId); + Task RestoreAIAnalysisItemAsync(Guid applicationId, string itemId); Task> GetListAsync(GrantApplicationListInputDto input); Task IsApplicantRedStopAsync(Guid applicationId); } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/AddressInfoDataProvider.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/AddressInfoDataProvider.cs index 8139d4b390..adae496d40 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/AddressInfoDataProvider.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/AddressInfoDataProvider.cs @@ -18,7 +18,8 @@ namespace Unity.GrantManager.ApplicantProfile /// Addresses are resolved via both the ApplicationId and ApplicantId /// relationships, with duplicates removed. Addresses linked via /// ApplicationId are always read-only. Addresses linked via ApplicantId - /// are editable only when that set resolves to a single ApplicantId. + /// are editable only when they have no ApplicationId and the subject's + /// form submissions resolve to a single ApplicantId. /// [ExposeServices(typeof(IApplicantProfileDataProvider))] public class AddressInfoDataProvider( @@ -56,7 +57,14 @@ public async Task GetDataAsync(ApplicantProfileInfoRequ from submission in matchingSubmissions join address in addressesQuery on submission.ApplicationId equals address.ApplicationId join application in applicationsQuery on address.ApplicationId equals application.Id - select new { address, address.CreationTime, application.ReferenceNo, IsFromApplicantPath = false, address.ApplicantId }; + select new + { + address, + address.CreationTime, + application.ReferenceNo, + IsFromApplicantPath = false, + address.ApplicantId + }; // Addresses linked via ApplicantId — conditionally editable var byApplicantId = @@ -64,8 +72,15 @@ from submission in matchingSubmissions join address in addressesQuery on submission.ApplicantId equals address.ApplicantId join application in applicationsQuery on address.ApplicationId equals application.Id into apps from application in apps.DefaultIfEmpty() - select new { address, address.CreationTime, ReferenceNo = application != null ? application.ReferenceNo : null, IsFromApplicantPath = true, address.ApplicantId }; - + select new + { + address, + address.CreationTime, + ReferenceNo = application != null ? application.ReferenceNo : null, + IsFromApplicantPath = true, + address.ApplicantId + }; + var results = await byApplicationId .Concat(byApplicantId) .ToListAsync(); @@ -76,13 +91,13 @@ from application in apps.DefaultIfEmpty() .Select(g => g.OrderBy(r => r.IsFromApplicantPath).First()) .ToList(); - // Addresses from the ApplicantId path are editable only when - // that path resolves to a single ApplicantId - var applicantPathEditable = results - .Where(r => r.IsFromApplicantPath && r.ApplicantId != null) - .Select(r => r.ApplicantId) + // Determine editability from submissions, not addresses + var distinctApplicantIds = await matchingSubmissions + .Select(s => s.ApplicantId) .Distinct() - .Count() <= 1; + .ToListAsync(); + + var distinctApplicants = distinctApplicantIds.Count > 1; var addressDtos = deduplicated.Select(r => new AddressInfoItemDto { @@ -96,7 +111,7 @@ from application in apps.DefaultIfEmpty() PostalCode = r.address.Postal ?? string.Empty, Country = r.address.Country ?? string.Empty, IsPrimary = r.address.HasProperty(AddressExtraPropertyNames.IsPrimary) && r.address.GetProperty(AddressExtraPropertyNames.IsPrimary), - IsEditable = r.IsFromApplicantPath && applicantPathEditable, + IsEditable = r.IsFromApplicantPath && !distinctApplicants, ReferenceNo = r.ReferenceNo }).ToList(); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/AIOperationServices.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/AIOperationServices.cs deleted file mode 100644 index 2446f07e8e..0000000000 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/AIOperationServices.cs +++ /dev/null @@ -1,17 +0,0 @@ -using Unity.AI; -using Unity.AI.Operations; -using Volo.Abp.DependencyInjection; - -namespace Unity.GrantManager.GrantApplications.Automation.BackgroundJobs; - -/// -/// Aggregates the four AI operation services used by . -/// Introduced to keep the constructor within SonarQube's 7-parameter limit (S107) -/// while preserving explicit, testable constructor injection for each dependency. -/// Implements so ABP's DI scanning registers it in the container. -/// -public record AIOperationServices( - IAttachmentSummaryService AttachmentSummary, - IApplicationAnalysisService ApplicationAnalysis, - IApplicationScoringService ApplicationScoring, - IAIService AI) : ITransientDependency; diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/ApplicationFormServices.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/ApplicationFormServices.cs deleted file mode 100644 index 0876669358..0000000000 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/ApplicationFormServices.cs +++ /dev/null @@ -1,13 +0,0 @@ -using Unity.GrantManager.Applications; -using Volo.Abp.DependencyInjection; - -namespace Unity.GrantManager.GrantApplications.Automation.BackgroundJobs; - -/// -/// Aggregates the application and form repositories used by . -/// Introduced to keep the constructor within SonarQube's 7-parameter limit (S107). -/// Implements so ABP's DI scanning registers it in the container. -/// -public record ApplicationFormServices( - IApplicationRepository Application, - IApplicationFormRepository ApplicationForm) : ITransientDependency; diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateApplicationAnalysisJob.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateApplicationAnalysisJob.cs index 703093123a..c3d3448687 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateApplicationAnalysisJob.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateApplicationAnalysisJob.cs @@ -1,12 +1,12 @@ using Microsoft.Extensions.Logging; using System.Threading.Tasks; -using Unity.AI.Operations; +using Unity.GrantManager.GrantApplications; using Volo.Abp.BackgroundJobs; using Volo.Abp.DependencyInjection; using Volo.Abp.MultiTenancy; namespace Unity.GrantManager.GrantApplications.Automation.BackgroundJobs; public class GenerateApplicationAnalysisJob( - IApplicationAnalysisService applicationAnalysisService, + IApplicationAnalysisAppService applicationAnalysisService, ICurrentTenant currentTenant, ILogger logger) : AsyncBackgroundJob, ITransientDependency { @@ -15,7 +15,11 @@ public override async Task ExecuteAsync(GenerateApplicationAnalysisBackgroundJob using (currentTenant.Change(args.TenantId)) { logger.LogInformation("Executing AI application analysis job for application {ApplicationId}.", args.ApplicationId); - await applicationAnalysisService.RegenerateAndSaveAsync(args.ApplicationId, args.PromptVersion); + var result = await applicationAnalysisService.GenerateApplicationAnalysisAsync(args.ApplicationId, args.PromptVersion); + if (result.Completed) + { + logger.LogInformation("Completed AI application analysis job for application {ApplicationId}.", args.ApplicationId); + } } } -} \ No newline at end of file +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateApplicationScoringJob.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateApplicationScoringJob.cs index e57e157ac0..61d87d63a7 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateApplicationScoringJob.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateApplicationScoringJob.cs @@ -1,7 +1,6 @@ using Microsoft.Extensions.Logging; -using System; using System.Threading.Tasks; -using Unity.AI.Operations; +using Unity.GrantManager.GrantApplications; using Unity.GrantManager.GrantApplications.Automation.Events; using Volo.Abp.BackgroundJobs; using Volo.Abp.DependencyInjection; @@ -9,7 +8,7 @@ using Volo.Abp.MultiTenancy; namespace Unity.GrantManager.GrantApplications.Automation.BackgroundJobs; public class GenerateApplicationScoringJob( - IApplicationScoringService applicationScoringService, + IApplicationScoringAppService applicationScoringService, ILocalEventBus localEventBus, ICurrentTenant currentTenant, ILogger logger) : AsyncBackgroundJob, ITransientDependency @@ -19,8 +18,8 @@ public override async Task ExecuteAsync(GenerateApplicationScoringBackgroundJobA using (currentTenant.Change(args.TenantId)) { logger.LogInformation("Executing AI application scoring job for application {ApplicationId}.", args.ApplicationId); - var result = await applicationScoringService.RegenerateAndSaveAsync(args.ApplicationId, args.PromptVersion); - if (!string.Equals(result, "{}", StringComparison.Ordinal)) + var result = await applicationScoringService.GenerateApplicationScoringAsync(args.ApplicationId, args.PromptVersion); + if (result.Completed) { await localEventBus.PublishAsync(new ApplicationAIScoringGeneratedEvent { @@ -29,4 +28,4 @@ await localEventBus.PublishAsync(new ApplicationAIScoringGeneratedEvent } } } -} \ No newline at end of file +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateAttachmentSummaryJob.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateAttachmentSummaryJob.cs index 21a9a7450a..c47c667005 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateAttachmentSummaryJob.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/GenerateAttachmentSummaryJob.cs @@ -1,12 +1,12 @@ using Microsoft.Extensions.Logging; using System.Threading.Tasks; -using Unity.AI.Operations; +using Unity.GrantManager.Attachments; using Volo.Abp.BackgroundJobs; using Volo.Abp.DependencyInjection; using Volo.Abp.MultiTenancy; namespace Unity.GrantManager.GrantApplications.Automation.BackgroundJobs; public class GenerateAttachmentSummaryJob( - IAttachmentSummaryService attachmentSummaryService, + IAttachmentSummaryAppService attachmentSummaryService, ICurrentTenant currentTenant, ILogger logger) : AsyncBackgroundJob, ITransientDependency { @@ -17,7 +17,8 @@ public override async Task ExecuteAsync(GenerateAttachmentSummaryBackgroundJobAr logger.LogInformation( "Executing AI attachment summary job for {AttachmentCount} attachment(s).", args.AttachmentIds.Count); - await attachmentSummaryService.GenerateAndSaveAsync(args.AttachmentIds, args.PromptVersion); + var results = await attachmentSummaryService.GenerateAttachmentSummariesAsync(args.AttachmentIds, args.PromptVersion); + logger.LogInformation("Completed AI attachment summaries for {CompletedCount} attachment(s).", results.Count); } } -} \ No newline at end of file +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/RunApplicationAIPipelineJob.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/RunApplicationAIPipelineJob.cs index 86cae4be34..9c06a650f5 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/RunApplicationAIPipelineJob.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/BackgroundJobs/RunApplicationAIPipelineJob.cs @@ -1,71 +1,67 @@ using Microsoft.Extensions.Logging; using System; +using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; -using Unity.AI.Settings; +using Unity.GrantManager.Applications; +using Unity.GrantManager.Attachments; +using Unity.GrantManager.GrantApplications; using Unity.GrantManager.GrantApplications.Automation.Events; using Volo.Abp.BackgroundJobs; using Volo.Abp.DependencyInjection; using Volo.Abp.EventBus.Local; using Volo.Abp.Features; using Volo.Abp.MultiTenancy; -using Volo.Abp.Settings; + namespace Unity.GrantManager.GrantApplications.Automation.BackgroundJobs; + public class RunApplicationAIPipelineJob( - AIOperationServices aiOperationServices, - ApplicationFormServices applicationFormServices, + IApplicationChefsFileAttachmentRepository applicationChefsFileAttachmentRepository, + IAttachmentSummaryAppService attachmentSummaryAppService, + IApplicationAnalysisAppService applicationAnalysisAppService, + IApplicationScoringAppService applicationScoringAppService, IFeatureChecker featureChecker, ILocalEventBus localEventBus, ICurrentTenant currentTenant, - ISettingProvider settingProvider, ILogger logger) : AsyncBackgroundJob, ITransientDependency { public override async Task ExecuteAsync(RunApplicationAIPipelineJobArgs args) { using (currentTenant.Change(args.TenantId)) { - var automaticGenerationEnabled = await settingProvider.GetAsync( - AISettings.AutomaticGenerationEnabled, defaultValue: false); - - if (!automaticGenerationEnabled) - { - logger.LogDebug("Automatic AI generation is disabled at tenant level, skipping intake pipeline for application {ApplicationId}.", args.ApplicationId); - return; - } - - var application = await applicationFormServices.Application.GetAsync(args.ApplicationId); - var applicationForm = await applicationFormServices.ApplicationForm.GetAsync(application.ApplicationFormId); - - if (!applicationForm.AutomaticallyGenerateAIAnalysis) - { - logger.LogDebug("Automatic AI analysis is disabled at form level for application {ApplicationId}, skipping intake pipeline.", args.ApplicationId); - return; - } - var attachmentSummariesEnabled = await featureChecker.IsEnabledAsync("Unity.AI.AttachmentSummaries"); var applicationAnalysisEnabled = await featureChecker.IsEnabledAsync("Unity.AI.ApplicationAnalysis"); var scoringEnabled = await featureChecker.IsEnabledAsync("Unity.AI.Scoring"); + if (!attachmentSummariesEnabled && !applicationAnalysisEnabled && !scoringEnabled) { logger.LogDebug("All AI features are disabled, skipping queued AI generation for application {ApplicationId}.", args.ApplicationId); return; } - if (!await aiOperationServices.AI.IsAvailableAsync()) - { - logger.LogWarning("AI service is not available, skipping queued AI generation for application {ApplicationId}.", args.ApplicationId); - return; - } + logger.LogInformation("Executing queued AI content pipeline for application {ApplicationId}.", args.ApplicationId); + if (attachmentSummariesEnabled) { - await aiOperationServices.AttachmentSummary.GenerateForApplicationAsync(args.ApplicationId, args.PromptVersion); + var attachmentResults = await attachmentSummaryAppService.GenerateAttachmentSummariesAsync( + await GetAttachmentIdsAsync(args.ApplicationId), + args.PromptVersion); + + logger.LogInformation("Completed AI attachment summaries for application {ApplicationId} with {AttachmentCount} result(s).", args.ApplicationId, attachmentResults.Count); } + Exception? analysisException = null; Exception? scoringException = null; + if (applicationAnalysisEnabled) { try { - await aiOperationServices.ApplicationAnalysis.RegenerateAndSaveAsync(args.ApplicationId, args.PromptVersion); + var analysisResult = await applicationAnalysisAppService.GenerateApplicationAnalysisAsync(args.ApplicationId, args.PromptVersion); + if (analysisResult.Completed) + { + logger.LogInformation("Completed AI application analysis stage for application {ApplicationId}.", args.ApplicationId); + } } catch (Exception ex) { @@ -73,12 +69,13 @@ public override async Task ExecuteAsync(RunApplicationAIPipelineJobArgs args) logger.LogError(ex, "Error executing AI application analysis stage for application {ApplicationId}.", args.ApplicationId); } } + if (scoringEnabled) { try { - var result = await aiOperationServices.ApplicationScoring.RegenerateAndSaveAsync(args.ApplicationId, args.PromptVersion); - if (!string.Equals(result, "{}", StringComparison.Ordinal)) + var result = await applicationScoringAppService.GenerateApplicationScoringAsync(args.ApplicationId, args.PromptVersion); + if (result.Completed) { await localEventBus.PublishAsync(new ApplicationAIScoringGeneratedEvent { @@ -92,14 +89,22 @@ await localEventBus.PublishAsync(new ApplicationAIScoringGeneratedEvent logger.LogError(ex, "Error executing AI application scoring stage for application {ApplicationId}.", args.ApplicationId); } } + if (scoringException != null) { throw scoringException; } + if (analysisException != null) { throw analysisException; } } } + + private async Task> GetAttachmentIdsAsync(Guid applicationId) + { + var attachments = await applicationChefsFileAttachmentRepository.GetListAsync(a => a.ApplicationId == applicationId); + return attachments.Select(a => a.Id).ToList(); + } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/GrantApplicationAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/GrantApplicationAppService.cs index 89761862d0..b05dfa0bbf 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/GrantApplicationAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/GrantApplicationAppService.cs @@ -1072,22 +1072,22 @@ private static Dictionary ExtractCustomFieldsForWorksheet(dynami return result; } - public async Task HideAIAnalysisItemAsync(Guid applicationId, string itemId) - { - return await UpdateAIAnalysisItemVisibilityStateAsync(applicationId, itemId, isHidden: true); - } - - public async Task ShowAIAnalysisItemAsync(Guid applicationId, string itemId) - { - return await UpdateAIAnalysisItemVisibilityStateAsync(applicationId, itemId, isHidden: false); - } - - private async Task UpdateAIAnalysisItemVisibilityStateAsync(Guid applicationId, string itemId, bool isHidden) - { - if (string.IsNullOrWhiteSpace(itemId)) - { - throw new UserFriendlyException("AI analysis item id is required."); - } + public async Task DismissAIAnalysisItemAsync(Guid applicationId, string itemId) + { + return await UpdateAIAnalysisItemDismissedStateAsync(applicationId, itemId, isDismissed: true); + } + + public async Task RestoreAIAnalysisItemAsync(Guid applicationId, string itemId) + { + return await UpdateAIAnalysisItemDismissedStateAsync(applicationId, itemId, isDismissed: false); + } + + private async Task UpdateAIAnalysisItemDismissedStateAsync(Guid applicationId, string itemId, bool isDismissed) + { + if (string.IsNullOrWhiteSpace(itemId)) + { + throw new UserFriendlyException("AI analysis item id is required."); + } var application = await applicationRepository.GetAsync(applicationId); @@ -1095,67 +1095,67 @@ private async Task UpdateAIAnalysisItemVisibilityStateAsync(Guid applica { throw new UserFriendlyException("No AI analysis available for this application."); } - - try - { - var updatedAnalysis = SetAnalysisItemHiddenState(application.AIAnalysis, itemId, isHidden); - application.AIAnalysis = updatedAnalysis; - await applicationRepository.UpdateAsync(application); - return updatedAnalysis; - } - catch (Exception ex) - { - var action = isHidden ? "hiding" : "showing"; - var userMessage = isHidden - ? "Failed to hide the AI item. Please try again." - : "Failed to show the AI item. Please try again."; + + try + { + var updatedAnalysis = SetAnalysisItemDismissedState(application.AIAnalysis, itemId, isDismissed); + application.AIAnalysis = updatedAnalysis; + await applicationRepository.UpdateAsync(application); + return updatedAnalysis; + } + catch (Exception ex) + { + var action = isDismissed ? "dismissing" : "restoring"; + var userMessage = isDismissed + ? "Failed to dismiss the AI item. Please try again." + : "Failed to restore the AI item. Please try again."; Logger.LogError(ex, "Error {Action} AI analysis item {ItemId} for application {ApplicationId}", action, itemId, applicationId); throw new UserFriendlyException(userMessage); - } - } - - private static string SetAnalysisItemHiddenState(string analysisJson, string itemId, bool isHidden) - { - if (string.IsNullOrWhiteSpace(analysisJson)) - { - return analysisJson; - } + } + } + + private static string SetAnalysisItemDismissedState(string analysisJson, string itemId, bool isDismissed) + { + if (string.IsNullOrWhiteSpace(analysisJson)) + { + return analysisJson; + } try { var analysis = System.Text.Json.JsonSerializer.Deserialize(analysisJson, AiAnalysisReadOptions); - if (analysis == null) - { - return analysisJson; - } - - UpdateFindingHiddenState(analysis.Errors, itemId, isHidden); - UpdateFindingHiddenState(analysis.Warnings, itemId, isHidden); - UpdateFindingHiddenState(analysis.Summaries, itemId, isHidden); - UpdateFindingHiddenState(analysis.NextSteps, itemId, isHidden); - - return System.Text.Json.JsonSerializer.Serialize(analysis, AiAnalysisWriteOptions); - } - catch (System.Text.Json.JsonException) - { - return analysisJson; - } - } - - private static void UpdateFindingHiddenState(IEnumerable findings, string itemId, bool isHidden) - { - foreach (var finding in findings) - { - if (!string.Equals(finding.Id, itemId, StringComparison.Ordinal)) - { - continue; - } - - finding.Hidden = isHidden; - return; - } - } + if (analysis == null) + { + return analysisJson; + } + + UpdateFindingDismissedState(analysis.Errors, itemId, isDismissed); + UpdateFindingDismissedState(analysis.Warnings, itemId, isDismissed); + UpdateFindingDismissedState(analysis.Summaries, itemId, isDismissed); + UpdateFindingDismissedState(analysis.Recommendations, itemId, isDismissed); + + return System.Text.Json.JsonSerializer.Serialize(analysis, AiAnalysisWriteOptions); + } + catch (System.Text.Json.JsonException) + { + return analysisJson; + } + } + + private static void UpdateFindingDismissedState(IEnumerable findings, string itemId, bool isDismissed) + { + foreach (var finding in findings) + { + if (!string.Equals(finding.Id, itemId, StringComparison.Ordinal)) + { + continue; + } + + finding.Dismissed = isDismissed; + return; + } + } private static ApplicationAnalysisResponse? ParseAiAnalysisData(string? analysisJson) { diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/ContactCreateHandler.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/ContactCreateHandler.cs index 769e4b1135..8a8368ef26 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/ContactCreateHandler.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/ContactCreateHandler.cs @@ -26,7 +26,7 @@ public virtual async Task HandleAsync(PluginDataPayload payload) var contactId = Guid.Parse(payload.ContactId ?? throw new ArgumentException("contactId is required")); var profileId = Guid.Parse(payload.ProfileId ?? throw new ArgumentException("profileId is required")); var innerData = payload.Data?.ToObject() - ?? throw new ArgumentException("Contact data is required"); + ?? throw new ArgumentException("Contact data is required"); // Idempotency: if the contact already exists, treat as success var existing = await contactRepository.FindAsync(contactId); @@ -83,28 +83,5 @@ public virtual async Task HandleAsync(PluginDataPayload payload) logger.LogInformation("Contact {ContactId} created successfully", contactId); return "Contact created successfully"; - } - - /// - /// Normalizes a raw OIDC subject by stripping the IDP suffix (after @) and uppercasing. - /// This matches the format stored in ApplicationFormSubmission.OidcSub. - /// - internal static string? NormalizeOidcSub(string? subject) - { - if (string.IsNullOrWhiteSpace(subject)) - { - return null; - } - - var atIndex = subject.IndexOf('@'); - - if (atIndex == 0) - { - return null; - } - - return atIndex > 0 - ? subject[..atIndex].ToUpperInvariant() - : subject.ToUpperInvariant(); } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/ContactEditHandler.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/ContactEditHandler.cs index 856821d7f7..2aa38756bc 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/ContactEditHandler.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/ContactEditHandler.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using Microsoft.Extensions.Logging; @@ -24,7 +25,7 @@ public virtual async Task HandleAsync(PluginDataPayload payload) { var contactId = Guid.Parse(payload.ContactId ?? throw new ArgumentException("contactId is required")); var innerData = payload.Data?.ToObject() - ?? throw new ArgumentException("Contact data is required"); + ?? throw new ArgumentException("Contact data is required"); if (innerData.ApplicantId == Guid.Empty) { @@ -33,50 +34,70 @@ public virtual async Task HandleAsync(PluginDataPayload payload) logger.LogInformation("Editing contact {ContactId} for profile {ProfileId}", contactId, payload.ProfileId); + await UpdateContactAsync(contactId, innerData); + await SyncContactLinkAsync(contactId, innerData); + + logger.LogInformation("Contact {ContactId} updated successfully", contactId); + return "Contact updated successfully"; + } + + private async Task UpdateContactAsync(Guid contactId, ContactEditData data) + { var contact = await contactRepository.GetAsync(contactId); - contact.Name = innerData.Name; - contact.Email = innerData.Email; - contact.Title = innerData.Title; - contact.HomePhoneNumber = innerData.HomePhoneNumber; - contact.MobilePhoneNumber = innerData.MobilePhoneNumber; - contact.WorkPhoneNumber = innerData.WorkPhoneNumber; - contact.WorkPhoneExtension = innerData.WorkPhoneExtension; + contact.Name = data.Name; + contact.Email = data.Email; + contact.Title = data.Title; + contact.HomePhoneNumber = data.HomePhoneNumber; + contact.MobilePhoneNumber = data.MobilePhoneNumber; + contact.WorkPhoneNumber = data.WorkPhoneNumber; + contact.WorkPhoneExtension = data.WorkPhoneExtension; - // Sync contact-link primary flags to match the incoming value + await contactRepository.UpdateAsync(contact); + } + + private async Task SyncContactLinkAsync(Guid contactId, ContactEditData data) + { var contactLinks = await contactLinkRepository.GetListAsync( cl => cl.RelatedEntityType == ApplicantEntityType - && cl.RelatedEntityId == innerData.ApplicantId + && cl.RelatedEntityId == data.ApplicantId && cl.IsActive); - if (innerData.IsPrimary) + if (data.IsPrimary) + { + await DemoteOtherPrimaryLinksAsync(contactLinks, contactId); + } + + var targetLink = contactLinks.FirstOrDefault(cl => cl.ContactId == contactId); + if (targetLink != null) { - foreach (var stale in contactLinks.Where(cl => cl.IsPrimary && cl.ContactId != contactId)) + var hasChanges = false; + + if (targetLink.IsPrimary != data.IsPrimary) { - stale.IsPrimary = false; - await contactLinkRepository.UpdateAsync(stale); + targetLink.IsPrimary = data.IsPrimary; + hasChanges = true; } - var newPrimary = contactLinks.FirstOrDefault(cl => cl.ContactId == contactId && !cl.IsPrimary); - if (newPrimary != null) + if (data.Role is not null && targetLink.Role != data.Role) { - newPrimary.IsPrimary = true; - await contactLinkRepository.UpdateAsync(newPrimary); + targetLink.Role = data.Role; + hasChanges = true; } - } - else - { - var demoted = contactLinks.FirstOrDefault(cl => cl.ContactId == contactId && cl.IsPrimary); - if (demoted != null) + + if (hasChanges) { - demoted.IsPrimary = false; - await contactLinkRepository.UpdateAsync(demoted); + await contactLinkRepository.UpdateAsync(targetLink); } } + } - await contactRepository.UpdateAsync(contact); - - logger.LogInformation("Contact {ContactId} updated successfully", contactId); - return "Contact updated successfully"; + private async Task DemoteOtherPrimaryLinksAsync(List contactLinks, Guid contactId) + { + foreach (var stale in contactLinks.Where(cl => cl.IsPrimary && cl.ContactId != contactId)) + { + stale.IsPrimary = false; + await contactLinkRepository.UpdateAsync(stale); + } } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/Localization/GrantManager/en.json b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/Localization/GrantManager/en.json index 76daa84512..89d5df054e 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/Localization/GrantManager/en.json +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/Localization/GrantManager/en.json @@ -102,10 +102,20 @@ "ReviewerList:Subtotal": "Subtotal", "ReviewerList:CloneAssessment": "Clone Assessment", - "AssessmentResultAttachments:Id": "#", - "AssessmentResultAttachments:DocumentName": "Document Name", - "AssessmentResultAttachments:UploadedDate": "Date", - "AssessmentResultAttachments:AttachedBy": "Attached by", + "AssessmentResultAttachments:Id": "#", + "AssessmentResultAttachments:DocumentName": "Document Name", + "AssessmentResultAttachments:UploadedDate": "Date", + "AssessmentResultAttachments:AttachedBy": "Attached by", + "ChefsAttachments:Title": "Submission Attachments", + "ChefsAttachments:Filter": "Filter", + "ChefsAttachments:Download": "Download", + "ChefsAttachments:GenerateSummaries": "Generate AI Summaries", + "ChefsAttachments:GenerateSummary": "Generate Summary", + "ChefsAttachments:HideAISummaries": "Hide AI Summaries", + "ChefsAttachments:ShowAISummaries": "Show AI Summaries", + "ChefsAttachments:HideSummaries": "Hide Summaries", + "ChefsAttachments:ShowSummaries": "Show Summaries", + "ChefsAttachments:NoSummariesAvailable": "No summaries available", "Enum:AssessmentState.IN_PROGRESS": "In Progress", "Enum:AssessmentState.IN_REVIEW": "Under Review by Team Lead", diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/HostMigrations/20260413232525_RenameAIPermissions.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/HostMigrations/20260413232525_RenameAIPermissions.cs index 4b24b08a98..cdbadef3db 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/HostMigrations/20260413232525_RenameAIPermissions.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/HostMigrations/20260413232525_RenameAIPermissions.cs @@ -10,17 +10,34 @@ public partial class RenameAIPermissions : Migration /// protected override void Up(MigrationBuilder migrationBuilder) { - migrationBuilder.Sql($"UPDATE public.\"PermissionGrants\" SET \"Name\" = 'AI.Analysis.ViewApplicationAnalysis' WHERE \"Name\" = 'AI.ApplicationAnalysis';"); - migrationBuilder.Sql($"UPDATE public.\"PermissionGrants\" SET \"Name\" = 'AI.Analysis.ViewAttachmentSummary' WHERE \"Name\" = 'AI.AttachmentSummary';"); - migrationBuilder.Sql($"UPDATE public.\"PermissionGrants\" SET \"Name\" = 'AI.Analysis.ViewScoringResult' WHERE \"Name\" = 'AI.ScoringAssistant';"); + RenamePermissionGrant(migrationBuilder, "AI.ApplicationAnalysis", "AI.Analysis.ViewApplicationAnalysis"); + RenamePermissionGrant(migrationBuilder, "AI.AttachmentSummary", "AI.Analysis.ViewAttachmentSummary"); + RenamePermissionGrant(migrationBuilder, "AI.ScoringAssistant", "AI.Analysis.ViewScoringResult"); } /// protected override void Down(MigrationBuilder migrationBuilder) { - migrationBuilder.Sql($"UPDATE public.\"PermissionGrants\" SET \"Name\" = 'AI.ApplicationAnalysis' WHERE \"Name\" = 'AI.Analysis.ViewApplicationAnalysis';"); - migrationBuilder.Sql($"UPDATE public.\"PermissionGrants\" SET \"Name\" = 'AI.AttachmentSummary' WHERE \"Name\" = 'AI.Analysis.ViewAttachmentSummary';"); - migrationBuilder.Sql($"UPDATE public.\"PermissionGrants\" SET \"Name\" = 'AI.ScoringAssistant' WHERE \"Name\" = 'AI.Analysis.ViewScoringResult';"); + RenamePermissionGrant(migrationBuilder, "AI.Analysis.ViewApplicationAnalysis", "AI.ApplicationAnalysis"); + RenamePermissionGrant(migrationBuilder, "AI.Analysis.ViewAttachmentSummary", "AI.AttachmentSummary"); + RenamePermissionGrant(migrationBuilder, "AI.Analysis.ViewScoringResult", "AI.ScoringAssistant"); + } + + private static void RenamePermissionGrant(MigrationBuilder migrationBuilder, string fromName, string toName) + { + migrationBuilder.Sql($@" +DELETE FROM public.""PermissionGrants"" target +USING public.""PermissionGrants"" source +WHERE target.""Name"" = '{toName}' + AND source.""Name"" = '{fromName}' + AND target.""TenantId"" IS NOT DISTINCT FROM source.""TenantId"" + AND target.""ProviderName"" IS NOT DISTINCT FROM source.""ProviderName"" + AND target.""ProviderKey"" IS NOT DISTINCT FROM source.""ProviderKey""; + +UPDATE public.""PermissionGrants"" +SET ""Name"" = '{toName}' +WHERE ""Name"" = '{fromName}'; +"); } } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.HttpApi.Client/GrantManagerHttpApiClientModule.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.HttpApi.Client/GrantManagerHttpApiClientModule.cs index ac9bbde1e2..5ecae6c8bf 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.HttpApi.Client/GrantManagerHttpApiClientModule.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.HttpApi.Client/GrantManagerHttpApiClientModule.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.DependencyInjection; +using Unity.AI; using Volo.Abp.FeatureManagement; using Volo.Abp.Identity; using Volo.Abp.Modularity; @@ -19,7 +20,8 @@ namespace Unity.GrantManager; typeof(UnityTenantManagementHttpApiClientModule), typeof(AbpFeatureManagementHttpApiClientModule), typeof(AbpSettingManagementHttpApiClientModule), - typeof(NotificationsHttpApiClientModule) + typeof(NotificationsHttpApiClientModule), + typeof(AIApplicationContractsModule) )] public class GrantManagerHttpApiClientModule : AbpModule { @@ -31,6 +33,10 @@ public override void ConfigureServices(ServiceConfigurationContext context) typeof(GrantManagerApplicationContractsModule).Assembly, RemoteServiceName ); + context.Services.AddHttpClientProxies( + typeof(AIApplicationContractsModule).Assembly, + AIRemoteServiceConsts.RemoteServiceName + ); // Register Geocoder API client context.Services.AddHttpClient(client => diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml index 30ea386fc6..f364808bfe 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml @@ -265,15 +265,15 @@ - - @if (aiApplicationAnalysisEnabled) - { - - } + + @if (aiApplicationAnalysisEnabled) + { + + } @if (Model.IsDevPromptControlsEnabled) {