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..330d995d 100644 --- a/api/parser.py +++ b/api/parser.py @@ -50,6 +50,135 @@ def parse_notebook_template(filepath: str) -> dict: "multihead_attention": "mha", } + +_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, 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) + + raw_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 + + raw_cells.append({"type": cell_type, "source": source}) + + 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": [], "_raw_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) + # 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) + match = re.match(r"^\d+_(.+)_solution\.ipynb$", filename) + if not match: + continue + task_id = match.group(1) + + solutions[task_id] = {"cells": parsed["cells"]} + + 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/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 093d55f9..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 } 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"; @@ -37,6 +37,11 @@ type TestResult = { stderr: string; }; +type SolutionCell = { + type: 'markdown' | 'code'; + source: string; +}; + type SubmissionResponse = { success: boolean; error?: string; @@ -49,6 +54,87 @@ 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 ( + + ); +} + +const SOLUTION_LINE_HEIGHT = 22; +const SOLUTION_EDITOR_PADDING = 40; + +function SolutionCodeCell({ source }: { source: string }) { + const lineCount = Math.max(source.split("\n").length, 1); + const height = Math.min( + Math.max(lineCount * SOLUTION_LINE_HEIGHT + SOLUTION_EDITOR_PADDING, 72), + 640 + ); + + return ( +
+
+ Python +
+ + 28 ? "auto" : "hidden", + horizontal: "auto", + handleMouseWheel: lineCount > 28, + }, + wordWrap: "off", + }} + /> +
+ ); +} + export default function Home() { const [tasks, setTasks] = useState([]); const [selectedTaskId, setSelectedTaskId] = useState(null); @@ -56,7 +142,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 +192,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; @@ -258,15 +363,17 @@ export default function Home() { {/* Tabs */}
+
- {activeTab === 'problem' ? ( -
+ {activeTab === 'problem' && ( +
{taskDetails ? ( <>

{taskDetails.title}

@@ -325,7 +439,9 @@ export default function Home() {
Loading problem...
)}
- ) : ( + )} + + {activeTab === 'results' && (
{isSubmitting ? (
@@ -423,6 +539,38 @@ export default function Home() { )}
)} + + {activeTab === 'solution' && ( +
+ {solutionLoading ? ( +
+ Loading solution... +
+ ) : solution && solution.length > 0 ? ( +
+ {solution.map((cell, idx) => + cell.type === 'markdown' ? ( +
+ + {cell.source} + +
+ ) : ( + + ) + )} +
+ ) : ( +
+ +

No reference solution for this exercise yet.

+
+ )} +
+ )}