This document is written for someone who wants to truly understand why the system works the way it does — not just what the files are. Every architectural decision, every flow, every tradeoff is explained in depth. Written specifically so you can answer any question in an interview about this project confidently.
Yes, with one conditional layer. The core loop is fully closed end-to-end:
- A user can enter any non-premium LeetCode problem number, paste their code in Python, C++, or Java, and receive a complete structured analysis
- The LLM pipeline (Groq) works unconditionally — correctness verdict, acceptance likelihood vs. constraints, complexity, code quality, pattern identification, and Socratic hints
- The execution layer (Judge0) works when configured, and degrades gracefully with a clear user-facing message when it isn't — the rest of the analysis is unaffected either way
- The problem metadata cache (Neon Postgres) lazily enriches on first access and serves from cache on every subsequent request
What "MVP-complete" means here: user enters problem number + code → presses Analyze → receives a judge-style breakdown of their submission, grounded in the problem's actual constraints and verified against its public example test cases where possible. That loop is closed.
Yes, with no feature-building remaining for the MVP scope. The architecture, data model, and all pipelines are stable. What remains is purely operational:
- Architecture: finalized — monorepo, layered backend, stateless frontend, lazy DB cache
- Data model: finalized — single
problemstable, no schema changes anticipated for MVP - Remaining work type: operational — seed the database, set env vars, deploy to Render + Vercel
Truly critical (will break without these):
DATABASE_URLmust be set to a valid Neon Postgres connection string with?sslmode=requireat the end, or the server crashes on startup with aDATABASE_URL is not seterrorGROQ_API_KEYmust be set to a valid Groq API key, or every/api/analyzerequest returns a 502 with "Analysis failed"pnpm seedmust run to completion (10–15 min) before any analysis request — without it, theproblemstable is empty and every request returns "No problem #X"VITE_API_BASE_URLmust be set in Vercel to your Render server URL before the frontend build is deployed — without it, API calls go tohttp://localhost:4000which is unreachable in production
Not critical but strongly recommended before showing to anyone:
- Verify
scripts/verifyLeetcodeDetail.tsreturns populatedmetaDataandexamplesfor at least 2–3 problem slugs — the LeetCode API is unofficial and can change shape - Check
console.groq.com/docs/deprecationsand confirm the model ID inservices/groq.tsis not deprecated - Set
CLIENT_ORIGINin Render to your Vercel URL to lock down CORS for production
Yes. The architecture follows three well-established patterns:
- Layered service architecture — HTTP entry → routes → services (external APIs) → DB → response. Every layer has exactly one responsibility. This determines where to look when something breaks.
- Lazy cache with explicit enrichment — problem metadata is fetched from LeetCode once per problem and stored permanently in Postgres. No re-fetching, no expiry complexity, no cache invalidation problem.
- Graceful degradation — the execution layer, the harness generator, and the LeetCode example extraction all fail safely and explicitly: the Groq analysis always runs regardless of what upstream layers produce.
Not overengineered (no Kubernetes, no message queues, no microservices) and not underengineered (real type safety throughout, real rate limiting, real structured LLM output).
| Category | Item |
|---|---|
| Deployment | Set CLIENT_ORIGIN on Render after Vercel URL is known |
| Security hardening | Add helmet.js for HTTP security headers (1 line) |
| Production hardening | Add a structured logger (pino) to replace console.error |
| UI/UX refinement | Render spin-up warning ("first request may take 30s on free tier") |
| Testing improvement | Add integration tests for the harness generators using real problem fixtures |
| Scalability | Cache problem detail in-memory (LRU) to reduce Neon round-trips |
| Optional polishing | Add Judge0 on Cloudflare Tunnel for execution in the deployed version |
verdict is a stateless LeetCode submission analyzer — a tool that takes a problem number and a code submission, then produces a judge-style structured breakdown of that code's correctness, complexity, and quality, grounded in the problem's actual constraints and verified against its real example test cases wherever possible.
Think of it as a personal coaching layer on top of the LeetCode judge. Unlike LeetCode's binary "Accepted / Wrong Answer" verdict, verdict tells you why — "your O(n²) approach will likely TLE against the stated n ≤ 10⁵ constraint," "you handled the base case correctly but missed the negative number edge," "this is a Sliding Window problem, not Two Pointers." And when something is wrong, it gives you a Socratic hint instead of the answer, because the point is that you figure it out.
A concrete example: a user pastes LeetCode #200 (Number of Islands) with a BFS solution. verdict runs the code against the 2 public examples via Judge0, sees both pass, hands the code and problem context to Groq, and returns: correctness = "likely correct (both public examples pass)," acceptance likelihood = "likely accepted (O(m×n) is well within the stated 1 ≤ m, n ≤ 300 constraint)," complexity = "Time O(m×n), Space O(min(m×n)) for the BFS queue," pattern = "Breadth-First Search / Union-Find," code quality = 3 specific notes, optimization = "a Union-Find approach exists with the same complexity but better real-world cache behavior (array-based disjoint set)." All of this returned in one API call, rendered in one UI.
Competitive programmers preparing for SDE interviews: They already understand time/space complexity, know what BFS and DP mean, and don't need an LLM to explain what a linked list is. They need something that reads their code like an experienced engineer would — tells them if their complexity claim holds against the problem's real constraints, flags the edge cases they missed, and points at the more elegant approach without just handing them the solution. The acceptance likelihood feature ("your O(n²) will TLE for n ≤ 10⁵") is specifically for them — it's a CP instinct encoded as a feature.
ECE/CS undergrads in internship prep: Solving problems in isolation without feedback is how bad habits form. They submit to LeetCode, get "Wrong Answer on test case 73," and don't know why. verdict gives them the in-between layer: "your code passes the visible examples but here's what's logically wrong, and here's what you should be thinking about."
- No accounts, no login, no history. Every request is stateless — problem number + language + code in, structured analysis out. There's nothing to persist about the user.
- No full problem catalog. The app never stores or displays LeetCode's problem descriptions. It only stores the structural metadata needed for analysis: title, difficulty, tags, constraints line, and example input/output literals. No prose, no paid-content risk.
- No "did you get Accepted." The tool gives acceptance likelihood against the stated constraints — not a binary verdict on LeetCode's hidden test suite, which we don't have access to. The distinction is engineered into the UI copy and the LLM prompt on purpose.
- Execution is a check on the LLM, not a replacement. Judge0 runs the code against public examples to give the LLM a factual ground truth — "passes both examples" or "fails example 2 with output [1] instead of [0,1]." The LLM then reasons about that result. Neither the LLM alone nor execution alone is sufficient: the LLM can hallucinate, and public examples don't cover edge cases.
- Hints are Socratic, not solutions. The prompt explicitly instructs Groq to give a directional nudge ("what category of input breaks your invariant?") not a fix. Giving the answer would defeat the purpose.
This model was chosen because it's the minimum viable coaching loop: submit → run → analyze → reason → hint. Everything else (user accounts, problem browsing, full test suites, leaderboards) adds product complexity that's out of scope for a portfolio project intended to demonstrate AI pipeline architecture.
The single most important architectural decision in this project: the LLM is one component in a verification pipeline, not the oracle. The server never:
- Claims execution results it doesn't have ("uses LLM to guess if code runs correctly" is not what this is)
- Trusts the LLM's complexity claim without grounding it in the problem's actual constraints
- Shows the LLM's raw output without structuring it first (all Groq responses are JSON-validated before returning to the client)
The LLM receives real execution results (or an explicit statement that execution isn't available and why), real constraint text pulled from the problem's own description, and real topic tags. It reasons about those. If any upstream layer fails — LeetCode API is down, Judge0 isn't configured, examples couldn't be extracted — the system tells the user exactly what's available and what it's reasoning from. Nothing is hidden, nothing is fabricated.
React is a JavaScript library for building component-based UIs. verdict uses:
- Functional components with hooks —
useStatefor status/result/error state inApp.tsx,useStateinHintPanel.tsxfor the on-demand hint fetch lifecycle - Conditional rendering — the entire results section, loading state, error banner, and hint panel are conditionally rendered based on the
statusstate machine inApp.tsx - Props-down, events-up —
App.tsxowns all state.ProblemFormreceivesonSubmitanddisabled.AnalysisResultreceives theresultandsubmission. No state lives in child components except the hint panel's own async lifecycle.
Why React and not vanilla JS? The UI has non-trivial conditional rendering: 4 states (idle, loading, success, error), a results section with 6+ sub-components, and an async hint panel with its own state. Managing this in vanilla JS means manual DOM updates, manual state syncing, and manual cleanup. React makes the UI a pure function of state: when status changes, the right thing renders automatically.
Why not Next.js? This is a pure client-side tool — no SEO requirements, no SSR needed, no pages. Next.js adds a Node.js render server, file-based routing, and hydration complexity that is genuine overkill for a single-page tool. Vite + React is the right scope.
Why React 19 specifically? React 19 is the current stable release. No React 19-specific features are used — it's React 18 patterns under the hood. Using the current major version is just correctness.
Vite is a build tool that uses native ES modules during development (no bundling, instant updates) and Rollup for the production bundle.
During development (pnpm dev:client): Vite serves each module as a native ES import. When you save a file, only that module is updated — not the whole bundle. Hot Module Replacement (HMR) updates the UI in under 100ms.
During production (vite build): Vite bundles with Rollup, tree-shakes unused code, and produces a single CSS file and one JS chunk. The client bundle is ~200KB gzipped — small because there are no heavy dependencies (no state management library, no component library, no router).
Why Vite and not Create React App (CRA)? CRA uses webpack, which re-bundles the entire app on every save. For a 10-component app this is fine; at scale it becomes slow. But the real reason to avoid CRA in 2025 is that it's unmaintained. Vite is the current community standard for React apps.
The critical detail — VITE_API_BASE_URL: Vite replaces import.meta.env.VITE_* variables at build time, not at runtime. This means the API base URL is baked into the bundle when Vercel builds it. If you set the env var after the build, it has no effect — you need to trigger a redeploy. This is why the Vercel environment variable must be set before clicking Deploy.
No CSS framework. All styling is hand-written in src/index.css using CSS custom properties (design tokens) for the color/spacing/typography system.
Why no Tailwind or a component library? Two reasons. First, for a project this small (10 components, one page), importing a full component library like MUI or Ant Design adds 300–500KB to the bundle and several hundred dependencies you don't control. Second, the design needed a specific aesthetic — dark, monospace-heavy, CP-judge feel — that generic component libraries fight against. CSS custom properties give full control.
Our design tokens:
--bg: #14171f; /* page background */
--surface: #1c2030; /* card background */
--surface-raised: #232838; /* input/inner card */
--border: #2a2f42; /* default border */
--accent: #e8a33d; /* amber — primary accent, brand color */
--success: #4fb286; /* green — correct/pass */
--error: #e2675a; /* red — wrong/fail */
--font-display: "Space Grotesk" /* headers */
--font-body: "Inter" /* prose */
--font-mono: "JetBrains Mono" /* code, monospace labels */The verdict chip (verdict-chip) is the signature visual element — a bordered badge that shows ACCEPTED, WRONG ANSWER, or UNVERIFIED in JetBrains Mono with color-matched border and background, styled to look like a real judge output. Every other design decision cascades from making that one element look authoritative.
There is no state management library (no Redux, no Zustand, no Jotai). All application state is useState in App.tsx and passed down as props.
The state that App.tsx owns:
status—"idle" | "loading" | "success" | "error"— drives which section rendersresult—AnalyzeResponse | null— the full API response, passed toAnalysisResulterrorMessage—string | null— the user-facing error textlastSubmission— the problem number, language, and code from the last submitted form — passed toHintPanelso it can make the hint API call with the same context
Why no state management library? The app has four states and one async operation. Redux or Zustand would add more code than the state they'd manage. The rule is: use a state library when prop drilling goes more than 2–3 levels deep or when multiple disconnected components need the same state. Neither condition is met here. HintPanel is the deepest component and it needs exactly two values from lastSubmission, passed as props.
Express is a minimal Node.js web framework. A request flows through:
Client request
→ cors() middleware (checks Origin header against CLIENT_ORIGIN)
→ express.json({ limit: '100kb' }) (parses JSON body, rejects oversized payloads)
→ analyzeRateLimiter (checks per-IP request count, rejects at 8/10min)
→ route handler in analyze.ts or problems.ts
→ service calls (leetcode.ts, judge0.ts, groq.ts)
→ res.json({ ... }) response
Why Express and not Fastify or Hono? Familiarity and ecosystem. Express has the largest Node.js ecosystem, the most readable middleware model, and is what most SDE interview interviewers recognize. The performance difference between Express and Fastify matters at tens of thousands of concurrent requests — not at portfolio/demo scale.
Why TypeScript? Three reasons that matter for this project specifically. First, the data flowing through this system is complex — ProblemMetaData, ProblemExample, ExecutionResult, AnalysisResult — and TypeScript catches shape mismatches at compile time instead of producing undefined is not a function at runtime. Second, the Groq response is untrusted JSON from an LLM — TypeScript forces you to explicitly cast and validate it. Third, the harness generators build code strings from typed values — type errors in the generators produce wrong code, and TypeScript catches the bad arguments before the program even runs.
"type": "module" in package.json: The server uses ES module syntax (import/export) throughout, which requires "type": "module". This means all imports must use the .js extension (even when the source file is .ts) — this is a TypeScript/ESM quirk that trips people up. import { db } from "./client.js" looks wrong in a .ts file but it's correct because TypeScript compiles to .js files and Node resolves .js.
Drizzle is a TypeScript-first ORM that generates type-safe queries from a schema definition. It's different from Prisma or TypeORM in that it doesn't hide SQL — queries look like SQL with TypeScript on top.
Neon is a serverless Postgres host with a genuinely free tier (no credit card, persistent, no auto-pause unless idle for 5+ days). It uses HTTP-based SQL execution instead of a persistent TCP connection, which is why the import is @neondatabase/serverless instead of pg.
Why Postgres and not MongoDB? The data in this project is strongly relational and has a known, fixed schema. The problems table has exactly these columns and no others. There are no variable-length documents, no nested JSON that changes shape, no schema evolution needed. Postgres's strict typing (integer, varchar, jsonb for known-shape arrays) is correct for this data. MongoDB's schema-less flexibility would add complexity without benefit.
Why Neon and not Supabase, PlanetScale, or a regular Postgres host? Neon's free tier has no credit card requirement and no artificial row limits. Supabase free tier pauses after 1 week of inactivity. PlanetScale free tier was discontinued. A regular Postgres host (Railway, Render) requires a paid plan for persistence. Neon is the only free-forever Postgres option that doesn't require a card.
Why Drizzle and not Prisma? Prisma generates a Prisma Client from a .prisma schema and runs migrations. It's excellent but adds a code-generation step that can be surprising in monorepo setups. Drizzle uses plain TypeScript objects as the schema — no codegen, no binary, just import { problems } from "./schema.js". For a project with one table and no migrations, Drizzle is simpler.
drizzle-kit push vs migrations: pnpm db:push uses Drizzle's push command, which diffs the TypeScript schema against the live database and applies the changes directly — no migration files. This is the right tool for development and for a project that has never been in production with real user data. If this project ever had real user data and needed to change the schema, you'd switch to drizzle-kit generate (which produces SQL migration files) + drizzle-kit migrate (which applies them). push is safe here because the DB is seeded, not user-generated.
leetcode-query is a third-party npm package that wraps LeetCode's unofficial GraphQL endpoint. We use it for exactly one thing: lc.problem(slug) — which hits LeetCode's questionData GraphQL query and returns the problem's content (HTML), metaData (JSON string of function signature), and exampleTestcases.
Why use this package instead of calling GraphQL directly? The problem() query uses several undocumented fields and requires LeetCode's session cookies to be handled correctly for non-public problems. The package handles authentication (for public problems, no credentials are needed) and abstracts the exact query shape. For the bulk listing query (seed script) we call GraphQL directly because leetcode-query doesn't expose a paginated listing API.
The critical caveat: This is an unofficial API. LeetCode has changed field names before without notice. The verifyLeetcodeDetail.ts script exists specifically to confirm the fields we depend on (content, metaData, exampleTestcases) are still shaped as expected before you run real traffic against the pipeline.
Groq is an inference provider that runs open-weight LLMs (LLaMA, Gemma, etc.) on custom hardware (Language Processing Units). The free tier allows ~1,000–14,400 requests/day depending on the model, with no credit card required.
Why Groq and not OpenAI? OpenAI requires a credit card and charges per token. Groq's free tier is genuinely free with a usable rate limit for a portfolio project. The models available on Groq (the LLaMA 3 family, openai/gpt-oss-120b) are competitive with GPT-4o for structured reasoning tasks.
Why response_format: { type: "json_object" } (JSON mode)? Without JSON mode, LLMs wrap their output in markdown fences (json ... ), include preamble ("Sure, here's the analysis:"), and occasionally produce invalid JSON. JSON mode constrains the model to output only valid JSON — no fences, no prose. The system prompt defines the exact shape. This is the difference between a reliable pipeline and a fragile one.
The structured output contract: The Groq system prompt specifies exactly this JSON shape:
{
"correctness": { "verdict": "correct|incorrect|uncertain", "explanation": "..." },
"acceptanceLikelihood": { "verdict": "likely|likely-tle|unknown", "reasoning": "..." },
"complexity": { "time": "O(...)", "space": "O(...)", "explanation": "..." },
"codeQuality": { "notes": ["...", "...", "..."] },
"pattern": "Depth-First Search",
"hasOptimizationHint": true,
"correctnessHint": "..." // null unless verdict is "incorrect"
}After parsing, the server validates the shape before returning it to the client. If Groq returns malformed JSON (rare, but possible), the server returns a 502 with a message asking the user to retry.
Temperature 0.2 for analysis, 0.3 for hints: Lower temperature = more deterministic, less creative. For code analysis you want repeatability — the same code should get the same verdict. For hints you want slightly more variation so repeated requests don't return the same sentence.
Judge0 is an open-source online code execution system. It accepts source code, compiles and runs it in an isolated Linux container (using isolate under the hood), and returns stdout, stderr, compile output, execution time, and a status (Accepted, Wrong Answer, Runtime Error, Time Limit Exceeded, etc.).
Why self-hosted instead of the RapidAPI public Judge0? The public free tier allows ~50 requests/day. A real user doing 5–10 problems in a session would hit that. The self-hosted version on a local machine (or a cloud VM) has no per-request limit.
The ?wait=true parameter: Judge0 has two modes — async (submit, poll for result) and sync (submit, wait, get result in one response). We use wait=true (sync mode) to keep the server code simple: one fetch call returns the result. The 15-second timeout in judge0.ts ensures a hung execution doesn't block the server indefinitely.
Language IDs (from judge0.ts):
- Python: 71 (Python 3.8.1)
- C++: 54 (C++ GCC 9.2.0)
- Java: 62 (Java OpenJDK 13.0.1)
These IDs are stable across Judge0 CE versions but should be confirmed against GET /languages on your own instance.
express-rate-limit is middleware that counts requests per IP within a time window and returns a 429 response when the limit is exceeded.
Why is rate limiting critical on a login-free app? Without auth, there's no per-user quota to enforce. Anyone who finds the URL can call /api/analyze in a loop and drain your Groq free-tier quota (1,000 requests/day) in minutes. Rate limiting is the only cost guardrail.
The limits set:
/api/analyze→ 8 requests per 10 minutes per IP. A real user solving problems doesn't need more than this. A bot farming requests gets stopped./api/problems/:number→ 30 requests per minute per IP. Problem metadata lookups are cheap and the response is cached in Postgres — this just prevents trivial scraping.
Why per-IP and not per-session? Sessions require cookies or tokens, which requires auth, which contradicts the "no login" design. IP-based limiting is imperfect (shared NAT means one IP = many users) but it's the correct tradeoff for a login-free tool at this scale.
pnpm workspaces is a feature of the pnpm package manager that lets multiple package.json files share a single node_modules at the root, with symlinks per workspace.
The structure:
leetcode-analyzer/ ← root workspace
├── pnpm-workspace.yaml ← declares apps/* as workspaces
├── node_modules/ ← single shared module store
├── apps/server/ ← workspace "server"
└── apps/client/ ← workspace "client"
Why a monorepo? Server and client are deployed separately (Render + Vercel) but share types. Keeping them in one repo means one git history, one PR per feature, and the ability to import shared types if needed. pnpm --filter server exec ... runs a command in only the server workspace — pnpm db:push maps to this.
Why pnpm and not npm workspaces or Turborepo? pnpm is faster and uses hard links instead of copies (saves disk space with many packages). Turborepo adds caching for CI builds — overkill for two workspaces with no CI pipeline yet.
problems (single table — no foreign keys)
│
├── frontendId (PRIMARY KEY) — the number the user types in
├── slug — used to fetch full detail from LeetCode API
├── title / difficulty / tags — from seed (bulk listing)
└── constraints / metaData / examples / detailsFetchedAt
— null until first request for this problem, then populated and cached permanently
This is a deliberately simple schema. One table, no joins, no foreign keys. The project has no users, no sessions, no submissions table — because the analysis is stateless. The database exists only to cache LeetCode's metadata so we don't hit their API on every request.
frontend_id INTEGER PRIMARY KEY
slug VARCHAR(255) NOT NULL UNIQUE
title VARCHAR(255) NOT NULL
difficulty VARCHAR(10) NOT NULL -- 'Easy' | 'Medium' | 'Hard'
tags JSONB NOT NULL DEFAULT [] -- string[]
constraints TEXT NULL -- populated lazily
meta_data JSONB NULL -- ProblemMetaData | null, populated lazily
examples JSONB NULL -- ProblemExample[], populated lazily
details_fetched_at TIMESTAMP NULL -- null = not yet enriched
created_at TIMESTAMP NOT NULL DEFAULT NOW()Purpose: A lazily-enriched cache of LeetCode problem metadata. Bulk fields are populated by seedProblems.ts for all problems at once. Detail fields are populated on first request for that specific problem.
The frontend_id column: This is the number shown in the LeetCode URL — https://leetcode.com/problems/two-sum/ has frontendQuestionId = 1. It's what the user types in. This is different from LeetCode's internal questionId (which LeetCode uses for ordering but doesn't expose in URLs). The seed script pulls questionFrontendId from the GraphQL API and stores it here. Using the wrong ID type — internal vs. frontend — would mean "problem #1" returns the wrong problem or nothing.
The slug column: LeetCode's URL-safe problem identifier — two-sum, reverse-linked-list, maximum-depth-of-binary-tree. This is what leetcode-query's .problem() method accepts. The seed populates both frontend_id and slug so we can go from user input (number) → slug → full detail without a second API call to look up the slug.
The meta_data JSONB column: Stores LeetCode's function signature for the problem as a structured object:
{
"name": "twoSum",
"params": [
{ "name": "nums", "type": "integer[]" },
{ "name": "target", "type": "integer" }
],
"return": { "type": "integer[]" }
}This is what the harness generator reads to know: what function to call, what arguments to construct, what types to serialize. Without metaData, execution is unavailable — the harness can't be generated. The isHarnessSupported() function checks this.
The examples JSONB column: Stores extracted public example test cases:
[
{ "input": "nums = [2,7,11,15], target = 9", "output": "[0,1]" },
{ "input": "nums = [3,2,4], target = 6", "output": "[1,2]" }
]These are the inputs the harness generator uses to build the runnable program. The input string must be parsed — parse.ts handles converting "nums = [2,7,11,15], target = 9" into [[2,7,11,15], 9] as typed JS values.
The details_fetched_at column: The lazy enrichment sentinel. null means "seed populated this row but we haven't fetched the full detail yet." Any non-null timestamp means "fully enriched, serve from cache." The getOrEnrichProblem() function in problemRepo.ts checks this: if null, fetch from LeetCode, populate, set timestamp. If non-null, return the cached row immediately.
Why lazy enrichment instead of seeding full detail for all problems upfront? Fetching full detail for 2800+ problems would take ~2 hours and ~2800 API calls to LeetCode's undocumented endpoint — a real risk of getting rate-limited or blocked. Fetching detail on first request means you only pay the cost for problems users actually ask about. In practice, most traffic clusters around the top 150–200 well-known problems anyway.
Why JSONB for tags, meta_data, and examples? These fields are arrays or objects that don't need to be queried individually. We always read the full value and never filter on a tag or a specific example. JSONB stores the structure in a binary format Postgres can parse efficiently. For something like "find all Array problems" (filtering on tags), we'd use a proper array column or a separate tags table — but since we never do that query, JSONB is the right call.
There are no foreign keys in this project. The problems table is self-contained. No cascading deletes, no referential integrity constraints beyond the primary key.
Why no foreign keys? Because there are no related tables. There are no users to reference, no submissions to relate to problems. The schema is exactly as normalized as the data requires — one entity (problems), one table.
User enters problem #200, selects Python, pastes their code, clicks Analyze
│
▼
ProblemForm.tsx onSubmit()
→ calls analyzeSubmission({ problemNumber: 200, language: 'python', code: '...' })
→ App.tsx sets status = 'loading'
→ fetch('POST /api/analyze', { body: JSON.stringify({...}) })
│
▼
Server: cors() middleware
→ checks request Origin against CLIENT_ORIGIN env var
→ passes (same-origin or whitelisted)
│
▼
Server: express.json() middleware
→ parses JSON body
→ rejects payloads > 100KB
│
▼
Server: analyzeRateLimiter middleware (express-rate-limit)
→ increments counter for this IP
→ if > 8 in last 10 min → 429 'Too many requests'
→ passes
│
▼
Server: POST /api/analyze handler in routes/analyze.ts
→ isValidAnalyzeBody(req.body) — validates shape, language is one of [python|cpp|java], code non-empty
→ if invalid → 400 'Body must include problemNumber, language, and code'
│
▼
getOrEnrichProblem(200) in db/problemRepo.ts
→ db.select().from(problems).where(eq(problems.frontendId, 200))
→ if no row → 404 'No problem #200'
→ if row.detailsFetchedAt is null:
→ fetchProblemDetail('number-of-islands') in services/leetcode.ts
→ lc.problem('number-of-islands') — hits LeetCode's GraphQL
→ extractConstraints(content) → "1 <= m, n <= 300"
→ extractExamples(content) → [{ input: '...', output: '2' }, ...]
→ parse metaData from JSON string → { name: 'numIslands', params: [...], return: {...} }
→ db.update(problems).set({ constraints, metaData, examples, detailsFetchedAt })
→ returns fully enriched row
│
▼
buildExecutionResult(problem, 'python', code) in routes/analyze.ts
│
├── GUARD: if JUDGE0_BASE_URL not set → return { available: false, reason: 'not enabled' }
│
├── isHarnessSupported(problem.metaData)
│ → checks every param type and return type is in the known set
│ → if not → return { available: false, reason: 'signature not covered' }
│
├── if problem.examples is empty → return { available: false, reason: 'no examples extracted' }
│
├── generateCases(metaData, examples, 'python', userCode) in lib/harness/index.ts
│ → for each example:
│ parseAssignments(example.input) → { 'grid': '...', ... }
│ parseLiteral(raw) → typed JS value
│ generatePython(metaData, args, userCode) → full runnable Python source string
│ canonicalJson(parseLiteral(example.output)) → "[1,0]" (compact JSON for comparison)
│
├── for each generated case:
│ runOnJudge0(source, 'python') in services/judge0.ts
│ → POST http://judge0-host:2358/submissions?wait=true
│ { source_code: '...', language_id: 71, cpu_time_limit: 5 }
│ → returns { stdout, stderr, compile_output, status.description, time, memory }
│ compare stdout.trim() === expectedOutputJson → passed: true/false
│
└── returns ExecutionResult { available: true, allPassed: bool, cases: [...] }
│
▼
analyzeSubmission(context) in services/groq.ts
→ builds user prompt: problem title/difficulty/tags/constraints + code + execution results
→ groq.chat.completions.create({
model: 'openai/gpt-oss-120b',
temperature: 0.2,
response_format: { type: 'json_object' },
messages: [{ role: 'system', content: SYSTEM_PROMPT }, { role: 'user', content: prompt }]
})
→ JSON.parse(response.choices[0].message.content)
→ returns { analysis, correctnessHint }
│
▼
Server: res.json({ problem, execution, analysis, correctnessHint })
│
▼
Client: status = 'success', result = response
→ App.tsx re-renders, shows <AnalysisResult result={...} />
→ VerdictChip, execution cases, complexity card, pattern card, quality notes all render
→ if analysis.hasOptimizationHint && verdict === 'correct': <HintPanel> appears
This is triggered only when:
- The main analysis returned
hasOptimizationHint: true - The correctness verdict was
"correct"
In other words: your code works, but a better approach exists. The hint is withheld until requested.
User clicks "Show a hint" in HintPanel
│
▼
HintPanel.tsx handleRequestHint()
→ status = 'loading'
→ fetchOptimizationHint({ problemNumber, language, code })
→ fetch('POST /api/analyze/hint', { body: JSON.stringify({...}) })
│
▼
Server: POST /api/analyze/hint handler
→ validates body
→ getOrEnrichProblem(problemNumber)
→ buildExecutionResult(...) — same execution check as main flow
→ getOptimizationHint(context) in services/groq.ts
→ groq.chat.completions.create({
temperature: 0.3, ← slightly more variation than analysis
messages: [{ role: 'system', content: OPTIMIZATION_HINT_SYSTEM_PROMPT }, ...]
})
→ plain text response (not JSON mode — hint is prose)
→ res.json({ hint: '...' })
│
▼
HintPanel: status = 'loaded', renders hint text
Why is the hint a separate API call and not included in the main analysis response? Two reasons. First, it's on-demand — most users either already know a better approach or don't want the hint. Generating it unconditionally would waste a Groq call on every analysis. Second, the hint is intentionally shown after the user has seen the verdict and decided they want a nudge — not frontloaded with the results.
There is no authentication in this project. The access model is:
Layer 1: CORS (is this request coming from an allowed browser origin?)
Layer 2: Per-IP Rate Limiting (has this IP sent too many requests recently?)
Layer 3: Input Validation (is the request body well-formed?)
Layer 4: Business Logic (does problem #X exist in the database?)
No JWT, no sessions, no user accounts. The project is intentionally public and stateless — the rate limiter is the only protection between the internet and the Groq API.
CORS (Cross-Origin Resource Sharing) is a browser security mechanism. When CLIENT_ORIGIN is set to your Vercel URL, the server's cors() middleware adds this to every response:
Access-Control-Allow-Origin: https://your-app.vercel.app
This tells browsers: "only run JS on your-app.vercel.app is allowed to call this API." Any request from a script on malicious.com will be blocked by the browser before it reaches our server.
What CORS does NOT prevent: Direct curl or fetch calls from non-browser clients (Postman, a Python script, another server). CORS is a browser enforcement mechanism — it has no effect on server-to-server calls. The rate limiter handles abuse from non-browser clients.
During local development: CLIENT_ORIGIN should be set to http://localhost:5173. During initial Render setup before you know your Vercel URL, leaving CLIENT_ORIGIN blank makes cors() allow all origins — acceptable for setup, not for production.
// /api/analyze and /api/analyze/hint
analyzeRateLimiter:
windowMs: 10 * 60 * 1000 // 10-minute window
limit: 8 // 8 requests per window per IP
standardHeaders: true // returns Retry-After in response headers
message: { error: '...' }
// /api/problems/:number
problemsRateLimiter:
windowMs: 60 * 1000 // 1-minute window
limit: 30 // 30 requests per window per IPWhy 8 per 10 minutes for analysis? A real user solving problems genuinely doesn't submit the same problem more than a few times in 10 minutes. 8 is generous for real use. For a bot hammering the endpoint, 8 requests then a 10-minute lockout means they can exhaust ~50 Groq calls per hour per IP — still limited. The rate limit isn't a perfect defense, it's a floor.
This is the most technically complex part of the project. Read it carefully — interviewers will probe this.
LeetCode presents problems as a function signature: twoSum(nums: int[], target: int) -> int[]. The user submits a class with that method. To actually run that code against an example input, you need a complete executable program — not just the method. The harness generator builds that program.
For Two Sum with input nums = [2,7,11,15], target = 9:
# Generated by generatePython.ts — this is what gets sent to Judge0
import json
import sys
sys.setrecursionlimit(10000)
# (user's code pasted in here)
class Solution:
def twoSum(self, nums, target):
...
if __name__ == "__main__":
solution = Solution()
result = solution.twoSum([2, 7, 11, 15], 9) # args constructed from example
print(json.dumps(result, separators=(",", ":"))) # compact JSON for comparisonThe output ([0,1]) is compared against the expected output ("[0,1]" from the example) using exact string matching of compact JSON.
ProblemExample { input: "nums = [2,7,11,15], target = 9", output: "[0,1]" }
│
▼
parseAssignments("nums = [2,7,11,15], target = 9")
→ { "nums": "[2,7,11,15]", "target": "9" }
How: splitTopLevel() splits on commas, but respects bracket nesting.
"nums = [2,7,11,15], target = 9" has a comma inside [...] and one outside.
The outside comma is the separator; the inside one is part of the array literal.
Without nesting-aware splitting, this would incorrectly split into 5 parts.
│
▼
For each param in metaData.params, parseLiteral(assignments[param.name]):
"nums": parseLiteral("[2,7,11,15]") → [2, 7, 11, 15] (JS array)
"target": parseLiteral("9") → 9 (JS number)
│
▼
parseLiteral(example.output) → [0, 1]
canonicalJson([0, 1]) → "[0,1]" (JSON.stringify default — compact, no spaces)
│
▼
generatePython(metaData, [[2,7,11,15], 9], userCode)
→ argExprs: ["[2, 7, 11, 15]", "9"] (Python literals)
→ printExpr: "result" (integer[] return → serialize with json.dumps)
→ builds complete Python source string
│
▼
runOnJudge0(source, 'python')
→ POST /submissions?wait=true to Judge0
→ Judge0 executes: python3 solution.py
→ stdout: "[0,1]\n"
│
▼
actual = stdout.trim() = "[0,1]"
expected = "[0,1]"
passed = actual === expected → true
Supported (harness generates a runnable program):
- Primitive params/returns:
integer,long,double,boolean,string,character - 1D arrays:
integer[],long[],double[],string[],character[] - 2D arrays:
integer[][],string[][] - Tree/linked-list structures:
ListNode,TreeNode
Not supported (execution unavailable, falls back to LLM-only):
- Design problems (
LRUCache,MinStack, etc.) — these have multiple methods, no single function signature - Custom node types beyond
ListNode/TreeNode(Nodewithchildrenarray for N-ary trees, etc.) - Interactive problems
- Problems where
metaDatacouldn't be parsed orexamplescouldn't be extracted
Why is this the right scope? A fully general harness that handles every LeetCode problem type would require parsing LeetCode's problem HTML, understanding their class-based problem format, generating JUnit/pytest test infrastructure, and handling dozens of edge cases. That's a substantially larger project. The current set covers ~80% of real array/string/DP/graph/tree problems — the ones people actually solve in interviews. The remaining 20% degrade gracefully with a clear explanation.
const KNOWN_TYPES = new Set([
"integer", "long", "double", "boolean", "string", "character",
"integer[]", "long[]", "double[]", "string[]", "character[]",
"integer[][]", "string[][]",
"ListNode", "TreeNode",
]);
export function isHarnessSupported(metaData): boolean {
if (!metaData) return false;
const types = [...metaData.params.map(p => p.type), metaData.return.type];
return types.every(t => KNOWN_TYPES.has(t));
}Every param type AND the return type must be in KNOWN_TYPES. If any type is outside this set, the harness can't generate correct code, so execution is skipped entirely rather than generating wrong code.
Python: Uses json.dumps(result, separators=(",", ":")) — the separators argument is critical. Python's default json.dumps([0,1]) produces "[0, 1]" (with a space after the comma). JavaScript's JSON.stringify([0,1]) produces "[0,1]" (no space). Without separators=(",", ":"), every Python case would fail the string comparison even when the output is correct. This was a real bug caught during testing.
C++: Uses named local variables for every argument instead of passing temporaries directly:
// WRONG (doesn't compile for non-const reference params):
solution.twoSum({2,7,11,15}, 9);
// CORRECT (named variable is an lvalue, binds to vector<int>&):
vector<int> arg0 = {2, 7, 11, 15};
int arg1 = 9;
solution.twoSum(arg0, arg1);LeetCode's C++ templates commonly use vector<int>& (non-const reference) parameters. A temporary like {2,7,11,15} is an rvalue and cannot bind to a non-const reference. Named variables are lvalues and bind correctly. This was a real compilation bug caught during testing.
Java: Always includes the ListNode/TreeNode class definitions — even for problems that don't use them. This is because HELPER_METHODS (the static utility methods for building/serializing linked lists and trees) always references ListNode and TreeNode in its method signatures. If a problem doesn't use them but the helper methods are included (which they always are), the classes must be defined. The fix was to make NODE_HELPERS unconditional. This was a real compilation bug caught during testing.
LeetCode represents linked lists and trees as flat arrays in their example inputs:
head = [1,2,3]means a linked list 1 → 2 → 3root = [3,9,20,null,null,15,7]means a binary tree (BFS level-order serialization)
The harness generators include helper functions that convert these flat arrays into actual node structures at runtime, and convert the output structures back to arrays for comparison:
Python (ListNode):
def _build_list(arr): # [1,2,3] → 1→2→3 linked list
def _list_to_arr(node): # 1→2→3 linked list → [1,2,3]C++ (TreeNode):
TreeNode* _buildTree(vector<optional<int>> arr) // [3,9,20,null,null,15,7] → tree
string _treeToArrJson(TreeNode* root) // tree → "[3,9,20,null,null,15,7]"Java (both):
static ListNode buildList(int[] arr)
static int[] listToArr(ListNode node)
static TreeNode buildTree(Integer[] arr)
static String treeToArrJson(TreeNode root)apps/client/src/
├── types/index.ts TypeScript types mirroring the server's API response shapes
├── lib/api.ts Thin fetch wrapper — all HTTP calls live here
├── vite-env.d.ts Type reference for import.meta.env (Vite-injected)
├── index.css Global styles + design tokens (single CSS file)
├── main.tsx React root — mounts <App> into #root
├── App.tsx Owns all state. Orchestrates form + results + loading + error
└── components/
├── ProblemForm.tsx Controlled form — problem number, language select, code textarea
├── LoadingState.tsx Animated dots shown while analysis is in flight
├── AnalysisResult.tsx Full results display — verdict chip, execution cases, 4 result cards, hints
└── HintPanel.tsx On-demand optimization hint — has its own async lifecycle
Why this structure? It follows the one rule that scales: UI logic (how things look) lives in components, application logic (what to do next) lives in the page (App.tsx). Components are passive — they render what they're given and call callbacks when the user does something. App.tsx is active — it calls the API, updates state, decides what to show.
type Status = "idle" | "loading" | "success" | "error";
// State:
const [status, setStatus] = useState<Status>("idle");
const [result, setResult] = useState<AnalyzeResponse | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [lastSubmission, setLastSubmission] = useState<{...} | null>(null);State transitions:
idle
→ (user submits form) → loading
→ (API success) → success (result is set, lastSubmission is set)
→ (API error) → error (errorMessage is set)
Why lastSubmission is stored: The HintPanel needs to make its own API call for the optimization hint. That call needs the same problemNumber, language, and code that was used in the main analysis. lastSubmission stores exactly these three values when the main form is submitted, so HintPanel can use them without prop-drilling the entire form state or having its own copy of the form.
const [problemNumber, setProblemNumber] = useState("");
const [language, setLanguage] = useState<SupportedLanguage>("python");
const [code, setCode] = useState("");
const [validationError, setValidationError] = useState<string | null>(null);Every input is a controlled React input — its value is driven by state, and onChange updates state. This means React always knows the current values. On submit, the handler reads from state (not from the DOM), validates (integer > 0, code non-empty), and calls props.onSubmit.
Why inputMode="numeric" on the problem number field? inputMode="numeric" tells mobile browsers to show the number keyboard for this text field, without making it type="number" (which adds browser-native up/down arrows and has edge cases with leading zeros and empty strings that are annoying to handle).
The verdict chip is the visual centerpiece:
function verdictDisplay(verdict: "correct" | "incorrect" | "uncertain") {
if (verdict === "correct") return { label: "ACCEPTED", className: "verdict-pass" };
if (verdict === "incorrect") return { label: "WRONG ANSWER", className: "verdict-fail" };
return { label: "UNVERIFIED", className: "verdict-neutral" };
}The chip text is in all-caps JetBrains Mono — deliberately styled to look like a real OJ (Online Judge) output. Green border for accepted, red for wrong answer, amber for uncertain. This is the first thing a user's eye goes to, and it should feel like a verdict, not a card.
HintPanel has its own status state (idle | loading | loaded | error) separate from App.tsx's status. This is intentional: the hint is a follow-up interaction that happens after the main analysis is complete. Putting hint state in App.tsx would mean App.tsx manages two async lifecycles simultaneously, which complicates the state machine. By giving HintPanel its own state, each component manages exactly one async operation.
type HintStatus = "idle" | "loading" | "loaded" | "error";
const [status, setStatus] = useState<HintStatus>("idle");
const [hint, setHint] = useState<string | null>(null);The hint is only fetched when the user clicks "Show a hint" — never on mount, never automatically.
async function parseJsonOrThrow<T>(res: Response): Promise<T> {
const data = await res.json();
if (!res.ok) {
throw new Error((data as ApiError).error || `Request failed with status ${res.status}`);
}
return data as T;
}All HTTP calls go through this helper. It always parses JSON first (because even error responses have a JSON body with { error: "..." }), then throws if the status is not OK. The thrown error carries the server's message, which App.tsx catches and stores in errorMessage for display.
VITE_API_BASE_URL: Set at build time by Vite. In development it defaults to http://localhost:4000 (the fallback in api.ts). In production it must be https://your-render-url.onrender.com — set as an environment variable in Vercel before deploying.
HTTP Request
│
▼
src/index.ts (entry point)
└── cors() checks Origin header
└── express.json() parses body, 100KB limit
└── /api/health simple liveness check
└── /api/problems → problemsRouter
└── /api/analyze → analyzeRouter
│
▼
routes/problems.ts or routes/analyze.ts
└── problemsRateLimiter / analyzeRateLimiter
└── input validation
└── call db/problemRepo.ts
└── call services/*.ts
└── res.json(...)
│
▼
db/problemRepo.ts
└── db.select() from Neon
└── db.update() if enrichment needed
└── calls services/leetcode.ts for enrichment
│
▼
services/leetcode.ts → LeetCode GraphQL (external)
services/judge0.ts → Judge0 instance (external)
services/groq.ts → Groq API (external)
lib/harness/*.ts → in-memory code generation (no I/O)
Why this layering? Each layer has one job:
index.ts— wires together middleware and routesroutes/*.ts— validates input, orchestrates calls, formats outputdb/problemRepo.ts— all database reads and writes, nothing elseservices/*.ts— all external API calls, nothing elselib/harness/*.ts— pure functions, no I/O
When something breaks, this layering tells you exactly where to look. "502 from Groq" → services/groq.ts. "404 for a problem that exists" → db/problemRepo.ts or the seed. "Execution showing wrong output" → lib/harness/*.ts.
routes/problems.ts — one route: GET /api/problems/:number. Parses the number, calls getOrEnrichProblem, returns title/difficulty/tags/constraints. Used by the frontend to preview problem metadata before submission (currently not wired to the UI — available for future use).
routes/analyze.ts — two routes:
POST /api/analyze— the full pipeline: lookup → execution → Groq analysis → responsePOST /api/analyze/hint— optimization hint only, same lookup + execution context but only callsgetOptimizationHint
Both routes share analyzeRateLimiter (8 req/10min per IP) because the hint call also uses a Groq token.
export async function getOrEnrichProblem(frontendId: number): Promise<ProblemRow | null> {
// 1. Lookup by frontendId (always present after seed)
const [row] = await db.select().from(problems).where(eq(problems.frontendId, frontendId));
if (!row) return null;
// 2. If already enriched, return immediately (cached)
if (row.detailsFetchedAt) return row;
// 3. First time: fetch from LeetCode and persist
try {
const detail = await fetchProblemDetail(row.slug);
await db.update(problems)
.set({ constraints: detail.constraints, metaData: detail.metaData,
examples: detail.examples, detailsFetchedAt: new Date() })
.where(eq(problems.frontendId, frontendId));
// mutate row in-place for immediate use
row.constraints = detail.constraints;
row.metaData = detail.metaData;
row.examples = detail.examples;
row.detailsFetchedAt = new Date();
} catch (err) {
// Enrichment failed (LeetCode API down?) — return partially enriched row.
// The analyze route degrades gracefully when metaData/examples are null.
console.error(`Failed to enrich problem #${frontendId}:`, err);
}
return row;
}The try/catch on enrichment: If the LeetCode detail API fails (rate limit, network issue, API change), we don't fail the entire request. We return the row with whatever we have (metaData: null, examples: null). The analyze route handles both cases: if metaData is null, isHarnessSupported() returns false, execution is skipped, and Groq analyzes with less context. The user gets a partial result instead of an error.
The system prompt is the most important thing in services/groq.ts. The key instructions:
- Ground correctness in execution: "If execution results are provided and ALL passed, treat that as strong evidence of correctness. If ANY failed, the verdict is 'incorrect' — explain which case and why."
- Ground acceptance likelihood in constraints: "Reason about the code's time complexity against the problem's stated constraints. Use 'likely-tle' when correctness looks fine but complexity will exceed limits at the upper bound. Cite the actual bound and actual complexity."
- Enforce the correctness hint rule: "correctnessHint ONLY when verdict is 'incorrect'. One or two sentences nudging toward the bug — NEVER the fix, NEVER corrected code. If verdict is 'correct' or 'uncertain', correctnessHint must be null."
- Specific code quality: "2-4 short, specific, actionable notes. Not generic praise."
- JSON only: "Output ONLY the JSON object. No markdown fences, no commentary outside the JSON."
Why temperature: 0.2? Low temperature makes the model more deterministic. For code analysis you want the same code to get the same verdict on repeated submissions. At temperature 0, the model is fully deterministic but can get "stuck" on certain phrasings. 0.2 gives near-determinism with slight variation.
Every route handler wraps its logic in try/catch:
try {
// ... pipeline ...
res.json(response);
} catch (err) {
console.error("Analyze pipeline failed:", err);
res.status(502).json({ error: "Analysis failed - the AI or execution backend may be temporarily unavailable. Try again shortly." });
}Why 502 and not 500? 502 is "Bad Gateway" — the server received a bad response from an upstream service. The analyze route's most likely failure mode is Groq or Judge0 returning an unexpected response. 502 is semantically more accurate than 500 ("Internal Server Error") for a pipeline that depends on external services.
Why hide the real error from the client? The real error (GroqError: model not found, FetchError: ECONNREFUSED) contains system information that's useful to an attacker and useless to a user. Log it server-side (visible in Render logs), send a generic message to the client.
| Status | Meaning | Example in this project |
|---|---|---|
200 |
Success, returning data | Successful analysis response |
400 |
Bad request — client sent malformed input | Missing code, invalid language, non-integer problem number |
404 |
Not found | Problem number not in database (not seeded yet) |
429 |
Too many requests | IP exceeded rate limit |
502 |
Bad Gateway — upstream service failed | Groq API error, Judge0 unreachable |
The client maps these status codes to UI states: 404 shows "Run the seed script" message, 429 shows rate limit message, 502 shows "Try again" message.
[User] [Frontend] [Server] [Neon DB] [LeetCode API]
User enters #200
→ clicks Analyze
App.tsx setStatus('loading')
fetch('POST /api/analyze')
isValidAnalyzeBody() ✓
getOrEnrichProblem(200)
SELECT * FROM problems
WHERE frontend_id = 200
→ row (detailsFetchedAt: null)
detailsFetchedAt is null:
lc.problem('number-of-islands')
→ { content, metaData, ... }
extractConstraints(content)
extractExamples(content)
parse metaData JSON
UPDATE problems
SET constraints=..., meta_data=...,
examples=..., details_fetched_at=NOW()
WHERE frontend_id = 200
row fully enriched, pipeline continues
Second request for #200:
getOrEnrichProblem(200)
SELECT * FROM problems
WHERE frontend_id = 200
→ row (detailsFetchedAt: '2025-11-01...')
detailsFetchedAt is set → return immediately
(no LeetCode API call)
[Server pipeline, for problem #1 "Two Sum", Python, user's brute-force code]
problem.metaData = {
name: "twoSum",
params: [{ name: "nums", type: "integer[]" }, { name: "target", type: "integer" }],
return: { type: "integer[]" }
}
problem.examples = [
{ input: "nums = [2,7,11,15], target = 9", output: "[0,1]" },
{ input: "nums = [3,2,4], target = 6", output: "[1,2]" }
]
isHarnessSupported(metaData):
types = ["integer[]", "integer", "integer[]"]
all in KNOWN_TYPES → true
generateCases(metaData, examples, "python", userCode):
Example 1:
parseAssignments("nums = [2,7,11,15], target = 9")
→ { "nums": "[2,7,11,15]", "target": "9" }
parseLiteral("[2,7,11,15]") → [2, 7, 11, 15]
parseLiteral("9") → 9
parseLiteral("[0,1]") → [0, 1]
canonicalJson([0, 1]) → "[0,1]"
generatePython(metaData, [[2,7,11,15], 9], userCode)
→ full Python source with args [2, 7, 11, 15] and 9
Example 2:
parseAssignments("nums = [3,2,4], target = 6")
→ { "nums": "[3,2,4]", "target": "6" }
... (same process)
runOnJudge0(source1, "python"):
POST http://judge0:2358/submissions?wait=true
→ { stdout: "[0,1]\n", status: { description: "Accepted" }, time: "0.045" }
actual = "[0,1]"
expected = "[0,1]"
passed = true ✓
runOnJudge0(source2, "python"):
POST http://judge0:2358/submissions?wait=true
→ { stdout: "[1,2]\n", status: { description: "Accepted" }, time: "0.045" }
actual = "[1,2]"
expected = "[1,2]"
passed = true ✓
ExecutionResult:
{ available: true, allPassed: true, cases: [
{ input: "nums = [2,7,11,15], target = 9", expectedOutput: "[0,1]", actualOutput: "[0,1]", passed: true },
{ input: "nums = [3,2,4], target = 6", expectedOutput: "[1,2]", actualOutput: "[1,2]", passed: true }
]}
analyzeSubmission({ problem, language: "python", code: brute_force_code, execution: above })
User prompt sent to Groq:
"Problem: Two Sum (Easy)
Tags: Array, Hash Table
Constraints: 2 <= nums.length <= 10^4, -10^9 <= nums[i] <= 10^9
Language: python
Submitted code:
class Solution:
def twoSum(self, nums, target):
for i in range(len(nums)):
for j in range(i+1, len(nums)):
...
Execution results:
Example 1: input=nums = [2,7,11,15], target = 9 | expected=[0,1] | actual=[0,1] | passed=true
Example 2: input=nums = [3,2,4], target = 6 | expected=[1,2] | actual=[1,2] | passed=true"
Groq response (JSON mode):
{
"correctness": {
"verdict": "correct",
"explanation": "Both public examples pass. The logic correctly iterates all pairs and returns indices when the sum matches target."
},
"acceptanceLikelihood": {
"verdict": "likely-tle",
"reasoning": "O(n²) time complexity. The constraint allows n up to 10^4, giving up to 10^8 operations in the worst case — above the ~10^8 threshold LeetCode typically allows for Python."
},
"complexity": {
"time": "O(n²)",
"space": "O(1)",
"explanation": "Nested loop checking all pairs. No extra space used."
},
"codeQuality": {
"notes": [
"Variable names are minimal — `i`, `j`, `nums` are standard for this problem",
"No early return optimization (could return immediately on first match — which this does correctly)",
"Missing type hints, which are standard in modern Python"
]
},
"pattern": "Brute Force / Nested Loop",
"hasOptimizationHint": true,
"correctnessHint": null ← null because verdict is "correct"
}
What it means: Every request is anonymous. No login, no accounts, no sessions, no tokens anywhere in the system.
Why: Authentication adds a user table, a sessions/JWT infrastructure, registration and login flows, password hashing, and token refresh logic — all of which are demonstrated in Creator Dashboard (the other project in this portfolio). Adding auth here would be duplication, not demonstration. The design constraint of "no login" forced a cleaner, simpler architecture and is genuinely the right choice for a tool people want to use without friction.
The implication: The rate limiter becomes the only abuse prevention mechanism. This was an explicit, known tradeoff — not an oversight.
What it means: No submission is ever stored in the database. Every analysis request is self-contained. Closing the browser loses nothing because there's nothing to lose.
Why: Storing submissions requires a user identity to attach them to (which requires auth), a submissions table, and privacy considerations (you're storing other people's code). The value proposition — "tell me about this code right now" — doesn't require history. A history feature would be a v2 addition alongside auth.
The tradeoff: Users who want to track their weaknesses over time can't. This was a conscious product scope decision.
What it means: The seed script populates only the fast, paginated fields (title, difficulty, tags, slug). Per-problem detail (constraints, signature, examples) is fetched from LeetCode on the first request for that problem.
Why: Bulk enrichment of 2800+ problems would require ~2800 individual API calls to an undocumented endpoint over 1–2 hours, with real risk of rate-limiting or IP blocking. Lazy enrichment means you only pay the cost for problems that are actually requested, and the first request for any new problem is ~300–500ms slower than subsequent ones — imperceptible in practice.
The implementation: detailsFetchedAt is the sentinel. null = not yet fetched. Set = cached. getOrEnrichProblem() checks this on every request.
What it means: The LLM never makes a correctness claim without receiving the execution results first (or an explicit statement that execution isn't available and why). The execution results are injected into the prompt as facts.
Why: LLMs hallucinate about code correctness. They'll confidently say code is correct when it isn't, and vice versa. Running the code against real test cases gives the LLM a factual anchor. "Your code passed both examples but the brute-force approach will TLE at the upper bound" is a claim the system can actually back up — example pass/fail is empirical, and complexity vs. constraints is deterministic reasoning.
The implementation: buildExecutionResult() is always called before analyzeSubmission(). The execution result is part of the Groq prompt, not an afterthought.
What it means: No single failure kills the entire analysis. Every failure mode has an explicit fallback.
| Failure | Fallback |
|---|---|
JUDGE0_BASE_URL not set |
Skip execution, include "not enabled" message in response |
| Problem signature not in supported types | Skip execution, LLM reasons from code alone |
| Example extraction failed | Skip execution, LLM reasons from code alone |
| Judge0 unreachable / times out | Skip execution, include error message, LLM reasons from code alone |
| LeetCode detail API fails during enrichment | Return partially enriched row, execution unavailable, LLM reasons with less context |
| Groq returns malformed JSON | Return 502, log error, ask user to retry |
Why this matters: A pipeline with 4 external dependencies (Neon, LeetCode API, Judge0, Groq) has 4 potential failure points. Any one of them can be down. "All or nothing" would mean the tool is down whenever any one service is. Graceful degradation means the tool is partially useful even when upstream systems fail.
What it means: The database never stores the HTML content of problem descriptions. The API never returns problem descriptions to the client. The client has no page that displays problems.
Why: LeetCode's problem descriptions are their intellectual property. Storing and serving them would create legal risk and would make the project look like an unauthorized mirror of their content. The three things we do store and use (constraints, function signature, example I/O) are the structural facts needed for analysis — not the prose problem statement.
The implementation: extractConstraints() pulls only the short constraint line. extractExamples() pulls only the raw input/output literals. The full content HTML is never written to the database.
What happened: The initial package.json specified "leetcode-query": "^3.4.0". The actual latest published version of leetcode-query on npm is 2.0.1 — there is no version 3.x. pnpm failed to install with ERR_PNPM_NO_MATCHING_VERSION.
The error:
No matching version found for leetcode-query@^3.4.0
The latest release of leetcode-query is "2.0.1"
The fix: Changed the version specifier to "leetcode-query": "^2.0.1" in apps/server/package.json.
The lesson: Never write a version number for a third-party package without verifying it exists on the registry. npm view <package> version takes one second and would have caught this. Package version hallucination is the most common AI-assisted coding error for dependencies.
What happened: The comparison logic in buildExecutionResult() compared stdout.trim() against canonicalJson(parseLiteral(expected)). canonicalJson uses JSON.stringify, which produces "[0,1]" (no spaces). Python's json.dumps([0,1]) by default produces "[0, 1]" (space after comma). Every Python test case would fail the string comparison even when the code output was logically correct.
The error: Silent — no exception. The case would simply show passed: false, actualOutput: "[0, 1]", expectedOutput: "[0,1]", and an incorrect FAIL verdict.
The fix:
# BEFORE (wrong):
print(json.dumps(result))
# AFTER (correct):
print(json.dumps(result, separators=(",", ":")))separators=(",", ":") tells json.dumps to use comma and colon with no surrounding spaces — matching JSON.stringify's output format exactly.
The lesson: String comparison of serialized values is format-sensitive. When comparing output across languages, establish a canonical format and verify every serializer matches it. Test with a real execution, not just type-checking.
What happened: The original C++ harness generator passed literal values directly as arguments:
solution.twoSum({2, 7, 11, 15}, 9);LeetCode's C++ templates commonly declare parameters as vector<int>& nums (non-const reference). A brace-initialized temporary {2, 7, 11, 15} is an rvalue — it cannot bind to a non-const reference. This produced a compilation error in Judge0.
The error:
error: cannot bind non-const lvalue reference of type 'vector<int>&'
to an rvalue of type 'vector<int>'
The fix: Generate named local variables (lvalues) for each argument:
// BEFORE (wrong):
solution.twoSum({2, 7, 11, 15}, 9);
// AFTER (correct):
vector<int> arg0 = {2, 7, 11, 15};
int arg1 = 9;
solution.twoSum(arg0, arg1);Named variables are lvalues and bind correctly to both const-ref and non-const-ref parameters.
The lesson: When generating code, you must respect the calling conventions of the target language. LeetCode's templates use non-const references for performance (avoids copying large vectors). The harness generator must know this. Testing by actually compiling the generated output — not just reading it — is the only reliable check.
What happened: HELPER_METHODS (the static utility method block always included in every Java harness) contains method signatures that reference ListNode and TreeNode:
static ListNode buildList(int[] arr) { ... }
static TreeNode buildTree(Integer[] arr) { ... }The NODE_HELPERS block (which defines the ListNode and TreeNode classes) was conditionally included only when the problem actually used those types. For problems that don't use ListNode or TreeNode, NODE_HELPERS was omitted, but HELPER_METHODS still referenced them — causing a "cannot find symbol: ListNode" compilation error.
The error:
./Main.java:3: error: cannot find symbol
class ListNode { ← wait, this class doesn't exist in this file
The fix: Make NODE_HELPERS unconditional — always include the class definitions, regardless of whether the problem uses them:
// BEFORE (wrong):
const usesNode = metaData.params.some(p => p.type === "ListNode" || p.type === "TreeNode");
return `... ${usesNode ? NODE_HELPERS : ""} ...`;
// AFTER (correct):
return `... ${NODE_HELPERS} ...`; // always includedThe lesson: When utility code references types, those types must always be defined — you can't conditionally include helper methods and then conditionally include the types they reference. The dependency goes one way: helpers depend on types, so types must always be present when helpers are.
With no authentication, the rate limiter is the only mechanism preventing:
- Groq quota exhaustion — free tier: ~1,000–14,400 requests/day. Without a rate limiter, a single script calling
/api/analyzein a loop would drain this in minutes. - Judge0 overload — each submission spawns an execution sandbox. Unlimited concurrent submissions could exhaust the Judge0 instance's process limit.
The rate limit is per-IP, not per-user (there are no users). This means a single household or university network behind NAT shares one rate limit. This is an acceptable tradeoff — the tool isn't designed for simultaneous use by many people on the same IP.
const allowedOrigins = process.env.CLIENT_ORIGIN
? process.env.CLIENT_ORIGIN.split(",").map(o => o.trim())
: undefined; // undefined = allow all (local dev only)
app.use(cors({ origin: allowedOrigins }));In production, CLIENT_ORIGIN should be set to the Vercel URL only. This means:
- Browser requests from your Vercel app → allowed
- Browser requests from any other domain → blocked by browser's CORS enforcement
- Non-browser requests (curl, Postman) → not affected by CORS (handled by rate limiting)
What CORS prevents: Cross-site request forgery from other browser-based apps. A malicious website cannot silently call your API on behalf of a visitor.
What CORS does NOT prevent: Direct API calls. Anyone can call curl https://your-server.onrender.com/api/analyze and it will work. This is by design — the API is public. The rate limiter handles abuse.
Drizzle ORM uses parameterized queries internally. Every database call in this project goes through Drizzle:
// What you write:
await db.select().from(problems).where(eq(problems.frontendId, frontendId));
// What Drizzle generates:
SELECT * FROM problems WHERE frontend_id = $1 -- with [frontendId] as the parameter$1 is a placeholder — the database driver sends the query and parameters separately. The database receives frontendId as data, not as SQL. Even if frontendId were somehow '; DROP TABLE problems; --, the database would look for a row where frontend_id equals that literal string (which would just return null) rather than executing it as SQL.
No raw string interpolation into SQL queries exists anywhere in this codebase.
The isValidAnalyzeBody() function validates every field in the request body before touching the database or calling external services:
function isValidAnalyzeBody(body): body is AnalyzeRequestBody {
return (
typeof body?.problemNumber === "number" &&
Number.isInteger(body.problemNumber) &&
typeof body.language === "string" &&
SUPPORTED_LANGUAGES.includes(body.language) &&
typeof body.code === "string" &&
body.code.trim().length > 0 &&
body.code.length <= MAX_CODE_LENGTH // 20,000 characters
);
}The MAX_CODE_LENGTH of 20,000 characters prevents someone from sending a 10MB code payload to run through Judge0 or Groq.
The server never stores or returns the full content of LeetCode problems. From services/leetcode.ts:
// extractConstraints() — pulls ONLY the constraints line from HTML
export function extractConstraints(contentHtml: string): string {
const match = contentHtml.match(/Constraints:?<\/[a-z]+>([\s\S]*?)(<p[ >]|$)/i);
// strips HTML, returns short text like "2 <= nums.length <= 10^4"
}
// extractExamples() — pulls ONLY the raw input/output literals from <pre> blocks
export function extractExamples(contentHtml: string): ProblemExample[] {
// returns [{ input: "nums = [2,7,11,15], target = 9", output: "[0,1]" }]
// deliberately stops before "Explanation:" — the structural fact, not the explanation
}The content HTML variable from LeetCode is used and discarded. It is never written to the database. It is never included in any API response.
Add helmet.js (HTTP security headers): 2 lines to add content security policy, X-Frame-Options, and other security headers:
import helmet from "helmet";
app.use(helmet()); // add before routesAdd a structured logger: Replace console.error with pino for structured JSON logs that Render's log aggregation can parse:
import pino from "pino";
const logger = pino();
logger.error({ err, problemNumber }, "Analyze pipeline failed");Render cold-start warning in the UI: Render's free tier spins down after 15 minutes of inactivity. Add a banner in the frontend that appears on first load: "First analysis after a period of inactivity may take up to 60 seconds while the server wakes up."
In-memory LRU cache for problem metadata: Currently every request hits Neon for the getOrEnrichProblem() lookup. For a popular problem like Two Sum (#1), this is a database round-trip on every single request. An LRU cache in memory:
import { LRUCache } from "lru-cache";
const problemCache = new LRUCache<number, ProblemRow>({ max: 500 });would serve 99% of requests without a database call at all, since traffic clusters around the top 200 problems.
Support more parameter types: The harness generator currently doesn't support:
character[][](2D character arrays — used in word search, game of life)- N-ary
Node(trees with variable children) voidreturn with in-place modification (reverse array in-place)- Design problems (
LRUCache,MinStack— multiple methods)
Each is a well-defined extension to KNOWN_TYPES and the per-language generators.
Streaming Groq responses: Currently the Groq call blocks until the full JSON response is complete (~1-3 seconds). Streaming would let the UI show partial results as they arrive, making the analysis feel faster. Requires switching from JSON mode to a streaming-aware format.
Per-user submission history (requires adding auth): Store each analysis per user: problem number, verdict, complexity, pattern, timestamp. Show a "weakness map" — "you keep missing edge cases on Sliding Window problems." This is the feature that Creator Dashboard's JWT auth would pair with. The schema addition is one submissions table with a user FK.
Code diff view for optimized solution: When hasOptimizationHint is true and the user clicks through the hint, offer to show a side-by-side diff of their code vs. an optimized reference solution. Currently the hint is Socratic — this would be "show me after I've tried."
Add more languages: The harness supports Python, C++, Java. JavaScript (Judge0 language ID: 63) and Go (ID: 60) would cover most interview-language use cases. The harness generators are mechanical to extend — the only hard part is correctly serializing the output type in each new language.
Problem slug autocomplete: Currently the user must know the exact problem number. A frontend autocomplete that calls GET /api/problems/:number as the user types (debounced) and shows the title would significantly reduce friction.
Switch from db:push to Drizzle migrations before real user data: drizzle-kit push diffs and applies schema changes directly — fine for development, dangerous for production with real data. Once submissions history is added (a schema change), use:
pnpm drizzle-kit generate # generate SQL migration file
pnpm drizzle-kit migrate # apply it against production DBThis gives a reversible, auditable change history for the schema.
Add Judge0 on a named Cloudflare Tunnel: The current "run Judge0 locally with a quick tunnel" approach gives a different trycloudflare.com URL every restart. A named tunnel (cloudflared login + cloudflared tunnel create verdict-judge0) gives a stable URL that doesn't change between restarts, so the Render env var doesn't need updating every time.
The database is empty. Zero rows in all tables. The seed hasn't run.
Developer runs pnpm db:push. Drizzle reads db/schema.ts, diffs it against the empty Neon database, and creates the problems table with all columns (frontend_id, slug, title, difficulty, tags, constraints, meta_data, examples, details_fetched_at, created_at). The table is empty. details_fetched_at defaults to null for all future rows.
Developer runs pnpm seed (takes 10–15 minutes). seedProblems.ts calls LeetCode's paginated GraphQL listing query 56 times (2800 problems / 50 per page), waiting 300ms between calls. For each page it inserts rows with frontend_id, slug, title, difficulty, tags, created_at populated — and constraints, meta_data, examples, details_fetched_at left as null. After the script completes, the database has ~2800 rows, all partially enriched.
SELECT COUNT(*) FROM problems; -- ~2800
SELECT COUNT(*) FROM problems
WHERE details_fetched_at IS NOT NULL; -- 0 (nothing enriched yet)First user opens the app. The frontend loads from Vercel. App.tsx mounts with status = "idle". The user sees the form — problem number input, language dropdown (Python selected by default), code textarea. No API calls have fired yet.
User types 200 and pastes their Number of Islands BFS solution in Python. Clicks Analyze.
ProblemForm.tsx's handleSubmit fires. parseInt("200") = 200 ✓. code.trim().length > 0 ✓. props.onSubmit({ problemNumber: 200, language: "python", code: "..." }) is called. App.tsx sets status = "loading", stores lastSubmission, calls analyzeSubmission(). The loading dots appear.
POST /api/analyze hits the server.
Rate limiter: first request from this IP, counter = 1, passes. Body validation: all fields present, language is "python", code is non-empty, passes.
getOrEnrichProblem(200):
SELECT * FROM problems WHERE frontend_id = 200→ row found (detailsFetchedAt: null)- First time: calls
fetchProblemDetail("number-of-islands") leetcode-queryhits LeetCode's GraphQL withtitleSlug: "number-of-islands"- Returns
{ content: "...<html>...", metaData: '{"name":"numIslands","params":[{"name":"grid","type":"character[][]"}],"return":{"type":"integer"}}', ... } extractConstraints(content)→"1 <= m, n <= 300 1 <= m * n <= 10^4"extractExamples(content)→[{ input: 'grid = [["1","1","1","1","0"],["1","1","0","1","0"],["1","1","0","0","0"],["0","0","0","0","0"]]', output: '1' }, ...]JSON.parse(metaData)→{ name: "numIslands", params: [{ name: "grid", type: "character[][]" }], return: { type: "integer" } }UPDATE problems SET constraints=..., meta_data=..., examples=..., details_fetched_at=NOW() WHERE frontend_id=200
Database now: row #200 fully enriched. This is the only time LeetCode is called for problem #200 for as long as the database exists.
buildExecutionResult(row200, "python", userCode) runs.
JUDGE0_BASE_URL is not set (Option 1 deployment):
return { available: false, reason: "Code execution is not enabled in this deployment — the analysis below is powered by the LLM only.", allPassed: false, cases: [] }
Execution is skipped cleanly. The Groq analysis will run with no execution context and will note this explicitly.
analyzeSubmission(context) runs.
Groq receives this user prompt:
Problem: Number of Islands (Medium)
Tags: Array, Depth-First Search, Breadth-First Search, Union Find, Matrix
Constraints: 1 <= m, n <= 300 1 <= m * n <= 10^4
Language: python
Submitted code:
class Solution:
def numIslands(self, grid):
if not grid: return 0
count = 0
for i in range(len(grid)):
for j in range(len(grid[0])):
if grid[i][j] == '1':
self.bfs(grid, i, j)
count += 1
return count
def bfs(self, grid, r, c):
queue = deque([(r, c)])
grid[r][c] = '0'
while queue:
row, col = queue.popleft()
for dr, dc in [(0,1),(0,-1),(1,0),(-1,0)]:
if 0<=row+dr<len(grid) and 0<=col+dc<len(grid[0]) and grid[row+dr][col+dc]=='1':
queue.append((row+dr, col+dc))
grid[row+dr][col+dc] = '0'
Execution results against public examples:
Not available — Code execution is not enabled in this deployment. Reason purely from the code and problem description.
Groq responds (JSON mode, temperature 0.2) with:
{
"correctness": { "verdict": "uncertain", "explanation": "Code cannot be execution-verified in this deployment. From reading the code: BFS correctly marks visited cells by setting them to '0', explores all 4 neighbors, and increments count for each unvisited island start. Logic appears correct but this is reasoning-only." },
"acceptanceLikelihood": { "verdict": "likely", "reasoning": "O(m×n) time — each cell visited at most once. Space O(min(m,n)) for the BFS queue in the worst case. Both are well within the constraints (m,n ≤ 300, m×n ≤ 10^4)." },
"complexity": { "time": "O(m×n)", "space": "O(min(m,n))", "explanation": "Every cell is visited exactly once and marked '0' to prevent revisiting. Queue grows at most proportional to the shorter dimension in a fully-filled grid." },
"codeQuality": { "notes": ["Missing `from collections import deque` — this will raise NameError at runtime", "Grid is mutated in-place — correct and efficient, but a comment would help readability", "Could extract grid dimensions to variables to avoid repeated len() calls"] },
"pattern": "Breadth-First Search / Matrix Traversal",
"hasOptimizationHint": false,
"correctnessHint": null
}Note: "Missing from collections import deque" — a real, specific bug in the submitted code that Groq caught. This is the value of code quality analysis.
Server sends res.json({ problem, execution, analysis, correctnessHint: null }) (200 OK).
App.tsx receives the response. setStatus("success"). setResult(response). The loading dots disappear. <AnalysisResult> renders.
User sees the results.
- Verdict chip:
UNVERIFIEDin amber (because execution wasn't available) - Correctness explanation: "Code cannot be execution-verified... logic appears correct but this is reasoning-only."
- Acceptance likelihood:
Likely acceptedpill in green — "O(m×n) time... well within constraints" - Complexity:
Time O(m×n)/Space O(min(m,n)) - Pattern:
Breadth-First Search / Matrix Traversal - Code quality: 3 notes, including the
dequeimport bug - Execution section: "Code execution is not enabled in this deployment — the analysis below is powered by the LLM only."
- No hint panel (hasOptimizationHint = false — BFS is already optimal for this problem)
User fixes the deque import, re-submits.
The form allows re-submission at any time. App.tsx re-runs the full pipeline. The problem row for #200 is now fully enriched (detailsFetchedAt is set), so getOrEnrichProblem(200) returns the cached row instantly — no LeetCode API call. The Groq analysis runs again with the corrected code.
Second user opens the app in a different browser on a different machine.
The experience is identical. There is no shared state between users — no sessions, no user-specific data. The only shared state is the Neon database (problem metadata cache), which both users benefit from. If the second user also analyzes #200, they get the cached row from Neon with no LeetCode API call and no delay from enrichment.
The rate limiter fires.
After the same IP sends 8 analysis requests within 10 minutes, the 9th request receives:
HTTP 429 Too Many Requests
{ "error": "Too many analysis requests from this IP. Please wait a few minutes and try again." }App.tsx sets status = "error" and shows the error banner. After 10 minutes, the window resets and the IP can submit again.
From empty database to complete, grounded analysis — 4 real state transitions:
- Schema creation (
db:push) — empty Postgres → typedproblemstable - Seed — 0 rows → ~2800 partially-enriched rows
- First request for a problem — partial row → fully-enriched row (one LeetCode API call, cached permanently)
- Analysis request — enriched row → structured analysis from Groq, grounded in the problem's real constraints
Every subsequent analysis request for the same problem executes only steps 4. Every analysis for a new problem executes steps 3 and 4. LeetCode is called at most once per problem, ever. Groq is called once per analysis request. Neon is called twice per analysis request (lookup + optional update). Judge0 is called zero times in Option 1 (LLM-only) deployment.
That is the architecture. That is the system. That is the codebase.