Files
breakpilot-lehrer/timetable-solver-service/app/repository.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

259 lines
9.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Read stammdaten + constraints from PostgreSQL and turn them into Timefold
domain objects. Used by runner.py to build a Timetable problem instance.
Ownership is enforced via created_by_user_id everywhere — the solver only
sees data belonging to the Rektor who triggered the solve.
"""
from typing import Any
import asyncpg
from .domain import Lesson, Room, SchoolClass, Subject, Teacher, Timeslot, Timetable
from .rules import (
RoomRequiresTypeRule, RoomUnavailableRule,
SubjectPreferredPeriodRule, TeacherExcludedRoomRule,
TeacherUnavailableDayRule, TeacherUnavailableWindowRule,
)
async def build_problem(pool: asyncpg.Pool, user_id: str) -> Timetable:
async with pool.acquire() as conn:
timeslots = await _load_timeslots(conn, user_id)
rooms = await _load_rooms(conn, user_id)
teachers = await _load_teachers(conn, user_id)
classes = await _load_classes(conn, user_id)
subjects = await _load_subjects(conn, user_id)
lessons = await _build_lessons(conn, user_id, classes, subjects, teachers)
rules = await _load_rules(conn, user_id)
return Timetable(
timeslots=timeslots,
rooms=rooms,
teachers=teachers,
classes=classes,
subjects=subjects,
lessons=lessons,
score=None,
**rules,
)
async def _load_timeslots(conn: asyncpg.Connection, user_id: str) -> list[Timeslot]:
rows = await conn.fetch("""
SELECT id::text, day_of_week, period_index,
to_char(start_time, 'HH24:MI') AS st,
to_char(end_time, 'HH24:MI') AS et,
is_break
FROM tt_period
WHERE created_by_user_id = $1 AND is_break = false
ORDER BY day_of_week, period_index
""", user_id)
return [
Timeslot(id=r["id"], day_of_week=r["day_of_week"], period_index=r["period_index"],
start_time=r["st"], end_time=r["et"])
for r in rows
]
async def _load_rooms(conn: asyncpg.Connection, user_id: str) -> list[Room]:
rows = await conn.fetch("""
SELECT id::text, name, COALESCE(room_type, '') AS rt
FROM tt_room WHERE created_by_user_id = $1 ORDER BY name
""", user_id)
return [Room(id=r["id"], name=r["name"], room_type=r["rt"]) for r in rows]
async def _load_teachers(conn: asyncpg.Connection, user_id: str) -> list[Teacher]:
rows = await conn.fetch("""
SELECT id::text, first_name, last_name, short_code
FROM tt_teacher WHERE created_by_user_id = $1 ORDER BY last_name, first_name
""", user_id)
return [Teacher(id=r["id"], first_name=r["first_name"], last_name=r["last_name"], short_code=r["short_code"]) for r in rows]
async def _load_classes(conn: asyncpg.Connection, user_id: str) -> list[SchoolClass]:
rows = await conn.fetch("""
SELECT id::text, name, grade_level
FROM tt_class WHERE created_by_user_id = $1 ORDER BY grade_level, name
""", user_id)
return [SchoolClass(id=r["id"], name=r["name"], grade_level=r["grade_level"]) for r in rows]
async def _load_subjects(conn: asyncpg.Connection, user_id: str) -> list[Subject]:
rows = await conn.fetch("""
SELECT id::text, name, short_code, COALESCE(required_room_type, '') AS rt
FROM tt_subject WHERE created_by_user_id = $1 ORDER BY name
""", user_id)
return [Subject(id=r["id"], name=r["name"], short_code=r["short_code"], required_room_type=r["rt"]) for r in rows]
async def _build_lessons(
conn: asyncpg.Connection,
user_id: str,
classes: list[SchoolClass],
subjects: list[Subject],
teachers: list[Teacher],
) -> list[Lesson]:
"""Materialise curriculum × assignment into Lesson instances.
For each (class, subject) row in tt_curriculum with weekly_hours=N, we
create N Lesson rows. The teacher is the one assigned in tt_assignment
for the same (class, subject) — there must be exactly one, else the
lesson can't be scheduled and is skipped (the UI surfaces this gap).
"""
rows = await conn.fetch("""
SELECT cu.class_id::text, cu.subject_id::text, cu.weekly_hours,
a.teacher_id::text
FROM tt_curriculum cu
JOIN tt_class cl ON cu.class_id = cl.id
LEFT JOIN tt_assignment a
ON a.class_id = cu.class_id AND a.subject_id = cu.subject_id
WHERE cl.created_by_user_id = $1
""", user_id)
class_by_id = {c.id: c for c in classes}
subject_by_id = {s.id: s for s in subjects}
teacher_by_id = {t.id: t for t in teachers}
lessons: list[Lesson] = []
counter = 0
for r in rows:
cls = class_by_id.get(r["class_id"])
sub = subject_by_id.get(r["subject_id"])
tch = teacher_by_id.get(r["teacher_id"]) if r["teacher_id"] else None
if cls is None or sub is None or tch is None:
# Missing assignment — solver can't schedule without a teacher.
continue
for _ in range(int(r["weekly_hours"])):
lessons.append(Lesson(
id=f"L{counter}-{cls.id[:6]}-{sub.id[:6]}",
school_class=cls,
subject=sub,
teacher=tch,
))
counter += 1
return lessons
async def _load_rules(conn: asyncpg.Connection, user_id: str) -> dict[str, list[Any]]:
"""Pull the subset of constraint tables the constraint provider uses."""
rules: dict[str, list[Any]] = {}
rows = await conn.fetch("""
SELECT teacher_id::text, day_of_week, is_hard, weight
FROM tt_constraint_teacher_unavailable_day
WHERE created_by_user_id = $1 AND active = true
""", user_id)
rules["teacher_unavailable_days"] = [TeacherUnavailableDayRule(**dict(r)) for r in rows]
rows = await conn.fetch("""
SELECT teacher_id::text, day_of_week,
to_char(start_time, 'HH24:MI') AS start_time,
to_char(end_time, 'HH24:MI') AS end_time,
is_hard, weight
FROM tt_constraint_teacher_unavailable_window
WHERE created_by_user_id = $1 AND active = true
""", user_id)
rules["teacher_unavailable_windows"] = [TeacherUnavailableWindowRule(**dict(r)) for r in rows]
rows = await conn.fetch("""
SELECT teacher_id::text, room_id::text, is_hard, weight
FROM tt_constraint_teacher_excluded_room
WHERE created_by_user_id = $1 AND active = true
""", user_id)
rules["teacher_excluded_rooms"] = [TeacherExcludedRoomRule(**dict(r)) for r in rows]
rows = await conn.fetch("""
SELECT room_id::text, day_of_week, period_index, is_hard, weight
FROM tt_constraint_room_unavailable
WHERE created_by_user_id = $1 AND active = true
""", user_id)
rules["room_unavailables"] = [RoomUnavailableRule(**dict(r)) for r in rows]
rows = await conn.fetch("""
SELECT subject_id::text, period_from, period_to, is_hard, weight
FROM tt_constraint_subject_preferred_period
WHERE created_by_user_id = $1 AND active = true
""", user_id)
rules["subject_preferred_periods"] = [SubjectPreferredPeriodRule(**dict(r)) for r in rows]
rows = await conn.fetch("""
SELECT subject_id::text, room_type, is_hard, weight
FROM tt_constraint_room_requires_type
WHERE created_by_user_id = $1 AND active = true
""", user_id)
rules["room_requires_types"] = [RoomRequiresTypeRule(**dict(r)) for r in rows]
return rules
async def persist_solution(
pool: asyncpg.Pool,
solution_id: str,
timetable: Timetable,
hard_score: int,
soft_score: int,
) -> None:
"""Write the solver result back to tt_solution + tt_lesson."""
async with pool.acquire() as conn:
async with conn.transaction():
await conn.execute("""
UPDATE tt_solution
SET status = 'completed',
hard_score = $2,
soft_score = $3,
finished_at = NOW()
WHERE id = $1
""", solution_id, hard_score, soft_score)
# Clear any prior lesson rows (re-solves overwrite).
await conn.execute("DELETE FROM tt_lesson WHERE solution_id = $1", solution_id)
for lesson in timetable.lessons:
if lesson.timeslot is None:
continue
await conn.execute("""
INSERT INTO tt_lesson
(solution_id, class_id, subject_id, teacher_id, room_id,
day_of_week, period_index, pinned)
VALUES ($1, $2::uuid, $3::uuid, $4::uuid, $5::uuid, $6, $7, false)
""",
solution_id,
lesson.school_class.id,
lesson.subject.id,
lesson.teacher.id,
lesson.room.id if lesson.room else None,
lesson.timeslot.day_of_week,
lesson.timeslot.period_index,
)
async def mark_failed(pool: asyncpg.Pool, solution_id: str, error_message: str) -> None:
async with pool.acquire() as conn:
await conn.execute("""
UPDATE tt_solution
SET status = 'failed', error_message = $2, finished_at = NOW()
WHERE id = $1
""", solution_id, error_message)
async def mark_running(pool: asyncpg.Pool, solution_id: str) -> None:
async with pool.acquire() as conn:
await conn.execute("""
UPDATE tt_solution SET status = 'running', started_at = NOW()
WHERE id = $1
""", solution_id)
async def mark_infeasible(pool: asyncpg.Pool, solution_id: str, hard_score: int, soft_score: int) -> None:
async with pool.acquire() as conn:
await conn.execute("""
UPDATE tt_solution
SET status = 'infeasible',
hard_score = $2,
soft_score = $3,
finished_at = NOW()
WHERE id = $1
""", solution_id, hard_score, soft_score)