Files
breakpilot-lehrer/timetable-solver-service/app/runner.py
T
Benjamin Admin bf5ea860cc
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-school (push) Successful in 37s
CI / test-go-edu-search (push) Successful in 29s
CI / test-python-klausur (push) Failing after 3m56s
CI / test-python-agent-core (push) Successful in 19s
CI / test-nodejs-website (push) Successful in 23s
Phase 7: pinning, plan versions, solver budget + UX polish
Backend (school-service):
  - tt_solution gains parent_solution_id (self-FK, ON DELETE SET NULL)
    and seconds_limit columns via ALTER TABLE IF NOT EXISTS.
  - CreateTimetableSolutionRequest accepts optional parent_solution_id
    and seconds_limit (5-600s) with binding validation.
  - CreateSolution checks parent ownership before INSERT so users can't
    fork another tenant's plan.
  - New PUT /timetable/lessons/:id/pin endpoint; ownership enforced via
    the lesson's solution.created_by_user_id JOIN.

Solver:
  - Lesson.pinned now carries @PlanningPin so Timefold leaves locked
    cells untouched during the search.
  - build_problem() takes optional parent_solution_id; if set, copies
    pinned (class_id, subject_id, day, period, room) tuples onto fresh
    Lesson objects via greedy first-fit matching. Surplus pinned rows
    from curriculum changes are silently dropped.
  - _build_factory(seconds) replaces the module-level factory so each
    job honours its tt_solution.seconds_limit override.
  - persist_solution writes lesson.pinned back so subsequent re-solves
    inherit it.

Frontend (studio-v2):
  - SolutionList grows three knobs in the create-form: Basieren auf
    (parent dropdown, only completed solutions, disabled when none),
    Sekunden-Limit (5-600), and the existing Name.
  - PlanView cells get a pin/unpin button with optimistic update and
    rollback on error. Pinned cells gain an amber ring.
  - types.ts + api.ts mirror the new fields; lessonsApi.pin(id, bool).
  - HelpPanel: collapsible 6-step Bedienungsanleitung explaining the
    setup-to-plan workflow. Anchored at the top of /stundenplan above
    the dev token banner.
  - page.tsx switches to the same gradient + animated-blob background
    used on /korrektur so /stundenplan stops looking like a slate-900
    test page.
  - JWT dev banner gets a step-by-step explanation of how to grab the
    token from DevTools and a non-blocking success indicator (no more
    alert()).

Tests:
  - school-service: 6 new validator cases for parent_solution_id +
    seconds_limit boundaries. 73 subtests total, all green.
  - studio-v2: mockSchoolApi adds PUT /lessons/:id/pin route. 5 new
    Playwright tests across two suites (parent-selector visibility +
    options, seconds-limit input, pin button render, pin-icon flip).
    Existing tests adjusted to the new help panel + JWT banner wording.

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

114 lines
4.2 KiB
Python

"""Solver job runner. One async entry point per solve.
Lifecycle:
1. mark_running -> tt_solution.status = 'running'
2. build_problem -> Timetable from DB
3. SolverFactory.buildSolver() -> Timefold solver
4. solver.solve(problem) -> completed Timetable
5. persist_solution or mark_infeasible based on hard_score
Errors at any step → mark_failed.
Long solves are CPU-bound. We run the solver in an executor so the FastAPI
event loop stays responsive for other requests.
"""
import asyncio
import logging
import traceback
from concurrent.futures import ThreadPoolExecutor
from timefold.solver import SolverFactory
from timefold.solver.config import (
SolverConfig,
ScoreDirectorFactoryConfig,
TerminationConfig,
Duration,
)
from .config import settings
from .constraints import define_constraints
from .db import get_pool
from .domain import Lesson, Timetable
from .repository import build_problem, mark_failed, mark_infeasible, mark_running, persist_solution
logger = logging.getLogger(__name__)
_executor = ThreadPoolExecutor(max_workers=2)
def _build_factory(seconds: int) -> SolverFactory:
"""One factory per solve so we can honour per-job timeout overrides.
Cheap to construct — the Java side caches what it can across builds."""
return SolverFactory.create(
SolverConfig(
solution_class=Timetable,
entity_class_list=[Lesson],
score_director_factory_config=ScoreDirectorFactoryConfig(
constraint_provider_function=define_constraints,
),
termination_config=TerminationConfig(
spent_limit=Duration(seconds=seconds),
),
)
)
def _solve_sync(problem: Timetable, seconds: int) -> Timetable:
"""Blocking solver call; runs in a worker thread."""
solver = _build_factory(seconds).build_solver()
return solver.solve(problem)
async def _fetch_solve_options(pool, solution_id: str) -> tuple[str | None, int]:
"""Read parent_solution_id + seconds_limit from tt_solution."""
async with pool.acquire() as conn:
row = await conn.fetchrow("""
SELECT parent_solution_id::text, seconds_limit
FROM tt_solution WHERE id = $1
""", solution_id)
parent = row["parent_solution_id"] if row else None
limit = row["seconds_limit"] if row and row["seconds_limit"] else settings.solver_seconds_limit
return parent, int(limit)
async def run_solve(solution_id: str, user_id: str) -> None:
"""Top-level async entry. Caller fires-and-forgets via BackgroundTasks."""
pool = await get_pool()
try:
parent_id, seconds = await _fetch_solve_options(pool, solution_id)
await mark_running(pool, solution_id)
problem = await build_problem(pool, user_id, parent_solution_id=parent_id)
if not problem.lessons:
await mark_failed(pool, solution_id,
"Keine Lessons — pruefe Stundentafel + Lehrauftraege.")
return
if not problem.timeslots:
await mark_failed(pool, solution_id,
"Kein Zeitraster definiert.")
return
if not problem.rooms:
await mark_failed(pool, solution_id,
"Keine Raeume definiert.")
return
loop = asyncio.get_running_loop()
solved: Timetable = await loop.run_in_executor(_executor, _solve_sync, problem, seconds)
score = solved.score
hard = score.hard_score() if score else 0
soft = score.soft_score() if score else 0
if hard < 0:
await mark_infeasible(pool, solution_id, hard, soft)
logger.info("Solution %s infeasible: hard=%d soft=%d", solution_id, hard, soft)
else:
await persist_solution(pool, solution_id, solved, hard, soft)
logger.info("Solution %s completed: hard=%d soft=%d", solution_id, hard, soft)
except Exception as exc:
logger.exception("Solver failed for %s", solution_id)
try:
await mark_failed(pool, solution_id, f"{exc.__class__.__name__}: {exc}\n{traceback.format_exc()[:1000]}")
except Exception:
logger.exception("Failed to even mark solution as failed")