"""Timetable solver service — FastAPI entrypoint. POST /api/v1/solve schedules a solve job (BackgroundTasks). Returns 202. GET /api/v1/jobs/{solution_id} reads back tt_solution status from DB. GET /health liveness probe for Docker. The actual solver call lives in runner.py and runs in a worker thread, so this process can accept multiple concurrent solves without blocking. """ import logging import os from fastapi import BackgroundTasks, FastAPI, HTTPException, status from pydantic import BaseModel from .config import settings from .db import close_pool, get_pool from .runner import run_solve logging.basicConfig(level=os.getenv("LOG_LEVEL", settings.log_level)) logger = logging.getLogger(__name__) app = FastAPI(title="BreakPilot Timetable Solver", version="0.1.0") class SolveRequest(BaseModel): solution_id: str created_by_user_id: str class SolveResponse(BaseModel): solution_id: str status: str message: str class JobStatus(BaseModel): solution_id: str status: str hard_score: int | None = None soft_score: int | None = None error_message: str | None = None @app.get("/health") async def health() -> dict[str, str]: return {"status": "healthy", "service": "timetable-solver"} @app.post("/api/v1/solve", response_model=SolveResponse, status_code=status.HTTP_202_ACCEPTED) async def solve(req: SolveRequest, bg: BackgroundTasks) -> SolveResponse: pool = await get_pool() async with pool.acquire() as conn: row = await conn.fetchrow( "SELECT status FROM tt_solution WHERE id = $1 AND created_by_user_id = $2", req.solution_id, req.created_by_user_id, ) if row is None: raise HTTPException(status_code=404, detail="Solution not found") if row["status"] in ("running", "completed"): return SolveResponse( solution_id=req.solution_id, status=row["status"], message="already in progress or finished", ) bg.add_task(run_solve, req.solution_id, req.created_by_user_id) logger.info("Solve queued for %s (user %s)", req.solution_id, req.created_by_user_id) return SolveResponse( solution_id=req.solution_id, status="queued", message="job accepted, poll tt_solution for progress", ) @app.get("/api/v1/jobs/{solution_id}", response_model=JobStatus) async def job_status(solution_id: str) -> JobStatus: pool = await get_pool() async with pool.acquire() as conn: row = await conn.fetchrow(""" SELECT id::text, status, hard_score, soft_score, COALESCE(error_message, '') AS err FROM tt_solution WHERE id = $1 """, solution_id) if row is None: raise HTTPException(status_code=404, detail="Solution not found") return JobStatus( solution_id=row["id"], status=row["status"], hard_score=row["hard_score"], soft_score=row["soft_score"], error_message=row["err"] or None, ) @app.on_event("shutdown") async def _on_shutdown() -> None: await close_pool()