@studious-lms/server 1.4.3 → 1.4.5
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/.vscode/settings.json +3 -0
- package/dist/index.js +4 -18
- package/dist/index.js.map +1 -1
- package/dist/models/conversation.d.ts +22 -0
- package/dist/models/conversation.d.ts.map +1 -1
- package/dist/models/conversation.js +13 -2
- package/dist/models/conversation.js.map +1 -1
- package/dist/models/labChat.d.ts +3 -0
- package/dist/models/labChat.d.ts.map +1 -1
- package/dist/models/message.d.ts +2 -0
- package/dist/models/message.d.ts.map +1 -1
- package/dist/models/newtonChat.d.ts +1 -0
- package/dist/models/newtonChat.d.ts.map +1 -1
- package/dist/models/worksheet.d.ts +1 -0
- package/dist/models/worksheet.d.ts.map +1 -1
- package/dist/models/worksheet.js +2 -2
- package/dist/models/worksheet.js.map +1 -1
- package/dist/pipelines/pdfToWorksheet.d.ts +12 -0
- package/dist/pipelines/pdfToWorksheet.d.ts.map +1 -0
- package/dist/pipelines/pdfToWorksheet.js +155 -0
- package/dist/pipelines/pdfToWorksheet.js.map +1 -0
- package/dist/routers/_app.d.ts +252 -0
- package/dist/routers/_app.d.ts.map +1 -1
- package/dist/routers/_app.js +4 -2
- package/dist/routers/_app.js.map +1 -1
- package/dist/routers/conversation.d.ts +14 -0
- package/dist/routers/conversation.d.ts.map +1 -1
- package/dist/routers/conversation.js +6 -3
- package/dist/routers/conversation.js.map +1 -1
- package/dist/routers/labChat.d.ts +2 -0
- package/dist/routers/labChat.d.ts.map +1 -1
- package/dist/routers/search.d.ts +41 -0
- package/dist/routers/search.d.ts.map +1 -0
- package/dist/routers/search.js +15 -0
- package/dist/routers/search.js.map +1 -0
- package/dist/routers/worksheet.d.ts +71 -0
- package/dist/routers/worksheet.d.ts.map +1 -1
- package/dist/routers/worksheet.js +110 -3
- package/dist/routers/worksheet.js.map +1 -1
- package/dist/services/conversation.d.ts +8 -0
- package/dist/services/conversation.d.ts.map +1 -1
- package/dist/services/conversation.js +24 -6
- package/dist/services/conversation.js.map +1 -1
- package/dist/services/labChat.d.ts +2 -0
- package/dist/services/labChat.d.ts.map +1 -1
- package/dist/services/message.d.ts.map +1 -1
- package/dist/services/message.js +9 -2
- package/dist/services/message.js.map +1 -1
- package/dist/services/search.d.ts +21 -0
- package/dist/services/search.d.ts.map +1 -0
- package/dist/services/search.js +352 -0
- package/dist/services/search.js.map +1 -0
- package/dist/services/worksheet.d.ts +21 -0
- package/dist/services/worksheet.d.ts.map +1 -1
- package/dist/services/worksheet.js +50 -2
- package/dist/services/worksheet.js.map +1 -1
- package/dist/utils/inference.d.ts +2 -1
- package/dist/utils/inference.d.ts.map +1 -1
- package/dist/utils/inference.js +53 -5
- package/dist/utils/inference.js.map +1 -1
- package/package.json +1 -1
- package/prisma/migrations/20260417083000_add_conversation_member_hidden_at/migration.sql +2 -0
- package/prisma/schema.prisma +1 -0
- package/src/index.ts +4 -19
- package/src/models/conversation.ts +12 -0
- package/src/models/worksheet.ts +1 -0
- package/src/pipelines/pdfToWorksheet.ts +177 -0
- package/src/routers/_app.ts +2 -0
- package/src/routers/conversation.ts +7 -0
- package/src/routers/search.ts +14 -0
- package/src/routers/worksheet.ts +143 -0
- package/src/services/conversation.ts +27 -3
- package/src/services/message.ts +8 -0
- package/src/services/search.ts +397 -0
- package/src/services/worksheet.ts +61 -0
- package/src/utils/inference.ts +77 -7
- package/tests/pipelines/pdfToWorksheet.test.ts +176 -0
- package/tests/routers/conversation.test.ts +114 -2
- package/tests/routers/search.test.ts +109 -0
- package/tests/utils/inference.test.ts +37 -0
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
import { prisma } from "../lib/prisma.js";
|
|
2
|
+
|
|
3
|
+
type SearchScope =
|
|
4
|
+
| "class"
|
|
5
|
+
| "assignment"
|
|
6
|
+
| "event"
|
|
7
|
+
| "syllabus"
|
|
8
|
+
| "worksheet"
|
|
9
|
+
| "file"
|
|
10
|
+
| "announcement"
|
|
11
|
+
| "section";
|
|
12
|
+
|
|
13
|
+
type SearchResult = {
|
|
14
|
+
id: string;
|
|
15
|
+
type: SearchScope;
|
|
16
|
+
title: string;
|
|
17
|
+
subtitle: string;
|
|
18
|
+
classId: string;
|
|
19
|
+
className: string;
|
|
20
|
+
href: string;
|
|
21
|
+
matchedText?: string | null;
|
|
22
|
+
date?: Date | null;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const SEARCH_LIMIT = 12;
|
|
26
|
+
|
|
27
|
+
const contains = (query: string) => ({
|
|
28
|
+
contains: query,
|
|
29
|
+
mode: "insensitive" as const,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const includesToken = (query: string, ...tokens: string[]) =>
|
|
33
|
+
tokens.some((token) => token.includes(query.toLowerCase()));
|
|
34
|
+
|
|
35
|
+
const excerpt = (value: string | null | undefined, query: string) => {
|
|
36
|
+
if (!value) return null;
|
|
37
|
+
const trimmed = value.replace(/\s+/g, " ").trim();
|
|
38
|
+
if (!trimmed) return null;
|
|
39
|
+
if (!query) return trimmed.slice(0, 140);
|
|
40
|
+
|
|
41
|
+
const index = trimmed.toLowerCase().indexOf(query.toLowerCase());
|
|
42
|
+
if (index < 0) return trimmed.slice(0, 140);
|
|
43
|
+
|
|
44
|
+
const start = Math.max(0, index - 48);
|
|
45
|
+
const end = Math.min(trimmed.length, index + query.length + 92);
|
|
46
|
+
return `${start > 0 ? "..." : ""}${trimmed.slice(start, end)}${end < trimmed.length ? "..." : ""}`;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const trimResults = (results: SearchResult[], limit: number) =>
|
|
50
|
+
results
|
|
51
|
+
.sort((a, b) => {
|
|
52
|
+
const priority: Record<SearchScope, number> = {
|
|
53
|
+
class: 0,
|
|
54
|
+
assignment: 1,
|
|
55
|
+
event: 2,
|
|
56
|
+
syllabus: 3,
|
|
57
|
+
worksheet: 4,
|
|
58
|
+
file: 5,
|
|
59
|
+
announcement: 6,
|
|
60
|
+
section: 7,
|
|
61
|
+
};
|
|
62
|
+
const priorityDiff = priority[a.type] - priority[b.type];
|
|
63
|
+
if (priorityDiff !== 0) return priorityDiff;
|
|
64
|
+
return (b.date?.getTime() ?? 0) - (a.date?.getTime() ?? 0);
|
|
65
|
+
})
|
|
66
|
+
.slice(0, limit);
|
|
67
|
+
|
|
68
|
+
export async function searchGlobalClasses(
|
|
69
|
+
userId: string,
|
|
70
|
+
input: { query?: string; limit?: number }
|
|
71
|
+
) {
|
|
72
|
+
const rawQuery = input.query?.trim() ?? "";
|
|
73
|
+
const query = rawQuery.slice(0, 80);
|
|
74
|
+
const limit = Math.min(Math.max(input.limit ?? SEARCH_LIMIT, 1), 30);
|
|
75
|
+
|
|
76
|
+
const memberships = await prisma.class.findMany({
|
|
77
|
+
where: {
|
|
78
|
+
OR: [
|
|
79
|
+
{ teachers: { some: { id: userId } } },
|
|
80
|
+
{ students: { some: { id: userId } } },
|
|
81
|
+
],
|
|
82
|
+
},
|
|
83
|
+
select: {
|
|
84
|
+
id: true,
|
|
85
|
+
name: true,
|
|
86
|
+
section: true,
|
|
87
|
+
subject: true,
|
|
88
|
+
syllabus: true,
|
|
89
|
+
teachers: { where: { id: userId }, select: { id: true } },
|
|
90
|
+
students: { where: { id: userId }, select: { id: true } },
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const allClassIds = memberships.map((cls) => cls.id);
|
|
95
|
+
if (allClassIds.length === 0) {
|
|
96
|
+
return { query, results: [] as SearchResult[] };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const teacherClassIds = memberships
|
|
100
|
+
.filter((cls) => cls.teachers.length > 0)
|
|
101
|
+
.map((cls) => cls.id);
|
|
102
|
+
const studentClassIds = memberships
|
|
103
|
+
.filter((cls) => cls.students.length > 0 && cls.teachers.length === 0)
|
|
104
|
+
.map((cls) => cls.id);
|
|
105
|
+
const classById = new Map(memberships.map((cls) => [cls.id, cls]));
|
|
106
|
+
|
|
107
|
+
const queryFilters = query
|
|
108
|
+
? {
|
|
109
|
+
class: [
|
|
110
|
+
{ name: contains(query) },
|
|
111
|
+
{ section: contains(query) },
|
|
112
|
+
{ subject: contains(query) },
|
|
113
|
+
],
|
|
114
|
+
assignment: [
|
|
115
|
+
{ title: contains(query) },
|
|
116
|
+
{ instructions: contains(query) },
|
|
117
|
+
],
|
|
118
|
+
event: [
|
|
119
|
+
{ name: contains(query) },
|
|
120
|
+
{ location: contains(query) },
|
|
121
|
+
{ remarks: contains(query) },
|
|
122
|
+
],
|
|
123
|
+
worksheet: [{ name: contains(query) }],
|
|
124
|
+
file: [{ name: contains(query) }, { type: contains(query) }],
|
|
125
|
+
announcement: [{ remarks: contains(query) }],
|
|
126
|
+
section: [{ name: contains(query) }],
|
|
127
|
+
}
|
|
128
|
+
: null;
|
|
129
|
+
|
|
130
|
+
const assignmentAccess = [
|
|
131
|
+
...(teacherClassIds.length > 0
|
|
132
|
+
? [{ classId: { in: teacherClassIds } }]
|
|
133
|
+
: []),
|
|
134
|
+
...(studentClassIds.length > 0
|
|
135
|
+
? [
|
|
136
|
+
{
|
|
137
|
+
classId: { in: studentClassIds },
|
|
138
|
+
OR: [
|
|
139
|
+
{ assignedTo: { some: { id: userId } } },
|
|
140
|
+
{ assignedTo: { none: {} } },
|
|
141
|
+
],
|
|
142
|
+
},
|
|
143
|
+
]
|
|
144
|
+
: []),
|
|
145
|
+
];
|
|
146
|
+
|
|
147
|
+
const [
|
|
148
|
+
classes,
|
|
149
|
+
assignments,
|
|
150
|
+
events,
|
|
151
|
+
worksheets,
|
|
152
|
+
files,
|
|
153
|
+
announcements,
|
|
154
|
+
sections,
|
|
155
|
+
] = await Promise.all([
|
|
156
|
+
prisma.class.findMany({
|
|
157
|
+
where: {
|
|
158
|
+
id: { in: allClassIds },
|
|
159
|
+
...(queryFilters ? { OR: queryFilters.class } : {}),
|
|
160
|
+
},
|
|
161
|
+
select: { id: true, name: true, section: true, subject: true },
|
|
162
|
+
take: limit,
|
|
163
|
+
}),
|
|
164
|
+
prisma.assignment.findMany({
|
|
165
|
+
where: {
|
|
166
|
+
template: false,
|
|
167
|
+
AND: [
|
|
168
|
+
...(queryFilters ? [{ OR: queryFilters.assignment }] : []),
|
|
169
|
+
...(assignmentAccess.length > 0 ? [{ OR: assignmentAccess }] : []),
|
|
170
|
+
],
|
|
171
|
+
},
|
|
172
|
+
select: {
|
|
173
|
+
id: true,
|
|
174
|
+
title: true,
|
|
175
|
+
instructions: true,
|
|
176
|
+
dueDate: true,
|
|
177
|
+
modifiedAt: true,
|
|
178
|
+
classId: true,
|
|
179
|
+
class: { select: { name: true } },
|
|
180
|
+
},
|
|
181
|
+
orderBy: { dueDate: "asc" },
|
|
182
|
+
take: limit,
|
|
183
|
+
}),
|
|
184
|
+
prisma.event.findMany({
|
|
185
|
+
where: {
|
|
186
|
+
classId: { in: allClassIds },
|
|
187
|
+
...(queryFilters ? { OR: queryFilters.event } : {}),
|
|
188
|
+
},
|
|
189
|
+
select: {
|
|
190
|
+
id: true,
|
|
191
|
+
name: true,
|
|
192
|
+
location: true,
|
|
193
|
+
remarks: true,
|
|
194
|
+
startTime: true,
|
|
195
|
+
classId: true,
|
|
196
|
+
class: { select: { name: true } },
|
|
197
|
+
},
|
|
198
|
+
orderBy: { startTime: "asc" },
|
|
199
|
+
take: limit,
|
|
200
|
+
}),
|
|
201
|
+
prisma.worksheet.findMany({
|
|
202
|
+
where: {
|
|
203
|
+
classId: { in: allClassIds },
|
|
204
|
+
...(queryFilters && !includesToken(query, "worksheet", "worksheets")
|
|
205
|
+
? { OR: queryFilters.worksheet }
|
|
206
|
+
: {}),
|
|
207
|
+
},
|
|
208
|
+
select: {
|
|
209
|
+
id: true,
|
|
210
|
+
name: true,
|
|
211
|
+
updatedAt: true,
|
|
212
|
+
classId: true,
|
|
213
|
+
class: { select: { name: true } },
|
|
214
|
+
},
|
|
215
|
+
orderBy: { updatedAt: "desc" },
|
|
216
|
+
take: limit,
|
|
217
|
+
}),
|
|
218
|
+
prisma.file.findMany({
|
|
219
|
+
where: {
|
|
220
|
+
...(queryFilters ? { OR: queryFilters.file } : {}),
|
|
221
|
+
AND: [
|
|
222
|
+
{
|
|
223
|
+
OR: [
|
|
224
|
+
{ folder: { classId: { in: allClassIds } } },
|
|
225
|
+
{ classDraftId: { in: teacherClassIds } },
|
|
226
|
+
{
|
|
227
|
+
assignment: {
|
|
228
|
+
OR: assignmentAccess,
|
|
229
|
+
},
|
|
230
|
+
},
|
|
231
|
+
{ announcement: { classId: { in: allClassIds } } },
|
|
232
|
+
],
|
|
233
|
+
},
|
|
234
|
+
],
|
|
235
|
+
},
|
|
236
|
+
select: {
|
|
237
|
+
id: true,
|
|
238
|
+
name: true,
|
|
239
|
+
type: true,
|
|
240
|
+
uploadedAt: true,
|
|
241
|
+
folder: { select: { classId: true, class: { select: { name: true } } } },
|
|
242
|
+
classDraft: { select: { id: true, name: true } },
|
|
243
|
+
assignment: { select: { classId: true, class: { select: { name: true } } } },
|
|
244
|
+
announcement: { select: { classId: true, class: { select: { name: true } } } },
|
|
245
|
+
},
|
|
246
|
+
orderBy: { uploadedAt: "desc" },
|
|
247
|
+
take: limit,
|
|
248
|
+
}),
|
|
249
|
+
prisma.announcement.findMany({
|
|
250
|
+
where: {
|
|
251
|
+
classId: { in: allClassIds },
|
|
252
|
+
...(queryFilters ? { OR: queryFilters.announcement } : {}),
|
|
253
|
+
},
|
|
254
|
+
select: {
|
|
255
|
+
id: true,
|
|
256
|
+
remarks: true,
|
|
257
|
+
createdAt: true,
|
|
258
|
+
classId: true,
|
|
259
|
+
class: { select: { name: true } },
|
|
260
|
+
},
|
|
261
|
+
orderBy: { createdAt: "desc" },
|
|
262
|
+
take: limit,
|
|
263
|
+
}),
|
|
264
|
+
prisma.section.findMany({
|
|
265
|
+
where: {
|
|
266
|
+
classId: { in: allClassIds },
|
|
267
|
+
...(queryFilters && !includesToken(query, "section", "sections", "assignment", "assignments")
|
|
268
|
+
? { OR: queryFilters.section }
|
|
269
|
+
: {}),
|
|
270
|
+
},
|
|
271
|
+
select: {
|
|
272
|
+
id: true,
|
|
273
|
+
name: true,
|
|
274
|
+
classId: true,
|
|
275
|
+
class: { select: { name: true } },
|
|
276
|
+
},
|
|
277
|
+
take: limit,
|
|
278
|
+
}),
|
|
279
|
+
]);
|
|
280
|
+
|
|
281
|
+
const results: SearchResult[] = [
|
|
282
|
+
...classes.map((cls) => ({
|
|
283
|
+
id: cls.id,
|
|
284
|
+
type: "class" as const,
|
|
285
|
+
title: cls.name,
|
|
286
|
+
subtitle: [cls.subject, cls.section].filter(Boolean).join(" · "),
|
|
287
|
+
classId: cls.id,
|
|
288
|
+
className: cls.name,
|
|
289
|
+
href: `/class/${cls.id}`,
|
|
290
|
+
})),
|
|
291
|
+
...assignments.map((assignment) => ({
|
|
292
|
+
id: assignment.id,
|
|
293
|
+
type: "assignment" as const,
|
|
294
|
+
title: assignment.title,
|
|
295
|
+
subtitle: `${assignment.class.name} · Assignment`,
|
|
296
|
+
classId: assignment.classId,
|
|
297
|
+
className: assignment.class.name,
|
|
298
|
+
href: `/class/${assignment.classId}/assignment/${assignment.id}`,
|
|
299
|
+
matchedText: excerpt(assignment.instructions, query),
|
|
300
|
+
date: assignment.dueDate ?? assignment.modifiedAt,
|
|
301
|
+
})),
|
|
302
|
+
...events.map((event) => ({
|
|
303
|
+
id: event.id,
|
|
304
|
+
type: "event" as const,
|
|
305
|
+
title: event.name || "Untitled event",
|
|
306
|
+
subtitle: `${event.class?.name ?? "Class"} · Event`,
|
|
307
|
+
classId: event.classId!,
|
|
308
|
+
className: event.class?.name ?? "Class",
|
|
309
|
+
href: `/class/${event.classId}/attendance/${event.id}`,
|
|
310
|
+
matchedText: excerpt(event.location || event.remarks, query),
|
|
311
|
+
date: event.startTime,
|
|
312
|
+
})),
|
|
313
|
+
...memberships
|
|
314
|
+
.filter((cls) => {
|
|
315
|
+
if (!cls.syllabus) return false;
|
|
316
|
+
if (!query) return true;
|
|
317
|
+
return (
|
|
318
|
+
includesToken(query, "syllabus") ||
|
|
319
|
+
cls.name.toLowerCase().includes(query.toLowerCase()) ||
|
|
320
|
+
cls.subject.toLowerCase().includes(query.toLowerCase()) ||
|
|
321
|
+
cls.syllabus.toLowerCase().includes(query.toLowerCase())
|
|
322
|
+
);
|
|
323
|
+
})
|
|
324
|
+
.slice(0, limit)
|
|
325
|
+
.map((cls) => ({
|
|
326
|
+
id: `${cls.id}:syllabus`,
|
|
327
|
+
type: "syllabus" as const,
|
|
328
|
+
title: `${cls.name} syllabus`,
|
|
329
|
+
subtitle: `${cls.subject} · Syllabus`,
|
|
330
|
+
classId: cls.id,
|
|
331
|
+
className: cls.name,
|
|
332
|
+
href: `/class/${cls.id}/syllabus`,
|
|
333
|
+
matchedText: excerpt(cls.syllabus, query),
|
|
334
|
+
})),
|
|
335
|
+
...worksheets.map((worksheet) => ({
|
|
336
|
+
id: worksheet.id,
|
|
337
|
+
type: "worksheet" as const,
|
|
338
|
+
title: worksheet.name,
|
|
339
|
+
subtitle: `${worksheet.class?.name ?? "Class"} · Worksheet`,
|
|
340
|
+
classId: worksheet.classId,
|
|
341
|
+
className: worksheet.class?.name ?? "Class",
|
|
342
|
+
href: `/class/${worksheet.classId}/worksheets`,
|
|
343
|
+
date: worksheet.updatedAt,
|
|
344
|
+
})),
|
|
345
|
+
...files.flatMap((file) => {
|
|
346
|
+
const classId =
|
|
347
|
+
file.folder?.classId ??
|
|
348
|
+
file.classDraft?.id ??
|
|
349
|
+
file.assignment?.classId ??
|
|
350
|
+
file.announcement?.classId;
|
|
351
|
+
const className =
|
|
352
|
+
file.folder?.class?.name ??
|
|
353
|
+
file.classDraft?.name ??
|
|
354
|
+
file.assignment?.class?.name ??
|
|
355
|
+
file.announcement?.class?.name ??
|
|
356
|
+
(classId ? classById.get(classId)?.name : undefined);
|
|
357
|
+
|
|
358
|
+
if (!classId || !className) return [];
|
|
359
|
+
|
|
360
|
+
return [
|
|
361
|
+
{
|
|
362
|
+
id: file.id,
|
|
363
|
+
type: "file" as const,
|
|
364
|
+
title: file.name,
|
|
365
|
+
subtitle: `${className} · File`,
|
|
366
|
+
classId,
|
|
367
|
+
className,
|
|
368
|
+
href: `/class/${classId}/files`,
|
|
369
|
+
matchedText: file.type,
|
|
370
|
+
date: file.uploadedAt,
|
|
371
|
+
},
|
|
372
|
+
];
|
|
373
|
+
}),
|
|
374
|
+
...announcements.map((announcement) => ({
|
|
375
|
+
id: announcement.id,
|
|
376
|
+
type: "announcement" as const,
|
|
377
|
+
title: excerpt(announcement.remarks, query) ?? "Announcement",
|
|
378
|
+
subtitle: `${announcement.class.name} · Announcement`,
|
|
379
|
+
classId: announcement.classId,
|
|
380
|
+
className: announcement.class.name,
|
|
381
|
+
href: `/class/${announcement.classId}`,
|
|
382
|
+
matchedText: excerpt(announcement.remarks, query),
|
|
383
|
+
date: announcement.createdAt,
|
|
384
|
+
})),
|
|
385
|
+
...sections.map((section) => ({
|
|
386
|
+
id: section.id,
|
|
387
|
+
type: "section" as const,
|
|
388
|
+
title: section.name,
|
|
389
|
+
subtitle: `${section.class.name} · Section`,
|
|
390
|
+
classId: section.classId,
|
|
391
|
+
className: section.class.name,
|
|
392
|
+
href: `/class/${section.classId}/assignments`,
|
|
393
|
+
})),
|
|
394
|
+
];
|
|
395
|
+
|
|
396
|
+
return { query, results: trimResults(results, limit) };
|
|
397
|
+
}
|
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
import { TRPCError } from "@trpc/server";
|
|
6
6
|
import { GenerationStatus, type WorksheetQuestionType } from "@prisma/client";
|
|
7
7
|
import { prisma } from "../lib/prisma.js";
|
|
8
|
+
import { getFileUrl } from "../lib/fileUpload.js";
|
|
8
9
|
import { cancelGradePipeline, regradeWorksheetPipeline } from "../pipelines/gradeWorksheet.js";
|
|
10
|
+
import { pdfToWorksheet } from "../pipelines/pdfToWorksheet.js";
|
|
9
11
|
import {
|
|
10
12
|
findWorksheetByIdMinimal,
|
|
11
13
|
findWorksheetByIdForClassMember,
|
|
@@ -121,6 +123,65 @@ export async function createWorksheetRecord(classId: string, name: string) {
|
|
|
121
123
|
return createWorksheet({ classId, name });
|
|
122
124
|
}
|
|
123
125
|
|
|
126
|
+
/**
|
|
127
|
+
* Create a worksheet by converting a PDF (URL) into structured questions.
|
|
128
|
+
* Uses the `pdfToWorksheet` pipeline to do inference + persistence.
|
|
129
|
+
*/
|
|
130
|
+
export async function createWorksheetFromPdf(
|
|
131
|
+
classId: string,
|
|
132
|
+
url: string
|
|
133
|
+
) {
|
|
134
|
+
const created = await pdfToWorksheet(url, classId);
|
|
135
|
+
if (!created) {
|
|
136
|
+
throw new TRPCError({
|
|
137
|
+
code: "INTERNAL_SERVER_ERROR",
|
|
138
|
+
message: "Failed to create worksheet from PDF",
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
return created;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Create a worksheet from a PDF that was uploaded through the direct-upload flow.
|
|
146
|
+
*/
|
|
147
|
+
export async function createWorksheetFromUploadedPdf(
|
|
148
|
+
classId: string,
|
|
149
|
+
fileId: string,
|
|
150
|
+
userId: string
|
|
151
|
+
) {
|
|
152
|
+
const file = await prisma.file.findFirst({
|
|
153
|
+
where: {
|
|
154
|
+
id: fileId,
|
|
155
|
+
userId,
|
|
156
|
+
uploadStatus: "COMPLETED",
|
|
157
|
+
},
|
|
158
|
+
select: {
|
|
159
|
+
id: true,
|
|
160
|
+
path: true,
|
|
161
|
+
type: true,
|
|
162
|
+
},
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
if (!file) {
|
|
166
|
+
throw new TRPCError({
|
|
167
|
+
code: "NOT_FOUND",
|
|
168
|
+
message: "Uploaded file not found or not ready",
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const isPdf =
|
|
173
|
+
file.type === "application/pdf" || file.path.toLowerCase().endsWith(".pdf");
|
|
174
|
+
if (!isPdf) {
|
|
175
|
+
throw new TRPCError({
|
|
176
|
+
code: "BAD_REQUEST",
|
|
177
|
+
message: "Only PDF files can be converted into worksheets",
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const signedUrl = await getFileUrl(file.path);
|
|
182
|
+
return createWorksheetFromPdf(classId, signedUrl);
|
|
183
|
+
}
|
|
184
|
+
|
|
124
185
|
export async function addQuestionToWorksheet(
|
|
125
186
|
worksheetId: string,
|
|
126
187
|
userId: string,
|
package/src/utils/inference.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* Inference util – OpenAI client, inference() for structured output, sendAIMessage for chat.
|
|
3
3
|
*/
|
|
4
4
|
import OpenAI from 'openai';
|
|
5
|
+
import type { ResponseInput } from "openai/resources/responses/responses.js";
|
|
5
6
|
import { logger } from './logger.js';
|
|
6
7
|
import { prisma } from '../lib/prisma.js';
|
|
7
8
|
import { chatChannel, pusher } from '../lib/pusher.js';
|
|
@@ -275,25 +276,93 @@ export async function sendAIMessage(
|
|
|
275
276
|
};
|
|
276
277
|
}
|
|
277
278
|
|
|
279
|
+
/** Responses API user parts use `type: "input_*"`; chat completions use `text` / `image_url` / etc. */
|
|
280
|
+
function isResponseInputWithInputParts(
|
|
281
|
+
content: OpenAI.Chat.Completions.ChatCompletionMessageParam[] | ResponseInput
|
|
282
|
+
): content is ResponseInput {
|
|
283
|
+
if (!Array.isArray(content)) return false;
|
|
284
|
+
for (const msg of content) {
|
|
285
|
+
if (!msg || typeof msg !== "object" || !("content" in msg)) continue;
|
|
286
|
+
const parts = (msg as { content?: unknown }).content;
|
|
287
|
+
if (!Array.isArray(parts)) continue;
|
|
288
|
+
for (const part of parts) {
|
|
289
|
+
if (
|
|
290
|
+
part &&
|
|
291
|
+
typeof part === "object" &&
|
|
292
|
+
"type" in part &&
|
|
293
|
+
typeof (part as { type: string }).type === "string" &&
|
|
294
|
+
(part as { type: string }).type.startsWith("input_")
|
|
295
|
+
) {
|
|
296
|
+
return true;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
return false;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function buildResponsesParseInput(
|
|
304
|
+
content: string | OpenAI.Chat.Completions.ChatCompletionMessageParam[] | ResponseInput
|
|
305
|
+
): ResponseInput {
|
|
306
|
+
if (typeof content === "string") {
|
|
307
|
+
return [{ role: "user", content: [{ type: "input_text", text: content }] }];
|
|
308
|
+
}
|
|
309
|
+
if (isResponseInputWithInputParts(content)) {
|
|
310
|
+
return content;
|
|
311
|
+
}
|
|
312
|
+
return content as unknown as ResponseInput;
|
|
313
|
+
}
|
|
314
|
+
|
|
278
315
|
export async function inference<T>(
|
|
279
|
-
content:
|
|
316
|
+
content:
|
|
317
|
+
| string
|
|
318
|
+
| OpenAI.Chat.Completions.ChatCompletionMessageParam[]
|
|
319
|
+
| ResponseInput,
|
|
280
320
|
format?: ZodSchema,
|
|
281
321
|
options?: InferenceOptions,
|
|
282
322
|
): Promise<T> {
|
|
283
|
-
const input = normalizeInferenceInput(content);
|
|
284
323
|
const reason = getInferenceReason(options);
|
|
285
324
|
const model = options?.model ?? DEFAULT_INFERENCE_MODEL;
|
|
286
|
-
const estimatedInputTokens =
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
325
|
+
const estimatedInputTokens =
|
|
326
|
+
typeof content === 'string'
|
|
327
|
+
? estimateTokenCount(content)
|
|
328
|
+
: isResponseInputWithInputParts(content)
|
|
329
|
+
? estimateTokenCount(
|
|
330
|
+
content
|
|
331
|
+
.flatMap((item) =>
|
|
332
|
+
typeof (item as any)?.content === 'string'
|
|
333
|
+
? [(item as any).content]
|
|
334
|
+
: Array.isArray((item as any)?.content)
|
|
335
|
+
? (item as any).content
|
|
336
|
+
.filter((p: any) => p?.type === 'input_text')
|
|
337
|
+
.map((p: any) => p?.text ?? '')
|
|
338
|
+
: [],
|
|
339
|
+
)
|
|
340
|
+
.join('\n'),
|
|
341
|
+
)
|
|
342
|
+
: (content as OpenAI.Chat.Completions.ChatCompletionMessageParam[]).reduce(
|
|
343
|
+
(total, message) =>
|
|
344
|
+
total +
|
|
345
|
+
estimateTokenCount(
|
|
346
|
+
typeof message.content === 'string' ? message.content : '',
|
|
347
|
+
),
|
|
348
|
+
0,
|
|
349
|
+
);
|
|
290
350
|
const operation = format ? 'responses.parse' : 'chat.completions';
|
|
291
351
|
|
|
292
352
|
try {
|
|
293
353
|
if (!format) {
|
|
354
|
+
if (typeof content !== 'string' && isResponseInputWithInputParts(content)) {
|
|
355
|
+
throw new Error(
|
|
356
|
+
'normalizeInferenceInput cannot handle ResponseInput content with input_* parts when format is undefined; pass a format to use responses.parse.',
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const messages = normalizeInferenceInput(
|
|
361
|
+
content as string | OpenAI.Chat.Completions.ChatCompletionMessageParam[],
|
|
362
|
+
);
|
|
294
363
|
const completion = await openAIClient.chat.completions.create({
|
|
295
364
|
model,
|
|
296
|
-
messages
|
|
365
|
+
messages,
|
|
297
366
|
...(options?.temperature !== undefined
|
|
298
367
|
? { temperature: options.temperature }
|
|
299
368
|
: {}),
|
|
@@ -315,6 +384,7 @@ export async function inference<T>(
|
|
|
315
384
|
return completion.choices[0]?.message?.content as T;
|
|
316
385
|
}
|
|
317
386
|
|
|
387
|
+
const input = buildResponsesParseInput(content);
|
|
318
388
|
const completion = await openAIClient.responses.parse({
|
|
319
389
|
model,
|
|
320
390
|
input,
|