@studious-lms/server 1.15.0 → 1.16.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 (60) hide show
  1. package/dist/lib/chatAttachmentPrompt.d.ts +47 -0
  2. package/dist/lib/chatAttachmentPrompt.d.ts.map +1 -0
  3. package/dist/lib/chatAttachmentPrompt.js +190 -0
  4. package/dist/lib/chatAttachmentPrompt.js.map +1 -0
  5. package/dist/lib/inferDocumentFormat.js +3 -3
  6. package/dist/lib/inferDocumentFormat.js.map +1 -1
  7. package/dist/pipelines/aiLabChat.d.ts.map +1 -1
  8. package/dist/pipelines/aiLabChat.js +7 -35
  9. package/dist/pipelines/aiLabChat.js.map +1 -1
  10. package/dist/pipelines/aiLabChatContract.d.ts +2 -2
  11. package/dist/pipelines/aiNewtonChat.d.ts +1 -1
  12. package/dist/pipelines/aiNewtonChat.d.ts.map +1 -1
  13. package/dist/pipelines/aiNewtonChat.js +20 -19
  14. package/dist/pipelines/aiNewtonChat.js.map +1 -1
  15. package/dist/pipelines/gradeWorksheet.d.ts +1 -0
  16. package/dist/pipelines/gradeWorksheet.d.ts.map +1 -1
  17. package/dist/pipelines/gradeWorksheet.js +146 -36
  18. package/dist/pipelines/gradeWorksheet.js.map +1 -1
  19. package/dist/pipelines/labChatPrompt.d.ts +4 -20
  20. package/dist/pipelines/labChatPrompt.d.ts.map +1 -1
  21. package/dist/pipelines/labChatPrompt.js +22 -65
  22. package/dist/pipelines/labChatPrompt.js.map +1 -1
  23. package/dist/routers/_app.d.ts +14 -0
  24. package/dist/routers/_app.d.ts.map +1 -1
  25. package/dist/routers/worksheet.d.ts +7 -0
  26. package/dist/routers/worksheet.d.ts.map +1 -1
  27. package/dist/routers/worksheet.js +8 -3
  28. package/dist/routers/worksheet.js.map +1 -1
  29. package/dist/services/aiProposalTools.js +5 -5
  30. package/dist/services/aiProposalTools.js.map +1 -1
  31. package/dist/services/newtonChat.d.ts.map +1 -1
  32. package/dist/services/newtonChat.js +2 -8
  33. package/dist/services/newtonChat.js.map +1 -1
  34. package/dist/services/openaiDocumentGen.d.ts.map +1 -1
  35. package/dist/services/openaiDocumentGen.js +58 -17
  36. package/dist/services/openaiDocumentGen.js.map +1 -1
  37. package/dist/services/worksheet.d.ts +1 -0
  38. package/dist/services/worksheet.d.ts.map +1 -1
  39. package/dist/services/worksheet.js +13 -3
  40. package/dist/services/worksheet.js.map +1 -1
  41. package/dist/workers/processors/aiNewtonChat.d.ts.map +1 -1
  42. package/dist/workers/processors/aiNewtonChat.js +3 -3
  43. package/dist/workers/processors/aiNewtonChat.js.map +1 -1
  44. package/package.json +2 -1
  45. package/src/lib/chatAttachmentPrompt.ts +270 -0
  46. package/src/lib/inferDocumentFormat.ts +1 -1
  47. package/src/pipelines/aiLabChat.ts +5 -43
  48. package/src/pipelines/aiNewtonChat.ts +20 -23
  49. package/src/pipelines/gradeWorksheet.ts +171 -38
  50. package/src/pipelines/labChatPrompt.ts +27 -99
  51. package/src/routers/worksheet.ts +11 -0
  52. package/src/services/aiProposalTools.ts +3 -3
  53. package/src/services/newtonChat.ts +0 -7
  54. package/src/services/openaiDocumentGen.ts +57 -15
  55. package/src/services/worksheet.ts +20 -1
  56. package/src/workers/processors/aiNewtonChat.ts +0 -1
  57. package/tests/integration/routers/newtonChat.test.ts +9 -8
  58. package/tests/unit/lib/chatAttachmentPrompt.test.ts +135 -0
  59. package/tests/unit/lib/inferDocumentFormat.test.ts +10 -0
  60. package/tests/unit/pipelines/labChatPrompt.test.ts +6 -6
@@ -1,23 +1,10 @@
1
1
  import type { ModelMessage } from "ai";
2
+ import {
3
+ buildChatModelMessages,
4
+ type ChatHistoryMessage,
5
+ } from "../lib/chatAttachmentPrompt.js";
2
6
 
3
- export type ConversationMessage = {
4
- id?: string;
5
- content: string;
6
- senderId: string;
7
- createdAt?: Date | string | number;
8
- sender?: {
9
- username?: string | null;
10
- profile?: {
11
- displayName?: string | null;
12
- } | null;
13
- } | null;
14
- attachments?: {
15
- id: string;
16
- name: string;
17
- type: string;
18
- url?: string;
19
- }[];
20
- };
7
+ export type ConversationMessage = ChatHistoryMessage;
21
8
 
22
9
  const LAB_CHAT_BASE_INSTRUCTIONS = [
23
10
  "IMPORTANT INSTRUCTIONS:",
@@ -47,20 +34,27 @@ const LAB_CHAT_REFERENCE_RULES = [
47
34
 
48
35
  const LAB_CHAT_DOC_RULES = [
49
36
  "WHEN CREATING FILES (generate_document tool):",
50
- "- Call generate_document with title, content (markdown/plain), and format.",
37
+ "- Call generate_document with title, content, and format.",
51
38
  "- You can generate MANY file types — not just PDF. Always pass format explicitly.",
52
39
  "- Match format to what the teacher asked for:",
53
- " * slides / presentation / deck → pptx",
54
- " * spreadsheet / grade table / data → xlsx or csv",
40
+ " * slides / presentation / deck → pptx (separate slides with --- or Slide N: headers in content)",
41
+ " * spreadsheet / grade table / data → xlsx or csv (include markdown tables or row data in content)",
55
42
  " * editable handout / letter → docx",
56
43
  " * printable worksheet / reading → pdf",
57
- " * diagram / chart image → png",
44
+ " * diagram / chart / flowchart / figure / graph / visual → png (describe the visual in detail: labels, arrows, shapes, data — NOT pdf)",
58
45
  " * notes / readme → md or txt",
46
+ " * source code → py/js/ts only when the teacher explicitly wants code files",
59
47
  "- Supported: pdf, docx, xlsx, pptx, csv, md, html, txt, json, xml, tex, png, jpeg, gif, zip, py, js, ts, css, sh.",
60
- "- Use markdown structure in content: # headings, paragraphs, - bullets, 1. numbered lists.",
61
- "- Write complete classroom-ready content — no placeholders.",
48
+ "- Write complete classroom-ready content — no placeholders like [diagram here].",
62
49
  "- One file per tool call; split long packs into logical separate documents if needed.",
63
- "- In your spoken reply, name the actual format (e.g. \"PowerPoint\", \"spreadsheet\") — never say you only make PDFs.",
50
+ "- In your spoken reply, name the actual format (e.g. \"PowerPoint\", \"spreadsheet\", \"diagram image\") — never say you only make PDFs.",
51
+ "- Never include download links or URLs; the file appears as an attachment on this message automatically after you call generate_document.",
52
+ ];
53
+
54
+ const LAB_CHAT_ATTACHMENT_RULES = [
55
+ "WHEN THE TEACHER ATTACHES FILES:",
56
+ "- Read attached PDFs, Word docs, PowerPoints, spreadsheets, images, and text files — content is provided inline.",
57
+ "- Reference specifics from the attachment when answering or creating materials.",
64
58
  ];
65
59
 
66
60
  const LAB_CHAT_WORKSHEET_RULES = [
@@ -100,33 +94,6 @@ FILE GENERATION: You can attach pdf, docx, xlsx, pptx, csv, images, and more via
100
94
 
101
95
  const buildPrompt = (sections: string[]): string => sections.filter(Boolean).join("\n\n");
102
96
 
103
- const sortMessagesChronologically = (
104
- recentMessages: ConversationMessage[],
105
- ): ConversationMessage[] =>
106
- recentMessages
107
- .map((message, index) => ({ message, index }))
108
- .sort((left, right) => {
109
- const leftTime = left.message.createdAt
110
- ? new Date(left.message.createdAt).getTime()
111
- : null;
112
- const rightTime = right.message.createdAt
113
- ? new Date(right.message.createdAt).getTime()
114
- : null;
115
-
116
- if (leftTime != null && rightTime != null) {
117
- return leftTime - rightTime;
118
- }
119
- if (leftTime != null) {
120
- return -1;
121
- }
122
- if (rightTime != null) {
123
- return 1;
124
- }
125
-
126
- return left.index - right.index;
127
- })
128
- .map(({ message }) => message);
129
-
130
97
  const LAB_CHAT_TOOL_RULES = [
131
98
  "HOW TO CREATE CONTENT (TOOLS):",
132
99
  "- You have tools to search class materials and to PROPOSE content for the teacher to review and accept.",
@@ -147,6 +114,7 @@ export const buildLabChatAgentSystemPrompt = (context: string): string =>
147
114
  LAB_CHAT_REFERENCE_RULES.join("\n"),
148
115
  LAB_CHAT_TOOL_RULES.join("\n"),
149
116
  LAB_CHAT_DOC_RULES.join("\n"),
117
+ LAB_CHAT_ATTACHMENT_RULES.join("\n"),
150
118
  LAB_CHAT_WORKSHEET_RULES.join("\n"),
151
119
  LAB_CHAT_SECTION_RULES.join("\n"),
152
120
  LAB_CHAT_ASSIGNMENT_RULES.join("\n"),
@@ -158,7 +126,7 @@ export const buildLabChatAgentSystemPrompt = (context: string): string =>
158
126
  * Returns a `system` string and AI-SDK `ModelMessage[]` history. Per-request
159
127
  * class context (IDs) is folded into the system string.
160
128
  */
161
- export const buildLabChatAgentMessages = ({
129
+ export const buildLabChatAgentMessages = async ({
162
130
  context,
163
131
  classContext,
164
132
  recentMessages,
@@ -168,54 +136,14 @@ export const buildLabChatAgentMessages = ({
168
136
  classContext: string;
169
137
  recentMessages: ConversationMessage[];
170
138
  isAIUser: (senderId: string) => boolean;
171
- }): { system: string; messages: ModelMessage[] } => {
139
+ }): Promise<{ system: string; messages: ModelMessage[] }> => {
172
140
  const system = `${buildLabChatAgentSystemPrompt(context)}\n\nCLASS CONTEXT (use these IDs when proposing assignments, worksheets, or attaching files):\n${classContext}`;
173
141
 
174
- const messages: ModelMessage[] = sortMessagesChronologically(recentMessages).map(
175
- (message) => {
176
- const role = isAIUser(message.senderId) ? "assistant" : "user";
177
- const senderName =
178
- message.sender?.profile?.displayName || message.sender?.username || "Teacher";
179
-
180
- if (role === "assistant") {
181
- return { role, content: message.content };
182
- }
183
-
184
- // Build user message with attachments
185
- const textContent = `${senderName}: ${message.content}`;
186
- const attachments = message.attachments ?? [];
187
-
188
- if (attachments.length === 0) {
189
- return { role, content: textContent };
190
- }
191
-
192
- // Separate images (multimodal) from other files (text description)
193
- const imageAttachments = attachments.filter((a) => a.type.startsWith('image/'));
194
- const otherAttachments = attachments.filter((a) => !a.type.startsWith('image/'));
195
-
196
- const parts: Array<{ type: string; text?: string; image?: string }> = [];
197
-
198
- // Add text content with non-image file descriptions
199
- let fullText = textContent;
200
- if (otherAttachments.length > 0) {
201
- const fileList = otherAttachments.map((a) => `${a.name} (${a.type})`).join(', ');
202
- fullText += `\n[Attached files: ${fileList}]`;
203
- }
204
- parts.push({ type: 'text', text: fullText });
205
-
206
- // Add image attachments as image parts for multimodal
207
- for (const img of imageAttachments) {
208
- if (img.url) {
209
- parts.push({ type: 'image', image: img.url });
210
- } else {
211
- // Fallback: mention the image name in text if no URL available
212
- parts.push({ type: 'text', text: `[Image attached: ${img.name}]` });
213
- }
214
- }
215
-
216
- return { role, content: parts } as ModelMessage;
217
- },
218
- );
142
+ const messages = await buildChatModelMessages({
143
+ messages: recentMessages,
144
+ isAIUser,
145
+ defaultSenderLabel: 'Teacher',
146
+ });
219
147
 
220
148
  return { system, messages };
221
149
  };
@@ -30,6 +30,7 @@ import {
30
30
  answerWorksheetQuestion,
31
31
  cancelGrading,
32
32
  regradeQuestion,
33
+ regradeAll,
33
34
  gradeAnswer,
34
35
  addCommentToResponse,
35
36
  } from "../services/worksheet.js";
@@ -370,6 +371,16 @@ export const worksheetRouter = createTRPCRouter({
370
371
  regradeQuestion(input.worksheetResponseId, input.progressId, ctx.user!.id)
371
372
  ),
372
373
 
374
+ regradeAll: protectedProcedure
375
+ .input(
376
+ z.object({
377
+ worksheetResponseId: z.string(),
378
+ })
379
+ )
380
+ .mutation(({ ctx, input }) =>
381
+ regradeAll(input.worksheetResponseId, ctx.user!.id)
382
+ ),
383
+
373
384
  gradeAnswer: protectedProcedure
374
385
  .input(
375
386
  z.object({
@@ -237,7 +237,7 @@ export function labProposalTools(proposals: LabProposals): ToolSet {
237
237
 
238
238
  generate_document: {
239
239
  description:
240
- 'Generate a downloadable file and attach it to this message. You are NOT limited to PDF — pick the best format for the request (pptx for slides/presentations, xlsx/csv for data, docx for editable handouts, pdf for print-ready docs, png for diagrams, etc.). Always set format explicitly.',
240
+ 'Generate a downloadable file and attach it to this message. Pick the best format for the request — pptx for slides, xlsx/csv for data, docx for editable handouts, pdf for print-ready docs, png/jpeg for charts/diagrams/figures (NOT pdf for visuals). Use py/js/ts only when the teacher explicitly wants source code. Always set format explicitly.',
241
241
  inputSchema: jsonSchema({
242
242
  type: 'object',
243
243
  properties: {
@@ -245,12 +245,12 @@ export function labProposalTools(proposals: LabProposals): ToolSet {
245
245
  content: {
246
246
  type: 'string',
247
247
  description:
248
- 'Full body — markdown or plain text with headings, lists, tables, slide notes, etc.',
248
+ 'Full body. Structure by format: pptx — separate slides with "---" or "Slide N:" headers; xlsx/csv — markdown tables or row data; png/jpeg — describe the visual in detail (shapes, labels, arrows, data points, colors); pdf/docx — headings, lists, tables; code formats — complete runnable source.',
249
249
  },
250
250
  format: {
251
251
  type: 'string',
252
252
  description:
253
- 'Output file type. REQUIRED. pptx=slides/presentation, xlsx/csv=spreadsheet/data, docx=Word handout, pdf=printable doc, md/txt/html=text, png/jpeg/gif=image, py/js/ts=code.',
253
+ 'Output file type. REQUIRED. pptx=slides, xlsx/csv=spreadsheet, docx=Word, pdf=printable doc, png/jpeg/gif=chart/diagram/figure image, md/txt/html=text. py/js/ts/css/sh=source code only when requested.',
254
254
  enum: [
255
255
  'pdf', 'docx', 'doc', 'xlsx', 'pptx', 'csv', 'md', 'html', 'txt',
256
256
  'json', 'xml', 'tex', 'png', 'jpeg', 'jpg', 'gif', 'zip', 'tar',
@@ -159,13 +159,6 @@ export async function postToNewtonChat(
159
159
  ensureMessageHasContentOrAttachments(content, fileIds);
160
160
  const normalizedContent = content.trim();
161
161
 
162
- if (!normalizedContent) {
163
- throw new TRPCError({
164
- code: "BAD_REQUEST",
165
- message: "Newton chat messages must include text content",
166
- });
167
- }
168
-
169
162
  const result = await prisma.$transaction(async (tx) => {
170
163
  const message = await tx.message.create({
171
164
  data: {
@@ -6,7 +6,7 @@ import OpenAI from 'openai';
6
6
  import type { Response } from 'openai/resources/responses/responses.js';
7
7
  import { logger } from '../utils/logger.js';
8
8
 
9
- const DOCUMENT_GEN_MODEL = 'gpt-4o-mini';
9
+ const DOCUMENT_GEN_MODEL = 'gpt-5.5';
10
10
 
11
11
  /**
12
12
  * Extensions OpenAI Code Interpreter can read/write in its sandbox.
@@ -67,11 +67,11 @@ const FORMAT_MIME: Record<DocumentFormat, string> = {
67
67
  };
68
68
 
69
69
  const FORMAT_HINTS: Record<DocumentFormat, string> = {
70
- pdf: 'fpdf2 or reportlab — structured document with headings and body text',
71
- docx: 'python-docx — Word document with headings and paragraphs',
70
+ pdf: 'fpdf2 or reportlab — polished document with headings, body text, and tables',
71
+ docx: 'python-docx — Word document with heading styles, paragraphs, and lists',
72
72
  doc: 'python-docx saving as .doc if supported, otherwise .docx',
73
- xlsx: 'openpyxl or pandas — spreadsheet with labelled columns/rows',
74
- pptx: 'python-pptx — slides with title + bullet content per slide',
73
+ xlsx: 'openpyxl or pandas — spreadsheet with labelled columns, formulas where useful',
74
+ pptx: 'python-pptx — full slide deck: title slide, section slides, bullets, diagrams on slides',
75
75
  csv: 'pandas or csv module — tabular data with headers',
76
76
  md: 'write the markdown file directly',
77
77
  html: 'write a complete, styled HTML page',
@@ -79,19 +79,58 @@ const FORMAT_HINTS: Record<DocumentFormat, string> = {
79
79
  json: 'json.dumps with readable indentation',
80
80
  xml: 'well-formed XML document',
81
81
  tex: 'LaTeX source that compiles to the requested content',
82
- png: 'matplotlib or Pillow — chart, diagram, or visual',
83
- jpeg: 'matplotlib or Pillow — save as JPEG',
84
- jpg: 'matplotlib or Pillow — save as JPEG',
82
+ png: 'matplotlib, networkx, or Pillow — render the described chart/diagram as a finished image',
83
+ jpeg: 'matplotlib or Pillow — high-quality visual saved as JPEG',
84
+ jpg: 'matplotlib or Pillow — high-quality visual saved as JPEG',
85
85
  gif: 'matplotlib or Pillow — save as GIF',
86
86
  zip: 'zipfile — bundle multiple related files if needed',
87
87
  tar: 'tarfile — archive if multiple files are required',
88
- py: 'valid Python source code file',
89
- js: 'valid JavaScript source file',
90
- ts: 'valid TypeScript source file',
88
+ py: 'valid, runnable Python source with comments',
89
+ js: 'valid JavaScript source with comments',
90
+ ts: 'valid TypeScript source with comments',
91
91
  css: 'stylesheet file',
92
92
  sh: 'shell script file',
93
93
  };
94
94
 
95
+ /** Extra quality rules appended for formats that need more than a one-line library hint. */
96
+ const FORMAT_QUALITY_RULES: Partial<Record<DocumentFormat, string[]>> = {
97
+ png: [
98
+ 'Interpret the source content as a visual spec — draw it, do not dump text into the image.',
99
+ 'Charts: labelled axes, legend, title, readable font sizes, 150+ DPI.',
100
+ 'Diagrams/flowcharts: use matplotlib patches, networkx, or Pillow drawing — boxes, arrows, labels.',
101
+ 'Geometry/maps: accurate relative positions; label every important part.',
102
+ 'Use color meaningfully; avoid blank or placeholder images.',
103
+ ],
104
+ jpeg: [
105
+ 'Same visual quality rules as PNG — polished chart or diagram, not a screenshot of text.',
106
+ ],
107
+ jpg: [
108
+ 'Same visual quality rules as PNG — polished chart or diagram, not a screenshot of text.',
109
+ ],
110
+ gif: [
111
+ 'Render a clear educational visual; static GIF is fine unless animation is described.',
112
+ ],
113
+ pptx: [
114
+ 'Split slides on "---" lines or "Slide N:" / "# " headings in the source content.',
115
+ 'Title slide first, then content slides with concise bullets (not walls of text).',
116
+ 'If the content describes a diagram, draw it on a slide (shapes/arrows) or embed a generated chart.',
117
+ 'Use distinct slide layouts where appropriate (title, title+content).',
118
+ ],
119
+ xlsx: [
120
+ 'Parse markdown tables or tabular lists in the source into real rows/columns.',
121
+ 'Bold header row; auto-fit column widths where possible.',
122
+ ],
123
+ pdf: [
124
+ 'If the source describes a figure/chart, generate and embed it — do not leave a "[diagram]" placeholder.',
125
+ ],
126
+ docx: [
127
+ 'Use Heading 1/2 styles for section titles; bullets and numbered lists for enumerations.',
128
+ ],
129
+ html: [
130
+ 'Include embedded CSS for readable typography; use tables or simple layout for structured content.',
131
+ ],
132
+ };
133
+
95
134
  export interface GenerateDocumentInput {
96
135
  title: string;
97
136
  /** Markdown or plain-text source material for the output file. */
@@ -218,6 +257,7 @@ function safeBasename(title: string): string {
218
257
  }
219
258
 
220
259
  function buildDocumentGenInstructions(format: DocumentFormat): string {
260
+ const qualityRules = FORMAT_QUALITY_RULES[format] ?? [];
221
261
  return [
222
262
  'You are a document generator for K-12 and university teachers.',
223
263
  'When given a title, output format, and source content:',
@@ -225,14 +265,17 @@ function buildDocumentGenInstructions(format: DocumentFormat): string {
225
265
  `2. For this format use: ${FORMAT_HINTS[format]}.`,
226
266
  `3. You MUST output a ${format.toUpperCase()} file — not any other format.`,
227
267
  `4. The saved filename MUST end with .${extensionForFormat(format)}.`,
228
- '5. Structure content clearly — headings, sections, lists, tables/charts as appropriate.',
268
+ '5. Faithfully adapt ALL source content to the output medium — do not summarize away detail.',
229
269
  '6. Include the document title prominently where the format allows.',
230
270
  '7. Put ONLY the educational content in the file — no meta commentary.',
231
271
  '8. You MUST save the file to disk in the working directory before you finish.',
232
272
  ...(format !== 'pdf'
233
273
  ? [`9. Do NOT create a PDF when ${format.toUpperCase()} was requested.`]
234
274
  : []),
235
- ].join(' ');
275
+ ...(qualityRules.length > 0
276
+ ? ['', 'Format-specific quality:', ...qualityRules.map((r, i) => `${i + 1}. ${r}`)]
277
+ : []),
278
+ ].join('\n');
236
279
  }
237
280
 
238
281
  /** Generate a file via OpenAI Code Interpreter. */
@@ -260,8 +303,7 @@ export async function generateDocumentWithOpenAI(
260
303
  `Output filename (exact): ${filename}`,
261
304
  `Output format: ${ext}`,
262
305
  `Document title: ${input.title}`,
263
- 'Source content (markdown or plain text — adapt to the output format):',
264
- input.content,
306
+ ['Source content — adapt fully to the output medium (do not paste raw markdown into slides/images):', input.content].join('\n'),
265
307
  ].join('\n\n'),
266
308
  });
267
309
 
@@ -6,7 +6,7 @@ import { TRPCError } from "@trpc/server";
6
6
  import { JobStatus, Prisma, type WorksheetQuestionType } from "@prisma/client";
7
7
  import { prisma } from "../lib/prisma.js";
8
8
  import { getFileUrl } from "../lib/fileUpload.js";
9
- import { cancelGradePipeline, regradeWorksheetPipeline } from "../pipelines/gradeWorksheet.js";
9
+ import { cancelGradePipeline, regradeWorksheetPipeline, regradeAllPipeline } from "../pipelines/gradeWorksheet.js";
10
10
  import { enqueueRunJob } from "../workers/queues.js";
11
11
  import { logger } from "../utils/logger.js";
12
12
  import {
@@ -629,6 +629,25 @@ export async function regradeQuestion(
629
629
  return regradeWorksheetPipeline(worksheetResponseId, progressId);
630
630
  }
631
631
 
632
+ export async function regradeAll(
633
+ worksheetResponseId: string,
634
+ userId: string
635
+ ) {
636
+ const worksheetResponse = await findWorksheetResponseByIdForTeacher(
637
+ worksheetResponseId,
638
+ userId
639
+ );
640
+
641
+ if (!worksheetResponse) {
642
+ throw new TRPCError({
643
+ code: "FORBIDDEN",
644
+ message: "Only class teachers can regrade all worksheet answers",
645
+ });
646
+ }
647
+
648
+ await regradeAllPipeline(worksheetResponseId);
649
+ }
650
+
632
651
  export async function gradeAnswer(
633
652
  questionId: string,
634
653
  studentWorksheetResponseId: string,
@@ -64,7 +64,6 @@ export const aiNewtonChatWorker = createRunJobWorker({
64
64
 
65
65
  await generateAndSendNewtonResponse(
66
66
  newtonChat.id,
67
- triggeringMessage.content,
68
67
  conversationId,
69
68
  newtonChat.submission,
70
69
  aiMessageId,
@@ -50,7 +50,7 @@ describe('Newton Chat Router', () => {
50
50
  conversationId = tutorConversation.conversationId;
51
51
  });
52
52
 
53
- test('rejects attachment-only Newton chat posts', async () => {
53
+ test('allows attachment-only Newton chat posts', async () => {
54
54
  const [uploadTarget] = await user2Caller.message.getUploadUrls({
55
55
  conversationId,
56
56
  files: [
@@ -70,12 +70,13 @@ describe('Newton Chat Router', () => {
70
70
  },
71
71
  });
72
72
 
73
- await expect(
74
- user2Caller.newtonChat.postToNewtonChat({
75
- newtonChatId,
76
- content: ' ',
77
- fileIds: [uploadTarget!.id],
78
- }),
79
- ).rejects.toThrow();
73
+ const message = await user2Caller.newtonChat.postToNewtonChat({
74
+ newtonChatId,
75
+ content: ' ',
76
+ fileIds: [uploadTarget!.id],
77
+ });
78
+
79
+ expect(message.attachments).toHaveLength(1);
80
+ expect(message.attachments[0]?.name).toBe('newton-attachment-only.pdf');
80
81
  });
81
82
  });
@@ -0,0 +1,135 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import {
3
+ buildUserMessageContentWithAttachments,
4
+ loadChatAttachments,
5
+ } from '../../../src/lib/chatAttachmentPrompt.js';
6
+
7
+ const pdfBytes = new Uint8Array([0x25, 0x50, 0x44, 0x46]);
8
+ const docxText = 'Unit 3 lesson plan content';
9
+
10
+ vi.mock('officeparser', () => ({
11
+ OfficeParser: {
12
+ parseOffice: vi.fn(async () => ({
13
+ toText: () => docxText,
14
+ })),
15
+ },
16
+ }));
17
+
18
+ vi.mock('../../../src/lib/googleCloudStorage.js', () => ({
19
+ bucket: {
20
+ file: vi.fn((path: string) => ({
21
+ download: vi.fn(async () => {
22
+ if (path.includes('missing')) {
23
+ throw new Error('not found');
24
+ }
25
+ if (path.endsWith('.docx')) {
26
+ return [Buffer.from('fake-docx-bytes')];
27
+ }
28
+ return [Buffer.from(pdfBytes)];
29
+ }),
30
+ })),
31
+ },
32
+ }));
33
+
34
+ describe('buildUserMessageContentWithAttachments', () => {
35
+ it('returns plain text when there are no attachments', () => {
36
+ expect(buildUserMessageContentWithAttachments('Hello', [])).toBe('Hello');
37
+ });
38
+
39
+ it('includes PDFs as inline file parts with bytes', () => {
40
+ const content = buildUserMessageContentWithAttachments('Review this', [
41
+ {
42
+ id: '1',
43
+ name: 'essay.pdf',
44
+ type: 'application/pdf',
45
+ path: 'essay.pdf',
46
+ data: pdfBytes,
47
+ },
48
+ ]);
49
+
50
+ expect(content).toEqual([
51
+ { type: 'text', text: 'Review this' },
52
+ {
53
+ type: 'file',
54
+ data: pdfBytes,
55
+ mediaType: 'application/pdf',
56
+ filename: 'essay.pdf',
57
+ },
58
+ ]);
59
+ });
60
+
61
+ it('includes extracted docx text in the message body', () => {
62
+ const content = buildUserMessageContentWithAttachments('Summarize', [
63
+ {
64
+ id: '2',
65
+ name: 'plan.docx',
66
+ type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
67
+ path: 'plan.docx',
68
+ extractedText: docxText,
69
+ },
70
+ ]);
71
+
72
+ expect(content).toBe(
73
+ `Summarize\n\n[Content of plan.docx]:\n${docxText}`,
74
+ );
75
+ });
76
+
77
+ it('lists unsupported file types when extraction failed', () => {
78
+ const content = buildUserMessageContentWithAttachments('Here', [
79
+ {
80
+ id: '3',
81
+ name: 'clip.mp4',
82
+ type: 'video/mp4',
83
+ path: 'clip.mp4',
84
+ },
85
+ ]);
86
+
87
+ expect(content).toBe(
88
+ 'Here\n[Attached files (content not extracted): clip.mp4 (video/mp4)]',
89
+ );
90
+ });
91
+ });
92
+
93
+ describe('loadChatAttachments', () => {
94
+ it('loads bytes and extracts office document text', async () => {
95
+ const resolved = await loadChatAttachments([
96
+ {
97
+ id: '1',
98
+ name: 'plan.docx',
99
+ type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
100
+ path: 'uploads/plan.docx',
101
+ },
102
+ ]);
103
+
104
+ expect(resolved[0]?.data).toBeDefined();
105
+ expect(resolved[0]?.extractedText).toBe(docxText);
106
+ });
107
+
108
+ it('loads pdf bytes without extracted text', async () => {
109
+ const resolved = await loadChatAttachments([
110
+ {
111
+ id: '2',
112
+ name: 'scan.pdf',
113
+ type: 'application/pdf',
114
+ path: 'uploads/scan.pdf',
115
+ },
116
+ ]);
117
+
118
+ expect(resolved[0]?.data).toEqual(pdfBytes);
119
+ expect(resolved[0]?.extractedText).toBeUndefined();
120
+ });
121
+
122
+ it('skips download for video attachments', async () => {
123
+ const resolved = await loadChatAttachments([
124
+ {
125
+ id: '3',
126
+ name: 'clip.mp4',
127
+ type: 'video/mp4',
128
+ path: 'uploads/clip.mp4',
129
+ },
130
+ ]);
131
+
132
+ expect(resolved[0]?.data).toBeUndefined();
133
+ expect(resolved[0]?.extractedText).toBeUndefined();
134
+ });
135
+ });
@@ -30,4 +30,14 @@ describe('inferDocumentFormat', () => {
30
30
  it('defaults to pdf for generic handouts', () => {
31
31
  expect(inferDocumentFormat('Reading Guide', 'Chapter summary', 'pdf')).toBe('pdf');
32
32
  });
33
+
34
+ it('overrides pdf when content mentions a flowchart', () => {
35
+ expect(
36
+ inferDocumentFormat(
37
+ 'Photosynthesis Overview',
38
+ 'Include a flowchart of the light reactions.',
39
+ 'pdf',
40
+ ),
41
+ ).toBe('png');
42
+ });
33
43
  });
@@ -17,8 +17,8 @@ describe("buildLabChatAgentSystemPrompt", () => {
17
17
  });
18
18
 
19
19
  describe("buildLabChatAgentMessages", () => {
20
- test("folds the class context into the system prompt, not the message list", () => {
21
- const { system, messages } = buildLabChatAgentMessages({
20
+ test("folds the class context into the system prompt, not the message list", async () => {
21
+ const { system, messages } = await buildLabChatAgentMessages({
22
22
  context: "Topic context",
23
23
  classContext: "CLASS: Biology",
24
24
  recentMessages: [
@@ -41,8 +41,8 @@ describe("buildLabChatAgentMessages", () => {
41
41
  ]);
42
42
  });
43
43
 
44
- test("sorts timestamped history into chronological order before prompt assembly", () => {
45
- const { messages } = buildLabChatAgentMessages({
44
+ test("sorts timestamped history into chronological order before prompt assembly", async () => {
45
+ const { messages } = await buildLabChatAgentMessages({
46
46
  context: "Topic context",
47
47
  classContext: "CLASS: Biology",
48
48
  recentMessages: [
@@ -76,8 +76,8 @@ describe("buildLabChatAgentMessages", () => {
76
76
  });
77
77
  });
78
78
 
79
- test("keeps original order among messages without timestamps", () => {
80
- const { messages } = buildLabChatAgentMessages({
79
+ test("keeps original order among messages without timestamps", async () => {
80
+ const { messages } = await buildLabChatAgentMessages({
81
81
  context: "Topic context",
82
82
  classContext: "CLASS: Biology",
83
83
  recentMessages: [