From f042f2896b01ce5f5a05ce32d7f3285e62d4aab2 Mon Sep 17 00:00:00 2001 From: Benjamin Admin Date: Fri, 22 May 2026 00:16:52 +0200 Subject: [PATCH] Phase 5: Timefold timetable-solver-service + solution persistence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- docker-compose.yml | 20 ++ school-service/cmd/server/main.go | 9 +- school-service/internal/config/config.go | 4 + school-service/internal/database/database.go | 3 + .../database/timetable_solution_migrations.go | 49 ++++ school-service/internal/handlers/handlers.go | 4 +- .../handlers/timetable_solution_handlers.go | 91 ++++++ .../internal/models/timetable_solutions.go | 49 ++++ .../internal/services/timetable_solutions.go | 149 ++++++++++ .../services/timetable_solutions_test.go | 27 ++ timetable-solver-service/Dockerfile | 30 ++ timetable-solver-service/app/__init__.py | 0 timetable-solver-service/app/config.py | 16 ++ timetable-solver-service/app/constraints.py | 209 ++++++++++++++ timetable-solver-service/app/db.py | 28 ++ timetable-solver-service/app/domain.py | 134 +++++++++ timetable-solver-service/app/main.py | 96 +++++++ timetable-solver-service/app/repository.py | 258 ++++++++++++++++++ timetable-solver-service/app/rules.py | 64 +++++ timetable-solver-service/app/runner.py | 94 +++++++ timetable-solver-service/pyproject.toml | 13 + timetable-solver-service/requirements.txt | 8 + timetable-solver-service/tests/__init__.py | 0 timetable-solver-service/tests/test_domain.py | 51 ++++ timetable-solver-service/tests/test_rules.py | 27 ++ 25 files changed, 1431 insertions(+), 2 deletions(-) create mode 100644 school-service/internal/database/timetable_solution_migrations.go create mode 100644 school-service/internal/handlers/timetable_solution_handlers.go create mode 100644 school-service/internal/models/timetable_solutions.go create mode 100644 school-service/internal/services/timetable_solutions.go create mode 100644 school-service/internal/services/timetable_solutions_test.go create mode 100644 timetable-solver-service/Dockerfile create mode 100644 timetable-solver-service/app/__init__.py create mode 100644 timetable-solver-service/app/config.py create mode 100644 timetable-solver-service/app/constraints.py create mode 100644 timetable-solver-service/app/db.py create mode 100644 timetable-solver-service/app/domain.py create mode 100644 timetable-solver-service/app/main.py create mode 100644 timetable-solver-service/app/repository.py create mode 100644 timetable-solver-service/app/rules.py create mode 100644 timetable-solver-service/app/runner.py create mode 100644 timetable-solver-service/pyproject.toml create mode 100644 timetable-solver-service/requirements.txt create mode 100644 timetable-solver-service/tests/__init__.py create mode 100644 timetable-solver-service/tests/test_domain.py create mode 100644 timetable-solver-service/tests/test_rules.py diff --git a/docker-compose.yml b/docker-compose.yml index d4e3507..b2796b2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -289,6 +289,26 @@ services: ENVIRONMENT: ${ENVIRONMENT:-development} ALLOWED_ORIGINS: "*" LLM_GATEWAY_URL: http://backend-lehrer:8001/llm + SOLVER_SERVICE_URL: http://timetable-solver-service:8095 + depends_on: + core-health-check: + condition: service_completed_successfully + restart: unless-stopped + networks: + - breakpilot-network + + timetable-solver-service: + build: + context: ./timetable-solver-service + dockerfile: Dockerfile + container_name: bp-lehrer-timetable-solver + platform: linux/arm64 + ports: + - "8095:8095" + environment: + DATABASE_URL: postgresql://${POSTGRES_USER:-breakpilot}:${POSTGRES_PASSWORD:-breakpilot123}@bp-core-postgres:5432/${POSTGRES_DB:-breakpilot_db} + SOLVER_SECONDS_LIMIT: ${SOLVER_SECONDS_LIMIT:-60} + LOG_LEVEL: ${LOG_LEVEL:-INFO} depends_on: core-health-check: condition: service_completed_successfully diff --git a/school-service/cmd/server/main.go b/school-service/cmd/server/main.go index 229934f..a7c29b1 100644 --- a/school-service/cmd/server/main.go +++ b/school-service/cmd/server/main.go @@ -35,7 +35,7 @@ func main() { } // Create handler - handler := handlers.NewHandler(db.Pool, cfg.LLMGatewayURL) + handler := handlers.NewHandler(db.Pool, cfg.LLMGatewayURL, cfg.SolverServiceURL) // Create router router := gin.New() @@ -218,6 +218,13 @@ func main() { api.GET("/timetable/constraints/room/unavailable", handler.ListRoomUnavailable) api.POST("/timetable/constraints/room/unavailable", handler.CreateRoomUnavailable) api.DELETE("/timetable/constraints/room/unavailable/:id", handler.DeleteRoomUnavailable) + + // Timetable Solver — Solutions + api.GET("/timetable/solutions", handler.ListTimetableSolutions) + api.POST("/timetable/solutions", handler.CreateTimetableSolution) + api.GET("/timetable/solutions/:id", handler.GetTimetableSolution) + api.DELETE("/timetable/solutions/:id", handler.DeleteTimetableSolution) + api.GET("/timetable/solutions/:id/lessons", handler.ListTimetableLessons) } // Start server diff --git a/school-service/internal/config/config.go b/school-service/internal/config/config.go index 1c974b1..338cbfa 100644 --- a/school-service/internal/config/config.go +++ b/school-service/internal/config/config.go @@ -28,6 +28,9 @@ type Config struct { // LLM Gateway (for AI features) LLMGatewayURL string + + // Timetable solver service (Python/FastAPI, port 8095) + SolverServiceURL string } // Load loads configuration from environment variables @@ -43,6 +46,7 @@ func Load() (*Config, error) { RateLimitRequests: getEnvInt("RATE_LIMIT_REQUESTS", 100), RateLimitWindow: getEnvInt("RATE_LIMIT_WINDOW", 60), LLMGatewayURL: getEnv("LLM_GATEWAY_URL", "http://backend:8000/llm"), + SolverServiceURL: getEnv("SOLVER_SERVICE_URL", "http://timetable-solver-service:8095"), } // Parse allowed origins diff --git a/school-service/internal/database/database.go b/school-service/internal/database/database.go index 4be2b82..8fecc33 100644 --- a/school-service/internal/database/database.go +++ b/school-service/internal/database/database.go @@ -218,6 +218,9 @@ func Migrate(db *DB) error { // Append timetable constraint migrations (see timetable_constraints_migrations.go) migrations = append(migrations, TimetableConstraintMigrations()...) + // Append timetable solution migrations (see timetable_solution_migrations.go) + migrations = append(migrations, TimetableSolutionMigrations()...) + for _, migration := range migrations { _, err := db.Pool.Exec(ctx, migration) if err != nil { diff --git a/school-service/internal/database/timetable_solution_migrations.go b/school-service/internal/database/timetable_solution_migrations.go new file mode 100644 index 0000000..dfad5f8 --- /dev/null +++ b/school-service/internal/database/timetable_solution_migrations.go @@ -0,0 +1,49 @@ +package database + +// TimetableSolutionMigrations creates tt_solution + tt_lesson for the solver +// pipeline. One run of the solver produces exactly one tt_solution row plus +// many tt_lesson rows (one per scheduled class-subject hour). +// +// Status flow: +// pending → running → completed | failed | infeasible +// +// hard_score / soft_score come straight from Timefold's HardSoftScore. Lower +// (more negative) hard_score means more hard-constraint violations; the UI +// only ever offers solutions with hard_score == 0 as "valid". +func TimetableSolutionMigrations() []string { + return []string{ + `CREATE TABLE IF NOT EXISTS tt_solution ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + created_by_user_id UUID NOT NULL, + name VARCHAR(120), + status VARCHAR(20) NOT NULL DEFAULT 'pending', + hard_score INT, + soft_score INT, + error_message TEXT, + started_at TIMESTAMPTZ, + finished_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT NOW() + )`, + + `CREATE TABLE IF NOT EXISTS tt_lesson ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + solution_id UUID NOT NULL REFERENCES tt_solution(id) ON DELETE CASCADE, + class_id UUID NOT NULL REFERENCES tt_class(id) ON DELETE CASCADE, + subject_id UUID NOT NULL REFERENCES tt_subject(id) ON DELETE CASCADE, + teacher_id UUID NOT NULL REFERENCES tt_teacher(id) ON DELETE CASCADE, + room_id UUID REFERENCES tt_room(id) ON DELETE SET NULL, + day_of_week INT NOT NULL CHECK (day_of_week BETWEEN 1 AND 7), + period_index INT NOT NULL CHECK (period_index BETWEEN 1 AND 12), + pinned BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(solution_id, class_id, day_of_week, period_index), + UNIQUE(solution_id, teacher_id, day_of_week, period_index), + UNIQUE(solution_id, room_id, day_of_week, period_index) + )`, + + `CREATE INDEX IF NOT EXISTS idx_tt_solution_user ON tt_solution(created_by_user_id)`, + `CREATE INDEX IF NOT EXISTS idx_tt_lesson_solution ON tt_lesson(solution_id)`, + `CREATE INDEX IF NOT EXISTS idx_tt_lesson_class ON tt_lesson(class_id)`, + `CREATE INDEX IF NOT EXISTS idx_tt_lesson_teacher ON tt_lesson(teacher_id)`, + } +} diff --git a/school-service/internal/handlers/handlers.go b/school-service/internal/handlers/handlers.go index 368c0bd..2aaefd9 100644 --- a/school-service/internal/handlers/handlers.go +++ b/school-service/internal/handlers/handlers.go @@ -17,10 +17,11 @@ type Handler struct { certificateService *services.CertificateService aiService *services.AIService timetableService *services.TimetableService + solverServiceURL string } // NewHandler creates a new Handler with all services -func NewHandler(db *pgxpool.Pool, llmGatewayURL string) *Handler { +func NewHandler(db *pgxpool.Pool, llmGatewayURL, solverServiceURL string) *Handler { classService := services.NewClassService(db) examService := services.NewExamService(db) gradeService := services.NewGradeService(db) @@ -37,6 +38,7 @@ func NewHandler(db *pgxpool.Pool, llmGatewayURL string) *Handler { certificateService: certificateService, aiService: aiService, timetableService: timetableService, + solverServiceURL: solverServiceURL, } } diff --git a/school-service/internal/handlers/timetable_solution_handlers.go b/school-service/internal/handlers/timetable_solution_handlers.go new file mode 100644 index 0000000..2eac1f1 --- /dev/null +++ b/school-service/internal/handlers/timetable_solution_handlers.go @@ -0,0 +1,91 @@ +package handlers + +import ( + "net/http" + + "github.com/breakpilot/school-service/internal/models" + "github.com/gin-gonic/gin" +) + +// ---------- Solutions ---------- + +func (h *Handler) CreateTimetableSolution(c *gin.Context) { + uid := getUserID(c) + if uid == "" { + respondError(c, http.StatusUnauthorized, "User not authenticated") + return + } + var req models.CreateTimetableSolutionRequest + if err := c.ShouldBindJSON(&req); err != nil { + respondError(c, http.StatusBadRequest, "Invalid request: "+err.Error()) + return + } + sol, err := h.timetableService.CreateSolution(c.Request.Context(), uid, &req) + if err != nil { + respondError(c, http.StatusInternalServerError, "Failed to create solution: "+err.Error()) + return + } + // Fire-and-forget the solver invocation; the row is persisted regardless. + if err := h.timetableService.TriggerSolve(c.Request.Context(), h.solverServiceURL, sol.ID.String(), uid); err != nil { + // Don't fail the request — the solution row already shows status=failed. + // The client will see error_message via GET /solutions/:id. + respondCreated(c, sol) + return + } + respondCreated(c, sol) +} + +func (h *Handler) ListTimetableSolutions(c *gin.Context) { + uid := getUserID(c) + if uid == "" { + respondError(c, http.StatusUnauthorized, "User not authenticated") + return + } + out, err := h.timetableService.ListSolutions(c.Request.Context(), uid) + if err != nil { + respondError(c, http.StatusInternalServerError, "Failed to list solutions: "+err.Error()) + return + } + respondSuccess(c, out) +} + +func (h *Handler) GetTimetableSolution(c *gin.Context) { + uid := getUserID(c) + if uid == "" { + respondError(c, http.StatusUnauthorized, "User not authenticated") + return + } + sol, err := h.timetableService.GetSolution(c.Request.Context(), c.Param("id"), uid) + if err != nil { + respondError(c, http.StatusNotFound, "Solution not found") + return + } + respondSuccess(c, sol) +} + +func (h *Handler) ListTimetableLessons(c *gin.Context) { + uid := getUserID(c) + if uid == "" { + respondError(c, http.StatusUnauthorized, "User not authenticated") + return + } + out, err := h.timetableService.ListLessons(c.Request.Context(), c.Param("id"), uid) + if err != nil { + respondError(c, http.StatusInternalServerError, "Failed to list lessons: "+err.Error()) + return + } + respondSuccess(c, out) +} + +func (h *Handler) DeleteTimetableSolution(c *gin.Context) { + uid := getUserID(c) + if uid == "" { + respondError(c, http.StatusUnauthorized, "User not authenticated") + return + } + if err := h.timetableService.DeleteSolution(c.Request.Context(), c.Param("id"), uid); err != nil { + respondError(c, http.StatusInternalServerError, "Failed to delete solution: "+err.Error()) + return + } + c.JSON(http.StatusOK, gin.H{"message": "Solution deleted"}) +} diff --git a/school-service/internal/models/timetable_solutions.go b/school-service/internal/models/timetable_solutions.go new file mode 100644 index 0000000..278febb --- /dev/null +++ b/school-service/internal/models/timetable_solutions.go @@ -0,0 +1,49 @@ +package models + +import ( + "time" + + "github.com/google/uuid" +) + +// TimetableSolution is one run of the solver — exactly one row per solve. +// Lessons attached via tt_lesson.solution_id. +type TimetableSolution struct { + ID uuid.UUID `json:"id" db:"id"` + CreatedByUserID uuid.UUID `json:"created_by_user_id" db:"created_by_user_id"` + Name string `json:"name,omitempty" db:"name"` + Status string `json:"status" db:"status"` + HardScore *int `json:"hard_score,omitempty" db:"hard_score"` + SoftScore *int `json:"soft_score,omitempty" db:"soft_score"` + ErrorMessage string `json:"error_message,omitempty" db:"error_message"` + StartedAt *time.Time `json:"started_at,omitempty" db:"started_at"` + FinishedAt *time.Time `json:"finished_at,omitempty" db:"finished_at"` + CreatedAt time.Time `json:"created_at" db:"created_at"` +} + +// TimetableLesson is one scheduled class-period in a solution. +type TimetableLesson struct { + ID uuid.UUID `json:"id" db:"id"` + SolutionID uuid.UUID `json:"solution_id" db:"solution_id"` + ClassID uuid.UUID `json:"class_id" db:"class_id"` + SubjectID uuid.UUID `json:"subject_id" db:"subject_id"` + TeacherID uuid.UUID `json:"teacher_id" db:"teacher_id"` + RoomID *uuid.UUID `json:"room_id,omitempty" db:"room_id"` + DayOfWeek int `json:"day_of_week" db:"day_of_week"` + PeriodIndex int `json:"period_index" db:"period_index"` + Pinned bool `json:"pinned" db:"pinned"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + + // Joined fields for display + ClassName string `json:"class_name,omitempty"` + SubjectName string `json:"subject_name,omitempty"` + TeacherName string `json:"teacher_name,omitempty"` + RoomName string `json:"room_name,omitempty"` +} + +// CreateTimetableSolutionRequest kicks off a solve. The solver-service is +// invoked async — this endpoint only registers the solution row and queues +// the job. +type CreateTimetableSolutionRequest struct { + Name string `json:"name"` +} diff --git a/school-service/internal/services/timetable_solutions.go b/school-service/internal/services/timetable_solutions.go new file mode 100644 index 0000000..75d8dc5 --- /dev/null +++ b/school-service/internal/services/timetable_solutions.go @@ -0,0 +1,149 @@ +package services + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "time" + + "github.com/breakpilot/school-service/internal/models" +) + +// TimetableSolutionService persists solver runs and forwards solve requests +// to the timetable-solver-service. The solver writes lesson rows back to the +// same DB once it finishes, so listing solutions = simple SELECTs here. + +func (s *TimetableService) CreateSolution(ctx context.Context, userID string, req *models.CreateTimetableSolutionRequest) (*models.TimetableSolution, error) { + var sol models.TimetableSolution + err := s.db.QueryRow(ctx, ` + INSERT INTO tt_solution (created_by_user_id, name, status) + VALUES ($1, $2, 'pending') + RETURNING id, created_by_user_id, COALESCE(name, ''), status, hard_score, soft_score, + COALESCE(error_message, ''), started_at, finished_at, created_at + `, userID, req.Name).Scan( + &sol.ID, &sol.CreatedByUserID, &sol.Name, &sol.Status, + &sol.HardScore, &sol.SoftScore, &sol.ErrorMessage, + &sol.StartedAt, &sol.FinishedAt, &sol.CreatedAt, + ) + return &sol, err +} + +func (s *TimetableService) ListSolutions(ctx context.Context, userID string) ([]models.TimetableSolution, error) { + rows, err := s.db.Query(ctx, ` + SELECT id, created_by_user_id, COALESCE(name, ''), status, hard_score, soft_score, + COALESCE(error_message, ''), started_at, finished_at, created_at + FROM tt_solution WHERE created_by_user_id = $1 ORDER BY created_at DESC + `, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []models.TimetableSolution + for rows.Next() { + var sol models.TimetableSolution + if err := rows.Scan(&sol.ID, &sol.CreatedByUserID, &sol.Name, &sol.Status, + &sol.HardScore, &sol.SoftScore, &sol.ErrorMessage, + &sol.StartedAt, &sol.FinishedAt, &sol.CreatedAt); err != nil { + return nil, err + } + out = append(out, sol) + } + return out, nil +} + +func (s *TimetableService) GetSolution(ctx context.Context, id, userID string) (*models.TimetableSolution, error) { + var sol models.TimetableSolution + err := s.db.QueryRow(ctx, ` + SELECT id, created_by_user_id, COALESCE(name, ''), status, hard_score, soft_score, + COALESCE(error_message, ''), started_at, finished_at, created_at + FROM tt_solution WHERE id = $1 AND created_by_user_id = $2 + `, id, userID).Scan( + &sol.ID, &sol.CreatedByUserID, &sol.Name, &sol.Status, + &sol.HardScore, &sol.SoftScore, &sol.ErrorMessage, + &sol.StartedAt, &sol.FinishedAt, &sol.CreatedAt, + ) + if err != nil { + return nil, err + } + return &sol, nil +} + +func (s *TimetableService) ListLessons(ctx context.Context, solutionID, userID string) ([]models.TimetableLesson, error) { + rows, err := s.db.Query(ctx, ` + SELECT l.id, l.solution_id, l.class_id, l.subject_id, l.teacher_id, l.room_id, + l.day_of_week, l.period_index, l.pinned, l.created_at, + cl.name, sub.name, t.last_name || ', ' || t.first_name, + COALESCE(r.name, '') + FROM tt_lesson l + JOIN tt_solution s ON l.solution_id = s.id + JOIN tt_class cl ON l.class_id = cl.id + JOIN tt_subject sub ON l.subject_id = sub.id + JOIN tt_teacher t ON l.teacher_id = t.id + LEFT JOIN tt_room r ON l.room_id = r.id + WHERE s.id = $1 AND s.created_by_user_id = $2 + ORDER BY l.day_of_week, l.period_index + `, solutionID, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []models.TimetableLesson + for rows.Next() { + var l models.TimetableLesson + if err := rows.Scan(&l.ID, &l.SolutionID, &l.ClassID, &l.SubjectID, &l.TeacherID, &l.RoomID, + &l.DayOfWeek, &l.PeriodIndex, &l.Pinned, &l.CreatedAt, + &l.ClassName, &l.SubjectName, &l.TeacherName, &l.RoomName); err != nil { + return nil, err + } + out = append(out, l) + } + return out, nil +} + +func (s *TimetableService) DeleteSolution(ctx context.Context, id, userID string) error { + _, err := s.db.Exec(ctx, `DELETE FROM tt_solution WHERE id = $1 AND created_by_user_id = $2`, id, userID) + return err +} + +// TriggerSolve hands the freshly-created solution off to the solver-service. +// The solver writes back to tt_solution/tt_lesson directly once finished, so +// from this side we just need to fire-and-forget and let the client poll. +func (s *TimetableService) TriggerSolve(ctx context.Context, solverURL, solutionID, userID string) error { + payload := map[string]string{ + "solution_id": solutionID, + "created_by_user_id": userID, + } + body, _ := json.Marshal(payload) + + // 5s timeout — solver should accept the job in milliseconds and run async. + reqCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(reqCtx, "POST", solverURL+"/api/v1/solve", bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + // Mark solution as failed so the user sees something went wrong. + _, _ = s.db.Exec(ctx, ` + UPDATE tt_solution SET status = 'failed', error_message = $1, finished_at = NOW() + WHERE id = $2 + `, "solver-service unreachable: "+err.Error(), solutionID) + return err + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + _, _ = s.db.Exec(ctx, ` + UPDATE tt_solution SET status = 'failed', error_message = $1, finished_at = NOW() + WHERE id = $2 + `, fmt.Sprintf("solver returned HTTP %d", resp.StatusCode), solutionID) + return fmt.Errorf("solver returned HTTP %d", resp.StatusCode) + } + return nil +} diff --git a/school-service/internal/services/timetable_solutions_test.go b/school-service/internal/services/timetable_solutions_test.go new file mode 100644 index 0000000..edbf5ab --- /dev/null +++ b/school-service/internal/services/timetable_solutions_test.go @@ -0,0 +1,27 @@ +package services + +import ( + "testing" + + "github.com/breakpilot/school-service/internal/models" +) + +func TestCreateTimetableSolutionRequest_NoBindingTags(t *testing.T) { + // CreateSolution accepts an empty name; the binding tag is intentionally + // absent. Both states (with + without name) must pass validation. + tests := []struct { + name string + req models.CreateTimetableSolutionRequest + wantErr bool + }{ + {"empty", models.CreateTimetableSolutionRequest{}, false}, + {"with name", models.CreateTimetableSolutionRequest{Name: "Schuljahr 26/27 Test"}, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if (validate.Struct(tt.req) != nil) != tt.wantErr { + t.Errorf("unexpected validation outcome") + } + }) + } +} diff --git a/timetable-solver-service/Dockerfile b/timetable-solver-service/Dockerfile new file mode 100644 index 0000000..6b28763 --- /dev/null +++ b/timetable-solver-service/Dockerfile @@ -0,0 +1,30 @@ +# Timetable solver — Timefold needs a JVM at runtime, so this image bundles +# OpenJDK 17 alongside Python 3.11. +FROM eclipse-temurin:17-jdk-alpine AS jdk + +FROM python:3.11-slim + +# Pull the JVM from the temurin image so we don't shell out to apt for a JDK +# (which on slim adds ~400 MB). +COPY --from=jdk /opt/java/openjdk /opt/java/openjdk +ENV JAVA_HOME=/opt/java/openjdk +ENV PATH="${JAVA_HOME}/bin:${PATH}" + +WORKDIR /app + +# Install runtime deps the JVM bridge needs. +RUN apt-get update && apt-get install -y --no-install-recommends \ + libgomp1 \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY app ./app + +EXPOSE 8095 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \ + CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8095/health')" || exit 1 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8095"] diff --git a/timetable-solver-service/app/__init__.py b/timetable-solver-service/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/timetable-solver-service/app/config.py b/timetable-solver-service/app/config.py new file mode 100644 index 0000000..3f9bb04 --- /dev/null +++ b/timetable-solver-service/app/config.py @@ -0,0 +1,16 @@ +from pydantic_settings import BaseSettings + + +class Settings(BaseSettings): + """Solver-service configuration. Values come from env vars.""" + + database_url: str = "" + solver_seconds_limit: int = 60 + log_level: str = "INFO" + + class Config: + env_file = ".env" + env_prefix = "" + + +settings = Settings() diff --git a/timetable-solver-service/app/constraints.py b/timetable-solver-service/app/constraints.py new file mode 100644 index 0000000..abf0296 --- /dev/null +++ b/timetable-solver-service/app/constraints.py @@ -0,0 +1,209 @@ +"""Timefold constraint provider for the school timetable. + +Three categories: + * universal hard — no double-booking class/teacher/room. These can't be + turned off; the school can't physically run lessons that overlap. + * DB-driven hard — soft-fallback if is_hard=False. Each constraint joins + Lesson against a rule-fact collection from the corresponding tt_ + constraint_* table. + * Quality soft — preferred periods, etc. + +Scoring uses HardSoftScore. Hard violations are weighted by 1; soft +violations use the rule's stored `weight` (0-100). The UI rejects any +solution where hard_score < 0. +""" + +from timefold.solver.score import ( + constraint_provider, + HardSoftScore, + ConstraintFactory, + Constraint, + Joiners, +) + +from .domain import Lesson +from .rules import ( + TeacherUnavailableDayRule, TeacherUnavailableWindowRule, TeacherExcludedRoomRule, + RoomUnavailableRule, SubjectPreferredPeriodRule, RoomRequiresTypeRule, +) + + +@constraint_provider +def define_constraints(factory: ConstraintFactory) -> list[Constraint]: + return [ + # ---------- Universal hard ---------- + _class_conflict(factory), + _teacher_conflict(factory), + _room_conflict(factory), + + # ---------- DB-driven hard or soft ---------- + _teacher_unavailable_day(factory), + _teacher_unavailable_window(factory), + _teacher_excluded_room(factory), + _room_unavailable(factory), + _room_requires_type(factory), + + # ---------- Quality soft ---------- + _subject_preferred_period(factory), + ] + + +# ========================================================================== +# Universal hard constraints +# ========================================================================== + +def _class_conflict(factory: ConstraintFactory) -> Constraint: + """A class can't sit in two lessons at once.""" + return ( + factory.for_each_unique_pair( + Lesson, + Joiners.equal(lambda l: l.school_class.id), + Joiners.equal(lambda l: l.timeslot.id if l.timeslot else None), + ) + .filter(lambda l1, l2: l1.timeslot is not None and l2.timeslot is not None) + .penalize(HardSoftScore.ONE_HARD) + .as_constraint("class_conflict") + ) + + +def _teacher_conflict(factory: ConstraintFactory) -> Constraint: + """A teacher can't run two lessons at once.""" + return ( + factory.for_each_unique_pair( + Lesson, + Joiners.equal(lambda l: l.teacher.id), + Joiners.equal(lambda l: l.timeslot.id if l.timeslot else None), + ) + .filter(lambda l1, l2: l1.timeslot is not None and l2.timeslot is not None) + .penalize(HardSoftScore.ONE_HARD) + .as_constraint("teacher_conflict") + ) + + +def _room_conflict(factory: ConstraintFactory) -> Constraint: + """A room can't host two lessons at once.""" + return ( + factory.for_each_unique_pair( + Lesson, + Joiners.equal(lambda l: l.room.id if l.room else None), + Joiners.equal(lambda l: l.timeslot.id if l.timeslot else None), + ) + .filter(lambda l1, l2: l1.room is not None and l2.room is not None + and l1.timeslot is not None and l2.timeslot is not None) + .penalize(HardSoftScore.ONE_HARD) + .as_constraint("room_conflict") + ) + + +# ========================================================================== +# DB-driven constraints +# ========================================================================== + +def _score_for(rule, *, hard_per_violation: int = 1) -> HardSoftScore: + """Pick HardSoftScore from a rule's is_hard + weight.""" + if rule.is_hard: + return HardSoftScore.of(hard_per_violation, 0) + return HardSoftScore.of(0, max(rule.weight, 1)) + + +def _teacher_unavailable_day(factory: ConstraintFactory) -> Constraint: + return ( + factory.for_each(Lesson) + .filter(lambda l: l.timeslot is not None) + .join( + TeacherUnavailableDayRule, + Joiners.equal(lambda l: l.teacher.id, lambda r: r.teacher_id), + Joiners.equal(lambda l: l.timeslot.day_of_week, lambda r: r.day_of_week), + ) + .penalize(HardSoftScore.ONE_HARD, lambda l, r: 1 if r.is_hard else 0) + .penalize(HardSoftScore.ONE_SOFT, lambda l, r: r.weight if not r.is_hard else 0) + .as_constraint("teacher_unavailable_day") + ) + + +def _teacher_unavailable_window(factory: ConstraintFactory) -> Constraint: + def overlaps(l: Lesson, r: TeacherUnavailableWindowRule) -> bool: + if l.timeslot is None: + return False + # Compare HH:MM strings — they sort correctly when zero-padded. + return l.timeslot.start_time < r.end_time and l.timeslot.end_time > r.start_time + + return ( + factory.for_each(Lesson) + .filter(lambda l: l.timeslot is not None) + .join( + TeacherUnavailableWindowRule, + Joiners.equal(lambda l: l.teacher.id, lambda r: r.teacher_id), + Joiners.equal(lambda l: l.timeslot.day_of_week, lambda r: r.day_of_week), + ) + .filter(overlaps) + .penalize(HardSoftScore.ONE_HARD, lambda l, r: 1 if r.is_hard else 0) + .penalize(HardSoftScore.ONE_SOFT, lambda l, r: r.weight if not r.is_hard else 0) + .as_constraint("teacher_unavailable_window") + ) + + +def _teacher_excluded_room(factory: ConstraintFactory) -> Constraint: + return ( + factory.for_each(Lesson) + .filter(lambda l: l.room is not None) + .join( + TeacherExcludedRoomRule, + Joiners.equal(lambda l: l.teacher.id, lambda r: r.teacher_id), + Joiners.equal(lambda l: l.room.id, lambda r: r.room_id), + ) + .penalize(HardSoftScore.ONE_HARD, lambda l, r: 1 if r.is_hard else 0) + .penalize(HardSoftScore.ONE_SOFT, lambda l, r: r.weight if not r.is_hard else 0) + .as_constraint("teacher_excluded_room") + ) + + +def _room_unavailable(factory: ConstraintFactory) -> Constraint: + return ( + factory.for_each(Lesson) + .filter(lambda l: l.room is not None and l.timeslot is not None) + .join( + RoomUnavailableRule, + Joiners.equal(lambda l: l.room.id, lambda r: r.room_id), + Joiners.equal(lambda l: l.timeslot.day_of_week, lambda r: r.day_of_week), + Joiners.equal(lambda l: l.timeslot.period_index, lambda r: r.period_index), + ) + .penalize(HardSoftScore.ONE_HARD, lambda l, r: 1 if r.is_hard else 0) + .penalize(HardSoftScore.ONE_SOFT, lambda l, r: r.weight if not r.is_hard else 0) + .as_constraint("room_unavailable") + ) + + +def _room_requires_type(factory: ConstraintFactory) -> Constraint: + """If a subject requires a specific room type, the assigned room must match.""" + return ( + factory.for_each(Lesson) + .filter(lambda l: l.room is not None) + .join( + RoomRequiresTypeRule, + Joiners.equal(lambda l: l.subject.id, lambda r: r.subject_id), + ) + .filter(lambda l, r: l.room.room_type != r.room_type) + .penalize(HardSoftScore.ONE_HARD, lambda l, r: 1 if r.is_hard else 0) + .penalize(HardSoftScore.ONE_SOFT, lambda l, r: r.weight if not r.is_hard else 0) + .as_constraint("room_requires_type") + ) + + +# ========================================================================== +# Quality soft +# ========================================================================== + +def _subject_preferred_period(factory: ConstraintFactory) -> Constraint: + """Soft penalty when a lesson lands outside the subject's preferred period range.""" + return ( + factory.for_each(Lesson) + .filter(lambda l: l.timeslot is not None) + .join( + SubjectPreferredPeriodRule, + Joiners.equal(lambda l: l.subject.id, lambda r: r.subject_id), + ) + .filter(lambda l, r: not (r.period_from <= l.timeslot.period_index <= r.period_to)) + .penalize(HardSoftScore.ONE_SOFT, lambda l, r: r.weight) + .as_constraint("subject_preferred_period") + ) diff --git a/timetable-solver-service/app/db.py b/timetable-solver-service/app/db.py new file mode 100644 index 0000000..60369ec --- /dev/null +++ b/timetable-solver-service/app/db.py @@ -0,0 +1,28 @@ +import asyncpg +from typing import Optional + +from .config import settings + +_pool: Optional[asyncpg.Pool] = None + + +async def get_pool() -> asyncpg.Pool: + """Lazy-init the asyncpg pool. Reused across requests + background jobs.""" + global _pool + if _pool is None: + if not settings.database_url: + raise RuntimeError("DATABASE_URL not configured") + _pool = await asyncpg.create_pool( + dsn=settings.database_url, + min_size=2, + max_size=10, + command_timeout=30, + ) + return _pool + + +async def close_pool() -> None: + global _pool + if _pool is not None: + await _pool.close() + _pool = None diff --git a/timetable-solver-service/app/domain.py b/timetable-solver-service/app/domain.py new file mode 100644 index 0000000..605ec00 --- /dev/null +++ b/timetable-solver-service/app/domain.py @@ -0,0 +1,134 @@ +"""Timefold planning domain for school timetables. + +Lessons are the planning entities; their `timeslot` and `room` are the +variables the solver picks. Class/subject/teacher come from the assignment +(`tt_assignment`) and stay fixed for a given Lesson instance. + +Note on equality: Timefold compares facts by identity by default, so we +use frozen dataclasses with id-based equality where needed. +""" + +from dataclasses import dataclass, field +from typing import Annotated, Optional + +from timefold.solver.domain import ( + planning_entity, + planning_solution, + PlanningVariable, + PlanningId, + PlanningEntityCollectionProperty, + ProblemFactCollectionProperty, + ValueRangeProvider, + PlanningScore, +) +from timefold.solver.score import HardSoftScore + + +@dataclass(frozen=True) +class Timeslot: + """A single weekday + lesson period (e.g. Monday 1st hour, 08:00–08:45).""" + + id: Annotated[str, PlanningId] + day_of_week: int # 1..7 + period_index: int # 1..N + start_time: str # HH:MM + end_time: str # HH:MM + + def __str__(self) -> str: + return f"D{self.day_of_week}P{self.period_index}" + + +@dataclass(frozen=True) +class Room: + id: Annotated[str, PlanningId] + name: str + room_type: str = "" + + def __str__(self) -> str: + return self.name + + +@dataclass(frozen=True) +class Teacher: + id: Annotated[str, PlanningId] + last_name: str + first_name: str + short_code: str + + def __str__(self) -> str: + return f"{self.last_name}, {self.first_name}" + + +@dataclass(frozen=True) +class SchoolClass: + id: Annotated[str, PlanningId] + name: str + grade_level: int + + def __str__(self) -> str: + return self.name + + +@dataclass(frozen=True) +class Subject: + id: Annotated[str, PlanningId] + name: str + short_code: str + required_room_type: str = "" + + def __str__(self) -> str: + return self.short_code + + +@planning_entity +@dataclass +class Lesson: + """One scheduled class-subject pairing. + + Curriculum says "5a needs 4 hours of Mathe per week" → 4 Lesson instances + with school_class=5a, subject=Mathe, teacher fixed (from tt_assignment). + The solver assigns timeslot + room. + """ + + id: Annotated[str, PlanningId] + school_class: SchoolClass + subject: Subject + teacher: Teacher + timeslot: Annotated[Optional[Timeslot], PlanningVariable] = field(default=None) + room: Annotated[Optional[Room], PlanningVariable] = field(default=None) + + def __str__(self) -> str: + return f"{self.school_class}-{self.subject}#{self.id[:8]}" + + +from .rules import ( + TeacherUnavailableDayRule, TeacherUnavailableWindowRule, TeacherExcludedRoomRule, + RoomUnavailableRule, SubjectPreferredPeriodRule, RoomRequiresTypeRule, +) + + +@planning_solution +@dataclass +class Timetable: + """The solver works on one Timetable instance: shuffles `lessons[*].timeslot` + and `lessons[*].room` to satisfy the constraints. + + Constraint-rule facts are pulled from the DB at solve time and passed + here so the constraint provider can join against them. + """ + + timeslots: Annotated[list[Timeslot], ProblemFactCollectionProperty, ValueRangeProvider] + rooms: Annotated[list[Room], ProblemFactCollectionProperty, ValueRangeProvider] + teachers: Annotated[list[Teacher], ProblemFactCollectionProperty] + classes: Annotated[list[SchoolClass], ProblemFactCollectionProperty] + subjects: Annotated[list[Subject], ProblemFactCollectionProperty] + + teacher_unavailable_days: Annotated[list[TeacherUnavailableDayRule], ProblemFactCollectionProperty] + teacher_unavailable_windows: Annotated[list[TeacherUnavailableWindowRule], ProblemFactCollectionProperty] + teacher_excluded_rooms: Annotated[list[TeacherExcludedRoomRule], ProblemFactCollectionProperty] + room_unavailables: Annotated[list[RoomUnavailableRule], ProblemFactCollectionProperty] + subject_preferred_periods: Annotated[list[SubjectPreferredPeriodRule], ProblemFactCollectionProperty] + room_requires_types: Annotated[list[RoomRequiresTypeRule], ProblemFactCollectionProperty] + + lessons: Annotated[list[Lesson], PlanningEntityCollectionProperty] + score: Annotated[Optional[HardSoftScore], PlanningScore] = field(default=None) diff --git a/timetable-solver-service/app/main.py b/timetable-solver-service/app/main.py new file mode 100644 index 0000000..1bb2c5c --- /dev/null +++ b/timetable-solver-service/app/main.py @@ -0,0 +1,96 @@ +"""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() diff --git a/timetable-solver-service/app/repository.py b/timetable-solver-service/app/repository.py new file mode 100644 index 0000000..d359236 --- /dev/null +++ b/timetable-solver-service/app/repository.py @@ -0,0 +1,258 @@ +"""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) diff --git a/timetable-solver-service/app/rules.py b/timetable-solver-service/app/rules.py new file mode 100644 index 0000000..fde185a --- /dev/null +++ b/timetable-solver-service/app/rules.py @@ -0,0 +1,64 @@ +"""DB-driven constraint rules as Timefold problem facts. + +Each tt_constraint_* table from school-service maps to one dataclass here. +Rows loaded at solve time are passed in via Timetable.* fact collections +(see domain.py for wiring) and queried by the constraint provider. + +Only the rule types actually wired into constraints.py are defined for now. +Adding a new one is two steps: define the dataclass, add it to Timetable's +problem-fact properties, then implement a constraint that joins it. +""" + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class TeacherUnavailableDayRule: + teacher_id: str + day_of_week: int + is_hard: bool + weight: int + + +@dataclass(frozen=True) +class TeacherUnavailableWindowRule: + teacher_id: str + day_of_week: int + start_time: str # HH:MM + end_time: str # HH:MM + is_hard: bool + weight: int + + +@dataclass(frozen=True) +class TeacherExcludedRoomRule: + teacher_id: str + room_id: str + is_hard: bool + weight: int + + +@dataclass(frozen=True) +class RoomUnavailableRule: + room_id: str + day_of_week: int + period_index: int + is_hard: bool + weight: int + + +@dataclass(frozen=True) +class SubjectPreferredPeriodRule: + subject_id: str + period_from: int + period_to: int + is_hard: bool + weight: int + + +@dataclass(frozen=True) +class RoomRequiresTypeRule: + subject_id: str + room_type: str + is_hard: bool + weight: int diff --git a/timetable-solver-service/app/runner.py b/timetable-solver-service/app/runner.py new file mode 100644 index 0000000..70e0df4 --- /dev/null +++ b/timetable-solver-service/app/runner.py @@ -0,0 +1,94 @@ +"""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, + 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) + +_solver_factory = SolverFactory.create( + SolverConfig( + solution_class=Timetable, + entity_class_list=[Lesson], + score_director_factory_config={"constraint_provider_function": define_constraints}, + termination_config=TerminationConfig( + spent_limit=Duration(seconds=settings.solver_seconds_limit), + ), + ) +) + + +def _solve_sync(problem: Timetable) -> Timetable: + """Blocking solver call; runs in a worker thread.""" + solver = _solver_factory.build_solver() + return solver.solve(problem) + + +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: + await mark_running(pool, solution_id) + problem = await build_problem(pool, user_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) + + 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") diff --git a/timetable-solver-service/pyproject.toml b/timetable-solver-service/pyproject.toml new file mode 100644 index 0000000..358f246 --- /dev/null +++ b/timetable-solver-service/pyproject.toml @@ -0,0 +1,13 @@ +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[project] +name = "timetable-solver-service" +version = "0.1.0" +description = "BreakPilot timetable solver (Timefold + FastAPI)" +requires-python = ">=3.10" + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["."] diff --git a/timetable-solver-service/requirements.txt b/timetable-solver-service/requirements.txt new file mode 100644 index 0000000..ed767e3 --- /dev/null +++ b/timetable-solver-service/requirements.txt @@ -0,0 +1,8 @@ +fastapi==0.115.0 +uvicorn[standard]==0.32.0 +asyncpg==0.30.0 +pydantic==2.9.2 +pydantic-settings==2.6.0 +timefold-solver==1.21.1 +httpx==0.27.2 +python-multipart==0.0.12 diff --git a/timetable-solver-service/tests/__init__.py b/timetable-solver-service/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/timetable-solver-service/tests/test_domain.py b/timetable-solver-service/tests/test_domain.py new file mode 100644 index 0000000..1b0fc72 --- /dev/null +++ b/timetable-solver-service/tests/test_domain.py @@ -0,0 +1,51 @@ +"""Unit tests for the planning domain dataclasses. + +These tests deliberately avoid spinning up the JVM-backed solver — they +only verify that the domain objects construct, serialise, and compare as +expected. The full solver lifecycle is exercised by integration tests run +against a populated DB (Phase 8). +""" + +from app.domain import Lesson, Room, SchoolClass, Subject, Teacher, Timeslot + + +def _ts() -> Timeslot: + return Timeslot(id="ts1", day_of_week=1, period_index=1, start_time="08:00", end_time="08:45") + + +def _room() -> Room: + return Room(id="r1", name="A101", room_type="standard") + + +def _teacher() -> Teacher: + return Teacher(id="t1", last_name="Schmidt", first_name="Anna", short_code="SCH") + + +def _class() -> SchoolClass: + return SchoolClass(id="c1", name="5a", grade_level=5) + + +def _subject() -> Subject: + return Subject(id="s1", name="Mathematik", short_code="M") + + +def test_timeslot_str() -> None: + assert str(_ts()) == "D1P1" + + +def test_teacher_str() -> None: + assert str(_teacher()) == "Schmidt, Anna" + + +def test_lesson_starts_unassigned() -> None: + lesson = Lesson(id="L1", school_class=_class(), subject=_subject(), teacher=_teacher()) + assert lesson.timeslot is None + assert lesson.room is None + + +def test_lesson_accepts_assignment() -> None: + lesson = Lesson(id="L1", school_class=_class(), subject=_subject(), teacher=_teacher()) + lesson.timeslot = _ts() + lesson.room = _room() + assert lesson.timeslot.day_of_week == 1 + assert lesson.room.name == "A101" diff --git a/timetable-solver-service/tests/test_rules.py b/timetable-solver-service/tests/test_rules.py new file mode 100644 index 0000000..861cb68 --- /dev/null +++ b/timetable-solver-service/tests/test_rules.py @@ -0,0 +1,27 @@ +"""Smoke tests for the constraint-rule dataclasses.""" + +from app.rules import ( + RoomRequiresTypeRule, RoomUnavailableRule, + SubjectPreferredPeriodRule, TeacherExcludedRoomRule, + TeacherUnavailableDayRule, TeacherUnavailableWindowRule, +) + + +def test_rules_construct_with_expected_fields() -> None: + rules = [ + TeacherUnavailableDayRule(teacher_id="t1", day_of_week=1, is_hard=True, weight=100), + TeacherUnavailableWindowRule(teacher_id="t1", day_of_week=2, start_time="13:00", end_time="17:00", is_hard=True, weight=100), + TeacherExcludedRoomRule(teacher_id="t1", room_id="r1", is_hard=True, weight=100), + RoomUnavailableRule(room_id="r1", day_of_week=3, period_index=4, is_hard=True, weight=100), + SubjectPreferredPeriodRule(subject_id="s1", period_from=1, period_to=4, is_hard=False, weight=40), + RoomRequiresTypeRule(subject_id="s1", room_type="Sporthalle", is_hard=True, weight=100), + ] + assert len(rules) == 6 + + +def test_rules_are_hashable_frozen() -> None: + # Frozen dataclasses are hashable, important when Timefold inserts them + # into hash-based caches inside the solver. + rule = TeacherUnavailableDayRule(teacher_id="t1", day_of_week=1, is_hard=True, weight=100) + s = {rule, rule} + assert len(s) == 1