Files
breakpilot-lehrer/timetable-solver-service/app/main.py
T
Benjamin Admin f042f2896b Phase 5: Timefold timetable-solver-service + solution persistence
school-service additions:
  - tt_solution + tt_lesson migration. tt_lesson carries three UNIQUEs
    (solution+class, solution+teacher, solution+room per slot) so the
    DB itself rejects any double-booking the solver might emit by
    mistake.
  - Solution CRUD + GET solutions/:id/lessons endpoint with joined
    class/subject/teacher/room names for display.
  - POST /timetable/solutions creates the row then fires off the
    solver-service via HTTP (5s timeout, mark failed if unreachable).
  - SOLVER_SERVICE_URL config wired through main.go/handlers.

New service timetable-solver-service:
  - Python 3.11 + FastAPI + Timefold Solver 1.21 (Apache-2.0). Dockerfile
    bundles OpenJDK 17 since Timefold for Python is a JPype bridge.
  - app/domain.py — Timefold @planning_entity Lesson with timeslot+room
    as PlanningVariables; @planning_solution Timetable holds problem
    facts (rooms/teachers/etc.) AND rule-fact collections.
  - app/rules.py — frozen dataclasses mirroring 6 of the 15 tt_
    constraint_* tables initially.
  - app/constraints.py — ConstraintProvider with 3 universal hard
    constraints (no double-booking) + 5 DB-driven constraints
    (teacher_unavailable_day/window, teacher_excluded_room,
    room_unavailable, room_requires_type) + 1 quality soft constraint
    (subject_preferred_period). Remaining 9 constraint types ready to
    plug in via the same join pattern.
  - app/repository.py — async loaders for stammdaten + rules; builds
    one Lesson per (curriculum row × weekly_hours), skipping rows
    without a tt_assignment teacher.
  - app/runner.py — runs solver in ThreadPoolExecutor so the FastAPI
    event loop stays responsive. Updates tt_solution status
    pending→running→completed|infeasible|failed.
  - app/main.py — POST /api/v1/solve (202 Accepted, background task),
    GET /api/v1/jobs/{id}, /health. School-service polls tt_solution
    directly instead of GET /jobs for the typical case.
  - docker-compose.yml adds the service on port 8095, depending on
    core-health-check.

Tests:
  - school-service: validator test for CreateTimetableSolutionRequest
    (allows empty name).
  - solver-service: tests/test_domain.py + tests/test_rules.py cover
    construction + hashability of the planning facts. Full solve flow
    deferred to Phase 8 integration with seed data.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 00:16:52 +02:00

97 lines
3.0 KiB
Python

"""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()