@studious-lms/server 1.4.0 → 1.4.1

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.
Files changed (45) hide show
  1. package/dist/pipelines/aiLabChat.d.ts +9 -5
  2. package/dist/pipelines/aiLabChat.d.ts.map +1 -1
  3. package/dist/pipelines/aiLabChat.js +37 -144
  4. package/dist/pipelines/aiLabChat.js.map +1 -1
  5. package/dist/pipelines/aiLabChatContract.d.ts +413 -0
  6. package/dist/pipelines/aiLabChatContract.d.ts.map +1 -0
  7. package/dist/pipelines/aiLabChatContract.js +74 -0
  8. package/dist/pipelines/aiLabChatContract.js.map +1 -0
  9. package/dist/pipelines/labChatPrompt.d.ts +2 -0
  10. package/dist/pipelines/labChatPrompt.d.ts.map +1 -0
  11. package/dist/pipelines/labChatPrompt.js +72 -0
  12. package/dist/pipelines/labChatPrompt.js.map +1 -0
  13. package/dist/routers/_app.d.ts +146 -0
  14. package/dist/routers/_app.d.ts.map +1 -1
  15. package/dist/routers/_app.js +4 -2
  16. package/dist/routers/_app.js.map +1 -1
  17. package/dist/routers/studentProgress.d.ts +75 -0
  18. package/dist/routers/studentProgress.d.ts.map +1 -0
  19. package/dist/routers/studentProgress.js +33 -0
  20. package/dist/routers/studentProgress.js.map +1 -0
  21. package/dist/services/labChat.d.ts.map +1 -1
  22. package/dist/services/labChat.js +31 -15
  23. package/dist/services/labChat.js.map +1 -1
  24. package/dist/services/message.d.ts.map +1 -1
  25. package/dist/services/message.js +90 -48
  26. package/dist/services/message.js.map +1 -1
  27. package/dist/services/studentProgress.d.ts +45 -0
  28. package/dist/services/studentProgress.d.ts.map +1 -0
  29. package/dist/services/studentProgress.js +291 -0
  30. package/dist/services/studentProgress.js.map +1 -0
  31. package/package.json +2 -2
  32. package/sentry.properties +3 -0
  33. package/src/pipelines/aiLabChat.ts +37 -148
  34. package/src/pipelines/aiLabChatContract.ts +75 -0
  35. package/src/pipelines/labChatPrompt.ts +68 -0
  36. package/src/routers/_app.ts +4 -2
  37. package/src/routers/studentProgress.ts +47 -0
  38. package/src/services/labChat.ts +31 -22
  39. package/src/services/message.ts +97 -48
  40. package/src/services/studentProgress.ts +390 -0
  41. package/tests/lib/aiLabChatContract.test.ts +32 -0
  42. package/tests/pipelines/aiLabChat.test.ts +95 -0
  43. package/tests/routers/studentProgress.test.ts +283 -0
  44. package/tests/utils/aiLabChatPrompt.test.ts +18 -0
  45. package/vitest.unit.config.ts +7 -1
@@ -0,0 +1 @@
1
+ {"version":3,"file":"studentProgress.js","sources":["services/studentProgress.ts"],"sourceRoot":"/","sourcesContent":["/**\n * Student progress service - assignment recommendations and progress chat.\n */\nimport { TRPCError } from \"@trpc/server\";\nimport { prisma } from \"../lib/prisma.js\";\nimport { inference } from \"../utils/inference.js\";\nimport { logger } from \"../utils/logger.js\";\nimport { isTeacherInClass } from \"../models/class.js\";\nimport type { AssignmentType } from \"@prisma/client\";\n\ntype ProgressChatMessage = {\n role: \"user\" | \"assistant\";\n content: string;\n};\n\ntype GradeSubmission = {\n id: string;\n gradeReceived: number | null;\n submitted: boolean | null;\n returned: boolean | null;\n submittedAt: Date | null;\n assignment: {\n id: string;\n title: string;\n dueDate: Date;\n maxGrade: number | null;\n weight: number;\n type: AssignmentType;\n section: { id: string; name: string } | null;\n };\n};\n\nfunction calculatePercentage(submission: GradeSubmission) {\n if (submission.gradeReceived == null || !submission.assignment.maxGrade)\n return null;\n return (\n Math.round(\n (submission.gradeReceived / submission.assignment.maxGrade) * 1000,\n ) / 10\n );\n}\n\nfunction calculateTrend(submissions: GradeSubmission[]) {\n const graded = submissions\n .filter(\n (submission) =>\n submission.gradeReceived != null && submission.assignment.maxGrade,\n )\n .sort((a, b) => {\n const aTime = a.submittedAt?.getTime() ?? a.assignment.dueDate.getTime();\n const bTime = b.submittedAt?.getTime() ?? b.assignment.dueDate.getTime();\n return aTime - bTime;\n })\n .map((submission) => calculatePercentage(submission))\n .filter((value): value is number => value != null);\n\n if (graded.length < 2) return 0;\n const midpoint = Math.floor(graded.length / 2);\n const first = graded.slice(0, midpoint);\n const second = graded.slice(midpoint);\n const average = (values: number[]) =>\n values.reduce((sum, value) => sum + value, 0) / values.length;\n return Math.round((average(second) - average(first)) * 10) / 10;\n}\n\nfunction getOverallGrade(submissions: GradeSubmission[]) {\n let totalWeighted = 0;\n let totalWeight = 0;\n\n for (const submission of submissions) {\n if (\n submission.gradeReceived != null &&\n submission.assignment.maxGrade &&\n submission.assignment.weight\n ) {\n totalWeighted +=\n (submission.gradeReceived / submission.assignment.maxGrade) *\n submission.assignment.weight;\n totalWeight += submission.assignment.weight;\n }\n }\n\n return totalWeight > 0\n ? Math.round((totalWeighted / totalWeight) * 1000) / 10\n : null;\n}\n\nasync function loadStudentProgressContext(\n viewerId: string,\n classId: string,\n studentId: string,\n) {\n const isTeacher = await isTeacherInClass(classId, viewerId);\n if (viewerId !== studentId && !isTeacher) {\n throw new TRPCError({\n code: \"UNAUTHORIZED\",\n message: \"You can only view your own progress\",\n });\n }\n\n const [classData, student, submissions] = await Promise.all([\n prisma.class.findUnique({\n where: { id: classId },\n select: { id: true, name: true, subject: true },\n }),\n prisma.user.findFirst({\n where: {\n id: studentId,\n studentIn: { some: { id: classId } },\n },\n select: {\n id: true,\n username: true,\n profile: { select: { displayName: true } },\n },\n }),\n prisma.submission.findMany({\n where: {\n studentId,\n assignment: { classId, graded: true },\n },\n include: {\n assignment: {\n select: {\n id: true,\n title: true,\n dueDate: true,\n maxGrade: true,\n weight: true,\n type: true,\n section: { select: { id: true, name: true } },\n },\n },\n },\n orderBy: { assignment: { dueDate: \"asc\" } },\n }),\n ]);\n\n if (!classData) {\n throw new TRPCError({ code: \"NOT_FOUND\", message: \"Class not found\" });\n }\n\n if (!student) {\n throw new TRPCError({\n code: \"NOT_FOUND\",\n message: \"Student not found in this class\",\n });\n }\n\n return { classData, student, submissions };\n}\n\nexport async function getStudentProgressRecommendations(\n viewerId: string,\n classId: string,\n studentId: string,\n) {\n const { student, submissions } = await loadStudentProgressContext(\n viewerId,\n classId,\n studentId,\n );\n\n const now = new Date();\n const recommendationCandidates = submissions\n .map((submission) => {\n const percentage = calculatePercentage(submission);\n const isMissing =\n !submission.submitted &&\n submission.assignment.dueDate.getTime() < now.getTime();\n const isLowScore = percentage != null && percentage < 70;\n const isUnreturned =\n Boolean(submission.submitted) &&\n submission.gradeReceived == null &&\n !submission.returned;\n const isUpcoming =\n !Boolean(submission.submitted) &&\n !submission.submittedAt &&\n !submission.returned &&\n submission.gradeReceived == null &&\n submission.assignment.dueDate.getTime() >= now.getTime();\n const reasons: string[] = [];\n let priorityScore = 0;\n\n if (isMissing) {\n reasons.push(\"Missing past-due work\");\n priorityScore += 100;\n }\n if (isLowScore) {\n reasons.push(`Scored ${percentage}%`);\n priorityScore += 85 - (percentage ?? 0);\n }\n if (isUnreturned) {\n reasons.push(\"Awaiting grade or feedback\");\n priorityScore += 15;\n }\n if (isUpcoming) {\n reasons.push(\"Upcoming graded assignment\");\n priorityScore += 5;\n }\n\n return {\n assignmentId: submission.assignment.id,\n submissionId: submission.id,\n title: submission.assignment.title,\n type: submission.assignment.type,\n sectionName: submission.assignment.section?.name ?? null,\n dueDate: submission.assignment.dueDate,\n gradeReceived: submission.gradeReceived,\n maxGrade: submission.assignment.maxGrade,\n percentage,\n submitted: Boolean(submission.submitted),\n returned: Boolean(submission.returned),\n reasons,\n priorityScore,\n };\n })\n .filter((candidate) => candidate.priorityScore > 0)\n .sort((a, b) => b.priorityScore - a.priorityScore)\n .slice(0, 5);\n\n const gradedPercentages = submissions\n .map(calculatePercentage)\n .filter((value): value is number => value != null);\n const lowScoreCount = gradedPercentages.filter(\n (percentage) => percentage < 70,\n ).length;\n const missingCount = submissions.filter(\n (submission) =>\n !submission.submitted &&\n submission.assignment.dueDate.getTime() < now.getTime(),\n ).length;\n const trend = calculateTrend(submissions);\n const overallGrade = getOverallGrade(submissions);\n\n const nextSteps = [\n missingCount > 0\n ? `Prioritize ${missingCount} missing assignment${missingCount === 1 ? \"\" : \"s\"}.`\n : null,\n lowScoreCount > 0\n ? `Review ${lowScoreCount} low-scoring assignment${lowScoreCount === 1 ? \"\" : \"s\"} before introducing new material.`\n : null,\n trend < -5\n ? \"Schedule a check-in because recent performance is trending down.\"\n : null,\n recommendationCandidates.length === 0\n ? \"No urgent assignment issues detected from the available grades.\"\n : null,\n ].filter((step): step is string => Boolean(step));\n\n return {\n student: {\n id: student.id,\n username: student.username,\n displayName: student.profile?.displayName ?? student.username,\n },\n summary: {\n overallGrade,\n trend,\n completedAssignments: submissions.filter((submission) =>\n Boolean(submission.submitted),\n ).length,\n totalAssignments: submissions.length,\n missingCount,\n lowScoreCount,\n },\n recommendations: recommendationCandidates.map(\n ({ priorityScore, ...candidate }) => candidate,\n ),\n nextSteps,\n };\n}\n\nfunction buildProgressSummary(submissions: GradeSubmission[]) {\n return submissions.map((submission) => ({\n title: submission.assignment.title,\n type: submission.assignment.type,\n dueDate: submission.assignment.dueDate.toISOString(),\n submitted: Boolean(submission.submitted),\n returned: Boolean(submission.returned),\n gradeReceived: submission.gradeReceived,\n maxGrade: submission.assignment.maxGrade,\n percentage: calculatePercentage(submission),\n section: submission.assignment.section?.name ?? null,\n }));\n}\n\nexport async function chatAboutStudentProgress(\n viewerId: string,\n input: {\n classId: string;\n studentId: string;\n message: string;\n history?: ProgressChatMessage[];\n },\n) {\n const { classData, student, submissions } = await loadStudentProgressContext(\n viewerId,\n input.classId,\n input.studentId,\n );\n\n const displayName = student.profile?.displayName ?? student.username;\n const summary = {\n overallGrade: getOverallGrade(submissions),\n trend: calculateTrend(submissions),\n assignments: buildProgressSummary(submissions),\n };\n\n const messages = [\n {\n role: \"system\" as const,\n content:\n \"You are an educational progress assistant for teachers and students. Use only the provided class and grade context. Be concise, specific, supportive, and avoid fabricating grades or assignments.\",\n },\n {\n role: \"user\" as const,\n content: JSON.stringify({\n class: classData,\n student: { id: student.id, username: student.username, displayName },\n progress: summary,\n }),\n },\n ...(input.history ?? []).slice(-8).map((message) => ({\n role: message.role,\n content: message.content,\n })),\n { role: \"user\" as const, content: input.message },\n ];\n\n try {\n const response = await inference<string>(messages);\n if (typeof response !== \"string\" || response.trim().length === 0) {\n throw new Error(\"Student progress chat returned an empty response\");\n }\n return { message: response, isFallback: false };\n } catch (error) {\n logger.error(\"Failed to generate student progress chat response\", {\n error,\n classId: input.classId,\n studentId: input.studentId,\n });\n\n const overall =\n summary.overallGrade == null\n ? \"not enough graded work\"\n : `${summary.overallGrade}%`;\n const missingItems = summary.assignments.filter(\n (assignment) =>\n !assignment.submitted &&\n new Date(assignment.dueDate).getTime() < Date.now(),\n ).length;\n const lowScores = summary.assignments.filter(\n (assignment) =>\n assignment.percentage != null && assignment.percentage < 70,\n ).length;\n const awaitingFeedback = summary.assignments.filter(\n (assignment) =>\n assignment.submitted &&\n assignment.gradeReceived == null &&\n !assignment.returned,\n ).length;\n const trendLabel =\n summary.overallGrade == null\n ? \"not enough graded work to determine a recent trend\"\n : summary.trend > 5\n ? \"improving\"\n : summary.trend < -5\n ? \"declining\"\n : \"stable\";\n const advice = [\n missingItems > 0\n ? `Review ${missingItems} missing assignment${missingItems === 1 ? \"\" : \"s\"}.`\n : null,\n lowScores > 0\n ? `Use targeted practice for ${lowScores} low-scoring assignment${lowScores === 1 ? \"\" : \"s\"}.`\n : null,\n awaitingFeedback > 0\n ? `Check back after feedback is returned for ${awaitingFeedback} submitted assignment${awaitingFeedback === 1 ? \"\" : \"s\"}.`\n : null,\n missingItems === 0 && lowScores === 0 && awaitingFeedback === 0\n ? \"Check upcoming work and ask for feedback as new grades are returned.\"\n : null,\n ].filter((item): item is string => Boolean(item));\n return {\n message: `${displayName}'s current overall progress is ${overall}, with ${trendLabel}. ${advice.join(\" \")}`,\n isFallback: true,\n };\n }\n}\n"],"names":[],"mappings":"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","debug_id":"e4b426af-1ccd-5152-a996-9a03a09b6503"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@studious-lms/server",
3
- "version": "1.4.0",
3
+ "version": "1.4.1",
4
4
  "description": "Backend server for Studious application",
5
5
  "main": "dist/exportType.js",
6
6
  "types": "dist/exportType.d.ts",
@@ -24,7 +24,7 @@
24
24
  "test:watch": "NODE_ENV=test vitest watch",
25
25
  "test:coverage": "NODE_ENV=test vitest run --coverage",
26
26
  "db:seed": "tsx src/seedDatabase.ts",
27
- "sentry:sourcemaps": "sentry-cli sourcemaps inject --org studious-fp --project server ./dist && sentry-cli sourcemaps upload --org studious-fp --project server ./dist"
27
+ "sentry:sourcemaps": "sentry-cli sourcemaps inject ./dist && sentry-cli sourcemaps upload --org \"${SENTRY_ORG:-studious-lms}\" --project \"${SENTRY_PROJECT:-server}\" ./dist"
28
28
  },
29
29
  "dependencies": {
30
30
  "@google-cloud/storage": "^7.16.0",
@@ -0,0 +1,3 @@
1
+ # Sentry CLI API host. Required for EU data residency (DSN host ingest.de.sentry.io).
2
+ # Override anytime: SENTRY_URL=https://sentry.io
3
+ defaults.url=https://de.sentry.io
@@ -8,80 +8,14 @@ import { GenerationStatus } from "@prisma/client";
8
8
  import { pusher, teacherChannel } from "../lib/pusher.js";
9
9
  import type { Assignment, Class, File, Section, User } from "@prisma/client";
10
10
  import { inference, inferenceClient, sendAIMessage } from "../utils/inference.js";
11
- import z from "zod";
12
11
  import { logger } from "../utils/logger.js";
13
12
  import { createPdf } from "../lib/jsonConversion.js";
14
13
  import { v4 } from "uuid";
15
14
  import { bucket } from "../lib/googleCloudStorage.js";
16
15
  import OpenAI from "openai";
17
16
  import { DocumentBlock } from "../lib/jsonStyles.js";
18
-
19
- // Schema for lab chat response with PDF document generation
20
- const labChatResponseSchema = z.object({
21
- text: z.string(),
22
- worksheetsToCreate: z.array(z.object({
23
- title: z.string(),
24
- questions: z.array(z.object({
25
- type: z.enum(['MULTIPLE_CHOICE', 'TRUE_FALSE', 'SHORT_ANSWER', 'LONG_ANSWER', 'MATH_EXPRESSION', 'ESSAY']),
26
- question: z.string(),
27
- answer: z.string(),
28
- options: z.array(z.object({
29
- id: z.string(),
30
- text: z.string(),
31
- isCorrect: z.boolean(),
32
- })).optional().default([]),
33
- markScheme: z.array(z.object({
34
- id: z.string(),
35
- points: z.number(),
36
- description: z.string(),
37
- })).optional().default([]),
38
- points: z.number().optional().default(0),
39
- order: z.number(),
40
- })),
41
- })),
42
- sectionsToCreate: z.array(z.object({
43
- name: z.string(),
44
- color: z.string().regex(/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/).nullable().optional(),
45
- })),
46
- assignmentsToCreate: z.array(z.object({
47
- title: z.string(),
48
- instructions: z.string(),
49
- dueDate: z.string().datetime(),
50
- acceptFiles: z.boolean(),
51
- acceptExtendedResponse: z.boolean(),
52
- acceptWorksheet: z.boolean(),
53
- maxGrade: z.number(),
54
- gradingBoundaryId: z.string().nullable().optional(),
55
- markschemeId: z.string().nullable().optional(),
56
- worksheetIds: z.array(z.string()),
57
- studentIds: z.array(z.string()),
58
- sectionId: z.string().nullable().optional(),
59
- type: z.enum(['HOMEWORK', 'QUIZ', 'TEST', 'PROJECT', 'ESSAY', 'DISCUSSION', 'PRESENTATION', 'LAB', 'OTHER']),
60
- attachments: z.array(z.object({
61
- id: z.string(),
62
- })),
63
- })).nullable().optional(),
64
- docs: z.array(z.object({
65
- title: z.string(),
66
- blocks: z.array(z.object({
67
- format: z.number().int().min(0).max(12),
68
- content: z.union([z.string(), z.array(z.string())]),
69
- metadata: z.object({
70
- fontSize: z.number().min(6).nullable().optional(),
71
- lineHeight: z.number().min(0.6).nullable().optional(),
72
- paragraphSpacing: z.number().min(0).nullable().optional(),
73
- indentWidth: z.number().min(0).nullable().optional(),
74
- paddingX: z.number().min(0).nullable().optional(),
75
- paddingY: z.number().min(0).nullable().optional(),
76
- font: z.number().int().min(0).max(5).nullable().optional(),
77
- color: z.string().regex(/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/).nullable().optional(),
78
- background: z.string().regex(/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/).nullable().optional(),
79
- align: z.enum(["left", "center", "right"]).nullable().optional(),
80
- }).nullable().optional(),
81
- })),
82
- })).nullable().optional(),
83
- });
84
-
17
+ import { type LabChatResponse, labChatArrayFieldInstructions, labChatResponseFormat, labChatResponseSchema } from "./aiLabChatContract.js";
18
+ import { buildLabChatSystemPrompt } from "./labChatPrompt.js";
85
19
 
86
20
  /** Extended class data for AI context (schema-aware) */
87
21
  type ClassContextData = {
@@ -91,8 +25,8 @@ type ClassContextData = {
91
25
  gradingBoundaries: { id: string; structured: string }[];
92
26
  worksheets: { id: string; name: string; questionCount: number }[];
93
27
  files: File[];
94
- students: (User & { profile?: { displayName: string | null } | null })[];
95
- teachers: (User & { profile?: { displayName: string | null } | null })[];
28
+ students: { id: string; username: string; profile?: { displayName: string | null } | null }[];
29
+ teachers: { id: string; username: string; profile?: { displayName: string | null } | null }[];
96
30
  assignments: (Assignment & {
97
31
  section?: { id: string; name: string; order?: number | null } | null;
98
32
  markScheme?: { id: string } | null;
@@ -169,8 +103,8 @@ Syllabus: ${cls.syllabus ? cls.syllabus.slice(0, 200) + (cls.syllabus.length > 2
169
103
  SECTIONS (use sectionId when creating assignments):
170
104
  ${sectionList || " (none - suggest sectionsToCreate first)"}
171
105
 
172
- MARK SCHEMES (use markschemeId when creating assignments):
173
- ${markSchemeList || " (none - suggest creating one or omit markschemeId)"}
106
+ MARK SCHEMES (use markSchemeId when creating assignments):
107
+ ${markSchemeList || " (none - suggest creating one or omit markSchemeId)"}
174
108
 
175
109
  GRADING BOUNDARIES (use gradingBoundaryId when creating assignments):
176
110
  ${gradingBoundaryList || " (none - suggest creating one or omit gradingBoundaryId)"}
@@ -322,12 +256,10 @@ export const generateAndSendLabIntroduction = async (
322
256
  export const generateAndSendLabResponse = async (
323
257
  labChatId: string,
324
258
  teacherMessage: string,
325
- conversationId: string,
326
259
  emitOptions?: { classId: string; messageId: string }
327
260
  ): Promise<void> => {
328
261
  try {
329
262
  // Get lab context from database
330
-
331
263
  const fullLabChat = await prisma.labChat.findUnique({
332
264
  where: { id: labChatId },
333
265
  include: {
@@ -344,6 +276,8 @@ export const generateAndSendLabIntroduction = async (
344
276
  throw new Error('Lab chat not found');
345
277
  }
346
278
 
279
+ const conversationId = fullLabChat.conversationId;
280
+
347
281
  // Get recent conversation history
348
282
  const recentMessages = await prisma.message.findMany({
349
283
  where: {
@@ -370,67 +304,7 @@ export const generateAndSendLabIntroduction = async (
370
304
 
371
305
  // Build conversation history as proper message objects
372
306
  // Enhance the stored context with schema-aware instructions
373
- const enhancedSystemPrompt = `${fullLabChat.context}
374
-
375
- IMPORTANT INSTRUCTIONS:
376
- - Use the context information above (subject, topic, difficulty, objectives, etc.) as your foundation
377
- - A separate CLASS CONTEXT message lists this class's sections, mark schemes, grading boundaries, worksheets, files, and students with their database IDs
378
- - Do NOT ask teachers about technical details (hex codes, format numbers, IDs, schema fields). Use sensible defaults yourself.
379
- - Only ask clarifying questions about content or pedagogy (e.g., topic scope, difficulty, number of questions). Never ask "what hex color?" or "which format?"
380
- - When creating content, make reasonable choices: pick nice default colors, use standard formatting. Teachers care about the content, not implementation.
381
- - Only output final course materials when you have sufficient details about the content itself
382
- - Do not use markdown in your responses - use plain text only
383
- - You are primarily a chatbot - only provide docs/assignments when the teacher explicitly requests them
384
- - If the request is vague, ask 1-2 high-level clarifying questions (topic, scope, style) - never technical ones
385
-
386
- CRITICAL: REFERENCING OBJECTS - NAMES vs IDs:
387
- - In "text": Refer to objects by NAME (e.g., "Unit 1", "Biology Rubric", "Cell_Structure_Worksheet")
388
- - In "assignmentsToCreate", "worksheetsToCreate", "sectionsToCreate": Use DATABASE IDs from the CLASS CONTEXT
389
- * sectionId, gradingBoundaryId, markschemeId, worksheetIds, studentIds, attachments[].id must be real IDs from the context
390
- * If the class has no sections/mark schemes/grading boundaries, use sectionsToCreate first, or omit optional IDs
391
-
392
- RESPONSE FORMAT (JSON):
393
- { "text": string, "docs": null | array, "worksheetsToCreate": null | array, "sectionsToCreate": null | array, "assignmentsToCreate": null | array }
394
-
395
- CRITICAL - "text" field rules:
396
- - "text" must be a SHORT conversational summary (2-4 sentences). Plain text, no markdown.
397
- - NEVER list assignment/worksheet fields in text (no "Type:", "dueDate:", "worksheetIds:", "sectionId:", etc.)
398
- - NEVER dump schema or JSON-like output in text. The teacher sees the actual content in UI cards below.
399
- - Good example: "I've created 4 assignments for Unit 1: Week 1 homework on the worksheet, Week 2 quiz, Week 3 lab activity, and Week 4 review test. You can create them below."
400
- - Bad example: "Week 1 - Homework. Type: HOMEWORK. dueDate: 2026-03-10. worksheetIds: [...]" — NEVER do this.
401
-
402
- - "docs": PDF documents when creating course materials (worksheets, handouts, answer keys)
403
- - "worksheetsToCreate": Worksheets with questions when teacher wants structured assessments
404
- - "sectionsToCreate": New sections when the class has none or teacher wants new units
405
- - "assignmentsToCreate": Assignments when teacher explicitly requests them. Use IDs from CLASS CONTEXT. The structured data goes HERE only, not in text.
406
-
407
- WHEN CREATING DOCUMENTS (docs):
408
- - docs: [ { "title": string, "blocks": [ { "format": 0-12, "content": string | string[], "metadata"?: {...} } ] } ]
409
- - Format: 0=H1, 1=H2, 2=H3, 3=H4, 4=H5, 5=H6, 6=PARAGRAPH, 7=BULLET, 8=NUMBERED, 9=TABLE, 10=IMAGE, 11=CODE_BLOCK, 12=QUOTE
410
- - Bullets (7) and Numbered (8): content is array of strings; do NOT include * or 1. 2. 3. - renderer adds them
411
- - Table (9) and Image (10) not supported - do not emit
412
- - Colors: use sensible defaults (e.g. "#3B82F6" blue, "#10B981" green) - never ask the teacher
413
-
414
- WHEN CREATING WORKSHEETS (worksheetsToCreate):
415
- - Question types: MULTIPLE_CHOICE, TRUE_FALSE, SHORT_ANSWER, LONG_ANSWER, MATH_EXPRESSION, ESSAY
416
- - For MULTIPLE_CHOICE/TRUE_FALSE: options array with { id, text, isCorrect }
417
- - For others: options can be empty; answer holds the key
418
- - markScheme: array of { id, points, description } for rubric items
419
- - points: total points per question; order: display order
420
-
421
- WHEN CREATING SECTIONS (sectionsToCreate):
422
- - Use when class has no sections or teacher wants new units (e.g., "Unit 1", "Chapter 3")
423
- - color: pick a nice default (e.g. "#3B82F6") - do not ask
424
-
425
- WHEN CREATING ASSIGNMENTS (assignmentsToCreate):
426
- - Put ALL assignment data (title, type, dueDate, instructions, worksheetIds, etc.) ONLY in assignmentsToCreate. The "text" field gets a brief friendly summary only.
427
- - Use IDs from CLASS CONTEXT. If class has no sections, suggest sectionsToCreate first.
428
- - type: HOMEWORK | QUIZ | TEST | PROJECT | ESSAY | DISCUSSION | PRESENTATION | LAB | OTHER
429
- - sectionId, gradingBoundaryId, markschemeId: use from context; omit if class has none (suggest creating first)
430
- - studentIds: empty array = assign to all; otherwise list specific student IDs
431
- - worksheetIds: IDs of existing worksheets; empty if using docs-only or new worksheets
432
- - attachments[].id: file IDs from CLASS CONTEXT (PDFs, documents)
433
- - acceptFiles, acceptExtendedResponse, acceptWorksheet: set based on assignment type`;
307
+ const enhancedSystemPrompt = buildLabChatSystemPrompt(fullLabChat.context);
434
308
 
435
309
  const messages: OpenAI.Chat.Completions.ChatCompletionMessageParam[] = [
436
310
  { role: 'system', content: enhancedSystemPrompt },
@@ -471,10 +345,18 @@ WHEN CREATING ASSIGNMENTS (assignmentsToCreate):
471
345
  },
472
346
  },
473
347
  students: {
474
- include: { profile: { select: { displayName: true } } },
348
+ select: {
349
+ id: true,
350
+ username: true,
351
+ profile: { select: { displayName: true } },
352
+ },
475
353
  },
476
354
  teachers: {
477
- include: { profile: { select: { displayName: true } } },
355
+ select: {
356
+ id: true,
357
+ username: true,
358
+ profile: { select: { displayName: true } },
359
+ },
478
360
  },
479
361
  classFiles: {
480
362
  include: {
@@ -529,7 +411,7 @@ REMINDER: Your "text" response must be a short, friendly summary (2-4 sentences)
529
411
  // response_format: zodTextFormat(labChatResponseSchema, "lab_chat_response_format"),
530
412
  // });
531
413
 
532
- const response = await inference<z.infer<typeof labChatResponseSchema>>(messages, labChatResponseSchema);
414
+ const response = await inference<LabChatResponse>(messages, labChatResponseSchema);
533
415
 
534
416
  if (!response) {
535
417
  throw new Error('No response generated from inference API');
@@ -635,6 +517,10 @@ REMINDER: Your "text" response must be a short, friendly summary (2-4 sentences)
635
517
  }
636
518
 
637
519
  if (emitOptions) {
520
+ await prisma.message.update({
521
+ where: { id: emitOptions.messageId },
522
+ data: { status: GenerationStatus.COMPLETED },
523
+ });
638
524
  try {
639
525
  await pusher.trigger(teacherChannel(emitOptions.classId), "lab-response-completed", {
640
526
  labChatId,
@@ -643,10 +529,6 @@ REMINDER: Your "text" response must be a short, friendly summary (2-4 sentences)
643
529
  } catch (broadcastError) {
644
530
  logger.error("Failed to broadcast lab response completed:", { error: broadcastError });
645
531
  }
646
- await prisma.message.update({
647
- where: { id: emitOptions.messageId },
648
- data: { status: GenerationStatus.COMPLETED },
649
- });
650
532
  }
651
533
 
652
534
  logger.info('AI response sent', { labChatId, conversationId });
@@ -663,20 +545,27 @@ REMINDER: Your "text" response must be a short, friendly summary (2-4 sentences)
663
545
  });
664
546
 
665
547
  if (emitOptions) {
666
- const errorMessage = error instanceof Error ? error.message : String(error);
548
+ try {
549
+ await prisma.message.update({
550
+ where: { id: emitOptions.messageId },
551
+ data: { status: GenerationStatus.FAILED },
552
+ });
553
+ } catch (statusError) {
554
+ logger.error("Failed to set message status FAILED:", {
555
+ error: statusError,
556
+ labChatId,
557
+ messageId: emitOptions.messageId,
558
+ });
559
+ }
667
560
  try {
668
561
  await pusher.trigger(teacherChannel(emitOptions.classId), "lab-response-failed", {
669
562
  labChatId,
670
563
  messageId: emitOptions.messageId,
671
- error: errorMessage,
564
+ error: "AI response generation failed",
672
565
  });
673
566
  } catch (broadcastError) {
674
567
  logger.error("Failed to broadcast lab response failed:", { error: broadcastError });
675
568
  }
676
- await prisma.message.update({
677
- where: { id: emitOptions.messageId },
678
- data: { status: GenerationStatus.FAILED },
679
- });
680
569
  }
681
570
 
682
571
  throw error; // Re-throw to see the full error in the calling function
@@ -0,0 +1,75 @@
1
+ import z from "zod";
2
+
3
+ export const labChatResponseSchema = z.object({
4
+ text: z.string(),
5
+ worksheetsToCreate: z.array(z.object({
6
+ title: z.string(),
7
+ questions: z.array(z.object({
8
+ type: z.enum(["MULTIPLE_CHOICE", "TRUE_FALSE", "SHORT_ANSWER", "LONG_ANSWER", "MATH_EXPRESSION", "ESSAY"]),
9
+ question: z.string(),
10
+ answer: z.string(),
11
+ options: z.array(z.object({
12
+ id: z.string(),
13
+ text: z.string(),
14
+ isCorrect: z.boolean(),
15
+ })).optional().default([]),
16
+ markScheme: z.array(z.object({
17
+ id: z.string(),
18
+ points: z.number(),
19
+ description: z.string(),
20
+ })).optional().default([]),
21
+ points: z.number().optional().default(0),
22
+ order: z.number(),
23
+ })),
24
+ })).default([]),
25
+ sectionsToCreate: z.array(z.object({
26
+ name: z.string(),
27
+ color: z.string().regex(/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/).nullable().optional(),
28
+ })).default([]),
29
+ assignmentsToCreate: z.array(z.object({
30
+ title: z.string(),
31
+ instructions: z.string(),
32
+ dueDate: z.string().datetime(),
33
+ acceptFiles: z.boolean(),
34
+ acceptExtendedResponse: z.boolean(),
35
+ acceptWorksheet: z.boolean(),
36
+ maxGrade: z.number(),
37
+ gradingBoundaryId: z.string().nullable().optional(),
38
+ markSchemeId: z.string().nullable().optional(),
39
+ worksheetIds: z.array(z.string()),
40
+ studentIds: z.array(z.string()),
41
+ sectionId: z.string().nullable().optional(),
42
+ type: z.enum(["HOMEWORK", "QUIZ", "TEST", "PROJECT", "ESSAY", "DISCUSSION", "PRESENTATION", "LAB", "OTHER"]),
43
+ attachments: z.array(z.object({
44
+ id: z.string(),
45
+ })),
46
+ })).nullable().optional(),
47
+ docs: z.array(z.object({
48
+ title: z.string(),
49
+ blocks: z.array(z.object({
50
+ format: z.number().int().min(0).max(12),
51
+ content: z.union([z.string(), z.array(z.string())]),
52
+ metadata: z.object({
53
+ fontSize: z.number().min(6).nullable().optional(),
54
+ lineHeight: z.number().min(0.6).nullable().optional(),
55
+ paragraphSpacing: z.number().min(0).nullable().optional(),
56
+ indentWidth: z.number().min(0).nullable().optional(),
57
+ paddingX: z.number().min(0).nullable().optional(),
58
+ paddingY: z.number().min(0).nullable().optional(),
59
+ font: z.number().int().min(0).max(5).nullable().optional(),
60
+ color: z.string().regex(/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/).nullable().optional(),
61
+ background: z.string().regex(/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/).nullable().optional(),
62
+ align: z.enum(["left", "center", "right"]).nullable().optional(),
63
+ }).nullable().optional(),
64
+ })),
65
+ })).nullable().optional(),
66
+ });
67
+
68
+ export type LabChatResponse = z.infer<typeof labChatResponseSchema>;
69
+
70
+ export const labChatResponseFormat = `{ "text": string, "docs": null | array, "worksheetsToCreate": array, "sectionsToCreate": array, "assignmentsToCreate": null | array }`;
71
+
72
+ export const labChatArrayFieldInstructions = [
73
+ `- "worksheetsToCreate": always output an array. Use [] when there are no worksheets to create.`,
74
+ `- "sectionsToCreate": always output an array. Use [] when there are no sections to create.`,
75
+ ].join("\n");
@@ -0,0 +1,68 @@
1
+ export const buildLabChatSystemPrompt = (context: string): string => `${context}
2
+
3
+ IMPORTANT INSTRUCTIONS:
4
+ - Use the context information above (subject, topic, difficulty, objectives, etc.) as your foundation
5
+ - A separate CLASS CONTEXT message lists this class's sections, mark schemes, grading boundaries, worksheets, files, and students with their database IDs
6
+ - Do NOT ask teachers about technical details (hex codes, format numbers, IDs, schema fields). Use sensible defaults yourself.
7
+ - Only ask clarifying questions about content or pedagogy (e.g., topic scope, difficulty, number of questions). Never ask "what hex color?" or "which format?"
8
+ - When creating content, make reasonable choices: pick nice default colors, use standard formatting. Teachers care about the content, not implementation.
9
+ - Only output final course materials when you have sufficient details about the content itself
10
+ - Do not use markdown in your responses - use plain text only
11
+ - You are primarily a chatbot - only provide docs/assignments when the teacher explicitly requests them
12
+ - If the request is vague, ask 1-2 high-level clarifying questions (topic, scope, style) - never technical ones
13
+
14
+ CRITICAL: REFERENCING OBJECTS - NAMES vs IDs:
15
+ - In "text": Refer to objects by NAME (e.g., "Unit 1", "Biology Rubric", "Cell_Structure_Worksheet")
16
+ - In "assignmentsToCreate", "worksheetsToCreate", "sectionsToCreate": Use DATABASE IDs from the CLASS CONTEXT
17
+ * sectionId, gradingBoundaryId, markSchemeId, worksheetIds, studentIds, attachments[].id must be real IDs from the context
18
+ * If the class has no sections/mark schemes/grading boundaries, use sectionsToCreate first, or omit optional IDs
19
+
20
+ RESPONSE FORMAT (JSON):
21
+ { "text": string, "docs": null | array, "worksheetsToCreate": array, "sectionsToCreate": array, "assignmentsToCreate": null | array }
22
+
23
+ CRITICAL ARRAY RULES:
24
+ - "worksheetsToCreate" must always be an array. Use [] when there are no worksheets to create.
25
+ - "sectionsToCreate" must always be an array. Use [] when there are no sections to create.
26
+ - Do not return null for "worksheetsToCreate" or "sectionsToCreate".
27
+
28
+ CRITICAL - "text" field rules:
29
+ - "text" must be a SHORT conversational summary (2-4 sentences). Plain text, no markdown.
30
+ - NEVER list assignment/worksheet fields in text (no "Type:", "dueDate:", "worksheetIds:", "sectionId:", etc.)
31
+ - NEVER dump schema or JSON-like output in text. The teacher sees the actual content in UI cards below.
32
+ - Good example: "I've created 4 assignments for Unit 1: Week 1 homework on the worksheet, Week 2 quiz, Week 3 lab activity, and Week 4 review test. You can create them below."
33
+ - Bad example: "Week 1 - Homework. Type: HOMEWORK. dueDate: 2026-03-10. worksheetIds: [...]" - NEVER do this.
34
+
35
+ - "docs": PDF documents when creating course materials (worksheets, handouts, answer keys)
36
+ - "worksheetsToCreate": Worksheets with questions when teacher wants structured assessments. Always return an array.
37
+ - "sectionsToCreate": New sections when the class has none or teacher wants new units. Always return an array.
38
+ - "assignmentsToCreate": Assignments when teacher explicitly requests them. Use IDs from CLASS CONTEXT. The structured data goes HERE only, not in text.
39
+
40
+ WHEN CREATING DOCUMENTS (docs):
41
+ - docs: [ { "title": string, "blocks": [ { "format": 0-12, "content": string | string[], "metadata"?: {...} } ] } ]
42
+ - Format: 0=H1, 1=H2, 2=H3, 3=H4, 4=H5, 5=H6, 6=PARAGRAPH, 7=BULLET, 8=NUMBERED, 9=TABLE, 10=IMAGE, 11=CODE_BLOCK, 12=QUOTE
43
+ - Bullets (7) and Numbered (8): content is array of strings; do NOT include * or 1. 2. 3. - renderer adds them
44
+ - Table (9) and Image (10) not supported - do not emit
45
+ - Colors: use sensible defaults (e.g. "#3B82F6" blue, "#10B981" green) - never ask the teacher
46
+
47
+ WHEN CREATING WORKSHEETS (worksheetsToCreate):
48
+ - Return an array every time, even when empty.
49
+ - Question types: MULTIPLE_CHOICE, TRUE_FALSE, SHORT_ANSWER, LONG_ANSWER, MATH_EXPRESSION, ESSAY
50
+ - For MULTIPLE_CHOICE/TRUE_FALSE: options array with { id, text, isCorrect }
51
+ - For others: options can be empty; answer holds the key
52
+ - markScheme: array of { id, points, description } for rubric items
53
+ - points: total points per question; order: display order
54
+
55
+ WHEN CREATING SECTIONS (sectionsToCreate):
56
+ - Return an array every time, even when empty.
57
+ - Use when class has no sections or teacher wants new units (e.g., "Unit 1", "Chapter 3")
58
+ - color: pick a nice default (e.g. "#3B82F6") - do not ask
59
+
60
+ WHEN CREATING ASSIGNMENTS (assignmentsToCreate):
61
+ - Put ALL assignment data (title, type, dueDate, instructions, worksheetIds, etc.) ONLY in assignmentsToCreate. The "text" field gets a brief friendly summary only.
62
+ - Use IDs from CLASS CONTEXT. If class has no sections, suggest sectionsToCreate first.
63
+ - type: HOMEWORK | QUIZ | TEST | PROJECT | ESSAY | DISCUSSION | PRESENTATION | LAB | OTHER
64
+ - sectionId, gradingBoundaryId, markSchemeId: use from context; omit if class has none (suggest creating first)
65
+ - studentIds: empty array = assign to all; otherwise list specific student IDs
66
+ - worksheetIds: IDs of existing worksheets; empty if using docs-only or new worksheets
67
+ - attachments[].id: file IDs from CLASS CONTEXT (PDFs, documents)
68
+ - acceptFiles, acceptExtendedResponse, acceptWorksheet: set based on assignment type`;
@@ -20,6 +20,7 @@ import { newtonChatRouter } from "./newtonChat.js";
20
20
  import { marketingRouter } from "./marketing.js";
21
21
  import { worksheetRouter } from "./worksheet.js";
22
22
  import { commentRouter } from "./comment.js";
23
+ import { studentProgressRouter } from "./studentProgress.js";
23
24
 
24
25
  export const appRouter = createTRPCRouter({
25
26
  class: classRouter,
@@ -41,7 +42,8 @@ export const appRouter = createTRPCRouter({
41
42
  marketing: marketingRouter,
42
43
  worksheet: worksheetRouter,
43
44
  comment: commentRouter,
44
- });
45
+ studentProgress: studentProgressRouter,
46
+ });
45
47
 
46
48
  // Export type router type definition
47
49
  export type AppRouter = typeof appRouter;
@@ -49,4 +51,4 @@ export type RouterInputs = inferRouterInputs<AppRouter>;
49
51
  export type RouterOutputs = inferRouterOutputs<AppRouter>;
50
52
 
51
53
  // Export caller
52
- export const createCaller = createCallerFactory(appRouter);
54
+ export const createCaller = createCallerFactory(appRouter);
@@ -0,0 +1,47 @@
1
+ import { z } from "zod";
2
+ import { createTRPCRouter, protectedClassMemberProcedure } from "../trpc.js";
3
+ import {
4
+ chatAboutStudentProgress,
5
+ getStudentProgressRecommendations,
6
+ } from "../services/studentProgress.js";
7
+
8
+ const progressInputSchema = z.object({
9
+ classId: z.string(),
10
+ studentId: z.string(),
11
+ });
12
+
13
+ export const studentProgressRouter = createTRPCRouter({
14
+ getRecommendations: protectedClassMemberProcedure
15
+ .input(progressInputSchema)
16
+ .query(({ ctx, input }) =>
17
+ getStudentProgressRecommendations(
18
+ ctx.user!.id,
19
+ input.classId,
20
+ input.studentId,
21
+ ),
22
+ ),
23
+
24
+ chat: protectedClassMemberProcedure
25
+ .input(
26
+ progressInputSchema.extend({
27
+ message: z.string().min(1).max(4000),
28
+ history: z
29
+ .array(
30
+ z.object({
31
+ role: z.enum(["user", "assistant"]),
32
+ content: z.string().min(1).max(4000),
33
+ }),
34
+ )
35
+ .max(8)
36
+ .optional(),
37
+ }),
38
+ )
39
+ .mutation(({ ctx, input }) =>
40
+ chatAboutStudentProgress(ctx.user!.id, {
41
+ classId: input.classId,
42
+ studentId: input.studentId,
43
+ message: input.message,
44
+ history: input.history,
45
+ }),
46
+ ),
47
+ });