diff --git a/.env b/.env index 344fe61a4..e71bac8e7 100644 --- a/.env +++ b/.env @@ -46,6 +46,9 @@ RPC_MOODLE_PORT=3011 RPC_PDF_HOST=127.0.0.1 RPC_PDF_PORT=3012 +RPC_LITELLM_HOST=127.0.0.1 +RPC_LITELLM_PORT=3013 + # System Statistics # PG_STATS_INTERVAL_MS: # Interval in milliseconds between each PostgreSQL statistics snapshot diff --git a/Makefile b/Makefile index 4d76059ce..c8d8ebc8f 100644 --- a/Makefile +++ b/Makefile @@ -28,6 +28,7 @@ help: @echo "make build Create a dockerized production build including frontend, backend, nlp, services" @echo "make build-clean Clean the environment of production build" @echo "make docker Start docker images" + @echo "make docker-recompose Rebuild and start the dev Docker stack (postgres, rpc_*)" @echo "make backup_db CONTAINER= Backup the database in the given container" @echo "make recover_db CONTAINER= DUMP= Recover database into container" @echo "make anonymize_dump CONTAINER= DUMP= [SEED=] [NUM=] Create anonymized dump (consent-filtered + pseudonymized)" @@ -77,7 +78,11 @@ lint: frontend/node_modules/.uptodate .PHONY: docker docker: - @docker compose -f docker-compose.yml -f docker-dev.yml up postgres rpc_test rpc_moodle rpc_pdf + @docker compose -f docker-compose.yml -f docker-dev.yml up postgres rpc_test rpc_moodle rpc_pdf rpc_litellm + +.PHONY: docker-recompose +docker-recompose: + @docker compose -f docker-compose.yml -f docker-dev.yml up --build postgres rpc_test rpc_moodle rpc_pdf rpc_litellm .PHONY: db db: backend/node_modules/.uptodate $(UTILS_MODULES_UPTODATE) diff --git a/backend/db/MetaModel.js b/backend/db/MetaModel.js index fc27d7984..cb9f23578 100644 --- a/backend/db/MetaModel.js +++ b/backend/db/MetaModel.js @@ -17,6 +17,38 @@ module.exports = class MetaModel extends Model { */ static fields = []; + /** + * Lets appDataUpdate accept a foreign userId when the requester owns the parent row. + * @type {{column: string, table: string}|null} + */ + static foreignOwner = null; + + /** + * Checks whether `requesterId` owns the parent row referenced by `foreignOwner.column`. + * Called generically from AppSocket#updateData's foreign-userId guard. + * @param {Object} payload Incoming appDataUpdate payload. + * @param {number} requesterId The socket's authenticated user id. + * @param {import("sequelize").Transaction} [transaction] + * @returns {Promise} + */ + static async validateForeignUserId(payload, requesterId, transaction) { + if (!this.foreignOwner) return false; + const {column, table} = this.foreignOwner; + + let fkValue = payload[column]; + if (!fkValue && payload.id) { + const existing = await this.findByPk(payload.id, {transaction}); + fkValue = existing?.[column]; + } + if (!fkValue) return false; + + const parent = await this.sequelize.models[table].findOne({ + where: {id: fkValue, deleted: false}, + transaction, + }); + return Boolean(parent) && Number(parent.userId) === Number(requesterId); + } + /** * Filter object by keys * @param {Object} obj diff --git a/backend/db/migrations/20260331100037-create-ai_credential.js b/backend/db/migrations/20260331100037-create-ai_credential.js new file mode 100644 index 000000000..df1beeab3 --- /dev/null +++ b/backend/db/migrations/20260331100037-create-ai_credential.js @@ -0,0 +1,82 @@ +'use strict'; + +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.createTable('ai_credential', { + id: { + type: Sequelize.INTEGER, + primaryKey: true, + autoIncrement: true, + allowNull: false, + }, + userId: { + type: Sequelize.INTEGER, + allowNull: false, + references: { + model: 'user', + key: 'id', + }, + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + }, + name: { + type: Sequelize.STRING, + allowNull: false, + }, + apiKey: { + type: Sequelize.TEXT, + allowNull: true, + defaultValue: null, + }, + provider: { + type: Sequelize.STRING, + allowNull: true, + defaultValue: null, + }, + apiBaseUrl: { + type: Sequelize.STRING, + allowNull: true, + defaultValue: null, + }, + apiVersion: { + type: Sequelize.STRING, + allowNull: true, + defaultValue: null, + }, + additionalParameters: { + type: Sequelize.JSONB, + allowNull: true, + defaultValue: {}, + }, + enabled: { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: true, + }, + deleted: { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: false, + }, + deletedAt: { + type: Sequelize.DATE, + allowNull: true, + defaultValue: null, + }, + createdAt: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.fn('NOW'), + }, + updatedAt: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.fn('NOW'), + }, + }); + }, + + async down(queryInterface, Sequelize) { + await queryInterface.dropTable('ai_credential'); + }, +}; diff --git a/backend/db/migrations/20260331101522-create-ai_model.js b/backend/db/migrations/20260331101522-create-ai_model.js new file mode 100644 index 000000000..db29c0c40 --- /dev/null +++ b/backend/db/migrations/20260331101522-create-ai_model.js @@ -0,0 +1,86 @@ +'use strict'; + +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.createTable('ai_model', { + id: { + type: Sequelize.INTEGER, + primaryKey: true, + autoIncrement: true, + allowNull: false, + }, + aiCredentialId: { + type: Sequelize.INTEGER, + allowNull: true, + defaultValue: null, + references: { + model: 'ai_credential', + key: 'id', + }, + onDelete: 'SET NULL', + onUpdate: 'CASCADE', + }, + userId: { + type: Sequelize.INTEGER, + allowNull: false, + references: { + model: 'user', + key: 'id', + }, + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + }, + name: { + type: Sequelize.STRING, + allowNull: false, + }, + model: { + type: Sequelize.STRING, + allowNull: false, + }, + description: { + type: Sequelize.TEXT, + allowNull: true, + defaultValue: null, + }, + additionalParameters: { + type: Sequelize.JSONB, + defaultValue: {}, + }, + enabled: { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: true, + }, + freeModel: { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: false, + }, + deleted: { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: false, + }, + deletedAt: { + type: Sequelize.DATE, + allowNull: true, + defaultValue: null, + }, + createdAt: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.fn('NOW'), + }, + updatedAt: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.fn('NOW'), + }, + }); + }, + + async down(queryInterface, Sequelize) { + await queryInterface.dropTable('ai_model'); + }, +}; diff --git a/backend/db/migrations/20260331102200-create-ai_model_share.js b/backend/db/migrations/20260331102200-create-ai_model_share.js new file mode 100644 index 000000000..bb128f2df --- /dev/null +++ b/backend/db/migrations/20260331102200-create-ai_model_share.js @@ -0,0 +1,74 @@ +'use strict'; + +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.createTable('ai_model_share', { + id: { + type: Sequelize.INTEGER, + primaryKey: true, + autoIncrement: true, + allowNull: false, + }, + aiModelId: { + type: Sequelize.INTEGER, + allowNull: false, + references: { + model: 'ai_model', + key: 'id', + }, + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + }, + userId: { + type: Sequelize.INTEGER, + allowNull: true, + defaultValue: null, + references: { + model: 'user', + key: 'id', + }, + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + }, + roleId: { + type: Sequelize.INTEGER, + allowNull: true, + defaultValue: null, + references: { + model: 'user_role', + key: 'id', + }, + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + }, + expiryDate: { + type: Sequelize.DATE, + allowNull: false, + }, + deleted: { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: false, + }, + deletedAt: { + type: Sequelize.DATE, + allowNull: true, + defaultValue: null, + }, + createdAt: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.fn('NOW'), + }, + updatedAt: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.fn('NOW'), + }, + }); + }, + + async down(queryInterface, Sequelize) { + await queryInterface.dropTable('ai_model_share'); + }, +}; diff --git a/backend/db/migrations/20260331103048-create-ai_log.js b/backend/db/migrations/20260331103048-create-ai_log.js new file mode 100644 index 000000000..70d2dd85e --- /dev/null +++ b/backend/db/migrations/20260331103048-create-ai_log.js @@ -0,0 +1,137 @@ +'use strict'; + +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.createTable('ai_log', { + id: { + type: Sequelize.INTEGER, + primaryKey: true, + autoIncrement: true, + allowNull: false, + }, + userId: { + type: Sequelize.INTEGER, + allowNull: false, + references: { + model: 'user', + key: 'id', + }, + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + }, + aiModelId: { + type: Sequelize.INTEGER, + allowNull: true, + references: { + model: 'ai_model', + key: 'id', + }, + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + }, + documentId: { + type: Sequelize.INTEGER, + allowNull: true, + defaultValue: null, + references: { + model: 'document', + key: 'id', + }, + onDelete: 'SET NULL', + onUpdate: 'CASCADE', + }, + studySessionId: { + type: Sequelize.INTEGER, + allowNull: true, + defaultValue: null, + references: { + model: 'study_session', + key: 'id', + }, + onDelete: 'SET NULL', + onUpdate: 'CASCADE', + }, + studyStepId: { + type: Sequelize.INTEGER, + allowNull: true, + defaultValue: null, + references: { + model: 'study_step', + key: 'id', + }, + onDelete: 'SET NULL', + onUpdate: 'CASCADE', + }, + requestId: { + type: Sequelize.STRING, + allowNull: true, + defaultValue: null, + }, + input: { + type: Sequelize.TEXT, + allowNull: true, + }, + output: { + type: Sequelize.TEXT, + allowNull: true, + }, + reasoning: { + type: Sequelize.TEXT, + allowNull: true, + }, + inputTokens: { + type: Sequelize.INTEGER, + allowNull: true, + defaultValue: null, + }, + outputTokens: { + type: Sequelize.INTEGER, + allowNull: true, + defaultValue: null, + }, + total_tokens: { + type: Sequelize.INTEGER, + allowNull: true, + defaultValue: null, + }, + costs: { + type: Sequelize.FLOAT, + allowNull: true, + defaultValue: null, + }, + status: { + type: Sequelize.STRING, + allowNull: true, + }, + requestStart: { + type: Sequelize.DATE, + allowNull: true, + defaultValue: null, + }, + deleted: { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: false, + }, + deletedAt: { + type: Sequelize.DATE, + allowNull: true, + defaultValue: null, + }, + createdAt: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.fn('NOW'), + }, + updatedAt: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.fn('NOW'), + }, + }); + }, + + async down(queryInterface, Sequelize) { + await queryInterface.dropTable('ai_log'); + }, +}; diff --git a/backend/db/migrations/20260504120157-add-ai-nav.js b/backend/db/migrations/20260504120157-add-ai-nav.js new file mode 100644 index 000000000..8760f3699 --- /dev/null +++ b/backend/db/migrations/20260504120157-add-ai-nav.js @@ -0,0 +1,124 @@ +'use strict'; + +const navElements = [ + { + name: 'AI Log', + icon: 'journal-text', + order: 2, + admin: false, + path: 'ai_log', + component: 'AILog', + }, + { + name: 'AI Models', + icon: 'robot', + order: 3, + admin: false, + path: 'ai_models', + component: 'AIModels', + }, +]; + +const userRights = [ + { + name: 'frontend.dashboard.ai_log.view', + description: 'access to view AI logs in the dashboard', + }, + { + name: 'frontend.dashboard.ai_models.view', + description: 'access to view AI models in the dashboard', + }, +]; + +const roleRights = [ + { + role: 'user', + userRightName: 'frontend.dashboard.ai_log.view', + }, + { + role: 'user', + userRightName: 'frontend.dashboard.ai_models.view', + }, +]; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + const aiGroupId = await queryInterface.rawSelect( + 'nav_group', + { + where: { name: 'AI' }, + }, + ['id'] + ); + + await queryInterface.bulkInsert( + 'nav_element', + navElements.map((element) => ({ + name: element.name, + icon: element.icon, + order: element.order, + admin: element.admin, + path: element.path, + component: element.component, + groupId: aiGroupId, + createdAt: new Date(), + updatedAt: new Date(), + })), + {} + ); + + await queryInterface.bulkInsert( + 'user_right', + userRights.map((right) => ({ + ...right, + createdAt: new Date(), + updatedAt: new Date(), + })), + {} + ); + + const userRoles = await queryInterface.sequelize.query( + 'SELECT id, name FROM "user_role"', + { + type: queryInterface.sequelize.QueryTypes.SELECT, + } + ); + + const roleNameIdMapping = userRoles.reduce((acc, role) => { + acc[role.name] = role.id; + return acc; + }, {}); + + await queryInterface.bulkInsert( + 'role_right_matching', + roleRights.map((right) => ({ + userRoleId: roleNameIdMapping[right.role], + userRightName: right.userRightName, + createdAt: new Date(), + updatedAt: new Date(), + })), + {} + ); + }, + + async down(queryInterface, Sequelize) { + await queryInterface.bulkDelete( + 'role_right_matching', + { userRightName: roleRights.map((right) => right.userRightName) }, + {} + ); + + await queryInterface.bulkDelete( + 'user_right', + { name: userRights.map((right) => right.name) }, + {} + ); + + await queryInterface.bulkDelete( + 'nav_element', + { name: navElements.map((element) => element.name) }, + {} + ); + }, +}; diff --git a/backend/db/migrations/20260509093241-extend-placeholder-prompt_template.js b/backend/db/migrations/20260509093241-extend-placeholder-prompt_template.js new file mode 100644 index 000000000..0d0fe1738 --- /dev/null +++ b/backend/db/migrations/20260509093241-extend-placeholder-prompt_template.js @@ -0,0 +1,118 @@ +'use strict'; + +/** @type {import('sequelize-cli').Migration} */ +const promptPlaceholders = [ + { + type: 8, + placeholderKey: "pdfText", + placeholderLabel: "PDF text", + placeholderType: "text", + placeholderDescription: "Text from the PDF in the current context.", + placeholderExample: "Document text content from the current PDF context. ... [truncated at 15000 chars if needed]", + }, + { + type: 8, + placeholderKey: "editorText", + placeholderLabel: "Editor text", + placeholderType: "text", + placeholderDescription: "Text from the current editor document in the current context.", + placeholderExample: "Document text content from the current editor context. ... [truncated at 15000 chars if needed]", + }, + { + type: 8, + placeholderKey: "assessmentResult", + placeholderLabel: "Assessment result", + placeholderType: "text", + placeholderDescription: "Saved rubric from the assessment sidebar for this document and step.", + placeholderExample: + '{"clarity":{"currentScore":3,"assessment":"Good structure but weak conclusion"},"sources":{"currentScore":2,"assessment":"Missing one reference"}}', + }, + { + type: 8, + placeholderKey: "inlineComments", + placeholderLabel: "Inline comments", + placeholderType: "text", + placeholderDescription: "Structured comments and annotations for this document and step.", + placeholderExample: + '[{"page":2,"quote":"Baseline is unclear","comment":"Please define baseline.","tag":"MajorIssue"},{"page":4,"quote":"Table 2","comment":"Nice comparison.","tag":"Strength"}]', + }, + { + type: 8, + placeholderKey: "nlpAssessmentSuggestion", + placeholderLabel: "NLP assessment suggestion", + placeholderType: "text", + placeholderDescription: "Model draft assessment for this step if available.", + placeholderExample: + '[{"name":"clarity","score":3,"justification":"Clear flow with minor issues"},{"name":"sources","score":2,"justification":"Some claims lack citations"}]', + }, + { + type: 8, + placeholderKey: "previousAssessmentResult", + placeholderLabel: "Previous assessment result", + placeholderType: "text", + placeholderDescription: "Saved rubric from the previous step when carry-over is configured.", + placeholderExample: + '{"clarity":{"currentScore":2,"assessment":"Argumentation was fragmented"},"sources":{"currentScore":2,"assessment":"References were incomplete"}}', + }, + { + type: 8, + placeholderKey: "assessmentConfiguration", + placeholderLabel: "Assessment configuration", + placeholderType: "text", + placeholderDescription: "Assessment rubric configuration used in this step.", + placeholderExample: + '{"type":"assessment","rubrics":[{"name":"overall","criteria":[{"name":"clarity","maxPoints":5},{"name":"sources","maxPoints":5}]}]}', + }, + { + type: 8, + placeholderKey: "submissionFiles", + placeholderLabel: "Submission file", + placeholderType: "text", + placeholderDescription: "Text from submission files mapped per slot in the hook step.", + placeholderExample: "Extracted text from the file mapped to this instance (e.g. main PDF body)…", + }, + { + type: 8, + placeholderKey: "studyContext", + placeholderLabel: "Study context", + placeholderType: "text", + placeholderDescription: "Basic metadata from current study, step, and document context.", + placeholderExample: + '{"studyName":"Peer Review Pilot","stepName":"Essay feedback","documentTitle":"Draft essay v2.pdf"}', + }, +]; + +module.exports = { + async up (queryInterface, Sequelize) { + await queryInterface.addColumn("placeholder", "placeholderExample", { + type: Sequelize.TEXT, + allowNull: true, + }); + + await queryInterface.bulkInsert( + "placeholder", + promptPlaceholders.map((placeholder) => ({ + ...placeholder, + required: false, + deleted: false, + deletedAt: null, + createdAt: new Date(), + updatedAt: new Date(), + })), + {} + ); + }, + + async down (queryInterface, Sequelize) { + await queryInterface.bulkDelete( + "placeholder", + { + type: 8, + placeholderKey: promptPlaceholders.map((placeholder) => placeholder.placeholderKey), + }, + {} + ); + + await queryInterface.removeColumn("placeholder", "placeholderExample"); + } +}; diff --git a/backend/db/migrations/20260601102650-create-trigger_event.js b/backend/db/migrations/20260601102650-create-trigger_event.js new file mode 100644 index 000000000..e9f08f788 --- /dev/null +++ b/backend/db/migrations/20260601102650-create-trigger_event.js @@ -0,0 +1,52 @@ +'use strict'; + +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.createTable('trigger_event', { + id: { + type: Sequelize.INTEGER, + primaryKey: true, + autoIncrement: true, + allowNull: false, + }, + name: { + type: Sequelize.STRING, + allowNull: false, + }, + enabled: { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: true, + }, + configuration: { + type: Sequelize.JSONB, + allowNull: true, + defaultValue: {}, + }, + deleted: { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: false, + }, + deletedAt: { + type: Sequelize.DATE, + allowNull: true, + defaultValue: null, + }, + createdAt: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.fn('NOW'), + }, + updatedAt: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.fn('NOW'), + }, + }); + }, + + async down(queryInterface, Sequelize) { + await queryInterface.dropTable('trigger_event'); + }, +}; diff --git a/backend/db/migrations/20260601103628-create-trigger_action.js b/backend/db/migrations/20260601103628-create-trigger_action.js new file mode 100644 index 000000000..dfa6bd701 --- /dev/null +++ b/backend/db/migrations/20260601103628-create-trigger_action.js @@ -0,0 +1,52 @@ +'use strict'; + +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.createTable('trigger_action', { + id: { + type: Sequelize.INTEGER, + primaryKey: true, + autoIncrement: true, + allowNull: false, + }, + name: { + type: Sequelize.STRING, + allowNull: false, + }, + enabled: { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: true, + }, + configuration: { + type: Sequelize.JSONB, + allowNull: true, + defaultValue: {}, + }, + deleted: { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: false, + }, + deletedAt: { + type: Sequelize.DATE, + allowNull: true, + defaultValue: null, + }, + createdAt: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.fn('NOW'), + }, + updatedAt: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.fn('NOW'), + }, + }); + }, + + async down(queryInterface, Sequelize) { + await queryInterface.dropTable('trigger_action'); + }, +}; diff --git a/backend/db/migrations/20260601103629-create-trigger.js b/backend/db/migrations/20260601103629-create-trigger.js new file mode 100644 index 000000000..003bcb618 --- /dev/null +++ b/backend/db/migrations/20260601103629-create-trigger.js @@ -0,0 +1,115 @@ +'use strict'; + +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.createTable('trigger', { + id: { + type: Sequelize.INTEGER, + primaryKey: true, + autoIncrement: true, + allowNull: false, + }, + name: { + type: Sequelize.STRING, + allowNull: false, + }, + userId: { + type: Sequelize.INTEGER, + allowNull: false, + references: { + model: 'user', + key: 'id', + }, + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + }, + triggerEventId: { + type: Sequelize.INTEGER, + allowNull: false, + references: { + model: 'trigger_event', + key: 'id', + }, + onDelete: 'RESTRICT', + onUpdate: 'CASCADE', + }, + triggerActionId: { + type: Sequelize.INTEGER, + allowNull: false, + references: { + model: 'trigger_action', + key: 'id', + }, + onDelete: 'RESTRICT', + onUpdate: 'CASCADE', + }, + projectId: { + type: Sequelize.INTEGER, + allowNull: true, + references: { + model: 'project', + key: 'id', + }, + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + }, + scheduledAt: { + type: Sequelize.DATE, + allowNull: true, + defaultValue: null, + }, + parallelLimit: { + type: Sequelize.INTEGER, + allowNull: false, + defaultValue: 1, + }, + maxRetries: { + type: Sequelize.INTEGER, + allowNull: false, + defaultValue: 3, + }, + enabled: { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: true, + }, + timeout: { + type: Sequelize.INTEGER, + allowNull: false, + defaultValue: 300, + }, + configuration: { + type: Sequelize.JSONB, + allowNull: true, + defaultValue: {}, + }, + deleted: { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: false, + }, + deletedAt: { + type: Sequelize.DATE, + allowNull: true, + defaultValue: null, + }, + createdAt: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.fn('NOW'), + }, + updatedAt: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.fn('NOW'), + }, + }); + await queryInterface.addIndex('trigger', ['projectId']); + await queryInterface.addIndex('trigger', ['triggerEventId']); + await queryInterface.addIndex('trigger', ['triggerActionId']); + }, + + async down(queryInterface, Sequelize) { + await queryInterface.dropTable('trigger'); + }, +}; diff --git a/backend/db/migrations/20260601103631-create-trigger_queue.js b/backend/db/migrations/20260601103631-create-trigger_queue.js new file mode 100644 index 000000000..62fedc532 --- /dev/null +++ b/backend/db/migrations/20260601103631-create-trigger_queue.js @@ -0,0 +1,92 @@ +'use strict'; + +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.createTable('trigger_queue', { + id: { + type: Sequelize.INTEGER, + primaryKey: true, + autoIncrement: true, + allowNull: false, + }, + triggerId: { + type: Sequelize.INTEGER, + allowNull: false, + references: { + model: 'trigger', + key: 'id', + }, + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + }, + status: { + type: Sequelize.INTEGER, + allowNull: false, + defaultValue: 0, + }, + userId: { + type: Sequelize.INTEGER, + allowNull: true, + defaultValue: null, + references: { + model: 'user', + key: 'id', + }, + onDelete: 'SET NULL', + onUpdate: 'CASCADE', + }, + configuration: { + type: Sequelize.JSONB, + allowNull: true, + defaultValue: {}, + }, + errorMessage: { + type: Sequelize.TEXT, + allowNull: true, + defaultValue: null, + }, + attemptCount: { + type: Sequelize.INTEGER, + allowNull: false, + defaultValue: 0, + }, + startedAt: { + type: Sequelize.DATE, + allowNull: true, + defaultValue: null, + }, + completedAt: { + type: Sequelize.DATE, + allowNull: true, + defaultValue: null, + }, + deleted: { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: false, + }, + deletedAt: { + type: Sequelize.DATE, + allowNull: true, + defaultValue: null, + }, + createdAt: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.fn('NOW'), + }, + updatedAt: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.fn('NOW'), + }, + }); + await queryInterface.addIndex('trigger_queue', ['triggerId']); + await queryInterface.addIndex('trigger_queue', ['status']); + await queryInterface.addIndex('trigger_queue', ['triggerId', 'status']); + }, + + async down(queryInterface, Sequelize) { + await queryInterface.dropTable('trigger_queue'); + }, +}; diff --git a/backend/db/migrations/20260601114407-add-triggers-nav.js b/backend/db/migrations/20260601114407-add-triggers-nav.js new file mode 100644 index 000000000..dc6f5c89f --- /dev/null +++ b/backend/db/migrations/20260601114407-add-triggers-nav.js @@ -0,0 +1,139 @@ +'use strict'; + +const navGroup = { + name: 'Triggers', + icon: 'lightning-charge', + order: 6, +}; + +const navElements = [ + { + name: 'Triggers', + icon: 'lightning', + order: 1, + admin: true, + path: 'triggers', + component: 'Triggers', + }, + { + name: 'Trigger Logs', + icon: 'list-ul', + order: 2, + admin: true, + path: 'trigger_logs', + component: 'TriggerLogs', + }, +]; + +const userRights = [ + { + name: 'frontend.dashboard.triggers.view', + description: 'access to manage automatic triggers in the dashboard', + }, + { + name: 'frontend.dashboard.trigger_logs.view', + description: 'access to view trigger execution logs in the dashboard', + }, +]; + +const roleRights = [ + { role: 'admin', userRightName: 'frontend.dashboard.triggers.view' }, + { role: 'admin', userRightName: 'frontend.dashboard.trigger_logs.view' }, +]; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + const now = new Date(); + + await queryInterface.bulkInsert('nav_group', [{ + name: navGroup.name, + icon: navGroup.icon, + order: navGroup.order, + admin: true, + deleted: false, + createdAt: now, + updatedAt: now, + deletedAt: null, + }]); + + const groupId = await queryInterface.rawSelect( + 'nav_group', + { where: { name: navGroup.name } }, + ['id'] + ); + + await queryInterface.bulkInsert( + 'nav_element', + navElements.map((element) => ({ + name: element.name, + icon: element.icon, + order: element.order, + admin: element.admin, + path: element.path, + component: element.component, + groupId, + createdAt: now, + updatedAt: now, + })), + {} + ); + + await queryInterface.bulkInsert( + 'user_right', + userRights.map((right) => ({ + ...right, + createdAt: now, + updatedAt: now, + })), + {} + ); + + const userRoles = await queryInterface.sequelize.query( + 'SELECT id, name FROM "user_role"', + { type: queryInterface.sequelize.QueryTypes.SELECT } + ); + + const roleNameIdMapping = userRoles.reduce((acc, role) => { + acc[role.name] = role.id; + return acc; + }, {}); + + await queryInterface.bulkInsert( + 'role_right_matching', + roleRights.map((right) => ({ + userRoleId: roleNameIdMapping[right.role], + userRightName: right.userRightName, + createdAt: now, + updatedAt: now, + })), + {} + ); + }, + + async down(queryInterface, Sequelize) { + await queryInterface.bulkDelete( + 'role_right_matching', + { userRightName: roleRights.map((r) => r.userRightName) }, + {} + ); + + await queryInterface.bulkDelete( + 'user_right', + { name: userRights.map((r) => r.name) }, + {} + ); + + await queryInterface.bulkDelete( + 'nav_element', + { name: navElements.map((e) => e.name) }, + {} + ); + + await queryInterface.bulkDelete( + 'nav_group', + { name: navGroup.name }, + {} + ); + }, +}; diff --git a/backend/db/migrations/20260601130623-basic-trigger-event-actions.js b/backend/db/migrations/20260601130623-basic-trigger-event-actions.js new file mode 100644 index 000000000..fc2f0b441 --- /dev/null +++ b/backend/db/migrations/20260601130623-basic-trigger-event-actions.js @@ -0,0 +1,149 @@ +'use strict'; + +const triggerEvents = [ + { + name: 'submission.uploaded', + enabled: true, + configuration: { + label: 'Assignment', + description: 'Fires when a student uploads a submission for a selected assignment.', + provides: ['userId', 'submissionId', 'projectId', 'assignmentId'], + formSchema: [ + { + key: 'assignmentId', + label: 'Assignment', + type: 'select', + required: true, + optionsSource: { + table: 'assignment', + labelKey: 'name', + valueKey: 'id', + filter: { disable: false, parentAssignmentId: null }, + filterFromForm: { projectId: 'projectId' }, + }, + }, + ], + }, + }, +]; + +const triggerActions = [ + { + name: 'Email notification', + enabled: true, + configuration: { + label: 'Send an email', + description: 'Sends an email to a recipient derived from the event context.', + requires: ['userId'], + handler: 'send_email', + formSchema: [ + { + key: 'recipient', + label: 'Send to', + type: 'select', + required: true, + options: [ + { name: 'The uploader', value: 'uploader' }, + { name: 'All admins', value: 'admins' }, + ], + }, + { + key: 'templateId', + label: 'Email template', + type: 'select', + required: true, + optionsSource: { + table: 'template', + labelKey: 'name', + valueKey: 'id', + filter: { type: 3 }, + }, + }, + ], + }, + }, + { + name: 'AI Preprocessing', + enabled: true, + configuration: { + label: 'AI Preprocessing', + description: + 'Runs an NLP skill on the uploaded submission with the same skill, input mapping, and base file options as Dashboard → Submissions → Apply Skills. Results are stored in document_data.', + requires: ['submissionId'], + handler: 'nlp_preprocess', + componentSchema: [ + { + type: 'skillSelector', + key: 'skillName', + required: true, + }, + { + type: 'inputMap', + key: 'inputMappings', + skillKey: 'skillName', + studyBased: false, + required: true, + requireTableBasedInput: true, + tableSelectionSource: 'eventContext', + contextKey: 'submissionId', + }, + { + type: 'inputGroup', + key: 'baseFiles', + baseFileParameterKey: 'baseFileParameter', + selectedFilesKey: 'selectedFiles', + visibleWhen: 'requiresValidation', + required: true, + }, + ], + }, + }, +]; + +module.exports = { + async up(queryInterface, Sequelize) { + const now = new Date(); + + await queryInterface.bulkInsert( + 'trigger_event', + triggerEvents.map((event) => ({ + name: event.name, + enabled: event.enabled, + configuration: JSON.stringify(event.configuration), + deleted: false, + deletedAt: null, + createdAt: now, + updatedAt: now, + })), + {} + ); + + await queryInterface.bulkInsert( + 'trigger_action', + triggerActions.map((action) => ({ + name: action.name, + enabled: action.enabled, + configuration: JSON.stringify(action.configuration), + deleted: false, + deletedAt: null, + createdAt: now, + updatedAt: now, + })), + {} + ); + }, + + async down(queryInterface, Sequelize) { + await queryInterface.bulkDelete( + 'trigger_action', + { name: triggerActions.map((a) => a.name) }, + {} + ); + + await queryInterface.bulkDelete( + 'trigger_event', + { name: triggerEvents.map((e) => e.name) }, + {} + ); + }, +}; diff --git a/backend/db/migrations/20260608123533-create-ai_hook.js b/backend/db/migrations/20260608123533-create-ai_hook.js new file mode 100644 index 000000000..c4604d0bb --- /dev/null +++ b/backend/db/migrations/20260608123533-create-ai_hook.js @@ -0,0 +1,77 @@ +'use strict'; + +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.createTable('ai_hook', { + id: { + type: Sequelize.INTEGER, + primaryKey: true, + autoIncrement: true, + allowNull: false, + }, + userId: { + type: Sequelize.INTEGER, + allowNull: false, + references: { + model: 'user', + key: 'id', + }, + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + }, + name: { + type: Sequelize.STRING, + allowNull: false, + }, + description: { + type: Sequelize.TEXT, + allowNull: true, + defaultValue: null, + }, + templateId: { + type: Sequelize.INTEGER, + allowNull: false, + references: { + model: 'template', + key: 'id', + }, + onDelete: 'RESTRICT', + onUpdate: 'CASCADE', + }, + outputMode: { + type: Sequelize.INTEGER, + allowNull: false, + defaultValue: 0, + }, + enabled: { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: true, + }, + deleted: { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: false, + }, + deletedAt: { + type: Sequelize.DATE, + allowNull: true, + defaultValue: null, + }, + createdAt: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.fn('NOW'), + }, + updatedAt: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.fn('NOW'), + }, + }); + }, + + async down(queryInterface, Sequelize) { + await queryInterface.dropTable('ai_hook'); + }, +}; diff --git a/backend/db/migrations/20260608123534-create-ai_hook_models.js b/backend/db/migrations/20260608123534-create-ai_hook_models.js new file mode 100644 index 000000000..1b42c61d5 --- /dev/null +++ b/backend/db/migrations/20260608123534-create-ai_hook_models.js @@ -0,0 +1,72 @@ +'use strict'; + +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.createTable('ai_hook_models', { + id: { + type: Sequelize.INTEGER, + primaryKey: true, + autoIncrement: true, + allowNull: false, + }, + aiHookId: { + type: Sequelize.INTEGER, + allowNull: false, + references: { + model: 'ai_hook', + key: 'id', + }, + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + }, + aiModelId: { + type: Sequelize.INTEGER, + allowNull: false, + references: { + model: 'ai_model', + key: 'id', + }, + onDelete: 'RESTRICT', + onUpdate: 'CASCADE', + }, + priority: { + type: Sequelize.INTEGER, + allowNull: false, + }, + additionalParameters: { + type: Sequelize.JSONB, + allowNull: true, + defaultValue: {}, + }, + deleted: { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: false, + }, + deletedAt: { + type: Sequelize.DATE, + allowNull: true, + defaultValue: null, + }, + createdAt: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.fn('NOW'), + }, + updatedAt: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.fn('NOW'), + }, + }); + await queryInterface.addIndex('ai_hook_models', ['aiHookId', 'priority'], { + unique: true, + where: { deleted: false }, + name: 'ai_hook_models_active_priority_unique', + }); + }, + + async down(queryInterface, Sequelize) { + await queryInterface.dropTable('ai_hook_models'); + }, +}; diff --git a/backend/db/migrations/20260608123536-add-ai-hooks-nav.js b/backend/db/migrations/20260608123536-add-ai-hooks-nav.js new file mode 100644 index 000000000..a96806e7c --- /dev/null +++ b/backend/db/migrations/20260608123536-add-ai-hooks-nav.js @@ -0,0 +1,107 @@ +'use strict'; + +const navElements = [ + { + name: 'AI Hooks', + icon: 'link-45deg', + order: 4, + admin: false, + path: 'ai_hooks', + component: 'AIHooks', + }, +]; + +const userRights = [ + { + name: 'frontend.dashboard.ai_hooks.view', + description: 'access to view AI hooks in the dashboard', + }, +]; + +const roleRights = [ + { + role: 'user', + userRightName: 'frontend.dashboard.ai_hooks.view', + }, +]; + +module.exports = { + async up(queryInterface, Sequelize) { + const aiGroupId = await queryInterface.rawSelect( + 'nav_group', + { + where: { name: 'AI' }, + }, + ['id'] + ); + + await queryInterface.bulkInsert( + 'nav_element', + navElements.map((element) => ({ + name: element.name, + icon: element.icon, + order: element.order, + admin: element.admin, + path: element.path, + component: element.component, + groupId: aiGroupId, + createdAt: new Date(), + updatedAt: new Date(), + })), + {} + ); + + await queryInterface.bulkInsert( + 'user_right', + userRights.map((right) => ({ + ...right, + createdAt: new Date(), + updatedAt: new Date(), + })), + {} + ); + + const userRoles = await queryInterface.sequelize.query( + 'SELECT id, name FROM "user_role"', + { + type: queryInterface.sequelize.QueryTypes.SELECT, + } + ); + + const roleNameIdMapping = userRoles.reduce((acc, role) => { + acc[role.name] = role.id; + return acc; + }, {}); + + await queryInterface.bulkInsert( + 'role_right_matching', + roleRights.map((right) => ({ + userRoleId: roleNameIdMapping[right.role], + userRightName: right.userRightName, + createdAt: new Date(), + updatedAt: new Date(), + })), + {} + ); + }, + + async down(queryInterface, Sequelize) { + await queryInterface.bulkDelete( + 'role_right_matching', + { userRightName: roleRights.map((right) => right.userRightName) }, + {} + ); + + await queryInterface.bulkDelete( + 'user_right', + { name: userRights.map((right) => right.name) }, + {} + ); + + await queryInterface.bulkDelete( + 'nav_element', + { name: navElements.map((element) => element.name) }, + {} + ); + }, +}; diff --git a/backend/db/migrations/20260610143500-create-ai_hook_share.js b/backend/db/migrations/20260610143500-create-ai_hook_share.js new file mode 100644 index 000000000..e62b9c024 --- /dev/null +++ b/backend/db/migrations/20260610143500-create-ai_hook_share.js @@ -0,0 +1,74 @@ +'use strict'; + +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.createTable('ai_hook_share', { + id: { + type: Sequelize.INTEGER, + primaryKey: true, + autoIncrement: true, + allowNull: false, + }, + aiHookId: { + type: Sequelize.INTEGER, + allowNull: false, + references: { + model: 'ai_hook', + key: 'id', + }, + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + }, + userId: { + type: Sequelize.INTEGER, + allowNull: true, + defaultValue: null, + references: { + model: 'user', + key: 'id', + }, + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + }, + roleId: { + type: Sequelize.INTEGER, + allowNull: true, + defaultValue: null, + references: { + model: 'user_role', + key: 'id', + }, + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + }, + expiryDate: { + type: Sequelize.DATE, + allowNull: false, + }, + deleted: { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: false, + }, + deletedAt: { + type: Sequelize.DATE, + allowNull: true, + defaultValue: null, + }, + createdAt: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.fn('NOW'), + }, + updatedAt: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.fn('NOW'), + }, + }); + }, + + async down(queryInterface, Sequelize) { + await queryInterface.dropTable('ai_hook_share'); + }, +}; diff --git a/backend/db/migrations/20260618120200-extend-ai_log-aiHookId.js b/backend/db/migrations/20260618120200-extend-ai_log-aiHookId.js new file mode 100644 index 000000000..504986554 --- /dev/null +++ b/backend/db/migrations/20260618120200-extend-ai_log-aiHookId.js @@ -0,0 +1,25 @@ +'use strict'; + +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.addColumn( + "ai_log", + "aiHookId", + { + type: Sequelize.INTEGER, + references: { + model: "ai_hook", + key: "id", + }, + allowNull: true, + defaultValue: null, + onDelete: "SET NULL", + onUpdate: "CASCADE", + } + ); + }, + + async down(queryInterface, Sequelize) { + await queryInterface.removeColumn("ai_log", "aiHookId"); + }, +}; diff --git a/backend/db/migrations/20260619120000-create-ai_budget.js b/backend/db/migrations/20260619120000-create-ai_budget.js new file mode 100644 index 000000000..521cff2dc --- /dev/null +++ b/backend/db/migrations/20260619120000-create-ai_budget.js @@ -0,0 +1,143 @@ +'use strict'; + + +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.sequelize.transaction(async (transaction) => { + await queryInterface.createTable('ai_budget', { + id: { + allowNull: false, + autoIncrement: true, + primaryKey: true, + type: Sequelize.INTEGER, + }, + userId: { + type: Sequelize.INTEGER, + allowNull: false, + references: { model: 'user', key: 'id' }, + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + }, + aiModelId: { + type: Sequelize.INTEGER, + allowNull: true, + defaultValue: null, + references: { model: 'ai_model', key: 'id' }, + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + }, + aiModelShareId: { + type: Sequelize.INTEGER, + allowNull: true, + defaultValue: null, + references: { model: 'ai_model_share', key: 'id' }, + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + }, + aiHookId: { + type: Sequelize.INTEGER, + allowNull: true, + defaultValue: null, + references: { model: 'ai_hook', key: 'id' }, + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + }, + aiHookShareId: { + type: Sequelize.INTEGER, + allowNull: true, + defaultValue: null, + references: { model: 'ai_hook_share', key: 'id' }, + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + }, + studyId: { + type: Sequelize.INTEGER, + allowNull: true, + defaultValue: null, + references: { model: 'study', key: 'id' }, + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + }, + studyStepId: { + type: Sequelize.INTEGER, + allowNull: true, + defaultValue: null, + references: { model: 'study_step', key: 'id' }, + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + }, + limitType: { + type: Sequelize.INTEGER, + allowNull: false, + defaultValue: 0, + }, + costLimit: { + type: Sequelize.DECIMAL(18, 6), + allowNull: false, + }, + resetAt: { + type: Sequelize.DATE, + allowNull: true, + defaultValue: null, + }, + deleted: { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: false, + }, + deletedAt: { + type: Sequelize.DATE, + allowNull: true, + defaultValue: null, + }, + createdAt: { + allowNull: false, + type: Sequelize.DATE, + defaultValue: Sequelize.fn('NOW'), + }, + updatedAt: { + allowNull: false, + type: Sequelize.DATE, + defaultValue: Sequelize.fn('NOW'), + }, + }, { transaction }); + + // Exactly one valid entity FK pattern must match. + await queryInterface.sequelize.query(` + ALTER TABLE "ai_budget" ADD CONSTRAINT "chk_ai_budget_shape" CHECK ( + ("aiModelId" IS NOT NULL AND "aiModelShareId" IS NULL AND "aiHookId" IS NULL + AND "aiHookShareId" IS NULL AND "studyId" IS NULL AND "studyStepId" IS NULL) + OR + ("aiModelShareId" IS NOT NULL AND "aiModelId" IS NULL AND "aiHookId" IS NULL + AND "aiHookShareId" IS NULL AND "studyId" IS NULL AND "studyStepId" IS NULL) + OR + ("aiHookId" IS NOT NULL AND "studyStepId" IS NULL AND "aiModelId" IS NULL + AND "aiModelShareId" IS NULL AND "aiHookShareId" IS NULL AND "studyId" IS NULL) + OR + ("aiHookShareId" IS NOT NULL AND "aiModelId" IS NULL AND "aiModelShareId" IS NULL + AND "aiHookId" IS NULL AND "studyId" IS NULL AND "studyStepId" IS NULL) + OR + ("studyId" IS NOT NULL AND "aiModelId" IS NULL AND "aiModelShareId" IS NULL + AND "aiHookId" IS NULL AND "aiHookShareId" IS NULL AND "studyStepId" IS NULL) + OR + ("studyStepId" IS NOT NULL AND "aiHookId" IS NOT NULL AND "aiModelId" IS NULL + AND "aiModelShareId" IS NULL AND "aiHookShareId" IS NULL AND "studyId" IS NULL) + ) + `, { transaction }); + + // limitType != TOTAL only makes sense for study or step_hook caps. + await queryInterface.sequelize.query(` + ALTER TABLE "ai_budget" ADD CONSTRAINT "chk_ai_budget_limit_type" CHECK ( + "limitType" IN (0, 1, 2) AND + ("limitType" = 0 + OR "studyId" IS NOT NULL + OR "studyStepId" IS NOT NULL) + ) + `, { transaction }); + }); + }, + + async down(queryInterface) { + await queryInterface.dropTable('ai_budget'); + }, +}; diff --git a/backend/db/migrations/20260622120000-add-ai-budget-nav.js b/backend/db/migrations/20260622120000-add-ai-budget-nav.js new file mode 100644 index 000000000..5bdd4e86f --- /dev/null +++ b/backend/db/migrations/20260622120000-add-ai-budget-nav.js @@ -0,0 +1,107 @@ +'use strict'; + +const navElements = [ + { + name: 'AI Budget', + icon: 'piggy-bank', + order: 5, + admin: false, + path: 'ai_budget', + component: 'AIBudgets', + }, +]; + +const userRights = [ + { + name: 'frontend.dashboard.ai_budget.view', + description: 'access to view AI budgets in the dashboard', + }, +]; + +const roleRights = [ + { + role: 'user', + userRightName: 'frontend.dashboard.ai_budget.view', + }, +]; + +module.exports = { + async up(queryInterface, Sequelize) { + const aiGroupId = await queryInterface.rawSelect( + 'nav_group', + { + where: { name: 'AI' }, + }, + ['id'] + ); + + await queryInterface.bulkInsert( + 'nav_element', + navElements.map((element) => ({ + name: element.name, + icon: element.icon, + order: element.order, + admin: element.admin, + path: element.path, + component: element.component, + groupId: aiGroupId, + createdAt: new Date(), + updatedAt: new Date(), + })), + {} + ); + + await queryInterface.bulkInsert( + 'user_right', + userRights.map((right) => ({ + ...right, + createdAt: new Date(), + updatedAt: new Date(), + })), + {} + ); + + const userRoles = await queryInterface.sequelize.query( + 'SELECT id, name FROM "user_role"', + { + type: queryInterface.sequelize.QueryTypes.SELECT, + } + ); + + const roleNameIdMapping = userRoles.reduce((acc, role) => { + acc[role.name] = role.id; + return acc; + }, {}); + + await queryInterface.bulkInsert( + 'role_right_matching', + roleRights.map((right) => ({ + userRoleId: roleNameIdMapping[right.role], + userRightName: right.userRightName, + createdAt: new Date(), + updatedAt: new Date(), + })), + {} + ); + }, + + async down(queryInterface, Sequelize) { + await queryInterface.bulkDelete( + 'role_right_matching', + { userRightName: roleRights.map((right) => right.userRightName) }, + {} + ); + + await queryInterface.bulkDelete( + 'user_right', + { name: userRights.map((right) => right.name) }, + {} + ); + + await queryInterface.bulkDelete( + 'nav_element', + { name: navElements.map((element) => element.name) }, + {} + ); + }, +}; diff --git a/backend/db/migrations/20260706120000-extend-template_edit-text.js b/backend/db/migrations/20260706120000-extend-template_edit-text.js new file mode 100644 index 000000000..a67864307 --- /dev/null +++ b/backend/db/migrations/20260706120000-extend-template_edit-text.js @@ -0,0 +1,17 @@ +'use strict'; + +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.changeColumn('template_edit', 'text', { + type: Sequelize.TEXT, + allowNull: true, + }); + }, + + async down(queryInterface, Sequelize) { + await queryInterface.changeColumn('template_edit', 'text', { + type: Sequelize.STRING, + allowNull: true, + }); + }, +}; diff --git a/backend/db/migrations/20260731114152-extend-nav_group-settings-order.js b/backend/db/migrations/20260731114152-extend-nav_group-settings-order.js index 0584c7f14..0e0fcd9ad 100644 --- a/backend/db/migrations/20260731114152-extend-nav_group-settings-order.js +++ b/backend/db/migrations/20260731114152-extend-nav_group-settings-order.js @@ -2,7 +2,6 @@ /** * Force dashboard nav_group order so Settings is last. - * Older DBs can still have Settings at order 4 (above AI). */ const GROUP_ORDERS = [ { name: 'Home', order: 1 }, @@ -10,7 +9,8 @@ const GROUP_ORDERS = [ { name: 'Manage', order: 3 }, { name: 'Assignment', order: 4 }, { name: 'AI', order: 5 }, - { name: 'Settings', order: 6 }, + { name: 'Triggers', order: 6 }, + { name: 'Settings', order: 7 }, ]; /** @type {import('sequelize-cli').Migration} */ diff --git a/backend/db/models/ai_budget.js b/backend/db/models/ai_budget.js new file mode 100644 index 000000000..1fd119d9d --- /dev/null +++ b/backend/db/models/ai_budget.js @@ -0,0 +1,145 @@ +'use strict'; + +/** + * AI budget cap rows. One row per cap; six entity kinds discriminated by which FK column is non-null. + * + * @author Mohammed Rawhani + */ +const MetaModel = require('../MetaModel.js'); + +// Discriminates ai_budget.limitType. Caps on model / model_share / hook / hook_share are always TOTAL +const AI_BUDGET_LIMIT_TYPES = Object.freeze({ + TOTAL: 0, + PER_SESSION: 1, + PER_USER: 2, +}); + +module.exports = (sequelize, DataTypes) => { + class AiBudget extends MetaModel { + static limitTypes = AI_BUDGET_LIMIT_TYPES; + + // parentTables tells the framework to also load these parent rows + // when ai_budget rows are sent. Same shape as study_session.autoTable. + static autoTable = { + parentTables: [ + { table: "ai_model", by: "aiModelId" }, + { table: "ai_model_share", by: "aiModelShareId" }, + { table: "ai_hook", by: "aiHookId" }, + { table: "ai_hook_share", by: "aiHookShareId" }, + { table: "study", by: "studyId" }, + { table: "study_step", by: "studyStepId" }, + ], + }; + // userId is denormalized on each row so visibility is a direct column filter — no FK-chain queries at read time. + static async getUserFilter(userId) { + return { userId }; + } + + static associate(models) { + AiBudget.belongsTo(models["ai_model"], { foreignKey: "aiModelId", as: "model" }); + AiBudget.belongsTo(models["ai_model_share"], { foreignKey: "aiModelShareId", as: "share" }); + AiBudget.belongsTo(models["ai_hook"], { foreignKey: "aiHookId", as: "hook" }); + AiBudget.belongsTo(models["ai_hook_share"], { foreignKey: "aiHookShareId", as: "hookShare" }); + AiBudget.belongsTo(models["study"], { foreignKey: "studyId", as: "study" }); + AiBudget.belongsTo(models["study_step"], { foreignKey: "studyStepId", as: "studyStep" }); + } + + // Walk the FK chain to find which user owns the referenced entity. + // Called once at create time to resolve + stamp userId on the new row. + static async _resolveOwnerUserId(aiBudget, db, transaction) { + const aiModelId = Number(aiBudget.aiModelId) || null; + const aiModelShareId = Number(aiBudget.aiModelShareId) || null; + const aiHookId = Number(aiBudget.aiHookId) || null; + const aiHookShareId = Number(aiBudget.aiHookShareId) || null; + const studyId = Number(aiBudget.studyId) || null; + const studyStepId = Number(aiBudget.studyStepId) || null; + + if (aiModelId) { + const m = await db.ai_model.findByPk(aiModelId, { transaction, raw: true }); + return m ? Number(m.userId) : null; + } + if (aiModelShareId) { + const s = await db.ai_model_share.findByPk(aiModelShareId, { transaction, raw: true }); + if (!s) return null; + const m = await db.ai_model.findByPk(s.aiModelId, { transaction, raw: true }); + return m ? Number(m.userId) : null; + } + if (aiHookShareId) { + const hs = await db.ai_hook_share.findByPk(aiHookShareId, { transaction, raw: true }); + if (!hs) return null; + const h = await db.ai_hook.findByPk(hs.aiHookId, { transaction, raw: true }); + return h ? Number(h.userId) : null; + } + if (studyStepId && aiHookId) { + const ss = await db.study_step.findByPk(studyStepId, { transaction, raw: true }); + if (!ss) return null; + const s = await db.study.findByPk(ss.studyId, { transaction, raw: true }); + return s ? Number(s.userId) : null; + } + if (aiHookId) { + const h = await db.ai_hook.findByPk(aiHookId, { transaction, raw: true }); + return h ? Number(h.userId) : null; + } + if (studyId) { + const s = await db.study.findByPk(studyId, { transaction, raw: true }); + return s ? Number(s.userId) : null; + } + return null; + } + + // On create: resolve the entity's owner, stamp it on the row, verify the caller matches. + static async validateCreate(aiBudget, options = {}) { + const currentUserId = Number(options?.context?.currentUserId); + if (!Number.isInteger(currentUserId) || currentUserId <= 0) return; + + const resolvedOwnerId = await AiBudget._resolveOwnerUserId( + aiBudget, sequelize.models, options.transaction + ); + if (!resolvedOwnerId) throw new Error("Invalid budget scope"); + if (resolvedOwnerId !== currentUserId) throw new Error("You do not own this entity"); + aiBudget.userId = resolvedOwnerId; + } + + // On update: userId is already stored — one compare, no FK walk. + static async validateUpdate(aiBudget, options = {}) { + const currentUserId = Number(options?.context?.currentUserId); + if (!Number.isInteger(currentUserId) || currentUserId <= 0) return; + + const storedOwnerId = Number(aiBudget._previousDataValues?.userId); + if (storedOwnerId !== currentUserId) throw new Error("You do not own this budget"); + } + } + + AiBudget.init({ + userId: DataTypes.INTEGER, + aiModelId: DataTypes.INTEGER, + aiModelShareId: DataTypes.INTEGER, + aiHookId: DataTypes.INTEGER, + aiHookShareId: DataTypes.INTEGER, + studyId: DataTypes.INTEGER, + studyStepId: DataTypes.INTEGER, + limitType: DataTypes.INTEGER, + costLimit: DataTypes.DECIMAL(18, 6), + resetAt: DataTypes.DATE, + deleted: DataTypes.BOOLEAN, + deletedAt: DataTypes.DATE, + createdAt: DataTypes.DATE, + updatedAt: DataTypes.DATE, + }, { + sequelize, + modelName: 'ai_budget', + tableName: 'ai_budget', + hooks: { + beforeCreate: async (aiBudget, options) => { + await AiBudget.validateCreate(aiBudget, options); + }, + beforeUpdate: async (aiBudget, options) => { + await AiBudget.validateUpdate(aiBudget, options); + }, + }, + }); + + return AiBudget; +}; + +module.exports.AI_BUDGET_LIMIT_TYPES = AI_BUDGET_LIMIT_TYPES; diff --git a/backend/db/models/ai_credential.js b/backend/db/models/ai_credential.js new file mode 100644 index 000000000..3e0843f08 --- /dev/null +++ b/backend/db/models/ai_credential.js @@ -0,0 +1,137 @@ +'use strict'; + +/** + * Sequelize model for per-user LLM provider credentials and orchestration state. + * Soft-delete/disable cascades to linked models via afterUpdate (same pattern as study/document). + * + * @author Akash Gundapuneni + */ +const MetaModel = require('../MetaModel.js'); + +module.exports = (sequelize, DataTypes) => { + class AiCredential extends MetaModel { + static autoTable = true; + + static associate(models) { + AiCredential.belongsTo(models["user"], { foreignKey: "userId", as: "user" }); + AiCredential.hasMany(models["ai_model"], { foreignKey: "aiCredentialId", as: "models" }); + } + + /** + * Soft-delete linked models (and their shares / budgets / hook links). + * Uses individualHooks so GlobalChangeTrackingPlugin fills transaction.changes. + * + * @param {Object} credential Soft-deleted credential instance. + * @param {Object} options Sequelize hook options (transaction + context). + */ + static async cascadeSoftDelete(credential, options = {}) { + const {Op} = require("sequelize"); + const transaction = options.transaction; + const db = sequelize.models; + + const models = await db.ai_model.findAll({ + where: {aiCredentialId: credential.id, deleted: false}, + attributes: ["id"], + raw: true, + transaction, + }); + const modelIds = models.map((m) => m.id); + if (modelIds.length === 0) { + return; + } + + await db.ai_model.update( + {deleted: true, deletedAt: new Date()}, + { + where: {id: {[Op.in]: modelIds}, deleted: false}, + transaction, + context: options.context, + individualHooks: true, + }, + ); + + await db.ai_model_share.update( + {deleted: true, deletedAt: new Date()}, + { + where: {aiModelId: {[Op.in]: modelIds}, deleted: false}, + transaction, + context: options.context, + individualHooks: true, + }, + ); + + await db.ai_budget.update( + {deleted: true, deletedAt: new Date()}, + { + where: {aiModelId: {[Op.in]: modelIds}, deleted: false}, + transaction, + context: options.context, + individualHooks: true, + }, + ); + + await db.ai_hook_models.update( + {deleted: true, deletedAt: new Date()}, + { + where: {aiModelId: {[Op.in]: modelIds}, deleted: false}, + transaction, + context: options.context, + individualHooks: true, + }, + ); + } + + /** + * Disable linked models when the credential is disabled. + * + * @param {Object} credential Disabled credential instance. + * @param {Object} options Sequelize hook options (transaction + context). + */ + static async cascadeDisable(credential, options = {}) { + await sequelize.models.ai_model.update( + {enabled: false}, + { + where: {aiCredentialId: credential.id, deleted: false}, + transaction: options.transaction, + context: options.context, + individualHooks: true, + }, + ); + } + } + + AiCredential.init({ + userId: DataTypes.INTEGER, + name: DataTypes.STRING, + apiKey: DataTypes.TEXT, + provider: DataTypes.STRING, + apiBaseUrl: DataTypes.STRING, + apiVersion: DataTypes.STRING, + enabled: DataTypes.BOOLEAN, + additionalParameters: DataTypes.JSONB, + deleted: DataTypes.BOOLEAN, + deletedAt: DataTypes.DATE, + createdAt: DataTypes.DATE, + updatedAt: DataTypes.DATE, + }, { + sequelize, + modelName: 'ai_credential', + tableName: 'ai_credential', + hooks: { + afterUpdate: async (credential, options) => { + if (credential.deleted && !credential._previousDataValues.deleted) { + await AiCredential.cascadeSoftDelete(credential, options); + return; + } + if ( + credential.enabled === false && + credential._previousDataValues.enabled !== false + ) { + await AiCredential.cascadeDisable(credential, options); + } + }, + }, + }); + + return AiCredential; +}; diff --git a/backend/db/models/ai_hook.js b/backend/db/models/ai_hook.js new file mode 100644 index 000000000..3b42bfe94 --- /dev/null +++ b/backend/db/models/ai_hook.js @@ -0,0 +1,125 @@ +'use strict'; + +/** + * User-owned AI hook configuration connecting prompt templates, models, and output handling. + * + * @author Akash Gundapuneni + */ +const MetaModel = require('../MetaModel.js'); +const {AI_HOOK_OUTPUT_MODES, normalizeAiHookOutputMode} = require('../../utils/aiHookOutputModes.js'); +const {Op} = require("sequelize"); + +module.exports = (sequelize, DataTypes) => { + class AiHook extends MetaModel { + // Cascade ai_hook_share and ai_hook_models rows to anyone subscribing to + // ai_hook (ai_hook_models itself cascades on to the full ai_model rows — + // see ai_hook_models.js), and the owner's user row back to anyone the hook is shared with. + static autoTable = { + foreignTables: [ + { table: "ai_hook_share", by: "aiHookId" }, + { table: "ai_hook_models", by: "aiHookId" }, + ], + parentTables: [ + { table: "user", by: "userId" }, + ], + }; + + static associate(models) { + AiHook.belongsTo(models["user"], { foreignKey: "userId", as: "user" }); + AiHook.belongsTo(models["template"], { foreignKey: "templateId", as: "template" }); + AiHook.hasMany(models["ai_hook_models"], { foreignKey: "aiHookId", as: "hookModels" }); + } + + /** + * Grants row visibility to anyone with an active ai_hook_share grant for this hook, in addition to the owner. + * + * @param {number} userId Viewer's id. + * @returns {Promise} + */ + static async getUserFilter(userId) { + const roleIds = await sequelize.models.user_role_matching.getUserRolesById(userId); + const shareRows = await sequelize.models.ai_hook_share.findAll({ + where: { + deleted: false, + expiryDate: {[Op.gt]: new Date()}, + [Op.or]: [ + {userId}, + ...(roleIds.length ? [{roleId: {[Op.in]: roleIds}}] : []), + ], + }, + attributes: ["aiHookId"], + raw: true, + }); + const hookIds = [...new Set(shareRows.map((row) => Number(row.aiHookId)))] + .filter((id) => Number.isInteger(id) && id > 0); + return hookIds.length > 0 ? {id: {[Op.in]: hookIds}} : {id: -1}; + } + + static fields = [ + { + key: "name", + label: "Name", + type: "text", + required: true, + }, + { + key: "templateId", + label: "Prompt Template", + type: "select", + required: true, + }, + { + key: "outputMode", + label: "Output Mode", + type: "select", + required: true, + default: AI_HOOK_OUTPUT_MODES.TEXT, + }, + ]; + + static validateOutputMode(aiHook) { + aiHook.outputMode = normalizeAiHookOutputMode( + aiHook.outputMode ?? AI_HOOK_OUTPUT_MODES.TEXT + ); + } + + static validateOwner(aiHook, options = {}) { + const currentUserId = Number(options?.context?.currentUserId); + if (!Number.isInteger(currentUserId) || currentUserId <= 0) { + return; + } + const ownerUserId = Number(aiHook.userId ?? aiHook._previousDataValues?.userId); + if (ownerUserId !== currentUserId) { + throw new Error("You are not allowed to update this AI hook"); + } + } + } + + AiHook.init({ + userId: DataTypes.INTEGER, + name: DataTypes.STRING, + description: DataTypes.TEXT, + templateId: DataTypes.INTEGER, + outputMode: DataTypes.INTEGER, + enabled: DataTypes.BOOLEAN, + deleted: DataTypes.BOOLEAN, + deletedAt: DataTypes.DATE, + createdAt: DataTypes.DATE, + updatedAt: DataTypes.DATE, + }, { + sequelize, + modelName: 'ai_hook', + tableName: 'ai_hook', + hooks: { + beforeCreate: (aiHook) => { + AiHook.validateOutputMode(aiHook); + }, + beforeUpdate: (aiHook, options) => { + AiHook.validateOwner(aiHook, options); + AiHook.validateOutputMode(aiHook); + }, + }, + }); + + return AiHook; +}; diff --git a/backend/db/models/ai_hook_models.js b/backend/db/models/ai_hook_models.js new file mode 100644 index 000000000..9bc10c924 --- /dev/null +++ b/backend/db/models/ai_hook_models.js @@ -0,0 +1,137 @@ +'use strict'; + +/** + * Ordered AI models for an AI hook. + * Priority 1 is the primary model; priority 2+ are fallback models. + * + * @author Akash Gundapuneni + */ +const MetaModel = require('../MetaModel.js'); + +module.exports = (sequelize, DataTypes) => { + class AiHookModels extends MetaModel { + // Cascade the referenced ai_model row to anyone who can see this hook_models + // row (i.e. anyone who can see the parent ai_hook — owner or share recipient). + static autoTable = { + parentTables: [ + { table: "ai_model", by: "aiModelId" }, + ], + }; + + static associate(models) { + AiHookModels.belongsTo(models["ai_hook"], { foreignKey: "aiHookId", as: "hook" }); + AiHookModels.belongsTo(models["ai_model"], { foreignKey: "aiModelId", as: "model" }); + } + + static async getUserFilter(userId) { + const {Op} = require("sequelize"); + const hooks = await sequelize.models.ai_hook.findAll({ + attributes: ["id"], + where: {userId, deleted: false}, + raw: true, + }); + const hookIds = hooks.map((hook) => hook.id).filter(Boolean); + if (hookIds.length === 0) { + return {aiHookId: -1}; + } + return {aiHookId: {[Op.in]: hookIds}}; + } + + static async validateHookModel(hookModel, options = {}) { + const userId = options?.context?.currentUserId; + if (!userId) { + return; + } + + const hookId = hookModel.aiHookId ?? hookModel._previousDataValues?.aiHookId; + if (!hookId) { + throw new Error("AI hook is required for hook models"); + } + + const hook = await sequelize.models.ai_hook.getById( + hookId, + {transaction: options.transaction} + ); + if (!hook || hook.deleted || Number(hook.userId) !== Number(userId)) { + throw new Error("You are not allowed to manage models for this AI hook"); + } + + const priority = Number(hookModel.priority ?? hookModel._previousDataValues?.priority); + if (!Number.isInteger(priority) || priority < 1) { + throw new Error("AI hook model priority must be at least 1"); + } + + const aiModelId = Number(hookModel.aiModelId ?? hookModel._previousDataValues?.aiModelId); + const existingRows = await sequelize.models.ai_hook_models.findAll({ + attributes: ["id", "aiModelId", "priority"], + where: {aiHookId: hookId, deleted: false}, + raw: true, + transaction: options.transaction, + }); + const currentId = Number(hookModel.id); + + const hasDuplicatePriority = existingRows.some( + (row) => Number(row.id) !== currentId && Number(row.priority) === priority + ); + if (hasDuplicatePriority) { + throw new Error("AI hook model priority must be unique for this hook"); + } + + const primaryRow = existingRows.find((row) => Number(row.priority) === 1); + if ( + priority > 1 && + primaryRow && + Number(primaryRow.id) !== currentId && + Number(primaryRow.aiModelId) === aiModelId + ) { + throw new Error("Fallback model cannot be the same as the primary model"); + } + } + + static fields = [ + { + key: "aiHookId", + label: "AI Hook", + type: "select", + required: true, + }, + { + key: "aiModelId", + label: "Model", + type: "select", + required: true, + }, + { + key: "priority", + label: "Priority", + type: "number", + required: true, + }, + ]; + } + + AiHookModels.init({ + aiHookId: DataTypes.INTEGER, + aiModelId: DataTypes.INTEGER, + priority: DataTypes.INTEGER, + additionalParameters: DataTypes.JSONB, + deleted: DataTypes.BOOLEAN, + deletedAt: DataTypes.DATE, + createdAt: DataTypes.DATE, + updatedAt: DataTypes.DATE, + }, { + sequelize, + modelName: 'ai_hook_models', + tableName: 'ai_hook_models', + hooks: { + beforeCreate: async (hookModel, options) => { + await AiHookModels.validateHookModel(hookModel, options); + }, + beforeUpdate: async (hookModel, options) => { + await AiHookModels.validateHookModel(hookModel, options); + }, + }, + }); + + return AiHookModels; +}; diff --git a/backend/db/models/ai_hook_share.js b/backend/db/models/ai_hook_share.js new file mode 100644 index 000000000..3eb3d2620 --- /dev/null +++ b/backend/db/models/ai_hook_share.js @@ -0,0 +1,58 @@ +'use strict'; + +/** + * Delegated access grants for sharing an `ai_hook` with peers via direct users or roles. + * + * @author Akash Gundapuneni + */ +const MetaModel = require('../MetaModel.js'); + +module.exports = (sequelize, DataTypes) => { + class AiHookShare extends MetaModel { + // Chain user → recipient row comes along when ai_hook_share is loaded + // via another model's parentTables (e.g. ai_budget). + static autoTable = { + parentTables: [ + { table: "user", by: "userId" }, + ], + }; + + // Row access: visible/writable by anyone who owns the referenced ai_hook. + // by/target mirror study_step → study (owned parent ids → FK on this table). + static accessMap = [ + { + table: "ai_hook", + by: "id", + target: "aiHookId", + columns: this.getAttributes(), + }, + ]; + + // Requester may write a foreign userId (the share recipient) when they own the referenced ai_hook. + // AppSocket#updateData calls MetaModel.validateForeignUserId for this. + static foreignOwner = {column: "aiHookId", table: "ai_hook"}; + + static associate(models) { + AiHookShare.belongsTo(models["ai_hook"], { foreignKey: "aiHookId", as: "hook" }); + AiHookShare.belongsTo(models["user"], { foreignKey: "userId", as: "user" }); + AiHookShare.belongsTo(models["user_role"], { foreignKey: "roleId", as: "role" }); + } + } + + AiHookShare.init({ + aiHookId: DataTypes.INTEGER, + userId: DataTypes.INTEGER, + roleId: DataTypes.INTEGER, + expiryDate: DataTypes.DATE, + deleted: DataTypes.BOOLEAN, + deletedAt: DataTypes.DATE, + createdAt: DataTypes.DATE, + updatedAt: DataTypes.DATE, + }, { + sequelize, + modelName: 'ai_hook_share', + tableName: 'ai_hook_share', + }); + + return AiHookShare; +}; diff --git a/backend/db/models/ai_log.js b/backend/db/models/ai_log.js new file mode 100644 index 000000000..e4ee460c9 --- /dev/null +++ b/backend/db/models/ai_log.js @@ -0,0 +1,50 @@ +'use strict'; + +/** + * Append-only audit trail for AI chat/test invocations (tokens, cost, status). + * + * @author Akash Gundapuneni + */ +const MetaModel = require('../MetaModel.js'); + +module.exports = (sequelize, DataTypes) => { + class AiLog extends MetaModel { + static autoTable = true; + + static associate(models) { + AiLog.belongsTo(models["study_session"], { foreignKey: "studySessionId", as: "studySession" }); + } + } + + AiLog.init({ + userId: DataTypes.INTEGER, + aiModelId: DataTypes.INTEGER, + aiHookId: DataTypes.INTEGER, + documentId: DataTypes.INTEGER, + studySessionId: DataTypes.INTEGER, + studyStepId: DataTypes.INTEGER, + requestId: DataTypes.STRING, + input: DataTypes.TEXT, + output: DataTypes.TEXT, + reasoning: DataTypes.TEXT, + inputTokens: DataTypes.INTEGER, + outputTokens: DataTypes.INTEGER, + totalTokens: { + type: DataTypes.INTEGER, + field: 'total_tokens', + }, + costs: DataTypes.FLOAT, + status: DataTypes.STRING, + requestStart: DataTypes.DATE, + deleted: DataTypes.BOOLEAN, + deletedAt: DataTypes.DATE, + createdAt: DataTypes.DATE, + updatedAt: DataTypes.DATE, + }, { + sequelize, + modelName: 'ai_log', + tableName: 'ai_log', + }); + + return AiLog; +}; diff --git a/backend/db/models/ai_model.js b/backend/db/models/ai_model.js new file mode 100644 index 000000000..b00017e96 --- /dev/null +++ b/backend/db/models/ai_model.js @@ -0,0 +1,118 @@ +'use strict'; + +/** + * User-owned logical model configuration referencing credential rows (`ai_credential`). + * Hooks ensure attached credentials remain valid for the same `userId`. + * + * @author Akash Gundapuneni + */ +const MetaModel = require('../MetaModel.js'); +const {Op} = require("sequelize"); + +module.exports = (sequelize, DataTypes) => { + class AiModel extends MetaModel { + // Cascade ai_model_share rows to anyone subscribing to ai_model, and the + // owner's user row back to anyone the model is shared with. + static autoTable = { + foreignTables: [ + { table: "ai_model_share", by: "aiModelId" }, + ], + parentTables: [ + { table: "user", by: "userId" }, + ], + }; + + static associate(models) { + AiModel.belongsTo(models["ai_credential"], { foreignKey: "aiCredentialId", as: "credential" }); + AiModel.belongsTo(models["user"], { foreignKey: "userId", as: "user" }); + AiModel.hasMany(models["ai_model_share"], { foreignKey: "aiModelId", as: "shares" }); + AiModel.hasMany(models["ai_budget"], { foreignKey: "aiModelId", as: "budgets" }); + AiModel.hasMany(models["ai_hook_models"], { foreignKey: "aiModelId", as: "hookModels" }); + AiModel.hasMany(models["ai_log"], { foreignKey: "aiModelId", as: "logs" }); + } + + /** + * Grants row visibility to anyone with an active direct-user or role-based + * ai_model_share grant for this model, in addition to the owner (handled by + * the base autoTable userId rule). + * + * @param {number} userId Viewer's id. + * @returns {Promise} + */ + static async getUserFilter(userId) { + const roleIds = await sequelize.models.user_role_matching.getUserRolesById(userId); + const shareRows = await sequelize.models.ai_model_share.findAll({ + where: { + deleted: false, + expiryDate: {[Op.gt]: new Date()}, + [Op.or]: [ + {userId}, + ...(roleIds.length ? [{roleId: {[Op.in]: roleIds}}] : []), + ], + }, + attributes: ["aiModelId"], + raw: true, + }); + const modelIds = [...new Set(shareRows.map((row) => Number(row.aiModelId)))] + .filter((id) => Number.isInteger(id) && id > 0); + return modelIds.length > 0 ? {id: {[Op.in]: modelIds}} : {id: -1}; + } + + /** + * Ensures linked credentials exist, belong to this model owner, and allow enablement semantics. + * + * @param {import('sequelize').Model} aiModel Mutated instance triggering the hook. + * @param {{ transaction?: import('sequelize').Transaction }} [options={}] Sequelize hook options bundle. + */ + static async validateCredentialOwnership(aiModel, options = {}) { + if (!aiModel.aiCredentialId) { + return; + } + + const credential = await sequelize.models.ai_credential.findByPk(aiModel.aiCredentialId, { + transaction: options.transaction, + }); + + if (!credential || credential.deleted) { + throw new Error("Selected AI credential does not exist"); + } + + if (credential.userId !== aiModel.userId) { + throw new Error("Selected AI credential does not belong to this user"); + } + + if (!credential.enabled && aiModel.enabled) { + throw new Error("Cannot enable this model while its credential is disabled"); + } + } + } + + AiModel.init({ + aiCredentialId: DataTypes.INTEGER, + userId: DataTypes.INTEGER, + name: DataTypes.STRING, + model: DataTypes.STRING, + description: DataTypes.TEXT, + additionalParameters: DataTypes.JSONB, + enabled: DataTypes.BOOLEAN, + freeModel: DataTypes.BOOLEAN, + deleted: DataTypes.BOOLEAN, + deletedAt: DataTypes.DATE, + createdAt: DataTypes.DATE, + updatedAt: DataTypes.DATE, + }, { + sequelize, + modelName: 'ai_model', + tableName: 'ai_model', + hooks: { + beforeCreate: async (aiModel, options) => { + await AiModel.validateCredentialOwnership(aiModel, options); + }, + beforeUpdate: async (aiModel, options) => { + await AiModel.validateCredentialOwnership(aiModel, options); + }, + }, + }); + + return AiModel; +}; diff --git a/backend/db/models/ai_model_share.js b/backend/db/models/ai_model_share.js new file mode 100644 index 000000000..e50d6a633 --- /dev/null +++ b/backend/db/models/ai_model_share.js @@ -0,0 +1,57 @@ +'use strict'; + +/** + * Delegated access grants for sharing an `ai_model` with peers via direct users or roles. + * + * @author Akash Gundapuneni + */ +const MetaModel = require('../MetaModel.js'); + +module.exports = (sequelize, DataTypes) => { + class AiModelShare extends MetaModel { + // Chain user → so anything subscribing to ai_model_share also gets the recipient user row for free. + static autoTable = { + parentTables: [ + { table: "user", by: "userId" }, + ], + }; + + // Row access: visible/writable by anyone who owns the referenced ai_model. + // by/target mirror study_step → study (owned parent ids → FK on this table). + static accessMap = [ + { + table: "ai_model", + by: "id", + target: "aiModelId", + columns: this.getAttributes(), + }, + ]; + + // Requester may write a foreign userId (the share recipient) when they own the referenced ai_model. + // AppSocket#updateData calls MetaModel.validateForeignUserId for this. + static foreignOwner = {column: "aiModelId", table: "ai_model"}; + + static associate(models) { + AiModelShare.belongsTo(models["ai_model"], { foreignKey: "aiModelId", as: "model" }); + AiModelShare.belongsTo(models["user"], { foreignKey: "userId", as: "user" }); + AiModelShare.belongsTo(models["user_role"], { foreignKey: "roleId", as: "role" }); + } + } + + AiModelShare.init({ + aiModelId: DataTypes.INTEGER, + userId: DataTypes.INTEGER, + roleId: DataTypes.INTEGER, + expiryDate: DataTypes.DATE, + deleted: DataTypes.BOOLEAN, + deletedAt: DataTypes.DATE, + createdAt: DataTypes.DATE, + updatedAt: DataTypes.DATE, + }, { + sequelize, + modelName: 'ai_model_share', + tableName: 'ai_model_share', + }); + + return AiModelShare; +}; diff --git a/backend/db/models/document.js b/backend/db/models/document.js index 90a7adeda..b9d92270c 100644 --- a/backend/db/models/document.js +++ b/backend/db/models/document.js @@ -2,6 +2,7 @@ const MetaModel = require("../MetaModel.js"); const path = require("path"); const fs = require('fs') +const JSZip = require("jszip"); const SequelizeSimpleCache = require("sequelize-simple-cache"); const UPLOAD_PATH = `${__dirname}/../../../files`; @@ -278,6 +279,49 @@ module.exports = (sequelize, DataTypes) => { } } + /** + * Reads a document's backing file from disk by hash and extension. + * + * @param {object} doc - The document record (must include `hash`). + * @param {string} extension - File extension including the dot (e.g. ".pdf"). + * @returns {Promise} File buffer, or null if the file is missing. + */ + static async readDocumentFile(doc, extension) { + const filePath = path.join(UPLOAD_PATH, `${doc.hash}${extension}`); + try { + return await fs.promises.readFile(filePath); + } catch { + return null; + } + } + + /** + * Extracts specific files from a zip buffer by regex pattern. + * Each spec carries a logical `name` (used as the result key) and a `pattern` + * (the validation-config regex that matches the actual filename inside the zip, + * e.g. "Expose\\.tex$"). Falls back to exact/basename match when pattern is absent. + * + * @param {Buffer} buffer - Raw zip bytes. + * @param {{name: string, pattern: string|null}[]} fileSpecs - Files to extract. + * @returns {Promise} Map of spec.name → text content for each found file. + */ + static async extractZipFiles(buffer, fileSpecs) { + const zip = await JSZip.loadAsync(buffer); + const result = {}; + for (const {name, pattern} of fileSpecs) { + //If pattern exists, turn it into a regular expression. + const regex = pattern ? new RegExp(pattern) : null; + const entry = Object.values(zip.files).find(f => + !f.dir && (regex ? regex.test(f.name) : (f.name === name || f.name.split("/").pop() === name)) + ); + if (entry) { + //entry is a JSZip file object. JSZip gives each file entry methods (e.g. async) + result[name] = await entry.async("string"); + } + } + return result; + } + /** * Resolve the file path for a document and return its content as base64. * diff --git a/backend/db/models/placeholder.js b/backend/db/models/placeholder.js index 538d36cae..87902e660 100644 --- a/backend/db/models/placeholder.js +++ b/backend/db/models/placeholder.js @@ -25,6 +25,7 @@ module.exports = (sequelize, DataTypes) => { placeholderKey: DataTypes.STRING, placeholderLabel: DataTypes.STRING, placeholderDescription: DataTypes.TEXT, + placeholderExample: DataTypes.TEXT, placeholderType: DataTypes.STRING, required: DataTypes.BOOLEAN, deleted: DataTypes.BOOLEAN, diff --git a/backend/db/models/study.js b/backend/db/models/study.js index ca7e32101..9c5ec484b 100644 --- a/backend/db/models/study.js +++ b/backend/db/models/study.js @@ -192,6 +192,33 @@ module.exports = (sequelize, DataTypes) => { default: false, help: "Specify whether participants can submit their study multiple times.", advanced: true + }, { + key: "aiCostLimitTotal", + label: "AI cost limit - total ($):", + type: "number", + required: false, + default: null, + advanced: true, + size: 4, + help: "Total AI spend allowed in this study across all participants. Leave empty for no cap." + }, { + key: "aiCostLimitPerSession", + label: "Per session ($):", + type: "number", + required: false, + default: null, + advanced: true, + size: 4, + help: "AI spend allowed in a single session. Leave empty for no per-session cap." + }, { + key: "aiCostLimitPerUser", + label: "Per participant ($):", + type: "number", + required: false, + default: null, + advanced: true, + size: 4, + help: "AI spend allowed per participant in this study. Leave empty for no per-participant cap." },]; /** @@ -243,6 +270,46 @@ module.exports = (sequelize, DataTypes) => { } } + /** + * Soft-delete every ai_budget row tied to this study or any of its + * steps. Called when the study is deleted (afterUpdate sees deleted=true) + * and when the study closes due to a new version (afterUpdate sees + * closed + _isVersioning). + * + * @param {Object} study - The study being closed or deleted. + * @param {Object} options - Sequelize options bundle (transaction + context). + */ + static async deleteAiBudgets(study, options) { + const {Op} = require("sequelize"); + const transaction = options.transaction; + const db = sequelize.models; + + const steps = await db.study_step.findAll({ + where: {studyId: study.id}, + attributes: ["id"], + raw: true, + transaction, + }); + const stepIds = steps.map((s) => s.id); + + const orClauses = [{studyId: study.id}]; + if (stepIds.length > 0) { + orClauses.push({studyStepId: {[Op.in]: stepIds}}); + } + + // individualHooks: true makes Sequelize load each matching row + // and fire the per-instance afterUpdate hook. + await db.ai_budget.update( + {deleted: true, deletedAt: new Date()}, + { + where: {deleted: false, [Op.or]: orClauses}, + transaction, + context: options.context, + individualHooks: true, + } + ); + } + /** * Create study steps for a study * @param study - The study object @@ -304,6 +371,75 @@ module.exports = (sequelize, DataTypes) => { .filter(Boolean) )]; + // Return the workflowStepId → studyStep map so callers + return studyStepsMap; + } + + /** + * Persist AI budget caps requested by the coordinator payload. + * Two layers are written here: + * - one study-level cap row per limitType (TOTAL/PER_SESSION/PER_USER) + * - one step-hook cap row per (studyStep, hook, limitType) + * + * @param {Object} study - Newly created study row. + * @param {Object} options - Sequelize options bundle (transaction + context). + * @param {Object} studyStepsMap - workflowStepId → study_step instance. + */ + static async createBudgets(study, options, studyStepsMap) { + const ctx = options.context || {}; + const Budget = sequelize.models.ai_budget; + const LT = Budget.limitTypes; + const { transaction } = options; + + // Each create runs in the same transaction that's writing the study and its steps. options.context is forwarded so the + // ai_budget.validateOwner hook sees the caller's userId. + const createCap = (rowData) => + Budget.create( + { ...rowData, deleted: false }, + { transaction, context: options.context } + ); + + // Study-level caps, read from the three virtual fields on the coordinator form (aiCostLimitTotal / PerSession / PerUser). + const studyDimensions = [ + [ctx.aiCostLimitTotal, LT.TOTAL], + [ctx.aiCostLimitPerSession, LT.PER_SESSION], + [ctx.aiCostLimitPerUser, LT.PER_USER], + ]; + for (const [rawValue, limitType] of studyDimensions) { + const value = Number(rawValue); + if (Number.isFinite(value) && value > 0) { + await createCap({ studyId: study.id, limitType, costLimit: value }); + } + } + + // Step-hook caps — live in each step's configuration.services[] + + const stepDocuments = Array.isArray(ctx.stepDocuments) ? ctx.stepDocuments : []; + for (const stepDoc of stepDocuments) { + const studyStep = studyStepsMap[stepDoc?.id]; + if (!studyStep) continue; + const services = Array.isArray(stepDoc.configuration?.services) ? stepDoc.configuration.services : []; + for (const serviceEntry of services) { + const hookId = Number(serviceEntry?.hookId); + if (!Number.isInteger(hookId) || hookId <= 0) continue; + const hookDimensions = [ + [serviceEntry.capTotal, LT.TOTAL], + [serviceEntry.capPerSession, LT.PER_SESSION], + [serviceEntry.capPerUser, LT.PER_USER], + ]; + for (const [rawValue, limitType] of hookDimensions) { + const value = Number(rawValue); + if (Number.isFinite(value) && value > 0) { + await createCap({ + studyStepId: studyStep.id, + aiHookId: hookId, + limitType, + costLimit: value, + }); + } + } + } + } } /** @@ -435,8 +571,9 @@ module.exports = (sequelize, DataTypes) => { throw new Error("Missing context or stepDocuments in options. Cancelling transaction."); } - await Study.createStudySteps(study, options); - }, + const studyStepsMap = await Study.createStudySteps(study, options); + await Study.createBudgets(study, options, studyStepsMap || {}); + }, beforeUpdate: async (study, options) => { // Keep close metadata in model layer to avoid transport-specific logic. if (study.changed("closed") && study.closed && !study.userIdClosed) { @@ -457,6 +594,7 @@ module.exports = (sequelize, DataTypes) => { if (study.deleted) { await Study.deleteStudySteps(study, options); await Study.deleteStudySessions(study, options); + await Study.deleteAiBudgets(study, options); } // Check if this is a versioning operation (_isVersioning is a custom flag) @@ -465,6 +603,13 @@ module.exports = (sequelize, DataTypes) => { await Study.handleConfiguration(study, transaction); } + // Versioning just closed this study; soft-delete its budget rows + // (study-level + step-hook) so they don't linger as orphans on + // the closed version. + if (study.closed && options._isVersioning) { + await Study.deleteAiBudgets(study, options); + } + // NOTE: Comment out the following update operation since we now use versioning. // We only update if the context and stepDocuments are available // if (options.context && options.context.stepDocuments) { diff --git a/backend/db/models/study_session.js b/backend/db/models/study_session.js index 51855ddcd..da3f83a9d 100644 --- a/backend/db/models/study_session.js +++ b/backend/db/models/study_session.js @@ -153,7 +153,6 @@ module.exports = (sequelize, DataTypes) => { sequelize: sequelize, modelName: 'study_session', tableName: 'study_session', hooks: { beforeCreate: async (studySession, options) => { - if(studySession.parentStudySessionId === null){ // check for study session availability await StudySession.checkSessionAvailability(studySession.studyId, studySession.userId, options); diff --git a/backend/db/models/template.js b/backend/db/models/template.js index fd9e65863..01154d02d 100644 --- a/backend/db/models/template.js +++ b/backend/db/models/template.js @@ -24,12 +24,12 @@ module.exports = (sequelize, DataTypes) => { // Admins: own templates (all types) OR public templates from others return {[Op.or]: [{userId: userId}, {public: true}]}; } else { - // Non-admins: own templates (types 4, 5 only) OR public templates from others (types 4, 5 only) + // Non-admins: own templates (types 4, 5, 8 only) OR public templates from others (types 4, 5, 8 only) // Email templates (types 1, 2, 3, 6, 7) are admin-only return { [Op.or]: [ - {[Op.and]: [{userId: userId}, {type: {[Op.in]: [4, 5]}}]}, - {[Op.and]: [{public: true}, {type: {[Op.in]: [4, 5]}}]} + {[Op.and]: [{userId: userId}, {type: {[Op.in]: [4, 5, 8]}}]}, + {[Op.and]: [{public: true}, {type: {[Op.in]: [4, 5, 8]}}]} ] }; } @@ -161,6 +161,10 @@ module.exports = (sequelize, DataTypes) => { { name: "Document - Study", value: 5 + }, + { + name: "Prompt", + value: 8 } ], }, diff --git a/backend/db/models/trigger.js b/backend/db/models/trigger.js new file mode 100644 index 000000000..dc27e050e --- /dev/null +++ b/backend/db/models/trigger.js @@ -0,0 +1,44 @@ +'use strict'; +const MetaModel = require("../MetaModel.js"); +module.exports = (sequelize, DataTypes) => { + /** + * Trigger rule model. + * Links an event to an action (with optional project scope) and holds + * execution settings (retries, parallel limit, timeout) plus a JSONB + * `configuration` carrying the action's collected data. + */ + class Trigger extends MetaModel { + static autoTable = true; + + static associate(models) { + Trigger.belongsTo(models["user"], { foreignKey: "userId", as: "user" }); + Trigger.belongsTo(models["trigger_event"], { foreignKey: "triggerEventId", as: "event" }); + Trigger.belongsTo(models["trigger_action"], { foreignKey: "triggerActionId", as: "action" }); + Trigger.belongsTo(models["project"], { foreignKey: "projectId", as: "project" }); + } + } + + Trigger.init({ + name: DataTypes.STRING, + userId: DataTypes.INTEGER, + triggerEventId: DataTypes.INTEGER, + triggerActionId: DataTypes.INTEGER, + projectId: DataTypes.INTEGER, + scheduledAt: DataTypes.DATE, + parallelLimit: DataTypes.INTEGER, + maxRetries: DataTypes.INTEGER, + enabled: DataTypes.BOOLEAN, + timeout: DataTypes.INTEGER, + configuration: DataTypes.JSONB, + deleted: DataTypes.BOOLEAN, + deletedAt: DataTypes.DATE, + createdAt: DataTypes.DATE, + updatedAt: DataTypes.DATE, + }, { + sequelize, + modelName: 'trigger', + tableName: 'trigger', + }); + + return Trigger; +}; diff --git a/backend/db/models/trigger_action.js b/backend/db/models/trigger_action.js new file mode 100644 index 000000000..485882946 --- /dev/null +++ b/backend/db/models/trigger_action.js @@ -0,0 +1,33 @@ +'use strict'; +const MetaModel = require("../MetaModel.js"); + +module.exports = (sequelize, DataTypes) => { + /** + * Trigger action catalog model. + * Global, admin-managed list of actions a trigger can run. Each row's + * `configuration` carries the action's form schema and runtime metadata. + */ + class TriggerAction extends MetaModel { + static autoTable = true; + static publicTable = true; + + static associate(models) { + } + } + + TriggerAction.init({ + name: DataTypes.STRING, + enabled: DataTypes.BOOLEAN, + configuration: DataTypes.JSONB, + deleted: DataTypes.BOOLEAN, + deletedAt: DataTypes.DATE, + createdAt: DataTypes.DATE, + updatedAt: DataTypes.DATE, + }, { + sequelize, + modelName: 'trigger_action', + tableName: 'trigger_action', + }); + + return TriggerAction; +}; diff --git a/backend/db/models/trigger_event.js b/backend/db/models/trigger_event.js new file mode 100644 index 000000000..cd59bfb54 --- /dev/null +++ b/backend/db/models/trigger_event.js @@ -0,0 +1,32 @@ +'use strict'; +const MetaModel = require("../MetaModel.js"); + +module.exports = (sequelize, DataTypes) => { + /** + * Trigger event catalog model. + * Global, admin-managed list of events that a trigger can react to. + */ + class TriggerEvent extends MetaModel { + static autoTable = true; + static publicTable = true; + + static associate(models) { + } + } + + TriggerEvent.init({ + name: DataTypes.STRING, + enabled: DataTypes.BOOLEAN, + configuration: DataTypes.JSONB, + deleted: DataTypes.BOOLEAN, + deletedAt: DataTypes.DATE, + createdAt: DataTypes.DATE, + updatedAt: DataTypes.DATE, + }, { + sequelize, + modelName: 'trigger_event', + tableName: 'trigger_event', + }); + + return TriggerEvent; +}; diff --git a/backend/db/models/trigger_queue.js b/backend/db/models/trigger_queue.js new file mode 100644 index 000000000..03f0c7bfe --- /dev/null +++ b/backend/db/models/trigger_queue.js @@ -0,0 +1,45 @@ +'use strict'; +const MetaModel = require("../MetaModel.js"); + +module.exports = (sequelize, DataTypes) => { + /** + * Trigger queue / execution log model. + * Records each run of a trigger rule (FIFO worker entries). + */ + class TriggerQueue extends MetaModel { + static autoTable = true; + static STATUS = { + PENDING: 0, + RUNNING: 1, + COMPLETED: 2, + CANCELLED: 3, + FAILED: 4, + }; + + static associate(models) { + TriggerQueue.belongsTo(models["trigger"], { foreignKey: "triggerId", as: "trigger" }); + TriggerQueue.belongsTo(models["user"], { foreignKey: "userId", as: "user" }); + } + } + + TriggerQueue.init({ + triggerId: DataTypes.INTEGER, + status: DataTypes.INTEGER, + userId: DataTypes.INTEGER, + configuration: DataTypes.JSONB, + errorMessage: DataTypes.TEXT, + attemptCount: DataTypes.INTEGER, + startedAt: DataTypes.DATE, + completedAt: DataTypes.DATE, + deleted: DataTypes.BOOLEAN, + deletedAt: DataTypes.DATE, + createdAt: DataTypes.DATE, + updatedAt: DataTypes.DATE, + }, { + sequelize, + modelName: 'trigger_queue', + tableName: 'trigger_queue', + }); + + return TriggerQueue; +}; diff --git a/backend/package-lock.json b/backend/package-lock.json index f626dbbc7..ffba7136a 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -38,6 +38,7 @@ "passport-ldapauth": "^3.0.1", "passport-local": "^1.0.0", "passport-orcid": "^0.0.4", + "pdf-parse": "^2.4.5", "pg-promise": "^12.1.3", "quill-delta": "^5.1.0", "sequelize": "^6.37.8", @@ -1115,6 +1116,190 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@napi-rs/canvas": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.80.tgz", + "integrity": "sha512-DxuT1ClnIPts1kQx8FBmkk4BQDTfI5kIzywAaMjQSXfNnra5UFU9PwurXrl+Je3bJ6BGsp/zmshVVFbCmyI+ww==", + "license": "MIT", + "workspaces": [ + "e2e/*" + ], + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@napi-rs/canvas-android-arm64": "0.1.80", + "@napi-rs/canvas-darwin-arm64": "0.1.80", + "@napi-rs/canvas-darwin-x64": "0.1.80", + "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.80", + "@napi-rs/canvas-linux-arm64-gnu": "0.1.80", + "@napi-rs/canvas-linux-arm64-musl": "0.1.80", + "@napi-rs/canvas-linux-riscv64-gnu": "0.1.80", + "@napi-rs/canvas-linux-x64-gnu": "0.1.80", + "@napi-rs/canvas-linux-x64-musl": "0.1.80", + "@napi-rs/canvas-win32-x64-msvc": "0.1.80" + } + }, + "node_modules/@napi-rs/canvas-android-arm64": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.80.tgz", + "integrity": "sha512-sk7xhN/MoXeuExlggf91pNziBxLPVUqF2CAVnB57KLG/pz7+U5TKG8eXdc3pm0d7Od0WreB6ZKLj37sX9muGOQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-darwin-arm64": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.80.tgz", + "integrity": "sha512-O64APRTXRUiAz0P8gErkfEr3lipLJgM6pjATwavZ22ebhjYl/SUbpgM0xcWPQBNMP1n29afAC/Us5PX1vg+JNQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-darwin-x64": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.80.tgz", + "integrity": "sha512-FqqSU7qFce0Cp3pwnTjVkKjjOtxMqRe6lmINxpIZYaZNnVI0H5FtsaraZJ36SiTHNjZlUB69/HhxNDT1Aaa9vA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.80.tgz", + "integrity": "sha512-eyWz0ddBDQc7/JbAtY4OtZ5SpK8tR4JsCYEZjCE3dI8pqoWUC8oMwYSBGCYfsx2w47cQgQCgMVRVTFiiO38hHQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-gnu": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.80.tgz", + "integrity": "sha512-qwA63t8A86bnxhuA/GwOkK3jvb+XTQaTiVML0vAWoHyoZYTjNs7BzoOONDgTnNtr8/yHrq64XXzUoLqDzU+Uuw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-musl": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.80.tgz", + "integrity": "sha512-1XbCOz/ymhj24lFaIXtWnwv/6eFHXDrjP0jYkc6iHQ9q8oXKzUX1Lc6bu+wuGiLhGh2GS/2JlfORC5ZcXimRcg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-riscv64-gnu": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.80.tgz", + "integrity": "sha512-XTzR125w5ZMs0lJcxRlS1K3P5RaZ9RmUsPtd1uGt+EfDyYMu4c6SEROYsxyatbbu/2+lPe7MPHOO/0a0x7L/gw==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-gnu": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.80.tgz", + "integrity": "sha512-BeXAmhKg1kX3UCrJsYbdQd3hIMDH/K6HnP/pG2LuITaXhXBiNdh//TVVVVCBbJzVQaV5gK/4ZOCMrQW9mvuTqA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-musl": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.80.tgz", + "integrity": "sha512-x0XvZWdHbkgdgucJsRxprX/4o4sEed7qo9rCQA9ugiS9qE2QvP0RIiEugtZhfLH3cyI+jIRFJHV4Fuz+1BHHMg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-win32-x64-msvc": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.80.tgz", + "integrity": "sha512-Z8jPsM6df5V8B1HrCHB05+bDiCxjE9QA//3YrkKIdVDEwn5RKaqOxCJDRJkl48cJbylcrJbW4HxZbTte8juuPg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "0.2.12", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", @@ -6441,6 +6626,38 @@ "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz", "integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==" }, + "node_modules/pdf-parse": { + "version": "2.4.5", + "resolved": "https://registry.npmjs.org/pdf-parse/-/pdf-parse-2.4.5.tgz", + "integrity": "sha512-mHU89HGh7v+4u2ubfnevJ03lmPgQ5WU4CxAVmTSh/sxVTEDYd1er/dKS/A6vg77NX47KTEoihq8jZBLr8Cxuwg==", + "license": "Apache-2.0", + "dependencies": { + "@napi-rs/canvas": "0.1.80", + "pdfjs-dist": "5.4.296" + }, + "bin": { + "pdf-parse": "bin/cli.mjs" + }, + "engines": { + "node": ">=20.16.0 <21 || >=22.3.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/mehmet-kozan" + } + }, + "node_modules/pdfjs-dist": { + "version": "5.4.296", + "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.4.296.tgz", + "integrity": "sha512-DlOzet0HO7OEnmUmB6wWGJrrdvbyJKftI1bhMitK7O2N8W2gc757yyYBbINy9IDafXAV9wmKr9t7xsTaNKRG5Q==", + "license": "Apache-2.0", + "engines": { + "node": ">=20.16.0 || >=22.3.0" + }, + "optionalDependencies": { + "@napi-rs/canvas": "^0.1.80" + } + }, "node_modules/pend": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", diff --git a/backend/package.json b/backend/package.json index 001da0fa5..fc0d04d9b 100644 --- a/backend/package.json +++ b/backend/package.json @@ -49,6 +49,7 @@ "passport-ldapauth": "^3.0.1", "passport-local": "^1.0.0", "passport-orcid": "^0.0.4", + "pdf-parse": "^2.4.5", "pg-promise": "^12.1.3", "quill-delta": "^5.1.0", "sequelize": "^6.37.8", diff --git a/backend/tests/budget.caps.test.js b/backend/tests/budget.caps.test.js new file mode 100644 index 000000000..84dcb31c1 --- /dev/null +++ b/backend/tests/budget.caps.test.js @@ -0,0 +1,547 @@ +/** + * Gating tests for AI budget caps (webserver/services/ai/request.beginRequest). + * + * These seed ai_log rows with hand-chosen costs directly in the DB, then call the + * real beginRequest and assert allowed / denied. No LLM is involved, so the numbers + * are deterministic. Each cap kind gets an "under the limit" (allowed) and an + * "at the limit" (denied) case, plus attribution traps proving a cap does not count + * spend it should ignore. + * + * @author Mohammed Rawhani + */ + +// uuid v14 is ESM-only and Jest's module loader can't parse it. Sequelize only +// calls uuid.v1 / uuid.v4 to generate default ids, so stub them with Node's +// built-in crypto for this test run. Contained entirely in this file. +jest.mock("uuid", () => { + const { randomUUID } = require("crypto"); + return { v1: randomUUID, v4: randomUUID }; +}); + +const db = require("../db"); +const request = require("../webserver/services/ai/request"); + +// request.js reaches the DB through service.server.db and logs through service.logger. +const service = { + server: { db }, + logger: { info() {}, warn() {}, error() {} }, +}; + +let owner; // owns the models/hooks/studies under test +let other; // a second real user, for user/owner attribution traps + +let seq = 0; +const uniq = (prefix) => `${prefix}-${seq++}`; + +// --- seed helpers ----------------------------------------------------------- + +function makeModel({ userId, freeModel = false, enabled = true }) { + return db.models.ai_model.create({ + userId, name: uniq("model"), model: "test-model", + enabled, freeModel, deleted: false, + }); +} + +async function makeHook({ userId, enabled = true }) { + const template = await db.models.template.create({ + name: uniq("tpl"), description: "cap-test", userId, public: false, type: 0, + }); + return db.models.ai_hook.create({ + userId, name: uniq("hook"), templateId: template.id, + outputMode: 0, enabled, deleted: false, + }); +} + +// hooks:false — study/study_session afterCreate hooks build steps and expect a +// full request context we don't have; the tests only need the bare rows. +function makeStudy(userId) { + return db.models.study.create({ + userId, name: uniq("study"), hash: uniq("study-hash"), deleted: false, + }, { hooks: false }); +} + +function makeSession({ studyId, userId }) { + return db.models.study_session.create({ + studyId, userId, hash: uniq("session-hash"), public: false, deleted: false, + }, { hooks: false }); +} + +function seedLog({ userId, aiModelId = null, aiHookId = null, studySessionId = null, costs, status = "completed" }) { + return db.models.ai_log.create({ + userId, aiModelId, aiHookId, studySessionId, + costs, status, requestId: uniq("req"), deleted: false, + }); +} + +function seedCap(fields) { + return db.models.ai_budget.create({ deleted: false, limitType: 0, ...fields }); +} + +function begin(payload) { + return request.beginRequest(service, { requestId: uniq("begin"), ...payload }); +} + +// A share must be non-deleted and not yet expired for the budget code to find it. +const TOMORROW = () => new Date(Date.now() + 24 * 60 * 60 * 1000); + +function shareModelWith({ aiModelId, userId }) { + return db.models.ai_model_share.create({ + aiModelId, userId, expiryDate: TOMORROW(), deleted: false, + }); +} + +function shareHookWith({ aiHookId, userId }) { + return db.models.ai_hook_share.create({ + aiHookId, userId, expiryDate: TOMORROW(), deleted: false, + }); +} + +// A study_step needs a workflow_step, which needs a workflow. hooks:false skips +// the afterCreate business logic on workflow/study_step; we only need the rows. +async function makeStep(study) { + const workflow = await db.models.workflow.create( + { name: uniq("wf"), deleted: false }, { hooks: false }); + const wfStep = await db.models.workflow_step.create( + { workflowId: workflow.id, stepType: 0, allowBackward: false, deleted: false }); + return db.models.study_step.create( + { studyId: study.id, workflowStepId: wfStep.id, stepType: 0, allowBackward: false, deleted: false }, + { hooks: false }); +} + +// --- lifecycle -------------------------------------------------------------- + +beforeAll(async () => { + // Any two distinct real users work; the migration always seeds at least two. + const users = await db.models.user.findAll({ order: [["id", "ASC"]], limit: 2 }); + expect(users.length).toBeGreaterThanOrEqual(2); + [owner, other] = users; +}); + +// Wipe every table these tests touch, child-before-parent to respect FKs. +// ai_budget goes early because it points at almost everything below it. +afterEach(async () => { + const tables = [ + "ai_log", "ai_budget", "ai_model_share", "ai_hook_share", + "study_step", "study_session", "study", + "workflow_step", "workflow", "ai_hook", "ai_model", "template", + ]; + for (const m of tables) { + await db.models[m].destroy({ where: {}, force: true }); + } +}); + +afterAll(async () => { + await db.sequelize.close(); +}); + +// --------------------------------------------------------------------------- +// Model total cap +// --------------------------------------------------------------------------- +describe("model total cap", () => { + test("allowed when spend is under the limit", async () => { + const model = await makeModel({ userId: owner.id }); + await seedCap({ userId: owner.id, aiModelId: model.id, costLimit: 10 }); + await seedLog({ userId: owner.id, aiModelId: model.id, costs: 9.99 }); + + const res = await begin({ userId: owner.id, aiModelId: model.id }); + expect(res.allowed).toBe(true); + }); + + test("denied when spend is exactly at the limit", async () => { + const model = await makeModel({ userId: owner.id }); + await seedCap({ userId: owner.id, aiModelId: model.id, costLimit: 10 }); + await seedLog({ userId: owner.id, aiModelId: model.id, costs: 10 }); + + const res = await begin({ userId: owner.id, aiModelId: model.id }); + expect(res.allowed).toBe(false); + }); + + test("trap: spend on a different model does not count", async () => { + const model = await makeModel({ userId: owner.id }); + const otherModel = await makeModel({ userId: owner.id }); + await seedCap({ userId: owner.id, aiModelId: model.id, costLimit: 10 }); + await seedLog({ userId: owner.id, aiModelId: otherModel.id, costs: 100 }); + + const res = await begin({ userId: owner.id, aiModelId: model.id }); + expect(res.allowed).toBe(true); + }); + + test("trap: failed requests do not count, completed ones do", async () => { + const model = await makeModel({ userId: owner.id }); + await seedCap({ userId: owner.id, aiModelId: model.id, costLimit: 10 }); + // 100 of spend, but only on failed rows -> ignored by the sum. + await seedLog({ userId: owner.id, aiModelId: model.id, costs: 100, status: "failed" }); + + const res = await begin({ userId: owner.id, aiModelId: model.id }); + expect(res.allowed).toBe(true); + }); + + test("free model bypasses the cap even when over the limit", async () => { + const model = await makeModel({ userId: owner.id, freeModel: true }); + await seedCap({ userId: owner.id, aiModelId: model.id, costLimit: 10 }); + await seedLog({ userId: owner.id, aiModelId: model.id, costs: 999 }); + + const res = await begin({ userId: owner.id, aiModelId: model.id }); + expect(res.allowed).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// Study caps (TOTAL / PER_SESSION / PER_USER) +// --------------------------------------------------------------------------- +describe("study caps", () => { + // Inside a study, access rides on the study owner; the participant may differ. + async function studySetup() { + const model = await makeModel({ userId: owner.id }); + const study = await makeStudy(owner.id); + return { model, study }; + } + + test("TOTAL: denied at the limit, counting all users in the study", async () => { + const { model, study } = await studySetup(); + const session = await makeSession({ studyId: study.id, userId: other.id }); + await seedCap({ userId: owner.id, studyId: study.id, limitType: 0, costLimit: 10 }); + await seedLog({ userId: other.id, aiModelId: model.id, studySessionId: session.id, costs: 10 }); + + const res = await begin({ + userId: other.id, aiModelId: model.id, studyId: study.id, studySessionId: session.id, + }); + expect(res.allowed).toBe(false); + }); + + test("TOTAL trap: spend in a different study does not count", async () => { + const { model, study } = await studySetup(); + const otherStudy = await makeStudy(owner.id); + const otherSession = await makeSession({ studyId: otherStudy.id, userId: other.id }); + const session = await makeSession({ studyId: study.id, userId: other.id }); + await seedCap({ userId: owner.id, studyId: study.id, limitType: 0, costLimit: 10 }); + await seedLog({ userId: other.id, aiModelId: model.id, studySessionId: otherSession.id, costs: 100 }); + + const res = await begin({ + userId: other.id, aiModelId: model.id, studyId: study.id, studySessionId: session.id, + }); + expect(res.allowed).toBe(true); + }); + + test("PER_SESSION: this session's spend blocks; another session's does not", async () => { + const { model, study } = await studySetup(); + const sessionA = await makeSession({ studyId: study.id, userId: other.id }); + const sessionB = await makeSession({ studyId: study.id, userId: other.id }); + await seedCap({ userId: owner.id, studyId: study.id, limitType: 1, costLimit: 10 }); + // All spend is in session B; a request in session A must still be allowed. + await seedLog({ userId: other.id, aiModelId: model.id, studySessionId: sessionB.id, costs: 100 }); + + const allowedInA = await begin({ + userId: other.id, aiModelId: model.id, studyId: study.id, studySessionId: sessionA.id, + }); + expect(allowedInA.allowed).toBe(true); + }); + + test("PER_SESSION: denied at the limit within the same session", async () => { + const { model, study } = await studySetup(); + const session = await makeSession({ studyId: study.id, userId: other.id }); + await seedCap({ userId: owner.id, studyId: study.id, limitType: 1, costLimit: 10 }); + await seedLog({ userId: other.id, aiModelId: model.id, studySessionId: session.id, costs: 10 }); + + const res = await begin({ + userId: other.id, aiModelId: model.id, studyId: study.id, studySessionId: session.id, + }); + expect(res.allowed).toBe(false); + }); + + test("PER_USER: this user's spend blocks; another user's does not", async () => { + const { model, study } = await studySetup(); + const guestSession = await makeSession({ studyId: study.id, userId: other.id }); + const ownerSession = await makeSession({ studyId: study.id, userId: owner.id }); + await seedCap({ userId: owner.id, studyId: study.id, limitType: 2, costLimit: 10 }); + // Only the owner has spent; the guest's per-user total is still 0. + await seedLog({ userId: owner.id, aiModelId: model.id, studySessionId: ownerSession.id, costs: 100 }); + + const guestAllowed = await begin({ + userId: other.id, aiModelId: model.id, studyId: study.id, studySessionId: guestSession.id, + }); + expect(guestAllowed.allowed).toBe(true); + }); + + test("PER_USER: denied at the limit for the same user", async () => { + const { model, study } = await studySetup(); + const session = await makeSession({ studyId: study.id, userId: other.id }); + await seedCap({ userId: owner.id, studyId: study.id, limitType: 2, costLimit: 10 }); + await seedLog({ userId: other.id, aiModelId: model.id, studySessionId: session.id, costs: 10 }); + + const res = await begin({ + userId: other.id, aiModelId: model.id, studyId: study.id, studySessionId: session.id, + }); + expect(res.allowed).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// Hook access gate (independent of model access) +// --------------------------------------------------------------------------- +describe("hook access gate", () => { + test("allowed when the requester owns the hook", async () => { + const model = await makeModel({ userId: owner.id }); + const hook = await makeHook({ userId: owner.id }); + + const res = await begin({ userId: owner.id, aiModelId: model.id, aiHookId: hook.id }); + expect(res.allowed).toBe(true); + }); + + test("denied when the requester has model access but not hook access", async () => { + // owner owns the model (so the model check passes) but the hook belongs to + // 'other' and was never shared with owner -> the hook gate must deny. + const model = await makeModel({ userId: owner.id }); + const foreignHook = await makeHook({ userId: other.id }); + + const res = await begin({ userId: owner.id, aiModelId: model.id, aiHookId: foreignHook.id }); + expect(res.allowed).toBe(false); + expect(res.reason).toMatch(/access to this AI hook/i); + }); +}); + +// --------------------------------------------------------------------------- +// Hook total cap — counts hook usage across ALL users and ALL models +// --------------------------------------------------------------------------- +describe("hook total cap", () => { + test("counts spend from every user (all students), not just the requester", async () => { + const model = await makeModel({ userId: owner.id }); + const hook = await makeHook({ userId: owner.id }); + await seedCap({ userId: owner.id, aiHookId: hook.id, costLimit: 10 }); + // owner and other each spent 5 on the hook -> 10 total, hitting the cap. + await seedLog({ userId: owner.id, aiModelId: model.id, aiHookId: hook.id, costs: 5 }); + await seedLog({ userId: other.id, aiModelId: model.id, aiHookId: hook.id, costs: 5 }); + + const res = await begin({ userId: owner.id, aiModelId: model.id, aiHookId: hook.id }); + expect(res.allowed).toBe(false); + }); + + test("counts the hook's spend regardless of which model ran it", async () => { + const modelA = await makeModel({ userId: owner.id }); + const modelB = await makeModel({ userId: owner.id }); + const hook = await makeHook({ userId: owner.id }); + await seedCap({ userId: owner.id, aiHookId: hook.id, costLimit: 10 }); + // Same hook, two different models; the hook cap must sum both. + await seedLog({ userId: owner.id, aiModelId: modelA.id, aiHookId: hook.id, costs: 6 }); + await seedLog({ userId: owner.id, aiModelId: modelB.id, aiHookId: hook.id, costs: 4 }); + + const res = await begin({ userId: owner.id, aiModelId: modelA.id, aiHookId: hook.id }); + expect(res.allowed).toBe(false); + }); + + test("trap: spend on a different hook does not count", async () => { + const model = await makeModel({ userId: owner.id }); + const hook = await makeHook({ userId: owner.id }); + const otherHook = await makeHook({ userId: owner.id }); + await seedCap({ userId: owner.id, aiHookId: hook.id, costLimit: 10 }); + await seedLog({ userId: owner.id, aiModelId: model.id, aiHookId: otherHook.id, costs: 100 }); + + const res = await begin({ userId: owner.id, aiModelId: model.id, aiHookId: hook.id }); + expect(res.allowed).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// Model-share cap — attributed to the grantee (their own + their studies' use) +// --------------------------------------------------------------------------- +describe("model-share cap", () => { + test("denied at the limit, counting the grantee's own spend", async () => { + const model = await makeModel({ userId: owner.id }); + const share = await shareModelWith({ aiModelId: model.id, userId: other.id }); + await seedCap({ userId: owner.id, aiModelShareId: share.id, costLimit: 10 }); + await seedLog({ userId: other.id, aiModelId: model.id, costs: 10 }); + + const res = await begin({ userId: other.id, aiModelId: model.id }); + expect(res.allowed).toBe(false); + }); + + test("trap: the model owner's own spend is not charged to the grantee's cap", async () => { + const model = await makeModel({ userId: owner.id }); + const share = await shareModelWith({ aiModelId: model.id, userId: other.id }); + await seedCap({ userId: owner.id, aiModelShareId: share.id, costLimit: 10 }); + // Owner spent heavily outside any of the grantee's studies -> must not count. + await seedLog({ userId: owner.id, aiModelId: model.id, costs: 100 }); + + const res = await begin({ userId: other.id, aiModelId: model.id }); + expect(res.allowed).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// Hook-share cap — same grantee attribution, for a shared hook +// --------------------------------------------------------------------------- +describe("hook-share cap", () => { + // Grantee needs both model access and hook access to reach the cap check. + async function shareSetup() { + const model = await makeModel({ userId: owner.id }); + const hook = await makeHook({ userId: owner.id }); + await shareModelWith({ aiModelId: model.id, userId: other.id }); + const hookShare = await shareHookWith({ aiHookId: hook.id, userId: other.id }); + return { model, hook, hookShare }; + } + + test("denied at the limit, counting the grantee's own hook spend", async () => { + const { model, hook, hookShare } = await shareSetup(); + await seedCap({ userId: owner.id, aiHookShareId: hookShare.id, costLimit: 10 }); + await seedLog({ userId: other.id, aiModelId: model.id, aiHookId: hook.id, costs: 10 }); + + const res = await begin({ userId: other.id, aiModelId: model.id, aiHookId: hook.id }); + expect(res.allowed).toBe(false); + }); + + test("allowed when under the limit", async () => { + const { model, hook, hookShare } = await shareSetup(); + await seedCap({ userId: owner.id, aiHookShareId: hookShare.id, costLimit: 10 }); + await seedLog({ userId: other.id, aiModelId: model.id, aiHookId: hook.id, costs: 9.99 }); + + const res = await begin({ userId: other.id, aiModelId: model.id, aiHookId: hook.id }); + expect(res.allowed).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// Step-hook caps — a hook capped at one study step (study "steps service" limit) +// --------------------------------------------------------------------------- +describe("step-hook caps", () => { + async function stepSetup() { + const model = await makeModel({ userId: owner.id }); + const hook = await makeHook({ userId: owner.id }); + const study = await makeStudy(owner.id); + const step = await makeStep(study); + return { model, hook, study, step }; + } + + test("TOTAL: denied at the limit, counting the hook's use across the study", async () => { + const { model, hook, study, step } = await stepSetup(); + const session = await makeSession({ studyId: study.id, userId: other.id }); + await seedCap({ userId: owner.id, studyStepId: step.id, aiHookId: hook.id, limitType: 0, costLimit: 10 }); + await seedLog({ userId: other.id, aiModelId: model.id, aiHookId: hook.id, studySessionId: session.id, costs: 10 }); + + const res = await begin({ + userId: other.id, aiModelId: model.id, aiHookId: hook.id, + studyId: study.id, studySessionId: session.id, studyStepId: step.id, + }); + expect(res.allowed).toBe(false); + }); + + test("TOTAL trap: same hook used outside this study does not count", async () => { + const { model, hook, study, step } = await stepSetup(); + const session = await makeSession({ studyId: study.id, userId: other.id }); + const otherStudy = await makeStudy(owner.id); + const otherSession = await makeSession({ studyId: otherStudy.id, userId: other.id }); + await seedCap({ userId: owner.id, studyStepId: step.id, aiHookId: hook.id, limitType: 0, costLimit: 10 }); + await seedLog({ userId: other.id, aiModelId: model.id, aiHookId: hook.id, studySessionId: otherSession.id, costs: 100 }); + + const res = await begin({ + userId: other.id, aiModelId: model.id, aiHookId: hook.id, + studyId: study.id, studySessionId: session.id, studyStepId: step.id, + }); + expect(res.allowed).toBe(true); + }); + + test("PER_SESSION: another session's spend does not block this one", async () => { + const { model, hook, study, step } = await stepSetup(); + const sessionA = await makeSession({ studyId: study.id, userId: other.id }); + const sessionB = await makeSession({ studyId: study.id, userId: other.id }); + await seedCap({ userId: owner.id, studyStepId: step.id, aiHookId: hook.id, limitType: 1, costLimit: 10 }); + await seedLog({ userId: other.id, aiModelId: model.id, aiHookId: hook.id, studySessionId: sessionB.id, costs: 100 }); + + const res = await begin({ + userId: other.id, aiModelId: model.id, aiHookId: hook.id, + studyId: study.id, studySessionId: sessionA.id, studyStepId: step.id, + }); + expect(res.allowed).toBe(true); + }); + + test("PER_USER: another user's spend does not block this one", async () => { + const { model, hook, study, step } = await stepSetup(); + const guestSession = await makeSession({ studyId: study.id, userId: other.id }); + const ownerSession = await makeSession({ studyId: study.id, userId: owner.id }); + await seedCap({ userId: owner.id, studyStepId: step.id, aiHookId: hook.id, limitType: 2, costLimit: 10 }); + await seedLog({ userId: owner.id, aiModelId: model.id, aiHookId: hook.id, studySessionId: ownerSession.id, costs: 100 }); + + const res = await begin({ + userId: other.id, aiModelId: model.id, aiHookId: hook.id, + studyId: study.id, studySessionId: guestSession.id, studyStepId: step.id, + }); + expect(res.allowed).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// Multiple study caps at once (TOTAL + PER_SESSION + PER_USER on one study) +// beginRequest checks every applicable cap and denies on the first one that is full. +// --------------------------------------------------------------------------- +describe("combined study caps", () => { + async function studyWithThreeCaps({ total, perSession, perUser }) { + const model = await makeModel({ userId: owner.id }); + const study = await makeStudy(owner.id); + await seedCap({ userId: owner.id, studyId: study.id, limitType: 0, costLimit: total }); + await seedCap({ userId: owner.id, studyId: study.id, limitType: 1, costLimit: perSession }); + await seedCap({ userId: owner.id, studyId: study.id, limitType: 2, costLimit: perUser }); + return { model, study }; + } + + test("allowed when spend is under all three caps", async () => { + const { model, study } = await studyWithThreeCaps({ total: 100, perSession: 100, perUser: 100 }); + const session = await makeSession({ studyId: study.id, userId: other.id }); + await seedLog({ userId: other.id, aiModelId: model.id, studySessionId: session.id, costs: 5 }); + + const res = await begin({ + userId: other.id, aiModelId: model.id, studyId: study.id, studySessionId: session.id, + }); + expect(res.allowed).toBe(true); + }); + + test("the tightest cap wins: full PER_SESSION blocks even when TOTAL and PER_USER are fine", async () => { + const { model, study } = await studyWithThreeCaps({ total: 100, perSession: 10, perUser: 100 }); + const session = await makeSession({ studyId: study.id, userId: other.id }); + await seedLog({ userId: other.id, aiModelId: model.id, studySessionId: session.id, costs: 10 }); + + const res = await begin({ + userId: other.id, aiModelId: model.id, studyId: study.id, studySessionId: session.id, + }); + expect(res.allowed).toBe(false); + }); + + test("TOTAL fires across users even when each user's PER_USER share is fine", async () => { + const { model, study } = await studyWithThreeCaps({ total: 10, perSession: 100, perUser: 100 }); + const sessionOther = await makeSession({ studyId: study.id, userId: other.id }); + const sessionOwner = await makeSession({ studyId: study.id, userId: owner.id }); + // Two users, 5 each: neither hits PER_USER (100), but together they hit TOTAL (10). + await seedLog({ userId: other.id, aiModelId: model.id, studySessionId: sessionOther.id, costs: 5 }); + await seedLog({ userId: owner.id, aiModelId: model.id, studySessionId: sessionOwner.id, costs: 5 }); + + const res = await begin({ + userId: other.id, aiModelId: model.id, studyId: study.id, studySessionId: sessionOther.id, + }); + expect(res.allowed).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// resetAt window — spend before the reset timestamp is ignored +// --------------------------------------------------------------------------- +describe("resetAt window", () => { + const YESTERDAY = () => new Date(Date.now() - 24 * 60 * 60 * 1000); + + test("spend older than resetAt is ignored (cap counts only after the reset)", async () => { + const model = await makeModel({ userId: owner.id }); + // resetAt is in the future, so a log created now is 'before' it -> excluded. + await seedCap({ userId: owner.id, aiModelId: model.id, costLimit: 10, resetAt: TOMORROW() }); + await seedLog({ userId: owner.id, aiModelId: model.id, costs: 100 }); + + const res = await begin({ userId: owner.id, aiModelId: model.id }); + expect(res.allowed).toBe(true); + }); + + test("spend after resetAt still counts", async () => { + const model = await makeModel({ userId: owner.id }); + await seedCap({ userId: owner.id, aiModelId: model.id, costLimit: 10, resetAt: YESTERDAY() }); + await seedLog({ userId: owner.id, aiModelId: model.id, costs: 10 }); + + const res = await begin({ userId: owner.id, aiModelId: model.id }); + expect(res.allowed).toBe(false); + }); +}); + diff --git a/backend/tests/rpcs/RPCtest.test.js b/backend/tests/rpcs/RPCtest.test.js index 4b4d0ff16..56968a8fc 100644 --- a/backend/tests/rpcs/RPCtest.test.js +++ b/backend/tests/rpcs/RPCtest.test.js @@ -3,9 +3,9 @@ const Server = require("../../webserver/Server.js"); describe('Test RPC call', () => { /** - * Test the RPC call + * Test the RPC healthy probe */ - test('Test call', async () => { + test('Test healthy', async () => { let server = new Server(); // wait until RPCtest service is connected @@ -14,9 +14,9 @@ describe('Test RPC call', () => { // check status expect(await server.rpcs["RPCtest"].isOnline()).toEqual(true); - // call rpc and check response - const response = await server.rpcs["RPCtest"].call("Hello") - expect(response).toEqual("World!") + // run healthy probe and check response + const response = await server.rpcs["RPCtest"].healthy(); + expect(response).toEqual("World!"); server.stop(); diff --git a/backend/utils/aiHookOutputModes.js b/backend/utils/aiHookOutputModes.js new file mode 100644 index 000000000..8d7b325ea --- /dev/null +++ b/backend/utils/aiHookOutputModes.js @@ -0,0 +1,22 @@ +'use strict'; + +const AI_HOOK_OUTPUT_MODES = Object.freeze({ + TEXT: 0, + JSON: 1, +}); + +const AI_HOOK_OUTPUT_MODE_VALUES = Object.freeze(Object.values(AI_HOOK_OUTPUT_MODES)); + +function normalizeAiHookOutputMode(value) { + const numericValue = Number(value); + if (Number.isInteger(numericValue) && AI_HOOK_OUTPUT_MODE_VALUES.includes(numericValue)) { + return numericValue; + } + + throw new Error("Invalid AI hook output mode"); +} + +module.exports = { + AI_HOOK_OUTPUT_MODES, + normalizeAiHookOutputMode, +}; diff --git a/backend/utils/helper/templateResolver.js b/backend/utils/helper/templateResolver.js index f01afeedf..32e26acfe 100644 --- a/backend/utils/helper/templateResolver.js +++ b/backend/utils/helper/templateResolver.js @@ -4,10 +4,25 @@ * Resolves template placeholders with context data and handles privacy/anonymity. * Converts Quill Delta format templates to resolved HTML or Delta format. * - * @author Mohammad Elwan + * @author Mohammad Elwan, Mohammed Rawhani */ const Delta = require("quill-delta"); -const {deltaToPlainText} = require("editor-delta-conversion"); +const fs = require("fs"); +const path = require("path"); +const {Op} = require("sequelize"); +const {deltaToPlainText, dbToDelta} = require("editor-delta-conversion"); +const {resolveNlpAssessmentDraft} = require("../studyNlpDocumentData"); +const { + applyPlaceholderReplacements, + countPlaceholdersByKey, + formatDuplicatePlaceholderToken, + getDuplicatePlaceholderIndexes, + getUsedIndexes, + hasPlaceholderForKey, + tokenInnerText, +} = require("../placeholderTokens"); +const UPLOAD_PATH = `${__dirname}/../../files`; +const TEXT_PLACEHOLDER_CHAR_CAP = 2000; /** * Extract plain text from Quill Delta operations @@ -16,9 +31,7 @@ const {deltaToPlainText} = require("editor-delta-conversion"); * @returns {string} Plain text extracted from Delta */ function extractTextFromDelta(delta) { - if (!delta || !delta.ops) { - return ""; - } + if (!delta || !delta.ops) return ""; return delta.ops .filter(op => op.insert && typeof op.insert === 'string') @@ -33,10 +46,431 @@ function extractTextFromDelta(delta) { * @returns {Object} Quill Delta object */ function textToDelta(text) { - if (!text) { + if (!text) return new Delta(); + return new Delta().insert(text); +} + +/** + * Cap text deterministically to a maximum number of characters. + * + * @param {string} text - Input text + * @param {number} cap - Max character count + * @returns {string} Input truncated to at most cap characters + */ +function capText(text, cap = TEXT_PLACEHOLDER_CHAR_CAP) { + if (typeof text !== "string") return ""; + return text.length > cap ? text.slice(0, cap) : text; +} + +/** + * Convert a placeholder value to a string for template replacement. + * Objects/arrays are serialized to JSON text. + * + * @param {*} value - Value to convert + * @returns {string} String form of value, or empty string when nullish or not serializable + */ +function normalizeReplacementValue(value) { + if (value === undefined || value === null) return ""; + if (typeof value === "string") return value; + if (typeof value === "number" || typeof value === "boolean") return String(value); + try { + return JSON.stringify(value); + } catch (_error) { + return ""; + } +} + +/** + * Load a base delta from disk for HTML/MODAL documents. + * + * @param {Object} document - Document row + * @returns {Delta} Base delta from disk, or empty delta when missing or invalid + */ +function loadDocumentBaseDelta(document) { + const deltaPath = path.join(UPLOAD_PATH, `${document.hash}.delta`); + if (!fs.existsSync(deltaPath)) { return new Delta(); } - return new Delta().insert(text); + try { + const raw = fs.readFileSync(deltaPath, "utf8"); + const parsed = raw ? JSON.parse(raw) : {}; + return new Delta(parsed.ops || []); + } catch (_error) { + return new Delta(); + } +} + +/** + * Plain text for ~editorText~ (uses context.editorText when set, else document delta + draft edits). + * + * @param {Object} models - DB models + * @param {Object} context - Resolver context + * @param {Object} options - Query options + * @returns {Promise} Capped plain text from editor context or document delta + */ +async function resolveEditorText(models, context, options = {}) { + if (context.editorText) return capText(context.editorText); + if (!context.documentId) return ""; + + const document = await models["document"].getById(context.documentId, options); + if (!document) return ""; + + const docTypes = models["document"].docTypes; + if (![docTypes.DOC_TYPE_HTML, docTypes.DOC_TYPE_MODAL].includes(document.type)) { + return ""; + } + + const baseDelta = loadDocumentBaseDelta(document); + let edits = []; + + if (context.studySessionId == null && context.studyStepId == null) { + edits = await models["document_edit"].findAll({ + where: { + documentId: document.id, + studySessionId: null, + studyStepId: null, + draft: true, + deleted: false, + }, + order: [["createdAt", "ASC"], ["order", "ASC"]], + raw: true, + ...options, + }); + } else { + const allEdits = await models["document_edit"].findAll({ + where: { + documentId: document.id, + deleted: false, + }, + order: [["createdAt", "ASC"], ["order", "ASC"]], + raw: true, + ...options, + }); + edits = allEdits.filter((edit) => + edit.draft === true && + (edit.studySessionId === context.studySessionId || edit.studySessionId === null) + ); + } + + const mergedDelta = baseDelta.compose(new Delta(dbToDelta(edits))); + return capText(deltaToPlainText({ops: mergedDelta.ops})); +} + +/** + * Load merged document_data values for current document/session/step context. + * Session/step-specific keys override global null/null keys. + * + * @param {Object} models - DB models + * @param {Object} context - Resolver context + * @param {Object} options - Query options + * @returns {Promise} Merged document_data key/value map for the current context + */ +async function getMergedDocumentData(models, context, options = {}) { + if (!context.documentId) return {}; + + const where = { + documentId: context.documentId, + deleted: false, + }; + + if (context.studySessionId != null && context.studyStepId != null) { + where[Op.or] = [ + {studySessionId: context.studySessionId, studyStepId: context.studyStepId}, + {studySessionId: null, studyStepId: null}, + ]; + } else { + where.studySessionId = null; + where.studyStepId = null; + } + + const rows = await models["document_data"].findAll({ + where, + order: [["updatedAt", "ASC"]], + raw: true, + ...options, + }); + + const merged = {}; + for (const row of rows) { + merged[row.key] = row.value; + } + return merged; +} + +/** + * Read per-index mapping for a placeholder key from context. + * + * @param {Object} context - Resolver context + * @param {string} baseKey - Placeholder key without tildes + * @returns {Object|null} Per-index placeholder mapping + */ +function getPlaceholderMappingForKey(context, baseKey) { + const mappingRoot = context.placeholderMapping; + if (mappingRoot && mappingRoot[baseKey] != null) { + return mappingRoot[baseKey]; + } + return null; +} + +/** + * Resolve one entry from a placeholder mapping at an index. + * + * @param {Object} mapping - Placeholder mapping + * @param {number} index - Placeholder index + * @returns {*} Mapped value or undefined + */ +function resolveMappingEntry(mapping, index) { + if (mapping == null) return undefined; + if (Array.isArray(mapping)) { + return mapping[index - 1]; + } + if (typeof mapping === "object") { + return mapping[index] ?? mapping[String(index)]; + } + return undefined; +} + +/** + * Plain text for a document id (used by indexed submissionFiles placeholders). + * + * @param {number} documentId - Document id + * @param {Object} models - DB models + * @param {Object} context - Resolver context + * @param {Object} options - Query options + * @returns {Promise} Capped plain text for the document id + */ +async function resolveDocumentPlainText(documentId, models, context, options = {}) { + if (!documentId) return ""; + + const submissionPdfTexts = context.submissionPdfTexts && typeof context.submissionPdfTexts === "object" + ? context.submissionPdfTexts + : null; + if (submissionPdfTexts) { + const fromMap = + submissionPdfTexts[documentId] ?? + submissionPdfTexts[String(documentId)]; + if (fromMap) { + return capText(fromMap); + } + } + if (context.pdfText && Number(context.documentId) === Number(documentId)) { + return capText(context.pdfText); + } + + const extracted = await models["document"].loadPlainText(documentId); + return extracted ? capText(extracted) : ""; +} + +/** + * Resolve placeholder value from a replacement map. + * + * @param {string} baseKey - Placeholder key + * @param {number} index - Placeholder index, or null for unbracketed ~key~ + * @param {Object} replacements - Map of ~token~ to resolved string + * @returns {string|undefined} Resolved replacement value, or undefined when not in the map + */ +function resolveReplacementForToken(baseKey, index, replacements) { + if (index != null) { + const bracketToken = `~${baseKey}[${index}]~`; + if (Object.prototype.hasOwnProperty.call(replacements, bracketToken)) { + return replacements[bracketToken]; + } + } + const legacyToken = `~${baseKey}~`; + if (Object.prototype.hasOwnProperty.call(replacements, legacyToken)) { + return replacements[legacyToken]; + } + return undefined; +} + +/** + * Add per-index ~submissionFiles[N]~ replacements from context.placeholderMapping. + * + * @param {string} text - Template plain text + * @param {Object} replacements - Mutable replacement map + * @param {Object} context - Resolver context + * @param {Object} models - DB models + * @param {Object} options - Query options + * @returns {Promise} Resolves nothing; mutates replacements with per-index submission file text + */ +async function addIndexedSubmissionFileReplacements(text, replacements, context, models, options = {}) { + const indexes = getUsedIndexes(text, "submissionFiles"); + if (indexes.length === 0) return; + const mapping = getPlaceholderMappingForKey(context, "submissionFiles"); + for (const index of indexes) { + const documentId = resolveMappingEntry(mapping, index); + const fileText = await resolveDocumentPlainText(documentId, models, context, options); + replacements[`~submissionFiles[${index}]~`] = fileText; + } +} + +/** + * Resolve prompt-specific placeholders (type 8) from context and database. It is similar to the payload that NLP skills had built. + * + * @param {Object} context - Resolver context + * @param {Object} models - DB models + * @param {Function} allow - Allowed-key checker + * @param {Object} options - Query options + * @returns {Promise} Map of ~token~ keys to resolved prompt placeholder values + */ +async function buildPromptPlaceholderValues(context, models, allow, options = {}) { + const promptValues = {}; + + // Fetch the anchor step once and derive the document/study from it when the caller supplied + // only a step id. This makes the step id the single required input and avoids callers (and + // this function) fetching the same row twice. + let studyStep = null; + if (context.studyStepId) { + studyStep = await models["study_step"].getById(context.studyStepId, options); + if (studyStep) { + if (context.documentId == null) { + context.documentId = studyStep.documentId ?? null; + } + if (context.studyId == null) { + context.studyId = studyStep.studyId ?? null; + } + } + } + + const mergedDocumentData = await getMergedDocumentData(models, context, options); + + if (allow("pdfText")) { + // Prefer caller-supplied text; otherwise extract it from the document on demand + // (loadPlainText returns "" for non file-based types, e.g. editor/modal documents). + let pdfText = context.pdfText; + if (!pdfText && context.documentId) { + pdfText = await models["document"].loadPlainText(context.documentId); + } + promptValues["~pdfText~"] = pdfText ? capText(pdfText) : ""; + } + + if (allow("editorText")) { + promptValues["~editorText~"] = await resolveEditorText(models, context, options); + } + + if (allow("assessmentResult")) { + promptValues["~assessmentResult~"] = mergedDocumentData.assessment_result || ""; + } + + if (allow("inlineComments")) { + const comments = await models["comment"].findAll({ + where: { + documentId: context.documentId || null, + studySessionId: context.studySessionId || null, + studyStepId: context.studyStepId || null, + deleted: false, + }, + order: [["createdAt", "ASC"]], + raw: true, + ...options, + }); + + const annotationsById = {}; + if (comments.length > 0) { + const annotationIds = [...new Set(comments.map((comment) => comment.annotationId).filter(Boolean))]; + if (annotationIds.length > 0) { + const annotations = await models["annotation"].findAll({ + where: {id: annotationIds, deleted: false}, + raw: true, + ...options, + }); + for (const annotation of annotations) { + annotationsById[annotation.id] = annotation; + } + } + } + + promptValues["~inlineComments~"] = comments.map((comment) => ({ + id: comment.id, + comment: comment.text || "", + quote: annotationsById[comment.annotationId]?.text || "", + annotationId: comment.annotationId || null, + createdAt: comment.createdAt || null, + })); + } + + if (allow("nlpAssessmentSuggestion")) { + let nlpAssessmentSuggestion = ""; + if ( + context.documentId && + context.studySessionId != null && + context.studyStepId && + studyStep?.configuration + ) { + nlpAssessmentSuggestion = resolveNlpAssessmentDraft( + mergedDocumentData, + studyStep.configuration + ); + } + promptValues["~nlpAssessmentSuggestion~"] = nlpAssessmentSuggestion; + } + + if (allow("previousAssessmentResult")) { + let previous = ""; + if (context.studyStepId && context.studySessionId != null && studyStep?.studyStepPrevious) { + const prevStep = await models["study_step"].getById(studyStep.studyStepPrevious, options); + if (prevStep?.documentId) { + const prevRows = await models["document_data"].findAll({ + where: { + documentId: prevStep.documentId, + studySessionId: context.studySessionId, + studyStepId: prevStep.id, + key: "assessment_result", + deleted: false, + }, + order: [["updatedAt", "DESC"]], + limit: 1, + raw: true, + ...options, + }); + previous = prevRows[0]?.value || ""; + } + } + promptValues["~previousAssessmentResult~"] = previous; + } + + if (allow("assessmentConfiguration")) { + let assessmentConfiguration = ""; + if (studyStep) { + const configurationId = studyStep.configuration?.settings?.configurationId || null; + if (configurationId) { + const configuration = await models["configuration"].getById(configurationId, options); + assessmentConfiguration = configuration?.content || ""; + } else { + assessmentConfiguration = studyStep.configuration || ""; + } + } + promptValues["~assessmentConfiguration~"] = assessmentConfiguration; + } + + // submissionFiles uses ~submissionFiles[N]~ tokens resolved via placeholderMapping in resolveTemplate. + + if (allow("studyContext")) { + let studyName = ""; + let stepName = ""; + let documentTitle = ""; + + if (studyStep) { + stepName = `Step ${studyStep.stepNumber || ""}`.trim(); + if (studyStep.studyId) { + const study = await models["study"].getById(studyStep.studyId, options); + studyName = study?.name || ""; + } + } + + if (context.documentId) { + const document = await models["document"].getById(context.documentId, options); + documentTitle = document?.name || ""; + } + + promptValues["~studyContext~"] = { + studyName, + stepName, + documentTitle, + }; + } + + return promptValues; } /** @@ -131,6 +565,27 @@ async function buildReplacementMap(context, models, options = {}) { replacements["~timestamp~"] = context.timestamp; } + const promptKeys = [ + "pdfText", + "editorText", + "assessmentResult", + "inlineComments", + "nlpAssessmentSuggestion", + "previousAssessmentResult", + "assessmentConfiguration", + "submissionFiles", + "studyContext", + ]; + const shouldResolvePromptPlaceholders = promptKeys.some((key) => allow(key)); + if (shouldResolvePromptPlaceholders) { + const promptReplacements = await buildPromptPlaceholderValues(context, models, allow, options); + Object.assign(replacements, promptReplacements); + } + + for (const key of Object.keys(replacements)) { + replacements[key] = normalizeReplacementValue(replacements[key]); + } + return replacements; } @@ -143,9 +598,7 @@ async function buildReplacementMap(context, models, options = {}) { * @returns {Promise} True if study anonymizes data */ async function shouldAnonymize(studyId, models, options = {}) { - if (!studyId) { - return false; - } + if (!studyId) return false; const study = await models["study"].getById(studyId, options); return study ? (study.anonymize === true) : false; @@ -163,9 +616,7 @@ async function shouldAnonymize(studyId, models, options = {}) { */ async function getTemplateContentForLanguage(templateId, language, models, options = {}) { const templateContentModel = models["template_content"]; - if (!templateContentModel) { - return null; - } + if (!templateContentModel) return null; const row = await templateContentModel.findOne({ where: { templateId, language, deleted: false }, raw: true, @@ -221,12 +672,12 @@ async function resolveTemplate(templateId, context, models, options = {}) { const replacements = await buildReplacementMap(context, models, options); const text = deltaToPlainText(content); - let resolvedText = text; - for (const [placeholder, value] of Object.entries(replacements)) { - const escapedPlaceholder = placeholder.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - const regex = new RegExp(escapedPlaceholder, 'g'); - resolvedText = resolvedText.replace(regex, value || ""); + if (template.type === 8) { + await addIndexedSubmissionFileReplacements(text, replacements, context, models, options); } + let resolvedText = applyPlaceholderReplacements(text, (baseKey, index) => { + return resolveReplacementForToken(baseKey, index, replacements); + }); // Make URLs clickable: split by URL pattern, escape non-URL parts, wrap URLs in const urlPattern = /(https?:\/\/\S+)/g; @@ -294,25 +745,17 @@ async function resolveTemplateToDelta(templateId, context, models, options = {}) originalDelta = new Delta(content.ops); } - let text = extractTextFromDelta(originalDelta); - let resolvedText = text; - - for (const [placeholder, value] of Object.entries(replacements)) { - const escapedPlaceholder = placeholder.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - const regex = new RegExp(escapedPlaceholder, 'g'); - resolvedText = resolvedText.replace(regex, value || ""); + const text = extractTextFromDelta(originalDelta); + if (template.type === 8) { + await addIndexedSubmissionFileReplacements(text, replacements, context, models, options); } + const resolveToken = (baseKey, index) => resolveReplacementForToken(baseKey, index, replacements); const resolvedDelta = new Delta(); for (const op of originalDelta.ops) { if (op.insert && typeof op.insert === 'string') { - let insertText = op.insert; - for (const [placeholder, value] of Object.entries(replacements)) { - const escapedPlaceholder = placeholder.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - const regex = new RegExp(escapedPlaceholder, 'g'); - insertText = insertText.replace(regex, value || ""); - } + let insertText = applyPlaceholderReplacements(op.insert, resolveToken); if (op.attributes) { resolvedDelta.insert(insertText, op.attributes); @@ -337,7 +780,7 @@ async function resolveTemplateToDelta(templateId, context, models, options = {}) * Return placeholder keys that are required for the given template type but missing in content. * * @param {Object} content - Quill Delta object with ops array - * @param {number} templateType - Template type (e.g. 1, 2, 3, 6, 7) + * @param {number} templateType - Template type (e.g. 1, 2, 3, 6, 7, 8) * @param {Object} models - Database models * @param {Object} [options] * @returns {Promise} Array of missing required placeholder keys (e.g. ['link']) @@ -348,14 +791,134 @@ async function getMissingRequiredPlaceholders(content, templateType, models, opt if (requiredKeys.length === 0) return []; const text = deltaToPlainText(content && content.ops ? { ops: content.ops } : content); + const bracketOnly = templateType === 8; const missing = []; for (const key of requiredKeys) { - const token = `~${key}~`; - if (!text.includes(token)) missing.push(key); + if (!hasPlaceholderForKey(text, key, { bracketOnly })) { + missing.push(key); + } } return missing; } +/** + * Return duplicate placeholder token strings for allowed keys in template content. + * + * @param {Object} content - Quill Delta object with ops array + * @param {number} templateType - Template type + * @param {Object} models - Database models + * @param {Object} [options] + * @returns {Promise} Duplicate placeholder token strings + */ +async function getDuplicatePlaceholderIds(content, templateType, models, options = {}) { + const rows = await models["placeholder"].getAllByKey("type", templateType, options); + const allowedKeys = new Set(rows.map((row) => row.placeholderKey)); + const contentDelta = content && content.ops ? { ops: content.ops } : content; + const text = deltaToPlainText(contentDelta); + const duplicates = getDuplicatePlaceholderIndexes(text) + .filter((entry) => allowedKeys.has(entry.key)) + .map((entry) => formatDuplicatePlaceholderToken(entry)); + return duplicates; +} + +/** + * Resolve a prompt template by substituting caller-supplied placeholder values (push model). + * + * Unlike {@link resolveTemplate}, this does NOT query the database for placeholder data — the + * caller provides a `{ placeholderKey: value }` map (e.g. assembled in the frontend and the runhook function from the + * input mapping). Each `~placeholderKey[N]~` token is replaced by its value (objects/arrays are + * JSON-stringified). Used by the AI-hook runtime. + * + * @param {number} templateId - Prompt template id. + * @param {Object} values - Map of placeholderKey → value (optional `language`). + * @param {Object} models - Database models object. + * @param {Object} [options] - Sequelize options (e.g. transaction). + * @returns {Promise} Resolved prompt as plain text. + * @throws {Error} If the template is missing. + */ +async function resolveTemplateWithValues(templateId, values, models, options = {}) { + if (!templateId) { + throw new Error("Template ID is required"); + } + if (!models) { + throw new Error("Models object is required"); + } + + const template = await models["template"].getById(templateId, options); + if (!template) { + throw new Error(`Template with ID ${templateId} not found`); + } + + const language = (values && values.language) || template.defaultLanguage || "en"; + let content = await getTemplateContentForLanguage(templateId, language, models, options); + if (!content && language !== (template.defaultLanguage || "en")) { + content = await getTemplateContentForLanguage(templateId, template.defaultLanguage || "en", models, options); + } + + let resolvedText = deltaToPlainText(content || {ops: []}); + const toText = (value) => { + if (value === null || value === undefined) return ""; + return typeof value === "string" ? value : JSON.stringify(value); + }; + const valueMap = values || {}; + resolvedText = applyPlaceholderReplacements(resolvedText, (baseKey, index) => { + if (index != null) { + const inner = tokenInnerText(baseKey, index); + if (Object.prototype.hasOwnProperty.call(valueMap, inner)) { + return toText(valueMap[inner]); + } + return undefined; + } + if (template.type !== 8 && Object.prototype.hasOwnProperty.call(valueMap, baseKey)) { + return toText(valueMap[baseKey]); + } + return undefined; + }); + return resolvedText; +} + +/** + * Return placeholder catalog rows that appear in a template's content. + * Each row includes usedIndexes and occurrenceCount for hook input mapping. + * + * @param {number} templateId - Template id + * @param {Object} models + * @param {Object} [options] + * @returns {Promise} Placeholder catalog rows used in the template, with usedIndexes and occurrenceCount + * @throws {Error} When the template id is invalid + */ +async function getUsedPlaceholders(templateId, models, options = {}) { + const template = await models["template"].getById(templateId, options); + if (!template) { + throw new Error(`Template with ID ${templateId} not found`); + } + const content = await getTemplateContentForLanguage( + templateId, template.defaultLanguage || "en", models, options + ); + const text = content ? deltaToPlainText(content) : ""; + const bracketOnly = template.type === 8; + const countsByKey = countPlaceholdersByKey(text, { bracketOnly }); + const rows = await models["placeholder"].getAllByKey("type", template.type, options); + return rows + .filter((row) => { + if (bracketOnly) { + return getUsedIndexes(text, row.placeholderKey).length > 0; + } + return countsByKey[row.placeholderKey] > 0; + }) + .map((row) => { + let usedIndexes = getUsedIndexes(text, row.placeholderKey); + if (!bracketOnly && text.includes(`~${row.placeholderKey}~`) && !usedIndexes.includes(1)) { + usedIndexes = [1, ...usedIndexes].sort((a, b) => a - b); + } + return { + ...row, + occurrenceCount: countsByKey[row.placeholderKey] || usedIndexes.length, + usedIndexes, + }; + }); +} + function formatMissingPlaceholderError(missing, { action = "saving", language } = {}) { const tokens = missing.map((k) => `~${k}~`).join(", "); if (language) { @@ -370,8 +933,9 @@ function formatMissingPlaceholderError(missing, { action = "saving", language } * * @param {number} templateId * @param {Object} models - * @param {Object} - * @returns {Promise} + * @param {Object} options + * @returns {Promise} Resolves when required placeholders are present + * @throws {Error} When a required placeholder is missing from stable template content */ async function assertStableEmailTemplateContent(templateId, models, options = {}) { const action = options.action || "publishing"; @@ -409,7 +973,11 @@ async function assertStableEmailTemplateContent(templateId, models, options = {} module.exports = { resolveTemplate, resolveTemplateToDelta, + resolveTemplateWithValues, getMissingRequiredPlaceholders, + getDuplicatePlaceholderIds, + getUsedPlaceholders, + resolveEditorText, formatMissingPlaceholderError, assertStableEmailTemplateContent, }; \ No newline at end of file diff --git a/backend/utils/placeholderTokens.js b/backend/utils/placeholderTokens.js new file mode 100644 index 000000000..31e9ac667 --- /dev/null +++ b/backend/utils/placeholderTokens.js @@ -0,0 +1,229 @@ +/** + * Parse and format placeholder tokens in template text (~key~, ~key[N]~, ~key[N]{options}~). + * + * Placeholders in a template are written as tilded tokens in the editor text, for example + * ~pdfText[1]~ or ~submissionFiles[2]{characterLimit:5000}~. That string is the placeholder + * token — it is not the placeholder definition row in the database. + * + * This module reads, builds, and substitutes those tokens when a template is resolved. + * + * @author Mohammad Elwan + */ + +/** Matches ~placeholderKey~ and ~placeholderKey[N]~. */ +const PLACEHOLDER_TOKEN_REGEX = /~([A-Za-z0-9_]+)(?:\[(\d+)\])?~/g; + +/** + * Parse capture groups from PLACEHOLDER_TOKEN_REGEX exec result. + * + * @param {RegExpExecArray} match - Regex exec result + * @returns {Object} Parsed placeholder token + */ +function parsePlaceholderMatch(match) { + if (!match || !match[1]) { + return { baseKey: "", index: null }; + } + return { + baseKey: match[1], + index: match[2] ? parseInt(match[2], 10) : null, + }; +} + +/** + * Format a bracket-indexed placeholder token. + * + * @param {string} baseKey - Placeholder key without tildes + * @param {number} index - Placeholder index + * @returns {string} Token string e.g. ~link[3]~ + */ +function formatPlaceholderToken(baseKey, index) { + return `~${baseKey}[${index}]~`; +} + +/** + * Inner text between tildes for mapping and hook value keys. + * + * @param {string} baseKey - Placeholder key + * @param {number} index - Placeholder index + * @returns {string} Inner token text e.g. submissionFiles[2] + */ +function tokenInnerText(baseKey, index) { + return `${baseKey}[${index}]`; +} + +/** + * List indexes used for a placeholder key in template text. + * + * @param {string} text - Template plain text or HTML scan text + * @param {string} baseKey - Placeholder key + * @returns {Array} Sorted unique indexes + */ +function getUsedIndexes(text, baseKey) { + if (!text || !baseKey) { + return []; + } + const indexes = []; + const regex = new RegExp(PLACEHOLDER_TOKEN_REGEX.source, "g"); + let match; + while ((match = regex.exec(text)) !== null) { + const parsed = parsePlaceholderMatch(match); + if (parsed.baseKey === baseKey && parsed.index != null) { + indexes.push(parsed.index); + } + } + return [...new Set(indexes)].sort((a, b) => a - b); +} + +/** + * Next index to assign when adding a placeholder from the sidebar. + * + * @param {string} text - Template content scan text + * @param {string} baseKey - Placeholder key + * @returns {number} Next index (max existing + 1, or 1) + */ +function getNextPlaceholderIndex(text, baseKey) { + const indexes = getUsedIndexes(text, baseKey); + if (indexes.length === 0) { + return 1; + } + return Math.max(...indexes) + 1; +} + +/** + * Count placeholder instances per base key. + * + * @param {string} text - Template content scan text + * @param {Object} [options] - Options + * @param {boolean} [options.bracketOnly] - When true, skip unbracketed ~key~ tokens (default: false) + * @returns {Object} Map of placeholder key to occurrence count + */ +function countPlaceholdersByKey(text, options = {}) { + const { bracketOnly = false } = options; + const counts = {}; + if (!text) { + return counts; + } + const regex = new RegExp(PLACEHOLDER_TOKEN_REGEX.source, "g"); + let match; + while ((match = regex.exec(text)) !== null) { + const parsed = parsePlaceholderMatch(match); + if (!parsed.baseKey) { + continue; + } + if (bracketOnly && parsed.index == null) { + continue; + } + counts[parsed.baseKey] = (counts[parsed.baseKey] || 0) + 1; + } + return counts; +} + +/** + * Whether a required placeholder key appears in content. + * + * @param {string} text - Template content + * @param {string} baseKey - Placeholder key + * @param {Object} [options] - Options + * @param {boolean} [options.bracketOnly] - When true, require ~key[N]~ only (default: false) + * @returns {boolean} + */ +function hasPlaceholderForKey(text, baseKey, options = {}) { + const { bracketOnly = false } = options; + if (!text || !baseKey) { + return false; + } + if (!bracketOnly && text.includes(`~${baseKey}~`)) { + return true; + } + return getUsedIndexes(text, baseKey).length > 0; +} + +/** + * Find duplicate ~key[N]~ tokens (same key and same index). + * Unbracketed ~key~ tokens are not checked. + * + * @param {string} text - Template content + * @param {string} baseKeyFilter - Optional placeholder key filter + * @returns {Array} Duplicate key/index pairs + */ +function getDuplicatePlaceholderIndexes(text, baseKeyFilter) { + const seen = new Map(); + const duplicates = []; + if (!text) { + return duplicates; + } + const regex = new RegExp(PLACEHOLDER_TOKEN_REGEX.source, "g"); + let match; + while ((match = regex.exec(text)) !== null) { + const parsed = parsePlaceholderMatch(match); + if (!parsed.baseKey || parsed.index == null) { + continue; + } + if (baseKeyFilter && parsed.baseKey !== baseKeyFilter) { + continue; + } + const indexKey = String(parsed.index); + if (!seen.has(parsed.baseKey)) { + seen.set(parsed.baseKey, new Map()); + } + const indexMap = seen.get(parsed.baseKey); + indexMap.set(indexKey, (indexMap.get(indexKey) || 0) + 1); + } + for (const [key, indexMap] of seen) { + for (const [indexKey, count] of indexMap) { + if (count > 1) { + duplicates.push({ + key, + index: parseInt(indexKey, 10), + }); + } + } + } + return duplicates; +} + +/** + * Format a duplicate entry as a token string for error messages. + * + * @param {Object} entry - Duplicate placeholder entry + * @returns {string} Token string e.g. ~link[2]~ + */ +function formatDuplicatePlaceholderToken(entry) { + return formatPlaceholderToken(entry.key, entry.index); +} + +/** + * Replace placeholder tokens using a resolver callback. + * + * @param {string} text - Input text + * @param {Function} resolveValue - Resolver for each matched token + * @returns {string} Text with placeholders replaced + */ +function applyPlaceholderReplacements(text, resolveValue) { + if (!text || typeof resolveValue !== "function") { + return text || ""; + } + const regex = new RegExp(PLACEHOLDER_TOKEN_REGEX.source, "g"); + return text.replace(regex, (match, baseKey, indexStr) => { + const index = indexStr ? parseInt(indexStr, 10) : null; + const value = resolveValue(baseKey, index); + if (value === undefined || value === null) { + return match; + } + return String(value); + }); +} + +module.exports = { + PLACEHOLDER_TOKEN_REGEX, + parsePlaceholderMatch, + formatPlaceholderToken, + tokenInnerText, + getUsedIndexes, + getNextPlaceholderIndex, + countPlaceholdersByKey, + hasPlaceholderForKey, + getDuplicatePlaceholderIndexes, + formatDuplicatePlaceholderToken, + applyPlaceholderReplacements, +}; diff --git a/backend/utils/studyNlpDocumentData.js b/backend/utils/studyNlpDocumentData.js new file mode 100644 index 000000000..32ee2eb0e --- /dev/null +++ b/backend/utils/studyNlpDocumentData.js @@ -0,0 +1,142 @@ +"use strict"; + +/** + * Study-step NLP document_data key helpers. + * + * Keys match NlpRequest.saveResult: {service.name}_{service.skill}_{resultField}. + * Service discovery for assessment drafts matches Assessment.vue (nlpService / + * preprocessedAssessmentKeyCandidates). + * + * @author Mohammad Elwan + */ + +const NLP_ASSESSMENT_RESULT_FIELD = "assessment"; + +/** + * Turn a config label into a safe segment for a document_data key. + * + * Hook and service names from step configuration may contain spaces; keys use underscores + * instead (e.g. "Essay feedback" → "Essay_feedback"). Non-string input is returned unchanged. + * + * @param {string} value - Hook name or other label from step configuration + * @returns {string|*} Sanitized segment for key building, or the original value when not a string + */ +function normalizeDocumentDataKeyPart(value) { + return typeof value === "string" ? value.trim().replace(/\s+/g, "_") : value; +} + +/** + * Build document_data key for AI hook results saved from a study step. + * + * @param {string} serviceName - service.name or service.type from step configuration + * @param {string} hookName - service.hookName from step configuration + * @returns {string} Key in the form `{serviceName}_{hookName}` + */ +function buildStudyHookKey(serviceName, hookName) { + return `${serviceName}_${normalizeDocumentDataKeyPart(hookName)}`; +} + +/** + * Build exact document_data key for study-step NLP save (NlpRequest.saveResult). + * + * @param {string} serviceName - service.name from step configuration + * @param {string} skill - service.skill from step configuration + * @param {string} resultField - top-level field name in NLP JSON response + * @returns {string} Key in the form `{serviceName}_{skill}_{resultField}` + */ +function buildStudyNlpKey(serviceName, skill, resultField) { + return `${serviceName}_${skill}_${resultField}`; +} + +/** + * Build candidate keys for reading study-step NLP or AI-hook data. + * + * @param {Object} service - Step service entry with name, type, and skill + * @param {string} resultField - top-level field name in NLP JSON response + * @returns {string[]} Candidate document_data keys to try, in priority order + */ +function getStudyNlpKeyCandidates(service, resultField) { + if (!service) return []; + + if (service.hookId) { + const keys = [service.name, service.type].filter(Boolean); + return [...new Set(keys)]; + } + + if (!service.skill || !resultField) return []; + + const keys = [ + service.name ? buildStudyNlpKey(service.name, service.skill, resultField) : null, + service.type ? buildStudyNlpKey(service.type, service.skill, resultField) : null, + ].filter(Boolean); + + return [...new Set(keys)]; +} + +/** + * Return the value for the first candidate key present in merged document_data. + * + * @param {Object} mergedData - Key/value map from getMergedDocumentData + * @param {string[]} candidateKeys - Keys to try in order + * @returns {*} Stored value for the first matching key, or empty string when none match + */ +function firstMergedValue(mergedData, candidateKeys) { + if (!mergedData || !Array.isArray(candidateKeys)) return ""; + + for (const key of candidateKeys) { + if (Object.prototype.hasOwnProperty.call(mergedData, key)) { + return mergedData[key]; + } + } + + return ""; +} + +/** + * Find the NLP assessment service on a study step (Assessment.vue nlpService). + * + * @param {Object} stepConfiguration - study_step.configuration + * @returns {Object|null} Matching service entry, or null when none is configured + */ +function findAssessmentNlpService(stepConfiguration) { + const stepConfig = stepConfiguration; + if (!stepConfig || !Array.isArray(stepConfig.services) || !stepConfig.services.length) { + return null; + } + + const nlpService = + stepConfig.services.find( + (service) => + (service.skill || service.hookId) && + (service.name === "nlpAssessment" || service.type === "nlpRequest") + ) || stepConfig.services[0]; + + return nlpService || null; +} + +/** + * Resolve NLP assessment draft for ~nlpAssessmentSuggestion~ from merged document_data. + * + * @param {Object} mergedData - Key/value map from getMergedDocumentData + * @param {Object} stepConfiguration - study_step.configuration + * @returns {*} Draft assessment payload, or empty string when not found + */ +function resolveNlpAssessmentDraft(mergedData, stepConfiguration) { + const nlpService = findAssessmentNlpService(stepConfiguration); + if (!nlpService) return ""; + + const candidateKeys = getStudyNlpKeyCandidates(nlpService, NLP_ASSESSMENT_RESULT_FIELD); + const value = firstMergedValue(mergedData, candidateKeys); + return value === undefined || value === null ? "" : value; +} + +module.exports = { + NLP_ASSESSMENT_RESULT_FIELD, + normalizeDocumentDataKeyPart, + buildStudyHookKey, + buildStudyNlpKey, + getStudyNlpKeyCandidates, + firstMergedValue, + findAssessmentNlpService, + resolveNlpAssessmentDraft, +}; diff --git a/backend/webserver/RPC.js b/backend/webserver/RPC.js index a9abad3b8..fa2386ac7 100644 --- a/backend/webserver/RPC.js +++ b/backend/webserver/RPC.js @@ -30,7 +30,6 @@ module.exports = class RPC { async init() { await this.reset(); - // connect to test service this.socket = io_client(this.url, { reconnection: true, @@ -77,7 +76,7 @@ module.exports = class RPC { // Handle reconnection attempts socket.on("reconnection_attempt", () => { - self.logger.error("RPC Test Reconnection attempt..."); + self.logger.error("RPC reconnection attempt..."); }); // establishing a connection @@ -139,64 +138,98 @@ module.exports = class RPC { } /** - * This method returns the status of the RPC. + * Returns a combined status for this RPC: + * - `online`: whether the local socket is connected + * - `health`: the payload returned by the Python RPC's "healthy" handler + * (omitted if the RPC does not implement it) + * - `error`: the error message if the health probe failed for any other reason * - * @returns {Promise<{}>} an object describing the status + * @returns {Promise<{online: boolean, health?: object, error?: string}>} */ async getStatus() { - return {}; + const online = await this.isOnline(); + if (!online) { + return {online: false}; + } + try { + const health = await this.healthy(); + return {online: true, health}; + } catch (err) { + if (err.message === "Not Implemented") { + return {online: true}; + } + return {online: true, error: err.message}; + } } /** - * This emits an event to the RPC service with handling the acknowledgement response accordingly + * Emits an event to the RPC service and returns the ack response. * - * @param event - * @param data + * @param {string} event + * @param {*} data + * @param {number} [timeoutMs] override of the default `this.timeout` for this call * @returns {Promise} */ - async emit(event, data) { + async emit(event, data, timeoutMs = this.timeout) { this.logger.info("Emitting event to RPC service..."); if (!this.socket) { throw new Error("RPC service not connected"); } - if (!this.socket) { - throw new Error("RPC service not connected"); - } - - try { - return new Promise((resolve, reject) => { - this.socket.timeout(this.timeout).emit(event, data, (err, response) => { - if (err) { - this.logger.error(err); - reject(err); - } else { - this.logger.info(response); - resolve(response); - } - }); + return new Promise((resolve, reject) => { + this.socket.timeout(timeoutMs).emit(event, data, (err, response) => { + if (err) { + this.logger.error(err); + reject(err); + } else { + this.logger.info(response); + resolve(response); + } }); - - } catch (err) { - throw err; - } + }); } /** - * This method should be overwritten to handle the call to the RPC service - * @param data - * @returns {Promise<*>} + * Standard health check for an RPC. Emits a "healthy" event to the + * Python RPC service and returns its response. + * + * By convention, Python RPCs that implement a health check respond with + * either: + * {success: true, data: {...}} - service is healthy + * {success: false, message: "Not Implemented"} - opt-out + * + * Throws Error("Not Implemented") when: + * - the Python RPC explicitly replied with that message + * - the ack times out (python-socketio silently drops events without a + * registered handler, so timeout is our best signal for a missing + * implementation on the other side) + * + * Other errors (e.g. transport failures) propagate as-is. + * + * @param {number} [timeoutMs=5000] ack timeout in ms + * @returns {Promise<*>} the `data` returned by the Python RPC + * @throws {Error} */ - async call(data) { - this.logger.info("Calling RPC service..."); - + async healthy(timeoutMs = 5000) { + let response; try { - return this.emit("call", data); + response = await this.emit("healthy", {}, timeoutMs); } catch (err) { - throw err + if (err && err.message && /timed out|timeout/i.test(err.message)) { + throw new Error("Not Implemented"); + } + throw err; + } + + if (response && response.success === false) { + if (response.message === "Not Implemented") { + throw new Error("Not Implemented"); + } + throw new Error(response.message || "Health check failed"); } + return response && response.data !== undefined ? response.data : response; } } \ No newline at end of file diff --git a/backend/webserver/Socket.js b/backend/webserver/Socket.js index 2b1eab34e..473941678 100644 --- a/backend/webserver/Socket.js +++ b/backend/webserver/Socket.js @@ -716,7 +716,7 @@ module.exports = class Socket { if (filter.length > 0) { allFilter[Op.or] = filter; } - const defaultExcludes = ["deleted", "deletedAt", "rolesUpdatedAt", "initialPassword", "passwordHash", "salt"]; + const defaultExcludes = ["deleted", "deletedAt", "rolesUpdatedAt", "initialPassword", "passwordHash", "salt","apiKey"]; let allAttributes = { exclude: defaultExcludes, }; diff --git a/backend/webserver/rpcs/liteLLMRPC.js b/backend/webserver/rpcs/liteLLMRPC.js new file mode 100644 index 000000000..c4e107469 --- /dev/null +++ b/backend/webserver/rpcs/liteLLMRPC.js @@ -0,0 +1,133 @@ +const RPC = require("../RPC.js"); +const {normalizeAiHookOutputMode} = require("../../utils/aiHookOutputModes.js"); + +const ACK_TIMEOUT_BUFFER_MS = 5000; + +/** + * LiteLLMRPC - Routes LLM requests through LiteLLM for external and local model access + * + * Pure passthrough: the caller supplies the model, messages, API key, and any + * provider-specific parameters. Nothing is hardcoded here; the bridge forwards + * everything to LiteLLM as-is. + * + * @author Akash Gundapuneni + * @class + * @extends RPC + */ +module.exports = class LiteLLMRPC extends RPC { + + constructor(server) { + const url = "ws://" + process.env.RPC_LITELLM_HOST + ":" + process.env.RPC_LITELLM_PORT; + super(server, url); + + this.timeout = 120000; + } + + /** + * Send a chat completion request to LiteLLM. + * All fields in `data` are forwarded to the Python bridge verbatim. + * At minimum the caller must provide `model` and `messages`. + * + * @param {Object} data - Arbitrary params forwarded to litellm.completion() + * @param {string} data.model - Model identifier (provider-specific, e.g. "gpt-4o", "ollama/llama3") + * @param {Array} data.messages - OpenAI-format messages array + * @param {number} [data.outputMode] - Internal CARE output mode. `1` repairs JSON content in the Python bridge. + * @returns {Promise} LiteLLM response with choices and usage + * @throws {Error} If the RPC service call fails + */ + async chatCompletion(data) { + const { + __requestId: requestId, + __timeoutMs: requestedTimeoutMs, + ...params + } = data || {}; + if (!requestId) { + throw new Error("Missing __requestId for chatCompletion"); + } + const timeoutOverride = Number(requestedTimeoutMs); + const timeoutMs = Number.isFinite(timeoutOverride) && timeoutOverride > 0 + ? Math.min(timeoutOverride, this.timeout) + : this.timeout; + const ackTimeoutMs = timeoutMs + ACK_TIMEOUT_BUFFER_MS; + if (params.outputMode !== undefined) { + params.outputMode = normalizeAiHookOutputMode(params.outputMode); + } + + this.logger.info("Sending chatCompletion request: model=" + params.model + " requestId=" + requestId); + + let response; + try { + response = await this.emit("chatCompletion", { + requestId, + timeoutMs, + params, + }, ackTimeoutMs); + } catch (err) { + await this.abortChatCompletion(requestId, "RPC acknowledgement timed out"); + throw err; + } + if (!response['success']) { + this.logger.error("chatCompletion error: " + response['message']); + throw new Error(response['message']); + } + return response; + } + + /** + * Fetch LiteLLM's supported provider slugs for credential selection. + * + * @returns {Promise<{providers: string[]}>} + */ + async getProviders() { + const response = await this.emit("getProviders", {}, this.timeout); + if (!response['success']) { + this.logger.error("getProviders error: " + response['message']); + throw new Error(response['message']); + } + return response.data || {providers: []}; + } + + /** + * Fetch models available for the supplied credential. + * + * @param {Object} data + * @param {string} [data.provider] + * @param {string} data.apiKey + * @param {string} [data.apiBaseUrl] + * @param {string} [data.apiVersion] + * @returns {Promise} + */ + async getValidModels(data) { + const response = await this.emit("getValidModels", data || {}, this.timeout); + if (!response['success']) { + this.logger.error("getValidModels error: " + response['message']); + throw new Error(response['message']); + } + return response.data || {models: []}; + } + + /** + * Ask the Python bridge to abort an in-flight chat completion. + * + * @param {string} requestId + * @param {string} [reason] + * @returns {Promise} + */ + async abortChatCompletion(requestId, reason = "request aborted") { + if (!requestId) { + return {aborted: false, message: "Missing requestId"}; + } + + try { + const response = await this.emit("abortChatCompletion", {requestId, reason}, 5000); + if (!response['success']) { + this.logger.error("abortChatCompletion error: " + response['message']); + return {aborted: false, message: response['message']}; + } + return response.data || {aborted: true}; + } catch (err) { + this.logger.error("abortChatCompletion failed: " + err.message); + return {aborted: false, message: err.message}; + } + } +} diff --git a/backend/webserver/services/ai.js b/backend/webserver/services/ai.js new file mode 100644 index 000000000..1090d0f3d --- /dev/null +++ b/backend/webserver/services/ai.js @@ -0,0 +1,10 @@ +"use strict"; + +/** + * Re-export so legacy `services/ai.js` consumers resolve the modular `AIService` facade. + * + * @module webserver/services/ai + * @author Akash Gundapuneni + */ + +module.exports = require("./ai/AIService.js"); diff --git a/backend/webserver/services/ai/AIService.js b/backend/webserver/services/ai/AIService.js new file mode 100644 index 000000000..5ea7d31c5 --- /dev/null +++ b/backend/webserver/services/ai/AIService.js @@ -0,0 +1,57 @@ +"use strict"; + +const Service = require("../../Service.js"); +const chat = require("./chat"); +const hook = require("./hook"); + +/** + * AIService — AI / LLM RPC handlers. + * + * Implementation is split under `./ai/` (`helpers`, `runtime`, `chat`, `hook`). + * + * @extends Service + * @author Akash Gundapuneni, Mohamed Rawhani + */ +module.exports = class AIService extends Service { + /** + * @param {*} server CARE webserver instance wiring DB plus RPC registrations. + */ + constructor(server) { + super(server, { + cmdTypes: [ + "chatCompletion", + "runHook", + "abortChatCompletion", + "getStatus", + "testModel", + "getProviders", + "getValidModels", + ], + resTypes: [], + }); + } + + /** + * Bridges declared `cmdTypes` into nested chat/hook helpers mirroring liteLLMRPC capabilities. + * + * @param {*} client RPC client emitting commands. + * @param {string} command Handler key enumerated in constructor `cmdTypes`. + * @param {*} data Serialized payload echoed from frontend tooling. + * @returns {Promise<*>} + */ + async command(client, command, data) { + const handlers = { + chatCompletion: () => chat.chatCompletion(this, client, data), + runHook: () => hook.runHook(this, client, data), + abortChatCompletion: () => chat.abortChatCompletion(this, data), + getStatus: () => chat.getStatus(this), + testModel: () => chat.testModel(this, client, data), + getProviders: () => chat.getProviders(this), + getValidModels: () => chat.getValidModels(this, client, data), + }; + if (handlers[command]) { + return handlers[command](); + } + return super.command(client, command, data); + } +}; diff --git a/backend/webserver/services/ai/chat.js b/backend/webserver/services/ai/chat.js new file mode 100644 index 000000000..74e5a388c --- /dev/null +++ b/backend/webserver/services/ai/chat.js @@ -0,0 +1,309 @@ +"use strict"; + +/** + * AIService helpers for forwarding chat and model-validation traffic to LiteLLM via RPC, + * enforcing credential ownership, and recording `ai_log` rows via the request module. + * + * @module webserver/services/ai/chat + * @author Akash Gundapuneni, Mohamed Rawhani + */ + +const {randomUUID} = require("crypto"); +const helpers = require("./helpers"); +const runtime = require("./runtime"); +const request = require("./request"); + +/** + * Normalizes provider-reported monetary cost fields for persisted logging. + * + * @param {unknown} value Raw value from LiteLLM (or provider) payload. + * @returns {number|null} Parsed finite number, or null if missing or invalid. + */ +function parseNumericCost(value) { + const numeric = Number(value); + return Number.isFinite(numeric) ? numeric : null; +} + +/** + * Load an enabled credential owned by `userId` via MetaModel.getById. + * + * @param {Object} models DB models map. + * @param {number} credentialId + * @param {number} userId + * @returns {Promise} + */ +async function requireOwnedCredential(models, credentialId, userId) { + const credential = await models.ai_credential.getById(credentialId, { + attributes: ["id", "userId", "provider", "apiKey", "apiBaseUrl", "apiVersion", "enabled"], + }); + if (!credential) { + throw new Error("Credential not found"); + } + if (!userId || credential.userId !== userId) { + throw new Error("You are not allowed to access this credential"); + } + if (!credential.enabled) { + throw new Error("Credential is disabled"); + } + return credential; +} + +/** + * Runs an OpenAI-style chat completion for the authenticated client, resolves `ai_model` linkage, + * persists success/failure to `ai_log`, and returns trimmed choice metadata. + * + * @param {{ logger: Object, server: Object }} service AIService runtime with logger and DB access. + * @param {{ userId?: number }} client Authenticated RPC client (creator of the log row). + * @param {Object} data Forwarded verbatim to LiteLLM except `__requestId` (optional override). + * @param {{ bypassChecks?: boolean, testLabel?: string }} [logOptions] `testLabel` is prepended to the + * saved `output` so admin test pings stay visible in `ai_log` while still counting toward spend sums. + * @returns {Promise<{choices: unknown[]}>} Provider choices array subset. + */ +async function chatCompletion(service, client, data, logOptions = {}) { + const rpc = runtime.getRPC(service.server); + if (!rpc) { + service.logger.error("LiteLLM RPC is not registered"); + throw new Error("LiteLLM service is not available"); + } + if (!(await rpc.isOnline())) { + service.logger.error("LiteLLM RPC is not connected"); + throw new Error("LiteLLM service is not connected"); + } + + const aiModelId = await runtime.resolveAiModelId(service.server, client?.userId, data); + const requestId = typeof data?.__requestId === "string" && data.__requestId.trim() + ? data.__requestId.trim() + : randomUUID(); + const { + aiModelId: _aiModelId, + aiHookId: _aiHookId, + aiCredentialId: _aiCredentialId, + credentialId: _credentialId, + __requestId: _requestId, + studyId: _studyId, + studySessionId: _studySessionId, + studyStepId: _studyStepId, + documentId: _documentId, + ...completionParams + } = data || {}; + + const guard = await request.beginRequest(service, { + userId: client?.userId, + aiModelId, + aiHookId: data?.aiHookId, + requestId, + input: helpers.extractInputText(data?.messages), + studyId: data?.studyId, + studySessionId: data?.studySessionId, + studyStepId: data?.studyStepId, + documentId: data?.documentId, + }, { + bypassChecks: !!logOptions.bypassChecks, + }); + if (!guard.allowed) { + throw new Error(guard.reason); + } + + let response; + try { + response = await rpc.chatCompletion({ + ...completionParams, + __requestId: requestId, + }); + } catch (error) { + const failureOutput = logOptions.testLabel + ? `${logOptions.testLabel}\n${error?.message || "Unknown error"}` + : error?.message; + await request.failRequest(service, guard.logId, failureOutput); + throw error; + } + const payload = response.data !== undefined ? response.data : response; + + const {choices = [], usage, model, id} = payload || {}; + const finishReasons = choices.map((choice) => choice.finish_reason).filter(Boolean); + service.logger.info( + `chatCompletion: id=${id} model=${model} ` + + `tokens=${usage ? usage.total_tokens : "N/A"} ` + + `finish=${finishReasons.join(",") || "N/A"}` + ); + + const outputPayload = JSON.stringify(choices); + await request.completeRequest(service, guard.logId, { + output: logOptions.testLabel ? `${logOptions.testLabel}\n${outputPayload}` : outputPayload, + reasoning: payload?.reasoning_content || null, + inputTokens: usage?.prompt_tokens ?? null, + outputTokens: usage?.completion_tokens ?? null, + totalTokens: usage?.total_tokens ?? null, + costs: parseNumericCost(payload?.response_cost), + }); + + return {choices}; +} + +/** + * Best-effort abort for an in-flight chat completion identified by provider request id. + * + * @param {{ server: Object }} service AIService with RPC registry access. + * @param {{ requestId?: string, reason?: string }} data Abort payload echoed to LiteLLM. + * @returns {Promise<{aborted: boolean, message?: string}>} + */ +async function abortChatCompletion(service, data) { + const rpc = runtime.getRPC(service.server); + if (!rpc || !(await rpc.isOnline())) { + return {aborted: false, message: "LiteLLM service is not connected"}; + } + + return rpc.abortChatCompletion(data && data.requestId, data && data.reason); +} + +/** + * Introspects the LiteLLM bridge health for dashboard diagnostics. + * + * @param {{ server: Object, logger: Object }} service AIService. + * @returns {Promise} RPC `getStatus` payload or `{online:false, error}` envelope. + */ +async function getStatus(service) { + const rpc = runtime.getRPC(service.server); + if (!rpc) { + return {online: false, error: "LiteLLM RPC not registered"}; + } + try { + return await rpc.getStatus(); + } catch (error) { + service.logger.error("Failed to get LLM status: " + error.message); + return {online: false, error: error.message}; + } +} + +/** + * Lists LiteLLM-supported provider slugs for credential UI selection. + * + * @param {{ server: Object }} service AIService. + * @returns {Promise<{providers: string[]}>} + */ +async function getProviders(service) { + const rpc = runtime.getRPC(service.server); + if (!rpc) { + throw new Error("LiteLLM service is not available"); + } + if (!(await rpc.isOnline())) { + throw new Error("LiteLLM service is not connected"); + } + return rpc.getProviders(); +} + +/** + * Lists remote models reachable with the caller-owned credential metadata. + * + * @param {{ server: Object }} service AIService. + * @param {{ userId: number }} client Authenticated user. + * @param {{ credentialId: number }} data Target credential PK. + * @returns {Promise} Same shape returned by LiteLLMRPC `getValidModels`. + */ +async function getValidModels(service, client, data) { + const rpc = runtime.getRPC(service.server); + if (!rpc) { + throw new Error("LiteLLM service is not available"); + } + if (!(await rpc.isOnline())) { + throw new Error("LiteLLM service is not connected"); + } + + const credentialId = Number(data?.credentialId); + if (!Number.isInteger(credentialId) || credentialId <= 0) { + throw new Error("Missing or invalid credentialId"); + } + + const credential = await requireOwnedCredential( + service.server.db.models, + credentialId, + client?.userId, + ); + const provider = typeof credential.provider === "string" ? credential.provider.trim().toLowerCase() : ""; + if (!provider) { + throw new Error("Credential provider is required to load models"); + } + + return rpc.getValidModels({ + provider, + apiKey: credential.apiKey, + apiBaseUrl: credential.apiBaseUrl || null, + apiVersion: credential.apiVersion || null, + }); +} + +/** + * Sends a deterministic low-token completion ("ping") to validate wiring for a credential/model pair, + * merges optional structured `additionalParameters`, and logs parity with production chat completions. + * + * @param {{ server: Object }} service AIService. + * @param {{ userId: number }} client Caller for ownership checks and logging attribution. + * @param {{ credentialId: number, model: string, aiModelId?: number, additionalParameters?: Object }} data + * @returns {Promise<{ok:true,outputText:string}>} + */ +async function testModel(service, client, data) { + const credentialId = Number(data?.credentialId); + const model = typeof data?.model === "string" ? data.model.trim() : ""; + if (!Number.isInteger(credentialId) || credentialId <= 0) { + throw new Error("Missing or invalid credentialId"); + } + if (!model) { + throw new Error("Missing model"); + } + + const credential = await requireOwnedCredential( + service.server.db.models, + credentialId, + client?.userId, + ); + + const params = { + ...helpers.buildLiteLLMParams(credential, model), + messages: [{role: "user", content: "ping"}], + max_tokens: 16, + }; + if ( + data?.additionalParameters && + typeof data.additionalParameters === "object" && + !Array.isArray(data.additionalParameters) + ) { + const reservedKeys = new Set([ + "model", + "messages", + "api_key", + "api_base", + "api_version", + "max_tokens", + ]); + Object.assign( + params, + Object.fromEntries( + Object.entries(data.additionalParameters).filter(([key]) => !reservedKeys.has(key)) + ) + ); + } + + const testLabel = `[TEST] model="${model}"${data?.aiModelId ? ` aiModelId=${data.aiModelId}` : ""}`; + const result = await chatCompletion(service, client, { + ...params, + aiModelId: data?.aiModelId, + aiCredentialId: credentialId, + }, { + bypassChecks: true, + testLabel, + }); + + const content = result.choices?.[0]?.message?.content; + const outputText = typeof content === "string" ? content : ""; + + return {ok: true, outputText}; +} + +module.exports = { + chatCompletion, + abortChatCompletion, + getStatus, + getProviders, + getValidModels, + testModel, +}; diff --git a/backend/webserver/services/ai/helpers.js b/backend/webserver/services/ai/helpers.js new file mode 100644 index 000000000..af78aaeeb --- /dev/null +++ b/backend/webserver/services/ai/helpers.js @@ -0,0 +1,107 @@ +"use strict"; + +/** + * Stateless helpers shared by AIService handlers (share UX, normalization, prompts). + * + * @module webserver/services/ai/helpers + * @author Akash Gundapuneni + */ + +/** + * Validates the RPC client's numeric `userId` or throws — share flows require a hardened principal. + * + * @param {{ userId?: number }} client Incoming RPC invocation context. + * @returns {number} Positive finite user id suitable for Sequelize filters. + */ +function requireClientUserId(client) { + const id = Number(client?.userId); + if (!Number.isInteger(id) || id <= 0) { + throw new Error("Invalid user context"); + } + return id; +} + +/** + * Flattens OpenAI-compatible `messages` into a condensed multi-line auditing string while retaining role labels. + * + * @param {unknown} messages Serialized chat history from client/RPC payloads. + * @returns {string|null} + */ +function extractInputText(messages) { + if (!Array.isArray(messages) || messages.length === 0) { + return null; + } + const text = messages + .map((message) => { + const role = typeof message?.role === "string" ? message.role.trim() : ""; + const content = message?.content; + let normalizedContent = ""; + if (typeof content === "string") { + normalizedContent = content.trim(); + } else if (Array.isArray(content)) { + normalizedContent = content + .map((part) => { + if (typeof part === "string") return part; + if (part && typeof part === "object" && typeof part.text === "string") { + return part.text; + } + return ""; + }) + .filter(Boolean) + .join("\n") + .trim(); + } else if (content !== null && content !== undefined) { + normalizedContent = String(content).trim(); + } + if (!normalizedContent) { + return ""; + } + return role ? `[${role}] ${normalizedContent}` : normalizedContent; + }) + .filter(Boolean) + .join("\n\n") + .trim(); + + return text || null; +} + +/** + * Dedupes non-zero integer-ish ids after optional coercion. + * + * @param {Iterable} values Source iterable. + * @param {(value: unknown) => number} [pick=(value)=>Number(value)] Mapper applied before filtration. + * @returns {number[]} + */ +function uniquePositiveInts(values, pick = (x) => Number(x)) { + return [...new Set((values || []).map(pick).filter((n) => Number.isInteger(n) && n > 0))]; +} + +/** + * Builds the params object passed directly to LiteLLM's completion() call from a credential row and a model name. + * + * @param {Object} credential - Credential row supplying provider auth. + * @param {string} [credential.provider] - Provider key (e.g. "openai", "ollama"). + * @param {string} [credential.apiKey] - Provider API key. + * @param {string} [credential.apiBaseUrl] - Optional provider base URL override. + * @param {string} [credential.apiVersion] - Optional provider API version override. + * @param {string} modelName - Raw model name as stored in ai_model.model. + * @returns {Object} Params object accepted by LiteLLM's completion() call. + */ +function buildLiteLLMParams(credential, modelName) { + const provider = typeof credential.provider === "string" ? credential.provider.trim().toLowerCase() : ""; + const model = provider && !modelName.startsWith(provider + "/") + ? `${provider}/${modelName}` + : modelName; + const params = { model, api_key: credential.apiKey }; + if (provider) params.custom_llm_provider = provider; + if (credential.apiBaseUrl) params.api_base = credential.apiBaseUrl; + if (credential.apiVersion) params.api_version = credential.apiVersion; + return params; +} + +module.exports = { + requireClientUserId, + extractInputText, + uniquePositiveInts, + buildLiteLLMParams, +}; diff --git a/backend/webserver/services/ai/hook.js b/backend/webserver/services/ai/hook.js new file mode 100644 index 000000000..8c8266a5f --- /dev/null +++ b/backend/webserver/services/ai/hook.js @@ -0,0 +1,218 @@ +"use strict"; + +/** + * AIService helpers for executing an AI hook + * + * @module webserver/services/ai/hook + * @author Mohammed Rawhani + */ + +const chat = require("./chat"); +const helpers = require("./helpers"); +const { resolveTemplateWithValues } = require("../../../utils/helper/templateResolver"); + +/** + * Loads an enabled, non-deleted AI hook by id. + * + * @param {Object} service - AIService runtime with DB access. + * @param {Object} service.server - CARE webserver instance (DB access). + * @param {number} hookId - Target `ai_hook` primary key. + * @returns {Promise} The hook row. + * @throws {Error} If the hook is missing, deleted, or disabled. + */ +async function loadEnabledHook(service, hookId) { + const hook = await service.server.db.models.ai_hook.getById(hookId); + if (!hook || hook.deleted) { + throw new Error("AI hook not found"); + } + if (!hook.enabled) { + throw new Error("AI hook is disabled"); + } + if (!hook.templateId) { + throw new Error("AI hook has no prompt template"); + } + return hook; +} + +/** + * Resolves the hook's primary model (priority 1) into the model string plus the owner's + * credential parameters required by the LiteLLM passthrough. + * + * @param {Object} service - AIService runtime with DB access. + * @param {Object} service.server - CARE webserver instance (DB access). + * @param {number} hookId - Target `ai_hook` primary key. + * @returns {Promise} Model string plus the owner's credential params for the LiteLLM passthrough. + * @throws {Error} If no usable model/credential is configured for the hook. + */ +async function resolveHookModelParams(service, hookId) { + const hookModel = await service.server.db.models['ai_hook_models'].findOne({ + where: { aiHookId: hookId, deleted: false }, + order: [["priority", "ASC"]], + raw: true, + }); + if (!hookModel) { + throw new Error("AI hook has no configured model"); + } + + const aiModel = await service.server.db.models['ai_model'].getById(hookModel.aiModelId); + if (!aiModel || aiModel.deleted) { + throw new Error("AI hook model not found"); + } + if (!aiModel.enabled) { + throw new Error("AI hook model is disabled"); + } + + const credential = await service.server.db.models['ai_credential'].getById(aiModel.aiCredentialId, { + attributes: ["id", "userId", "provider", "apiKey", "apiBaseUrl", "apiVersion", "enabled", "deleted"], + }); + if (!credential || credential.deleted) { + throw new Error("AI hook model credential not found"); + } + if (!credential.enabled) { + throw new Error("AI hook model credential is disabled"); + } + + return { + aiModelId: aiModel.id, + aiCredentialId: credential.id, + additionalParameters: hookModel.additionalParameters || {}, + ...helpers.buildLiteLLMParams(credential, aiModel.model), + }; +} + +/** + * Resolves a single backend-side input reference (mirrors NLP `serviceReplacement`, but yields + * text/JSON for prompt substitution rather than base64). + * + * @param {Object} service - AIService runtime with DB access. + * @param {Object} service.server - CARE webserver instance (DB access). + * @param {Object} input - The reference's `input` spec (carries `type` + ids). + * @returns {Promise<*>} Resolved value for the placeholder. + */ +async function resolveServiceInput(service, input) { + if (!input || typeof input !== "object") return null; + switch (input.type) { + case "configuration": { + const config = await service.server.db.models["configuration"].findByPk(input.configurationId, {raw: true}); + if (!config) return null; + if (typeof config.content === "string") { + try { + return JSON.parse(config.content); + } catch (e) { + return config.content; + } + } + return config.content; + } + case "submission": { + const { selectedFiles = [], pdfText, submissionId, filePatterns = {} } = input; + if (!submissionId || !selectedFiles.length) return ""; + + const parts = []; + + if (selectedFiles.includes("pdf") && pdfText) { + parts.push(pdfText); + } + + // Zip-based files (tex, bib, …) — unzip on the backend. + // filePatterns maps logical name → validation-config regex (e.g. "expose" → "Expose\\.tex$"). + const zipFileSpecs = selectedFiles + .filter(f => f !== "pdf") + .map(name => ({name, pattern: filePatterns[name] || null})); + if (zipFileSpecs.length) { + const zipDoc = await service.server.db.models["document"].findOne({ + where: { submissionId, type: 4, deleted: false }, + raw: true, + }); + if (zipDoc) { + const buffer = await service.server.db.models["document"] + .readDocumentFile(zipDoc, ".zip"); + if (buffer) { + const extracted = await service.server.db.models["document"] + .extractZipFiles(buffer, zipFileSpecs); + for (const content of Object.values(extracted)) { + parts.push(content); + } + } + } + } + + return parts.join("\n\n"); + } + default: + return null; + } +} + +/** + * Resolves any backend-side references in the pushed values map (configuration, submission), + * leaving frontend-resolved values (document text, study data) as-is. + * + * @param {Object} service - AIService runtime with DB access. + * @param {Object} service.server - CARE webserver instance (DB access). + * @param {Object} values - Map of placeholderKey → value or `{type:"serviceReplacement", input}`. + * @returns {Promise} Map with references resolved to values. + */ +async function resolveHookReferences(service, values) { + const resolved = {}; + for (const [key, value] of Object.entries(values || {})) { + if (value && typeof value === "object" && value.type === "serviceReplacement") { + resolved[key] = await resolveServiceInput(service, value.input); + } else { + resolved[key] = value; + } + } + return resolved; +} + +/** + * Executes an AI hook for the calling client: fills the hook's prompt template from the + * caller-supplied placeholder `values` (assembled in the frontend from the input mapping), + * attaches the hook's primary model credential, and forwards through the shared chat path. + * + * @param {Object} service - AIService runtime. + * @param {Object} client - Authenticated RPC client triggering the hook. + * @param {Object} data - Hook execution payload (hookId, values, studyId, studySessionId, studyStepId, documentId). + * @returns {Promise<{choices: unknown[], outputText: string}>} Provider choices plus first-choice text. + * @throws {Error} If the hook id is invalid or any required model/credential/template is missing. + */ +async function runHook(service, client, data) { + const hookId = Number(data?.hookId); + if (!Number.isInteger(hookId) || hookId <= 0) { + throw new Error("Missing or invalid hookId"); + } + + const hook = await loadEnabledHook(service, hookId); + const modelParams = await resolveHookModelParams(service, hookId); + const rawValues = (data?.values && typeof data.values === "object") ? data.values : {}; + const values = await resolveHookReferences(service, rawValues); + const promptText = await resolveTemplateWithValues(hook.templateId, values, service.server.db.models); + + const { additionalParameters, ...credentialParams } = modelParams; + const completionData = { + ...additionalParameters, + ...credentialParams, + aiHookId: hookId, + messages: [{ role: "user", content: promptText }], + outputMode: hook.outputMode, + studyId: data?.studyId, + studySessionId: data?.studySessionId, + studyStepId: data?.studyStepId, + documentId: data?.documentId, + }; + + service.logger.info( + `runHook: hookId=${hookId} templateId=${hook.templateId} ` + + `aiModelId=${modelParams.aiModelId} studyStepId=${data?.studyStepId ?? "N/A"}` + ); + + const result = await chat.chatCompletion(service, client, completionData); + const content = result.choices?.[0]?.message?.content; + const outputText = typeof content === "string" ? content : ""; + + return { choices: result.choices, outputText }; +} + +module.exports = { + runHook, +}; diff --git a/backend/webserver/services/ai/request.js b/backend/webserver/services/ai/request.js new file mode 100644 index 000000000..7296fbb6d --- /dev/null +++ b/backend/webserver/services/ai/request.js @@ -0,0 +1,418 @@ +"use strict"; + +/** + * Checks budgets before AI requests and tracks each request in ai_log. + * All cap rows live in the ai_budget table. Each request loads the caps + * that apply, sums the related spend, and denies on the first one that's full. + * + * @module webserver/services/ai/request + * @author Mohammed Rawhani + */ + +const { Op } = require("sequelize"); +const { AI_BUDGET_LIMIT_TYPES: LT } = require("../../../db/models/ai_budget.js"); + +/** + * Decides if an AI request can run. If yes, creates the ai_log row for it. + * Blocks a second request from the same user in the same session while one is still running. + * + * @param {Object} service - AIService, used for DB access. + * @param {Object} request - The request being made (user, model, hook, study, etc). + * @param {Object} [options] - Optional flags. + * @param {boolean} [options.bypassChecks] - Skip the access + cap checks (used for admin test prompts). + * @returns {Promise<{ allowed: boolean, logId?: number, reason?: string }>} + */ +async function beginRequest(service, request, options = {}) { + const { + userId, aiModelId, aiHookId, requestId, input, + studyId, studySessionId, studyStepId, documentId, + } = request || {}; + + if (await _hasInflight(service, userId, studySessionId)) { + return { allowed: false, reason: "You already have a pending AI request in this session" }; + } + + if (!options.bypassChecks) { + const model = await service.server.db.models["ai_model"].findByPk(aiModelId, { raw: true }); + if (!model || model.deleted || !model.enabled) { + return { allowed: false, reason: "AI model is not available" }; + } + + // Inside a study, access (and per-share budget attribution) rides on the + // study owner — participants don't carry shares. + const accessHolderId = studyId + ? await _getStudyOwnerId(service, studyId) + : userId; + if (!accessHolderId) { + return { allowed: false, reason: "Study owner could not be resolved" }; + } + + const isModelOwner = model.userId === accessHolderId; + const modelShare = await _findActiveShare(service, "ai_model_share", "aiModelId", accessHolderId, aiModelId); + if (!isModelOwner && !modelShare) { + return { + allowed: false, + reason: studyId + ? "Study creator no longer has access to this AI model" + : "You do not have access to this AI model", + }; + } + + // Model access does not imply hook access, a hook must be owned by, or actively shared with + let hookShare = null; + if (aiHookId) { + const hook = await service.server.db.models["ai_hook"].findByPk(aiHookId, { + attributes: ["userId", "deleted"], + raw: true, + }); + if (!hook || hook.deleted) { + return { allowed: false, reason: "AI hook is not available" }; + } + const isHookOwner = hook.userId === accessHolderId; + hookShare = await _findActiveShare(service, "ai_hook_share", "aiHookId", accessHolderId, aiHookId); + if (!isHookOwner && !hookShare) { + return { + allowed: false, + reason: studyId + ? "Study creator no longer has access to this AI hook" + : "You do not have access to this AI hook", + }; + } + } + + if (!model.freeModel) { + const caps = await _loadApplicableCaps(service, { + aiModelId, + aiModelShareId: modelShare?.id, + aiHookId, + aiHookShareId: hookShare?.id, + studyId, + studyStepId, + }); + + for (const cap of caps) { + const used = await _sumLogsFor(service, cap, { userId, studySessionId, accessHolderId }); + if (used >= cap.costLimit) { + return { allowed: false, reason: _capDenyMessage(cap, used) }; + } + } + } + } + + const log = await service.server.db.models["ai_log"].add({ + userId, + aiModelId, + aiHookId: aiHookId || null, + documentId: documentId || null, + studySessionId: studySessionId || null, + studyStepId: studyStepId || null, + requestId, + input, + status: "in_progress", + requestStart: new Date(), + }); + return { allowed: true, logId: log.id }; +} + +/** + * Marks a request as completed and saves the provider response. + * + * @param {Object} service - AIService, used for DB access. + * @param {number} logId - The ai_log row id returned by beginRequest. + * @param {Object} outcome - Parsed response fields to save (output, tokens, costs, etc). + */ +async function completeRequest(service, logId, outcome) { + await service.server.db.models["ai_log"].updateById(logId, { + ...outcome, + status: "completed", + }); +} + +/** + * Marks a request as failed and stores the error. + * + * @param {Object} service - AIService, used for DB access. + * @param {number} logId - The ai_log row id returned by beginRequest. + * @param {string} [errorMessage] - Error text to save on the log row. + */ +async function failRequest(service, logId, errorMessage) { + await service.server.db.models["ai_log"].updateById(logId, { + status: "failed", + output: errorMessage || "Unknown error", + }); +} + +/** + * Marks a running request as aborted (used after the caller stops it at the provider). + * + * @param {Object} service - AIService, used for DB access. + * @param {number} logId - The ai_log row id returned by beginRequest. + */ +async function cancelRequest(service, logId) { + await service.server.db.models["ai_log"].updateById(logId, { status: "aborted" }); + return { cancelled: true }; +} + + +/// Internal helpers + +// True if this user already has a request running in this session. +async function _hasInflight(service, userId, studySessionId) { + const existing = await service.server.db.models["ai_log"].findOne({ + where: { + userId, + studySessionId: studySessionId ?? null, + status: "in_progress", + deleted: false, + }, + attributes: ["id"], + }); + return existing !== null; +} + +// Returns the userId of the study's owner, or null if the study is gone. +async function _getStudyOwnerId(service, studyId) { + const study = await service.server.db.models["study"].findByPk(studyId, { + attributes: ["userId", "deleted"], + raw: true, + }); + if (!study || study.deleted) return null; + return Number(study.userId); +} + +// Returns the user's active (non-expired) share row for a model or hook, or null. +// One function handles both share tables since they have the same shape. +async function _findActiveShare(service, tableName, fkColumn, userId, entityId) { + const roleIds = await service.server.db.models["user_role_matching"].getUserRolesById(userId); + return service.server.db.models[tableName].findOne({ + where: { + [fkColumn]: entityId, + deleted: false, + expiryDate: { [Op.gt]: new Date() }, + [Op.or]: [ + { userId }, + ...(roleIds.length ? [{ roleId: { [Op.in]: roleIds } }] : []), + ], + }, + raw: true, + }); +} + +// Loads every cap row that could apply to this request (model, share, hook, +// hook share, study, step-hook) in one DB query. +async function _loadApplicableCaps(service, ctx) { + const orClauses = []; + if (ctx.aiModelId) orClauses.push({ aiModelId: ctx.aiModelId }); + if (ctx.aiModelShareId) orClauses.push({ aiModelShareId: ctx.aiModelShareId }); + if (ctx.aiHookShareId) orClauses.push({ aiHookShareId: ctx.aiHookShareId }); + if (ctx.studyId) orClauses.push({ studyId: ctx.studyId }); + if (ctx.aiHookId) orClauses.push({ aiHookId: ctx.aiHookId, studyStepId: null }); + if (ctx.studyStepId && ctx.aiHookId) { + orClauses.push({ studyStepId: ctx.studyStepId, aiHookId: ctx.aiHookId }); + } + if (orClauses.length === 0) return []; + + return service.server.db.models["ai_budget"].findAll({ + where: { deleted: false, [Op.or]: orClauses }, + raw: true, + }); +} + +// Picks the right sum function for this cap row based on its FKs and limitType. +// service: DB access. cap: an ai_budget row. ctx: { userId, studySessionId, accessHolderId }. +async function _sumLogsFor(service, cap, ctx) { + if (cap.aiModelId) return _sumModelTotal(service, cap); + if (cap.aiModelShareId) return _sumShareAttributable(service, cap, ctx.accessHolderId); + if (cap.aiHookShareId) return _sumHookShareAttributable(service, cap, ctx.accessHolderId); + if (cap.studyStepId && cap.aiHookId) { + if (cap.limitType === LT.PER_SESSION) return _sumStepHookSession(service, cap, ctx); + if (cap.limitType === LT.PER_USER) return _sumStepHookUser(service, cap, ctx); + return _sumStepHookTotal(service, cap); + } + if (cap.aiHookId && !cap.studyStepId) return _sumHookTotal(service, cap); + if (cap.studyId) { + if (cap.limitType === LT.PER_SESSION) return _sumStudySession(service, cap, ctx); + if (cap.limitType === LT.PER_USER) return _sumStudyUser(service, cap, ctx); + return _sumStudyTotal(service, cap); + } + return 0; +} + +// Human-readable deny message for the cap that blocked the request. +function _capDenyMessage(cap, used) { + const limit = Number(cap.costLimit).toFixed(2); + const spent = used.toFixed(2); + if (cap.aiModelId) return `Model budget exhausted: $${spent} / $${limit}`; + if (cap.aiModelShareId) return `Model share budget exhausted: $${spent} / $${limit}`; + if (cap.aiHookShareId) return `Hook share budget exhausted: $${spent} / $${limit}`; + if (cap.studyStepId) return `Step-hook budget exhausted: $${spent} / $${limit}`; + if (cap.aiHookId) return `Hook budget exhausted: $${spent} / $${limit}`; + if (cap.studyId) return `Study budget exhausted: $${spent} / $${limit}`; + return `Budget exhausted: $${spent} / $${limit}`; +} + +/// Sum helpers + +// Sums ai_log.costs for any WHERE the caller passes. Skips logs older than resetAt if set. +async function _sumLogs(service, where, resetAt, include = []) { + const Sequelize = service.server.db.Sequelize; + const filter = { + ...where, + deleted: false, + status: { [Op.in]: ["completed", "in_progress"] }, + }; + if (resetAt) filter.createdAt = { [Op.gte]: resetAt }; + + const result = await service.server.db.models["ai_log"].findOne({ + where: filter, + include, + attributes: [[Sequelize.fn("SUM", Sequelize.col("costs")), "total"]], + raw: true, + }); + return parseFloat(result?.total || 0); +} + +async function _sumModelTotal(service, cap) { + return _sumLogs(service, { aiModelId: cap.aiModelId }, cap.resetAt); +} + +async function _sumHookTotal(service, cap) { + return _sumLogs(service, { aiHookId: cap.aiHookId }, cap.resetAt); +} + +async function _sumStudyTotal(service, cap) { + return _sumLogs(service, {}, cap.resetAt, [{ + model: service.server.db.models["study_session"], + as: "studySession", + where: { studyId: cap.studyId }, + required: true, + attributes: [], + }]); +} + +async function _sumStudySession(service, cap, ctx) { + if (!ctx.studySessionId) return 0; + return _sumLogs(service, { studySessionId: ctx.studySessionId }, cap.resetAt); +} + +async function _sumStudyUser(service, cap, ctx) { + return _sumLogs(service, { userId: ctx.userId }, cap.resetAt, [{ + model: service.server.db.models["study_session"], + as: "studySession", + where: { studyId: cap.studyId }, + required: true, + attributes: [], + }]); +} + +// count hook usage but only inside this study +async function _sumStepHookTotal(service, cap) { + const step = await service.server.db.models["study_step"].findByPk(cap.studyStepId, { + attributes: ["studyId"], + raw: true, + }); + if (!step) return 0; + return _sumLogs(service, { aiHookId: cap.aiHookId }, cap.resetAt, [{ + model: service.server.db.models["study_session"], + as: "studySession", + where: { studyId: step.studyId }, + required: true, + attributes: [], + }]); +} + +// count hook usage inside this session +async function _sumStepHookSession(service, cap, ctx) { + if (!ctx.studySessionId) return 0; + return _sumLogs(service, { + aiHookId: cap.aiHookId, + studySessionId: ctx.studySessionId, + }, cap.resetAt); +} + +// sum for this user using hook across all their sessions within this study +async function _sumStepHookUser(service, cap, ctx) { + const step = await service.server.db.models["study_step"].findByPk(cap.studyStepId, { + attributes: ["studyId"], + raw: true, + }); + if (!step) return 0; + return _sumLogs(service, { + aiHookId: cap.aiHookId, + userId: ctx.userId, + }, cap.resetAt, [{ + model: service.server.db.models["study_session"], + as: "studySession", + where: { studyId: step.studyId }, + required: true, + attributes: [], + }]); +} + +// Spend on this model that belongs to the share owner: their own usage +// plus anyone using it inside studies they own. +async function _sumShareAttributable(service, cap, ownerId) { + const share = await service.server.db.models["ai_model_share"].findByPk(cap.aiModelShareId, { + attributes: ["aiModelId"], + raw: true, + }); + if (!share) return 0; + return _sumAttributableForEntity(service, { aiModelId: share.aiModelId }, ownerId, cap.resetAt); +} + +// Same as _sumShareAttributable but for hooks instead of models. +async function _sumHookShareAttributable(service, cap, ownerId) { + const hookShare = await service.server.db.models["ai_hook_share"].findByPk(cap.aiHookShareId, { + attributes: ["aiHookId"], + raw: true, + }); + if (!hookShare) return 0; + return _sumAttributableForEntity(service, { aiHookId: hookShare.aiHookId }, ownerId, cap.resetAt); +} + +// Owner's own usage on the entity + usage by anyone in studies they own. +// entityWhere narrows to one model or one hook. +async function _sumAttributableForEntity(service, entityWhere, ownerId, resetAt) { + const Sequelize = service.server.db.Sequelize; + const models = service.server.db.models; + const base = { + ...entityWhere, + deleted: false, + status: { [Op.in]: ["completed", "in_progress"] }, + }; + if (resetAt) base.createdAt = { [Op.gte]: resetAt }; + + const direct = await models["ai_log"].findOne({ + where: { ...base, userId: ownerId }, + attributes: [[Sequelize.fn("SUM", Sequelize.col("costs")), "total"]], + raw: true, + }); + + const studyOwned = await models["ai_log"].findOne({ + where: { ...base, userId: { [Op.ne]: ownerId } }, + include: [{ + model: models["study_session"], + as: "studySession", + required: true, + attributes: [], + include: [{ + model: models["study"], + as: "study", + required: true, + where: { userId: ownerId }, + attributes: [], + }], + }], + attributes: [[Sequelize.fn("SUM", Sequelize.col("costs")), "total"]], + raw: true, + }); + + return parseFloat(direct?.total || 0) + parseFloat(studyOwned?.total || 0); +} + +module.exports = { + beginRequest, + completeRequest, + failRequest, + cancelRequest, +}; diff --git a/backend/webserver/services/ai/runtime.js b/backend/webserver/services/ai/runtime.js new file mode 100644 index 000000000..c132bae7a --- /dev/null +++ b/backend/webserver/services/ai/runtime.js @@ -0,0 +1,96 @@ +"use strict"; + +/** + * Lightweight glue reachable from AIService orchestration helpers for RPC retrieval and auditing. + * + * @module webserver/services/ai/runtime + * @author Akash Gundapuneni + */ + +/** + * Resolves the registered LiteLLM RPC bridge on the webserver instance. + * + * @param {{ rpcs: Object }} server Bootstrapped CARE webserver. + * @returns {Object|null} + */ +function getRPC(server) { + return server.rpcs.LiteLLMRPC || null; +} + +/** + * Persists a single `ai_log` row swallowing serialization errors — chat flows must remain resilient. + * + * @param {{ logger: Object, server: Object }} service AIService (or compatible) shim. + * @param {Object} logData Sequelize-friendly column/value bag matching `ai_log` columns. + */ +async function logAiCall(service, logData) { + try { + await service.server.db.models.ai_log.add({ + userId: logData.userId, + aiModelId: logData.aiModelId || null, + requestId: logData.requestId || null, + input: logData.input || null, + output: logData.output || null, + reasoning: logData.reasoning || null, + inputTokens: logData.inputTokens ?? null, + outputTokens: logData.outputTokens ?? null, + totalTokens: logData.totalTokens ?? null, + costs: logData.costs ?? null, + status: logData.status || null, + requestStart: logData.requestStart || null, + }); + } catch (error) { + service.logger.warn("Failed to write ai_log entry: " + error.message); + } +} + +/** + * Derives FK linkage via explicit ids or by reverse lookup on user-owned `model` strings. + * + * @param {{ db: Object }} server DB accessor housing Sequelize models registry. + * @param {number|undefined|null} userId Owner filter for heuristic resolution. + * @param {{ aiModelId?: number, aiCredentialId?: number, credentialId?: number, model?: string }} data Chat payload remnants. + * @returns {Promise} Matching `ai_model.id` else null. + */ +async function resolveAiModelId(server, userId, data = {}) { + const explicitId = Number(data?.aiModelId); + if (Number.isInteger(explicitId) && explicitId > 0) { + return explicitId; + } + + const modelCandidates = []; + const rawModel = typeof data?.model === "string" ? data.model.trim() : ""; + if (rawModel) modelCandidates.push(rawModel); + if (rawModel.includes("/")) { + const modelWithoutProvider = rawModel.slice(rawModel.indexOf("/") + 1); + if (modelWithoutProvider && !modelCandidates.includes(modelWithoutProvider)) { + modelCandidates.push(modelWithoutProvider); + } + } + if (modelCandidates.length === 0) { + return null; + } + + const where = { + userId, + deleted: false, + model: modelCandidates, + }; + const credentialId = Number(data?.aiCredentialId || data?.credentialId); + if (Number.isInteger(credentialId) && credentialId > 0) { + where.aiCredentialId = credentialId; + } + + const aiModel = await server.db.models.ai_model.findOne({ + where, + order: [["updatedAt", "DESC"]], + raw: true, + }); + return aiModel?.id || null; +} + +module.exports = { + getRPC, + logAiCall, + resolveAiModelId, +}; diff --git a/backend/webserver/services/backgroundTask.js b/backend/webserver/services/backgroundTask.js index 31128a497..258fc97b8 100644 --- a/backend/webserver/services/backgroundTask.js +++ b/backend/webserver/services/backgroundTask.js @@ -104,6 +104,11 @@ module.exports = class BackgroundTaskService extends Service { throw new Error("You do not have permission to preprocess submissions"); } + const activePreprocess = this.backgroundTask.preprocess; + if (activePreprocess && !activePreprocess.cancelled && !activePreprocess.completed) { + throw new Error("Another preprocessing job is already running."); + } + await this.initializePreprocessingState(); await this.prepareProcessingItems(preprocessingData); @@ -118,7 +123,11 @@ module.exports = class BackgroundTaskService extends Service { const nlpInput = await this.prepareNlpInput(item); if (!nlpInput || Object.keys(nlpInput).length === 0) { - this.server.logger.error(`No valid NLP input prepared for item ${item.requestId}`); + const message = `No valid NLP input prepared for item ${item.requestId}`; + if (preprocessingData.failOnItemError) { + this.recordPreprocessingError(message, item); + } + this.server.logger.error(message); continue; } @@ -134,6 +143,9 @@ module.exports = class BackgroundTaskService extends Service { } } catch (err) { + if (preprocessingData.failOnItemError) { + this.recordPreprocessingError(err.message || String(err), item); + } this.sendAll("backgroundTaskUpdate", this.backgroundTask); this.server.logger.error(`Error processing item ${item.requestId}: ${err.message}`, err); } @@ -158,6 +170,11 @@ module.exports = class BackgroundTaskService extends Service { } this.sendAll("backgroundTaskUpdate", this.backgroundTask); + const errors = this.backgroundTask.preprocess?.errors || []; + if (preprocessingData.failOnItemError && errors.length) { + throw new Error(errors.map((err) => err.message).join("; ")); + } + return {count: this.preprocessItems.length}; } @@ -183,6 +200,28 @@ module.exports = class BackgroundTaskService extends Service { this.sendAll("backgroundTaskUpdate", this.backgroundTask); } + /** + * Record one preprocessing item error without duplicating the same request/message pair. + * + * @param {string} message Error message to display in preprocessing state + * @param {Object} item Preprocessing item metadata + */ + recordPreprocessingError(message, item = {}) { + if (!this.backgroundTask.preprocess) return; + const existing = this.backgroundTask.preprocess.errors || []; + const alreadyRecorded = existing.some((err) => err.requestId === item.requestId && err.message === message); + if (alreadyRecorded) return; + + existing.push({ + message, + requestId: item.requestId, + submissionId: item.submissionId, + documentId: item.documentId, + timestamp: Date.now() + }); + this.backgroundTask.preprocess.errors = existing; + } + /** * Prepare the list of items to be processed based on the new generalized data structure * @param {object} preprocessingData - The preprocessing data from ApplySkillModal diff --git a/backend/webserver/services/nlp.js b/backend/webserver/services/nlp.js index aa4f01d04..751fa1824 100644 --- a/backend/webserver/services/nlp.js +++ b/backend/webserver/services/nlp.js @@ -1,6 +1,6 @@ const {io: io_client} = require("socket.io-client"); const Service = require("../Service.js"); -const fs = require("fs"); +const fs = require("fs").promises; const path = require("path"); const yaml = require('js-yaml') @@ -98,8 +98,9 @@ module.exports = class NLPService extends Service { // Handle connection errors nlpSocket.on("connect_error", async () => { - if (this.fallback === "true") { + if (self.fallback === "true") { await self.loadFallbacks(); + self.sendAll("skillUpdate", self.skills); } setTimeout(() => { if (nlpSocket) { @@ -126,31 +127,27 @@ module.exports = class NLPService extends Service { // deal with broken connection nlpSocket.on("disconnect", async () => { self.logger.error(`Connection to NLP server disrupted: ${!self.nlpSocket.connected}`); - if (this.fallback === "true") { - // wait for self.skills to populate and then send updated skills to the frontend - await new Promise(resolve => { - self.loadFallbacks(); - const checkInterval = setInterval(() => { - if (self.skills.length > 0) { - clearInterval(checkInterval); - resolve(); - } - }, 50); - }); + if (self.fallback === "true") { + await self.loadFallbacks(); self.sendAll("skillUpdate", self.skills); } }); // receives a list of objects, where each indicates a skill name and the number of nodes that provide it // we store this information in the this.skills attribute to keep track of available skills. - nlpSocket.on("skillUpdate", (data) => { + nlpSocket.on("skillUpdate", async (data) => { // update cache with deep copy of data const skills = JSON.parse(JSON.stringify(data)); self.#updateSkillCache(skills); - // check for configs for skills without them - self.skills.filter(s => !self.#hasConfig(s)) - .map(s => nlpSocket.emit("skillGetConfig", {name: s.name})); + // Broker may connect with zero live nodes — fall back to local SDF skills. + if (self.skills.length === 0 && self.fallback === "true") { + await self.loadFallbacks(); + } else { + // check for configs for skills without them + self.skills.filter(s => !self.#hasConfig(s)) + .map(s => nlpSocket.emit("skillGetConfig", {name: s.name})); + } self.sendAll("skillUpdate", self.skills); }); @@ -191,6 +188,11 @@ module.exports = class NLPService extends Service { * @param data */ async connectClient(client, data) { + // Broker can be connected yet still have no skills; load SDF fallbacks so + // clients (SkillSelector) receive NLP skills alongside AI hooks. + if (this.skills.length === 0 && this.fallback === "true") { + await this.loadFallbacks(); + } await this.send(client, "skillUpdate", this.skills); await super.connectClient(client, data); } @@ -338,7 +340,8 @@ module.exports = class NLPService extends Service { } /** - * Overwrite method to handle incoming requests + * Overwrite method to handle incoming requests. + * Forwards skill requests to the NLP broker. * @param client * @param data * @return {Promise} @@ -363,21 +366,26 @@ module.exports = class NLPService extends Service { * @return {Promise<*>} */ async loadFallbacks() { - this.skills = []; - await fs.readdir(path.resolve(__dirname, "../../../files/sdf"), async (err, files) => { - await Promise.all(files.filter(file => file.endsWith(".yaml")).map(async file => { - if (file.endsWith(".yaml")) { - fs.readFile(path.join(path.resolve(__dirname, "../../../files/sdf"), file), "utf8", (err, data) => { - if (err) { - this.logger.error(err) - return null; - } - const skill = yaml.load(data); - this.skills.push({config: skill, nodes: 1, "fallback": true, name: skill.name}); - }); + const dir = path.resolve(__dirname, "../../../files/sdf"); + try { + const files = (await fs.readdir(dir)).filter((file) => file.endsWith(".yaml")); + const skills = []; + for (const file of files) { + try { + const data = await fs.readFile(path.join(dir, file), "utf8"); + const skill = yaml.load(data); + if (skill?.name) { + skills.push({config: skill, nodes: 1, fallback: true, name: skill.name}); + } + } catch (err) { + this.logger.error(err); } - })); - }); + } + this.skills = skills; + } catch (err) { + this.logger.error(err); + this.skills = []; + } } } \ No newline at end of file diff --git a/backend/webserver/services/triggerHandlers.js b/backend/webserver/services/triggerHandlers.js new file mode 100644 index 000000000..5ede950d6 --- /dev/null +++ b/backend/webserver/services/triggerHandlers.js @@ -0,0 +1,822 @@ +"use strict"; + +const { resolveTemplate } = require("../../utils/helper/templateResolver"); +const {buildStudyHookKey} = require("../../utils/studyNlpDocumentData"); +const aiHook = require("./ai/hook"); + +const QUEUE_STATUS = { + PENDING: 0, + RUNNING: 1, + COMPLETED: 2, + CANCELLED: 3, + FAILED: 4, +}; + +const HANDLERS = { + send_email: sendEmail, + nlp_preprocess: runAiPreprocessing, +}; + +const QUEUE_TABLE = "trigger_queue"; +const executionQueues = new WeakMap(); + +const EVENT_CONTEXT_BUILDERS = { + "submission.uploaded": buildSubmissionUploadContext, +}; + +/** + * Convert JSONB/string/null configuration values into plain objects. + * + * @param {*} value The value to normalize + * @returns {Object} + */ +function asObject(value) { + if (!value) return {}; + if (typeof value === "string") { + try { + return JSON.parse(value); + } catch (err) { + return {}; + } + } + return value; +} + +/** + * Extract transaction options for Sequelize calls. + * + * @param {Object} options Trigger runtime options + * @returns {Object} + */ +function transactionOptions(options = {}) { + return options.transaction ? { transaction: options.transaction } : {}; +} + +/** + * Build includes for trigger event/action catalog rows. + * + * @param {Object} models Sequelize models + * @param {Object} eventWhere Additional event filter + * @param {Object} actionWhere Additional action filter + * @returns {Array} + */ +function triggerCatalogInclude(models, eventWhere = {}, actionWhere = {}) { + return [ + { model: models["trigger_event"], as: "event", required: true, where: eventWhere }, + { model: models["trigger_action"], as: "action", required: true, where: actionWhere }, + ]; +} + +/** + * Resolve event-specific context before trigger matching and execution. + * + * @param {Object} server CARE server instance + * @param {string} eventName Trigger event name + * @param {Object} context Event payload + * @param {Object} options Trigger runtime options + * @returns {Promise} + */ +async function buildEventContext(server, eventName, context, options = {}) { + const builder = EVENT_CONTEXT_BUILDERS[eventName]; + return builder ? await builder(server, context, options) : { ...context }; +} + +/** + * Enrich submission upload events with assignment, project, user, and label data. + * + * @param {Object} server CARE server instance + * @param {Object} context Submission upload context + * @param {Object} options Trigger runtime options + * @returns {Promise} + */ +async function buildSubmissionUploadContext(server, context, options = {}) { + const models = server.db.models; + const queryOptions = transactionOptions(options); + const next = { ...context }; + + if (next.submissionId && (next.assignmentId == null || next.userId == null)) { + const submission = await models["submission"].getById(next.submissionId, queryOptions); + if (submission) { + next.assignmentId = next.assignmentId ?? submission.assignmentId; + next.userId = next.userId ?? submission.userId; + next.timestamp = next.timestamp ?? submission.createdAt; + } + } + + if (next.assignmentId && (!next.assignmentName || next.projectId == null)) { + const assignment = await models["assignment"].getById(next.assignmentId, queryOptions); + if (assignment) { + next.assignmentName = next.assignmentName ?? assignment.name; + next.projectId = next.projectId ?? assignment.projectId; + } + } + + const eventType = ["reupload", "reuploaded"].includes(next.eventType) ? "reuploaded" : "uploaded"; + next.eventType = eventType; + next.eventLabelLower = eventType; + next.eventLabel = eventType.charAt(0).toUpperCase() + eventType.slice(1); + + if (next.timestamp instanceof Date) { + next.timestamp = next.timestamp.toLocaleString("en-GB", { + day: "numeric", + month: "long", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + }); + } + + return next; +} + +/** + * Check whether one trigger applies to the resolved event context. + * + * @param {Object} trigger Trigger row with event/action includes + * @param {string} eventName Trigger event name + * @param {Object} context Resolved event context + * @returns {boolean} + */ +function matchesTrigger(trigger, eventName, context) { + const event = trigger.event || {}; + const action = trigger.action || {}; + const config = asObject(trigger.configuration); + const eventConfig = asObject(config.event); + + if (event.name !== eventName || event.enabled === false || event.deleted || action.enabled === false || action.deleted) return false; + if (trigger.projectId && context.projectId && Number(trigger.projectId) !== Number(context.projectId)) return false; + if (eventConfig.assignmentId && Number(eventConfig.assignmentId) !== Number(context.assignmentId)) return false; + + return true; +} + +/** + * Load enabled triggers whose event/action catalog entries match the event. + * + * @param {Object} server CARE server instance + * @param {string} eventName Trigger event name + * @param {Object} context Resolved event context + * @param {Object} options Trigger runtime options + * @returns {Promise>} + */ +async function findMatchingTriggers(server, eventName, context, options = {}) { + const models = server.db.models; + const triggers = await models["trigger"].findAll({ + where: { enabled: true, deleted: false }, + include: triggerCatalogInclude( + models, + { name: eventName, enabled: true, deleted: false }, + { enabled: true, deleted: false } + ), + raw: true, + nest: true, + ...transactionOptions(options), + }); + + return triggers.filter((trigger) => matchesTrigger(trigger, eventName, context)); +} + +/** + * Load a trigger with its event and action catalog rows. + * + * @param {Object} server CARE server instance + * @param {number} triggerId Trigger id + * @param {Object} options Trigger runtime options + * @returns {Promise} + */ +async function getTriggerWithCatalog(server, triggerId, options = {}) { + const models = server.db.models; + return await models["trigger"].findOne({ + where: { id: triggerId, deleted: false }, + include: triggerCatalogInclude(models), + raw: true, + nest: true, + ...transactionOptions(options), + }); +} + +/** + * Create and broadcast a pending queue item for a trigger execution. + * + * @param {Object} server CARE server instance + * @param {Object} trigger Trigger row + * @param {Object} context Resolved event context + * @param {Object} options Trigger runtime options + * @returns {Promise} + */ +async function createQueueItem(server, trigger, context, options = {}) { + const model = server.db.models[QUEUE_TABLE]; + if (!model) return null; + + const item = await model.add({ + triggerId: trigger.id, + status: QUEUE_STATUS.PENDING, + userId: trigger.userId, + configuration: { event: context, action: asObject(trigger.configuration).action || {} }, + errorMessage: null, + attemptCount: 0, + startedAt: null, + completedAt: null, + }, { transaction: options.transaction }); + + await broadcastQueueItem(item, options); + return item; +} + +/** + * Update and broadcast a queue item. + * + * @param {Object} server CARE server instance + * @param {Object} item Queue item + * @param {Object} data Fields to update + * @param {Object} options Trigger runtime options + * @returns {Promise} + */ +async function updateQueueItem(server, item, data, options = {}) { + if (!item) return; + const updated = await server.db.models[QUEUE_TABLE].updateById(item.id, data, { transaction: options.transaction }); + await broadcastQueueItem(updated || { ...item, ...data }, options); + return updated; +} + +/** + * Load a queue item by id. + * + * @param {Object} server CARE server instance + * @param {number} queueItemId Queue item id + * @param {Object} options Trigger runtime options + * @returns {Promise} + */ +async function getQueueItem(server, queueItemId, options = {}) { + return await server.db.models[QUEUE_TABLE].getById(queueItemId, { transaction: options.transaction }); +} + +/** + * Check whether a queue item was cancelled after the current run started. + * + * @param {Object} server CARE server instance + * @param {number} queueItemId Queue item id + * @param {Object} options Trigger runtime options + * @returns {Promise} + */ +async function isQueueItemCancelled(server, queueItemId, options = {}) { + const latest = await getQueueItem(server, queueItemId, options); + return latest?.status === QUEUE_STATUS.CANCELLED; +} + +/** + * Notify subscribers about a queue item when a broadcaster is provided. + * + * @param {Object} item Queue item + * @param {Object} options Trigger runtime options + * @returns {Promise} + */ +async function broadcastQueueItem(item, options = {}) { + if (item && typeof options.broadcastQueueItem === "function") { + await options.broadcastQueueItem(item); + } +} + +/** + * Run queued work up to the trigger's configured parallel limit. + * + * @param {Object} server CARE server instance + * @param {Object} trigger Trigger row + * @param {Function} execute Work to run when a slot is available + * @returns {Promise<*>} The work result + */ +function enqueueExecution(server, trigger, execute) { + const limit = Number(trigger.parallelLimit ?? 1); + if (!Number.isFinite(limit) || limit < 1) { + return Promise.reject(new Error("Trigger parallel limit must be at least 1.")); + } + + let queues = executionQueues.get(server); + if (!queues) { + queues = new Map(); + executionQueues.set(server, queues); + } + + let queue = queues.get(trigger.id); + if (!queue) { + queue = { running: 0, pending: [], limit }; + queues.set(trigger.id, queue); + } + queue.limit = limit; + + const drain = () => { + while (queue.running < queue.limit && queue.pending.length) { + const task = queue.pending.shift(); + queue.running += 1; + Promise.resolve() + .then(task.execute) + .then(task.resolve, task.reject) + .finally(() => { + queue.running -= 1; + if (!queue.running && !queue.pending.length) { + queues.delete(trigger.id); + } else { + drain(); + } + }); + } + }; + + return new Promise((resolve, reject) => { + queue.pending.push({ execute, resolve, reject }); + drain(); + }); +} + +/** + * Enqueue and execute a trigger for the current event. + * + * @param {Object} server CARE server instance + * @param {Object} trigger Trigger row + * @param {Object} context Resolved event context + * @param {Object} options Trigger runtime options + * @returns {Promise<*>} + */ +async function runTrigger(server, trigger, context, options = {}) { + const queueItem = await createQueueItem(server, trigger, context, options); + return await enqueueExecution( + server, + trigger, + () => runQueuedTrigger(server, trigger, queueItem, context, options) + ); +} + +/** + * Execute an existing queue item and update its lifecycle status. + * + * @param {Object} server CARE server instance + * @param {Object} trigger Trigger row with action catalog data + * @param {Object} queueItem Queue item to execute + * @param {Object} context Resolved event context + * @param {Object} options Trigger runtime options + * @returns {Promise<*>} + */ +async function runQueuedTrigger(server, trigger, queueItem, context, options = {}) { + if (!queueItem) { + throw new Error("Trigger execution requires a queue item."); + } + + const persistedConfig = asObject(queueItem.configuration); + const triggerConfig = asObject(trigger.configuration); + const persistedActionConfig = asObject(persistedConfig.action); + const executionTrigger = { + ...trigger, + configuration: { + ...triggerConfig, + action: Object.keys(persistedActionConfig).length ? persistedActionConfig : asObject(triggerConfig.action), + }, + }; + const actionConfig = asObject(trigger.action && trigger.action.configuration); + const handler = HANDLERS[actionConfig.handler]; + + if (!handler) { + throw new Error(`No trigger handler registered for ${actionConfig.handler}`); + } + + const attemptCount = Number(queueItem.attemptCount || 0) + 1; + + await updateQueueItem(server, queueItem, { + status: QUEUE_STATUS.RUNNING, + attemptCount, + startedAt: new Date(), + completedAt: null, + errorMessage: null, + }, options); + + try { + const result = await handler(server, executionTrigger, context, { ...options, queueItemId: queueItem.id }); + if (await isQueueItemCancelled(server, queueItem.id, options)) { + return { cancelled: true }; + } + + await updateQueueItem(server, queueItem, { + status: QUEUE_STATUS.COMPLETED, + completedAt: new Date(), + }, options); + return result; + } catch (err) { + if (await isQueueItemCancelled(server, queueItem.id, options)) { + return { cancelled: true }; + } + + await updateQueueItem(server, queueItem, { + status: QUEUE_STATUS.FAILED, + errorMessage: err.message || String(err), + completedAt: new Date(), + }, options); + throw err; + } +} + +/** + * Re-run a failed or cancelled queue item if retry limits allow it. + * + * @param {Object} server CARE server instance + * @param {number} queueItemId Queue item id + * @param {Object} options Trigger runtime options + * @returns {Promise} + */ +async function retryQueueItem(server, queueItemId, options = {}) { + const item = await getQueueItem(server, queueItemId, options); + if (!item) { + throw new Error("Queue item not found."); + } + + const retryableStatuses = [QUEUE_STATUS.FAILED, QUEUE_STATUS.CANCELLED]; + if (!retryableStatuses.includes(item.status)) { + throw new Error("Only failed or cancelled queue items can be retried."); + } + + const trigger = await getTriggerWithCatalog(server, item.triggerId, options); + if (!trigger) { + throw new Error("Associated trigger rule not found."); + } + + const retriesUsed = Math.max(0, Number(item.attemptCount || 0) - 1); + if (retriesUsed >= Number(trigger.maxRetries || 0)) { + throw new Error("Maximum retries for this trigger have been reached."); + } + + const pendingItem = await updateQueueItem(server, item, { + status: QUEUE_STATUS.PENDING, + errorMessage: null, + startedAt: null, + completedAt: null, + }, options); + + const eventContext = asObject(pendingItem.configuration).event || {}; + setImmediate(() => { + enqueueExecution( + server, + trigger, + () => runQueuedTrigger(server, trigger, pendingItem, eventContext, options) + ).catch((err) => { + server.logger.error(`Retry for trigger queue item ${pendingItem.id} failed: ${err.message}`, err); + }); + }); + + return pendingItem; +} + +/** + * Create a new execution from a completed queue item. + * + * The original queue item is kept unchanged so each manual re-run has its own + * log entry and failed retry limits remain scoped to that new execution. + * + * @param {Object} server CARE server instance + * @param {number} queueItemId Queue item id + * @param {Object} options Trigger runtime options + * @returns {Promise} + */ +async function rerunQueueItem(server, queueItemId, options = {}) { + const item = await getQueueItem(server, queueItemId, options); + if (!item) { + throw new Error("Queue item not found."); + } + if (item.status !== QUEUE_STATUS.COMPLETED) { + throw new Error("Only completed queue items can be re-run."); + } + + const trigger = await getTriggerWithCatalog(server, item.triggerId, options); + if (!trigger) { + throw new Error("Associated trigger rule not found."); + } + + const persistedConfig = asObject(item.configuration); + const queueItem = await server.db.models[QUEUE_TABLE].add({ + triggerId: trigger.id, + status: QUEUE_STATUS.PENDING, + userId: item.userId || trigger.userId, + configuration: { + event: asObject(persistedConfig.event), + action: asObject(persistedConfig.action), + }, + errorMessage: null, + attemptCount: 0, + startedAt: null, + completedAt: null, + }, { transaction: options.transaction }); + + await broadcastQueueItem(queueItem, options); + + setImmediate(() => { + enqueueExecution( + server, + trigger, + () => runQueuedTrigger(server, trigger, queueItem, asObject(persistedConfig.event), options) + ).catch((err) => { + server.logger.error(`Re-run for trigger queue item ${queueItem.id} failed: ${err.message}`, err); + }); + }); + + return queueItem; +} + +/** + * Handle any trigger event by resolving context, matching triggers, and running them. + * + * @param {Object} server CARE server instance + * @param {string} eventName Trigger event name + * @param {Object} context Event payload + * @param {Object} options Trigger runtime options + * @returns {Promise>} + */ +async function handleTriggerEvent(server, eventName, context = {}, options = {}) { + const eventContext = await buildEventContext(server, eventName, context, options); + const triggers = await findMatchingTriggers(server, eventName, eventContext, options); + const results = []; + + for (const trigger of triggers) { + try { + results.push(await runTrigger(server, trigger, eventContext, options)); + } catch (err) { + server.logger.error(`Trigger ${trigger.id} failed: ${err.message}`, err); + } + } + + return results; +} + +/** + * Handle submission upload events through the generic trigger runner. + * + * @param {Object} server CARE server instance + * @param {Object} context Submission upload context + * @param {Object} options Trigger runtime options + * @returns {Promise>} + */ +async function handleSubmissionUploaded(server, context = {}, options = {}) { + return await handleTriggerEvent(server, "submission.uploaded", context, options); +} + +/** + * Resolve configured email recipients for an email trigger action. + * + * @param {Object} server CARE server instance + * @param {string} recipient Recipient selector + * @param {Object} context Resolved event context + * @param {Object} options Trigger runtime options + * @returns {Promise>} + */ +async function resolveEmailRecipients(server, recipient, context, options = {}) { + const models = server.db.models; + + if (recipient === "admins") { + return (await models["user"].getUsersByRole("admin") || []).filter((user) => user.email); + } + + if (recipient !== "uploader") { + throw new Error(`Unsupported email recipient "${recipient}".`); + } + + const userId = context.userId || context.submitterUserId; + if (!userId) return []; + + const user = await models["user"].getById(userId, options); + return user && user.email ? [user] : []; +} + +/** + * Send a templated email for a trigger action. + * + * @param {Object} server CARE server instance + * @param {Object} trigger Trigger row with action configuration + * @param {Object} context Resolved event context + * @param {Object} options Trigger runtime options + * @returns {Promise} + */ +async function sendEmail(server, trigger, context, options = {}) { + const config = asObject(trigger.configuration).action || {}; + const templateId = config.templateId; + + if (!templateId) { + throw new Error("Email trigger action requires templateId."); + } + + const template = await server.db.models["template"].getById(templateId, options); + if (!template) { + throw new Error(`Email template ${templateId} not found.`); + } + + const recipients = await resolveEmailRecipients(server, config.recipient, context, options); + if (!recipients.length) { + throw new Error("Email trigger action did not resolve any recipients."); + } + + const sent = []; + + for (const recipient of recipients) { + const body = await resolveTemplate(templateId, { ...context, userId: recipient.id }, server.db.models, options); + await server.sendMail(recipient.email, template.name, body, { isHtml: true }); + sent.push(recipient.email); + } + + return { sent }; +} + +/** + * Convert NLP file mappings from context keys to concrete file ids. + * + * @param {Object} mappings Action parameter mappings + * @param {Object} context Resolved event context + * @returns {Object} + */ +function hydrateSkillParameterMappings(mappings, context) { + const hydrated = {}; + + for (const [paramName, mapping] of Object.entries(mappings || {})) { + if (Array.isArray(mapping.fileIds) && mapping.fileIds.length) { + hydrated[paramName] = mapping; + continue; + } + + if (!mapping.fromContext) { + throw new Error(`NLP parameter ${paramName} does not define fileIds or fromContext.`); + } + + const fileId = context[mapping.fromContext]; + if (!fileId) { + throw new Error(`NLP parameter ${paramName} could not resolve ${mapping.fromContext}.`); + } + + hydrated[paramName] = { + ...mapping, + fileIds: [fileId], + }; + delete hydrated[paramName].fromContext; + } + + return hydrated; +} + +function getConfiguredHookId(config) { + const selected = config?.hookId + ?? (config?.skillName?.startsWith("hook:") ? config.skillName.slice("hook:".length) : null); + const hookId = Number(selected); + return Number.isInteger(hookId) && hookId > 0 ? hookId : null; +} + +/** + * Execute an AI hook selected in an AI preprocessing trigger and persist its output. + * + * @param {Object} server CARE server instance + * @param {Object} trigger Trigger row with action configuration + * @param {Object} context Resolved event context + * @returns {Promise} + */ +async function runAiHookTrigger(server, trigger, context) { + const config = asObject(trigger.configuration).action || {}; + const hookId = getConfiguredHookId(config); + const inputMappings = asObject(config.inputMappings); + const baseMapping = inputMappings[config.baseFileParameter]; + const service = server.services["AIService"]; + if (!hookId || !baseMapping || !service) { + throw new Error("AI hook trigger is not configured correctly."); + } + const hook = await server.db.models["ai_hook"].getById(hookId); + if (!hook || hook.deleted || !hook.name) { + throw new Error("AI hook trigger could not resolve its hook name."); + } + + let documentId = Number(baseMapping.documentId || context.documentId); + if (baseMapping.type === "submission") { + const submission = await server.db.models["submission"].findByPk(context.submissionId, {raw: true}); + const baseType = asObject(config.baseFiles)[submission?.validationConfigurationId] + || baseMapping.selectedFiles?.[0]; + const docTypes = server.db.models["document"].docTypes; + const type = docTypes[`DOC_TYPE_${String(baseType).toUpperCase()}`] ?? docTypes.DOC_TYPE_ZIP; + const document = await server.db.models["document"].findOne({ + where: {submissionId: context.submissionId, type, deleted: false}, + raw: true, + }); + documentId = document?.id; + } + if (!documentId) { + throw new Error("AI hook trigger could not resolve its result document."); + } + + const values = {}; + for (const [placeholder, mapping] of Object.entries(inputMappings)) { + if (placeholder === "output" || !mapping) continue; + if (!["submission", "document", "configuration"].includes(mapping.type)) { + throw new Error(`Unsupported AI hook input type "${mapping.type}".`); + } + values[placeholder] = { + type: "serviceReplacement", + input: { + ...mapping, + submissionId: mapping.submissionId || context.submissionId, + documentId: mapping.documentId || documentId, + }, + }; + } + + const userId = trigger.userId || context.userId; + const result = await aiHook.runHook(service, { userId }, { + hookId, + values, + documentId, + }); + + let value = result.outputText || ""; + if (typeof value === "string") { + try { + value = JSON.parse(value); + } catch (_error) { + // Keep non-JSON hook output as text. + } + } + + await server.db.models["document_data"].upsertData({ + userId, + documentId, + studySessionId: null, + studyStepId: null, + key: buildStudyHookKey("nlpRequest", hook.name), + value, + }); + + return { ...result, documentId }; +} + +/** + * Run the configured NLP preprocessing action through BackgroundTaskService. + * + * @param {Object} server CARE server instance + * @param {Object} trigger Trigger row with action configuration + * @param {Object} context Resolved event context + * @param {Object} options Trigger runtime options + * @returns {Promise<*>} + */ +async function runAiPreprocessing(server, trigger, context, options = {}) { + const config = asObject(trigger.configuration).action || {}; + if (getConfiguredHookId(config)) { + return await runAiHookTrigger(server, trigger, context); + } + + const service = server.services["BackgroundTaskService"]; + + if (!service) { + throw new Error("BackgroundTaskService is not available."); + } + + const socketId = `trigger:${trigger.id}:${Date.now()}`; + const documentSocket = { + userId: trigger.userId || context.userId, + socket: { + id: socketId, + emit: async (event, payload) => { + if (event !== "serviceRefresh" || payload.service !== "NLPService") { + return; + } + if (payload.type === "skillResults") { + await service.setResult(payload.data); + } + if (payload.type === "error" && typeof service.setError === "function") { + await service.setError(payload.data); + } + }, + }, + isAdmin: async () => true, + }; + server.availSockets = server.availSockets || {}; + const previousSocket = server.availSockets[socketId]; + + server.availSockets[socketId] = { DocumentSocket: documentSocket }; + + try { + return await service.startPreprocessing( + { socket: { id: socketId } }, + { + skillName: config.skillName, + skillParameterMappings: hydrateSkillParameterMappings(config.skillParameterMappings, context), + baseFileParameter: config.baseFileParameter, + baseFiles: config.baseFiles, + failOnItemError: true, + } + ); + } finally { + if (previousSocket) { + server.availSockets[socketId] = previousSocket; + } else { + delete server.availSockets[socketId]; + } + } +} + +module.exports = { + asObject, + handleTriggerEvent, + handleSubmissionUploaded, + rerunQueueItem, + retryQueueItem, + sendEmail, + runAiPreprocessing, + handlers: HANDLERS, +}; diff --git a/backend/webserver/sockets/app.js b/backend/webserver/sockets/app.js index d60aaf766..50f904243 100644 --- a/backend/webserver/sockets/app.js +++ b/backend/webserver/sockets/app.js @@ -69,7 +69,7 @@ class AppSocket extends Socket { if (("id" in data.data && data.data.id !== 0) && - ('deleted' in data.data || 'closed' in data.data || 'public' in data.data || 'end' in data.data || 'disable' in data.data)) { + ('deleted' in data.data || 'closed' in data.data || 'public' in data.data || 'end' in data.data || 'disable' in data.data || 'enabled' in data.data)) { newEntry = await this.models[data.table].updateById( data.data.id, data.data, @@ -84,7 +84,14 @@ class AppSocket extends Socket { // check or set user information if ("userId" in data.data && !await this.checkUserAccess(data.data.userId)) { - throw new Error("You are not allowed to update the table " + data.table + " for another user!"); + // Some tables use userId as a business field (e.g. share recipient) rather than ownership. + // Such models expose a static validateForeignUserId that performs its own ownership check. + const model = this.models[data.table]; + const bypassAllowed = typeof model.validateForeignUserId === "function" + && await model.validateForeignUserId(data.data, this.userId, transaction); + if (!bypassAllowed) { + throw new Error("You are not allowed to update the table " + data.table + " for another user!"); + } } // check data exists for required fields diff --git a/backend/webserver/sockets/assignment.js b/backend/webserver/sockets/assignment.js index 03ab91f28..6eb2513c2 100644 --- a/backend/webserver/sockets/assignment.js +++ b/backend/webserver/sockets/assignment.js @@ -307,14 +307,14 @@ class AssignmentSocket extends Socket { // remove the swappable assignment from the other user and add it to the current user roleSelection[roleId]['assignments'][otherUser.id] = otherAssignments.filter( - (assignedId) => assignedId !== swappableAssignment.id + (assignedId) => assignedId !== swappableAssignment ); // instead adding the other user's new assignment roleSelection[roleId]['assignments'][otherUser.id].push(otherUserNewAssignment.id); // add the swappable assignment to the current user - roleSelection[roleId]['assignments'][user.id].push(swappableAssignment.id); + roleSelection[roleId]['assignments'][user.id].push(swappableAssignment); // update the counters assignmentCounter[otherUserNewAssignment.id]++; @@ -357,7 +357,12 @@ class AssignmentSocket extends Socket { let currentAssignment = 0; for (const [assignmentId, reviewerIds] of assignmentEntries) { - const assignment = shuffledAssignments.find((a) => a.id === Number(assignmentId)); + const assignment = shuffledAssignments.find( + (a) => String(a.id) === String(assignmentId) + ); + if (!assignment) { + throw new Error(`Selected assignment ${assignmentId} could not be resolved.`); + } const reviewers = reviewerIds.map((reviewerId) => data.selectedReviewer.find((reviewer) => reviewer.id === Number(reviewerId))); const assignmentData = { assignment: assignment, @@ -513,7 +518,12 @@ class AssignmentSocket extends Socket { let currentAssignment = 0; for (const [reviewerId, assignmentIds] of Object.entries(finalAssignments)) { for (const assignmentId of assignmentIds) { - const assignment = shuffledAssignments.find((a) => a.id === Number(assignmentId)); + const assignment = shuffledAssignments.find( + (a) => String(a.id) === String(assignmentId) + ); + if (!assignment) { + throw new Error(`Selected assignment ${assignmentId} could not be resolved.`); + } const reviewer = data.selectedReviewer.find((reviewer) => reviewer.id === Number(reviewerId)); const assignmentData = { @@ -595,7 +605,7 @@ class AssignmentSocket extends Socket { const result = {}; for (const [key, value] of Object.entries(config)) { - if (value.isTemplate) { + if (value?.isTemplate) { switch (context.assignmentType) { case 'submission': result[key] = { ...value, submissionId: context.submissionId }; diff --git a/backend/webserver/sockets/document.js b/backend/webserver/sockets/document.js index 42270d47b..f5c76b7d6 100644 --- a/backend/webserver/sockets/document.js +++ b/backend/webserver/sockets/document.js @@ -12,6 +12,7 @@ const {Op} = require('sequelize'); const {applyTemplateToDocument} = require("../../utils/helper/documentTemplate.js"); const {generateError} = require("../../utils/helper/generic.js"); const {getEmailContent} = require("../../utils/helper/email.js"); +const {handleSubmissionUploaded} = require("../services/triggerHandlers.js"); const UPLOAD_PATH = `${__dirname}/../../../files`; @@ -1204,17 +1205,35 @@ class DocumentSocket extends Socket { } if (assignmentId) { - transaction.afterCommit(async () => { - try { - await this.sendSubmissionUploadEmail({ - assignmentId, - submissionId: submission.id, - submitterUserId: userId, - eventType: "first_upload", - }); - } catch (emailError) { - this.server.logger.error("Failed to send submission upload email:", emailError); - } + // Schedule post-upload work after commit without blocking the socket ack, + // so the upload modal can close while email/triggers run in the background. + transaction.afterCommit(() => { + setImmediate(async () => { + try { + await this.sendSubmissionUploadEmail({ + assignmentId, + submissionId: submission.id, + submitterUserId: userId, + eventType: "first_upload", + }); + } catch (emailError) { + this.server.logger.error("Failed to send submission upload email:", emailError); + } + + try { + await handleSubmissionUploaded(this.server, { + assignmentId, + submissionId: submission.id, + userId, + projectId, + timestamp: submission.createdAt, + }, { + broadcastQueueItem: async (item) => this.broadcastTable("trigger_queue", [item]), + }); + } catch (triggerError) { + this.server.logger.error("Failed to run submission upload triggers:", triggerError); + } + }); }); } } catch (error) { @@ -1339,17 +1358,35 @@ class DocumentSocket extends Socket { ); } - transaction.afterCommit(async () => { - try { - await this.sendSubmissionUploadEmail({ - assignmentId: assignment.id, - submissionId: newSubmission.id, - submitterUserId: userId, - eventType: "reupload", - }); - } catch (emailError) { - this.server.logger.error("Failed to send submission reupload email:", emailError); - } + // Schedule post-reupload work after commit without blocking the socket ack, + // so the upload modal can close while email/triggers run in the background. + transaction.afterCommit(() => { + setImmediate(async () => { + try { + await this.sendSubmissionUploadEmail({ + assignmentId: assignment.id, + submissionId: newSubmission.id, + submitterUserId: userId, + eventType: "reupload", + }); + } catch (emailError) { + this.server.logger.error("Failed to send submission reupload email:", emailError); + } + + try { + await handleSubmissionUploaded(this.server, { + assignmentId: assignment.id, + submissionId: newSubmission.id, + userId, + timestamp: newSubmission.createdAt, + eventType: "reupload", + }, { + broadcastQueueItem: async (item) => this.broadcastTable("trigger_queue", [item]), + }); + } catch (triggerError) { + this.server.logger.error("Failed to run submission reupload triggers:", triggerError); + } + }); }); return { diff --git a/backend/webserver/sockets/service.js b/backend/webserver/sockets/service.js index e1a4068aa..006bb24cf 100644 --- a/backend/webserver/sockets/service.js +++ b/backend/webserver/sockets/service.js @@ -43,33 +43,43 @@ class ServiceSocket extends Socket { /** * Request a service (with default command) - * + * + * The return value of the service's `request` method is forwarded to the + * socket.io ack callback (via Socket.createSocket) so services that + * follow the new ack-based pattern can respond directly instead of + * pushing `serviceRefresh` events. + * * @socketEvent serviceRequest * @param {Object} data The input data * @param {string} data.service The name of the service to disconnect * @param {Object} data.data Additional data to pass to the service * @param {Object} options Additional configuration parameters (currently unused). - * @returns {Promise} A promise that resolves (with no value) once the service request attempt is complete. + * @returns {Promise<*>} The result returned by the service, if any. */ async requestService(data, options) { if (this.server.services[data.service]) { - await this.server.services[data.service].request(this, data.data); + return await this.server.services[data.service].request(this, data.data); } } /** * Request a service but with a specific command - * + * + * The return value of the service's `command` method is forwarded to the + * socket.io ack callback (via Socket.createSocket). Services using the + * legacy push flow can still return undefined without affecting clients + * that do not pass a callback. + * * @socketEvent serviceCommand * @param {Object} data The input data * @param {string} data.service The name of the service to disconnect * @param {Object} data.data Additional data to pass to the service * @param {Object} options Additional configuration parameters (currently unused). - * @returns {Promise} A promise that resolves (with no value) once the service command attempt is complete. + * @returns {Promise<*>} The result returned by the service, if any. */ async serviceCommand(data, options) { if (this.server.services[data.service]) { - await this.server.services[data.service].command(this, data.command, data.data); + return await this.server.services[data.service].command(this, data.command, data.data); } } diff --git a/backend/webserver/sockets/template.js b/backend/webserver/sockets/template.js index 5b8135a06..285f0a70c 100644 --- a/backend/webserver/sockets/template.js +++ b/backend/webserver/sockets/template.js @@ -7,6 +7,8 @@ const { resolveTemplate, resolveTemplateToDelta, getMissingRequiredPlaceholders, + getDuplicatePlaceholderIds, + getUsedPlaceholders, formatMissingPlaceholderError, } = require("../../utils/helper/templateResolver"); @@ -19,6 +21,62 @@ const { */ class TemplateSocket extends Socket { + /** + * Validate access to prompt-resolution context data for non-admin users. + * + * @param {Object} context Resolver context + * @param {number} [context.documentId] Document ID + * @param {number} [context.studySessionId] Study session ID + * @param {number} [context.studyStepId] Study step ID + * @param {Object} options + * @param {Object} options.transaction + * @returns {Promise} + */ + async validateResolveContextAccess(context, options = {}) { + let studyStep = null; + + if (context.documentId && !(await this.checkDocumentAccess(context.documentId))) { + throw new Error("Access denied"); + } + + if (context.studyStepId) { + studyStep = await this.models["study_step"].getById(context.studyStepId, options); + if (!studyStep) { + throw new Error("Study step not found"); + } + if (studyStep.documentId && !(await this.checkDocumentAccess(studyStep.documentId))) { + throw new Error("Access denied"); + } + if (context.documentId && studyStep.documentId && studyStep.documentId !== context.documentId) { + throw new Error("Study step does not match document"); + } + } + + if (context.studySessionId) { + const studySession = await this.models["study_session"].getById(context.studySessionId, options); + if (!studySession) { + throw new Error("Study session not found"); + } + + let hasSessionAccess = + studySession.userId === this.userId || + (await this.hasAccess("frontend.dashboard.studies.fullAccess")); + + if (!hasSessionAccess) { + const study = await this.models["study"].getById(studySession.studyId, options); + hasSessionAccess = !!study && (await this.checkUserAccess(study.userId)); + } + + if (!hasSessionAccess) { + throw new Error("Access denied"); + } + + if (studyStep?.studyId && studySession.studyId !== studyStep.studyId) { + throw new Error("Study session does not match study step"); + } + } + } + /** * Create a template * @@ -216,7 +274,7 @@ class TemplateSocket extends Socket { * * @socketEvent templatePlaceholderAdd * @param {Object} data The data object - * @param {number} data.templateType Template type (required, 1-5) + * @param {number} data.templateType Template type (required, 1-8) * @param {string} data.placeholderKey Placeholder key (required, e.g., "username") * @param {string} data.placeholderLabel Placeholder label (required, e.g., "Username") * @param {string} data.placeholderType Placeholder type (required, e.g., "text") @@ -227,8 +285,8 @@ class TemplateSocket extends Socket { */ async addPlaceholder(data, options) { if (!(await this.isAdmin())) throw new Error("Access denied"); - if (!data.templateType || ![1, 2, 3, 4, 5, 6, 7].includes(data.templateType)) { - throw new Error("Template type is required and must be 1-7"); + if (!data.templateType || ![1, 2, 3, 4, 5, 6, 7, 8].includes(data.templateType)) { + throw new Error("Template type is required and must be 1-8"); } if (!data.placeholderKey || !data.placeholderLabel || !data.placeholderType) { throw new Error("Missing required fields: placeholderKey, placeholderLabel, placeholderType"); @@ -315,6 +373,36 @@ class TemplateSocket extends Socket { ); } + /** + * Get the placeholders a specific template actually uses (tokens present in its content). + * + * Placeholders are defined per template type, not per template, so "used" is derived from the + * template's content. Returns the same row shape as {@link getAllPlaceholders}. + * + * @socketEvent templatePlaceholderGetUsed + * @param {Object} data The data object + * @param {number} data.templateId Template ID (required) + * @param {Object} options + * @param {Object} options.transaction + * @returns {Promise} + */ + async getUsedPlaceholders(data, options) { + if (!data.templateId) throw new Error("Template ID is required"); + + const template = await this.models["template"].getById(data.templateId); + if (!template) { + throw new Error("Template not found"); + } + + const isOwner = template.userId === this.userId; + const isPublicFromOthers = template.public === true && !isOwner; + if (!isOwner && !isPublicFromOthers) { + throw new Error("Access denied: You can only view placeholders for templates that you own or public templates from others"); + } + + return await getUsedPlaceholders(data.templateId, this.models, { transaction: options.transaction }); + } + /** * Get list of language codes that have content for a template @@ -414,6 +502,12 @@ class TemplateSocket extends Socket { * @param {number} [data.context.creatorId] Study creator ID * @param {number} [data.context.studyId] Study ID (for anonymization check) * @param {number} [data.context.studySessionId] Study session ID + * @param {number} [data.context.studyStepId] Study step ID (prompt placeholders, editor resolution) + * @param {number} [data.context.documentId] Document ID (prompt placeholders) + * @param {string} [data.context.pdfText] Extracted text for the current PDF (`~pdfText~`; caller-supplied) + * @param {Object} [data.context.submissionPdfTexts] Optional map documentId -> string for submission PDF text extraction + * @param {Object} [data.context.placeholderMapping] Per-key index maps for bracket tokens (e.g. submissionFiles: { 1: documentId, 3: documentId }) + * @param {string} [data.context.editorText] Optional editor plain-text override (`~editorText~`) * @param {string} [data.context.studySessionHash] Study session hash (for link) * @param {string} [data.context.baseUrl] Base URL for generating links * @param {string} [data.context.assignmentType] Assignment type @@ -425,11 +519,28 @@ class TemplateSocket extends Socket { * @returns {Promise} */ async resolveTemplatePlaceholders(data, options) { - if (!(await this.isAdmin())) throw new Error("Access denied"); if (!data.templateId) throw new Error("Template ID is required"); if (!data.context || typeof data.context !== 'object') { throw new Error("Context object is required"); } + const template = await this.models["template"].getById(data.templateId); + if (!template) { + throw new Error("Template not found"); + } + const isAdmin = await this.isAdmin(); + const isEmailTemplate = [1, 2, 3, 6].includes(template.type); + const isOwner = template.userId === this.userId; + const isPublicFromOthers = template.public === true && !isOwner; + + if (!isAdmin && isEmailTemplate) { + throw new Error("Access denied"); + } + if (!isAdmin && !isOwner && !isPublicFromOthers) { + throw new Error("Access denied"); + } + if (!isAdmin) { + await this.validateResolveContextAccess(data.context, options); + } // Get baseUrl from settings if not provided in context if (!data.context.baseUrl) { @@ -456,6 +567,30 @@ class TemplateSocket extends Socket { } } + /** + * Reject save when merged content contains duplicate placeholder ids. + * + * @param {Object} content - Delta content with ops + * @param {number} templateType - Template type + * @param {Object} options - Sequelize options + * @returns {Promise} + * @throws {Error} + */ + async assertNoDuplicatePlaceholders(content, templateType, options = {}) { + const duplicates = await getDuplicatePlaceholderIds( + content, + templateType, + this.models, + options + ); + if (duplicates.length > 0) { + throw new Error( + `This template has duplicate bracket placeholder ids: ${duplicates.join(", ")}. ` + + `Each ~key[N]~ must appear at most once. Legacy ~key~ tokens without [N] are unchanged and may repeat.` + ); + } + } + /** * Save template by merging draft edits into template_content for the given language * @@ -487,17 +622,17 @@ class TemplateSocket extends Socket { }); if (edits.length === 0) { + const templateContentModel = this.models["template_content"]; + const langRow = await templateContentModel.findOne({ + where: { templateId, language, deleted: false }, + raw: true, + ...options, + }); + let baseContent = new Delta(); + if (langRow && langRow.content && langRow.content.ops) { + baseContent = new Delta(langRow.content.ops); + } if ([1, 2, 3, 6, 7].includes(template.type)) { - const templateContentModel = this.models["template_content"]; - const langRow = await templateContentModel.findOne({ - where: { templateId, language, deleted: false }, - raw: true, - ...options, - }); - let baseContent = new Delta(); - if (langRow && langRow.content && langRow.content.ops) { - baseContent = new Delta(langRow.content.ops); - } const missing = await getMissingRequiredPlaceholders( { ops: baseContent.ops }, template.type, @@ -508,6 +643,11 @@ class TemplateSocket extends Socket { throw new Error(formatMissingPlaceholderError(missing, { action: "saving" })); } } + await this.assertNoDuplicatePlaceholders( + { ops: baseContent.ops }, + template.type, + options + ); return; } @@ -538,6 +678,12 @@ class TemplateSocket extends Socket { } } + await this.assertNoDuplicatePlaceholders( + { ops: mergedDelta.ops }, + template.type, + options + ); + const contentPayload = { content: { ops: mergedDelta.ops } }; if (langRow) { await templateContentModel.update(contentPayload, { @@ -773,6 +919,7 @@ class TemplateSocket extends Socket { this.createSocket("templatePlaceholderAdd", this.addPlaceholder, {}, true); this.createSocket("templatePlaceholderUpdate", this.updatePlaceholder, {}, true); this.createSocket("templatePlaceholderGetAll", this.getAllPlaceholders, {}, false); + this.createSocket("templatePlaceholderGetUsed", this.getUsedPlaceholders, {}, false); this.createSocket("templateResolve", this.resolveTemplatePlaceholders, {}, false); this.createSocket("templateCopy", this.copyTemplate, {}, true); this.createSocket("templateDetach", this.detachTemplate, {}, true); diff --git a/backend/webserver/sockets/trigger.js b/backend/webserver/sockets/trigger.js new file mode 100644 index 000000000..66b8355c3 --- /dev/null +++ b/backend/webserver/sockets/trigger.js @@ -0,0 +1,250 @@ +"use strict"; +const Socket = require("../Socket.js"); +const triggerHandlers = require("../services/triggerHandlers.js"); + +const QUEUE_STATUSES = [ + { name: "PENDING", value: 0, label: "Pending" }, + { name: "RUNNING", value: 1, label: "Running" }, + { name: "COMPLETED", value: 2, label: "Completed" }, + { name: "CANCELLED", value: 3, label: "Cancelled" }, + { name: "FAILED", value: 4, label: "Failed" }, +]; +const QUEUE_STATUS_BY_NAME = Object.fromEntries(QUEUE_STATUSES.map((s) => [s.name, s])); +const QUEUE_STATUS_BY_VALUE = Object.fromEntries(QUEUE_STATUSES.map((s) => [s.value, s])); +const QUEUE_STATUS = { + PENDING: QUEUE_STATUS_BY_NAME.PENDING.value, + RUNNING: QUEUE_STATUS_BY_NAME.RUNNING.value, + COMPLETED: QUEUE_STATUS_BY_NAME.COMPLETED.value, + CANCELLED: QUEUE_STATUS_BY_NAME.CANCELLED.value, + FAILED: QUEUE_STATUS_BY_NAME.FAILED.value, +}; + +/** + * Handle trigger rules through websocket. + * + * @type {TriggerSocket} + * @class TriggerSocket + */ +class TriggerSocket extends Socket { + /** + * Create a new trigger rule. + * + * @socketEvent triggerCreate + * @param {Object} data The trigger payload (event, action, settings, configuration) + * @param {Object} options Holds the managed transaction + * @returns {Promise} The created trigger + */ + async createTrigger(data, options) { + if (!(await this.isAdmin())) { + throw new Error("You do not have permission to create triggers."); + } + if (!data.triggerEventId) { + throw new Error("An event is required."); + } + if (!data.triggerActionId) { + throw new Error("An action is required."); + } + if (!data.name?.trim()) { + throw new Error("A name is required."); + } + + const payload = { + name: data.name, + userId: this.userId, + triggerEventId: data.triggerEventId, + triggerActionId: data.triggerActionId, + projectId: data.projectId, + parallelLimit: data.parallelLimit ?? 1, + maxRetries: data.maxRetries ?? 3, + timeout: data.timeout ?? 300, + enabled: data.enabled ?? true, + configuration: data.configuration || {}, + }; + + return await this.models["trigger"].add(payload, { transaction: options.transaction }); + } + + /** + * Update an existing trigger rule. + * + * @socketEvent triggerUpdate + * @param {Object} data Must contain `id`; any other trigger fields are updated. + * @param {Object} options Holds the managed transaction + * @returns {Promise} The updated trigger + */ + async updateTrigger(data, options) { + if (!(await this.isAdmin())) { + throw new Error("You do not have permission to update triggers."); + } + if (!data.id) { + throw new Error("A trigger id is required."); + } + + const allowed = [ + "name", "triggerEventId", "triggerActionId", "projectId", + "parallelLimit", "maxRetries", "timeout", "enabled", "configuration", + ]; + const payload = {}; + for (const key of allowed) { + if (key in data) { + payload[key] = data[key]; + } + } + + return await this.models["trigger"].updateById(data.id, payload, { transaction: options.transaction }); + } + + /** + * Soft-delete a trigger rule. + * + * @socketEvent triggerDelete + * @param {Object} data Must contain `id`. + * @param {Object} options Holds the managed transaction + * @returns {Promise} + */ + async deleteTrigger(data, options) { + if (!(await this.isAdmin())) { + throw new Error("You do not have permission to delete triggers."); + } + if (!data.id) { + throw new Error("A trigger id is required."); + } + + return await this.models["trigger"].deleteById(data.id, { transaction: options.transaction }); + } + + /** + * Load a queue log entry with related trigger and catalog labels. + * + * @socketEvent triggerQueueGetDetails + * @param {Object} data Must contain `id` (queue item id) + * @returns {Promise} + */ + async getQueueDetails(data) { + if (!(await this.isAdmin())) { + throw new Error("You do not have permission to view trigger logs."); + } + if (!data.id) { + throw new Error("A queue item id is required."); + } + + const item = await this.models["trigger_queue"].getById(data.id); + if (!item) { + throw new Error("Queue item not found."); + } + + const trigger = await this.models["trigger"].getById(item.triggerId, {}, true); + let eventLabel = "-"; + let actionLabel = "-"; + if (trigger) { + const event = await this.models["trigger_event"].getById(trigger.triggerEventId, {}, true); + const action = await this.models["trigger_action"].getById(trigger.triggerActionId, {}, true); + eventLabel = event?.configuration?.label || event?.name || "-"; + actionLabel = action?.configuration?.label || action?.name || "-"; + } + + return { + item, + trigger: trigger || null, + eventLabel, + actionLabel, + statusLabel: this.#statusLabel(item.status), + }; + } + + /** + * Re-queue a failed or cancelled trigger execution for another run. + * + * @socketEvent triggerQueueRetry + * @param {Object} data Must contain `id` (queue item id) + * @param {Object} options Holds the managed transaction + * @returns {Promise} + */ + async retryQueueItem(data, options) { + if (!(await this.isAdmin())) { + throw new Error("You do not have permission to retry trigger logs."); + } + if (!data.id) { + throw new Error("A queue item id is required."); + } + + return await triggerHandlers.retryQueueItem(this.server, data.id, { + ...options, + broadcastQueueItem: async (item) => this.broadcastTable("trigger_queue", [item]), + }); + } + + /** + * Create a new execution from a completed trigger log. + * + * @socketEvent triggerQueueRerun + * @param {Object} data Must contain `id` (queue item id) + * @param {Object} options Holds the managed transaction + * @returns {Promise} + */ + async rerunQueueItem(data, options) { + if (!(await this.isAdmin())) { + throw new Error("You do not have permission to re-run trigger logs."); + } + if (!data.id) { + throw new Error("A queue item id is required."); + } + + return await triggerHandlers.rerunQueueItem(this.server, data.id, { + ...options, + broadcastQueueItem: async (item) => this.broadcastTable("trigger_queue", [item]), + }); + } + + /** + * Cancel a pending or running trigger execution. + * + * @socketEvent triggerQueueCancel + * @param {Object} data Must contain `id` (queue item id) + * @param {Object} options Holds the managed transaction + * @returns {Promise} + */ + async cancelQueueItem(data, options) { + if (!(await this.isAdmin())) { + throw new Error("You do not have permission to cancel trigger logs."); + } + if (!data.id) { + throw new Error("A queue item id is required."); + } + + const item = await this.models["trigger_queue"].getById(data.id); + if (!item) { + throw new Error("Queue item not found."); + } + + const cancellable = [QUEUE_STATUS.PENDING, QUEUE_STATUS.RUNNING]; + if (!cancellable.includes(item.status)) { + throw new Error("Only pending or running queue items can be cancelled."); + } + + return await this.models["trigger_queue"].updateById( + data.id, + { + status: QUEUE_STATUS.CANCELLED, + completedAt: new Date(), + }, + { transaction: options.transaction } + ); + } + + #statusLabel(status) { + return QUEUE_STATUS_BY_VALUE[status]?.label ?? String(status); + } + + init() { + this.createSocket("triggerCreate", this.createTrigger, {}, true); + this.createSocket("triggerUpdate", this.updateTrigger, {}, true); + this.createSocket("triggerDelete", this.deleteTrigger, {}, true); + this.createSocket("triggerQueueGetDetails", this.getQueueDetails, {}, false); + this.createSocket("triggerQueueRetry", this.retryQueueItem, {}, false); + this.createSocket("triggerQueueRerun", this.rerunQueueItem, {}, false); + this.createSocket("triggerQueueCancel", this.cancelQueueItem, {}, true); + } +} + +module.exports = TriggerSocket; diff --git a/docker-compose.yml b/docker-compose.yml index 8addba609..24fdc9879 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -54,4 +54,12 @@ services: context: ./utils/rpcs/pdf dockerfile: Dockerfile command: gunicorn --workers 1 --threads 100 --bind 0.0.0.0:8082 'main:create_app()' --access-logfile '-' --error-logfile '-' + restart: unless-stopped + rpc_litellm: + build: + context: ./utils/rpcs/litellm + dockerfile: Dockerfile + command: gunicorn --workers 1 --threads 100 --timeout 120 --bind 0.0.0.0:8083 'main:create_app()' --access-logfile '-' --error-logfile '-' + extra_hosts: + - "host.docker.internal:host-gateway" restart: unless-stopped \ No newline at end of file diff --git a/docker-dev.yml b/docker-dev.yml index 8768a332b..faa3a946a 100644 --- a/docker-dev.yml +++ b/docker-dev.yml @@ -11,4 +11,7 @@ services: - ${RPC_MOODLE_PORT}:8081 rpc_pdf: ports: - - ${RPC_PDF_PORT}:8082 \ No newline at end of file + - ${RPC_PDF_PORT}:8082 + rpc_litellm: + ports: + - ${RPC_LITELLM_PORT}:8083 \ No newline at end of file diff --git a/docs/source/for_developers/frontend/basic/form.rst b/docs/source/for_developers/frontend/basic/form.rst index a153ed4bb..799901409 100644 --- a/docs/source/for_developers/frontend/basic/form.rst +++ b/docs/source/for_developers/frontend/basic/form.rst @@ -301,6 +301,17 @@ Passing an object: ] } +Optional searchable mode (useful for long option lists): + +.. code-block:: javascript + + { + type: "select", + search: true, // enables type-to-filter combobox UI + placeholder: "Select...", + options: [ /* ... */ ] + } + Using autotable: .. code-block:: javascript diff --git a/docs/source/for_developers/frontend/components/templates.rst b/docs/source/for_developers/frontend/components/templates.rst index 4bc18bda1..fd275ba52 100644 --- a/docs/source/for_developers/frontend/components/templates.rst +++ b/docs/source/for_developers/frontend/components/templates.rst @@ -18,7 +18,7 @@ Templates are listed and created from **Dashboard → Templates**. See the :doc: Location: ``frontend/src/components/dashboard/Templates.vue`` -When you open a template for editing, the Editor loads with ``templateId`` provided; it renders the :doc:`editor` (TemplateEditor) for the main content and, for email types (1, 2, 3, 6), a **Placeholders** sidebar so you can insert allowed placeholders (e.g. ``~username~``, ``~link~``) into the text. +When you open a template for editing, the Editor loads with ``templateId`` provided; it renders the :doc:`editor` (TemplateEditor) for the main content and, for email types (1, 2, 3, 6) and prompt templates (type 8), a **Placeholders** sidebar so you can insert allowed placeholders (e.g. ``~username~``, ``~link~`` for emails, or ``~nlpAssessmentSuggestion~``, ``~assessmentResult~`` for prompts) into the text. Location: ``frontend/src/components/editor/sidebar/TemplateConfigurator.vue`` @@ -32,7 +32,7 @@ Backend storage: Location: ``backend/utils/templateResolver.js`` Placeholder resolution is implemented there: ``resolveTemplate`` (returns HTML for emails) and ``resolveTemplateToDelta`` (returns Delta for document creation). -Only placeholders listed in ``PLACEHOLDERS_BY_TYPE`` for the template's type are substituted at runtime. +Allowed placeholders per template type come from the ``placeholder`` database table; ``buildReplacementMap`` / ``buildPromptPlaceholderValues`` substitute only keys allowed for ``context.templateType``. Implementing the Template Editor --------------------------------- @@ -101,6 +101,40 @@ At resolution time, only the placeholder keys listed in the following table are | | | | ``email.template.studyClosed`` (``sendStudyClosedEmails``) | | | | | in ``study.js``. | +--------------------------+--------+--------------------------------------+------------------------------------------------------------+ +| Prompt | 8 | ``pdfText``, ``editorText``, | Study/NLP prompt templates: ``templateResolve`` in | +| | | ``assessmentResult``, | ``backend/webserver/sockets/template.js`` (see below) | +| | | ``inlineComments``, | | +| | | ``nlpAssessmentSuggestion``, | | +| | | ``previousAssessmentResult``, | | +| | | ``assessmentConfiguration``, | | +| | | ``submissionFiles``, | | +| | | ``studyContext`` | | ++--------------------------+--------+--------------------------------------+------------------------------------------------------------+ + +Prompt templates (type 8) +~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. _prompt-templates-ref: + +Prompt templates use the same Placeholders sidebar and ``placeholder`` table as email templates. + +Location: ``backend/webserver/sockets/template.js`` (``templateResolve``) + +At edit time, TemplateEditor preview (types 1, 2, 3, 6, and 8) substitutes ``placeholderExample`` from the +``placeholder`` row when set (sample text only; rows may be empty until examples are added). +At runtime, ``buildPromptPlaceholderValues`` in ``backend/utils/templateResolver.js`` loads real values from +``context`` and the database. Many placeholders need ``documentId``, ``studySessionId``, and ``studyStepId``; +if they are missing, those tokens resolve to an empty string. + +``~nlpAssessmentSuggestion~`` is the NLP draft assessment for the current step (same ``document_data`` as the +Assessment sidebar pre-fill), not the saved rubric in ``assessment_result`` (use ``~assessmentResult~`` for that). +Resolution is implemented in ``backend/utils/studyNlpDocumentData.js``. + +``~editorText~`` is plain text from the HTML or modal document (``resolveEditorText`` in +``backend/utils/templateResolver.js``): base ``.delta`` plus session draft edits, including earlier steps in the same +session. Pass ``context.editorText`` on ``templateResolve`` to override (capped at 15k characters). Call +``templateResolve`` after step loading (``loadingReady``) or on user action—not in the same pass as NLP +``insertIntoEditor`` unless ``context.editorText`` is set explicitly. Adding a New Template Type or Placeholder ----------------------------------------- @@ -114,34 +148,41 @@ Adding a New Template Type or Placeholder enforced via ``getMissingRequiredPlaceholders`` and ``assertStableEmailTemplateContent`` in ``backend/utils/templateResolver.js``. -Here is a concrete example for adding a new placeholder (e.g. ``studyEndDate`` for type 6): +Email placeholders (types 1, 2, 3, 6) are resolved in ``buildReplacementMap`` from values on the resolver ``context``. +Prompt placeholders (type 8) are resolved in ``buildPromptPlaceholderValues`` (often from ``document_data`` or +``study_step``). For type 8, new keys must also be listed in the ``promptKeys`` array in ``buildReplacementMap`` so +that function is invoked. + +Here is a concrete example for adding a new placeholder: 1. **Backend (DB + resolver):** - - Add a row to the ``placeholder`` table via a migration: + - Add a row to the ``placeholder`` table via a migration (``type``, ``placeholderKey``, label, description, + ``required``, and optionally ``placeholderExample`` for editor preview). - - ``type``: ``6`` (Email - Study Close) - - ``placeholderKey``: ``studyEndDate`` - - other metadata as needed (label, required, etc.) + - **Email (e.g. ``studyEndDate`` for type 6):** in ``buildReplacementMap``, when ``allow("studyEndDate")``:: - - Update ``PLACEHOLDERS_BY_TYPE`` / ``buildReplacementMap`` in - ``backend/utils/templateResolver.js`` to fill ``studyEndDate`` from context, for example: + replacements["~studyEndDate~"] = context.studyEndDate || ""; - - In the resolver, when ``context.templateType === 6``, set - ``replacements["~studyEndDate~"] = ``. + Ensure the call site (e.g. ``sendStudyClosedEmails`` in ``study.js``) passes ``studyEndDate`` on ``context``. - - If the new placeholder is driven by a specific feature (e.g. study close emails), - ensure the call site (e.g. ``sendStudyClosedEmails`` in ``study.js``) passes - whatever additional data is needed into the resolver context. + - **Prompt (e.g. ``myNewField`` for type 8):** add ``"myNewField"`` to ``promptKeys`` in ``buildReplacementMap``, + then in ``buildPromptPlaceholderValues``, when ``allow("myNewField")``:: -2. **Frontend (editor + sidebar):** + promptValues["~myNewField~"] = context.myNewField || ""; + + For database-backed values, follow existing placeholders such as ``assessmentResult`` or + ``nlpAssessmentSuggestion``. Ensure ``templateResolve`` passes the needed ``context`` fields (often + ``documentId``, ``studySessionId``, ``studyStepId``). - - Add the placeholder to the template configurator configuration so it appears in the - Placeholders sidebar with a name/description (e.g. update - ``placeholderConfigs`` / ``longDescriptions`` in - ``frontend/src/components/editor/sidebar/TemplateConfigurator.vue``). +2. **Frontend (editor + sidebar):** + - The sidebar loads allowed placeholders from the database via ``templatePlaceholderGetAll``; no separate + frontend list is required. + - Optionally extend ``longDescriptions`` in + ``frontend/src/components/editor/sidebar/TemplateConfigurator.vue`` for richer tooltip help (types 1, 2, 3, 6, + and 8 already define entries; otherwise the sidebar uses ``placeholderDescription`` from the database). 3. **Access / type visibility:** diff --git a/frontend/src/assets/serviceDocumentDataKeys.js b/frontend/src/assets/serviceDocumentDataKeys.js new file mode 100644 index 000000000..f661e3cad --- /dev/null +++ b/frontend/src/assets/serviceDocumentDataKeys.js @@ -0,0 +1,48 @@ +/** + * Canonical document_data keys for study NLP services and AI hooks. + */ + +export const ASSESSMENT_RESULT_KEY = "assessment_result"; + +export function buildServiceSkillKey(serviceName, skillName) { + if (!serviceName || !skillName) return null; + return `${serviceName}_${skillName}`; +} + +export function buildSkillResultKey(serviceName, skillName, resultField) { + const baseKey = buildServiceSkillKey(serviceName, skillName); + if (!baseKey || !resultField) return null; + return `${baseKey}_${resultField}`; +} + +export function buildHookResultKey(serviceName) { + return serviceName || null; +} + +export function getHookResultKeyCandidates(serviceName, serviceType) { + return [...new Set([ + buildHookResultKey(serviceName), + buildHookResultKey(serviceType), + ].filter(Boolean))]; +} + +export function buildServiceResultKey(service, resultField) { + if (!service) return null; + if (service.hookId) { + return buildHookResultKey(service.name); + } + return buildSkillResultKey(service.name, service.skill, resultField); +} + +export function getAssessmentResultKeyCandidates(service, resultField = "assessment") { + if (!service) return []; + + const keys = service.hookId + ? getHookResultKeyCandidates(service.name, service.type) + : [ + buildSkillResultKey(service.name, service.skill, resultField), + buildSkillResultKey(service.type, service.skill, resultField), + ]; + + return [...new Set(keys.filter(Boolean))]; +} diff --git a/frontend/src/basic/Form.vue b/frontend/src/basic/Form.vue index 4f93479e0..12bf83b85 100644 --- a/frontend/src/basic/Form.vue +++ b/frontend/src/basic/Form.vue @@ -11,6 +11,7 @@ @update:model-value="currentData = $event" @update:config-status="handleConfigStatusChange" @file-change="(file) => $emit('file-change', file)" + @button-click="$emit('button-click', $event)" />
@@ -56,6 +58,7 @@ export default { provide() { return { formData: computed(() => this.currentData), + formButtonClick: (payload) => this.$emit("button-click", payload), }; }, props: { @@ -68,7 +71,7 @@ export default { required: true, }, }, - emits: ["update:modelValue", "update:configStatus", "file-change"], + emits: ["update:modelValue", "update:configStatus", "file-change", "button-click"], data() { return { currentData: null, diff --git a/frontend/src/basic/Sidebar.vue b/frontend/src/basic/Sidebar.vue index d6a537faf..233fedd0f 100644 --- a/frontend/src/basic/Sidebar.vue +++ b/frontend/src/basic/Sidebar.vue @@ -555,14 +555,19 @@ export default { } .sidebar-content { - height: 100%; - overflow-y: scroll; + flex: 1; + min-height: 0; + overflow-y: auto; + padding-bottom: 4.5rem; + scroll-padding-bottom: 1.5rem; + box-sizing: border-box; } #sidepane { background-color: #e6e6e6; width: 100%; - height: 100%; + flex: 1; + min-height: 0; display: flex; flex-direction: column; } diff --git a/frontend/src/basic/form/Element.vue b/frontend/src/basic/form/Element.vue index 1c9775203..2f04419ec 100644 --- a/frontend/src/basic/form/Element.vue +++ b/frontend/src/basic/form/Element.vue @@ -11,12 +11,30 @@
- + class="d-flex justify-content-between align-items-center mb-1" + > +
+ + +
+ +
@@ -46,6 +64,7 @@ + + diff --git a/frontend/src/basic/form/Select.vue b/frontend/src/basic/form/Select.vue index 768443800..0a61e0323 100644 --- a/frontend/src/basic/form/Select.vue +++ b/frontend/src/basic/form/Select.vue @@ -5,8 +5,63 @@ :options="options" >