Skip to content

Grading#4

Open
LLOKAI wants to merge 3 commits into
mainfrom
grading
Open

Grading#4
LLOKAI wants to merge 3 commits into
mainfrom
grading

Conversation

@LLOKAI

@LLOKAI LLOKAI commented Feb 19, 2026

Copy link
Copy Markdown
Owner

No description provided.

- Add Form Builder menu actions:
  - Sync Draft Grades to Classroom
  - Finalize & Return Grades to Classroom
- Track last posted Classroom assignment per user for targeted grade sync
- Parse latest scores from Form Responses sheet and map by student email
- Patch StudentSubmissions with draftGrade or draft+assignedGrade via Classroom API
- Optionally return TURNED_IN submissions during finalize flow
- Add required Classroom roster/profile email OAuth scopes in appsscript.json
- Restrict quiz generation to MCQ type for fully auto-gradable flow
- Keep image-enabled answer mode and question image support
- Add Classroom grade sync actions (draft + finalize/return)
- Harden image fetch validation and dialog HTML escaping
- Update sample/data docs to reflect MCQ-only behavior
@LLOKAI LLOKAI self-assigned this Feb 19, 2026
Copilot AI review requested due to automatic review settings February 19, 2026 16:03
@LLOKAI LLOKAI added the enhancement New feature or request label Feb 19, 2026

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds grade-sync/auto-grading functionality between generated Google Forms quizzes and Google Classroom, while narrowing quiz generation to MCQ-only and updating samples/docs accordingly.

Changes:

  • Add Classroom grade sync workflow (“Finalize & Return Grades to Classroom”) and installable form-submit auto-grading trigger.
  • Restrict quiz generation to MCQ type only (remove SA/PARA/MSQ generation paths) and update sample question set/docs.
  • Extend Apps Script OAuth scopes to support new Classroom roster/email + trigger capabilities.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 8 comments.

Show a summary per file
File Description
src/appsscript.json Adds scopes needed for ScriptApp triggers and Classroom roster/email lookups.
src/Code.js Implements auto-grade trigger + manual grade sync to Classroom; restricts quiz building to MCQ-only; stores Classroom targeting metadata.
sample/math_assignment1_questions.csv Updates sample questions to match MCQ-only support.
sample/Questions.sheet-format.md Updates sheet-format documentation to describe MCQ-only behavior.
README.md Updates feature list to reflect MCQ-only support.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/Code.js
Comment on lines +830 to +835
formState[email] = {
score: score,
syncedAt: new Date().toISOString(),
submissionId: submission.id,
};
state[formId] = formState;

Copilot AI Feb 19, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AUTO_GRADE_STATE is keyed by (and stores) student email addresses in Script Properties. That’s persistent PII stored in project-wide properties and accessible to all script editors. If you only need a stable identifier for “first/latest” tracking, prefer storing by submission.userId (available on the Classroom submission) and avoid persisting emails where possible.

Copilot uses AI. Check for mistakes.
Comment thread src/Code.js
Comment on lines +1100 to +1102
const candidateSheet = ss
.getSheets()
.find((sheet) => /^form responses/i.test(sheet.getName()));

Copilot AI Feb 19, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getLatestResponseScoresByEmail picks the first sheet whose name starts with “Form Responses”. If the spreadsheet has multiple response tabs (common after generating/posting multiple forms), this can sync grades from the wrong form. Prefer selecting the most recent matching responses sheet (e.g., last match) or deriving the correct responses sheet based on the stored form/assignment metadata.

Suggested change
const candidateSheet = ss
.getSheets()
.find((sheet) => /^form responses/i.test(sheet.getName()));
const responseSheets = ss
.getSheets()
.filter((sheet) => /^form responses/i.test(sheet.getName()));
const candidateSheet =
responseSheets.length > 0 ? responseSheets[responseSheets.length - 1] : null;

Copilot uses AI. Check for mistakes.
Comment thread src/Code.js
Comment on lines +1101 to +1130
.getSheets()
.find((sheet) => /^form responses/i.test(sheet.getName()));
if (!candidateSheet) {
throw new Error("Response sheet not found (expected tab starting with 'Form Responses').");
}

const values = candidateSheet.getDataRange().getDisplayValues();
if (!values || values.length < 2) {
throw new Error("Response sheet has no data rows.");
}

const header = values[0].map((value) => String(value || "").trim());
const emailIndex = header.findIndex((name) => /email/i.test(name));
const scoreIndex = header.findIndex((name) => /score/i.test(name));

if (emailIndex === -1 || scoreIndex === -1) {
throw new Error(
"Response sheet must include Email and Score columns. Open the linked quiz form and ensure scoring is enabled.",
);
}

const byEmail = {};
for (let rowIndex = 1; rowIndex < values.length; rowIndex++) {
const row = values[rowIndex];
const email = String(row[emailIndex] || "").toLowerCase().trim();
if (!email) continue;

const parsedScore = parseScoreCell(row[scoreIndex]);
if (parsedScore == null) continue;
byEmail[email] = parsedScore;

Copilot AI Feb 19, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This function reads the entire response sheet via getDataRange().getDisplayValues(), which can be slow and memory-heavy on large classes/quizzes. Consider reading only the header row first to locate the Email/Score columns, then fetching just those two columns for the populated rows.

Suggested change
.getSheets()
.find((sheet) => /^form responses/i.test(sheet.getName()));
if (!candidateSheet) {
throw new Error("Response sheet not found (expected tab starting with 'Form Responses').");
}
const values = candidateSheet.getDataRange().getDisplayValues();
if (!values || values.length < 2) {
throw new Error("Response sheet has no data rows.");
}
const header = values[0].map((value) => String(value || "").trim());
const emailIndex = header.findIndex((name) => /email/i.test(name));
const scoreIndex = header.findIndex((name) => /score/i.test(name));
if (emailIndex === -1 || scoreIndex === -1) {
throw new Error(
"Response sheet must include Email and Score columns. Open the linked quiz form and ensure scoring is enabled.",
);
}
const byEmail = {};
for (let rowIndex = 1; rowIndex < values.length; rowIndex++) {
const row = values[rowIndex];
const email = String(row[emailIndex] || "").toLowerCase().trim();
if (!email) continue;
const parsedScore = parseScoreCell(row[scoreIndex]);
if (parsedScore == null) continue;
byEmail[email] = parsedScore;
.getSheets()
.find((sheet) => /^form responses/i.test(sheet.getName()));
if (!candidateSheet) {
throw new Error("Response sheet not found (expected tab starting with 'Form Responses').");
}
const lastRow = candidateSheet.getLastRow();
const lastColumn = candidateSheet.getLastColumn();
if (lastRow < 1 || lastColumn < 1) {
throw new Error("Response sheet has no data rows.");
}
// Read only the header row to locate Email and Score columns.
const headerRowValues = candidateSheet
.getRange(1, 1, 1, lastColumn)
.getDisplayValues()[0];
const header = headerRowValues.map((value) => String(value || "").trim());
const emailIndex = header.findIndex((name) => /email/i.test(name));
const scoreIndex = header.findIndex((name) => /score/i.test(name));
if (emailIndex === -1 || scoreIndex === -1) {
throw new Error(
"Response sheet must include Email and Score columns. Open the linked quiz form and ensure scoring is enabled.",
);
}
// Require at least one data row below the header.
if (lastRow < 2) {
throw new Error("Response sheet has no data rows.");
}
const emailColumn = emailIndex + 1;
const scoreColumn = scoreIndex + 1;
const dataRowCount = lastRow - 1;
// Read only the Email and Score columns for populated rows.
const emailValues = candidateSheet
.getRange(2, emailColumn, dataRowCount, 1)
.getDisplayValues();
const scoreValues = candidateSheet
.getRange(2, scoreColumn, dataRowCount, 1)
.getDisplayValues();
const byEmail = {};
for (let offset = 0; offset < dataRowCount; offset++) {
const rawEmail = emailValues[offset][0];
const email = String(rawEmail || "").toLowerCase().trim();
if (!email) continue;
const rawScore = scoreValues[offset][0];
const parsedScore = parseScoreCell(rawScore);
if (parsedScore == null) continue;
byEmail[email] = parsedScore;

Copilot uses AI. Check for mistakes.
Comment thread src/Code.js

ui.alert(
actionLabel,
`Done.\n\nPatched grades: ${patched}\nReturned submissions: ${returned}\nSkipped (no email mapping): ${skippedNoEmail}\nSkipped (no score): ${skippedNoScore}\nSkipped (state not TURNED_IN): ${skippedState}`,

Copilot AI Feb 19, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The alert label says “Skipped (state not TURNED_IN)”, but skippedState only increments when the state is neither TURNED_IN nor RETURNED. Consider adjusting the message to reflect the actual condition (e.g., “not TURNED_IN or already RETURNED”) to avoid confusion when interpreting results.

Suggested change
`Done.\n\nPatched grades: ${patched}\nReturned submissions: ${returned}\nSkipped (no email mapping): ${skippedNoEmail}\nSkipped (no score): ${skippedNoScore}\nSkipped (state not TURNED_IN): ${skippedState}`,
`Done.\n\nPatched grades: ${patched}\nReturned submissions: ${returned}\nSkipped (no email mapping): ${skippedNoEmail}\nSkipped (no score): ${skippedNoScore}\nSkipped (state neither TURNED_IN nor RETURNED): ${skippedState}`,

Copilot uses AI. Check for mistakes.
Comment thread README.md
- Supports **MCQ** question type (one correct answer)
- **Image support**
- `ImageURL` embeds a question image
- `AnswerAImageURL..AnswerDImageURL` enables image-choice mode for MCQ/MSQ (labeled A-D images + selectable A-D choices)

Copilot AI Feb 19, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

README still mentions image-choice mode for “MCQ/MSQ”, but MSQ support has been removed in this PR. Update this bullet to refer to MCQ-only so the feature list matches current capabilities.

Suggested change
- `AnswerAImageURL..AnswerDImageURL` enables image-choice mode for MCQ/MSQ (labeled A-D images + selectable A-D choices)
- `AnswerAImageURL..AnswerDImageURL` enables image-choice mode for MCQ (labeled A-D images + selectable A-D choices)

Copilot uses AI. Check for mistakes.
| **AnswerA..D** | ✅ | Used for `MCQ`/`MSQ`. Leave blanks if not needed (e.g., for `SA`, `PARA`). |
| **AnswerA..D** | ✅ | Used for `MCQ`. Provide at least 2 options. |
| **ImageURL** | ❌ | Optional. A public URL to an image. If provided, an image is inserted before the question in the form. |
| **AnswerAImageURL..AnswerDImageURL** | ❌ | Optional. For MCQ/MSQ, each URL displays an image item labeled A..D before the question. |

Copilot AI Feb 19, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This table row still says answer-image URLs are optional “For MCQ/MSQ”, but MSQ is no longer supported. Adjust the wording to MCQ-only to keep the sheet-format docs consistent with the code.

Suggested change
| **AnswerAImageURL..AnswerDImageURL** || Optional. For MCQ/MSQ, each URL displays an image item labeled A..D before the question. |
| **AnswerAImageURL..AnswerDImageURL** || Optional. For MCQ, each URL displays an image item labeled A..D before the question. |

Copilot uses AI. Check for mistakes.
Comment thread src/Code.js
Comment on lines +770 to +784
function ensureAutoGradeTriggerForForm(formId) {
const triggers = ScriptApp.getProjectTriggers();
const hasTrigger = triggers.some(
(trigger) =>
trigger.getHandlerFunction() === "handleAutoGradeFormSubmit" &&
trigger.getTriggerSource() === ScriptApp.TriggerSource.FORMS &&
trigger.getTriggerSourceId() === formId,
);
if (hasTrigger) return;

ScriptApp.newTrigger("handleAutoGradeFormSubmit")
.forForm(formId)
.onFormSubmit()
.create();
}

Copilot AI Feb 19, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Auto-grading trigger creates one installable onFormSubmit trigger per form but never removes triggers for old/deleted forms. Over time this can hit Apps Script trigger quotas and leave orphan triggers firing unnecessarily. Consider adding cleanup (e.g., remove triggers for forms no longer in AUTO_GRADE_TARGETS) or reusing a single trigger strategy if possible.

Copilot uses AI. Check for mistakes.
Comment thread src/Code.js
Comment on lines +803 to +809
const attemptMode = target.attemptMode || "latest";
const state = readAutoGradeState();
const formState = state[formId] || {};

if (attemptMode === "first" && formState[email]) {
return;
}

Copilot AI Feb 19, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

handleAutoGradeFormSubmit does a read-modify-write on AUTO_GRADE_STATE without any locking. If multiple submissions arrive close together, concurrent trigger executions can overwrite each other and break the “first attempt” logic (or lose sync history). Use LockService.getScriptLock() (or similar) around the state update to make it atomic.

Copilot uses AI. Check for mistakes.
@LLOKAI

LLOKAI commented Feb 19, 2026

Copy link
Copy Markdown
Owner Author

@copilot open a new pull request to apply changes based on the comments in this thread

Copilot AI commented Feb 19, 2026

Copy link
Copy Markdown

@LLOKAI I've opened a new pull request, #5, to work on those changes. Once the pull request is ready, I'll request review from you.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants