@studious-lms/server 1.5.0 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +15 -3
- package/dist/index.js.map +1 -1
- package/dist/lib/config/cors.d.ts.map +1 -1
- package/dist/lib/config/cors.js +3 -2
- package/dist/lib/config/cors.js.map +1 -1
- package/dist/lib/pusher.d.ts +7 -1
- package/dist/lib/pusher.d.ts.map +1 -1
- package/dist/lib/pusher.js +9 -3
- package/dist/lib/pusher.js.map +1 -1
- package/dist/models/attendance.d.ts +16 -7
- package/dist/models/attendance.d.ts.map +1 -1
- package/dist/models/attendance.js +17 -2
- package/dist/models/attendance.js.map +1 -1
- package/dist/models/conversation.d.ts +17 -17
- package/dist/pipelines/aiLabChat.d.ts +20 -9
- package/dist/pipelines/aiLabChat.d.ts.map +1 -1
- package/dist/pipelines/aiLabChat.js +62 -74
- package/dist/pipelines/aiLabChat.js.map +1 -1
- package/dist/pipelines/aiNewtonChat.d.ts +13 -4
- package/dist/pipelines/aiNewtonChat.d.ts.map +1 -1
- package/dist/pipelines/aiNewtonChat.js +39 -27
- package/dist/pipelines/aiNewtonChat.js.map +1 -1
- package/dist/pipelines/pdfToWorksheet.d.ts +31 -2
- package/dist/pipelines/pdfToWorksheet.d.ts.map +1 -1
- package/dist/pipelines/pdfToWorksheet.js +160 -28
- package/dist/pipelines/pdfToWorksheet.js.map +1 -1
- package/dist/routers/_app.d.ts +18 -4
- package/dist/routers/_app.d.ts.map +1 -1
- package/dist/routers/assignment.d.ts.map +1 -1
- package/dist/routers/assignment.js +64 -4
- package/dist/routers/assignment.js.map +1 -1
- package/dist/routers/labChat.d.ts +1 -0
- package/dist/routers/labChat.d.ts.map +1 -1
- package/dist/routers/message.d.ts +3 -0
- package/dist/routers/message.d.ts.map +1 -1
- package/dist/routers/newtonChat.d.ts +1 -0
- package/dist/routers/newtonChat.d.ts.map +1 -1
- package/dist/routers/worksheet.d.ts +4 -2
- package/dist/routers/worksheet.d.ts.map +1 -1
- package/dist/routers/worksheet.js +16 -6
- package/dist/routers/worksheet.js.map +1 -1
- package/dist/services/announcement.js +4 -4
- package/dist/services/announcement.js.map +1 -1
- package/dist/services/assignment.d.ts.map +1 -1
- package/dist/services/assignment.js +16 -6
- package/dist/services/assignment.js.map +1 -1
- package/dist/services/attendance.d.ts.map +1 -1
- package/dist/services/attendance.js +68 -4
- package/dist/services/attendance.js.map +1 -1
- package/dist/services/labChat.d.ts +8 -1
- package/dist/services/labChat.d.ts.map +1 -1
- package/dist/services/labChat.js +101 -42
- package/dist/services/labChat.js.map +1 -1
- package/dist/services/message.d.ts +3 -0
- package/dist/services/message.d.ts.map +1 -1
- package/dist/services/message.js +8 -2
- package/dist/services/message.js.map +1 -1
- package/dist/services/newtonChat.d.ts +1 -0
- package/dist/services/newtonChat.d.ts.map +1 -1
- package/dist/services/newtonChat.js +40 -17
- package/dist/services/newtonChat.js.map +1 -1
- package/dist/services/worksheet.d.ts +15 -6
- package/dist/services/worksheet.d.ts.map +1 -1
- package/dist/services/worksheet.js +58 -12
- package/dist/services/worksheet.js.map +1 -1
- package/dist/utils/inference.d.ts +50 -1
- package/dist/utils/inference.d.ts.map +1 -1
- package/dist/utils/inference.js +167 -2
- package/dist/utils/inference.js.map +1 -1
- package/dist/workers/index.js +13 -3
- package/dist/workers/index.js.map +1 -1
- package/dist/workers/processors/aiLabChat.d.ts +7 -0
- package/dist/workers/processors/aiLabChat.d.ts.map +1 -0
- package/dist/workers/processors/aiLabChat.js +52 -0
- package/dist/workers/processors/aiLabChat.js.map +1 -0
- package/dist/workers/processors/aiNewtonChat.d.ts +7 -0
- package/dist/workers/processors/aiNewtonChat.d.ts.map +1 -0
- package/dist/workers/processors/aiNewtonChat.js +57 -0
- package/dist/workers/processors/aiNewtonChat.js.map +1 -0
- package/dist/workers/processors/createRunJobWorker.d.ts +46 -0
- package/dist/workers/processors/createRunJobWorker.d.ts.map +1 -0
- package/dist/workers/processors/createRunJobWorker.js +118 -0
- package/dist/workers/processors/createRunJobWorker.js.map +1 -0
- package/dist/workers/processors/gradeWorksheet.d.ts +6 -0
- package/dist/workers/processors/gradeWorksheet.d.ts.map +1 -0
- package/dist/workers/processors/gradeWorksheet.js +19 -0
- package/dist/workers/processors/gradeWorksheet.js.map +1 -0
- package/dist/workers/processors/pdfToWorksheet.d.ts +5 -6
- package/dist/workers/processors/pdfToWorksheet.d.ts.map +1 -1
- package/dist/workers/processors/pdfToWorksheet.js +15 -95
- package/dist/workers/processors/pdfToWorksheet.js.map +1 -1
- package/dist/workers/processors/studentProgressChat.d.ts +8 -0
- package/dist/workers/processors/studentProgressChat.d.ts.map +1 -0
- package/dist/workers/processors/studentProgressChat.js +27 -0
- package/dist/workers/processors/studentProgressChat.js.map +1 -0
- package/dist/workers/queues.d.ts +20 -4
- package/dist/workers/queues.d.ts.map +1 -1
- package/dist/workers/queues.js +3 -3
- package/dist/workers/queues.js.map +1 -1
- package/package.json +3 -3
- package/src/index.ts +12 -1
- package/src/lib/config/cors.ts +1 -0
- package/src/lib/pusher.ts +7 -1
- package/src/models/attendance.ts +15 -0
- package/src/pipelines/aiLabChat.ts +70 -78
- package/src/pipelines/aiNewtonChat.ts +47 -31
- package/src/pipelines/pdfToWorksheet.ts +224 -32
- package/src/routers/assignment.ts +74 -3
- package/src/routers/worksheet.ts +25 -4
- package/src/services/announcement.ts +2 -2
- package/src/services/assignment.ts +18 -4
- package/src/services/attendance.ts +87 -2
- package/src/services/labChat.ts +107 -47
- package/src/services/message.ts +6 -0
- package/src/services/newtonChat.ts +46 -25
- package/src/services/worksheet.ts +71 -10
- package/src/utils/inference.ts +209 -1
- package/src/workers/index.ts +11 -1
- package/src/workers/processors/aiLabChat.ts +65 -0
- package/src/workers/processors/aiNewtonChat.ts +75 -0
- package/src/workers/processors/createRunJobWorker.ts +164 -0
- package/src/workers/processors/gradeWorksheet.ts +16 -0
- package/src/workers/processors/pdfToWorksheet.ts +12 -114
- package/src/workers/processors/studentProgressChat.ts +28 -0
- package/src/workers/queues.ts +20 -5
- package/tests/integration/routers/attendance.test.ts +60 -1
package/dist/workers/queues.d.ts
CHANGED
|
@@ -15,21 +15,37 @@ import { type RunJobType } from '@prisma/client';
|
|
|
15
15
|
* The queue name string must match what the corresponding processor passes
|
|
16
16
|
* to `new Worker(<name>, ...)`.
|
|
17
17
|
*/
|
|
18
|
-
type JobInputs = {
|
|
18
|
+
export type JobInputs = {
|
|
19
|
+
/**
|
|
20
|
+
* `worksheetId` is the placeholder Worksheet row created synchronously by
|
|
21
|
+
* the producer (so the client has an id to navigate to). The pipeline fills
|
|
22
|
+
* that row in asynchronously and broadcasts progress on the per-worksheet
|
|
23
|
+
* job channel.
|
|
24
|
+
*/
|
|
19
25
|
PDF_TO_WORKSHEET: {
|
|
20
26
|
gcsUrl: string;
|
|
21
27
|
classId: string;
|
|
28
|
+
worksheetId: string;
|
|
22
29
|
};
|
|
23
30
|
GRADE_WORKSHEET: {
|
|
24
31
|
worksheetResponseId: string;
|
|
25
32
|
};
|
|
33
|
+
/**
|
|
34
|
+
* `aiMessageId` is the placeholder AI Message row created synchronously by
|
|
35
|
+
* the producer before enqueue (so the client renders a PENDING bubble in
|
|
36
|
+
* the timeline). The pipeline fills in the content and flips the row to
|
|
37
|
+
* COMPLETED / FAILED. Used for both intros and replies — single shape.
|
|
38
|
+
*/
|
|
26
39
|
AI_LAB_CHAT: {
|
|
27
40
|
conversationId: string;
|
|
28
|
-
|
|
41
|
+
aiMessageId: string;
|
|
29
42
|
};
|
|
43
|
+
/**
|
|
44
|
+
* `aiMessageId` placeholder pattern, same as AI_LAB_CHAT — see above.
|
|
45
|
+
*/
|
|
30
46
|
AI_NEWTON_CHAT: {
|
|
31
47
|
conversationId: string;
|
|
32
|
-
|
|
48
|
+
aiMessageId: string;
|
|
33
49
|
};
|
|
34
50
|
STUDENT_PROGRESS_CHAT: {
|
|
35
51
|
classId: string;
|
|
@@ -37,6 +53,7 @@ type JobInputs = {
|
|
|
37
53
|
message: string;
|
|
38
54
|
};
|
|
39
55
|
};
|
|
56
|
+
export declare const QUEUE_NAMES: Record<RunJobType, string>;
|
|
40
57
|
export interface EnqueueOptions {
|
|
41
58
|
classId?: string | null;
|
|
42
59
|
userId?: string | null;
|
|
@@ -65,5 +82,4 @@ export declare function enqueueRunJob<T extends RunJobType>(type: T, input: JobI
|
|
|
65
82
|
* the producer side disconnects from Redis cleanly.
|
|
66
83
|
*/
|
|
67
84
|
export declare function closeAllQueues(): Promise<void>;
|
|
68
|
-
export {};
|
|
69
85
|
//# sourceMappingURL=queues.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"queues.d.ts","sourceRoot":"/","sources":["workers/queues.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,OAAO,EAAS,KAAK,WAAW,EAAE,MAAM,QAAQ,CAAC;AACjD,OAAO,EAAU,KAAK,UAAU,EAAE,MAAM,gBAAgB,CAAC;AAYzD;;;;;;GAMG;AACH,
|
|
1
|
+
{"version":3,"file":"queues.d.ts","sourceRoot":"/","sources":["workers/queues.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,OAAO,EAAS,KAAK,WAAW,EAAE,MAAM,QAAQ,CAAC;AACjD,OAAO,EAAU,KAAK,UAAU,EAAE,MAAM,gBAAgB,CAAC;AAYzD;;;;;;GAMG;AACH,MAAM,MAAM,SAAS,GAAG;IACtB;;;;;OAKG;IACH,gBAAgB,EAAE;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAC;QAAC,WAAW,EAAE,MAAM,CAAA;KAAE,CAAC;IAC3E,eAAe,EAAE;QAAE,mBAAmB,EAAE,MAAM,CAAA;KAAE,CAAC;IACjD;;;;;OAKG;IACH,WAAW,EAAE;QAAE,cAAc,EAAE,MAAM,CAAC;QAAC,WAAW,EAAE,MAAM,CAAA;KAAE,CAAC;IAC7D;;OAEG;IACH,cAAc,EAAE;QAAE,cAAc,EAAE,MAAM,CAAC;QAAC,WAAW,EAAE,MAAM,CAAA;KAAE,CAAC;IAChE,qBAAqB,EAAE;QAAE,OAAO,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC;CAChF,CAAC;AAEF,eAAO,MAAM,WAAW,EAAE,MAAM,CAAC,UAAU,EAAE,MAAM,CAMlD,CAAC;AAoBF,MAAM,WAAW,cAAc;IAC7B,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,qEAAqE;IACrE,UAAU,CAAC,EAAE,WAAW,CAAC;CAC1B;AAED,MAAM,WAAW,aAAa;IAC5B,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;CAC1B;AAED;;;;;;;;;;;GAWG;AACH,wBAAsB,aAAa,CAAC,CAAC,SAAS,UAAU,EACtD,IAAI,EAAE,CAAC,EACP,KAAK,EAAE,SAAS,CAAC,CAAC,CAAC,EACnB,OAAO,GAAE,cAAmB,GAC3B,OAAO,CAAC,aAAa,CAAC,CA2DxB;AAED;;;GAGG;AACH,wBAAsB,cAAc,IAAI,OAAO,CAAC,IAAI,CAAC,CAGpD"}
|
package/dist/workers/queues.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* which is the entrypoint of the separate long-running worker process.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="
|
|
10
|
+
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="feefd10a-9381-5c30-a525-0ffe36e2e323")}catch(e){}}();
|
|
11
11
|
import { Queue } from 'bullmq';
|
|
12
12
|
import { bullConnection } from './connection.js';
|
|
13
13
|
import { prisma } from '../lib/prisma.js';
|
|
@@ -18,7 +18,7 @@ const DEFAULT_JOB_OPTIONS = {
|
|
|
18
18
|
removeOnComplete: { age: 60 * 60 }, // keep completed jobs in Redis for 1h, then drop
|
|
19
19
|
removeOnFail: { age: 24 * 60 * 60 }, // keep failed jobs for 24h for inspection
|
|
20
20
|
};
|
|
21
|
-
const QUEUE_NAMES = {
|
|
21
|
+
export const QUEUE_NAMES = {
|
|
22
22
|
PDF_TO_WORKSHEET: 'pdfToWorksheet',
|
|
23
23
|
GRADE_WORKSHEET: 'gradeWorksheet',
|
|
24
24
|
AI_LAB_CHAT: 'aiLabChat',
|
|
@@ -116,4 +116,4 @@ export async function closeAllQueues() {
|
|
|
116
116
|
queueCache.clear();
|
|
117
117
|
}
|
|
118
118
|
//# sourceMappingURL=queues.js.map
|
|
119
|
-
//# debugId=
|
|
119
|
+
//# debugId=feefd10a-9381-5c30-a525-0ffe36e2e323
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"queues.js","sources":["workers/queues.ts"],"sourceRoot":"/","sourcesContent":["/**\n * BullMQ queue producers and the `enqueueRunJob` helper.\n *\n * Producer side only — safe to import from HTTP routes / services running on\n * Vercel. Importing this file does NOT start any workers; workers live in\n * `src/workers/processors/*` and are wired up by `src/workers/index.ts`,\n * which is the entrypoint of the separate long-running worker process.\n */\nimport { Queue, type JobsOptions } from 'bullmq';\nimport { Prisma, type RunJobType } from '@prisma/client';\nimport { bullConnection } from './connection.js';\nimport { prisma } from '../lib/prisma.js';\nimport { logger } from '../utils/logger.js';\n\nconst DEFAULT_JOB_OPTIONS: JobsOptions = {\n attempts: 3,\n backoff: { type: 'exponential', delay: 5_000 },\n removeOnComplete: { age: 60 * 60 }, // keep completed jobs in Redis for 1h, then drop\n removeOnFail: { age: 24 * 60 * 60 }, // keep failed jobs for 24h for inspection\n};\n\n/**\n * Map RunJobType → (queue name, input payload shape).\n *\n * Add a new entry here whenever you introduce a new background job type.\n * The queue name string must match what the corresponding processor passes\n * to `new Worker(<name>, ...)`.\n */\
|
|
1
|
+
{"version":3,"file":"queues.js","sources":["workers/queues.ts"],"sourceRoot":"/","sourcesContent":["/**\n * BullMQ queue producers and the `enqueueRunJob` helper.\n *\n * Producer side only — safe to import from HTTP routes / services running on\n * Vercel. Importing this file does NOT start any workers; workers live in\n * `src/workers/processors/*` and are wired up by `src/workers/index.ts`,\n * which is the entrypoint of the separate long-running worker process.\n */\nimport { Queue, type JobsOptions } from 'bullmq';\nimport { Prisma, type RunJobType } from '@prisma/client';\nimport { bullConnection } from './connection.js';\nimport { prisma } from '../lib/prisma.js';\nimport { logger } from '../utils/logger.js';\n\nconst DEFAULT_JOB_OPTIONS: JobsOptions = {\n attempts: 3,\n backoff: { type: 'exponential', delay: 5_000 },\n removeOnComplete: { age: 60 * 60 }, // keep completed jobs in Redis for 1h, then drop\n removeOnFail: { age: 24 * 60 * 60 }, // keep failed jobs for 24h for inspection\n};\n\n/**\n * Map RunJobType → (queue name, input payload shape).\n *\n * Add a new entry here whenever you introduce a new background job type.\n * The queue name string must match what the corresponding processor passes\n * to `new Worker(<name>, ...)`.\n */\nexport type JobInputs = {\n /**\n * `worksheetId` is the placeholder Worksheet row created synchronously by\n * the producer (so the client has an id to navigate to). The pipeline fills\n * that row in asynchronously and broadcasts progress on the per-worksheet\n * job channel.\n */\n PDF_TO_WORKSHEET: { gcsUrl: string; classId: string; worksheetId: string };\n GRADE_WORKSHEET: { worksheetResponseId: string };\n /**\n * `aiMessageId` is the placeholder AI Message row created synchronously by\n * the producer before enqueue (so the client renders a PENDING bubble in\n * the timeline). The pipeline fills in the content and flips the row to\n * COMPLETED / FAILED. Used for both intros and replies — single shape.\n */\n AI_LAB_CHAT: { conversationId: string; aiMessageId: string };\n /**\n * `aiMessageId` placeholder pattern, same as AI_LAB_CHAT — see above.\n */\n AI_NEWTON_CHAT: { conversationId: string; aiMessageId: string };\n STUDENT_PROGRESS_CHAT: { classId: string; studentId: string; message: string };\n};\n\nexport const QUEUE_NAMES: Record<RunJobType, string> = {\n PDF_TO_WORKSHEET: 'pdfToWorksheet',\n GRADE_WORKSHEET: 'gradeWorksheet',\n AI_LAB_CHAT: 'aiLabChat',\n AI_NEWTON_CHAT: 'aiNewtonChat',\n STUDENT_PROGRESS_CHAT: 'studentProgressChat',\n};\n\n/**\n * One Queue instance per type. Created lazily so that importing this module\n * does not eagerly open queues you may never use in a given runtime.\n */\nconst queueCache = new Map<RunJobType, Queue>();\n\nfunction getQueue(type: RunJobType): Queue {\n let queue = queueCache.get(type);\n if (!queue) {\n queue = new Queue(QUEUE_NAMES[type], {\n connection: bullConnection,\n defaultJobOptions: DEFAULT_JOB_OPTIONS,\n });\n queueCache.set(type, queue);\n }\n return queue;\n}\n\nexport interface EnqueueOptions {\n classId?: string | null;\n userId?: string | null;\n /** Override BullMQ job options (attempts, delay, priority, etc.). */\n jobOptions?: JobsOptions;\n}\n\nexport interface EnqueueResult {\n runJobId: string;\n bullJobId: string | null;\n}\n\n/**\n * Create a `RunJob` audit row, enqueue the BullMQ job, and link the two.\n *\n * Returns immediately — the actual work happens on the worker process. The\n * caller can return `runJobId` to the client and have them poll `getRunJob`\n * (or subscribe over Pusher) to track progress.\n *\n * IMPORTANT: do not call this inside a Prisma `$transaction`. If the outer\n * transaction rolls back after the BullMQ job is added, the worker will pick\n * up an orphaned job pointing at a non-existent RunJob row. Enqueue *after*\n * the transaction commits.\n */\nexport async function enqueueRunJob<T extends RunJobType>(\n type: T,\n input: JobInputs[T],\n options: EnqueueOptions = {},\n): Promise<EnqueueResult> {\n const runJob = await prisma.runJob.create({\n data: {\n type,\n status: 'PENDING',\n classId: options.classId ?? null,\n userId: options.userId ?? null,\n input: input as unknown as Prisma.InputJsonValue,\n },\n });\n\n try {\n const queue = getQueue(type);\n const job = await queue.add(\n QUEUE_NAMES[type],\n { runJobId: runJob.id, ...input },\n options.jobOptions,\n );\n\n try {\n await prisma.runJob.update({\n where: { id: runJob.id },\n data: { bullJobId: job.id ?? null },\n });\n } catch (linkError) {\n logger.error('RunJob enqueued but bullJobId linkage failed', {\n runJobId: runJob.id,\n bullJobId: job.id ?? null,\n type,\n error: linkError instanceof Error ? linkError.message : String(linkError),\n });\n }\n\n return { runJobId: runJob.id, bullJobId: job.id ?? null };\n } catch (error) {\n logger.error('Failed to enqueue RunJob; marking FAILED', {\n runJobId: runJob.id,\n type,\n error: error instanceof Error ? error.message : String(error),\n });\n\n try {\n await prisma.runJob.update({\n where: { id: runJob.id },\n data: {\n status: 'FAILED',\n error: error instanceof Error ? error.message : String(error),\n finishedAt: new Date(),\n },\n });\n } catch (updateError) {\n logger.error('Failed to mark RunJob as FAILED after enqueue error', {\n runJobId: runJob.id,\n error: updateError instanceof Error ? updateError.message : String(updateError),\n });\n }\n\n throw error;\n }\n}\n\n/**\n * Close every cached queue. Call from the HTTP server's shutdown handler so\n * the producer side disconnects from Redis cleanly.\n */\nexport async function closeAllQueues(): Promise<void> {\n await Promise.all(Array.from(queueCache.values()).map((q) => q.close()));\n queueCache.clear();\n}\n"],"names":[],"mappings":"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","debug_id":"feefd10a-9381-5c30-a525-0ffe36e2e323"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@studious-lms/server",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.0",
|
|
4
4
|
"description": "Backend server for Studious application",
|
|
5
5
|
"main": "dist/exportType.js",
|
|
6
6
|
"types": "dist/exportType.d.ts",
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"sentry:sourcemaps": "sentry-cli sourcemaps inject ./dist && sentry-cli sourcemaps upload --org \"${SENTRY_ORG:-studious-lms}\" --project \"${SENTRY_PROJECT:-server}\" ./dist"
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
|
-
"@google-cloud/storage": "^
|
|
32
|
+
"@google-cloud/storage": "^5.18.3",
|
|
33
33
|
"@pdf-lib/fontkit": "^1.1.1",
|
|
34
34
|
"@prisma/client": "^6.7.0",
|
|
35
35
|
"@sentry/cli": "^2.58.2",
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
"express-rate-limit": "^8.2.1",
|
|
47
47
|
"helmet": "^8.1.0",
|
|
48
48
|
"ioredis": "^5.10.0",
|
|
49
|
-
"nodemailer": "^
|
|
49
|
+
"nodemailer": "^8.0.7",
|
|
50
50
|
"openai": "^5.23.0",
|
|
51
51
|
"pdf-lib": "^1.17.1",
|
|
52
52
|
"prisma": "^6.7.0",
|
package/src/index.ts
CHANGED
|
@@ -187,7 +187,18 @@ app.post('/api/pusher/auth', async (req, res) => {
|
|
|
187
187
|
}
|
|
188
188
|
}
|
|
189
189
|
|
|
190
|
-
if (channel_name.startsWith('private-worksheet-')) {
|
|
190
|
+
if (channel_name.startsWith('private-worksheet-job-')) {
|
|
191
|
+
const worksheetId = channel_name.replace('private-worksheet-job-', '');
|
|
192
|
+
const worksheet = await prisma.worksheet.findFirst({
|
|
193
|
+
where: {
|
|
194
|
+
id: worksheetId,
|
|
195
|
+
class: { teachers: { some: { id: user.id } } },
|
|
196
|
+
},
|
|
197
|
+
});
|
|
198
|
+
if (!worksheet) {
|
|
199
|
+
return res.status(403).json({ error: 'No access to this worksheet job' });
|
|
200
|
+
}
|
|
201
|
+
} else if (channel_name.startsWith('private-worksheet-')) {
|
|
191
202
|
const worksheetId = channel_name.replace('private-worksheet-', '');
|
|
192
203
|
const worksheet = await prisma.studentWorksheetResponse.findFirst({
|
|
193
204
|
where: { id: worksheetId, OR: [
|
package/src/lib/config/cors.ts
CHANGED
package/src/lib/pusher.ts
CHANGED
|
@@ -12,5 +12,11 @@ const pusher = new Pusher({
|
|
|
12
12
|
const chatChannel = (conversationId: string) => `private-conversation-${conversationId}`;
|
|
13
13
|
const worksheetChannel = (worksheetResponseId: string) => `private-worksheet-${worksheetResponseId}`;
|
|
14
14
|
const teacherChannel = (classId: string) => `private-teacher-${classId}`;
|
|
15
|
+
/**
|
|
16
|
+
* Per-worksheet job channel used for asynchronous worksheet creation
|
|
17
|
+
* (e.g. PDF → worksheet conversion). Distinct prefix from the student
|
|
18
|
+
* worksheet-response channel so both can co-exist without auth collisions.
|
|
19
|
+
*/
|
|
20
|
+
const worksheetJobChannel = (worksheetId: string) => `private-worksheet-job-${worksheetId}`;
|
|
15
21
|
|
|
16
|
-
export { pusher, chatChannel, worksheetChannel, teacherChannel };
|
|
22
|
+
export { pusher, chatChannel, worksheetChannel, teacherChannel, worksheetJobChannel };
|
package/src/models/attendance.ts
CHANGED
|
@@ -113,6 +113,11 @@ export function findClassWithTeacher(classId: string, teacherId: string) {
|
|
|
113
113
|
id: classId,
|
|
114
114
|
...classTeacherOrSchoolAdminWhere(teacherId),
|
|
115
115
|
},
|
|
116
|
+
select: {
|
|
117
|
+
id: true,
|
|
118
|
+
name: true,
|
|
119
|
+
students: { select: { id: true } },
|
|
120
|
+
},
|
|
116
121
|
});
|
|
117
122
|
}
|
|
118
123
|
|
|
@@ -137,6 +142,16 @@ export function findAttendanceByClassAndEvent(classId: string, eventId: string |
|
|
|
137
142
|
classId,
|
|
138
143
|
eventId,
|
|
139
144
|
},
|
|
145
|
+
include: {
|
|
146
|
+
event: {
|
|
147
|
+
select: {
|
|
148
|
+
id: true,
|
|
149
|
+
name: true,
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
late: { select: { id: true } },
|
|
153
|
+
absent: { select: { id: true } },
|
|
154
|
+
},
|
|
140
155
|
});
|
|
141
156
|
}
|
|
142
157
|
|
|
@@ -4,10 +4,13 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { isAIUser } from "../utils/aiUser.js";
|
|
6
6
|
import { prisma } from "../lib/prisma.js";
|
|
7
|
-
import { JobStatus } from "@prisma/client";
|
|
8
7
|
import { pusher, teacherChannel } from "../lib/pusher.js";
|
|
9
8
|
import type { Assignment, Class, File, Section, User } from "@prisma/client";
|
|
10
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
inference,
|
|
11
|
+
completePendingAIMessage,
|
|
12
|
+
failPendingAIMessage,
|
|
13
|
+
} from "../utils/inference.js";
|
|
11
14
|
import { logger } from "../utils/logger.js";
|
|
12
15
|
import { createPdf } from "../lib/jsonConversion.js";
|
|
13
16
|
import { v4 } from "uuid";
|
|
@@ -328,52 +331,53 @@ export const getBaseSystemPrompt = (
|
|
|
328
331
|
|
|
329
332
|
|
|
330
333
|
/**
|
|
331
|
-
*
|
|
332
|
-
*
|
|
334
|
+
* Fill the AI introduction placeholder for a brand-new lab chat.
|
|
335
|
+
*
|
|
336
|
+
* The caller (service layer) must have already created a PENDING AI Message
|
|
337
|
+
* row — `aiMessageId` is that placeholder. This function flips it to
|
|
338
|
+
* COMPLETED with the intro content, or to FAILED on a hard error.
|
|
333
339
|
*/
|
|
334
340
|
export const generateAndSendLabIntroduction = async (
|
|
335
341
|
labChatId: string,
|
|
336
342
|
conversationId: string,
|
|
337
343
|
_contextString: string,
|
|
338
|
-
|
|
344
|
+
_subject: string,
|
|
345
|
+
aiMessageId: string,
|
|
339
346
|
): Promise<void> => {
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
"Hello teaching team! I'm your AI assistant for course material development. I'll help you create educational content - when you have a clear request, I'll produce it directly. I only ask questions when I need to clarify the topic or scope. What would you like to work on?";
|
|
347
|
+
const introMessage =
|
|
348
|
+
"Hello teaching team! I'm your AI assistant for course material development. I'll help you create educational content - when you have a clear request, I'll produce it directly. I only ask questions when I need to clarify the topic or scope. What would you like to work on?";
|
|
343
349
|
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
logger.info('AI Introduction sent', { labChatId, conversationId });
|
|
347
|
-
|
|
350
|
+
try {
|
|
351
|
+
await completePendingAIMessage(aiMessageId, introMessage);
|
|
352
|
+
logger.info('AI Introduction sent', { labChatId, conversationId, aiMessageId });
|
|
348
353
|
} catch (error) {
|
|
349
|
-
logger.error('Failed to
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
await sendAIMessage(fallbackIntro, conversationId, {
|
|
356
|
-
subject,
|
|
357
|
-
});
|
|
358
|
-
|
|
359
|
-
logger.info('Fallback AI introduction sent', { labChatId });
|
|
360
|
-
|
|
361
|
-
} catch (fallbackError) {
|
|
362
|
-
logger.error('Failed to send fallback AI introduction:', { error: fallbackError, labChatId });
|
|
363
|
-
}
|
|
354
|
+
logger.error('Failed to complete AI introduction placeholder:', {
|
|
355
|
+
error,
|
|
356
|
+
labChatId,
|
|
357
|
+
aiMessageId,
|
|
358
|
+
});
|
|
359
|
+
await failPendingAIMessage(aiMessageId, { reason: 'lab_intro_complete_failed' });
|
|
364
360
|
}
|
|
365
361
|
}
|
|
366
362
|
|
|
367
363
|
/**
|
|
368
|
-
*
|
|
369
|
-
*
|
|
370
|
-
*
|
|
371
|
-
*
|
|
364
|
+
* Fill the AI response placeholder for a teacher reply in a lab chat.
|
|
365
|
+
*
|
|
366
|
+
* `aiMessageId` is the PENDING AI Message row created by the service
|
|
367
|
+
* layer; the pipeline flips it to COMPLETED with content / attachments /
|
|
368
|
+
* meta, or FAILED on error. Teacher-channel `lab-response-{pending,
|
|
369
|
+
* completed,failed}` events fire alongside the chat-channel status flips
|
|
370
|
+
* (which `completePendingAIMessage` / `failPendingAIMessage` already
|
|
371
|
+
* broadcast) so the lab list "AI busy" indicator stays accurate.
|
|
372
|
+
*
|
|
373
|
+
* `_teacherMessage` is retained for caller compatibility — the actual
|
|
374
|
+
* conversation history is reloaded from the DB anchored on the AI
|
|
375
|
+
* placeholder.
|
|
372
376
|
*/
|
|
373
377
|
export const generateAndSendLabResponse = async (
|
|
374
378
|
labChatId: string,
|
|
375
379
|
_teacherMessage: string,
|
|
376
|
-
emitOptions
|
|
380
|
+
emitOptions: { classId: string; aiMessageId: string }
|
|
377
381
|
): Promise<void> => {
|
|
378
382
|
try {
|
|
379
383
|
// Get lab context from database
|
|
@@ -395,9 +399,15 @@ export const generateAndSendLabIntroduction = async (
|
|
|
395
399
|
|
|
396
400
|
const conversationId = fullLabChat.conversationId;
|
|
397
401
|
|
|
398
|
-
|
|
402
|
+
// Anchor history on the AI placeholder — the slice will include the
|
|
403
|
+
// placeholder itself (empty content) at the top; drop it so the model
|
|
404
|
+
// does not see an empty assistant turn at the very end.
|
|
405
|
+
const anchoredMessages = await loadRecentLabChatMessages(
|
|
399
406
|
conversationId,
|
|
400
|
-
emitOptions
|
|
407
|
+
emitOptions.aiMessageId,
|
|
408
|
+
);
|
|
409
|
+
const recentMessages = anchoredMessages.filter(
|
|
410
|
+
(msg) => msg.id !== emitOptions.aiMessageId,
|
|
401
411
|
);
|
|
402
412
|
|
|
403
413
|
const classData = await prisma.class.findUnique({
|
|
@@ -486,7 +496,7 @@ export const generateAndSendLabIntroduction = async (
|
|
|
486
496
|
labChatId,
|
|
487
497
|
conversationId,
|
|
488
498
|
classId: fullLabChat.classId,
|
|
489
|
-
|
|
499
|
+
aiMessageId: emitOptions.aiMessageId,
|
|
490
500
|
},
|
|
491
501
|
}),
|
|
492
502
|
LAB_CHAT_RESPONSE_TIMEOUT_MS,
|
|
@@ -567,8 +577,7 @@ export const generateAndSendLabIntroduction = async (
|
|
|
567
577
|
}
|
|
568
578
|
}
|
|
569
579
|
|
|
570
|
-
|
|
571
|
-
await sendAIMessage(jsonData.text, conversationId, {
|
|
580
|
+
await completePendingAIMessage(emitOptions.aiMessageId, jsonData.text, {
|
|
572
581
|
attachments: {
|
|
573
582
|
connect: attachmentIds.map(id => ({ id })),
|
|
574
583
|
},
|
|
@@ -586,32 +595,24 @@ export const generateAndSendLabIntroduction = async (
|
|
|
586
595
|
id: v4(),
|
|
587
596
|
})) || null,
|
|
588
597
|
},
|
|
589
|
-
subject: fullLabChat.class?.subject || 'Lab',
|
|
590
598
|
});
|
|
591
599
|
} catch (parseError) {
|
|
592
600
|
logger.error('Failed to parse AI response or generate PDF:', { error: parseError, labChatId });
|
|
593
|
-
// Fallback:
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
});
|
|
601
|
+
// Fallback: complete the placeholder with the raw text so the
|
|
602
|
+
// teacher still sees something useful instead of FAILED.
|
|
603
|
+
await completePendingAIMessage(emitOptions.aiMessageId, response.text);
|
|
597
604
|
}
|
|
598
605
|
|
|
599
|
-
|
|
600
|
-
await
|
|
601
|
-
|
|
602
|
-
|
|
606
|
+
try {
|
|
607
|
+
await pusher.trigger(teacherChannel(emitOptions.classId), "lab-response-completed", {
|
|
608
|
+
labChatId,
|
|
609
|
+
messageId: emitOptions.aiMessageId,
|
|
603
610
|
});
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
labChatId,
|
|
607
|
-
messageId: emitOptions.messageId,
|
|
608
|
-
});
|
|
609
|
-
} catch (broadcastError) {
|
|
610
|
-
logger.error("Failed to broadcast lab response completed:", { error: broadcastError });
|
|
611
|
-
}
|
|
611
|
+
} catch (broadcastError) {
|
|
612
|
+
logger.error("Failed to broadcast lab response completed:", { error: broadcastError });
|
|
612
613
|
}
|
|
613
614
|
|
|
614
|
-
logger.info('AI response sent', { labChatId, conversationId });
|
|
615
|
+
logger.info('AI response sent', { labChatId, conversationId, aiMessageId: emitOptions.aiMessageId });
|
|
615
616
|
|
|
616
617
|
} catch (error) {
|
|
617
618
|
console.error('Full error object:', error);
|
|
@@ -621,31 +622,22 @@ export const generateAndSendLabIntroduction = async (
|
|
|
621
622
|
stack: error.stack,
|
|
622
623
|
name: error.name
|
|
623
624
|
} : error,
|
|
624
|
-
labChatId
|
|
625
|
+
labChatId,
|
|
626
|
+
aiMessageId: emitOptions.aiMessageId,
|
|
625
627
|
});
|
|
626
628
|
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
}
|
|
640
|
-
try {
|
|
641
|
-
await pusher.trigger(teacherChannel(emitOptions.classId), "lab-response-failed", {
|
|
642
|
-
labChatId,
|
|
643
|
-
messageId: emitOptions.messageId,
|
|
644
|
-
error: "AI response generation failed",
|
|
645
|
-
});
|
|
646
|
-
} catch (broadcastError) {
|
|
647
|
-
logger.error("Failed to broadcast lab response failed:", { error: broadcastError });
|
|
648
|
-
}
|
|
629
|
+
await failPendingAIMessage(emitOptions.aiMessageId, {
|
|
630
|
+
reason: 'lab_response_generation_failed',
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
try {
|
|
634
|
+
await pusher.trigger(teacherChannel(emitOptions.classId), "lab-response-failed", {
|
|
635
|
+
labChatId,
|
|
636
|
+
messageId: emitOptions.aiMessageId,
|
|
637
|
+
error: "AI response generation failed",
|
|
638
|
+
});
|
|
639
|
+
} catch (broadcastError) {
|
|
640
|
+
logger.error("Failed to broadcast lab response failed:", { error: broadcastError });
|
|
649
641
|
}
|
|
650
642
|
|
|
651
643
|
throw error; // Re-throw to see the full error in the calling function
|
|
@@ -3,11 +3,13 @@
|
|
|
3
3
|
* Uses assignment context and AI policy levels for tutoring.
|
|
4
4
|
*/
|
|
5
5
|
import { prisma } from "../lib/prisma.js";
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
inference,
|
|
8
|
+
completePendingAIMessage,
|
|
9
|
+
failPendingAIMessage,
|
|
10
|
+
} from "../utils/inference.js";
|
|
7
11
|
import { logger } from "../utils/logger.js";
|
|
8
|
-
import { sendAIMessage } from "../utils/inference.js";
|
|
9
12
|
import { isAIUser } from "../utils/aiUser.js";
|
|
10
|
-
import { Assignment } from "@prisma/client";
|
|
11
13
|
|
|
12
14
|
|
|
13
15
|
// AI Policy Levels Configuration
|
|
@@ -79,12 +81,18 @@ export interface AIPolicyLevel {
|
|
|
79
81
|
];
|
|
80
82
|
|
|
81
83
|
/**
|
|
82
|
-
*
|
|
84
|
+
* Fill the Newton tutor introduction placeholder.
|
|
85
|
+
*
|
|
86
|
+
* `aiMessageId` is the PENDING AI Message row created by the service layer
|
|
87
|
+
* (see `getTutorConversation`). On success we flip it to COMPLETED with the
|
|
88
|
+
* generated intro; on failure we fall back to a hardcoded greeting before
|
|
89
|
+
* marking the placeholder FAILED only as a last resort.
|
|
83
90
|
*/
|
|
84
91
|
export const generateAndSendNewtonIntroduction = async (
|
|
85
92
|
newtonChatId: string,
|
|
86
93
|
conversationId: string,
|
|
87
|
-
submissionId: string
|
|
94
|
+
submissionId: string,
|
|
95
|
+
aiMessageId: string,
|
|
88
96
|
): Promise<void> => {
|
|
89
97
|
try {
|
|
90
98
|
// Get submission details for context
|
|
@@ -144,6 +152,7 @@ export const generateAndSendNewtonIntroduction = async (
|
|
|
144
152
|
newtonChatId,
|
|
145
153
|
conversationId,
|
|
146
154
|
submissionId: submission.id,
|
|
155
|
+
aiMessageId,
|
|
147
156
|
},
|
|
148
157
|
model: 'command-a-03-2025',
|
|
149
158
|
maxTokens: 300,
|
|
@@ -154,28 +163,22 @@ export const generateAndSendNewtonIntroduction = async (
|
|
|
154
163
|
throw new Error('No response generated from inference API');
|
|
155
164
|
}
|
|
156
165
|
|
|
157
|
-
|
|
158
|
-
await sendAIMessage(response, conversationId, {
|
|
159
|
-
subject: submission.assignment.class.subject || 'Assignment',
|
|
160
|
-
});
|
|
166
|
+
await completePendingAIMessage(aiMessageId, response);
|
|
161
167
|
|
|
162
|
-
logger.info('AI Introduction sent', { newtonChatId, conversationId });
|
|
168
|
+
logger.info('AI Introduction sent', { newtonChatId, conversationId, aiMessageId });
|
|
163
169
|
|
|
164
170
|
} catch (error) {
|
|
165
|
-
logger.error('Failed to generate AI introduction:', { error, newtonChatId });
|
|
171
|
+
logger.error('Failed to generate AI introduction:', { error, newtonChatId, aiMessageId });
|
|
166
172
|
|
|
167
|
-
//
|
|
173
|
+
// Fallback: still complete the placeholder so the student isn't stuck
|
|
174
|
+
// staring at an empty bubble.
|
|
168
175
|
try {
|
|
169
176
|
const fallbackIntro = `Hello! I'm Newton, your AI tutor. I'm here to help you with your assignment. I can answer questions, explain concepts, and guide you through your work. What would you like help with today?`;
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
subject: 'Assignment',
|
|
173
|
-
});
|
|
174
|
-
|
|
175
|
-
logger.info('Fallback AI introduction sent', { newtonChatId });
|
|
176
|
-
|
|
177
|
+
await completePendingAIMessage(aiMessageId, fallbackIntro);
|
|
178
|
+
logger.info('Fallback AI introduction sent', { newtonChatId, aiMessageId });
|
|
177
179
|
} catch (fallbackError) {
|
|
178
|
-
logger.error('Failed to send fallback AI introduction:', { error: fallbackError, newtonChatId });
|
|
180
|
+
logger.error('Failed to send fallback AI introduction:', { error: fallbackError, newtonChatId, aiMessageId });
|
|
181
|
+
await failPendingAIMessage(aiMessageId, { reason: 'newton_intro_fallback_failed' });
|
|
179
182
|
}
|
|
180
183
|
}
|
|
181
184
|
}
|
|
@@ -209,7 +212,11 @@ export const generateAndSendNewtonIntroduction = async (
|
|
|
209
212
|
};
|
|
210
213
|
|
|
211
214
|
/**
|
|
212
|
-
*
|
|
215
|
+
* Fill the Newton AI response placeholder for a student turn.
|
|
216
|
+
*
|
|
217
|
+
* `aiMessageId` is the PENDING AI Message row the service created post-tx;
|
|
218
|
+
* the pipeline flips it to COMPLETED with the generated content (the
|
|
219
|
+
* helper broadcasts `message-updated` on the chat channel) or to FAILED.
|
|
213
220
|
*/
|
|
214
221
|
export const generateAndSendNewtonResponse = async (
|
|
215
222
|
newtonChatId: string,
|
|
@@ -225,13 +232,17 @@ export const generateAndSendNewtonIntroduction = async (
|
|
|
225
232
|
subject: string | null;
|
|
226
233
|
};
|
|
227
234
|
};
|
|
228
|
-
}
|
|
235
|
+
},
|
|
236
|
+
aiMessageId: string,
|
|
229
237
|
): Promise<void> => {
|
|
230
238
|
try {
|
|
231
|
-
// Get recent conversation history
|
|
239
|
+
// Get recent conversation history (excluding the empty AI placeholder
|
|
240
|
+
// we just created — otherwise the model sees a blank assistant turn at
|
|
241
|
+
// the very end of the transcript).
|
|
232
242
|
const recentMessages = await prisma.message.findMany({
|
|
233
243
|
where: {
|
|
234
244
|
conversationId,
|
|
245
|
+
id: { not: aiMessageId },
|
|
235
246
|
},
|
|
236
247
|
include: {
|
|
237
248
|
sender: {
|
|
@@ -329,6 +340,7 @@ export const generateAndSendNewtonIntroduction = async (
|
|
|
329
340
|
newtonChatId,
|
|
330
341
|
conversationId,
|
|
331
342
|
submissionId: submission.id,
|
|
343
|
+
aiMessageId,
|
|
332
344
|
},
|
|
333
345
|
});
|
|
334
346
|
|
|
@@ -336,13 +348,10 @@ export const generateAndSendNewtonIntroduction = async (
|
|
|
336
348
|
throw new Error('No response generated from inference API');
|
|
337
349
|
}
|
|
338
350
|
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
logger.info('AI response sent', { newtonChatId, conversationId });
|
|
345
|
-
|
|
351
|
+
await completePendingAIMessage(aiMessageId, response);
|
|
352
|
+
|
|
353
|
+
logger.info('AI response sent', { newtonChatId, conversationId, aiMessageId });
|
|
354
|
+
|
|
346
355
|
} catch (error) {
|
|
347
356
|
logger.error('Failed to generate AI response:', {
|
|
348
357
|
error: error instanceof Error ? {
|
|
@@ -350,8 +359,15 @@ export const generateAndSendNewtonIntroduction = async (
|
|
|
350
359
|
stack: error.stack,
|
|
351
360
|
name: error.name
|
|
352
361
|
} : error,
|
|
353
|
-
newtonChatId
|
|
362
|
+
newtonChatId,
|
|
363
|
+
aiMessageId,
|
|
354
364
|
});
|
|
365
|
+
|
|
366
|
+
await failPendingAIMessage(aiMessageId, {
|
|
367
|
+
reason: 'newton_response_generation_failed',
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
throw error;
|
|
355
371
|
}
|
|
356
372
|
}
|
|
357
373
|
|