Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down
129 changes: 129 additions & 0 deletions api/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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("<id>") 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("<id>") 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.
Expand Down
2 changes: 1 addition & 1 deletion web/src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading
Loading