diff --git a/grading-schema.json b/grading-schema.json new file mode 100644 index 0000000..1c6aa9b --- /dev/null +++ b/grading-schema.json @@ -0,0 +1,108 @@ +{ + "name": "grade", + "strict": true, + "schema": { + "type": "object", + "properties": { + "summary": { + "type": "string", + "description": "a short summary of the provided content. Must not be longer than 200 characters." + }, + "categories": { + "type": "object", + "properties": { + "bibliography": { + "type": "object", + "description": "Are at least 3 sources of primary or secondary literature cited and used in the work?", + "properties": { + "grade": { + "type": "number", + "minimum": 1, + "maximum": 6, + "description": "The swiss school grade for this category, which ranges from 1-6 where grade 1 is the worst/failing mark, grade 4 is the minimum passing grade (sufficient performance), and grade 6 is the highest/excellent grade." + }, + "sources": { + "type": "array", + "items": { + "type": "string" + } + }, + "feedback": { + "type": "object", + "properties": { + "good": { + "type": "string" + }, + "bad": { + "type": "string" + } + }, + "required": [ + "good", + "bad" + ], + "additionalProperties": false + } + }, + "required": [ + "grade", + "sources", + "feedback" + ], + "additionalProperties": false + }, + "language": { + "type": "object", + "description": "Is the language precise and to the point, without unnecessary embellishments and easily understandable?", + "properties": { + "grade": { + "type": "number", + "minimum": 1, + "maximum": 6, + "description": "The swiss school grade for this category, which ranges from 1-6 where grade 1 is the worst/failing mark, grade 4 is the minimum passing grade (sufficient performance), and grade 6 is the highest/excellent grade." + }, + "feedback": { + "type": "object", + "properties": { + "good": { + "type": "string" + }, + "bad": { + "type": "string" + } + }, + "required": [ + "good", + "bad" + ], + "additionalProperties": false + } + }, + "required": [ + "grade", + "feedback" + ], + "additionalProperties": false + } + }, + "required": [ + "bibliography", + "language" + ], + "additionalProperties": false + }, + "final_grade": { + "type": "number", + "minimum": 1, + "maximum": 6, + "description": "The overall grade (swiss school) for the provided document, which ranges from 1-6 where grade 1 is the worst/failing mark, grade 4 is the minimum passing grade (sufficient performance), and grade 6 is the highest/excellent grade. This should be the summary of all evaluated categories" + } + }, + "additionalProperties": false, + "required": [ + "summary", + "categories", + "final_grade" + ] + } +} \ No newline at end of file diff --git a/package.json b/package.json index 340e29f..2d4137f 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "global": "^4.4.0", "lodash": "^4.17.21", "morgan": "^1.10.0", + "openai": "^5.0.1", "passport": "^0.7.0", "passport-azure-ad": "^4.3.5", "socket.io": "^4.8.1", @@ -62,4 +63,4 @@ "engines": { "node": "^22.11.0" } -} \ No newline at end of file +} diff --git a/prisma/migrations/20250608170858_add_ai_models/migration.sql b/prisma/migrations/20250608170858_add_ai_models/migration.sql new file mode 100644 index 0000000..9036e56 --- /dev/null +++ b/prisma/migrations/20250608170858_add_ai_models/migration.sql @@ -0,0 +1,39 @@ +-- CreateTable +CREATE TABLE "ai_templates" ( + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "author_id" UUID NOT NULL, + "rate_limit" INTEGER NOT NULL DEFAULT 0, + "rate_limit_period_ms" BIGINT NOT NULL DEFAULT 3600000, + "is_active" BOOLEAN NOT NULL DEFAULT true, + "api_key" TEXT NOT NULL, + "api_url" TEXT NOT NULL, + "config" JSONB NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "ai_templates_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "ai_requests" ( + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "user_id" UUID NOT NULL, + "ai_template_id" UUID NOT NULL, + "status" TEXT NOT NULL DEFAULT 'pending', + "status_code" INTEGER, + "request" TEXT NOT NULL, + "response" JSONB NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "ai_requests_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "ai_templates" ADD CONSTRAINT "ai_templates_author_id_fkey" FOREIGN KEY ("author_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ai_requests" ADD CONSTRAINT "ai_requests_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ai_requests" ADD CONSTRAINT "ai_requests_ai_template_id_fkey" FOREIGN KEY ("ai_template_id") REFERENCES "ai_templates"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 2c4dbeb..d56a405 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -28,10 +28,54 @@ model User { view_AllDocumentUserPermissions view_AllDocumentUserPermissions[] cmsSettings CmsSettings? studentGroups UserStudentGroup[] @relation("user_student_groups") + aiRequests AiRequest[] + aiTemplates AiTemplate[] @relation("ai_templates") @@map("users") } +model AiTemplate { + id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid + + author User @relation("ai_templates", fields: [authorId], references: [id], onDelete: Cascade) + authorId String @map("author_id") @db.Uuid + rateLimit Int @default(0) @map("rate_limit") + rateLimitPeriodMs BigInt @default(3600000) @map("rate_limit_period_ms") // Default to 1 hour + isActive Boolean @default(true) @map("is_active") + + apiKey String @map("api_key") + apiUrl String @map("api_url") + + config Json @map("config") // Configuration for the AI template, e.g., model, parameters + + aiRequests AiRequest[] + + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") + + @@map("ai_templates") +} + +model AiRequest { + id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + userId String @map("user_id") @db.Uuid + + aiTemplate AiTemplate @relation(fields: [aiTemplateId], references: [id], onDelete: Cascade) + aiTemplateId String @map("ai_template_id") @db.Uuid + + status String @default("pending") // pending, completed, failed + statusCode Int? @map("status_code") + + request String + response Json + + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") + + @@map("ai_requests") +} + model CmsSettings { id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid user User @relation(fields: [userId], references: [id], onDelete: Cascade) diff --git a/prisma/seed-files/users.ts b/prisma/seed-files/users.ts index 720e233..d6211d9 100644 --- a/prisma/seed-files/users.ts +++ b/prisma/seed-files/users.ts @@ -24,7 +24,7 @@ const users: Prisma.UserCreateInput[] = [ if (USER_EMAIL && USER_ID) { const name = USER_EMAIL.split('@')[0]; users.push({ - email: USER_EMAIL, + email: USER_EMAIL.toLowerCase(), id: USER_ID, firstName: name.split('.')[0], lastName: name.split('.')[1] || name, diff --git a/src/controllers/aiRequest.ts b/src/controllers/aiRequest.ts new file mode 100644 index 0000000..16f8a9c --- /dev/null +++ b/src/controllers/aiRequest.ts @@ -0,0 +1,38 @@ +import { RequestHandler } from 'express'; +import AiRequest from '../models/AiRequest'; +import { AiRequest as DbAiRequest } from '@prisma/client'; +import { IoRoom } from '../routes/socketEvents'; +import { IoEvent, RecordType } from '../routes/socketEventTypes'; + +export const all: RequestHandler<{ aiTemplateId: string }> = async (req, res, next) => { + try { + const requests = await AiRequest.all(req.user!, req.params.aiTemplateId); + res.json(requests); + } catch (error) { + next(error); + } +}; + +export const find: RequestHandler<{ id: string; requestId: string }> = async (req, res, next) => { + try { + const request = await AiRequest.findModel(req.user!, req.params.id, req.params.requestId); + res.json(request); + } catch (error) { + next(error); + } +}; + +export const create: RequestHandler<{ id: string }, any, { request: string }> = async (req, res, next) => { + try { + const onResponse = (aiRequest: DbAiRequest) => { + req.io?.to([req.user!.id, IoRoom.ADMIN]).emit(IoEvent.CHANGED_RECORD, { + type: RecordType.AiRequest, + record: aiRequest + }); + }; + const request = await AiRequest.createModel(req.user!, req.params.id, req.body.request, onResponse); + res.status(201).json(request); + } catch (error) { + next(error); + } +}; diff --git a/src/controllers/aiTemplate.ts b/src/controllers/aiTemplate.ts new file mode 100644 index 0000000..6a0e81a --- /dev/null +++ b/src/controllers/aiTemplate.ts @@ -0,0 +1,54 @@ +import { RequestHandler } from 'express'; +import AiTemplate from '../models/AiTemplate'; +(BigInt.prototype as any).toJSON = function () { + return this.toString(); // Convert to string for serialization +}; +export const all: RequestHandler = async (req, res, next) => { + try { + const templates = await AiTemplate.all(req.user!); + res.json(templates); + } catch (error) { + next(error); + } +}; + +export const find: RequestHandler<{ id: string }> = async (req, res, next) => { + try { + const template = await AiTemplate.findModel(req.user!, req.params.id); + res.json(template); + } catch (error) { + next(error); + } +}; +export const clone: RequestHandler<{ id: string }, any, any> = async (req, res, next) => { + try { + const template = await AiTemplate.cloneModel(req.user!, req.params.id); + res.status(201).json(template); + } catch (error) { + next(error); + } +}; +export const create: RequestHandler = async (req, res, next) => { + try { + const template = await AiTemplate.createModel(req.user!, req.body); + res.status(201).json(template); + } catch (error) { + next(error); + } +}; +export const update: RequestHandler<{ id: string }, any, any> = async (req, res, next) => { + try { + const template = await AiTemplate.updateModel(req.user!, req.params.id, req.body); + res.status(200).json(template); + } catch (error) { + next(error); + } +}; +export const destroy: RequestHandler<{ id: string }> = async (req, res, next) => { + try { + const template = await AiTemplate.deleteModel(req.user!, req.params.id); + res.status(200).json(template); + } catch (error) { + next(error); + } +}; diff --git a/src/models/AiRequest.ts b/src/models/AiRequest.ts new file mode 100644 index 0000000..69b33be --- /dev/null +++ b/src/models/AiRequest.ts @@ -0,0 +1,111 @@ +import { PrismaClient, User as DbUser, AiRequest as DbAiRequest } from '@prisma/client'; +import prisma from '../prisma'; +import { HTTP403Error } from '../utils/errors/Errors'; +import _ from 'lodash'; +import OpenAI from 'openai'; + +function AiRequest(db: PrismaClient['aiRequest']) { + return Object.assign(db, { + async findModel(actor: DbUser, aiTemplateId: string, id: string): Promise { + return db.findUniqueOrThrow({ where: { id: id, aiTemplateId: aiTemplateId, userId: actor.id } }); + }, + async all(actor: DbUser, aiTemplateId: string): Promise { + const requests = await db.findMany({ + where: { + userId: actor.id, + aiTemplateId: aiTemplateId + } + }); + return requests; + }, + async createModel( + actor: DbUser, + aiTemplateId: string, + input: string, + onResponse?: (aiRequest: DbAiRequest) => void + ): Promise { + const template = await prisma.aiTemplate.findUniqueOrThrow({ + where: { id: aiTemplateId } + }); + if (!template.isActive) { + throw new HTTP403Error('This AI template is not active'); + } + const previousRequests = await this.all(actor, aiTemplateId); + const now = new Date(); + const withinRateLimit = previousRequests.filter((request) => { + if (request.status === 'error') { + return false; // Ignore requests with error status + } + const requestTime = new Date(request.createdAt); + return now.getTime() - requestTime.getTime() <= template.rateLimitPeriodMs; + }); + if (withinRateLimit.length >= template.rateLimit) { + throw new HTTP403Error('Rate limit exceeded for this AI template'); + } + + const client = new OpenAI({ + apiKey: template.apiKey, + baseURL: template.apiUrl + }); + + const templateConfig = + template.config as unknown as OpenAI.Responses.ResponseCreateParamsNonStreaming; + const config: OpenAI.Responses.ResponseCreateParamsNonStreaming = { + ...templateConfig, + input: Array.isArray(templateConfig.input) + ? (templateConfig.input as OpenAI.Responses.ResponseInput) + : [templateConfig.input as unknown as OpenAI.Responses.ResponseInputItem] + }; + + (config.input as OpenAI.Responses.ResponseInput).push({ + role: 'user', + content: [ + { + type: 'input_text', + text: input + } + ] + }); + + const aiRequest = await db.create({ + data: { + userId: actor.id, + aiTemplateId: aiTemplateId, + request: input, + status: 'pending', + response: {} + } + }); + + client.responses + .create(config) + .then((response) => { + db.update({ + where: { id: aiRequest.id }, + data: { + status: response.error ? 'error' : 'completed', + response: response.error ? response.error : JSON.parse(response.output_text) + } + }).then((updatedRequest) => { + onResponse?.(updatedRequest); + }); + }) + .catch((error) => { + db.update({ + where: { id: aiRequest.id }, + data: { + status: 'error', + response: { + error: error.message || 'Unknown error occurred' + } + } + }).then((updatedRequest) => { + onResponse?.(updatedRequest); + }); + }); + return aiRequest; + } + }); +} + +export default AiRequest(prisma.aiRequest); diff --git a/src/models/AiTemplate.ts b/src/models/AiTemplate.ts new file mode 100644 index 0000000..40de62d --- /dev/null +++ b/src/models/AiTemplate.ts @@ -0,0 +1,115 @@ +import { Prisma, PrismaClient, User as DbUser, AiTemplate as DbAiTemplate } from '@prisma/client'; +import prisma from '../prisma'; +import { HTTP403Error } from '../utils/errors/Errors'; +import _ from 'lodash'; +import { hasElevatedAccess } from './User'; + +export function prepareAiTemplate(aiTemplate: DbAiTemplate): DbAiTemplate; +export function prepareAiTemplate(aiTemplate: null): null; +export function prepareAiTemplate(aiTemplate: DbAiTemplate | null): DbAiTemplate | null { + if (!aiTemplate) { + return null; + } + return { + ...aiTemplate, + apiKey: aiTemplate.apiKey + ? `${aiTemplate.apiKey.slice(0, 4)}******${aiTemplate.apiKey.slice(-4)}` + : '' + }; +} +function AiTemplate(db: PrismaClient['aiTemplate']) { + return Object.assign(db, { + async findModel(actor: DbUser, id: string): Promise { + if (!hasElevatedAccess(actor.role)) { + throw new HTTP403Error('Not authorized to find AI templates'); + } + return db.findUniqueOrThrow({ where: { id } }).then((v) => prepareAiTemplate(v)); + }, + async cloneModel(actor: DbUser, id: string): Promise { + if (!hasElevatedAccess(actor.role)) { + throw new HTTP403Error('Not authorized to find AI templates'); + } + const record = await db.findUniqueOrThrow({ where: { id: id, authorId: actor.id } }); + if ((record.config as any)?.text?.format?.name) { + (record.config as any).text.format.name = + `${(record.config as any).text.format.name} (clone)`; + } + const cloneData: Prisma.AiTemplateCreateInput = { + config: record.config as Prisma.InputJsonValue, + apiKey: record.apiKey, + apiUrl: record.apiUrl, + isActive: record.isActive, + rateLimit: record.rateLimit, + rateLimitPeriodMs: record.rateLimitPeriodMs, + author: { + connect: { id: actor.id } + } + }; + return db + .create({ + data: cloneData + }) + .then((v) => prepareAiTemplate(v)); + }, + async updateModel(actor: DbUser, id: string, data: Partial): Promise { + if (!hasElevatedAccess(actor.role)) { + throw new HTTP403Error('Not authorized to update AI templates'); + } + const record = await this.findModel(actor, id); + if (record.authorId !== actor.id) { + throw new HTTP403Error('Not authorized to update this AI template'); + } + const updateData: Prisma.AiTemplateUpdateInput = { + config: data.config as Prisma.InputJsonValue, + apiKey: data.apiKey, + apiUrl: data.apiUrl, + isActive: data.isActive, + rateLimit: data.rateLimit, + rateLimitPeriodMs: data.rateLimitPeriodMs + }; + /** remove fields not updatable*/ + return db + .update({ + where: { + id: record.id + }, + data: updateData + }) + .then((v) => prepareAiTemplate(v)); + }, + async all(actor: DbUser): Promise { + if (!hasElevatedAccess(actor.role)) { + throw new HTTP403Error('Not authorized to get AI templates'); + } + const templates = await db.findMany({}); + return templates.map((template) => prepareAiTemplate(template)); + }, + async createModel(actor: DbUser, data: Prisma.AiTemplateCreateInput): Promise { + if (!hasElevatedAccess(actor.role)) { + throw new HTTP403Error('Not authorized to create AI templates'); + } + return db + .create({ + data: { + ...data, + author: { + connect: { id: actor.id } + } + } + }) + .then((v) => prepareAiTemplate(v)); + }, + async deleteModel(actor: DbUser, id: string): Promise { + const record = await this.findModel(actor, id); + return db + .delete({ + where: { + id: record.id + } + }) + .then((v) => prepareAiTemplate(v)); + } + }); +} + +export default AiTemplate(prisma.aiTemplate); diff --git a/src/routes/authConfig.ts b/src/routes/authConfig.ts index 2029d4c..7ac168f 100644 --- a/src/routes/authConfig.ts +++ b/src/routes/authConfig.ts @@ -80,6 +80,15 @@ const authConfig: Config = { } ] }, + aiTemplateRequests: { + path: '/aiTemplates/:id/requests', + access: [ + { + methods: ['GET', 'POST'], + minRole: Role.STUDENT + } + ] + }, users: { path: '/users', access: [ diff --git a/src/routes/router.ts b/src/routes/router.ts index 094ad48..600a7ac 100644 --- a/src/routes/router.ts +++ b/src/routes/router.ts @@ -45,6 +45,20 @@ import { logout as githubLogout } from '../controllers/cmsSettings'; +import { + all as allAiTemplates, + create as createAiTemplate, + destroy as destroyAiTemplate, + find as findAiTemplate, + update as updateAiTemplate, + clone as cloneAiTemplate +} from '../controllers/aiTemplate'; +import { + all as allAiRequests, + create as createAiRequest, + find as findAiRequest +} from '../controllers/aiRequest'; + // initialize router const router = express.Router(); @@ -111,4 +125,16 @@ router.get('/cms/settings', findCmsSettings); router.put('/cms/settings', updateCmsSettings); router.get('/cms/github-token', githubToken); router.post('/cms/logout', githubLogout); + +router.get('/admin/aiTemplates', allAiTemplates); +router.get('/admin/aiTemplates/:id', findAiTemplate); +router.post('/admin/aiTemplates/:id/clone', cloneAiTemplate); +router.post('/admin/aiTemplates', createAiTemplate); +router.put('/admin/aiTemplates/:id', updateAiTemplate); +router.delete('/admin/aiTemplates/:id', destroyAiTemplate); + +router.get('/aiTemplates/:id/requests', allAiRequests); +router.get('/aiTemplates/:id/requests/:requestId', findAiRequest); +router.post('/aiTemplates/:id/requests', createAiRequest); + export default router; diff --git a/src/routes/socketEventTypes.ts b/src/routes/socketEventTypes.ts index 08fc140..007d16b 100644 --- a/src/routes/socketEventTypes.ts +++ b/src/routes/socketEventTypes.ts @@ -1,4 +1,4 @@ -import { AllowedAction, CmsSettings, Prisma, User } from '@prisma/client'; +import { AiRequest, AllowedAction, CmsSettings, Prisma, User } from '@prisma/client'; import { ApiDocument } from '../models/Document'; import { ApiUserPermission } from '../models/RootUserPermission'; import { ApiGroupPermission } from '../models/RootGroupPermission'; @@ -22,7 +22,8 @@ export enum RecordType { DocumentRoot = 'DocumentRoot', StudentGroup = 'StudentGroup', AllowedAction = 'AllowedAction', - CmsSettings = 'CmsSettings' + CmsSettings = 'CmsSettings', + AiRequest = 'AiRequest' } type TypeRecordMap = { @@ -34,6 +35,7 @@ type TypeRecordMap = { [RecordType.StudentGroup]: ApiStudentGroup; [RecordType.AllowedAction]: AllowedAction; [RecordType.CmsSettings]: CmsSettings; + [RecordType.AiRequest]: AiRequest; }; export interface NewRecord { diff --git a/yarn.lock b/yarn.lock index 9b4a997..ffe3779 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4903,6 +4903,11 @@ open@7.4.2: is-docker "^2.0.0" is-wsl "^2.1.1" +openai@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/openai/-/openai-5.0.1.tgz#c7cabc4cb13554f8506158364566c16514d051fe" + integrity sha512-Do6vxhbDv7cXhji/4ct1lrpZYMAOmjYbhyA9LJTuG7OfpbWMpuS+EIXkRT7R+XxpRB1OZhU/op4FU3p3uxU6gw== + p-filter@2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/p-filter/-/p-filter-2.1.0.tgz#1b1472562ae7a0f742f0f3d3d3718ea66ff9c09c"