DEV Community

Cover image for Google AI Classroom Part 7: Serverless Map-Reduce-Map Video Intelligence & Rubric-Driven Practical Task Auto-Grading with Gemini 4 Argon
Cyrus Wong for Google Developer Experts

Posted on Edited on Originally published at github.com

Google AI Classroom Part 7: Serverless Map-Reduce-Map Video Intelligence & Rubric-Driven Practical Task Auto-Grading with Gemini 4 Argon

Author: Cyrus Wong (黃俊彥) — Google Developer Expert - Cloud (AI), Senior Lecturer at HKIIT


1. The 80-Hour Lab Grading Bottleneck & The HTTP 429 Quota Wall

In hands-on cloud computing, DevSecOps, and full-stack software engineering courses, students spend 2 to 3 hours building infrastructure in the AWS/GCP Console, writing Terraform modules in VS Code, and debugging container deployments in the terminal. Reviewing a single 40-student cohort's 2-hour screen recordings manually requires 80 hours of instructor time per lab.

However, when our engineering team first attempted to automate post-class video grading by firing 40 concurrent multimodal video analysis calls against the Gemini API at the end of a lab session, two hard distributed systems walls appeared immediately:

  1. The Serverless Execution Timeout Wall (540s): Even a 2nd-Gen Cloud Run-backed Cloud Function caps synchronous execution at 540 seconds (9 minutes). Sequentially evaluating 40 multi-megabyte lab recordings inside a single monolithic worker inevitably times out mid-cohort, leaving half the class ungraded and corrupting aggregate progress counters.
  2. Gemini API HTTP 429 RESOURCE_EXHAUSTED Rate-Limit Spikes: Sending 40 uncompressed 30-FPS screen recordings simultaneously floods regional Tokens-Per-Minute (TPM) and Requests-Per-Minute (RPM) quotas, triggering cascading HTTP 429 rate-limit errors.
  3. LLM Scoring Drift & Hallucinated Arithmetic: When evaluating multi-step practical exams, raw LLM JSON responses occasionally award 25/20 points on a rubric step, omit a milestone when a student stops early, or miscalculate the sum of individual step scores.

In Part 7 of our 10-part Google Developer Expert engineering series, we walk through how we solved all three challenges by combining:

  • Cloud Run FFmpeg 1-FPS H.264 Timelapse Compilation (shrinking a 2-hour lab into a <20 MB forensic MP4 and slashing multimodal video token consumption by 90%+).
  • Google Cloud Tasks Rate-Limited Fan-Out (maxDispatchesPerSecond: 2, maxConcurrentDispatches: 4) for zero-timeout, zero-429 distributed video processing.
  • Two-Stage Map-Reduce-Map Cohort Discovery (processVideoAnalysisJob $\rightarrow$ analyzeSingleVideoTask $\rightarrow$ generateLabTaskPrompt), enabling teachers to select 🧠 Gemini 4 Argon (gemini-4-argon), ⚡ Gemini 3.8 Flash (gemini-3.8-flash), or 🪶 Gemini 3.5 Flash-Lite (gemini-3.5-flash-lite) to automatically discover what milestones a class achieved and synthesize a tool-calling grading rubric.
  • A 4-Tab Practical Task & Deterministic Rubric Grading Engine (extractTaskDemoSteps.js, evaluateTaskSubmission.js, TaskEditorModal.jsx, and TaskGradingMatrixView.jsx) with Ultrafast Stream-Copy Retroactive Video Slicing (-c copy) and a deterministic mathematical guardrail (normalizeRubricEvaluation / reconcileStepEvaluationsWithRubric).


2. Diagram 1: From Periodic Screenshots to Forensic 1-FPS H.264 MP4s (80% Storage & 90% Token Reduction)

Why record a 30-FPS desktop video stream when a student coding in VS Code or configuring a VPC in the browser changes meaningful visual state every 5 to 15 seconds?

Before Gemini ever evaluates a student's work, StudentView.jsx and our high-memory Cloud Run worker processVideoJob.js (2 vCPU, 8 GiB RAM, 540s timeout) transform periodic screenshots into a razor-sharp, tamper-evident 1-FPS H.264 MP4 timelapse that is 80% smaller than continuous screen recordings:

Adaptive Client Scaling, Sharp 40px SVG Overlay & Cloud Run FFmpeg 1-FPS MP4 Pipeline

Engineering Highlights of the Cloud Run FFmpeg Pipeline

  1. Single-Pass Client Geometric Scaling: On 4K Retina displays, raw canvas captures can exceed 3 MB. Instead of an expensive multi-pass compression loop in the browser, StudentView.jsx computes a single-pass geometric scaling ratio: $$\text{scale} = \sqrt{\frac{\text{maxImageSize}}{\text{blob.size}}} \times 0.9$$ This guarantees every uploaded frame stays well beneath the 2 MB storage.rules threshold while preserving crisp terminal text legibility at up to 1920px (1080p).
  2. Memory-Bounded Sharp SVG Watermarking (BATCH_SIZE = 15): Inside processVideoJob.js, downloading and compositing 720 high-resolution frames concurrently via Promise.all() would spike heap memory and crash the container. We process frames in chunks of BATCH_SIZE = 15, using sharp to burn a 40px forensic SVG banner onto every frame (YYYY-MM-DD HH:mm:ss | Class ID | Student Email | [SCREEN]/[WEBCAM]) and rounding dimensions to even integers (w - (w % 2)) required by the H.264 yuv420p planar chroma format.
  3. Why Burn-In Timestamps Matter for Gemini 4: Because the compiled MP4 plays back at 1 frame per second (where 1 second of video equals 15 seconds of real classroom time), a naive LLM looking only at the MP4 container's playback clock would miscalculate how long a student spent on a task by a factor of $15\times$. Burning the real wall-clock timestamp directly into every frame allows Gemini 4 Argon (gemini-4-argon) and Gemini 3.8 Flash (gemini-3.8-flash) to read the exact on-screen clock via optical character recognition!

3. Diagram 2: Two-Stage Map-Reduce-Map Cohort Video Discovery with Cloud Tasks & Gemini 4 Argon

What happens when an instructor runs an open-ended hands-on cloud lab—where students explore AWS/GCP services at their own pace—and wants AI to:

  1. Watch all 40 student timelapse videos without hitting rate limits (Phase 1: Map),
  2. Discover the common coursework milestones, point weights, and technical blockers encountered across the entire cohort (Phase 2: Reduce), and
  3. Re-evaluate every student against that newly synthesized rubric to populate a live Coursework Milestone Matrix (Phase 3: Second Map)?

In functions/ai_flows/index.mjs, generateLabTaskPrompt.js, VideoLibrary.jsx, and VideoAnalysisJobs.jsx, we built a Serverless Map-Reduce-Map Pipeline where teachers can select gemini-4-argon, gemini-3.8-flash, or gemini-3.5-flash-lite at both the Map and Reduce stages:

Two-Stage Map-Reduce-Map Cohort Video Discovery with Cloud Tasks & Gemini 4 Argon

Production Code 1: The Reduce Stage — Synthesizing a Cohort Grading Rubric from 40 Student Videos (generateLabTaskPrompt.js)

Let's examine functions/ai_flows/generateLabTaskPrompt.js. When Phase 1 (the exploratory Map pass) finishes, each student's raw activity observation is stored in aiJobs/{jobId}. Because Firestore's in query operator accepts at most 30 document IDs per query, generateLabTaskPrompt fetches the completed child jobs in 30-document chunks, compiles them into a cohort-wide activity corpus, and invokes generateWithResilience using the teacher's chosen model (such as gemini-4-argon or gemini-3.8-flash):

// Source: functions/ai_flows/generateLabTaskPrompt.js
export const generateLabTaskPrompt = onCall(
  {
    region: FUNCTION_REGION,
    cors: CORS_ORIGINS,
    timeoutSeconds: 120,
    memory: '1GiB',
  },
  async (request) => {
    if (!request.auth) {
      throw new HttpsError('unauthenticated', 'The function must be called while authenticated.');
    }

    const { jobId, model } = request.data || {};
    const masterJobRef = db.collection('videoAnalysisJobs').doc(jobId);
    const jobDoc = await masterJobRef.get();
    const jobData = jobDoc.data();
    const classId = jobData.classId || 'unknown_class';
    const aiJobIds = jobData.aiJobIds || [];

    let completedJobs = [];

    // Fetch child aiJobs via 30-document batched FieldPath.documentId() queries
    if (aiJobIds.length > 0) {
      for (let i = 0; i < aiJobIds.length; i += 30) {
        const batchIds = aiJobIds.slice(i, i + 30);
        const querySnapshot = await db
          .collection('aiJobs')
          .where(FieldPath.documentId(), 'in', batchIds)
          .get();

        querySnapshot.forEach((doc) => {
          const data = doc.data();
          if (data.status === 'completed' && data.result && typeof data.result === 'string' && data.result.trim()) {
            completedJobs.push(data);
          }
        });
      }
    }

    // Compile observations across all students in the cohort
    const compiledObservations = completedJobs
      .map((job, idx) => {
        const studentIdentifier = job.studentEmail || job.studentUid || `Student ${idx + 1}`;
        return `### Student ${idx + 1}: ${studentIdentifier}\n${job.result.trim()}`;
      })
      .join('\n\n---\n\n');

    const preferredModel = model || 'gemini-3.8-flash';
    const { response, modelUsed } = await generateWithResilience(
      {
        prompt: [{ text: metaPrompt }],
        config: { temperature: 0.2 },
      },
      preferredModel
    );

    const usage = response?.usage || response?.usageMetadata || {};
    const cost = calculateCost(usage, modelUsed || preferredModel);
    await logJob({
      classId,
      jobType: 'generateLabTaskPrompt',
      status: 'completed',
      promptText: metaPrompt,
      mediaPaths: [],
      usage: {
        inputTokens: usage.inputTokens ?? usage.promptTokenCount ?? 0,
        outputTokens: usage.outputTokens ?? usage.candidatesTokenCount ?? 0,
      },
      cost,
      modelUsed: modelUsed || preferredModel,
      result: generatedPrompt.trim().substring(0, 300),
      masterJobId: jobId,
    });

    return {
      generatedPrompt: generatedPrompt.trim(),
      modelUsed,
      summaryCount: completedJobs.length,
      classId,
      jobId,
    };
  }
);
Enter fullscreen mode Exit fullscreen mode

The 3 Prompts Powering the Map-Reduce-Map Video Pipeline

To make this architecture concrete and reproducible, here are the exact prompts executed at each stage of the Map-Reduce-Map workflow:

1. Phase 1 (Exploratory Map Prompt) — Unsupervised Activity Discovery (Summarize Student's Work.md)

In Phase 1, the lecturer does not need to write a lab rubric in advance. Each Cloud Tasks worker (analyzeSingleVideoTask $\rightarrow$ analyzeSingleVideoFlow in functions/ai_flows/analysisFlows.js) wraps the exploratory template (admin/prompts/videos/Summarize Student's Work.md) with the class's time-lapse capture interval (captureInterval = 15s) and dispatches it in parallel across all 40 student videos:

# Summarize Student's Activity

**You are an AI teaching assistant. Your task is to summarize the activity of each student based on their video.**

## Video Context
* The video is a fast-forwarded time-lapse of screen captures, not a real-time recording (compiled at 1 frame per second from screenshots captured roughly every {{captureInterval}} seconds).
* Each frame includes a burned-in timestamp (date and time). All calculations of duration (e.g., for attendance, concentration time) **MUST** be based on these in-frame timestamps.
* **Do not use the video's playback length for any time-based calculations.**

## Attendance & Engagement Analysis
* Compare the timeframe of the student's activity (derived from the in-frame timestamps) with the scheduled lesson time ({{startTime}} to {{endTime}}) to estimate attendance. Note any late starts, early leaves, or gaps in screen sharing.
* Based on the student's on-screen activity and focus, estimate the total "concentration minutes" during the observed period using the in-frame timestamps, and call the `recordActualWorkingTime` tool.

## Analysis Guidelines
* Identify the main tasks, cloud consoles, CLI commands, and code files the student is working on throughout the video.
* Describe the student's pace, milestones achieved, and any test scores or verification outputs visible on screen.
* Note any periods of inactivity, error messages, or technical struggle.

## Actions
* Call `recordActualWorkingTime` to record your estimate of the student's concentration minutes.
* Call `recordLessonSummary` to record a concise, informative summary of their activity, progress, and blockers.
* Provide the exact same summary as your final text response.
Enter fullscreen mode Exit fullscreen mode

2. Phase 2 (Reduce Meta-Prompt) — Synthesizing a Cohort Rubric from 40 Observations (Two-Stage Map-Reduce Lab Rubric Synthesizer.md)

Once the Phase 1 Map workers finish, clicking ✨ Generate Lab Task Prompt in VideoAnalysisJobs.jsx invokes generateLabTaskPrompt.js, which injects all 40 student summaries into {{compiledObservations}} inside admin/prompts/videos/Two-Stage Map-Reduce Lab Rubric Synthesizer.md:

You are an expert Computer Science university curriculum designer and AI prompt engineer for classroom invigilation and automated lab assessment.

Below are student activity observations collected from an initial video analysis across a class session (Class ID: "{{classId}}"):

=== BEGIN OBSERVED STUDENT ACTIVITIES ===
{{compiledObservations}}
=== END OBSERVED STUDENT ACTIVITIES ===

Based on these actual classroom observations, analyze the student activities and generate an optimized, comprehensive, ready-to-use Markdown task prompt for a high-precision second-pass evaluation of this lab.

The generated prompt MUST strictly follow this Markdown structure:

# [Descriptive Lab Title Based on Discovered Coursework]

**You are an AI teaching assistant evaluating student screen recording time-lapses for [Lab Topic/Subject].**

## Video Timing Rules
* The video is a fast-forward time-lapse with on-screen timestamps.
* All duration, attendance, and task timing calculations **MUST** be derived from the in-frame date/time stamps, NOT the video playback length.

## Coursework Tasks to Evaluate
[Break down the lab into 3-5 clearly numbered, specific tasks discovered from the student observations. Include specific platforms (e.g. AWS, Azure, Cloud Shell, VS Code), repositories, scripts, resource names, and milestones].

## Milestones, Scores & Rubrics
[Detail any test numbers, scoring points (e.g., 10 pts, 20 pts), or completion verifications discovered from the observations].

## Known Blockers & Technical Obstacles to Watch For
[List the common technical errors, blockers, or pitfalls students encountered, such as backend errors, incorrect regions, or setup issues].

## Required Tool Actions
1. **Working Time**: Call 'recordActualWorkingTime' with the total estimated active concentration minutes (clamped to lesson duration).
2. **Task Durations**: For each identified task above, call 'recordTaskDuration' with studentUid, classId, taskName, and estimated durationMinutes spent on that task.
3. **Student Summary**: Call 'recordLessonSummary' with a structured bulleted summary of their progress across each task, points earned, blockers encountered, and engagement verdict.
4. **Final Response**: Output the exact same structured summary text provided to 'recordLessonSummary'.

Return ONLY the clean Markdown prompt text. Do not wrap in markdown code fence blocks.
Enter fullscreen mode Exit fullscreen mode

3. Phase 3 (Second-Map Evaluation Prompt) — Example Output Synthesized by Gemini 4 Argon

Here is an actual example of the Markdown prompt synthesized by Phase 2 from a 40-student cloud infrastructure lab, which the teacher reviews in the modal and dispatches back to Cloud Tasks (analyzeSingleVideoTask) for the Second Map pass:

# Serverless Cloud Run & Terraform Infrastructure Lab Evaluation

**You are an AI teaching assistant evaluating student screen recording time-lapses for Cloud Infrastructure & Serverless Container Deployment.**

## Video Timing Rules
* The video is a fast-forward time-lapse with on-screen timestamps (`YYYY-MM-DD HH:mm:ss`).
* All duration, attendance, and task timing calculations **MUST** be derived from the in-frame date/time stamps, NOT the video playback length.

## Coursework Tasks to Evaluate
1. **Task 1: Environment Setup & Repository Cloning**: Cloning the starter repo in VS Code / Cloud Shell, configuring `.env` variables, and running `npm install`.
2. **Task 2: Docker Containerization & Local Verification**: Writing the multi-stage `Dockerfile`, building the container image via `docker build` / `gcloud builds submit`, and verifying `localhost:8080/healthz` returns `HTTP 200 OK`.
3. **Task 3: Terraform Cloud Run & Firestore Provisioning**: Writing `main.tf` (`google_cloud_run_v2_service` and `google_firestore_database`), running `terraform init && terraform plan`, and applying infrastructure via `terraform apply -auto-approve`.
4. **Task 4: End-to-End Automated Grading Script Verification**: Executing `./run-lab-tests.sh` in the terminal and verifying all 5 integration assertions pass against the live `.run.app` endpoint.

## Milestones, Scores & Rubrics
* **Task 1 (15 pts)**: Starter repository cloned and dependencies installed without missing package errors.
* **Task 2 (25 pts)**: Container image built and local health check (`200 OK`) verified in terminal or browser.
* **Task 3 (35 pts)**: `terraform apply` completes with `Apply complete! Resources: 4 added, 0 changed, 0 destroyed` and outputs the live Cloud Run URL.
* **Task 4 (25 pts)**: Automated test script outputs `SCORE: 100/100 (5/5 tests passed)`.

## Known Blockers & Technical Obstacles to Watch For
* **Port Mismatch (`PORT=8080`)**: Container failing Cloud Run startup probe because the Express server listened on port `3000` instead of `process.env.PORT`.
* **IAM Permission Denied (`403`)**: `terraform apply` failing on Cloud Run IAM binding due to missing `roles/run.admin` or unenabled `run.googleapis.com` API.
* **Region Mismatch**: Deploying Firestore in `us-central1` while Cloud Run is configured in `asia-east2`.

## Required Tool Actions
1. **Working Time**: Call `recordActualWorkingTime` with the total estimated active concentration minutes (clamped to lesson duration).
2. **Task Durations**: For each identified task above (`Task 1`, `Task 2`, `Task 3`, `Task 4`), call `recordTaskDuration` with `studentUid`, `classId`, `taskName`, and estimated `durationMinutes` spent on that task.
3. **Student Summary**: Call `recordLessonSummary` with a structured bulleted summary of their progress across each task, points earned (out of 100), blockers encountered, and engagement verdict.
4. **Final Response**: Output the exact same structured summary text provided to `recordLessonSummary`.
Enter fullscreen mode Exit fullscreen mode

When the teacher runs Phase 3 (the Milestone Evaluation Map) using that synthesized prompt, each Cloud Tasks worker invokes Genkit tools (recordActualWorkingTime, recordTaskDuration, and recordLessonSummary) that populate VideoAnalysisJobs.jsx and TaskGradingMatrixView.jsx—giving the lecturer a color-coded cohort progress matrix showing exactly how many minutes each student spent on Task 1, Task 2, Task 3, and Task 4, and where they got stuck!


4. Diagram 3: 4-Tab Practical Tasks, Retroactive Post-Class Slicing & Deterministic Rubric Reconciliation

While Map-Reduce-Map excels at exploratory cohort discovery, graded practical assignments and lab tests (📋 Tasks & Homework tab) require a strict, reproducible rubric where every student is graded against an identical point breakdown, with timestamped visual proof and full teacher override authority.

To support this, we engineered a 4-Tab Practical Task & Rubric Grading System across TaskEditorModal.jsx, TaskGradingMatrixView.jsx, extractTaskDemoSteps.js, and evaluateTaskSubmission.js.

Teachers have two independent AI model selection control points supporting 🧠 Gemini 4 Argon (gemini-4-argon), ⚡ Gemini 3.8 Flash (gemini-3.8-flash), and 🪶 Gemini 3.5 Flash-Lite (gemini-3.5-flash-lite):

  1. Task Editor Modal (#task-ai-model-select in TaskEditorModal.jsx): Chooses the default AI model stored on classes/{classId}/tasks/{taskId}.model and used by extractTaskDemoSteps when watching a teacher's reference demonstration video to synthesize the step-by-step rubric.
  2. Grading Matrix Toolbar (Grading AI Model selector in TaskGradingMatrixView.jsx): Allows the teacher to override the evaluation model on the fly when running single-student re-evaluations (handleTriggerEvaluate) or batch cohort grading (handleBatchEvaluatePending via batchEvaluateTaskSubmissions). For example, a teacher can grade formative practice attempts with gemini-3.5-flash-lite and switch the toolbar selector to gemini-4-argon for final high-stakes practical exam grading!

4-Tab Practical Tasks, Retroactive Slicing & Deterministic Rubric Reconciliation


5. Deep Dive into the Production Code: Rubric Extraction, Zero-Race Stream-Copy Slicing & Deterministic Score Reconciliation

Production Code 2: Multimodal Reference Video Rubric Synthesis (extractTaskDemoSteps.js)

Instead of writing complex JSON rubrics by hand, an instructor can upload a 3-minute demonstration video of the solution (or pick an existing Teacher Lecture Recording or paste a YouTube URL) in Tab 2 of TaskEditorModal.jsx and click ✨ Extract Steps & Rubric with AI.

In functions/ai_flows/extractTaskDemoSteps.js, handleExtractTaskDemoSteps passes the video URI alongside any teacher marking notes to generateWithResilience(generateConfig, preferredModel) and enforces a strict JSON schema containing stepNumber, title, description, expectedEvidence, gradingCriteria, and points:

// Source: functions/ai_flows/extractTaskDemoSteps.js
export const handleExtractTaskDemoSteps = async ({
  classId,
  demoVideoPath,
  promptGuidelines,
  promptText,
  maxScore = 100,
  model,
}) => {
  const storage = getStorage();
  const bucketName = storage.bucket().name;

  const isYouTube = demoVideoPath
    ? /(?:youtu\.be\/|youtube\.com\/(?:embed\/|v\/|watch\?v=|watch\?.+&v=|live\/))([a-zA-Z0-9_-]{11})/i.test(demoVideoPath)
    : false;
  let resolvedVideoUri = null;
  if (demoVideoPath) {
    if (isYouTube || demoVideoPath.startsWith('http://') || demoVideoPath.startsWith('https://')) {
      resolvedVideoUri = demoVideoPath;
    } else if (demoVideoPath.startsWith('gs://')) {
      resolvedVideoUri = demoVideoPath;
    } else {
      resolvedVideoUri = `gs://${bucketName}/${demoVideoPath.replace(/^\/+/, '')}`;
    }
  }

  const preferredModel = model || 'gemini-3.8-flash';
  const promptParts = [{ text: metaPrompt }];
  if (resolvedVideoUri) {
    promptParts.push({ media: { url: resolvedVideoUri, contentType: 'video/mp4' } });
  }
  const generateConfig = {
    prompt: promptParts,
    config: {
      temperature: 0.2,
      responseMimeType: 'application/json',
    },
  };

  const { response, modelUsed } = await generateWithResilience(generateConfig, preferredModel);
  const parsed = JSON.parse(cleanJsonFence(response.text || ''));

  if (Array.isArray(parsed?.steps)) {
    parsed.steps = parsed.steps.map((s, idx) => ({
      stepNumber: Number(s.stepNumber) || idx + 1,
      title: s.title || `Milestone ${idx + 1}`,
      description: s.description || '',
      expectedEvidence: s.expectedEvidence || '',
      gradingCriteria: s.gradingCriteria || '',
      points: Number(s.points ?? s.weight ?? 20),
    }));
  }

  return { ...parsed, modelUsed, cost: calculateCost(response?.usage, modelUsed || preferredModel) };
};
Enter fullscreen mode Exit fullscreen mode

Here is the metaPrompt inside extractTaskDemoSteps.js (backed by admin/prompts/rubrics/Practical Task Demo Step Extractor.md) that extracts a chronological JSON rubric from a teacher's demo video:

You are an expert technical educator and curriculum designer creating a structured grading rubric for a hands-on practical assignment or lab test.
Analyze the visual actions, commands, code editor steps, browser verifications, and results shown in the demonstration video.

Extract a structured, chronological step-by-step assessment rubric (between 3 and 8 distinct milestones).
For each milestone:
1. Provide a concise, clear title.
2. Describe the specific action or requirement (e.g. terminal command, file created, setting configured).
3. Specify the expected visual proof/evidence that an AI evaluator should search for in a student's screen recording (e.g. terminal output text, open ports, browser UI, green test checks).
4. Provide explicit marking / partial-credit criteria (how full marks vs. partial marks vs. 0 marks should be awarded).
5. Assign a point weight (total across all steps must equal 100 points).

You MUST respond strictly with valid JSON conforming to this schema:
{
  "title": "A concise, descriptive title for this practical task",
  "description": "A clear 2-3 sentence overview of what the student is required to reproduce",
  "suggestedMaxScore": 100,
  "steps": [
    {
      "stepNumber": 1,
      "title": "Short Step Title",
      "description": "Specific action performed by the student",
      "expectedEvidence": "Specific visual evidence, keywords, or UI elements on screen",
      "gradingCriteria": "Full marks if completed and verified; partial marks if configured with minor syntax errors",
      "points": 20
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

Production Code 3: Eliminating the Post-Lesson Screenshot Deletion Race Condition with Ultrafast Stream-Copy Slicing (slicePendingTaskSubmissions)

A subtle race condition occurs during In-Class Practical Tasks:

  1. At the end of a scheduled lesson, processVideoJob.js compiles all raw screenshots from screenshots/{classId}/{studentUid}/ into the master class timelapse video (videos/{classId}/{videoId}.mp4), and then deletes the raw screenshots to save 80% of Cloud Storage costs.
  2. What if a student clicked Submit Task right as class ended, or what if a teacher realized after class that they forgot to create a task beforehand and wants to retroactively slice minutes 30:00 to 75:00 from all 40 students' compiled class videos?

In functions/media_processing/processVideoJob.js, slicePendingTaskSubmissions solves both problems simultaneously. Instead of re-downloading raw screenshots, it maps the task's [taskStartMs, taskEndMs] window (or tagged activeTaskId frame indices) to exact 1-FPS video offsets (startSec, durSec) and slices attempt_${attemptNumber}.mp4 directly out of the compiled class MP4 using FFmpeg lossless stream-copying (-c copy) in less than 0.5 seconds!

// Source: functions/media_processing/processVideoJob.js
export async function slicePendingTaskSubmissions({
  classId,
  studentUid,
  studentEmail,
  classStartTime,
  captureInterval = 15,
  outputVideoPath,
  screenshots = [],
  db,
  bucket,
  retry,
}) {
  // Identify exact 1-FPS start frame index and duration from tagged screenshots or timestamp math
  let firstIndex = screenshots.findIndex((s) =>
    (s.activeTaskId && s.activeTaskId === taskId) || (!isNaN(toMs(s.timestamp)) && toMs(s.timestamp) >= taskStartMs)
  );
  // ... (calculates startSec and durSec)

  const taskClipPath = path.join(os.tmpdir(), `${taskJobDoc.id}_clip.mp4`);
  await new Promise((resolve, reject) => {
    ffmpeg(outputVideoPath)
      .setStartTime(startSec)
      .setDuration(durSec)
      .outputOptions([
        '-c', 'copy',
        '-avoid_negative_ts', 'make_zero',
        '-movflags', '+faststart'
      ])
      .output(taskClipPath)
      .on('end', resolve)
      .on('error', () => {
        // Automatic fallback to ultrafast libx264 re-encode if keyframe boundary requires it
        ffmpeg(outputVideoPath)
          .setStartTime(startSec)
          .setDuration(durSec)
          .outputOptions(['-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-preset', 'ultrafast', '-crf', '28', '-movflags', '+faststart'])
          .output(taskClipPath)
          .on('end', resolve)
          .on('error', reject)
          .run();
      })
      .run();
  });

  const destinationPath = `classes/${classId}/tasks/${taskId}/submissions/${studentUid}/attempt_${attemptNumber}.mp4`;
  await retry(() => bucket.upload(taskClipPath, { destination: destinationPath, /* metadata */ }));
}
Enter fullscreen mode Exit fullscreen mode

And if a teacher uses ⏪ Post-Class Retroactive Slicing inside TaskGradingMatrixView.jsx, our retroactive slicing Cloud Function (functions/media_processing/extractRetroactiveTaskSubmissions.js) slices the specified [startOffsetMinutes, durationMinutes] window across all 40 class videos in parallel and immediately pipelines the sliced clips into the Gemini grading queue!


Production Code 4: Deterministic Rubric Reconciliation & Mathematical Guardrails (evaluateTaskSubmission.js)

Even frontier models like Gemini 4 Argon (gemini-4-argon) are probabilistic token generators—we should never trust raw LLM arithmetic for official academic gradebooks.

In functions/ai_flows/evaluateTaskSubmission.js (mirrored on the frontend by reconcileStepEvaluationsWithRubric in TaskGradingMatrixView.jsx), normalizeRubricEvaluation acts as a deterministic post-processing barrier between Gemini's JSON output and Firestore:

  1. 1-to-1 Step Alignment: It iterates over the authoritative effectiveSteps array from the task definition, matching AI step results by stepNumber, case-insensitive title, or positional index.
  2. Strict [0, maxPoints] Clamping: Every step's scoreAwarded is clamped via Math.max(0, Math.min(maxPoints, ...)) so the AI can never award negative points or exceed a step's point ceiling.
  3. Zero-Defaulting Omitted Steps: If a student stopped halfway through the lab and the LLM omitted Steps 4 and 5 from its JSON array, normalizeRubricEvaluation deterministically populates Steps 4 and 5 with scoreAwarded: 0, status: 'missing', and explicit feedback (No evidence of "${stepTitle}" was observed in the submitted screen recording.).
  4. Exact Bottom-Up Summation & Multi-Attempt Scoring Strategy: finalScore is computed strictly as the sum of reconciled scoreAwarded values across all steps, and the student's root submission document automatically updates bestScore, latestScore, averageScore, and effectiveScore based on the task's configured scoring strategy (highest, latest, or average).

First, here is the strict rubric-driven evaluation prompt dispatched by evaluateTaskSubmission.js:

You are an expert technical invigilator and automated practical exam evaluator.
Evaluate the student's continuous screen recording time-lapse video strictly against the following practical assignment rubric.

=== ASSIGNMENT DETAILS ===
Title: "{{taskData.title}}"
Overview: "{{taskData.description}}"
Total Possible Score: {{maxScore}} points

=== REQUIRED RUBRIC MILESTONES (STRICT MARK BREAKDOWN) ===
{{effectiveStepsJson}}

=== INSTRUCTIONS FOR RUBRIC-DRIVEN EVALUATION ===
1. Watch the student's screen actions chronologically from start to finish.
2. You MUST evaluate EVERY SINGLE milestone listed in REQUIRED RUBRIC MILESTONES above in exact order:
   - Determine whether the step was "completed", "partial", "error", or "missing".
   - Record the approximate "mm:ss" timestamp ("timestampInVideo") where the step was attempted or completed (or null if missing).
   - Describe the concrete visual proof observed on the student's screen ("evidenceObserved", e.g., terminal command output, code written, browser state, or what was missing).
   - Award marks ("scoreAwarded") strictly between 0 and the step's allocated "points" ("maxPoints") based on the step's "expectedEvidence" and "gradingCriteria".
   - Provide a clear, detailed per-step explanation ("feedback") for the student and teacher explaining WHY those exact marks were awarded or deducted according to the rubric.
3. Highlight 2-3 specific technical strengths demonstrated by this student in the video.
4. Highlight any errors, warnings, configuration oversights, or suspicious anomalies (e.g. abrupt paste of whole codebase, unhandled exceptions).
5. Calculate "finalScore" as the exact sum of "scoreAwarded" across all rubric steps (out of {{maxScore}}).
Enter fullscreen mode Exit fullscreen mode

And here is how normalizeRubricEvaluation deterministically reconciles Gemini's JSON response against the task rubric:

// Source: functions/ai_flows/evaluateTaskSubmission.js
export const normalizeRubricEvaluation = (rawEvaluation = {}, effectiveSteps = [], maxScore = 100) => {
  const normalizedMaxScore = Number(maxScore) || 100;
  const rawSteps = Array.isArray(rawEvaluation.stepResults)
    ? rawEvaluation.stepResults
    : Array.isArray(rawEvaluation.rubricScores)
    ? rawEvaluation.rubricScores
    : [];

  let reconciledSteps = [];

  if (Array.isArray(effectiveSteps) && effectiveSteps.length > 0) {
    const defaultStepPoints = Math.round(normalizedMaxScore / effectiveSteps.length);
    reconciledSteps = effectiveSteps.map((rubricStep, idx) => {
      const stepNum = Number(rubricStep.stepNumber ?? idx + 1);
      const maxPoints = Number(rubricStep.points ?? rubricStep.weight ?? defaultStepPoints) || 0;
      const stepTitle = rubricStep.title || rubricStep.criterion || `Step ${stepNum}`;

      const matched =
        rawSteps.find((s) => Number(s?.stepNumber) === stepNum) ||
        rawSteps.find(
          (s) =>
            s?.title &&
            stepTitle &&
            String(s.title).trim().toLowerCase() === String(stepTitle).trim().toLowerCase()
        ) ||
        rawSteps[idx] ||
        null;

      if (matched) {
        const rawScore = Number(
          matched.scoreAwarded ?? matched.score ?? (matched.status === 'completed' ? maxPoints : 0)
        );
        // Strictly clamp scoreAwarded to [0, maxPoints]
        const scoreAwarded = Math.max(
          0,
          Math.min(maxPoints, Number.isFinite(rawScore) ? Math.round(rawScore * 100) / 100 : 0)
        );
        const status =
          matched.status ||
          (scoreAwarded >= maxPoints && maxPoints > 0 ? 'completed' : scoreAwarded > 0 ? 'partial' : 'missing');

        return {
          stepNumber: stepNum,
          title: stepTitle,
          maxPoints,
          scoreAwarded,
          status,
          timestampInVideo: matched.timestampInVideo || matched.timestamp || null,
          evidenceObserved: matched.evidenceObserved || matched.evidence || '',
          feedback:
            matched.feedback ||
            matched.rationale ||
            matched.explanation ||
            (status === 'completed'
              ? `Completed all requirements for ${stepTitle}.`
              : `Incomplete or missing requirements for ${stepTitle}.`),
          expectedEvidence: rubricStep.expectedEvidence || '',
          gradingCriteria: rubricStep.gradingCriteria || '',
        };
      }

      // Deterministic fallback if the AI omitted a rubric step
      return {
        stepNumber: stepNum,
        title: stepTitle,
        maxPoints,
        scoreAwarded: 0,
        status: 'missing',
        timestampInVideo: null,
        evidenceObserved: 'Not observed in submission recording.',
        feedback: `No evidence of "${stepTitle}" was observed in the submitted screen recording.`,
        expectedEvidence: rubricStep.expectedEvidence || '',
        gradingCriteria: rubricStep.gradingCriteria || '',
      };
    });
  }

  // Exact bottom-up summation of clamped step scores
  const sum = reconciledSteps.reduce((acc, s) => acc + (Number(s.scoreAwarded) || 0), 0);
  const computedFinalScore = Math.max(0, Math.min(normalizedMaxScore, Math.round(sum * 100) / 100));

  return {
    ...rawEvaluation,
    finalScore: computedFinalScore,
    maxScore: normalizedMaxScore,
    completionPercentage: Math.round((computedFinalScore / normalizedMaxScore) * 100),
    stepResults: reconciledSteps,
  };
};
Enter fullscreen mode Exit fullscreen mode

6. Architectural Comparison: Choosing the Right Gemini Model for Video Intelligence & Rubric Grading

Because instructors can select the AI model per job in VideoLibrary.jsx, TaskEditorModal.jsx (#task-ai-model-select), and TaskGradingMatrixView.jsx (Grading AI Model), here is how our lecturers allocate models across different assessment workloads:

Assessment Workload Recommended Model Why It Excels Configured Rate in cost.js (Input / Output per 1M Tokens)
Daily Formative Lab Practice & Self-Check Attempts 🪶 Gemini 3.5 Flash-Lite (gemini-3.5-flash-lite) Fast feedback after submission; lowest token rate for high-frequency self-checks. $0.30 / $2.50
Standard Weekly Coursework & Map-Reduce Cohort Discovery ⚡ Gemini 3.8 Flash (gemini-3.8-flash) Balanced multimodal OCR on dense terminal logs and IDE split-panes. $0.75 / $3.75
High-Stakes Practical Exams & Complex Multi-Step Rubric Synthesis 🧠 Gemini 4 Argon (gemini-4-argon) Frontier visual reasoning across code diffs, architecture diagrams, and multi-terminal debugging. $2.00 / $10.00

In TaskGradingMatrixView.jsx, teachers see every student's per-step pill breakdown (S1: 30/30, S2: 62/70) directly in the matrix table, can click any [⏱️ 02:15] badge to seek the HTML5 video player to the exact frame where Gemini observed the evidence, override any individual step score or explanation with full audit trails (isTeacherEdited: true), and export a multi-sheet Excel Gradebook (.xlsx) or Google Drive Archive with one click.


What's Next in Part 8?

In Part 8, we move from individual lab screen recordings to face-to-face engineering capstone project consultations—showing how we turned the project supervisor's smartphone into a Streamlined Single-Device Multimodal Supervision Studio (continuous voice + zero-pause 0.1s photo snap + auto-sliced 720p mobile demo video) backed by self-healing Google Sheets weekly log sync, pre-meeting fake-log AI auditing, and a Two-Stage gemini-3.5-transcribe-preview + gemini-4-argon Cross-Examination Pipeline.

Top comments (0)