From 4cb89fb1470b34cc229aae006a126a82db069af6 Mon Sep 17 00:00:00 2001 From: laitifranz <25352428+laitifranz@users.noreply.github.com> Date: Thu, 23 Jul 2026 09:08:08 +0200 Subject: [PATCH 1/2] feat: add solution retrieval and display functionality on the web interface --- api/main.py | 12 ++++- api/parser.py | 84 +++++++++++++++++++++++++++++++++ web/src/app/page.tsx | 110 +++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 200 insertions(+), 6 deletions(-) diff --git a/api/main.py b/api/main.py index dfda7ea5..86bebe2a 100644 --- a/api/main.py +++ b/api/main.py @@ -9,7 +9,7 @@ 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_templates, get_all_solutions app = FastAPI(title="TorchCode UI Backend") @@ -22,8 +22,9 @@ allow_headers=["*"], ) -# Load templates on startup +# Load templates and solutions on startup TEMPLATES = get_all_templates() +SOLUTIONS = get_all_solutions() class SubmitRequest(BaseModel): code: str @@ -55,6 +56,13 @@ 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): + solution = SOLUTIONS.get(task_id) + if not solution: + raise HTTPException(status_code=404, detail="Solution not found") + return solution + @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 5a981f34..0e038459 100644 --- a/api/parser.py +++ b/api/parser.py @@ -50,6 +50,90 @@ def parse_notebook_template(filepath: str) -> dict: "multihead_attention": "mha", } + +def parse_notebook_solution(filepath: str) -> dict: + """ + Parses a solution Jupyter Notebook into an ordered list of renderable cells. + + Returns {"cells": [{"type": "markdown"|"code", "source": str}, ...]}, + skipping empty cells and Colab-install boilerplate. + """ + try: + with open(filepath, 'r', encoding='utf-8') as f: + nb = json.load(f) + + cells = [] + for cell in nb.get("cells", []): + cell_type = cell.get("cell_type") + if cell_type not in ("markdown", "code"): + continue + + source_lines = cell.get("source", []) + if cell_type == "markdown": + # Drop the Colab badge line, same as the template parser. + source_lines = [ + line for line in source_lines + if "![Open In Colab]" not in line + ] + + source = "".join(source_lines).strip() + if not source: + continue + + # Skip the Colab-only "pip install torch-judge" bootstrap cell. + if cell_type == "code" and "google.colab" in source: + continue + + cells.append({"type": cell_type, "source": source}) + + return {"cells": cells} + except Exception as e: + print(f"Error parsing solution {filepath}: {e}") + return {"cells": []} + + +def _extract_check_id(cells): + """Return the argument of the notebook's check("") call, if present.""" + for cell in cells: + if cell["type"] != "code": + continue + match = re.search(r"check\(\s*[\"']([^\"']+)[\"']", cell["source"]) + if match: + return match.group(1) + return None + + +def get_all_solutions(solutions_dir: str = "../solutions") -> dict: + """ + Returns a dict mapping task_id -> {"cells": [...]} for every solution notebook. + + The task_id is the argument of the notebook's check("") call (the + authoritative judge id, matching torch_judge.tasks), which is more reliable + than the filename slug (e.g. 06_multihead_attention_solution.ipynb -> "mha"). + Falls back to the filename slug if no check(...) call is found. + """ + 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")): + parsed = parse_notebook_solution(filepath) + task_id = _extract_check_id(parsed["cells"]) + + if not task_id: + filename = os.path.basename(filepath) + match = re.match(r"^\d+_(.+)_solution\.ipynb$", filename) + if not match: + continue + task_id = match.group(1) + + solutions[task_id] = parsed + + return solutions + + + def get_all_templates(templates_dir: str = "../templates") -> dict: """ Returns a dictionary mapping task_ids to their extracted template data. diff --git a/web/src/app/page.tsx b/web/src/app/page.tsx index 093d55f9..b72470c1 100644 --- a/web/src/app/page.tsx +++ b/web/src/app/page.tsx @@ -8,7 +8,7 @@ import remarkGfm from 'remark-gfm'; import remarkMath from 'remark-math'; import rehypeKatex from 'rehype-katex'; import 'katex/dist/katex.min.css'; -import { Play, RotateCcw, ChevronDown, Check, X, AlertCircle } from 'lucide-react'; +import { Play, RotateCcw, ChevronDown, Check, X, AlertCircle, BookOpen, Copy } from 'lucide-react'; import { Allotment } from "allotment"; import "allotment/dist/style.css"; @@ -37,6 +37,11 @@ type TestResult = { stderr: string; }; +type SolutionCell = { + type: 'markdown' | 'code'; + source: string; +}; + type SubmissionResponse = { success: boolean; error?: string; @@ -49,6 +54,38 @@ type SubmissionResponse = { stderr: string; }; +function CopyButton({ text }: { text: string }) { + const [copied, setCopied] = useState(false); + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(text); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + } catch (err) { + console.error("Copy failed", err); + } + }; + + return ( + + ); +} + export default function Home() { const [tasks, setTasks] = useState([]); const [selectedTaskId, setSelectedTaskId] = useState(null); @@ -56,7 +93,9 @@ export default function Home() { const [code, setCode] = useState(""); const [isSubmitting, setIsSubmitting] = useState(false); const [results, setResults] = useState(null); - const [activeTab, setActiveTab] = useState<'problem' | 'results'>('problem'); + const [activeTab, setActiveTab] = useState<'problem' | 'results' | 'solution'>('problem'); + const [solution, setSolution] = useState(null); + const [solutionLoading, setSolutionLoading] = useState(false); const [solvedTasks, setSolvedTasks] = useState>({}); const [isDropdownOpen, setIsDropdownOpen] = useState(false); const editorRef = useRef(null); @@ -104,12 +143,29 @@ export default function Home() { const savedCode = localStorage.getItem(`torchcode_code_${selectedTaskId}`); setCode(savedCode !== null ? savedCode : data.initial_code); setResults(null); + setSolution(null); setActiveTab('problem'); }) .catch(err => console.error("Failed to load task details", err)); } }, [selectedTaskId]); + useEffect(() => { + // Lazily load the reference solution the first time the tab is opened + // for the current task. + if (activeTab === 'solution' && selectedTaskId && solution === null && !solutionLoading) { + setSolutionLoading(true); + fetch(`${API_BASE}/tasks/${selectedTaskId}/solution`) + .then(res => (res.ok ? res.json() : { cells: [] })) + .then((data: { cells: SolutionCell[] }) => setSolution(data.cells || [])) + .catch(err => { + console.error("Failed to load solution", err); + setSolution([]); + }) + .finally(() => setSolutionLoading(false)); + } + }, [activeTab, selectedTaskId, solution, solutionLoading]); + const handleRunCode = async () => { if (!selectedTaskId) return; @@ -274,10 +330,17 @@ export default function Home() { )} +
- {activeTab === 'problem' ? ( + {activeTab === 'problem' && (
{taskDetails ? ( <> @@ -325,7 +388,9 @@ export default function Home() {
Loading problem...
)}
- ) : ( + )} + + {activeTab === 'results' && (
{isSubmitting ? (
@@ -423,6 +488,43 @@ export default function Home() { )}
)} + + {activeTab === 'solution' && ( +
+ {solutionLoading ? ( +
+ Loading solution... +
+ ) : solution && solution.length > 0 ? ( +
+ {solution.map((cell, idx) => + cell.type === 'markdown' ? ( +
+ + {cell.source} + +
+ ) : ( +
+ +
+                            {cell.source}
+                          
+
+ ) + )} +
+ ) : ( +
+ +

No reference solution for this exercise yet.

+
+ )} +
+ )}
From 89c6dcf2f3d6d432f5102e20984e7786fbc920a2 Mon Sep 17 00:00:00 2001 From: laitifranz <25352428+laitifranz@users.noreply.github.com> Date: Fri, 31 Jul 2026 08:50:09 +0200 Subject: [PATCH 2/2] feat(api): enhance notebook solution parsing and add boilerplate solution filtering --- api/parser.py | 67 ++++++++++++++++++++++++++++++++------- web/src/app/globals.css | 2 +- web/src/app/page.tsx | 70 ++++++++++++++++++++++++++++++++++------- 3 files changed, 115 insertions(+), 24 deletions(-) diff --git a/api/parser.py b/api/parser.py index 0e038459..330d995d 100644 --- a/api/parser.py +++ b/api/parser.py @@ -51,18 +51,65 @@ def parse_notebook_template(filepath: str) -> dict: } +_SOLUTION_MARKER_RE = re.compile(r"#\s*✅?\s*SOLUTION\b", re.IGNORECASE) + + +def _is_imports_only(source: str) -> bool: + """True if every non-empty, non-comment line is an import.""" + saw_import = False + for line in source.splitlines(): + stripped = line.strip() + if not stripped or stripped.startswith("#"): + continue + if stripped.startswith("import ") or stripped.startswith("from "): + saw_import = True + continue + return False + return saw_import + + +def _is_solution_boilerplate(cell: dict) -> bool: + """ + True for cells we omit from the Solution tab: Colab bootstrap, bare + imports, verify/demo scaffolding, and judge check(...) calls. + """ + if cell["type"] != "code": + return False + + source = cell["source"] + if "google.colab" in source: + return True + if "torch_judge" in source or re.search(r"\bcheck\s*\(", source): + return True + if _SOLUTION_MARKER_RE.search(source): + return False + + first_line = source.lstrip().split("\n", 1)[0] + if re.match(r"^#\s*(Verify|Demo|Run judge|No imports needed)\b", first_line, re.IGNORECASE): + return True + if _is_imports_only(source): + return True + + # Bare demo cells (no SOLUTION marker, no def/class) — notebook scaffolding. + if not re.search(r"^(def|class)\s", source, re.MULTILINE): + return True + + return False + + def parse_notebook_solution(filepath: str) -> dict: """ Parses a solution Jupyter Notebook into an ordered list of renderable cells. Returns {"cells": [{"type": "markdown"|"code", "source": str}, ...]}, - skipping empty cells and Colab-install boilerplate. + skipping empty cells, Colab-install boilerplate, imports-only cells, + verify/demo scaffolding, and judge check(...) cells. """ try: with open(filepath, 'r', encoding='utf-8') as f: nb = json.load(f) - cells = [] + raw_cells = [] for cell in nb.get("cells", []): cell_type = cell.get("cell_type") if cell_type not in ("markdown", "code"): @@ -80,16 +127,13 @@ def parse_notebook_solution(filepath: str) -> dict: if not source: continue - # Skip the Colab-only "pip install torch-judge" bootstrap cell. - if cell_type == "code" and "google.colab" in source: - continue - - cells.append({"type": cell_type, "source": source}) + raw_cells.append({"type": cell_type, "source": source}) - return {"cells": cells} + cells = [c for c in raw_cells if not _is_solution_boilerplate(c)] + return {"cells": cells, "_raw_cells": raw_cells} except Exception as e: print(f"Error parsing solution {filepath}: {e}") - return {"cells": []} + return {"cells": [], "_raw_cells": []} def _extract_check_id(cells): @@ -119,7 +163,8 @@ def get_all_solutions(solutions_dir: str = "../solutions") -> dict: for filepath in glob.glob(os.path.join(solutions_path, "*.ipynb")): parsed = parse_notebook_solution(filepath) - task_id = _extract_check_id(parsed["cells"]) + # Resolve task id from the raw notebook (before boilerplate is dropped). + task_id = _extract_check_id(parsed.get("_raw_cells", parsed["cells"])) if not task_id: filename = os.path.basename(filepath) @@ -128,7 +173,7 @@ def get_all_solutions(solutions_dir: str = "../solutions") -> dict: continue task_id = match.group(1) - solutions[task_id] = parsed + solutions[task_id] = {"cells": parsed["cells"]} return solutions diff --git a/web/src/app/globals.css b/web/src/app/globals.css index 01f95eaf..92ca8586 100644 --- a/web/src/app/globals.css +++ b/web/src/app/globals.css @@ -41,7 +41,7 @@ body { color: #eff1f6; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; } -.markdown-body h1:first-child { +.problem-pane.markdown-body > h1:first-child { display: none; } .markdown-body h1, .markdown-body h2, .markdown-body h3, .markdown-body h4 { diff --git a/web/src/app/page.tsx b/web/src/app/page.tsx index b72470c1..fb2f316d 100644 --- a/web/src/app/page.tsx +++ b/web/src/app/page.tsx @@ -8,7 +8,7 @@ import remarkGfm from 'remark-gfm'; import remarkMath from 'remark-math'; import rehypeKatex from 'rehype-katex'; import 'katex/dist/katex.min.css'; -import { Play, RotateCcw, ChevronDown, Check, X, AlertCircle, BookOpen, Copy } from 'lucide-react'; +import { Play, RotateCcw, ChevronDown, Check, X, AlertCircle, BookOpen, Copy, FileText, ListChecks } from 'lucide-react'; import { Allotment } from "allotment"; import "allotment/dist/style.css"; @@ -70,7 +70,7 @@ function CopyButton({ text }: { text: string }) { return (