diff --git a/api/main.py b/api/main.py index dfda7ea..2f2642c 100644 --- a/api/main.py +++ b/api/main.py @@ -3,13 +3,14 @@ from pydantic import BaseModel import sys import os +from pathlib import Path # Add the root directory to PYTHONPATH so we can import torch_judge sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from torch_judge.tasks import TASKS, get_task from torch_judge.web_engine import execute_code -from api.parser import get_all_templates +from api.parser import get_all_solutions, get_all_templates app = FastAPI(title="TorchCode UI Backend") @@ -24,10 +25,21 @@ # Load templates on startup TEMPLATES = get_all_templates() +SOLUTIONS = get_all_solutions() +ROOT_DIR = Path(__file__).resolve().parent.parent +USER_CODE_DIR = ROOT_DIR / "user_solutions" class SubmitRequest(BaseModel): code: str +class CodeRequest(BaseModel): + code: str + +def get_user_code_path(task_id: str) -> Path: + if task_id not in TASKS: + raise HTTPException(status_code=404, detail="Task not found") + return USER_CODE_DIR / f"{task_id}.py" + @app.get("/api/tasks") def list_tasks(): tasks_list = [] @@ -55,6 +67,39 @@ def get_task_details(task_id: str): "initial_code": template.get("initial_code", "# Write your code here.") } +@app.get("/api/tasks/{task_id}/solution") +def get_task_solution(task_id: str): + task = get_task(task_id) + if not task: + raise HTTPException(status_code=404, detail="Task not found") + + solution = SOLUTIONS.get(task_id) + if not solution: + raise HTTPException(status_code=404, detail="Solution not found") + + return solution + +@app.get("/api/code/{task_id}") +def get_saved_code(task_id: str): + code_path = get_user_code_path(task_id) + if not code_path.exists(): + return {"code": None, "saved": False} + return {"code": code_path.read_text(encoding="utf-8"), "saved": True} + +@app.put("/api/code/{task_id}") +def save_code(task_id: str, request: CodeRequest): + code_path = get_user_code_path(task_id) + USER_CODE_DIR.mkdir(parents=True, exist_ok=True) + code_path.write_text(request.code, encoding="utf-8") + return {"saved": True, "path": str(code_path.relative_to(ROOT_DIR))} + +@app.delete("/api/code/{task_id}") +def delete_saved_code(task_id: str): + code_path = get_user_code_path(task_id) + if code_path.exists(): + code_path.unlink() + return {"deleted": True} + @app.post("/api/submit/{task_id}") def submit_code(task_id: str, request: SubmitRequest): result = execute_code(task_id, request.code) diff --git a/api/parser.py b/api/parser.py index ce0de6a..806cdae 100644 --- a/api/parser.py +++ b/api/parser.py @@ -45,6 +45,46 @@ def parse_notebook_template(filepath: str) -> dict: "initial_code": "# Error loading template code." } +def parse_notebook_solution(filepath: str) -> dict: + """ + Parses a Jupyter Notebook solution to extract explanatory text and the + reference solution code. + """ + try: + with open(filepath, 'r', encoding='utf-8') as f: + nb = json.load(f) + + solution_parts = [] + + for cell in nb.get("cells", []): + source = cell.get("source", []) + source_str = "".join(source).strip() + + if not source_str: + continue + + if cell["cell_type"] == "markdown": + filtered_source = [ + line for line in source + if "![Open In Colab]" not in line + ] + markdown = "".join(filtered_source).strip() + if markdown: + solution_parts.append(markdown) + + elif cell["cell_type"] == "code" and ("# ✅ SOLUTION" in source_str or "# SOLUTION" in source_str): + solution_parts.append(f"```python\n{source_str}\n```") + break + + return { + "solution": "\n\n".join(solution_parts) or "Solution not found in notebook." + } + except Exception as e: + print(f"Error parsing {filepath}: {e}") + return { + "solution": "Error loading solution." + } + def get_all_templates(templates_dir: str = "../templates") -> dict: """ Returns a dictionary mapping task_ids to their extracted template data. @@ -69,6 +109,26 @@ def get_all_templates(templates_dir: str = "../templates") -> dict: return templates +def get_all_solutions(solutions_dir: str = "../solutions") -> dict: + """ + Returns a dictionary mapping task_ids to their extracted solution data. + task_id is inferred from the filename (e.g., '01_relu_solution.ipynb' -> 'relu'). + """ + solutions = {} + + base_dir = os.path.dirname(os.path.abspath(__file__)) + solutions_path = os.path.join(base_dir, solutions_dir) + + for filepath in glob.glob(os.path.join(solutions_path, "*.ipynb")): + filename = os.path.basename(filepath) + + match = re.match(r"^\d+_(.+)_solution\.ipynb$", filename) + if match: + task_id = match.group(1) + solutions[task_id] = parse_notebook_solution(filepath) + + return solutions + if __name__ == "__main__": # Test parser res = get_all_templates() diff --git a/api/requirements.txt b/api/requirements.txt index c9b6004..540f3e1 100644 --- a/api/requirements.txt +++ b/api/requirements.txt @@ -1,3 +1,4 @@ fastapi uvicorn pydantic +torch diff --git a/solutions/06_multihead_attention_solution.ipynb b/solutions/06_multihead_attention_solution.ipynb index 7ed4ad4..35d4bc6 100644 --- a/solutions/06_multihead_attention_solution.ipynb +++ b/solutions/06_multihead_attention_solution.ipynb @@ -100,7 +100,7 @@ "source": [ "# Run judge\n", "from torch_judge import check\n", - "check(\"mha\")" + "check(\"multihead_attention\")" ] } ], diff --git a/templates/06_multihead_attention.ipynb b/templates/06_multihead_attention.ipynb index 7812714..6785a70 100644 --- a/templates/06_multihead_attention.ipynb +++ b/templates/06_multihead_attention.ipynb @@ -112,7 +112,7 @@ "source": [ "# ✅ SUBMIT\n", "from torch_judge import check\n", - "check(\"mha\")" + "check(\"multihead_attention\")" ] } ], diff --git a/torch_judge/tasks/mha.py b/torch_judge/tasks/multihead_attention.py similarity index 100% rename from torch_judge/tasks/mha.py rename to torch_judge/tasks/multihead_attention.py diff --git a/web/package-lock.json b/web/package-lock.json index 42833dc..79e793a 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -606,9 +606,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -625,9 +622,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -644,9 +638,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -663,9 +654,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -682,9 +670,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -701,9 +686,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -720,9 +702,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -739,9 +718,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -758,9 +734,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -783,9 +756,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -808,9 +778,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -833,9 +800,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -858,9 +822,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -883,9 +844,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -908,9 +866,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -933,9 +888,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1168,9 +1120,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1187,9 +1136,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1206,9 +1152,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1225,9 +1168,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1466,9 +1406,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1486,9 +1423,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1506,9 +1440,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1526,9 +1457,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2145,9 +2073,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2162,9 +2087,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2179,9 +2101,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2196,9 +2115,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2213,9 +2129,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2230,9 +2143,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2247,9 +2157,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2264,9 +2171,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -5308,9 +5212,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5332,9 +5233,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5356,9 +5254,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5380,9 +5275,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ diff --git a/web/src/app/page.tsx b/web/src/app/page.tsx index dfdec0d..7b7d2dd 100644 --- a/web/src/app/page.tsx +++ b/web/src/app/page.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useRef } from 'react'; import Editor from '@monaco-editor/react'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; @@ -48,6 +48,15 @@ type SubmissionResponse = { stderr: string; }; +type SolutionResponse = { + solution: string; +}; + +type SavedCodeResponse = { + code: string | null; + saved: boolean; +}; + export default function Home() { const [tasks, setTasks] = useState([]); const [selectedTaskId, setSelectedTaskId] = useState(null); @@ -58,6 +67,26 @@ export default function Home() { const [activeTab, setActiveTab] = useState<'problem' | 'results'>('problem'); const [solvedTasks, setSolvedTasks] = useState>({}); const [isDropdownOpen, setIsDropdownOpen] = useState(false); + const [solution, setSolution] = useState(null); + const [isSolutionVisible, setIsSolutionVisible] = useState(false); + const [isSolutionLoading, setIsSolutionLoading] = useState(false); + const [solutionError, setSolutionError] = useState(null); + const saveTimersRef = useRef>>({}); + + const saveCodeToDisk = (taskId: string, newCode: string) => { + const existingTimer = saveTimersRef.current[taskId]; + if (existingTimer) { + clearTimeout(existingTimer); + } + + saveTimersRef.current[taskId] = setTimeout(() => { + fetch(`${API_BASE}/code/${taskId}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ code: newCode }), + }).catch(err => console.error("Failed to save code to disk", err)); + }, 500); + }; useEffect(() => { // Load solved tasks from local storage @@ -83,18 +112,56 @@ export default function Home() { .catch(err => console.error("Failed to load tasks", err)); }, []); + useEffect(() => { + const timers = saveTimersRef.current; + return () => { + Object.values(timers).forEach(clearTimeout); + }; + }, []); + useEffect(() => { if (selectedTaskId) { - fetch(`${API_BASE}/tasks/${selectedTaskId}`) - .then(res => res.json()) - .then((data: TaskDetails) => { + let isCurrentTask = true; + + const loadTask = async () => { + try { + const taskRes = await fetch(`${API_BASE}/tasks/${selectedTaskId}`); + const data: TaskDetails = await taskRes.json(); + let savedCodeData: SavedCodeResponse = { code: null, saved: false }; + + try { + const savedCodeRes = await fetch(`${API_BASE}/code/${selectedTaskId}`); + savedCodeData = await savedCodeRes.json(); + } catch (err) { + console.error("Failed to load saved code from disk", err); + } + + if (!isCurrentTask) return; + setTaskDetails(data); const savedCode = localStorage.getItem(`torchcode_code_${selectedTaskId}`); - setCode(savedCode !== null ? savedCode : data.initial_code); + const codeToUse = savedCodeData.saved && savedCodeData.code !== null + ? savedCodeData.code + : savedCode !== null + ? savedCode + : data.initial_code; + + setCode(codeToUse); setResults(null); + setSolution(null); + setSolutionError(null); + setIsSolutionVisible(false); setActiveTab('problem'); - }) - .catch(err => console.error("Failed to load task details", err)); + } catch (err) { + console.error("Failed to load task details", err); + } + }; + + loadTask(); + + return () => { + isCurrentTask = false; + }; } }, [selectedTaskId]); @@ -138,9 +205,43 @@ export default function Home() { } }; + const handleSolutionToggle = async (isOpen: boolean) => { + if (!selectedTaskId) return; + + setIsSolutionVisible(isOpen); + if (!isOpen) { + return; + } + + setSolutionError(null); + + if (solution !== null) return; + + setIsSolutionLoading(true); + try { + const res = await fetch(`${API_BASE}/tasks/${selectedTaskId}/solution`); + if (!res.ok) { + throw new Error("Solution not found for this task."); + } + const data: SolutionResponse = await res.json(); + setSolution(data.solution); + } catch (err) { + console.error("Failed to load solution", err); + setSolutionError("Failed to load solution."); + } finally { + setIsSolutionLoading(false); + } + }; + const handleReset = () => { if (taskDetails && selectedTaskId) { localStorage.removeItem(`torchcode_code_${selectedTaskId}`); + const existingTimer = saveTimersRef.current[selectedTaskId]; + if (existingTimer) { + clearTimeout(existingTimer); + } + fetch(`${API_BASE}/code/${selectedTaskId}`, { method: "DELETE" }) + .catch(err => console.error("Failed to delete saved code", err)); setCode(taskDetails.initial_code); setResults(null); } @@ -307,6 +408,39 @@ export default function Home() { )} +
+
{ + const isOpen = event.currentTarget.open; + if (isOpen !== isSolutionVisible) { + void handleSolutionToggle(isOpen); + } + }} + > + + Solution + + +
+ {isSolutionLoading ? ( +
Loading solution...
+ ) : solutionError ? ( +
+ {solutionError} +
+ ) : ( + + {solution || ""} + + )} +
+
+
) : (
Loading problem...
@@ -431,6 +565,7 @@ export default function Home() { setCode(newCode); if (selectedTaskId) { localStorage.setItem(`torchcode_code_${selectedTaskId}`, newCode); + saveCodeToDisk(selectedTaskId, newCode); } }} options={{