# Project export: Blindspot

This document was generated by HackStack to give an AI agent context about a hackathon project. Sections are labeled with their provenance; content marked as truncated was cut to keep this document small.

## Project metadata

- Hackathon: OpenAI Build Week
- Tagline: Catches correct math answers built on wrong reasoning.
- Devpost: https://devpost.com/software/blindspot-jiur58
- GitHub: https://github.com/mohamdImran/blindspot
- Demo: https://resonant-tulumba-7e93f5.netlify.app/
- Video: https://www.youtube.com/embed/d_hWwmdX5CU?enablejsapi=1&hl=en_US&rel=0&start=&version=3&wmode=transparent
- Team: 1 GitHub contributor(s) — justinDevel (2 commits)

## Devpost submission (written by the team)

### Overview

The problem Teacher grading and feedback workload is a documented crisis, not a hunch — a third of US teachers have seriously considered leaving the profession over grading burden alone, and the research is clear on why specific, timely feedback breaks down: it simply doesn't scale to an entire class by hand. But the deeper, less-discussed problem is a blind spot baked into grading itself, AI-assisted or not: grading checks answers, not reasoning. A 2026 study on AI tutoring identified this directly — nearly all misconception-detection research (academic and commercial) is triggered by a wrong answer, because that's the only signal current systems know how to use. When a student's answer happens to be correct, the system — and often the teacher, moving fast through a stack of papers — has no way to see that the method behind it was invalid and will fail on the next problem.

### Inspiration

Most education software looks for misconceptions after a student gets an answer wrong. But a correct answer can conceal a more dangerous problem: a student may have used a rule that happened to work once and will fail on the next problem. For example, a student can simplify 16/64 to 1/4 by crossing out the two sixes. The final answer is correct, but the operation is not. A conventional grader passes it; the student leaves believing that digit deletion is fraction reduction. Blindspot was built to find that hidden misunderstanding before it becomes visible as a wrong answer.

### What it does

Blindspot reviews a student's worked fraction-simplification solution, not just its final answer. It determines whether the displayed method is mathematically valid in general. When the final answer is correct but the method is fragile, Blindspot: identifies the exact invalid operation in a diff-style review; explains the valid underlying rule in educator-facing language; generates a same-concept problem with different numbers; and deterministically replays the flawed method to prove it now gives the wrong result. Teachers can submit a new worked solution, keep it in a local review queue, or use Class review to inspect a group of students who share the same hidden misconception.

### How we built it

Blindspot is a Next.js App Router application written in TypeScript and styled with Tailwind CSS. It uses the official OpenAI JavaScript SDK and the Responses API with GPT-5.6. The review route uses system-level instructions plus Zod-backed Structured Outputs to return a schema-safe validity result. The stress-test route asks GPT-5.6 for an adversarial candidate, then validates it with deterministic fraction arithmetic before rendering it. The model proposes; the verification layer decides whether there is proof.

### Challenges we ran into

The main challenge was avoiding a persuasive-looking but unproven AI explanation. A generated counterexample is only useful if the student's exact flawed operation actually breaks on it. We added a deterministic verification gate and reject live candidates that do not differ from the true reduced fraction. We also had to make the product understandable in seconds. The interface now leads with the causal chain—correct answer, method review, counterexample—rather than hiding the most important proof behind a dashboard.

### Accomplishments we're proud of

Built a feedback workflow for misconceptions hidden behind correct answers. Made the core claim testable: every displayed stress test is verified by deterministic arithmetic. Combined free-form AI review with strict structured outputs and local mathematical checks. Delivered an educator-facing class review that turns individual findings into a reteach group.

### What we learned

Correctness and understanding are different signals. A model can make feedback feel intelligent, but trust comes from constraining the model with a verification layer that users can understand. We also learned that the strongest education interaction is often a counterfactual: not merely "this method is wrong," but "apply it once more and watch exactly where it breaks."

### What's next

We are extending the reasoning stress-test engine beyond digit cancellation to additional fraction misconceptions, then to algebra, proportional reasoning, and introductory programming. The longer-term goal is a teacher review layer that surfaces fragile understanding across a class before it turns into failure—while retaining evidence for every intervention.

## README (from the GitHub repository)

# Blindspot

Blindspot finds correct mathematical answers reached through reasoning that does not generalize. It reviews a student's written method, identifies a fragile operation, then constructs and verifies a nearby counterexample before presenting feedback.

## Why it matters

Conventional answer checking treats `16/64 → 1/4` as correct. Blindspot recognizes that deleting matching digits is not fraction reduction. The final answer happens to be right; the method will fail on the next suitable problem.

Blindspot is built for the missing signal: misconceptions hidden behind correct answers.

## Demo flow

1. Open **Class review** to see students grouped by a shared, hidden misconception.
2. Inspect a student solution and run the validity check.
3. Review the method diff: invalid digit deletion versus valid factor cancellation.
4. Generate a stress test. Blindspot applies the same flawed rule to new numbers and verifies that its answer differs from the true reduction.
5. Use **New worked solution** to paste an individual student's fraction work.

## Verification guarantee

Blindspot never shows a stress test merely because an LLM suggested it. Before the UI renders it, deterministic fraction arithmetic applies the detected flawed operation and compares its result with the actual reduced fraction. If they match, the case is rejected.

## Run locally

Follow the steps below to set up the project on your machine.

### 1. Install dependencies

```bash
npm install
```

### 2. Configure environment variables

Create a local environment file by copying the example template:

On macOS or Linux:

```bash
cp .env.example .env
```

On Windows PowerShell:

```powershell
Copy-Item .env.example .env
```

Open the newly created `.env` file and add your API credentials:

```env
OPENAI_API_KEY=your_openai_api_key_here
```

Keep your `.env` file private and do not commit it to version control.

> For local demo mode, the app can run with the default configuration. To enable GPT-based validity analysis and live stress-test generation, provide a valid OpenAI API key.

### 3. Start the development server

```bash
npm run dev
```

The default `config.json` enables reliable demo mode with a deliberate review delay. Set `demoMode` to `false` and provide `OPENAI_API_KEY` to use GPT-5.6 for validity analysis and live stress-test generation.

## GPT-5.6 and Codex

GPT-5.6 evaluates free-form written fraction reasoning and proposes adversarial, same-concept stress tests. Codex was used to build the product, structured API routes, deterministic verification gate, and review-oriented interface. The deterministic layer constrains the model: it may propose, but it cannot assert proof without arithmetic verification.


## Detected evidence (automated analysis)

Indexed codebase: 12 recognized source files, 45 KB.
- CSS (language) — detected in the code
- Next.js (technology) — detected in the code
- OpenAI (technology) — detected in the code
- React (technology) — detected in the code
- Tailwind CSS (technology) — detected in the code
- TypeScript (language) — detected in the code

## Codebase structure (from repository index)

### Files (19 of 19)

```
.gitignore
config.json
eslint.config.mjs
LICENSE
next.config.ts
package.json
postcss.config.mjs
README.md
src/app/api/check/route.ts
src/app/api/stress-test/route.ts
src/app/globals.css
src/app/layout.tsx
src/app/page.tsx
src/lib/demo.ts
src/lib/fractions.ts
src/lib/openai.ts
src/lib/samples.ts
src/lib/types.ts
tsconfig.json
```

### Dependencies

- package.json: @tailwindcss/postcss@^4, @types/node@^20, @types/react@^19, @types/react-dom@^19, eslint@^9, eslint-config-next@16.2.10, next@16.2.10, openai@^6.48.0, react@19.2.4, react-dom@19.2.4, tailwindcss@^4, typescript@^5, zod@^4.4.3

### Recent commits (newest first)

- Include MIT License file
- Initial Blindspot implementation

## Key source files (fetched from GitHub, selected and truncated for size)

### package.json

```
{
  "name": "blindspot-app",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "eslint"
  },
  "dependencies": {
    "next": "16.2.10",
    "openai": "^6.48.0",
    "react": "19.2.4",
    "react-dom": "19.2.4",
    "zod": "^4.4.3"
  },
  "devDependencies": {
    "@tailwindcss/postcss": "^4",
    "@types/node": "^20",
    "@types/react": "^19",
    "@types/react-dom": "^19",
    "eslint": "^9",
    "eslint-config-next": "16.2.10",
    "tailwindcss": "^4",
    "typescript": "^5"
  }
}

```

### src/app/layout.tsx

```typescript
import type { Metadata } from "next";
import "./globals.css";

export const metadata: Metadata = {
  title: "Blindspot — Fraction method review",
  description: "Check whether correct math reasoning generalizes.",
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en">
      <body>{children}</body>
    </html>
  );
}

```

### src/app/page.tsx

```typescript
"use client";

import { useEffect, useState } from "react";
import { samples } from "@/lib/samples";
import type { StressTest, ValidityResult, WorkedSolution } from "@/lib/types";

type Screen = "select" | "checking" | "result" | "testing" | "proof" | "class";

export default function Home() {
  const [screen, setScreen] = useState<Screen>("select");
  const [selected, setSelected] = useState<WorkedSolution>(samples[4]);
  const [customMode, setCustomMode] = useState(false);
  const [savedSolutions, setSavedSolutions] = useState<WorkedSolution[]>([]);
  const [custom, setCustom] = useState({ problem: "Simplify 16/64", work: "Cancel the 6s\n16/64 → 1/4", answer: "1/4" });
  const [result, setResult] = useState<ValidityResult | null>(null);
  const [stressTest, setStressTest] = useState<StressTest | null>(null);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    const stored = window.localStorage.getItem("blindspot:worked-solutions");
    if (!stored) return;
    try { setSavedSolutions(JSON.parse(stored)); } catch { window.localStorage.removeItem("blindspot:worked-solutions"); }
  }, []);

  function saveCustomSolution(solution: WorkedSolution) {
    setSavedSolutions((current) => {
      const next = [solution, ...current.filter((item) => item.id !== solution.id)].slice(0, 20);
      window.localStorage.setItem("blindspot:worked-solutions", JSON.stringify(next));
      return next;
    });
    setSelected(solution);
  }

  async function check(override?: WorkedSolution) {
    setScreen("checking"); setError(null);
    try {
      const target = override ?? selected;
      const isBuiltInSample = samples.some((sample) => sample.id === target.id);
      const response = await fetch("/api/check", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(isBuiltInSample ? { sampleId: target.id } : { solution: target }) });
      const data = await response.json();
      if (!response.ok) throw new Error(data.error);
      setResult(data); setScreen("result");
    } catch (err) { setError(err instanceof Error ? err.message : "Check failed."); setScreen("select"); }
  }

  async function createStressTest() {
    setScreen("testing"); setError(null);
    try {
      const isBuiltInSample = samples.some((sample) => sample.id === selected.id);
      const response = await fetch("/api/stress-test", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(isBuiltInSample ? { sampleId: selected.id } : { solution: selected }) });
      const data = await response.json();
      if (!response.ok) throw new Error(data.error);
      setStressTest(data); setScreen("proof");
    } catch (err) { setError(err instanceof Error ? err.message : "Stress test failed."); setScreen("result"); }
  }

  function reset() { setScreen("select"); setResult(null); setStressTest(null); setError(null); }
  function openClassReview() { setCustomMode(false); setScreen("class"); setResult(null); setStressTest(null); setError(null); }

  return <main className="app-shell">
    <header className="topbar"><div className="brand"><span className="brand-mark">B</span><span>blindspot</span><small>TEACHER REVIEW</small></div><div className="run-state"><i /> CORRECTNESS IS NOT UNDERSTANDING</div><div className="header-actions"><button onClick={openClassReview}>Class review</button><button onClick={() => { setCustomMode(true); reset(); }}>New review</button></div></header>
    <nav className="mobile-nav" aria-label="Review navigation"><button className={screen === "class" ? "active" : ""} onClick={openClassReview}>Class review <span>{samples.filter((sample) => sample.method === "digit-cancel").length}</span></button><button className={customMode && screen === "select" ? "active" : ""} onClick={() => { setCustomMode(true); reset(); }}>New worked solution</button><button className={!customMode && screen === "select" ? "active" : ""} onClick={() => { setCustomMode(false); reset(); }}>Review queue</button></nav>
    <div className="workspace">
      <aside className="sidebar"><button className={`class-review ${screen === "class" ? "active" : ""}`} onClick={openClassReview}><span>▦</span><b>Class review</b><em>{samples.filter((sample) => sample.method === "digit-cancel").length}</em></button><div className="side-heading">WORKED SOLUTIONS <span>{samples.length + savedSolutions.length}</span></div>
        <button className={`new-solution ${customMode ? "active" : ""}`} onClick={() => { setCustomMode(true); reset(); }}>+ New worked solution</button><div className="sample-list">{savedSolutions.map((sample) => <button key={sample.id} className={`sample ${!customMode && selected.id === sample.id ? "active" : ""}`} onClick={() => { setCustomMode(false); setSelected(sample); reset(); }}><span className="status-dot pending" /><span><b>Saved review</b><code>{sample.numerator}/{sample.denominator} → {sample.finalAnswer}</code></span></button>)}{samples.map((sample) => <button key={sample.id} className={`sample ${!customMode && selected.id === sample.id ? "active" : ""}`} onClick={() => { setCustomMode(false); setSelected(sample); reset(); }}><span className={`status-dot ${sample.method === "factor" ? "valid" : "invalid"}`} /><span><b>{sample.id.replace("-", " ")}</b><code>{sample.numerator}/{sample.denominator} → {sample.finalAnswer}</code></span></button>)}</div>
        <div className="side-note">A passing final answer is not sufficient. Blindspot tests the method.</div>
      </aside>
      <section className="panel">
        <div className="panel-header"><span>{screen === "class" ? "CLASS REVIEW" : customMode ? "NEW REVIEW" : "WORKED SOLUTION"}</span><span className="mono muted">{screen === "class" ? "INTERVENTION QUEUE" : selected.id.toUpperCase()}</span></div>
        <div className="content">
          {error && <div className="error">{error}</div>}
          {screen === "class" && <ClassReview onInspect={(solution) => { setSelected(solution); setCusto
[truncated — 9306 more characters]
```

### src/app/api/check/route.ts

```typescript
import { NextResponse } from "next/server";
import { demoMode, pause } from "@/lib/demo";
import { findSample } from "@/lib/samples";
import type { ValidityResult, WorkedSolution } from "@/lib/types";
import { openaiClient, reviewInstructions, validityFormat } from "@/lib/openai";

function fixture(solution: WorkedSolution): ValidityResult {
  const digitCancellation = solution.method === "digit-cancel" || /cancel|cross.?out|delete/i.test(solution.steps.join(" "));
  if (!digitCancellation) return { valid: true, flaw: null, explanation: "Each numerator and denominator is divided by the same common factor. That operation preserves the value of every fraction, so the method generalizes.", confidence: "high" };
  return { valid: false, flaw: "The work deletes a matching digit instead of dividing both terms by a common factor.", explanation: "Digits are part of a number's notation, not factors. This answer is correct only because these particular numbers happen to reduce to the same fraction after the digit deletion.", confidence: "high" };
}

export async function POST(request: Request) {
  const { sampleId, solution } = await request.json() as { sampleId?: string; solution?: WorkedSolution };
  const input = solution ?? (sampleId ? findSample(sampleId) : undefined);
  if (!input) return NextResponse.json({ error: "A worked solution is required." }, { status: 400 });
  if (demoMode) { await pause(); return NextResponse.json(fixture(input)); }
  try {
    const response = await openaiClient().responses.parse({
      model: "gpt-5.6",
      instructions: reviewInstructions,
      input: `Review this worked solution:\n${JSON.stringify(input)}`,
      text: { format: validityFormat },
      max_output_tokens: 350,
    });
    if (!response.output_parsed) return NextResponse.json({ error: "The review could not be structured safely." }, { status: 502 });
    return NextResponse.json(response.output_parsed);
  } catch {
    return NextResponse.json({ error: "Validity check could not run." }, { status: 502 });
  }
}

```

### src/app/api/stress-test/route.ts

```typescript
import { NextResponse } from "next/server";
import { digitCancel, simplify } from "@/lib/fractions";
import { demoMode, pause } from "@/lib/demo";
import { findSample } from "@/lib/samples";
import type { WorkedSolution } from "@/lib/types";
import { openaiClient, stressCaseFormat } from "@/lib/openai";
import type { StressTest } from "@/lib/types";

const candidatesByDigit: Record<string, [number, number][]> = {
  "6": [[16, 46], [36, 65], [26, 67]],
  "9": [[19, 49], [29, 59], [39, 69]],
};

export async function POST(request: Request) {
  const { sampleId, solution } = await request.json() as { sampleId?: string; solution?: WorkedSolution };
  const original = solution ?? (sampleId ? findSample(sampleId) : undefined);
  const cancelledDigit = original?.cancelledDigit ?? original?.steps.join(" ").match(/(?:cancel|cross.?out|delete)\s+(?:the\s+)?(\d)/i)?.[1];
  if (!original || !cancelledDigit) return NextResponse.json({ error: "This method is not yet supported for an automated stress test." }, { status: 400 });
  if (demoMode) await pause();
  if (!demoMode) {
    try {
      for (let attempt = 0; attempt < 3; attempt += 1) {
        const response = await openaiClient().responses.parse({
          model: "gpt-5.6",
          instructions: `You generate adversarial fraction-simplification tests. Produce two integers that BOTH contain the digit ${cancelledDigit}. A student will delete one occurrence of that digit from each term. Choose numbers where that deletion gives a different reduced fraction from the true reduction. Return only the requested schema.`,
          input: `Original worked solution: ${JSON.stringify(original)}`,
          text: { format: stressCaseFormat },
          max_output_tokens: 100,
        });
        const candidate = response.output_parsed;
        if (!candidate) continue;
        const flawedAnswer = digitCancel(candidate.numerator, candidate.denominator, cancelledDigit);
        const correctAnswer = simplify(candidate.numerator, candidate.denominator);
        if (!flawedAnswer || flawedAnswer === correctAnswer) continue;
        return NextResponse.json({ problem: `Simplify ${candidate.numerator}/${candidate.denominator}`, ...candidate, correctAnswer, flawedAnswer, verification: "confirmed" satisfies "confirmed" });
      }
      return NextResponse.json({ error: "No verified stress test was generated. Please retry." }, { status: 422 });
    } catch {
      return NextResponse.json({ error: "Stress-test generator could not run." }, { status: 502 });
    }
  }
  // Verification gate: choose only a case where applying the exact digit deletion disagrees with reduction.
  const candidate = (candidatesByDigit[cancelledDigit] ?? []).find(([n, d]) => digitCancel(n, d, cancelledDigit) !== simplify(n, d));
  if (!candidate) return NextResponse.json({ error: "No verified stress test found." }, { status: 422 });
  const [numerator, denominator] = candidate;
  const flawedAnswer = digitCancel(numerator, denominator, cancelledDigit)!;
  const correctAnswer = simplify(numerator, denominator);
  const result: StressTest = { problem: `Simplify ${numerator}/${denominator}`, numerator, denominator, correctAnswer, flawedAnswer, verification: "confirmed" };
  return NextResponse.json(result);
}

```

### next.config.ts

```typescript
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  /* config options here */
};

export default nextConfig;

```

### src/lib/demo.ts

```typescript
import config from "../../config.json";

export const demoMode = config.demoMode;
export const pause = () => new Promise((resolve) => setTimeout(resolve, 1050));

```

### src/lib/types.ts

```typescript
export type WorkedSolution = {
  id: string;
  problem: string;
  numerator: number;
  denominator: number;
  steps: string[];
  finalAnswer: string;
  method: "factor" | "digit-cancel";
  cancelledDigit?: string;
};

export type ValidityResult = {
  valid: boolean;
  flaw: string | null;
  explanation: string;
  confidence: "high" | "medium" | "low";
};

export type StressTest = {
  problem: string;
  numerator: number;
  denominator: number;
  correctAnswer: string;
  flawedAnswer: string;
  verification: "confirmed";
};

```

### src/lib/fractions.ts

```typescript
export function gcd(a: number, b: number): number {
  return b === 0 ? Math.abs(a) : gcd(b, a % b);
}

export function simplify(numerator: number, denominator: number) {
  const divisor = gcd(numerator, denominator);
  return `${numerator / divisor}/${denominator / divisor}`;
}

export function digitCancel(numerator: number, denominator: number, digit: string) {
  const nextNumerator = Number(String(numerator).replace(digit, ""));
  const nextDenominator = Number(String(denominator).replace(digit, ""));
  if (!nextNumerator || !nextDenominator) return null;
  return simplify(nextNumerator, nextDenominator);
}

```

### src/lib/openai.ts

```typescript
import OpenAI from "openai";
import { zodTextFormat } from "openai/helpers/zod";
import { z } from "zod";

export const validitySchema = z.object({
  valid: z.boolean(),
  flaw: z.string().nullable(),
  explanation: z.string().min(1).max(500),
  confidence: z.enum(["high", "medium", "low"]),
});

export const stressCaseSchema = z.object({
  numerator: z.number().int().min(10).max(999),
  denominator: z.number().int().min(10).max(999),
});

export const reviewInstructions = `You are Blindspot's mathematical reasoning reviewer. Evaluate the METHOD, not just the final answer. Your domain is fraction simplification.

Valid methods preserve a fraction by dividing numerator and denominator by the same non-zero common factor. Deleting visually matching digits is invalid unless it can be justified as factor cancellation; do not infer validity from a coincidentally correct final answer.

Return a concise educator-facing explanation. If the supplied work is insufficient to determine the method, return valid=false, explain that the reasoning cannot be verified, and set confidence="low". Never reveal hidden chain-of-thought; state only the conclusion and the short mathematical justification.`;

export function openaiClient() {
  if (!process.env.OPENAI_API_KEY) throw new Error("OPENAI_API_KEY is not configured.");
  return new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
}

export const validityFormat = zodTextFormat(validitySchema, "validity_review");
export const stressCaseFormat = zodTextFormat(stressCaseSchema, "stress_test_candidate");

```

[2 more indexed source files omitted to keep this export small. The full file list is in the Codebase structure section above.]