Skip to content

Commit 848464e

Browse files
cursoragentjon-bell
andcommitted
Export error pin engagement
Co-authored-by: Jonathan Bell <jon@jonbell.net>
1 parent cfaa481 commit 848464e

2 files changed

Lines changed: 274 additions & 5 deletions

File tree

cli/commands/assessment/export.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -230,7 +230,7 @@ export async function exportHandler(args: ArgumentsCamelCase<ExportArgs>): Promi
230230
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
231231
const totals = await streamAssignmentToDir(args, salt, mode, dumpId, a.id as number, slug, dir);
232232
logger.info(
233-
` ${slug}: ${totals.submissions} submissions, ${totals.scores} scores, ${totals.grader_tests} tests, ${totals.hints} hints`
233+
` ${slug}: ${totals.submissions} submissions, ${totals.scores} scores, ${totals.grader_tests} tests, ${totals.hints} hints, ${totals.error_pin_engagement} error-pin engagement rows`
234234
);
235235
return totals;
236236
}),
@@ -248,7 +248,7 @@ export async function exportHandler(args: ArgumentsCamelCase<ExportArgs>): Promi
248248
writeJson(path.join(outputDir, "manifest.json"), enrichedManifest);
249249

250250
logger.success(
251-
`Done. ${assignments.length} assignment(s), ${grand.submissions} submissions, ${grand.scores} scores, ${grand.grader_tests} tests, ${grand.hints} hints, ${grand.gradebook_scores} gradebook cells in ${outputDir}`
251+
`Done. ${assignments.length} assignment(s), ${grand.submissions} submissions, ${grand.scores} scores, ${grand.grader_tests} tests, ${grand.hints} hints, ${grand.error_pin_engagement} error-pin engagement rows, ${grand.gradebook_scores} gradebook cells in ${outputDir}`
252252
);
253253
} catch (error) {
254254
handleError(error);
@@ -332,6 +332,7 @@ interface AssignmentTotals {
332332
scores: number;
333333
grader_tests: number;
334334
hints: number;
335+
error_pin_engagement: number;
335336
}
336337

337338
interface GradebookTotals {
@@ -383,7 +384,8 @@ async function streamAssignmentToDir(
383384
submission: [],
384385
score: [],
385386
grader_test: [],
386-
hint: []
387+
hint: [],
388+
error_pin_engagement: []
387389
};
388390
let manifest: Record<string, unknown> | null = null;
389391
let endRecord: Record<string, unknown> | null = null;
@@ -421,6 +423,7 @@ async function streamAssignmentToDir(
421423
assertExpectedCount(endRecord, "submissions", buckets.submission!.length);
422424
assertExpectedCount(endRecord, "grader_tests", buckets.grader_test!.length);
423425
assertExpectedCount(endRecord, "hints", buckets.hint!.length);
426+
assertExpectedCount(endRecord, "error_pin_engagement", buckets.error_pin_engagement!.length);
424427

425428
writeJson(path.join(dir, "manifest.json"), manifest);
426429
writeJson(path.join(dir, "rubric.json"), {
@@ -439,6 +442,7 @@ async function streamAssignmentToDir(
439442
writeJson(path.join(dir, "scores.json"), buckets.score);
440443
writeJson(path.join(dir, "tests.json"), buckets.grader_test);
441444
writeJson(path.join(dir, "hints.json"), buckets.hint);
445+
writeJson(path.join(dir, "error-pin-engagement.json"), buckets.error_pin_engagement);
442446

443447
const counts = (endRecord.counts as Record<string, number>) ?? {};
444448
return {
@@ -448,7 +452,8 @@ async function streamAssignmentToDir(
448452
submissions: counts.submissions ?? buckets.submission!.length,
449453
scores: counts.scores ?? buckets.score!.length,
450454
grader_tests: counts.grader_tests ?? buckets.grader_test!.length,
451-
hints: counts.hints ?? buckets.hint!.length
455+
hints: counts.hints ?? buckets.hint!.length,
456+
error_pin_engagement: counts.error_pin_engagement ?? buckets.error_pin_engagement!.length
452457
};
453458
}
454459

@@ -537,6 +542,7 @@ function aggregateTotals(
537542
scores: sum("scores"),
538543
grader_tests: sum("grader_tests"),
539544
hints: sum("hints"),
545+
error_pin_engagement: sum("error_pin_engagement"),
540546
gradebook_scores: gradebook.gradebook_scores
541547
};
542548
}

supabase/functions/cli/commands/assessment.ts

Lines changed: 264 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -480,6 +480,14 @@ async function handleAssignmentExport(ctx: MCPAuthContext, rawParams: Record<str
480480
testOutputMax
481481
);
482482
const hintCount = await streamHints(supabase, graderTestIds, mode, tokenizer, writer);
483+
const errorPinEngagementCount = await streamErrorPinEngagement(
484+
supabase,
485+
classData.id,
486+
submissionIds,
487+
mode,
488+
tokenizer,
489+
writer
490+
);
483491

484492
await writer.write({
485493
kind: "end",
@@ -490,7 +498,8 @@ async function handleAssignmentExport(ctx: MCPAuthContext, rawParams: Record<str
490498
submissions: submissionCount,
491499
scores: scoreCount,
492500
grader_tests: testCount,
493-
hints: hintCount
501+
hints: hintCount,
502+
error_pin_engagement: errorPinEngagementCount
494503
}
495504
});
496505
});
@@ -1119,6 +1128,260 @@ async function streamHints(
11191128
return total;
11201129
}
11211130

1131+
/**
1132+
* Student engagement with discussion posts pinned to errors on exported
1133+
* submissions. Emits one row per submission × pinned discussion post × student
1134+
* participant so group submissions retain per-student read/like state.
1135+
*/
1136+
async function streamErrorPinEngagement(
1137+
supabase: ReturnType<typeof getAdminClient>,
1138+
classId: number,
1139+
submissionIds: number[],
1140+
mode: IdentityMode,
1141+
tokenizer: Tokenizer | null,
1142+
writer: { write: (record: Record<string, unknown>) => Promise<void> }
1143+
): Promise<number> {
1144+
if (submissionIds.length === 0) return 0;
1145+
1146+
const participants = await loadSubmissionParticipants(supabase, classId, submissionIds);
1147+
const matches = await loadErrorPinMatches(supabase, submissionIds);
1148+
if (matches.length === 0) return 0;
1149+
1150+
const discussionThreadIds = unique(matches.map((m) => m.discussion_thread_id));
1151+
const profileIds = unique(Array.from(participants.values()).flatMap((profiles) => Array.from(profiles)));
1152+
const userIdByProfileId = await loadUserIdsByProfileId(supabase, classId, profileIds);
1153+
const readAtByUserAndThread = await loadReadStatusByUserAndThread(
1154+
supabase,
1155+
discussionThreadIds,
1156+
unique(Array.from(userIdByProfileId.values()))
1157+
);
1158+
const likedByProfileAndThread = await loadLikesByProfileAndThread(supabase, discussionThreadIds, profileIds);
1159+
1160+
let total = 0;
1161+
for (const match of matches) {
1162+
const submissionParticipants = participants.get(match.submission_id) ?? new Set<string>();
1163+
for (const profileId of submissionParticipants) {
1164+
const userId = userIdByProfileId.get(profileId) ?? null;
1165+
const submissionRef =
1166+
tokenizer === null
1167+
? { id: match.submission_id }
1168+
: { token: await tokenizer.token("submission", match.submission_id) };
1169+
const subjectRef =
1170+
tokenizer === null ? { id: profileId } : { token: await tokenizer.token("subject", profileId) };
1171+
const graderTestRef =
1172+
match.grader_result_test_id === null
1173+
? null
1174+
: tokenizer === null
1175+
? { id: match.grader_result_test_id }
1176+
: { token: await tokenizer.token("grader_test", match.grader_result_test_id) };
1177+
1178+
await writer.write({
1179+
kind: "error_pin_engagement",
1180+
submission: submissionRef,
1181+
subject: subjectRef,
1182+
discussion_thread_id: match.discussion_thread_id,
1183+
error_pin_id: match.error_pin_id,
1184+
grader_test: graderTestRef,
1185+
read_at:
1186+
userId === null ? null : (readAtByUserAndThread.get(compoundKey(userId, match.discussion_thread_id)) ?? null),
1187+
liked: likedByProfileAndThread.has(compoundKey(profileId, match.discussion_thread_id))
1188+
});
1189+
total += 1;
1190+
}
1191+
}
1192+
return total;
1193+
}
1194+
1195+
async function loadSubmissionParticipants(
1196+
supabase: ReturnType<typeof getAdminClient>,
1197+
classId: number,
1198+
submissionIds: number[]
1199+
): Promise<Map<number, Set<string>>> {
1200+
const participants = new Map<number, Set<string>>();
1201+
const groupIdsBySubmissionId = new Map<number, number>();
1202+
1203+
for (const batch of chunked(submissionIds, 500)) {
1204+
const { data, error } = await supabase
1205+
.from("submissions")
1206+
.select("id, profile_id, assignment_group_id")
1207+
.in("id", batch);
1208+
if (error) throw new CLICommandError(`Failed to load submission participants: ${error.message}`, 500);
1209+
1210+
for (const row of data ?? []) {
1211+
const profiles = participants.get(row.id) ?? new Set<string>();
1212+
if (row.profile_id !== null) profiles.add(row.profile_id);
1213+
if (row.assignment_group_id !== null) groupIdsBySubmissionId.set(row.id, row.assignment_group_id);
1214+
participants.set(row.id, profiles);
1215+
}
1216+
}
1217+
1218+
const groupIds = unique(Array.from(groupIdsBySubmissionId.values()));
1219+
const membersByGroupId = new Map<number, string[]>();
1220+
for (const batch of chunked(groupIds, 500)) {
1221+
const { data, error } = await supabase
1222+
.from("assignment_groups_members")
1223+
.select("assignment_group_id, profile_id")
1224+
.in("assignment_group_id", batch);
1225+
if (error) throw new CLICommandError(`Failed to load assignment group members: ${error.message}`, 500);
1226+
for (const row of data ?? []) {
1227+
const profiles = membersByGroupId.get(row.assignment_group_id) ?? [];
1228+
profiles.push(row.profile_id);
1229+
membersByGroupId.set(row.assignment_group_id, profiles);
1230+
}
1231+
}
1232+
1233+
for (const [submissionId, groupId] of groupIdsBySubmissionId.entries()) {
1234+
const profiles = participants.get(submissionId) ?? new Set<string>();
1235+
for (const profileId of membersByGroupId.get(groupId) ?? []) profiles.add(profileId);
1236+
participants.set(submissionId, profiles);
1237+
}
1238+
1239+
const profileIds = unique(Array.from(participants.values()).flatMap((profiles) => Array.from(profiles)));
1240+
const enrolledProfiles = new Set((await loadUserIdsByProfileId(supabase, classId, profileIds)).keys());
1241+
for (const [submissionId, profiles] of participants.entries()) {
1242+
participants.set(
1243+
submissionId,
1244+
new Set(Array.from(profiles).filter((profileId) => enrolledProfiles.has(profileId)))
1245+
);
1246+
}
1247+
1248+
return participants;
1249+
}
1250+
1251+
type ErrorPinMatchForExport = {
1252+
error_pin_id: number;
1253+
submission_id: number;
1254+
grader_result_test_id: number | null;
1255+
discussion_thread_id: number;
1256+
};
1257+
1258+
async function loadErrorPinMatches(
1259+
supabase: ReturnType<typeof getAdminClient>,
1260+
submissionIds: number[]
1261+
): Promise<ErrorPinMatchForExport[]> {
1262+
const rawMatches: Array<Omit<ErrorPinMatchForExport, "discussion_thread_id">> = [];
1263+
1264+
for (const batch of chunked(submissionIds, 500)) {
1265+
let cursor = 0;
1266+
while (true) {
1267+
const { data, error } = await supabase
1268+
.from("error_pin_submission_matches")
1269+
.select("id, error_pin_id, submission_id, grader_result_test_id")
1270+
.in("submission_id", batch)
1271+
.gt("id", cursor)
1272+
.order("id", { ascending: true })
1273+
.limit(FACT_PAGE_SIZE);
1274+
if (error) throw new CLICommandError(`Failed to load error pin matches: ${error.message}`, 500);
1275+
if (!data || data.length === 0) break;
1276+
1277+
for (const row of data) {
1278+
rawMatches.push({
1279+
error_pin_id: row.error_pin_id,
1280+
submission_id: row.submission_id,
1281+
grader_result_test_id: row.grader_result_test_id
1282+
});
1283+
}
1284+
1285+
if (data.length < FACT_PAGE_SIZE) break;
1286+
cursor = data[data.length - 1]!.id;
1287+
}
1288+
}
1289+
1290+
if (rawMatches.length === 0) return [];
1291+
1292+
const pinById = new Map<number, { discussion_thread_id: number }>();
1293+
for (const batch of chunked(unique(rawMatches.map((m) => m.error_pin_id)), 500)) {
1294+
const { data, error } = await supabase
1295+
.from("error_pins")
1296+
.select("id, discussion_thread_id")
1297+
.eq("enabled", true)
1298+
.in("id", batch);
1299+
if (error) throw new CLICommandError(`Failed to load error pins: ${error.message}`, 500);
1300+
for (const pin of data ?? []) {
1301+
pinById.set(pin.id, { discussion_thread_id: pin.discussion_thread_id });
1302+
}
1303+
}
1304+
1305+
return rawMatches.flatMap((match) => {
1306+
const pin = pinById.get(match.error_pin_id);
1307+
return pin ? [{ ...match, discussion_thread_id: pin.discussion_thread_id }] : [];
1308+
});
1309+
}
1310+
1311+
async function loadUserIdsByProfileId(
1312+
supabase: ReturnType<typeof getAdminClient>,
1313+
classId: number,
1314+
profileIds: string[]
1315+
): Promise<Map<string, string>> {
1316+
const userIdByProfileId = new Map<string, string>();
1317+
for (const batch of chunked(profileIds, 500)) {
1318+
const { data, error } = await supabase
1319+
.from("user_roles")
1320+
.select("private_profile_id, user_id")
1321+
.eq("class_id", classId)
1322+
.eq("role", "student")
1323+
.eq("disabled", false)
1324+
.in("private_profile_id", batch);
1325+
if (error) throw new CLICommandError(`Failed to load student user ids: ${error.message}`, 500);
1326+
for (const row of data ?? []) {
1327+
if (row.private_profile_id !== null) userIdByProfileId.set(row.private_profile_id, row.user_id);
1328+
}
1329+
}
1330+
return userIdByProfileId;
1331+
}
1332+
1333+
async function loadReadStatusByUserAndThread(
1334+
supabase: ReturnType<typeof getAdminClient>,
1335+
discussionThreadIds: number[],
1336+
userIds: string[]
1337+
): Promise<Map<string, string | null>> {
1338+
const readAtByUserAndThread = new Map<string, string | null>();
1339+
for (const threadBatch of chunked(discussionThreadIds, 200)) {
1340+
for (const userBatch of chunked(userIds, 200)) {
1341+
const { data, error } = await supabase
1342+
.from("discussion_thread_read_status")
1343+
.select("user_id, discussion_thread_id, read_at")
1344+
.in("discussion_thread_id", threadBatch)
1345+
.in("user_id", userBatch);
1346+
if (error) throw new CLICommandError(`Failed to load discussion read status: ${error.message}`, 500);
1347+
for (const row of data ?? []) {
1348+
readAtByUserAndThread.set(compoundKey(row.user_id, row.discussion_thread_id), row.read_at);
1349+
}
1350+
}
1351+
}
1352+
return readAtByUserAndThread;
1353+
}
1354+
1355+
async function loadLikesByProfileAndThread(
1356+
supabase: ReturnType<typeof getAdminClient>,
1357+
discussionThreadIds: number[],
1358+
profileIds: string[]
1359+
): Promise<Set<string>> {
1360+
const likedByProfileAndThread = new Set<string>();
1361+
for (const threadBatch of chunked(discussionThreadIds, 200)) {
1362+
for (const profileBatch of chunked(profileIds, 200)) {
1363+
const { data, error } = await supabase
1364+
.from("discussion_thread_likes")
1365+
.select("creator, discussion_thread")
1366+
.in("discussion_thread", threadBatch)
1367+
.in("creator", profileBatch);
1368+
if (error) throw new CLICommandError(`Failed to load discussion likes: ${error.message}`, 500);
1369+
for (const row of data ?? []) {
1370+
likedByProfileAndThread.add(compoundKey(row.creator, row.discussion_thread));
1371+
}
1372+
}
1373+
}
1374+
return likedByProfileAndThread;
1375+
}
1376+
1377+
function unique<T>(values: T[]): T[] {
1378+
return Array.from(new Set(values));
1379+
}
1380+
1381+
function compoundKey(left: string | number, right: string | number): string {
1382+
return `${left}:${right}`;
1383+
}
1384+
11221385
function* chunked<T>(arr: T[], size: number): Generator<T[]> {
11231386
for (let i = 0; i < arr.length; i += size) yield arr.slice(i, i + size);
11241387
}

0 commit comments

Comments
 (0)