diff --git a/README.md b/README.md index c368e92..4a754ff 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,8 @@ The `/tasks` endpoints expose an asynchronous `task_status_v1` lifecycle with `q Course PDFs can be ingested with Docling and indexed into a local Chroma store. During assignment creation, the app will search both indexed course documents and live Canvas module content, then attach the most relevant excerpts to generated outputs. +For explicit maze-search assignments that require `maze_solvers.py`, the generator now emits a working Python maze project with BFS, DFS, A* implementations, a sample maze file, and generated tests instead of only stub functions. + ## MCP Server The project also exposes an MCP stdio server so other agents and MCP-compatible clients can invoke the workflow directly. diff --git a/scaffolding/templates.py b/scaffolding/templates.py index 8ae190b..a5a2615 100644 --- a/scaffolding/templates.py +++ b/scaffolding/templates.py @@ -930,6 +930,302 @@ def _extend_python_requirements(existing_requirements: str, extra_requirements: return "\n".join(lines) + "\n" +def _is_python_maze_assignment(requested_files: List[str], assignment_description: str) -> bool: + requested_lower = {path.lower() for path in requested_files} + description_lower = assignment_description.lower() + return ( + "maze_solvers.py" in requested_lower + or ( + "maze" in description_lower + and "solver" in description_lower + and "python" in description_lower + ) + ) + + +def _build_maze_solver_file(assignment_name: str, function_names: List[str]) -> str: + solver_names = list(function_names[:3]) + defaults = ["maze_solver_one", "maze_solver_two", "maze_solver_three"] + for default_name in defaults: + if len(solver_names) >= 3: + break + if default_name not in solver_names: + solver_names.append(default_name) + + body = '''from __future__ import annotations + +from collections import deque +from heapq import heappop, heappush +from pathlib import Path +from typing import Iterable, Sequence + +Point = tuple[int, int] +Grid = list[list[str]] + + +def _load_maze_lines(maze: str | Sequence[str]) -> list[str]: + if isinstance(maze, (list, tuple)): + lines = [str(line).rstrip("\\n") for line in maze] + elif isinstance(maze, str): + candidate_path = Path(maze) + if "\\n" not in maze and candidate_path.exists(): + lines = candidate_path.read_text(encoding="utf-8").splitlines() + else: + lines = maze.splitlines() + else: + raise TypeError("maze must be a path, maze text, or a sequence of lines") + + lines = [line.rstrip("\\n") for line in lines if line is not None] + if not lines: + raise ValueError("maze input is empty") + return lines + + +def _parse_maze(maze: str | Sequence[str]) -> tuple[int, int, Grid, Point, Point]: + lines = _load_maze_lines(maze) + try: + width_text, height_text = lines[0].split() + width = int(width_text) + height = int(height_text) + except ValueError as error: + raise ValueError("first maze line must contain width and height") from error + + grid_lines = lines[1:] + if len(grid_lines) != height: + raise ValueError(f"maze height mismatch: expected {height}, found {len(grid_lines)}") + + grid = [list(row) for row in grid_lines] + for row in grid: + if len(row) != width: + raise ValueError(f"maze width mismatch: expected {width}, found {len(row)}") + + start = _find_symbol(grid, "S") + goal = _find_symbol(grid, "E") + return width, height, grid, start, goal + + +def _find_symbol(grid: Grid, symbol: str) -> Point: + for row_index, row in enumerate(grid): + for col_index, value in enumerate(row): + if value == symbol: + return row_index, col_index + raise ValueError(f"maze must contain exactly one {symbol}") + + +def _neighbors(grid: Grid, point: Point) -> Iterable[Point]: + row, col = point + candidates = [ + (row - 1, col), + (row, col + 1), + (row + 1, col), + (row, col - 1), + ] + height = len(grid) + width = len(grid[0]) if grid else 0 + for next_row, next_col in candidates: + if 0 <= next_row < height and 0 <= next_col < width and grid[next_row][next_col] != "X": + yield next_row, next_col + + +def _reconstruct_path(parents: dict[Point, Point | None], goal: Point) -> list[Point]: + path: list[Point] = [] + current: Point | None = goal + while current is not None: + path.append(current) + current = parents[current] + path.reverse() + return path + + +def _render_solution(width: int, height: int, grid: Grid, path: list[Point]) -> str: + solved = [row[:] for row in grid] + for row, col in path[1:-1]: + if solved[row][col] == " ": + solved[row][col] = "*" + body = ["".join(row) for row in solved] + return "\\n".join([f"{width} {height}", *body]) + + +def _heuristic(point: Point, goal: Point) -> int: + return abs(point[0] - goal[0]) + abs(point[1] - goal[1]) + + +def _solve_bfs(maze: str | Sequence[str]) -> str: + width, height, grid, start, goal = _parse_maze(maze) + frontier: deque[Point] = deque([start]) + parents: dict[Point, Point | None] = {start: None} + + while frontier: + current = frontier.popleft() + if current == goal: + return _render_solution(width, height, grid, _reconstruct_path(parents, goal)) + + for neighbor in _neighbors(grid, current): + if neighbor in parents: + continue + parents[neighbor] = current + frontier.append(neighbor) + + raise ValueError("maze has no solution") + + +def _solve_dfs(maze: str | Sequence[str]) -> str: + width, height, grid, start, goal = _parse_maze(maze) + frontier: list[Point] = [start] + parents: dict[Point, Point | None] = {start: None} + + while frontier: + current = frontier.pop() + if current == goal: + return _render_solution(width, height, grid, _reconstruct_path(parents, goal)) + + neighbors = list(_neighbors(grid, current)) + for neighbor in reversed(neighbors): + if neighbor in parents: + continue + parents[neighbor] = current + frontier.append(neighbor) + + raise ValueError("maze has no solution") + + +def _solve_astar(maze: str | Sequence[str]) -> str: + width, height, grid, start, goal = _parse_maze(maze) + frontier: list[tuple[int, int, Point]] = [(0, 0, start)] + parents: dict[Point, Point | None] = {start: None} + cost_so_far: dict[Point, int] = {start: 0} + tie_breaker = 1 + + while frontier: + _, _, current = heappop(frontier) + if current == goal: + return _render_solution(width, height, grid, _reconstruct_path(parents, goal)) + + for neighbor in _neighbors(grid, current): + tentative_cost = cost_so_far[current] + 1 + if tentative_cost >= cost_so_far.get(neighbor, tentative_cost + 1): + continue + + cost_so_far[neighbor] = tentative_cost + parents[neighbor] = current + priority = tentative_cost + _heuristic(neighbor, goal) + heappush(frontier, (priority, tie_breaker, neighbor)) + tie_breaker += 1 + + raise ValueError("maze has no solution") + + +''' + + algorithm_defs = [ + (solver_names[0], "breadth-first search", "_solve_bfs"), + (solver_names[1], "depth-first search", "_solve_dfs"), + (solver_names[2], "A* search with the Manhattan-distance heuristic", "_solve_astar"), + ] + + function_blocks = [] + for function_name, description, implementation_name in algorithm_defs: + function_blocks.append( + f"def {function_name}(maze: str | Sequence[str]) -> str:\n" + f' """Solve the maze with {description} and return the solved maze text."""\n' + f" return {implementation_name}(maze)\n" + ) + + return f'"""{assignment_name} maze solver interface."""\n\n' + body + "\n\n".join(function_blocks) + "\n" + + +def _build_maze_runner_file(function_names: List[str]) -> str: + solver_names = list(function_names[:3]) + defaults = ["maze_solver_one", "maze_solver_two", "maze_solver_three"] + for default_name in defaults: + if len(solver_names) >= 3: + break + if default_name not in solver_names: + solver_names.append(default_name) + + return ( + '"""Command-line runner for the generated maze solvers."""\n\n' + f"from maze_solvers import {solver_names[0]}, {solver_names[1]}, {solver_names[2]}\n\n" + "\n" + "def main() -> None:\n" + ' """Load maze.txt and print each solver output."""\n' + ' maze_path = "maze.txt"\n' + f" solvers = [(\"{solver_names[0]}\", {solver_names[0]}), (\"{solver_names[1]}\", {solver_names[1]}), (\"{solver_names[2]}\", {solver_names[2]})]\n" + " for name, solver in solvers:\n" + " print(f\"=== {name} ===\")\n" + " print(solver(maze_path))\n" + " print()\n\n" + 'if __name__ == "__main__":\n' + " main()\n" + ) + + +def _build_maze_solver_tests(function_names: List[str]) -> str: + solver_names = list(function_names[:3]) + defaults = ["maze_solver_one", "maze_solver_two", "maze_solver_three"] + for default_name in defaults: + if len(solver_names) >= 3: + break + if default_name not in solver_names: + solver_names.append(default_name) + + return ( + f"from maze_solvers import {solver_names[0]}, {solver_names[1]}, {solver_names[2]}\n\n" + 'SAMPLE_MAZE = """5 5\nS X\nXX XX\nX X\nX XXX\nX E\n"""\n\n' + "\n" + "def _assert_valid_solution(result: str) -> int:\n" + ' lines = result.splitlines()\n' + ' assert lines[0] == "5 5"\n' + ' body = "\\n".join(lines[1:])\n' + ' assert "S" in body\n' + ' assert "E" in body\n' + ' assert "*" in body\n' + ' return body.count("*")\n\n' + f"def test_{solver_names[0]}_returns_solved_maze():\n" + f" steps = _assert_valid_solution({solver_names[0]}(SAMPLE_MAZE))\n" + " assert steps > 0\n\n" + f"def test_{solver_names[1]}_returns_solved_maze():\n" + f" steps = _assert_valid_solution({solver_names[1]}(SAMPLE_MAZE))\n" + " assert steps > 0\n\n" + f"def test_{solver_names[2]}_matches_bfs_path_length():\n" + f" bfs_steps = _assert_valid_solution({solver_names[0]}(SAMPLE_MAZE))\n" + f" astar_steps = _assert_valid_solution({solver_names[2]}(SAMPLE_MAZE))\n" + " assert astar_steps == bfs_steps\n" + ) + + +def _build_maze_report_template(assignment_name: str) -> str: + return ( + f"# {assignment_name} Report\n\n" + "## Introduction to Search Algorithms\n\n" + "Summarize uninformed versus informed search and explain why maze solving is a useful benchmark problem.\n\n" + "## Selected Algorithms\n\n" + "- Breadth-first search (blind search)\n" + "- Depth-first search (blind search)\n" + "- A* search (heuristic search)\n\n" + "## Heuristics Used\n\n" + "Document the Manhattan-distance heuristic used by A* and explain why it is admissible for a 4-direction grid maze.\n\n" + "## Performance Comparison\n\n" + "Record the path length, nodes expanded, and runtime for each solver on the assigned report maze.\n\n" + "## Optimality, Time, and Space Analysis\n\n" + "Compare the optimality of the returned paths, the time taken, and the memory required by each approach.\n\n" + "## Maze Variations and Performance Impact\n\n" + "Describe how added walls, wider corridors, or misleading dead ends would change the performance of each algorithm.\n\n" + "## Real-life Application\n\n" + "Describe a real-world situation where one of these search algorithms would be useful.\n" + ) + + +def _append_maze_readme_notes(existing_readme: str) -> str: + return existing_readme.rstrip() + ( + "\n\n## Maze Solver Interface\n" + "- `maze_solvers.py` contains working implementations of breadth-first search, depth-first search, and A* search.\n" + "- Each solver accepts either a maze file path, raw maze text, or a sequence of maze lines.\n" + "- `main.py` runs all three solvers against `maze.txt` for quick smoke testing.\n" + "- Run `pytest tests/test_maze_solvers.py` to validate the generated maze solver interface.\n" + ) + + def build_assignment_specific_files( assignment_name: str, assignment_description: str, @@ -948,8 +1244,12 @@ def build_assignment_specific_files( assignment_summary = (short_description or assignment_description[:200]).strip() requested_lower = {path.lower() for path in requested_files} + is_maze_assignment = language_lower in {"python", "py"} and _is_python_maze_assignment( + requested_files, + assignment_description, + ) - if language_lower in {"python", "py"} and "maze_solvers.py" in requested_lower: + if is_maze_assignment: maze_functions = [ name for name in requested_functions if name.startswith("maze_solver_") or name in { @@ -961,42 +1261,22 @@ def build_assignment_specific_files( if not maze_functions: maze_functions = ["maze_solver_one", "maze_solver_two", "maze_solver_three"] - function_blocks = [] - for function_name in maze_functions: - function_blocks.append( - f"def {function_name}(maze):\n" - " \"\"\"Solve the maze and return the solved maze output.\"\"\"\n" - " return maze\n" - ) - - files["maze_solvers.py"] = ( - f'"""{assignment_name} maze solver interface."""\n\n' + - "\n\n".join(function_blocks) + - "\n" - ) + files["maze_solvers.py"] = _build_maze_solver_file(assignment_name, maze_functions) + files["main.py"] = _build_maze_runner_file(maze_functions) + files["tests/test_maze_solvers.py"] = _build_maze_solver_tests(maze_functions) - if "maze.txt" in requested_lower or "maze" in assignment_description.lower(): + if is_maze_assignment or "maze.txt" in requested_lower or "maze" in assignment_description.lower(): files["maze.txt"] = ( - "10 6\n" - "XXXXXXXXXX\n" - "X S\n" - "X XXXXXX X\n" - "X X XXX\n" - "X XX E\n" - "XXXXXXXXXX\n" + "5 5\n" + "S X\n" + "XX XX\n" + "X X\n" + "X XXX\n" + "X E\n" ) - if "report.md" in requested_lower or "report" in assignment_description.lower(): - files["Report.md"] = ( - f"# {assignment_name} Report\n\n" - "## Introduction to Search Algorithms\n\n" - "## Selected Algorithms\n\n" - "## Heuristics Used\n\n" - "## Performance Comparison\n\n" - "## Optimality, Time, and Space Analysis\n\n" - "## Maze Variations and Performance Impact\n\n" - "## Real-life Application\n" - ) + if is_maze_assignment or "report.md" in requested_lower or "report" in assignment_description.lower(): + files["Report.md"] = _build_maze_report_template(assignment_name) if language_lower in {"python", "py"} and assignment_mentions_jupyter_notebook(assignment_description): notebook_imports = inferred_python_imports @@ -1093,6 +1373,9 @@ def generate_starter_files( ) ) + if language.lower() in {"python", "py"} and "maze_solvers.py" in files: + files["README.md"] = _append_maze_readme_notes(files["README.md"]) + if language.lower() in {"python", "py"}: inferred_imports = infer_python_assignment_imports(assignment_description) inferred_requirements = infer_python_assignment_requirements(assignment_description) diff --git a/tests/test_agent.py b/tests/test_agent.py index 3504385..0d6b3c2 100644 --- a/tests/test_agent.py +++ b/tests/test_agent.py @@ -297,11 +297,13 @@ def test_extract_required_function_names(self): assert "maze_solver_three" in function_names def test_assignment_specific_scaffold_files(self): - """Generate requested files and solver stubs from assignment brief.""" + """Generate requested maze files and a working solver project from assignment brief.""" assignment_description = ( "The file must be named maze_solvers.py. " "The file must include the function names: maze_solver_one, " "maze_solver_two and maze_solver_three. " + "You must use at least one blind search algorithm and one heuristic search algorithm. " + "Your code must be written in Python. " "The maze will be stored as a text file. " "When you submit all code, include a README file and a report." ) @@ -313,12 +315,32 @@ def test_assignment_specific_scaffold_files(self): ) assert "maze_solvers.py" in files - assert "def maze_solver_one(maze):" in files["maze_solvers.py"] - assert "def maze_solver_two(maze):" in files["maze_solvers.py"] - assert "def maze_solver_three(maze):" in files["maze_solvers.py"] + assert "main.py" in files + assert "tests/test_maze_solvers.py" in files + assert "def maze_solver_one(maze: str | Sequence[str]) -> str:" in files["maze_solvers.py"] + assert "def maze_solver_two(maze: str | Sequence[str]) -> str:" in files["maze_solvers.py"] + assert "def maze_solver_three(maze: str | Sequence[str]) -> str:" in files["maze_solvers.py"] + assert "breadth-first search" in files["maze_solvers.py"] + assert "A* search" in files["maze_solvers.py"] + assert "pytest tests/test_maze_solvers.py" in files["README.md"] assert "maze.txt" in files assert "Report.md" in files + namespace: dict[str, object] = {} + exec(files["maze_solvers.py"], namespace) + solved_bfs = namespace["maze_solver_one"](files["maze.txt"]) + solved_dfs = namespace["maze_solver_two"](files["maze.txt"]) + solved_astar = namespace["maze_solver_three"](files["maze.txt"]) + + for solved in [solved_bfs, solved_dfs, solved_astar]: + assert solved.splitlines()[0] == "5 5" + assert "S" in solved + assert "E" in solved + assert "*" in solved + + assert solved_bfs.count("*") == solved_astar.count("*") + assert solved_dfs.count("*") >= solved_bfs.count("*") + def test_python_function_stubs_default_to_main(self): """Create Python function stubs in main.py when no source file is named.""" files = generate_starter_files(