| """ |
| Round 2 FastAPI server β /project/* endpoints for the multi-sprint environment. |
| |
| Runs ALONGSIDE the existing R1 server/app.py (not replacing it). |
| All routes are prefixed /project/ to avoid any conflict with R1's |
| /reset /step /state /health /tasks. |
| |
| Endpoints |
| --------- |
| POST /project/reset β start a new 60-day episode |
| POST /project/step β advance one day / take one action |
| GET /project/state β full internal state snapshot |
| GET /project/health β liveness check (judges hit this) |
| GET /project/tasks β list available R2 scenarios |
| |
| Integration with R1 ui.py |
| -------------------------- |
| Import `project_router` and mount it on the shared FastAPI app: |
| |
| from server.project_app import project_router |
| app.include_router(project_router) |
| |
| Or run this file standalone (port 8001) for isolated testing: |
| |
| python -m server.project_app |
| """ |
|
|
| from __future__ import annotations |
|
|
| import traceback |
| from typing import Any, Dict, Optional |
|
|
| import uvicorn |
| from fastapi import APIRouter, FastAPI, HTTPException |
| from fastapi.responses import JSONResponse |
| from pydantic import BaseModel, Field |
|
|
| from sprint_env.project_environment import ProjectManagerEnv, VALID_PROJECT_TASK_NAMES |
| from sprint_env.project_models import ProjectAction |
|
|
| |
| |
| |
|
|
| _env = ProjectManagerEnv() |
|
|
| |
|
|
|
|
| class ResetRequest(BaseModel): |
| task_name: str = Field( |
| default="project_easy", |
| description="Scenario name: project_easy | project_medium | project_hard", |
| ) |
| seed: Optional[int] = Field(default=None, description="Random seed for reproducibility") |
| episode_id: Optional[str] = Field(default=None, description="Optional custom episode ID") |
|
|
|
|
| class StepRequest(BaseModel): |
| action: Dict[str, Any] = Field( |
| default_factory=lambda: {"action_type": "skip"}, |
| description=( |
| "ProjectAction dict. Keys: action_type, task_id, dev_id, " |
| "new_priority, task_ids, notes" |
| ), |
| ) |
|
|
|
|
| |
|
|
| project_router = APIRouter(prefix="/project", tags=["Round 2 β Project Manager"]) |
|
|
|
|
| @project_router.post("/reset") |
| def project_reset(req: ResetRequest) -> Dict[str, Any]: |
| """ |
| Start a new 60-day multi-sprint episode. |
| |
| Returns the initial ProjectObservation dict. |
| """ |
| task_name = req.task_name |
| if task_name not in VALID_PROJECT_TASK_NAMES: |
| raise HTTPException( |
| status_code=422, |
| detail=( |
| f"Unknown task_name '{task_name}'. " |
| f"Must be one of: {VALID_PROJECT_TASK_NAMES}" |
| ), |
| ) |
| try: |
| obs = _env.reset( |
| task_name=task_name, |
| seed=req.seed, |
| episode_id=req.episode_id, |
| ) |
| return obs |
| except Exception as exc: |
| raise HTTPException(status_code=500, detail=_fmt_error(exc)) |
|
|
|
|
| @project_router.post("/step") |
| def project_step(req: StepRequest) -> Dict[str, Any]: |
| """ |
| Advance the environment by one day. |
| |
| The agent submits one action; the environment simulates the day, |
| releases any new instructions, checks sprint boundaries, and returns |
| the next observation together with the step reward and done flag. |
| |
| Returns: |
| observation β full ProjectObservation dict |
| reward β float, step reward (includes any sprint/project bonuses) |
| done β bool, True once day 60 is complete |
| info β dict with current_sprint, tech_debt_count, inst_score |
| """ |
| if _env._done: |
| |
| return { |
| "observation": _env.state, |
| "reward": 0.0, |
| "done": True, |
| "info": {"message": "Episode already done. Call /project/reset to start a new one."}, |
| } |
|
|
| try: |
| action = _parse_action(req.action) |
| except (ValueError, TypeError) as exc: |
| raise HTTPException(status_code=422, detail=f"Invalid action: {exc}") |
|
|
| try: |
| obs, reward, done, info = _env.step(action) |
| return { |
| "observation": obs, |
| "reward": round(reward, 4), |
| "done": done, |
| "info": info, |
| } |
| except Exception as exc: |
| raise HTTPException(status_code=500, detail=_fmt_error(exc)) |
|
|
|
|
| @project_router.get("/state") |
| def project_state() -> Dict[str, Any]: |
| """ |
| Full internal state snapshot. |
| |
| Returns ProjectState dict including tasks, developers, released |
| instructions, tech debt list, sprint rewards, and all counters. |
| Useful for debugging and UI polling. |
| """ |
| try: |
| return _env.state |
| except Exception as exc: |
| raise HTTPException(status_code=500, detail=_fmt_error(exc)) |
|
|
|
|
| @project_router.get("/health") |
| def project_health() -> Dict[str, Any]: |
| """ |
| Liveness check β judges verify this returns 200. |
| |
| Returns environment status and a summary of the current episode. |
| """ |
| return { |
| "status": "ok", |
| "env": "ai-sprint-manager-r2", |
| "round": 2, |
| "episode": { |
| "id": _env._episode_id or "not_started", |
| "task_name": _env._task_name, |
| "current_day": _env._current_day, |
| "current_sprint": _env._current_sprint, |
| "done": _env._done, |
| "cumulative_reward": round(_env._cumulative_reward, 4), |
| }, |
| "valid_scenarios": VALID_PROJECT_TASK_NAMES, |
| } |
|
|
|
|
| @project_router.get("/tasks") |
| def project_tasks() -> Dict[str, Any]: |
| """List the available R2 multi-sprint scenarios.""" |
| return { |
| "tasks": [ |
| { |
| "id": "project_easy", |
| "difficulty": "easy", |
| "description": "Small stable team, clear backlog, no cascade failures.", |
| "num_sprints": 6, |
| "total_days": 60, |
| "num_tasks": 24, |
| "num_instructions": 12, |
| }, |
| { |
| "id": "project_medium", |
| "difficulty": "medium", |
| "description": "Mid-size team, dev absences, urgent bugs, conflicting instructions.", |
| "num_sprints": 6, |
| "total_days": 60, |
| "num_tasks": 30, |
| "num_instructions": 18, |
| }, |
| { |
| "id": "project_hard", |
| "difficulty": "hard", |
| "description": "Large team, cascading failures, scope pivots, hard deadline.", |
| "num_sprints": 6, |
| "total_days": 60, |
| "num_tasks": 37, |
| "num_instructions": 25, |
| }, |
| ] |
| } |
|
|
|
|
| |
|
|
| _VALID_ACTIONS = {"assign", "reassign", "reprioritize", "skip", "unblock", "sprint_plan"} |
|
|
|
|
| def _parse_action(action_dict: Dict[str, Any]) -> ProjectAction: |
| """ |
| Sanitise and validate an action dict from the LLM into a ProjectAction. |
| |
| Handles all common LLM output mistakes to eliminate 422 errors: |
| - Unknown action_type β replaced with "skip" |
| - "null" string β None |
| - sprint_plan without task_ids β replaced with "skip" |
| - assign/reassign without task_id or dev_id β replaced with "skip" |
| - Extra unknown keys are ignored by Pydantic |
| """ |
| |
| d: Dict[str, Any] = dict(action_dict) |
|
|
| |
| raw_type = str(d.get("action_type", "skip")).lower().strip() |
| if raw_type not in _VALID_ACTIONS: |
| raw_type = "skip" |
| d["action_type"] = raw_type |
|
|
| |
| for key in ("task_id", "dev_id", "new_priority", "task_ids", "notes"): |
| if d.get(key) in ("null", "none", "None", "Null", "", "undefined"): |
| d[key] = None |
|
|
| |
| if d.get("new_priority") is not None: |
| try: |
| d["new_priority"] = int(d["new_priority"]) |
| if d["new_priority"] not in range(1, 6): |
| d["new_priority"] = None |
| except (ValueError, TypeError): |
| d["new_priority"] = None |
|
|
| |
| atype = d["action_type"] |
| if atype == "assign" and (not d.get("task_id") or not d.get("dev_id")): |
| d["action_type"] = "skip" |
| if atype == "reassign" and (not d.get("task_id") or not d.get("dev_id")): |
| d["action_type"] = "skip" |
| if atype == "reprioritize" and (not d.get("task_id") or not d.get("new_priority")): |
| d["action_type"] = "skip" |
| if atype == "unblock" and not d.get("task_id"): |
| d["action_type"] = "skip" |
| if atype == "sprint_plan" and not d.get("task_ids"): |
| d["action_type"] = "skip" |
|
|
| return ProjectAction(**d) |
|
|
|
|
| def _fmt_error(exc: Exception) -> str: |
| """Format exception for HTTPException detail β include type and message.""" |
| return f"{type(exc).__name__}: {exc}\n{traceback.format_exc(limit=3)}" |
|
|
|
|
| |
| |
|
|
| _standalone_app = FastAPI( |
| title="AI Sprint Manager β Round 2 Project Endpoints", |
| version="2.0.0", |
| description=( |
| "Standalone R2 server for isolated testing. " |
| "In production, project_router is mounted on the shared R1 app in ui.py." |
| ), |
| ) |
| _standalone_app.include_router(project_router) |
|
|
|
|
| @_standalone_app.get("/health") |
| def standalone_health() -> Dict[str, Any]: |
| """Root health β confirms both R1-style and R2 endpoints are live.""" |
| return {"status": "ok", "mode": "standalone-r2", "r2_prefix": "/project"} |
|
|
|
|
| def main() -> None: |
| """Entry point for isolated local testing only.""" |
| uvicorn.run(_standalone_app, host="0.0.0.0", port=8001) |
|
|
|
|
| if __name__ == "__main__": |
| main() |