From 9d8728d4f0e0d1a0ca706c31957ed078ee6e4b3e Mon Sep 17 00:00:00 2001 From: bh0fer Date: Mon, 2 Jun 2025 21:06:40 +0000 Subject: [PATCH 1/8] add ai model --- package.json | 3 +- .../migration.sql | 41 +++++++ prisma/schema.prisma | 46 ++++++++ prisma/seed-files/users.ts | 2 +- src/controllers/aiRequest.ts | 29 +++++ src/controllers/aiTemplate.ts | 46 ++++++++ src/models/AiRequest.ts | 103 ++++++++++++++++++ src/models/AiTemplate.ts | 79 ++++++++++++++ src/routes/authConfig.ts | 9 ++ src/routes/router.ts | 24 ++++ yarn.lock | 5 + 11 files changed, 385 insertions(+), 2 deletions(-) create mode 100644 prisma/migrations/20250602195519_add_ai_models/migration.sql create mode 100644 src/controllers/aiRequest.ts create mode 100644 src/controllers/aiTemplate.ts create mode 100644 src/models/AiRequest.ts create mode 100644 src/models/AiTemplate.ts 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/20250602195519_add_ai_models/migration.sql b/prisma/migrations/20250602195519_add_ai_models/migration.sql new file mode 100644 index 0000000..36a4be7 --- /dev/null +++ b/prisma/migrations/20250602195519_add_ai_models/migration.sql @@ -0,0 +1,41 @@ +-- CreateTable +CREATE TABLE "ai_templates" ( + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "name" TEXT NOT NULL DEFAULT '', + "rate_limit" INTEGER NOT NULL DEFAULT 0, + "rate_limit_period_ms" BIGINT NOT NULL DEFAULT 3600000, + "is_active" BOOLEAN NOT NULL DEFAULT true, + "model" TEXT NOT NULL, + "api_key" TEXT NOT NULL, + "api_url" TEXT NOT NULL, + "temperature" DOUBLE PRECISION NOT NULL DEFAULT 0.5, + "max_tokens" INTEGER NOT NULL DEFAULT 2048, + "top_p" DOUBLE PRECISION NOT NULL DEFAULT 0.8, + "system_message" TEXT, + "json_schema" JSONB, + "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_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..fb64841 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -28,10 +28,56 @@ model User { view_AllDocumentUserPermissions view_AllDocumentUserPermissions[] cmsSettings CmsSettings? studentGroups UserStudentGroup[] @relation("user_student_groups") + aiRequests AiRequest[] @@map("users") } +model AiTemplate { + id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid + name String @default("") + 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") + + model String @map("model") + apiKey String @map("api_key") + apiUrl String @map("api_url") + + temperature Float @default(0.5) @map("temperature") + maxTokens Int @default(2048) @map("max_tokens") + topP Float @default(0.8) @map("top_p") + systemMessage String? @map("system_message") + jsonSchema Json? @map("json_schema") + + 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..8ec3128 --- /dev/null +++ b/src/controllers/aiRequest.ts @@ -0,0 +1,29 @@ +import { RequestHandler } from 'express'; +import AiRequest from '../models/AiRequest'; + +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 request = await AiRequest.createModel(req.user!, req.params.id, req.body.request); + 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..93db5d7 --- /dev/null +++ b/src/controllers/aiTemplate.ts @@ -0,0 +1,46 @@ +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 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..8d04c5c --- /dev/null +++ b/src/models/AiRequest.ts @@ -0,0 +1,103 @@ +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): 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 requestInput: OpenAI.Responses.ResponseInput = []; + if (template.systemMessage) { + requestInput.push({ + role: 'system', + content: [ + { + type: 'input_text', + text: template.systemMessage + } + ] + }); + } + requestInput.push({ + role: 'user', + content: [ + { + type: 'input_text', + text: input + } + ] + }); + + const response = await client.responses.create({ + model: template.model, + input: requestInput, + text: { + format: template.jsonSchema + ? { + type: 'json_schema', + name: 'tdev_ai_response', + schema: {}, + ...(template.jsonSchema! as unknown as Partial) + } + : ({ type: 'text' } as OpenAI.ResponseFormatText) + }, + reasoning: {}, + tools: [], + temperature: template.temperature, + max_output_tokens: template.maxTokens, + top_p: template.topP, + store: false + }); + + const aiRequest = await db.create({ + data: { + userId: actor.id, + aiTemplateId: aiTemplateId, + request: input, + response: JSON.parse(response.output_text), + status: 'success' + } + }); + 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..3004120 --- /dev/null +++ b/src/models/AiTemplate.ts @@ -0,0 +1,79 @@ +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 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); + /** remove fields not updatable*/ + return db + .update({ + where: { + id: record.id + }, + data: { + ...data, + jsonSchema: + typeof data.jsonSchema === 'object' + ? (data.jsonSchema as Prisma.JsonObject) + : undefined + } + }) + .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 + }) + .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..a7b84f1 100644 --- a/src/routes/router.ts +++ b/src/routes/router.ts @@ -45,6 +45,19 @@ import { logout as githubLogout } from '../controllers/cmsSettings'; +import { + all as allAiTemplates, + create as createAiTemplate, + destroy as destroyAiTemplate, + find as findAiTemplate, + update as updateAiTemplate +} from '../controllers/aiTemplate'; +import { + all as allAiRequests, + create as createAiRequest, + find as findAiRequest +} from '../controllers/aiRequest'; + // initialize router const router = express.Router(); @@ -111,4 +124,15 @@ 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', 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/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" From e6d6a1fc27159f795e222a2a0e075dc705f760bd Mon Sep 17 00:00:00 2001 From: bh0fer Date: Mon, 2 Jun 2025 22:02:12 +0000 Subject: [PATCH 2/8] request ai model async --- src/controllers/aiRequest.ts | 11 ++++- src/models/AiRequest.ts | 78 +++++++++++++++++++++++----------- src/routes/socketEventTypes.ts | 6 ++- 3 files changed, 68 insertions(+), 27 deletions(-) diff --git a/src/controllers/aiRequest.ts b/src/controllers/aiRequest.ts index 8ec3128..16f8a9c 100644 --- a/src/controllers/aiRequest.ts +++ b/src/controllers/aiRequest.ts @@ -1,5 +1,8 @@ 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 { @@ -21,7 +24,13 @@ export const find: RequestHandler<{ id: string; requestId: string }> = async (re export const create: RequestHandler<{ id: string }, any, { request: string }> = async (req, res, next) => { try { - const request = await AiRequest.createModel(req.user!, req.params.id, req.body.request); + 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/models/AiRequest.ts b/src/models/AiRequest.ts index 8d04c5c..c195bf7 100644 --- a/src/models/AiRequest.ts +++ b/src/models/AiRequest.ts @@ -18,7 +18,12 @@ function AiRequest(db: PrismaClient['aiRequest']) { }); return requests; }, - async createModel(actor: DbUser, aiTemplateId: string, input: string): Promise { + async createModel( + actor: DbUser, + aiTemplateId: string, + input: string, + onResponse?: (aiRequest: DbAiRequest) => void + ): Promise { const template = await prisma.aiTemplate.findUniqueOrThrow({ where: { id: aiTemplateId } }); @@ -65,36 +70,61 @@ function AiRequest(db: PrismaClient['aiRequest']) { ] }); - const response = await client.responses.create({ - model: template.model, - input: requestInput, - text: { - format: template.jsonSchema - ? { - type: 'json_schema', - name: 'tdev_ai_response', - schema: {}, - ...(template.jsonSchema! as unknown as Partial) - } - : ({ type: 'text' } as OpenAI.ResponseFormatText) - }, - reasoning: {}, - tools: [], - temperature: template.temperature, - max_output_tokens: template.maxTokens, - top_p: template.topP, - store: false - }); - const aiRequest = await db.create({ data: { userId: actor.id, aiTemplateId: aiTemplateId, request: input, - response: JSON.parse(response.output_text), - status: 'success' + status: 'pending', + response: {} } }); + + client.responses + .create({ + model: template.model, + input: requestInput, + text: { + format: template.jsonSchema + ? { + type: 'json_schema', + name: template.name || 'tdev-ai-response', + schema: {}, + ...(template.jsonSchema as unknown as Partial) + } + : ({ type: 'text' } as OpenAI.ResponseFormatText) + }, + reasoning: {}, + tools: [], + temperature: template.temperature, + max_output_tokens: template.maxTokens, + top_p: template.topP, + store: false + }) + .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; } }); 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 { From f46e8b747c39013d3db44a8d2c227fa9ce32a501 Mon Sep 17 00:00:00 2001 From: bh0fer Date: Wed, 4 Jun 2025 17:45:01 +0000 Subject: [PATCH 3/8] remove redundant name from ai template --- .../migration.sql | 8 ++++++++ prisma/schema.prisma | 1 - 2 files changed, 8 insertions(+), 1 deletion(-) create mode 100644 prisma/migrations/20250604174413_remove_name_from_ai_template/migration.sql diff --git a/prisma/migrations/20250604174413_remove_name_from_ai_template/migration.sql b/prisma/migrations/20250604174413_remove_name_from_ai_template/migration.sql new file mode 100644 index 0000000..01ba648 --- /dev/null +++ b/prisma/migrations/20250604174413_remove_name_from_ai_template/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - You are about to drop the column `name` on the `ai_templates` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "ai_templates" DROP COLUMN "name"; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index fb64841..4de3894 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -35,7 +35,6 @@ model User { model AiTemplate { id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid - name String @default("") 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") From df3f04cd03a454f723b277a920b9b0c82a7f224c Mon Sep 17 00:00:00 2001 From: bh0fer Date: Wed, 4 Jun 2025 17:46:42 +0000 Subject: [PATCH 4/8] fix typo --- src/models/AiRequest.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/models/AiRequest.ts b/src/models/AiRequest.ts index c195bf7..a5a876b 100644 --- a/src/models/AiRequest.ts +++ b/src/models/AiRequest.ts @@ -88,7 +88,7 @@ function AiRequest(db: PrismaClient['aiRequest']) { format: template.jsonSchema ? { type: 'json_schema', - name: template.name || 'tdev-ai-response', + name: 'tdev-ai-response', schema: {}, ...(template.jsonSchema as unknown as Partial) } From f00c9a36124265f5d25d2f8c1948c0747de9ef56 Mon Sep 17 00:00:00 2001 From: bh0fer Date: Wed, 11 Jun 2025 19:23:59 +0000 Subject: [PATCH 5/8] refactor ai templates and requests --- .../migration.sql | 8 ---- .../migration.sql | 12 +++-- prisma/schema.prisma | 13 +++--- src/models/AiRequest.ts | 44 +++++-------------- src/models/AiTemplate.ts | 26 +++++++---- 5 files changed, 40 insertions(+), 63 deletions(-) delete mode 100644 prisma/migrations/20250604174413_remove_name_from_ai_template/migration.sql rename prisma/migrations/{20250602195519_add_ai_models => 20250608170858_add_ai_models}/migration.sql (83%) diff --git a/prisma/migrations/20250604174413_remove_name_from_ai_template/migration.sql b/prisma/migrations/20250604174413_remove_name_from_ai_template/migration.sql deleted file mode 100644 index 01ba648..0000000 --- a/prisma/migrations/20250604174413_remove_name_from_ai_template/migration.sql +++ /dev/null @@ -1,8 +0,0 @@ -/* - Warnings: - - - You are about to drop the column `name` on the `ai_templates` table. All the data in the column will be lost. - -*/ --- AlterTable -ALTER TABLE "ai_templates" DROP COLUMN "name"; diff --git a/prisma/migrations/20250602195519_add_ai_models/migration.sql b/prisma/migrations/20250608170858_add_ai_models/migration.sql similarity index 83% rename from prisma/migrations/20250602195519_add_ai_models/migration.sql rename to prisma/migrations/20250608170858_add_ai_models/migration.sql index 36a4be7..9036e56 100644 --- a/prisma/migrations/20250602195519_add_ai_models/migration.sql +++ b/prisma/migrations/20250608170858_add_ai_models/migration.sql @@ -1,18 +1,13 @@ -- CreateTable CREATE TABLE "ai_templates" ( "id" UUID NOT NULL DEFAULT gen_random_uuid(), - "name" TEXT NOT NULL DEFAULT '', + "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, - "model" TEXT NOT NULL, "api_key" TEXT NOT NULL, "api_url" TEXT NOT NULL, - "temperature" DOUBLE PRECISION NOT NULL DEFAULT 0.5, - "max_tokens" INTEGER NOT NULL DEFAULT 2048, - "top_p" DOUBLE PRECISION NOT NULL DEFAULT 0.8, - "system_message" TEXT, - "json_schema" JSONB, + "config" JSONB NOT NULL, "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, @@ -34,6 +29,9 @@ CREATE TABLE "ai_requests" ( 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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 4de3894..d56a405 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -29,25 +29,24 @@ model User { 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 + 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") - model String @map("model") apiKey String @map("api_key") apiUrl String @map("api_url") - temperature Float @default(0.5) @map("temperature") - maxTokens Int @default(2048) @map("max_tokens") - topP Float @default(0.8) @map("top_p") - systemMessage String? @map("system_message") - jsonSchema Json? @map("json_schema") + config Json @map("config") // Configuration for the AI template, e.g., model, parameters aiRequests AiRequest[] diff --git a/src/models/AiRequest.ts b/src/models/AiRequest.ts index a5a876b..69b33be 100644 --- a/src/models/AiRequest.ts +++ b/src/models/AiRequest.ts @@ -48,19 +48,16 @@ function AiRequest(db: PrismaClient['aiRequest']) { baseURL: template.apiUrl }); - const requestInput: OpenAI.Responses.ResponseInput = []; - if (template.systemMessage) { - requestInput.push({ - role: 'system', - content: [ - { - type: 'input_text', - text: template.systemMessage - } - ] - }); - } - requestInput.push({ + 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: [ { @@ -81,26 +78,7 @@ function AiRequest(db: PrismaClient['aiRequest']) { }); client.responses - .create({ - model: template.model, - input: requestInput, - text: { - format: template.jsonSchema - ? { - type: 'json_schema', - name: 'tdev-ai-response', - schema: {}, - ...(template.jsonSchema as unknown as Partial) - } - : ({ type: 'text' } as OpenAI.ResponseFormatText) - }, - reasoning: {}, - tools: [], - temperature: template.temperature, - max_output_tokens: template.maxTokens, - top_p: template.topP, - store: false - }) + .create(config) .then((response) => { db.update({ where: { id: aiRequest.id }, diff --git a/src/models/AiTemplate.ts b/src/models/AiTemplate.ts index 3004120..b478613 100644 --- a/src/models/AiTemplate.ts +++ b/src/models/AiTemplate.ts @@ -30,19 +30,24 @@ function AiTemplate(db: PrismaClient['aiTemplate']) { 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: { - ...data, - jsonSchema: - typeof data.jsonSchema === 'object' - ? (data.jsonSchema as Prisma.JsonObject) - : undefined - } + data: updateData }) .then((v) => prepareAiTemplate(v)); }, @@ -59,7 +64,12 @@ function AiTemplate(db: PrismaClient['aiTemplate']) { } return db .create({ - data: data + data: { + ...data, + author: { + connect: { id: actor.id } + } + } }) .then((v) => prepareAiTemplate(v)); }, From df02370840a854fd583f4dbd4c3654740a9ba2d7 Mon Sep 17 00:00:00 2001 From: bh0fer Date: Wed, 11 Jun 2025 20:05:21 +0000 Subject: [PATCH 6/8] add option to clone template --- src/controllers/aiTemplate.ts | 8 ++++++++ src/models/AiTemplate.ts | 29 +++++++++++++++++++++++++++++ src/routes/router.ts | 4 +++- 3 files changed, 40 insertions(+), 1 deletion(-) diff --git a/src/controllers/aiTemplate.ts b/src/controllers/aiTemplate.ts index 93db5d7..6a0e81a 100644 --- a/src/controllers/aiTemplate.ts +++ b/src/controllers/aiTemplate.ts @@ -20,6 +20,14 @@ export const find: RequestHandler<{ id: string }> = async (req, res, next) => { 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); diff --git a/src/models/AiTemplate.ts b/src/models/AiTemplate.ts index b478613..bf4da67 100644 --- a/src/models/AiTemplate.ts +++ b/src/models/AiTemplate.ts @@ -25,6 +25,35 @@ function AiTemplate(db: PrismaClient['aiTemplate']) { } 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 this.findModel(actor, id); + if (record.authorId !== actor.id) { + throw new HTTP403Error('Not authorized to clone this AI template'); + } + 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'); diff --git a/src/routes/router.ts b/src/routes/router.ts index a7b84f1..600a7ac 100644 --- a/src/routes/router.ts +++ b/src/routes/router.ts @@ -50,7 +50,8 @@ import { create as createAiTemplate, destroy as destroyAiTemplate, find as findAiTemplate, - update as updateAiTemplate + update as updateAiTemplate, + clone as cloneAiTemplate } from '../controllers/aiTemplate'; import { all as allAiRequests, @@ -127,6 +128,7 @@ 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); From fa50ea5094650821403a66235835d6e4a75b60d7 Mon Sep 17 00:00:00 2001 From: bh0fer Date: Wed, 11 Jun 2025 21:35:01 +0000 Subject: [PATCH 7/8] ensure cloned template has correct api key --- src/models/AiTemplate.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/models/AiTemplate.ts b/src/models/AiTemplate.ts index bf4da67..40de62d 100644 --- a/src/models/AiTemplate.ts +++ b/src/models/AiTemplate.ts @@ -29,10 +29,7 @@ function AiTemplate(db: PrismaClient['aiTemplate']) { if (!hasElevatedAccess(actor.role)) { throw new HTTP403Error('Not authorized to find AI templates'); } - const record = await this.findModel(actor, id); - if (record.authorId !== actor.id) { - throw new HTTP403Error('Not authorized to clone this AI template'); - } + 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)`; From 956bf577054579e59982783bf9abb733a7934eeb Mon Sep 17 00:00:00 2001 From: bh0fer Date: Sun, 31 Aug 2025 10:26:57 +0000 Subject: [PATCH 8/8] add possible grading scheme --- grading-schema.json | 108 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 grading-schema.json 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