Spaces:
Sleeping
Sleeping
| """ | |
| FlexTime β FastAPI Application | |
| OpenEnv-compliant workforce scheduling environment. | |
| All required endpoints: /reset /step /state /tasks /grader /baseline | |
| Plus: /health /info and interactive UI at / | |
| """ | |
| from __future__ import annotations | |
| import os | |
| from datetime import datetime, timezone | |
| from pathlib import Path | |
| from fastapi import FastAPI, HTTPException, Query | |
| from fastapi.middleware.cors import CORSMiddleware | |
| from fastapi.responses import HTMLResponse | |
| from fastapi.staticfiles import StaticFiles | |
| from server.engine import FlexTimeEnv, TASK_CONFIGS | |
| from server.models import ( | |
| Action, Observation, ResetRequest, StepResult, | |
| AddEmployeeRequest, EditEmployeeRequest, AddShiftRequest, LeaveRequest | |
| ) | |
| # ββ App βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| app = FastAPI( | |
| title="FlexTime", | |
| description=( | |
| "π **FlexTime** β Real-world AI Workforce Scheduling OpenEnv Environment\n\n" | |
| "Agents learn to assign employees to shifts while satisfying hard constraints " | |
| "(skill matching, availability, max hours) and soft objectives " | |
| "(fairness, preferences, demand coverage).\n\n" | |
| "**3 Tasks:** Easy β Medium β Hard with full programmatic graders (0.0β1.0).\n\n" | |
| "Compliant with the [OpenEnv specification](https://github.com/openenv/openenv)." | |
| ), | |
| version="1.0.0", | |
| docs_url="/docs", | |
| redoc_url="/redoc", | |
| ) | |
| app.add_middleware( | |
| CORSMiddleware, | |
| allow_origins=["*"], | |
| allow_methods=["*"], | |
| allow_headers=["*"], | |
| ) | |
| # Resolve static dir relative to this file so it works in Docker too | |
| _STATIC_DIR = Path(__file__).parent / "static" | |
| # Serve static assets (style.css, app.js) at /static/* | |
| app.mount("/static", StaticFiles(directory=_STATIC_DIR), name="static") | |
| # Global env instance (stateful single-session for HF Spaces) | |
| _env = FlexTimeEnv() | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # ROOT β Interactive UI | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async def root(): | |
| """Serve the FlexTime interactive demo UI.""" | |
| html_path = _STATIC_DIR / "index.html" | |
| return HTMLResponse(content=html_path.read_text(encoding="utf-8")) | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # HEALTH β Required for HF Spaces ping (must return 200) | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async def health(): | |
| """Health check β HF Spaces pings this. Must return 200.""" | |
| return {"status": "ok", "service": "FlexTime", "version": "1.0.0"} | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # OPENENV CORE ENDPOINTS | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async def reset(body: ResetRequest = None): | |
| """POST /reset β OpenEnv spec required endpoint.""" | |
| try: | |
| task_id = body.task_id if body and body.task_id else "task_medium" | |
| seed = body.seed if body else None | |
| obs = _env.reset(task_id=task_id, seed=seed) | |
| return obs | |
| except ValueError as e: | |
| raise HTTPException(status_code=400, detail=str(e)) | |
| async def step(action: Action): | |
| """POST /step β OpenEnv spec required endpoint.""" | |
| try: | |
| return _env.step(action) | |
| except RuntimeError as e: | |
| raise HTTPException(status_code=400, detail=str(e)) | |
| async def state(): | |
| """GET /state β OpenEnv spec required endpoint.""" | |
| try: | |
| return _env.state() | |
| except RuntimeError as e: | |
| raise HTTPException(status_code=400, detail=str(e)) | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # REQUIRED ADDITIONAL ENDPOINTS (per hackathon spec) | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async def tasks(): | |
| """GET /tasks β Returns task list + action schema (hackathon required).""" | |
| action_schema = Action.model_json_schema() | |
| task_list = [] | |
| for task_id, cfg in TASK_CONFIGS.items(): | |
| task_list.append({ | |
| "id": task_id, | |
| "name": cfg["name"], | |
| "difficulty": cfg["difficulty"], | |
| "description": cfg["description"], | |
| "max_steps": cfg["max_steps"], | |
| "target_score": cfg["target_score"], | |
| "n_employees": cfg["n_employees"], | |
| "n_shifts": cfg["n_shifts"], | |
| "action_schema": action_schema, # full JSON schema of Action model | |
| }) | |
| return { | |
| "tasks": task_list, | |
| "total": len(task_list), | |
| "action_schema": action_schema, # also at top level for convenience | |
| } | |
| async def grader(): | |
| """GET /grader β Returns grader score (hackathon required).""" | |
| try: | |
| return _env.grade() | |
| except RuntimeError as e: | |
| raise HTTPException(status_code=400, detail=str(e)) | |
| async def baseline( | |
| use_llm: bool = Query(False, description="Use OpenAI LLM agent (requires OPENAI_API_KEY)"), | |
| ): | |
| """POST /baseline β Runs baseline agent, returns scores (hackathon required).""" | |
| try: | |
| from scripts.baseline import run_baseline | |
| results = await run_baseline(use_llm=use_llm) | |
| return results | |
| except Exception as e: | |
| raise HTTPException(status_code=500, detail=f"Baseline error: {str(e)}") | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # SAAS / DYNAMIC UI ENDPOINTS | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async def add_employee(body: AddEmployeeRequest): | |
| """Dynamically add an employee. Mid-episode structural change.""" | |
| try: | |
| return _env.add_employee(body.model_dump()) | |
| except RuntimeError as e: | |
| raise HTTPException(status_code=400, detail=str(e)) | |
| async def edit_employee(body: EditEmployeeRequest): | |
| """Dynamically edit an employee.""" | |
| try: | |
| return _env.edit_employee(body.employee_id, body.model_dump(exclude_unset=True)) | |
| except (RuntimeError, ValueError) as e: | |
| raise HTTPException(status_code=400, detail=str(e)) | |
| async def add_shift(body: AddShiftRequest): | |
| """Dynamically add a shift. Mid-episode structural change.""" | |
| try: | |
| return _env.add_shift(body.model_dump()) | |
| except RuntimeError as e: | |
| raise HTTPException(status_code=400, detail=str(e)) | |
| async def leave_request(body: LeaveRequest): | |
| """Dynamically take an employee offline and drop their shifts.""" | |
| try: | |
| return _env.apply_leave(body.employee_id, body.from_day, body.to_day) | |
| except Exception as e: | |
| raise HTTPException(status_code=400, detail=str(e)) | |
| async def apply_scenario(scenario_type: str): | |
| """Mutate environment based on a scenario.""" | |
| if scenario_type not in ["shortage", "surge", "holiday"]: | |
| raise HTTPException(status_code=400, detail="Invalid scenario") | |
| try: | |
| return _env.apply_scenario(scenario_type) | |
| except Exception as e: | |
| raise HTTPException(status_code=400, detail=str(e)) | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # INFO | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async def info(): | |
| """Returns FlexTime metadata and full endpoint map.""" | |
| return { | |
| "name": "FlexTime", | |
| "version": "1.0.0", | |
| "description": "Real-world AI workforce scheduling OpenEnv environment", | |
| "openenv_compliant": True, | |
| "real_world_domain": "Workforce Scheduling / Operations Research", | |
| "tasks": list(TASK_CONFIGS.keys()), | |
| "action_types": ["assign", "remove", "swap", "noop"], | |
| "reward_range": [-1.0, 1.0], | |
| "reward_type": "dense_shaped", | |
| "constraints": { | |
| "hard": ["no_overlap", "skill_match", "max_hours", "availability"], | |
| "soft": ["fair_distribution", "preferences", "min_rest_gap", "consecutive_days"], | |
| }, | |
| "endpoints": { | |
| "reset": "POST /reset", | |
| "step": "POST /step", | |
| "state": "GET /state", | |
| "tasks": "GET /tasks", | |
| "grader": "GET /grader", | |
| "baseline": "POST /baseline", | |
| "health": "GET /health", | |
| "docs": "GET /docs", | |
| }, | |
| "baseline_scores": { | |
| "task_easy": {"agent": "GreedyBaseline", "score": 0.95, "seed": 42}, | |
| "task_medium": {"agent": "GreedyBaseline", "score": 0.72, "seed": 42}, | |
| "task_hard": {"agent": "GreedyBaseline", "score": 0.48, "seed": 42}, | |
| }, | |
| } | |
| def main(): | |
| import uvicorn | |
| uvicorn.run("server.app:app", host="0.0.0.0", port=7860, reload=False) | |
| if __name__ == "__main__": | |
| main() | |