Google Classroom LLM Feedback Automation - Implementation Spec
Overview
Automated workflow that:
- Receives student code submissions via Google Form
- Sends code to LLM for analysis
- Injects LLM feedback into student’s Google Classroom assignment document
Architecture Diagram
┌─────────────────────────────────────────────────────────────────┐
│ STUDENT ACTIONS │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 1. Submit Code via Form 2. View Feedback in Classroom │
│ (Google Form) (Feedback Assignment) │
│ │ ▲ │
│ │ │ │
└───────────┼────────────────────────────────────┼─────────────────┘
│ │
▼ │
┌─────────────────────────────────────────────────────────────────┐
│ AUTOMATED PROCESSING │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Google Sheet ──▶ Apps Script ──▶ LLM API │
│ (Form Response) (onFormSubmit) (Get Feedback) │
│ │ │ │
│ ▼ ▼ │
│ Get Student ID Format Response │
│ │ │ │
│ ▼ │ │
│ Query Classroom API │ │
│ (Find Feedback Doc ID) │ │
│ │ │ │
│ └────────┬───────┘ │
│ ▼ │
│ Edit Google Doc │
│ (Inject LLM Feedback) │
│ │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ GOOGLE CLASSROOM SETUP │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Assignment 1: Quiz/Code Challenge (optional - can be external) │
│ Assignment 2: Feedback Docs ★ "Make a copy for each student" │
│ │
│ Course Roster → Email-to-StudentID Mapping (generated once) │
│ │
└─────────────────────────────────────────────────────────────────┘
Key Points:
- Two Assignments: Code submission (Form) ≠ Feedback delivery (Classroom)
- Mapping: Email (from Form) → Student ID → Feedback Doc ID (from Classroom)
- One-Time Setup: Generate Email→StudentID mapping from course roster
- Runtime: For each submission, query Classroom API for student’s doc
Quick Start Guide
For Teachers - First Time Setup:
-
Create Configuration Sheets (in the same workbook as Form responses)
a. Settings Sheet:
- Create sheet named “Settings”
- A1: Paste Course ID from Classroom URL
- A3: Paste Feedback Assignment ID
- (A2 will be auto-filled by test connection)
b. AssignmentConfig Sheet:
- Create sheet named “AssignmentConfig”
- Headers: Question #, Question Text, Teacher Solution, Notes/Rubric
- Fill in your questions and expected solutions
- Number of rows must match NUM_QUESTIONS constant
c. StudentMapping Sheet:
- Will be auto-created by “Sync Student Mapping” function
- Create Google Classroom Feedback Assignment
- Create a NEW assignment (separate from any quiz/form)
- Use a blank Google Doc template
- Set to “Make a copy for each student” ✓
- This creates the feedback docs for LLM output
- Students don’t submit to this - it’s just for receiving feedback
- Copy Assignment ID from URL and paste into Settings sheet (A3)
- Create Google Form (for code submissions)
- Enable “Collect email addresses”
- Add long-answer fields for each question (match NUM_QUESTIONS)
- Link to Google Sheet
- This can be attached to a different assignment or external
- Open Apps Script (from the linked Google Sheet)
- Extensions → Apps Script
- Enable: Classroom API, Drive API (under Services)
- Add OAuth scopes to
appsscript.json - Set NUM_QUESTIONS and COL_FIRST_ANSWER constants
- Configure LLM (via script menu)
- Classroom Feedback → Configure LLMs
- Add your LLM (OpenAI, Anthropic, etc.)
- Enter API key securely
- Test Classroom Connection
- Classroom Feedback → Classroom Setup → Test Connection
- Verifies Course ID and shows course info
- Populates Settings sheet A2
- Sync Student Mapping
- Classroom Feedback → Classroom Setup → Sync Student Mapping
- Creates StudentMapping sheet automatically
- Fetches all students from Classroom
- Test Configuration
- Classroom Feedback → Test Configuration
- Verify all settings are correct
- Enable Form Trigger
- Set up
onFormSubmittrigger - Now ready to process submissions!
- Set up
Key Architecture:
- Settings Sheet → Course ID, Assignment ID (manually entered)
- AssignmentConfig Sheet → Teacher’s questions and solutions
- StudentMapping Sheet → Auto-synced from Classroom roster
- Form → Students submit code → Triggers script
- Feedback Assignment → Script writes LLM feedback → Students view in Classroom
Configuration Constants
Sheet Column Indices (1-based)
// Form response columns
const COL_TIMESTAMP = 1; // Form submission timestamp
const COL_EMAIL = 2; // Student email (auto-collected by Form)
// Answer columns (configurable based on number of questions)
const COL_FIRST_ANSWER = 3; // First answer column (adjust if needed)
const NUM_QUESTIONS = 4; // Number of questions in the quiz (2-4 typical)
// System columns (placed after all answer columns)
// Calculated: COL_FIRST_ANSWER + NUM_QUESTIONS = start of system columns
const COL_DEBUG_LOG = COL_FIRST_ANSWER + NUM_QUESTIONS; // Script execution log/errors
const COL_FEEDBACK_STATUS = COL_FIRST_ANSWER + NUM_QUESTIONS + 1; // Status: "Processing", "Complete", "Error"
const COL_RAW_LLM_RESPONSE = COL_FIRST_ANSWER + NUM_QUESTIONS + 2; // Raw LLM output (backup if doc editing fails)
// Helper function to get answer columns
function getAnswerColumns() {
const cols = [];
for (let i = 0; i < NUM_QUESTIONS; i++) {
cols.push(COL_FIRST_ANSWER + i);
}
return cols;
}
Example Layout with 4 Questions:
| A: Timestamp | B: Email | C: Q1 | D: Q2 | E: Q3 | F: Q4 | G: Debug | H: Status | I: Raw LLM |
Teacher Configuration Sheet
Sheet Name: "AssignmentConfig" (in the same workbook)
Layout:
| A: Question # | B: Question Text | C: Teacher Solution | D: Notes/Rubric |
|--------------|-----------------------------------|----------------------------|-----------------|
| 1 | Write a function to sort... | [Teacher's solution code] | Focus on... |
| 2 | Debug this code snippet... | [Fixed version] | Common error... |
| 3 | Explain why this outputs... | [Expected explanation] | Key concept... |
| 4 | Optimize the following... | [Optimized solution] | Mention O(n)... |
Configuration Sheet Constants
const CONFIG_SHEET_NAME = 'AssignmentConfig';
const CONFIG_COL_QUESTION_NUM = 1;
const CONFIG_COL_QUESTION_TEXT = 2;
const CONFIG_COL_SOLUTION = 3;
const CONFIG_COL_NOTES = 4;
Helper Functions
Base64 Course ID Decoding
/**
* Decodes a base64-encoded Classroom course ID from URL
* Classroom URLs may contain base64-encoded IDs like "course/abc123"
* This function extracts and decodes them to the numeric course ID
*
* @param {string} courseIdInput - Course ID from URL (may be base64 or numeric)
* @returns {string} Decoded numeric course ID
*/
function decodeCourseId(courseIdInput) {
// If already numeric, return as-is
if (/^\d+$/.test(courseIdInput)) {
return courseIdInput;
}
// USER WILL PROVIDE: Base64 decoding function
// This is a placeholder - actual implementation will be provided
try {
// Example: If URL has format like "d:course_abc123"
// Extract and decode the base64 portion
const decoded = Utilities.base64Decode(courseIdInput);
const decodedString = Utilities.newBlob(decoded).getDataAsString();
// Extract numeric ID from decoded string
const match = decodedString.match(/\d+/);
return match ? match[0] : courseIdInput;
} catch (e) {
// If decoding fails, return original (might already be numeric)
return courseIdInput;
}
}
Settings Sheet Functions
/**
* Gets the course ID from Settings sheet
* @returns {string} Course ID
*/
function getCourseId() {
const ss = SpreadsheetApp.getActiveSpreadsheet();
const settingsSheet = ss.getSheetByName(SETTINGS_SHEET_NAME);
if (!settingsSheet) {
throw new Error(`"${SETTINGS_SHEET_NAME}" sheet not found`);
}
const courseIdRaw = settingsSheet.getRange(SETTINGS_COURSE_ID_CELL).getValue();
if (!courseIdRaw) {
throw new Error('Course ID not set in Settings sheet (A1)');
}
return decodeCourseId(courseIdRaw.toString().trim());
}
/**
* Gets the feedback assignment ID from Settings sheet
* @returns {string} Assignment ID
*/
function getFeedbackAssignmentId() {
const ss = SpreadsheetApp.getActiveSpreadsheet();
const settingsSheet = ss.getSheetByName(SETTINGS_SHEET_NAME);
if (!settingsSheet) {
throw new Error(`"${SETTINGS_SHEET_NAME}" sheet not found`);
}
const assignmentId = settingsSheet.getRange(SETTINGS_FEEDBACK_ASSIGNMENT_ID_CELL).getValue();
if (!assignmentId) {
throw new Error('Feedback Assignment ID not set in Settings sheet (A3)');
}
return assignmentId.toString().trim();
}
/**
* Sets course info in Settings sheet (called by test connection)
* @param {string} courseInfo - Course information to display
*/
function setCourseInfo(courseInfo) {
const ss = SpreadsheetApp.getActiveSpreadsheet();
const settingsSheet = ss.getSheetByName(SETTINGS_SHEET_NAME);
if (settingsSheet) {
settingsSheet.getRange(SETTINGS_COURSE_INFO_CELL).setValue(courseInfo);
}
}
/**
* Test connection to Classroom and populate course info
* This is the "sanity check" function
*/
function testClassroomConnection() {
const ui = SpreadsheetApp.getUi();
try {
const courseId = getCourseId();
// Try to fetch course info
const course = Classroom.Courses.get(courseId);
// Get student count
const students = Classroom.Courses.Students.list(courseId).students || [];
// Format course info
const courseInfo = `${course.name} (${students.length} students)`;
// Write to Settings sheet
setCourseInfo(courseInfo);
ui.alert(
'Connection Successful',
`Connected to course:\n\n${courseInfo}\n\nCourse ID: ${courseId}`,
ui.ButtonSet.OK
);
return true;
} catch (error) {
setCourseInfo('ERROR: ' + error.toString());
ui.alert(
'Connection Failed',
`Could not connect to Classroom:\n\n${error.toString()}\n\nPlease check:\n- Course ID in A1\n- Classroom API is enabled\n- You have teacher access`,
ui.ButtonSet.OK
);
return false;
}
}
Student Mapping Sheet Functions
/**
* Gets student ID from email using StudentMapping sheet
* @param {string} email - Student email address
* @returns {string|null} Student ID or null if not found
*/
function getStudentIdFromEmail(email) {
const ss = SpreadsheetApp.getActiveSpreadsheet();
const mappingSheet = ss.getSheetByName(STUDENT_MAPPING_SHEET_NAME);
if (!mappingSheet) {
throw new Error(`"${STUDENT_MAPPING_SHEET_NAME}" sheet not found`);
}
const lastRow = mappingSheet.getLastRow();
if (lastRow < 2) {
throw new Error('No student mappings found. Please run "Sync Student Mapping"');
}
// Get all data (assuming headers in row 1)
const data = mappingSheet.getRange(2, 1, lastRow - 1, 2).getValues();
// Find matching email
for (let i = 0; i < data.length; i++) {
const [rowEmail, studentId] = data[i];
if (rowEmail && rowEmail.toString().toLowerCase() === email.toLowerCase()) {
return studentId.toString();
}
}
return null;
}
/**
* Syncs student mapping from Classroom to StudentMapping sheet
* Reads all enrolled students and populates Email → Student ID mapping
*/
function syncStudentMappingFromClassroom() {
const ui = SpreadsheetApp.getUi();
try {
const courseId = getCourseId();
// Get all students enrolled in the course
const students = Classroom.Courses.Students.list(courseId).students || [];
if (students.length === 0) {
ui.alert('No Students', 'No students found in this course.', ui.ButtonSet.OK);
return;
}
// Get or create StudentMapping sheet
const ss = SpreadsheetApp.getActiveSpreadsheet();
let mappingSheet = ss.getSheetByName(STUDENT_MAPPING_SHEET_NAME);
if (!mappingSheet) {
mappingSheet = ss.insertSheet(STUDENT_MAPPING_SHEET_NAME);
}
// Clear existing data
mappingSheet.clear();
// Set headers
mappingSheet.getRange(1, MAPPING_COL_EMAIL).setValue('Email');
mappingSheet.getRange(1, MAPPING_COL_STUDENT_ID).setValue('Student ID');
// Format headers
mappingSheet.getRange(1, 1, 1, 2)
.setFontWeight('bold')
.setBackground('#4285f4')
.setFontColor('#ffffff');
// Populate data
const mappingData = [];
let errorCount = 0;
students.forEach(student => {
try {
const profile = student.profile;
const email = profile.emailAddress;
const studentId = student.userId;
if (email && studentId) {
mappingData.push([email, studentId]);
} else {
errorCount++;
}
} catch (error) {
errorCount++;
}
});
// Write all data at once (faster than row-by-row)
if (mappingData.length > 0) {
mappingSheet.getRange(2, 1, mappingData.length, 2).setValues(mappingData);
}
// Auto-resize columns
mappingSheet.autoResizeColumns(1, 2);
// Show results
let message = `Successfully synced ${mappingData.length} students from Classroom.`;
if (errorCount > 0) {
message += `\n\n${errorCount} students had missing data and were skipped.`;
}
Logger.log(`Student mapping synced: ${mappingData.length} students`);
ui.alert('Sync Complete', message, ui.ButtonSet.OK);
} catch (error) {
ui.alert('Sync Failed', 'Failed to sync student mapping:\n\n' + error.toString(), ui.ButtonSet.OK);
}
}
/**
* Views current student mapping in a dialog
*/
function viewStudentMapping() {
const ui = SpreadsheetApp.getUi();
const ss = SpreadsheetApp.getActiveSpreadsheet();
const mappingSheet = ss.getSheetByName(STUDENT_MAPPING_SHEET_NAME);
if (!mappingSheet) {
ui.alert('Student Mapping', 'No mapping sheet found. Please run "Sync Student Mapping" first.', ui.ButtonSet.OK);
return;
}
const lastRow = mappingSheet.getLastRow();
if (lastRow < 2) {
ui.alert('Student Mapping', 'No students in mapping. Please run "Sync Student Mapping".', ui.ButtonSet.OK);
return;
}
const data = mappingSheet.getRange(2, 1, lastRow - 1, 2).getValues();
let message = 'Current Student Mapping (Email → Student ID):\n\n';
data.forEach(([email, studentId]) => {
if (email) {
message += `${email}\n → ${studentId}\n\n`;
}
});
ui.alert('Student Mapping', message, ui.ButtonSet.OK);
}
Reading Teacher Questions and Solutions
/**
* Reads teacher's questions and solutions from the AssignmentConfig sheet
* @returns {Array<Object>} Array of question objects
* Example: [
* { num: 1, question: "Write a function...", solution: "def sort()...", notes: "Focus on..." },
* { num: 2, question: "Debug this...", solution: "fixed code", notes: "Common error..." }
* ]
*/
function getTeacherQuestions() {
try {
const ss = SpreadsheetApp.getActiveSpreadsheet();
const configSheet = ss.getSheetByName(CONFIG_SHEET_NAME);
if (!configSheet) {
throw new Error(`Sheet "${CONFIG_SHEET_NAME}" not found. Please create it with question configurations.`);
}
// Get all data from config sheet (assuming headers in row 1)
const lastRow = configSheet.getLastRow();
if (lastRow < 2) {
throw new Error(`No questions found in "${CONFIG_SHEET_NAME}" sheet`);
}
const data = configSheet.getRange(2, 1, lastRow - 1, 4).getValues();
const questions = [];
data.forEach(row => {
const [num, question, solution, notes] = row;
// Skip empty rows
if (!question) return;
questions.push({
num: num || questions.length + 1,
question: question.toString().trim(),
solution: solution ? solution.toString().trim() : '',
notes: notes ? notes.toString().trim() : ''
});
});
if (questions.length === 0) {
throw new Error('No valid questions found in config sheet');
}
return questions;
} catch (error) {
Logger.log('Error reading teacher questions: ' + error.toString());
throw error;
}
}
/**
* Gets a specific question's configuration
* @param {number} questionNum - The question number (1-based)
* @returns {Object} Question object or null if not found
*/
function getQuestionConfig(questionNum) {
const questions = getTeacherQuestions();
return questions.find(q => q.num === questionNum) || null;
}
/**
* Validates that the number of questions in config matches NUM_QUESTIONS constant
* @returns {boolean} True if valid, throws error if not
*/
function validateQuestionConfig() {
const questions = getTeacherQuestions();
if (questions.length !== NUM_QUESTIONS) {
throw new Error(
`Configuration mismatch: NUM_QUESTIONS is ${NUM_QUESTIONS} but ` +
`found ${questions.length} questions in ${CONFIG_SHEET_NAME} sheet`
);
}
return true;
}
Extracting Student Answers
/**
* Extracts all student answers from the form submission row
* @param {Sheet} sheet - The active sheet
* @param {number} row - The row number
* @returns {Array<string>} Array of student answers
*/
function getStudentAnswers(sheet, row) {
const answers = [];
for (let i = 0; i < NUM_QUESTIONS; i++) {
const col = COL_FIRST_ANSWER + i;
const answer = sheet.getRange(row, col).getValue();
answers.push(answer ? answer.toString().trim() : '');
}
return answers;
}
/**
* Formats questions and answers for LLM prompt
* @param {Array<Object>} questions - Teacher questions from config
* @param {Array<string>} studentAnswers - Student's submitted answers
* @returns {string} Formatted text for LLM
*/
function formatQuestionsForLLM(questions, studentAnswers) {
let formatted = 'ASSIGNMENT QUESTIONS AND STUDENT ANSWERS:\n\n';
questions.forEach((q, index) => {
formatted += `=== QUESTION ${q.num} ===\n`;
formatted += `${q.question}\n\n`;
if (q.solution) {
formatted += `EXPECTED SOLUTION:\n${q.solution}\n\n`;
}
if (q.notes) {
formatted += `GRADING NOTES: ${q.notes}\n\n`;
}
formatted += `STUDENT'S ANSWER:\n${studentAnswers[index] || '[No answer provided]'}\n\n`;
formatted += '---\n\n';
});
return formatted;
}
Configuration Storage
Configuration is split between Script Properties (API keys) and Sheets (mappings & settings):
Script Properties (Secure - API Keys Only)
LLM_CONFIG: JSON string with LLM configurations{ "openai": { "name": "OpenAI GPT-4", "endpoint": "https://api.openai.com/v1/chat/completions", "model": "gpt-4", "active": true }, "anthropic": { "name": "Anthropic Claude", "endpoint": "https://api.anthropic.com/v1/messages", "model": "claude-3-sonnet-20240229", "active": false } }LLM_KEY_<keyName>: Separate properties for each LLM’s API keyLLM_KEY_openai= “sk-…”LLM_KEY_anthropic= “sk-ant-…”
Sheet: “Settings”
| Cell | Content | Example | Notes |
|---|---|---|---|
| A1 | Course ID | 123456789 |
Paste from Classroom URL (base64 encoded ID will be decoded) |
| A2 | Course Info | “CS101: Intro to Programming (25 students)” | Auto-filled by test connection function |
| A3 | Feedback Assignment ID | 987654321 |
The assignment with “Make a copy for each student” |
Sheet: “StudentMapping”
| Column A | Column B |
|---|---|
| Student ID | |
| student1@school.edu | 101303941676390353935 |
| student2@school.edu | 102445678901234567890 |
| student3@school.edu | 103556789012345678901 |
This sheet is auto-populated by syncStudentMappingFromClassroom() function.
Property Name Constants
// Script Properties (API keys only)
const PROP_LLM_CONFIG = 'LLM_CONFIG';
const PROP_LLM_KEY_PREFIX = 'LLM_KEY_';
// Sheet Names
const SETTINGS_SHEET_NAME = 'Settings';
const STUDENT_MAPPING_SHEET_NAME = 'StudentMapping';
const CONFIG_SHEET_NAME = 'AssignmentConfig';
// Settings Sheet Cell References
const SETTINGS_COURSE_ID_CELL = 'A1';
const SETTINGS_COURSE_INFO_CELL = 'A2';
const SETTINGS_FEEDBACK_ASSIGNMENT_ID_CELL = 'A3';
// StudentMapping Sheet Columns
const MAPPING_COL_EMAIL = 1;
const MAPPING_COL_STUDENT_ID = 2;
// AssignmentConfig Sheet Columns
const CONFIG_COL_QUESTION_NUM = 1;
const CONFIG_COL_QUESTION_TEXT = 2;
const CONFIG_COL_SOLUTION = 3;
const CONFIG_COL_NOTES = 4;
Prerequisites & Setup
1. Google Classroom Feedback Assignment (CRITICAL)
This is a SEPARATE assignment from the form/quiz:
- Create a new assignment with a blank Google Doc as material
- Set to “Make a copy for each student” (this is the key setting!)
- This auto-generates a unique Google Doc for each enrolled student
- These docs are where LLM feedback will be written
- Students don’t need to submit anything to this assignment - it’s just for feedback delivery
2. Google Form (for Code Submission)
- Enable “Collect email addresses” setting (critical for student identification)
- Include long-answer question for code submission
- Link to Google Sheet for responses
- This can be linked to a different Classroom assignment, or external
3. Google Sheet
- Linked to Form as response destination
- Contains columns as defined in constants above
- Apps Script project attached to this sheet
- This is where the script runs from
4. Apps Script Services
Enable in Apps Script project:
- Google Classroom API
- Google Drive API (for document editing)
5. OAuth Scopes
Add to appsscript.json:
{
"oauthScopes": [
"https://www.googleapis.com/auth/classroom.courses.readonly",
"https://www.googleapis.com/auth/classroom.coursework.students",
"https://www.googleapis.com/auth/classroom.rosters.readonly",
"https://www.googleapis.com/auth/documents",
"https://www.googleapis.com/auth/drive",
"https://www.googleapis.com/auth/spreadsheets.currentonly",
"https://www.googleapis.com/auth/script.storage"
]
}
Scope Descriptions:
classroom.courses.readonly: Read course informationclassroom.coursework.students: Access student submissions and courseworkclassroom.rosters.readonly: Access student roster (for email → Student ID mapping)documents: Edit Google Docs (inject feedback)drive: Access Drive filesspreadsheets.currentonly: Read form responses from the sheetscript.storage: Store configuration in Script Properties
Core Workflow - onFormSubmit(e) Trigger
Step 1: Extract Form Data
// Triggered when new form submission arrives
function onFormSubmit(e) {
const row = e.range.getRow();
const sheet = e.range.getSheet();
// Update status
sheet.getRange(row, COL_FEEDBACK_STATUS).setValue("Processing");
sheet.getRange(row, COL_DEBUG_LOG).setValue("Started: " + new Date());
try {
// Validate configuration first
validateQuestionConfig();
// Get student email
const studentEmail = sheet.getRange(row, COL_EMAIL).getValue();
if (!studentEmail) {
throw new Error("No email address in submission");
}
// Get all student answers
const studentAnswers = getStudentAnswers(sheet, row);
// Get teacher's questions and solutions
const teacherQuestions = getTeacherQuestions();
// Log what we're processing
sheet.getRange(row, COL_DEBUG_LOG).setValue(
`Processing ${NUM_QUESTIONS} questions for ${studentEmail}`
);
} catch (error) {
sheet.getRange(row, COL_FEEDBACK_STATUS).setValue("Error");
sheet.getRange(row, COL_DEBUG_LOG).setValue(
`Setup error: ${error.toString()}`
);
return;
}
// Continue to Step 2...
}
Step 2: Look Up Student ID and Get Feedback Doc ID
// Get Student ID from email using StudentMapping sheet
const studentId = getStudentIdFromEmail(studentEmail);
if (!studentId) {
// Log error and exit
sheet.getRange(row, COL_FEEDBACK_STATUS).setValue("Error");
sheet.getRange(row, COL_DEBUG_LOG).setValue(
`Student email "${studentEmail}" not found in mapping. ` +
`Please run "Sync Student Mapping" from menu.`
);
return;
}
// Get configuration from Settings sheet
const courseId = getCourseId();
const feedbackAssignmentId = getFeedbackAssignmentId();
// Query Classroom API for this student's submission on the feedback assignment
try {
const submissions = Classroom.Courses.CourseWork.StudentSubmissions.list(
courseId,
feedbackAssignmentId,
{ userId: studentId } // Filter by specific student
).studentSubmissions || [];
if (submissions.length === 0) {
sheet.getRange(row, COL_FEEDBACK_STATUS).setValue("Error");
sheet.getRange(row, COL_DEBUG_LOG).setValue(
`No submission found for student ID: ${studentId}. ` +
`Verify feedback assignment is distributed to all students.`
);
return;
}
// Get the Google Doc attachment from the submission
const submission = submissions[0];
const attachments = submission.assignmentSubmission?.attachments || [];
const docAttachment = attachments.find(
att => att.driveFile?.mimeType === 'application/vnd.google-apps.document'
);
if (!docAttachment) {
sheet.getRange(row, COL_FEEDBACK_STATUS).setValue("Error");
sheet.getRange(row, COL_DEBUG_LOG).setValue(
`No Google Doc found for ${studentEmail}. ` +
`Verify feedback assignment has "Make a copy for each student" enabled.`
);
return;
}
const feedbackDocId = docAttachment.driveFile.id;
} catch (error) {
sheet.getRange(row, COL_FEEDBACK_STATUS).setValue("Error");
sheet.getRange(row, COL_DEBUG_LOG).setValue("Classroom API error: " + error.toString());
return;
}
Step 3: Call LLM API
// Load LLM configuration
const props = PropertiesService.getScriptProperties();
const llmConfig = JSON.parse(props.getProperty(PROP_LLM_CONFIG) || '{}');
// Find first active LLM
let activeLlm = null;
let activeLlmKey = null;
for (const keyName in llmConfig) {
if (llmConfig[keyName].active === true) {
activeLlm = llmConfig[keyName];
activeLlmKey = keyName;
break;
}
}
if (!activeLlm) {
sheet.getRange(row, COL_FEEDBACK_STATUS).setValue("Error");
sheet.getRange(row, COL_DEBUG_LOG).setValue("No active LLM configured");
return;
}
// Get API key for the active LLM
const apiKey = props.getProperty(PROP_LLM_KEY_PREFIX + activeLlmKey);
if (!apiKey) {
sheet.getRange(row, COL_FEEDBACK_STATUS).setValue("Error");
sheet.getRange(row, COL_DEBUG_LOG).setValue("API key not found for: " + activeLlmKey);
return;
}
// Format questions and answers for LLM
const formattedContent = formatQuestionsForLLM(teacherQuestions, studentAnswers);
// Construct API request (format varies by LLM provider)
const systemPrompt = `You are a programming instructor providing detailed feedback on student code submissions.
For each question, analyze the student's answer against the expected solution, identify strengths and weaknesses,
and provide constructive feedback in well-formatted markdown. Be specific and educational.`;
const payload = {
model: activeLlm.model,
messages: [
{
role: "system",
content: systemPrompt
},
{
role: "user",
content: formattedContent
}
]
// Add other parameters as needed for specific LLM
};
try {
const response = UrlFetchApp.fetch(activeLlm.endpoint, {
method: 'post',
headers: {
'Authorization': 'Bearer ' + apiKey,
'Content-Type': 'application/json'
},
payload: JSON.stringify(payload),
muteHttpExceptions: true
});
if (response.getResponseCode() !== 200) {
sheet.getRange(row, COL_FEEDBACK_STATUS).setValue("Error");
sheet.getRange(row, COL_DEBUG_LOG).setValue(
"LLM API error: " + response.getResponseCode() + " - " + response.getContentText()
);
return;
}
const llmResponse = JSON.parse(response.getContentText());
// Extract feedback (adjust based on LLM response format)
// For OpenAI: llmResponse.choices[0].message.content
// For Anthropic: llmResponse.content[0].text
const feedbackMarkdown = llmResponse.choices?.[0]?.message?.content
|| llmResponse.content?.[0]?.text
|| "Error: Unable to parse LLM response";
// IMPORTANT: Store raw LLM response as backup
sheet.getRange(row, COL_RAW_LLM_RESPONSE).setValue(feedbackMarkdown);
sheet.getRange(row, COL_DEBUG_LOG).setValue(
sheet.getRange(row, COL_DEBUG_LOG).getValue() +
`\nLLM response received (${feedbackMarkdown.length} chars)`
);
} catch (error) {
sheet.getRange(row, COL_FEEDBACK_STATUS).setValue("Error");
sheet.getRange(row, COL_DEBUG_LOG).setValue(
"LLM API exception: " + error.toString()
);
return;
}
Step 4: Update Google Doc with Feedback
// Open student's feedback document
try {
const doc = DocumentApp.openById(feedbackDocId);
const body = doc.getBody();
// Clear existing content
body.clear();
// Insert LLM feedback (assumed to be well-formatted markdown)
// Note: Google Docs doesn't render markdown directly,
// but we can insert it as plain text
body.appendParagraph(feedbackMarkdown);
doc.saveAndClose();
sheet.getRange(row, COL_DEBUG_LOG).setValue(
sheet.getRange(row, COL_DEBUG_LOG).getValue() +
`\nFeedback written to doc: ${feedbackDocId}`
);
} catch (docError) {
// Document editing failed, but we already have raw response saved in COL_RAW_LLM_RESPONSE!
sheet.getRange(row, COL_FEEDBACK_STATUS).setValue("Partial");
sheet.getRange(row, COL_DEBUG_LOG).setValue(
sheet.getRange(row, COL_DEBUG_LOG).getValue() +
`\nDoc editing failed: ${docError.toString()}` +
`\nRaw LLM response saved in column ${COL_RAW_LLM_RESPONSE}`
);
Logger.log(`Doc edit failed for ${studentEmail}, but raw response saved in sheet`);
return; // Exit here with "Partial" status
}
Step 5: Update Sheet Status (Success)
// Only reached if doc editing succeeded
sheet.getRange(row, COL_FEEDBACK_STATUS).setValue("Complete");
sheet.getRange(row, COL_DEBUG_LOG).setValue(
sheet.getRange(row, COL_DEBUG_LOG).getValue() +
`\nCompleted: ${new Date()}`
);
Complete Integrated Workflow
/**
* Main trigger function - called when form is submitted
*/
function onFormSubmit(e) {
const row = e.range.getRow();
const sheet = e.range.getSheet();
// Initialize status
sheet.getRange(row, COL_FEEDBACK_STATUS).setValue("Processing");
sheet.getRange(row, COL_DEBUG_LOG).setValue("Started: " + new Date());
try {
// Step 1: Extract and validate form data
validateQuestionConfig();
const studentEmail = sheet.getRange(row, COL_EMAIL).getValue();
if (!studentEmail) {
throw new Error("No email address in submission");
}
const studentAnswers = getStudentAnswers(sheet, row);
const teacherQuestions = getTeacherQuestions();
sheet.getRange(row, COL_DEBUG_LOG).setValue(
`Processing ${NUM_QUESTIONS} questions for ${studentEmail}`
);
// Step 2: Get Student ID and Feedback Doc ID
const studentId = getStudentIdFromEmail(studentEmail);
if (!studentId) {
throw new Error(`Student email "${studentEmail}" not found in StudentMapping sheet`);
}
const courseId = getCourseId();
const feedbackAssignmentId = getFeedbackAssignmentId();
const submissions = Classroom.Courses.CourseWork.StudentSubmissions.list(
courseId,
feedbackAssignmentId,
{ userId: studentId }
).studentSubmissions || [];
if (submissions.length === 0) {
throw new Error(`No submission found for student ID: ${studentId}`);
}
const submission = submissions[0];
const attachments = submission.assignmentSubmission?.attachments || [];
const docAttachment = attachments.find(
att => att.driveFile?.mimeType === 'application/vnd.google-apps.document'
);
if (!docAttachment) {
throw new Error(`No Google Doc found in submission for: ${studentEmail}`);
}
const feedbackDocId = docAttachment.driveFile.id;
// Step 3: Call LLM API
const props = PropertiesService.getScriptProperties();
const llmConfig = JSON.parse(props.getProperty(PROP_LLM_CONFIG) || '{}');
let activeLlm = null;
let activeLlmKey = null;
for (const keyName in llmConfig) {
if (llmConfig[keyName].active === true) {
activeLlm = llmConfig[keyName];
activeLlmKey = keyName;
break;
}
}
if (!activeLlm) {
throw new Error("No active LLM configured");
}
const apiKey = props.getProperty(PROP_LLM_KEY_PREFIX + activeLlmKey);
if (!apiKey) {
throw new Error(`API key not found for: ${activeLlmKey}`);
}
const formattedContent = formatQuestionsForLLM(teacherQuestions, studentAnswers);
const systemPrompt = `You are a programming instructor providing detailed feedback on student code submissions.
For each question, analyze the student's answer against the expected solution, identify strengths and weaknesses,
and provide constructive feedback in well-formatted markdown. Be specific and educational.`;
const payload = {
model: activeLlm.model,
messages: [
{ role: "system", content: systemPrompt },
{ role: "user", content: formattedContent }
]
};
const response = UrlFetchApp.fetch(activeLlm.endpoint, {
method: 'post',
headers: {
'Authorization': 'Bearer ' + apiKey,
'Content-Type': 'application/json'
},
payload: JSON.stringify(payload),
muteHttpExceptions: true
});
if (response.getResponseCode() !== 200) {
throw new Error(`LLM API error: ${response.getResponseCode()} - ${response.getContentText()}`);
}
const llmResponse = JSON.parse(response.getContentText());
const feedbackMarkdown = llmResponse.choices?.[0]?.message?.content
|| llmResponse.content?.[0]?.text
|| "Error: Unable to parse LLM response";
// CRITICAL: Save raw response FIRST (before doc editing that might fail)
sheet.getRange(row, COL_RAW_LLM_RESPONSE).setValue(feedbackMarkdown);
sheet.getRange(row, COL_DEBUG_LOG).setValue(
`LLM response received (${feedbackMarkdown.length} chars)\n` +
`Using: ${activeLlm.name}`
);
// Step 4: Update Google Doc (might fail, but we have backup)
try {
const doc = DocumentApp.openById(feedbackDocId);
const body = doc.getBody();
body.clear();
body.appendParagraph(feedbackMarkdown);
doc.saveAndClose();
// Step 5: Complete Success
sheet.getRange(row, COL_FEEDBACK_STATUS).setValue("Complete");
sheet.getRange(row, COL_DEBUG_LOG).setValue(
sheet.getRange(row, COL_DEBUG_LOG).getValue() +
`\nFeedback written to doc: ${feedbackDocId}` +
`\nCompleted: ${new Date()}`
);
} catch (docError) {
// Partial success: LLM worked, doc editing failed
sheet.getRange(row, COL_FEEDBACK_STATUS).setValue("Partial");
sheet.getRange(row, COL_DEBUG_LOG).setValue(
sheet.getRange(row, COL_DEBUG_LOG).getValue() +
`\n⚠️ Doc editing failed: ${docError.toString()}` +
`\n✓ Raw LLM response saved in column ${COL_RAW_LLM_RESPONSE}`
);
Logger.log(`Partial success for row ${row}: LLM response saved but doc editing failed`);
}
} catch (error) {
// Complete failure
sheet.getRange(row, COL_FEEDBACK_STATUS).setValue("Error");
sheet.getRange(row, COL_DEBUG_LOG).setValue(
`ERROR at ${new Date()}: ${error.toString()}\n` +
`Stack: ${error.stack || 'N/A'}`
);
Logger.log(`Error processing submission row ${row}: ${error.toString()}`);
}
}
Configuration UI
Main Menu
function onOpen() {
const ui = SpreadsheetApp.getUi();
ui.createMenu('Classroom Feedback')
.addItem('⚙️ Configure LLMs', 'showLLMConfigMenu')
.addSeparator()
.addSubMenu(ui.createMenu('📚 Classroom Setup')
.addItem('Test Connection', 'testClassroomConnection')
.addItem('Sync Student Mapping', 'syncStudentMappingFromClassroom')
.addItem('View Student Mapping', 'viewStudentMapping'))
.addSeparator()
.addItem('🧪 Test Configuration', 'testConfiguration')
.addToUi();
}
/**
* Test all configuration settings
*/
function testConfiguration() {
const ui = SpreadsheetApp.getUi();
const ss = SpreadsheetApp.getActiveSpreadsheet();
let results = '=== Configuration Test ===\n\n';
let allGood = true;
// Test Settings Sheet
const settingsSheet = ss.getSheetByName(SETTINGS_SHEET_NAME);
if (settingsSheet) {
try {
const courseId = getCourseId();
const feedbackAssignmentId = getFeedbackAssignmentId();
results += `✓ Settings sheet found\n`;
results += ` Course ID: ${courseId}\n`;
results += ` Feedback Assignment ID: ${feedbackAssignmentId}\n`;
} catch (error) {
results += `❌ Settings sheet error: ${error.toString()}\n`;
allGood = false;
}
} else {
results += `❌ "${SETTINGS_SHEET_NAME}" sheet not found\n`;
allGood = false;
}
results += '\n';
// Test StudentMapping Sheet
const mappingSheet = ss.getSheetByName(STUDENT_MAPPING_SHEET_NAME);
if (mappingSheet) {
const lastRow = mappingSheet.getLastRow();
const studentCount = lastRow > 1 ? lastRow - 1 : 0;
if (studentCount > 0) {
results += `✓ ${STUDENT_MAPPING_SHEET_NAME} sheet: ${studentCount} students\n`;
} else {
results += `❌ ${STUDENT_MAPPING_SHEET_NAME} sheet is empty\n`;
allGood = false;
}
} else {
results += `❌ "${STUDENT_MAPPING_SHEET_NAME}" sheet not found\n`;
allGood = false;
}
results += '\n';
// Test Assignment Config Sheet
const configSheet = ss.getSheetByName(CONFIG_SHEET_NAME);
if (configSheet) {
try {
const questions = getTeacherQuestions();
results += `✓ ${CONFIG_SHEET_NAME} sheet found: ${questions.length} questions\n`;
if (questions.length === NUM_QUESTIONS) {
results += `✓ Question count matches NUM_QUESTIONS (${NUM_QUESTIONS})\n`;
} else {
results += `❌ Question count mismatch: Expected ${NUM_QUESTIONS}, found ${questions.length}\n`;
allGood = false;
}
} catch (error) {
results += `❌ Error reading ${CONFIG_SHEET_NAME}: ${error.toString()}\n`;
allGood = false;
}
} else {
results += `❌ ${CONFIG_SHEET_NAME} sheet not found\n`;
allGood = false;
}
results += '\n';
// Test LLM Config
const props = PropertiesService.getScriptProperties();
const llmConfig = JSON.parse(props.getProperty(PROP_LLM_CONFIG) || '{}');
if (Object.keys(llmConfig).length === 0) {
results += '❌ No LLMs configured\n';
allGood = false;
} else {
let hasActive = false;
Object.keys(llmConfig).forEach(key => {
if (llmConfig[key].active) {
hasActive = true;
const hasKey = !!props.getProperty(PROP_LLM_KEY_PREFIX + key);
results += `✓ Active LLM: ${llmConfig[key].name}\n`;
results += ` ${hasKey ? '✓' : '❌'} API Key: ${hasKey ? 'Set' : 'Missing'}\n`;
if (!hasKey) allGood = false;
}
});
if (!hasActive) {
results += '❌ No active LLM\n';
allGood = false;
}
}
results += '\n';
// Test Sheet Structure
const sheet = SpreadsheetApp.getActiveSheet();
const numCols = COL_FIRST_ANSWER + NUM_QUESTIONS + 2;
const headers = sheet.getRange(1, 1, 1, numCols).getValues()[0];
results += 'Form Response Sheet Column Check:\n';
results += ` ${headers[0] ? '✓' : '❌'} Col ${COL_TIMESTAMP}: ${headers[0] || 'Missing'}\n`;
results += ` ${headers[1] ? '✓' : '❌'} Col ${COL_EMAIL}: ${headers[1] || 'Missing'}\n`;
for (let i = 0; i < NUM_QUESTIONS; i++) {
const col = COL_FIRST_ANSWER + i;
results += ` ${headers[col - 1] ? '✓' : '❌'} Col ${col}: ${headers[col - 1] || 'Missing'}\n`;
}
results += ` Expected Debug Log Col: ${COL_DEBUG_LOG}\n`;
results += ` Expected Status Col: ${COL_FEEDBACK_STATUS}\n`;
results += ` Expected Raw LLM Col: ${COL_RAW_LLM_RESPONSE}\n`;
results += '\n';
if (allGood) {
results += '🎉 All configurations valid! Ready to process submissions.\n';
} else {
results += '⚠️ Please fix the issues above before processing submissions.\n';
}
ui.alert('Configuration Test', results, ui.ButtonSet.OK);
}
LLM Configuration UI
function showLLMConfigMenu() {
const ui = SpreadsheetApp.getUi();
ui.createMenu('LLM Configuration')
.addItem('Add/Edit LLM', 'configureLLM')
.addItem('Set Active LLM', 'setActiveLLM')
.addItem('View Current Config', 'viewLLMConfig')
.addToUi();
}
/**
* Add or edit an LLM configuration
*/
function configureLLM() {
const ui = SpreadsheetApp.getUi();
// Get key name (identifier for this LLM)
const keyNameResponse = ui.prompt(
'LLM Configuration',
'Enter a unique key name (e.g., "openai", "anthropic", "custom"):',
ui.ButtonSet.OK_CANCEL
);
if (keyNameResponse.getSelectedButton() !== ui.Button.OK) return;
const keyName = keyNameResponse.getResponseText().trim().toLowerCase();
if (!keyName) {
ui.alert('Error', 'Key name cannot be empty', ui.ButtonSet.OK);
return;
}
// Get display name
const nameResponse = ui.prompt(
'LLM Configuration',
'Enter display name (e.g., "OpenAI GPT-4"):',
ui.ButtonSet.OK_CANCEL
);
if (nameResponse.getSelectedButton() !== ui.Button.OK) return;
// Get endpoint URL
const endpointResponse = ui.prompt(
'LLM Configuration',
'Enter API endpoint URL:',
ui.ButtonSet.OK_CANCEL
);
if (endpointResponse.getSelectedButton() !== ui.Button.OK) return;
// Get model name
const modelResponse = ui.prompt(
'LLM Configuration',
'Enter model name (e.g., "gpt-4", "claude-3-sonnet-20240229"):',
ui.ButtonSet.OK_CANCEL
);
if (modelResponse.getSelectedButton() !== ui.Button.OK) return;
// Get API key
const apiKeyResponse = ui.prompt(
'LLM Configuration',
'Enter API key (will be stored securely):',
ui.ButtonSet.OK_CANCEL
);
if (apiKeyResponse.getSelectedButton() !== ui.Button.OK) return;
// Save configuration
const props = PropertiesService.getScriptProperties();
const llmConfig = JSON.parse(props.getProperty(PROP_LLM_CONFIG) || '{}');
llmConfig[keyName] = {
name: nameResponse.getResponseText(),
endpoint: endpointResponse.getResponseText(),
model: modelResponse.getResponseText(),
active: Object.keys(llmConfig).length === 0 // First LLM is active by default
};
props.setProperty(PROP_LLM_CONFIG, JSON.stringify(llmConfig));
props.setProperty(PROP_LLM_KEY_PREFIX + keyName, apiKeyResponse.getResponseText());
ui.alert('Success', `LLM "${keyName}" configured successfully!`, ui.ButtonSet.OK);
}
/**
* Set which LLM is active
*/
function setActiveLLM() {
const ui = SpreadsheetApp.getUi();
const props = PropertiesService.getScriptProperties();
const llmConfig = JSON.parse(props.getProperty(PROP_LLM_CONFIG) || '{}');
if (Object.keys(llmConfig).length === 0) {
ui.alert('Error', 'No LLMs configured. Please add an LLM first.', ui.ButtonSet.OK);
return;
}
// Build list of LLMs
const llmList = Object.keys(llmConfig)
.map(key => `${key}: ${llmConfig[key].name} ${llmConfig[key].active ? '(ACTIVE)' : ''}`)
.join('\n');
const response = ui.prompt(
'Set Active LLM',
`Current LLMs:\n${llmList}\n\nEnter the key name to activate:`,
ui.ButtonSet.OK_CANCEL
);
if (response.getSelectedButton() !== ui.Button.OK) return;
const selectedKey = response.getResponseText().trim().toLowerCase();
if (!llmConfig[selectedKey]) {
ui.alert('Error', `LLM "${selectedKey}" not found`, ui.ButtonSet.OK);
return;
}
// Deactivate all, activate selected
Object.keys(llmConfig).forEach(key => {
llmConfig[key].active = (key === selectedKey);
});
props.setProperty(PROP_LLM_CONFIG, JSON.stringify(llmConfig));
ui.alert('Success', `"${selectedKey}" is now active`, ui.ButtonSet.OK);
}
/**
* View current LLM configuration (without showing API keys)
*/
function viewLLMConfig() {
const ui = SpreadsheetApp.getUi();
const props = PropertiesService.getScriptProperties();
const llmConfig = JSON.parse(props.getProperty(PROP_LLM_CONFIG) || '{}');
if (Object.keys(llmConfig).length === 0) {
ui.alert('Configuration', 'No LLMs configured', ui.ButtonSet.OK);
return;
}
let configText = 'Current LLM Configuration:\n\n';
Object.keys(llmConfig).forEach(key => {
const llm = llmConfig[key];
configText += `${llm.active ? '✓ ' : ' '}${key}:\n`;
configText += ` Name: ${llm.name}\n`;
configText += ` Model: ${llm.model}\n`;
configText += ` Endpoint: ${llm.endpoint}\n`;
configText += ` API Key: ${props.getProperty(PROP_LLM_KEY_PREFIX + key) ? 'Set ✓' : 'Not set ✗'}\n\n`;
});
ui.alert('LLM Configuration', configText, ui.ButtonSet.OK);
}
Course & Assignment Selection UI
function showAssignmentPicker() {
const ui = SpreadsheetApp.getUi();
const props = PropertiesService.getScriptProperties();
// First, get/set course ID
let courseId = props.getProperty(PROP_COURSE_ID);
if (!courseId) {
const courseResponse = ui.prompt(
'Course Selection',
'Enter your Google Classroom Course ID:',
ui.ButtonSet.OK_CANCEL
);
if (courseResponse.getSelectedButton() !== ui.Button.OK) return;
courseId = courseResponse.getResponseText().trim();
props.setProperty(PROP_COURSE_ID, courseId);
}
try {
// Fetch all assignments for the course
const assignments = Classroom.Courses.CourseWork.list(courseId).courseWork || [];
if (assignments.length === 0) {
ui.alert('No Assignments', 'No assignments found in this course.', ui.ButtonSet.OK);
return;
}
// Create HTML dropdown
const html = `
<!DOCTYPE html>
<html>
<head>
<base target="_top">
<style>
body { font-family: Arial, sans-serif; padding: 20px; }
.warning { background: #fff3cd; padding: 10px; margin: 10px 0; border-left: 4px solid #ffc107; }
select { width: 100%; padding: 8px; margin: 10px 0; }
button { background: #4285f4; color: white; padding: 10px 20px; border: none; cursor: pointer; }
button:hover { background: #357ae8; }
</style>
</head>
<body>
<h3>Select Feedback Assignment</h3>
<div class="warning">
<strong>⚠️ Important:</strong> Select the assignment that has "Make a copy for each student" enabled.
This is where LLM feedback will be written, NOT the quiz/form assignment.
</div>
<select id="assignmentSelect">
${assignments.map(a => `<option value="${a.id}">${a.title}</option>`).join('')}
</select>
<br><br>
<button onclick="saveAssignment()">Set Feedback Assignment</button>
<script>
function saveAssignment() {
const assignmentId = document.getElementById('assignmentSelect').value;
google.script.run
.withSuccessHandler(() => {
alert('Feedback assignment set successfully!');
google.script.host.close();
})
.setFeedbackAssignment(assignmentId);
}
</script>
</body>
</html>
`;
const htmlOutput = HtmlService.createHtmlOutput(html)
.setWidth(550)
.setHeight(350);
ui.showModalDialog(htmlOutput, 'Select Feedback Assignment');
} catch (error) {
ui.alert('Error', 'Failed to fetch assignments: ' + error.toString(), ui.ButtonSet.OK);
}
}
function setFeedbackAssignment(assignmentId) {
PropertiesService.getScriptProperties().setProperty(PROP_FEEDBACK_ASSIGNMENT_ID, assignmentId);
}
API Verification & Limitations
✅ Verified Capabilities
- Classroom API - List Course Students:
Classroom.Courses.Students.list(courseId)Returns enrolled students with email addresses and Student IDs (userId)
- Classroom API - Get Student’s Submission:
Classroom.Courses.CourseWork.StudentSubmissions.list( courseId, feedbackAssignmentId, { userId: studentId } )Returns specific student’s submission with attached documents
- Document Editing:
DocumentApp.openById(fileId) // Open doc body.clear() // Clear content body.appendParagraph(text) // Add new content - Assignment Listing:
Classroom.Courses.CourseWork.list(courseId)Returns all assignments in course for dropdown selection
⚠️ Known Limitations
-
Markdown Rendering: Google Docs doesn’t natively render markdown. Feedback will appear as plain text unless you parse and format it programmatically.
- User Permissions: Script must run as a user with:
- Teacher access to the Classroom course
- Edit permissions on student feedback documents
-
Form Email Collection: Requires users to be signed in to their Google account and “Collect email addresses” enabled
- “Make a copy for each student”:
- The attachment appears in the submission with type
application/vnd.google-apps.document - Includes the Drive file ID that can be accessed for editing
- Documents are owned by students but teacher has edit access
- The attachment appears in the submission with type
- Two Assignment Pattern:
- Form submission is separate from feedback delivery
- Mapping connects form email → Student ID → Feedback doc
- Requires careful setup to connect the two workflows
Generating Student Mapping
One-Time Setup Function
function generateStudentMapping() {
const ui = SpreadsheetApp.getUi();
const props = PropertiesService.getScriptProperties();
const courseId = props.getProperty(PROP_COURSE_ID);
if (!courseId) {
ui.alert('Error', 'Please configure Course first', ui.ButtonSet.OK);
return;
}
try {
// Get all students enrolled in the course
const students = Classroom.Courses.Students.list(courseId).students || [];
if (students.length === 0) {
ui.alert('No Students', 'No students found in this course.', ui.ButtonSet.OK);
return;
}
const mapping = {};
let successCount = 0;
let errors = [];
students.forEach(student => {
try {
// Get student profile to get email
const profile = student.profile;
const studentEmail = profile.emailAddress;
const studentId = student.userId; // This is the Classroom user ID we need
if (studentEmail && studentId) {
mapping[studentEmail] = studentId;
successCount++;
} else {
errors.push(`Student missing email or ID: ${profile.name?.fullName}`);
}
} catch (error) {
errors.push(`Error processing student: ${error.toString()}`);
}
});
// Save to Script Properties
props.setProperty(PROP_STUDENT_MAPPING, JSON.stringify(mapping));
// Show results
let message = `Successfully mapped ${successCount} students.\n\n`;
if (errors.length > 0) {
message += `Errors:\n${errors.join('\n')}`;
}
Logger.log('Student Mapping (Email → Student ID):', JSON.stringify(mapping, null, 2));
ui.alert('Mapping Complete', message, ui.ButtonSet.OK);
} catch (error) {
ui.alert('Error', 'Failed to generate mapping: ' + error.toString(), ui.ButtonSet.OK);
}
}
/**
* View current student mapping
*/
function viewStudentMapping() {
const ui = SpreadsheetApp.getUi();
const props = PropertiesService.getScriptProperties();
const mapping = JSON.parse(props.getProperty(PROP_STUDENT_MAPPING) || '{}');
if (Object.keys(mapping).length === 0) {
ui.alert('Student Mapping', 'No student mapping found. Please generate it first.', ui.ButtonSet.OK);
return;
}
let message = 'Current Student Mapping (Email → Student ID):\n\n';
Object.keys(mapping).forEach(email => {
message += `${email}\n → ${mapping[email]}\n\n`;
});
ui.alert('Student Mapping', message, ui.ButtonSet.OK);
}
API Notes
This approach uses Classroom.Courses.Students.list() which:
- Returns all enrolled students in the course
- Includes student profile with email address
- Includes
userId(the Classroom Student ID) - Requires
classroom.rosters.readonlyscope
Alternative if scope unavailable: Manually create mapping from course roster:
const mapping = {
"student1@school.edu": "101303941676390353935",
"student2@school.edu": "102445678901234567890"
};
PropertiesService.getScriptProperties()
.setProperty(PROP_STUDENT_MAPPING, JSON.stringify(mapping));
To get Student IDs manually:
- Go to Google Classroom course
- Click on “People” tab
- Use browser dev tools to inspect student elements - Student IDs are in the HTML attributes
Error Handling
Common Issues & Solutions
| Issue | Detection | Solution |
|---|---|---|
| Student email not in mapping | studentId === undefined |
Log error in DEBUG_LOG, verify student is enrolled in course, regenerate mapping |
| No submission found for student | Empty submissions array | Student may not have the feedback assignment yet, check assignment is distributed to all |
| No Google Doc in submission | docAttachment === undefined |
Verify feedback assignment has “Make a copy for each student” enabled |
| LLM API failure | HTTP error from UrlFetchApp.fetch() |
Retry once, log error, check API key and endpoint, mark status as “Error” |
| Document permission denied | Exception from DocumentApp.openById() |
Log error, verify script runs as teacher with edit access to student docs |
| Invalid API response | JSON parse error | Log raw response, set status to “Error” |
| Course/Assignment not configured | Property is null/empty | Direct user to run configuration via menu |
Logging Template
try {
// Main workflow
} catch (error) {
sheet.getRange(row, COL_FEEDBACK_STATUS).setValue("Error");
sheet.getRange(row, COL_DEBUG_LOG).setValue(
`ERROR at ${new Date()}: ${error.toString()}\nStack: ${error.stack}`
);
// Log to Apps Script execution log as well
Logger.log(`Form submission error for row ${row}: ${error.toString()}`);
}
Detailed Error Messages
// Student not found in mapping
if (!studentId) {
const errorMsg = `Student email "${studentEmail}" not found in mapping. ` +
`Please verify student is enrolled and regenerate mapping.`;
sheet.getRange(row, COL_DEBUG_LOG).setValue(errorMsg);
return;
}
// No submission found
if (submissions.length === 0) {
const errorMsg = `No submission found for student ID ${studentId}. ` +
`Verify feedback assignment is distributed to this student.`;
sheet.getRange(row, COL_DEBUG_LOG).setValue(errorMsg);
return;
}
// No document attached
if (!docAttachment) {
const errorMsg = `No Google Doc found in submission for ${studentEmail}. ` +
`Verify feedback assignment has "Make a copy for each student" enabled.`;
sheet.getRange(row, COL_DEBUG_LOG).setValue(errorMsg);
return;
}
Testing Checklist
- Form collects email addresses correctly
- StudentMapping.json has all enrolled students
- LLMSettings.json is valid and endpoint is accessible
- Script has Classroom API and Drive API enabled
- Test with one submission before enabling for class
- Verify feedback appears in student’s Google Doc
- Check error logging in DEBUG_LOG column
- Confirm script runs under teacher’s credentials
Security & Best Practices
Why Script Properties for API Keys?
- Never in Code: API keys never appear in the script source code
- Not in Version Control: Properties don’t get committed to repositories
- Encrypted at Rest: Google encrypts Script Properties
- User-Specific: Each teacher sets their own keys
- Easy Rotation: Update keys via UI without code changes
Configuration Backup
Teachers should document their configuration (without API keys):
function exportConfigurationTemplate() {
const props = PropertiesService.getScriptProperties();
const llmConfig = JSON.parse(props.getProperty(PROP_LLM_CONFIG) || '{}');
// Remove sensitive data
const template = {};
Object.keys(llmConfig).forEach(key => {
template[key] = {
name: llmConfig[key].name,
endpoint: llmConfig[key].endpoint,
model: llmConfig[key].model,
active: llmConfig[key].active,
apiKey: '[SET VIA UI]'
};
});
Logger.log(JSON.stringify(template, null, 2));
return template;
}
Multi-LLM Strategy
The system supports multiple LLMs for resilience:
- Primary: Main LLM for everyday use
- Backup: Fallback if primary has issues or rate limits
- Testing: Development/testing without using production quota
To switch LLMs: Use “Set Active LLM” menu item (no code changes needed)
Future Enhancements (Out of Scope)
- Markdown-to-Google-Docs formatting conversion
- Batch processing of multiple submissions
- Grade passback to Classroom gradebook
- Custom rubric integration
- Multi-LLM fallback logic (automatic retry with backup LLM)
- Feedback templates per assignment type
- Student notification via Classroom API