Compare commits

..

89 Commits

Author SHA1 Message Date
Benjamin Admin 77c720e2df Document Stundenplan + Schulkalender end-of-session state
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 50s
CI / test-go-edu-search (push) Successful in 45s
CI / test-python-klausur (push) Failing after 3m50s
CI / test-python-agent-core (push) Successful in 36s
CI / test-nodejs-website (push) Successful in 49s
- CLAUDE.md gets a new section summarising the two feature strands,
  pitfalls (Timefold name, JSX quotes, LOC budget), the auth/messaging
  outsourcing, and pointers to the three memory files for next session.
- docs-src/services/schulkalender/ — 5 MkDocs pages mirroring the
  stundenplan structure: index, architecture, holidays, parent-flow,
  notifications. Each with DB tables, endpoints, and the dispatch
  payload contract for the colleague's Matrix/Email services.
- mkdocs.yml gains the Schulkalender nav entry under Services.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 18:41:31 +02:00
Benjamin Admin 89011d64f7 gofmt notification files
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 49s
CI / test-go-edu-search (push) Successful in 45s
CI / test-python-klausur (push) Failing after 3m41s
CI / test-python-agent-core (push) Successful in 38s
CI / test-nodejs-website (push) Successful in 49s
2026-05-22 18:14:15 +02:00
Benjamin Admin 8311b33fb3 Phase 9d: Notification cron + multilingual templates + status badges
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 1m10s
CI / test-go-edu-search (push) Successful in 43s
CI / test-python-klausur (push) Failing after 4m4s
CI / test-python-agent-core (push) Successful in 44s
CI / test-nodejs-website (push) Successful in 51s
Backend (school-service):
  - notification_log table with UNIQUE(event_id, lead_days, audience,
    channel) for idempotent re-runs. Status enum sent/failed/skipped.
  - internal/notifications/templates.go: per-event-type × audience ×
    lead-day-bucket × language templates in 8 languages (de/en/tr/ar/
    uk/ru/pl/fr). Fallback chain (lang→de, eventType→andere) so we
    never miss a render.
  - service.go scans cal_school_event for events whose
    (start_date - runDate) appears in notification_lead_days. For each
    due (audience, channel) tuple it dispatches via POST to the
    Matrix/Email upstreams owned by the colleague's services.
    Empty URL → status='skipped', logged for visibility.
  - dispatcher.go handles the POST, parent-recipient lookup (joins
    parent_account + parent_child + cal_school_event.affected_class_ids),
    and writeLog with the unique constraint dropping duplicate runs.
  - main.go runs a 1-hour ticker; when time.Hour()==6 it invokes the
    scanner for today. Idempotent so transient restarts don't double-
    send.
  - POST /calendar/notifications/run-now for manual trigger + backfill
    (?date=YYYY-MM-DD).
  - GET /calendar/events/:id/notifications returns notification_log
    rows scoped to the owning teacher.
  - MATRIX_SERVICE_URL + EMAIL_SERVICE_URL env vars added (default
    empty = stub mode).

Frontend (studio-v2):
  - NotificationStatus component fetches /events/:id/notifications and
    renders coloured badges per (lead, audience, channel, status).
  - DayDetail mounts NotificationStatus inside each event card when
    notify_parents or notify_students is set.

Tests:
  - 6 new Go unit tests for bucketFor + Render (de/tr/fallback paths)
    + substitute(class_suffix). 89 subtests gesamt.
  - 2 new Playwright tests: badge render with mocked log, hidden when
    notifications are off.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 18:12:39 +02:00
Benjamin Admin 85957ed5db gofmt parent backend files
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 36s
CI / test-go-edu-search (push) Successful in 33s
CI / test-python-klausur (push) Failing after 2m45s
CI / test-python-agent-core (push) Successful in 23s
CI / test-nodejs-website (push) Successful in 27s
2026-05-22 11:52:51 +02:00
Benjamin Admin d9858084dd Phase 9c: Parent accounts, magic-link login + parent timetable view
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 31s
CI / test-go-edu-search (push) Successful in 30s
CI / test-python-klausur (push) Failing after 2m36s
CI / test-python-agent-core (push) Successful in 21s
CI / test-nodejs-website (push) Successful in 26s
Backend (school-service):
  - parent_account, parent_child, parent_magic_link, parent_session
    tables. Tokens are sha256-hashed in DB; raw goes back exactly
    once to the inviting teacher.
  - InviteParent upserts the parent account, links a child to a tt_
    class, mints a 7-day magic link. Returns the link path so the
    teacher can paste it into Matrix/Email.
  - RedeemMagicLink validates + marks used + mints a 30-day session,
    sets HttpOnly bp_parent_session cookie.
  - ParentSessionMiddleware reads the cookie and resolves the parent.
    Lives in its own router group /api/v1/parent — totally separate
    from the teacher JWT path.
  - ParentMe returns the account + list of children (with class name).
  - ParentTimetable returns the latest completed tt_solution's lessons
    for the requested child's class, with full authorization check
    (parent must own a child in that class).

Frontend (studio-v2):
  - lib/calendar/subject-i18n.ts maps 22 German subject names to 8
    parent locales (de/en/tr/ar/uk/ru/pl/fr). Falls back to German
    for custom subjects.
  - ParentManager component on the Schulkalender page lets the teacher
    invite parents via email + child name + class + language. Newly
    minted magic-link is shown with a copy-to-clipboard button.
  - app/api/parent/[...path]/route.ts proxies parent-side endpoints
    via the cookie so HttpOnly survives the Next.js round-trip.
  - /eltern/login?token=… redeems and redirects to /eltern.
  - /eltern shows a Wochengrid with German days + translated subject
    names in the parent's preferred language. Headings and weekday
    labels also localised (de/en/tr/ar/uk/ru/pl/fr).

Tests:
  - 3 new Go unit tests (random token, hash stability, invite-request
    validator). 83 subtests gesamt.
  - studio-v2: e2e/eltern.spec.ts mit 7 tests across ParentManager,
    /eltern/login, /eltern overview, subject-i18n end-to-end.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 11:50:35 +02:00
Benjamin Admin 33409352ee Phase 9b: Schul-Events CRUD + Schuljahres-Rollover
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 28s
CI / test-go-edu-search (push) Successful in 28s
CI / test-python-klausur (push) Failing after 2m38s
CI / test-python-agent-core (push) Successful in 20s
CI / test-nodejs-website (push) Successful in 26s
Backend (school-service):
  - calendar_events.go — Create/List/Delete on cal_school_event with
    UUID[] handling for affected_class_ids. Default lead-days [7,1]
    if caller omits the array.
  - calendar_rollover.go — single-transaction promotion: graduating
    classes (grade >= 13) get deleted first so the +1 update doesn't
    bump them to invalid grade 14. defaultSchoolYearDates() picks the
    next Aug-Jul pair when the caller doesn't specify.
  - Handlers + routes: GET/POST /calendar/events,
    DELETE /calendar/events/:id, POST /calendar/school-year-rollover.

Frontend (studio-v2):
  - EventModal: form with Title / Typ / Datum/Zeit / unterrichtsfrei /
    Beschreibung / Sichtbarkeit + Notification-Checkboxen. Per-Type
    Farb-Mapping in types.ts.
  - DayDetail: Modal das beim Klick auf einen Kalender-Tag aufgeht und
    Feiertage + Schulferien + Schul-Events fuer diesen Tag listet,
    inkl. Loeschen-Button pro Event.
  - RolloverWizard: zwei-Schritt-Dialog mit Datums-Auswahl + Tipp-
    Bestaetigung ("SCHULJAHR WECHSELN") gegen versehentliche Auslo-
    sung, danach Ergebnis-Card mit promoted/graduated-Counts.
  - MonthView gewinnt onDayClick + onAddEvent + onRollover Props,
    rendert farb-codierte Punkte fuer School-Events am Tagesrand.
  - Page laed Events parallel mit Holidays und reicht alle Handler
    nach unten.

Tests:
  - Go: 3 neue Tests fuer defaultSchoolYearDates + parseClassIDs.
    Validator-Test fuer CreateSchoolEventRequest existiert bereits.
    80 Subtests gesamt, alle gruen.
  - Playwright: mockCalendarApi gewinnt Routes fuer events GET/POST/
    DELETE und school-year-rollover. 6 neue Tests (EventModal open,
    submit, DayDetail open, Rollover-Trigger, Confirm-Schutz,
    Ergebnis-Anzeige).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 10:32:33 +02:00
Benjamin Admin 3b8df0d294 Schulkalender test: use exact text for legend assertions
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 29s
CI / test-go-edu-search (push) Successful in 29s
CI / test-python-klausur (push) Failing after 2m39s
CI / test-python-agent-core (push) Successful in 19s
CI / test-nodejs-website (push) Successful in 22s
2026-05-22 09:54:22 +02:00
Benjamin Admin 09f6f5a5e1 gofmt calendar files
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 29s
CI / test-go-edu-search (push) Successful in 29s
CI / test-python-klausur (push) Failing after 2m25s
CI / test-python-agent-core (push) Successful in 19s
CI / test-nodejs-website (push) Successful in 21s
2026-05-22 09:47:37 +02:00
Benjamin Admin 97e37837ee Phase 9a: Schulkalender — Bundesland-Auswahl + Monatsansicht mit Ferien
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 29s
CI / test-go-edu-search (push) Successful in 27s
CI / test-python-klausur (push) Failing after 2m50s
CI / test-python-agent-core (push) Successful in 18s
CI / test-nodejs-website (push) Successful in 21s
Backend (school-service):
  - cal_public_event (region, event_type, name_de, name_en, start/end,
    UNIQUE(region, event_type, name_de, start_date)) — global snapshot.
  - cal_school_config (user_id PRIMARY KEY, bundesland, school year dates).
  - cal_school_event — Schul-eigene Termine; CRUD folgt in 9b.
  - GET /calendar/holidays?region=&from=&to= — Range-Query against
    cal_public_event, ordered by start_date.
  - GET / PUT /calendar/config — upsert Bundesland per User.
  - SeedFromSnapshot reads internal/seed/calendar_holidays.json on every
    boot; idempotent via the unique constraint. Async goroutine so the
    HTTP server starts immediately even if the seed file is large.

Data source:
  - scripts/calendar-snapshot.sh ruft openholidaysapi.org fuer alle 16
    Bundeslaender x 3 Schuljahre und schreibt
    school-service/internal/seed/calendar_holidays.json (854 Events,
    Stand Schuljahre 2026-2028).
  - Dockerfile kopiert das seed/-Verzeichnis ins Image, damit die
    Container-Datenbank beim ersten Start gefuellt wird.

Frontend (studio-v2):
  - /schulkalender Page mit Gradient + Blobs wie /stundenplan und
    /korrektur — gleicher Visual-Style.
  - BundeslandWizard: zeigt alle 16 Laender als Dropdown, speichert
    bei Klick die Config und switcht zur Monatsansicht.
  - MonthView: 6-Wochen-Grid Mo-So, Feiertage rose-toned, Schulferien
    amber-toned, heutiges Datum mit Indigo-Ring. Prev/Next/Heute
    Navigation.
  - lib/schulkalender/api.ts re-uses the stundenplan JWT helper so
    auth-mode wechselt nicht.
  - Sidebar bekommt einen Schulkalender-Eintrag (Icon mit Datum-Dots,
    Pfad /schulkalender) in allen 26 Sprachen.

Tests:
  - Go: 3 neue Validator-Tests (Bundesland len=5, EventType oneof,
    Pflichtfelder). 77 Tests gesamt, alle gruen.
  - Playwright: e2e/schulkalender.spec.ts mit Wizard, Save-Flow,
    MonthView-Render, Heute-Button, Sidebar-Link. Hermetisch via
    mockCalendarApi.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 09:46:39 +02:00
Benjamin Admin 65e7ed94f6 gofmt middleware.go
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 40s
CI / test-go-edu-search (push) Successful in 30s
CI / test-python-klausur (push) Failing after 2m27s
CI / test-python-agent-core (push) Successful in 19s
CI / test-nodejs-website (push) Successful in 22s
2026-05-22 08:58:11 +02:00
Benjamin Admin 306886a42b Phase 8: CSV + ICS export, print view, MkDocs docs, SBOM + dev-mode auth
Auth (Test-Mode):
  - middleware.AuthMiddleware now takes a devMode flag. In dev,
    requests without Authorization fall back to a deterministic dev
    UUID (00000000-...-001) and role=teacher. ENVIRONMENT=production
    re-enables the strict 401 path.
  - main.go wires devMode = cfg.Environment != "production".
  - page.tsx replaces the red 'Anmeldung noch nicht integriert' banner
    with a softer Testumgebung notice; the manual-token form moves
    behind a nested details block.

Export endpoints (school-service):
  - LoadExportLessons joins tt_lesson with tt_period for wall-clock
    times; one query feeds both CSV and ICS.
  - WriteCSV streams 10 columns including pinned flag.
  - WriteICS emits one VEVENT per lesson anchored to a Monday — caller
    overridable via ?start=YYYY-MM-DD. RFC 5545 escapes for ',', ';',
    '\n' in icsEscape().
  - NextMonday helper for the default anchor.
  - GET /timetable/solutions/:id/export.{csv,ics} handlers attach
    Content-Disposition: attachment so browsers download instead of
    rendering.

Frontend:
  - lib/stundenplan/api.ts downloadSolutionExport() fetches as blob,
    triggers a synthetic <a download> click, and forwards the JWT when
    present.
  - PlanView gains CSV / ICS / Drucken buttons next to the perspective
    selector. The toolbar carries class 'no-print' so window.print()
    yields only the grid.
  - globals.css @media print rule hides chrome, forces white
    background, gives the table proper borders for A4.

Docs:
  - docs-src/services/stundenplan/{index,architecture,constraints,
    solver-tuning,export}.md with nav entry in mkdocs.yml under
    Services → Stundenplaner.
  - sbom/stundenplan/README.md lists manually-verified key dependencies
    and the policy reference. scripts/stundenplan-sbom.sh generates
    full machine-readable inventories via go-licenses + pip-licenses
    + license-checker when those tools are available.

Tests:
  - internal/services/timetable_exports_test.go: 4 unit tests covering
    CSV column layout + quoting, ICS structure + DTSTART formatting,
    icsEscape special chars, NextMonday weekday math.
  - studio-v2/e2e/stundenplan-export.spec.ts split out of the main spec
    file (LOC budget) — 3 tests for button render, CSV download,
    ICS download.
  - mockSchoolApi extended with export.csv + export.ics routes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 08:57:07 +02:00
Benjamin Admin bf5ea860cc Phase 7: pinning, plan versions, solver budget + UX polish
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
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
Benjamin Admin 612ecec6d9 Phase 6: Plan-Ansicht — solution list + weekly grid view
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 27s
CI / test-go-edu-search (push) Successful in 29s
CI / test-python-klausur (push) Failing after 3m18s
CI / test-python-agent-core (push) Successful in 20s
CI / test-nodejs-website (push) Successful in 23s
Frontend additions in studio-v2:
  - types.ts adds TimetableSolution, TimetableLesson, SolutionStatus,
    CreateTimetableSolution mirroring the Go models.
  - lib/stundenplan/api.ts adds solutionsApi with list/get/create/remove/
    lessons. Solve trigger is POST /timetable/solutions — school-service
    forwards to the solver-service over the Docker network.
  - _components/plan/SolutionList: table of past solves with status
    badges, hard/soft score, Anzeigen + Loeschen buttons, and a
    'Neuen Plan generieren' trigger. Auto-polls every 4 s while any
    solution is pending/running, clears the interval otherwise.
  - _components/plan/PlanView: Mo–Fr × period weekly grid. Three
    perspectives (Klasse / Lehrer / Raum) toggleable via test-id'd
    buttons; selector below lists every unique resource with at least
    one lesson. Cells colour-coded by tt_subject.color.
  - _components/plan/PlanHub orchestrates list + view; default tab in
    page.tsx switches from 'klassen' to 'plan'.

Tests:
  - mockSchoolApi helper extracted to e2e/_helpers.ts so the spec file
    stays under 500 LOC. Helper now also mocks /solutions GET/POST/DELETE
    and /solutions/:id/lessons; solutions kept in a closure so POST
    appears in the next GET.
  - 8 new tests across two suites: SolutionList empty state, list
    render, completed-vs-failed Anzeigen visibility, solve trigger;
    PlanView placeholder when no selection, grid render, perspective
    switching.
  - Existing Klassen CRUD tests now click the Klassen tab first
    (Plan is the new default landing tab).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 01:03:55 +02:00
Benjamin Admin 0744769d88 Split DB-driven constraints into hard + soft variants
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 27s
CI / test-go-edu-search (push) Successful in 29s
CI / test-python-klausur (push) Failing after 2m30s
CI / test-python-agent-core (push) Successful in 17s
CI / test-nodejs-website (push) Successful in 25s
Timefold's penalize() emits one score axis per constraint, so the
'penalize as hard OR soft based on is_hard' pattern needs two
constraints. Each rule type now has _hard (filters is_hard=True,
penalises HardSoftScore.ONE_HARD) and _soft (filters is_hard=False,
penalises ONE_SOFT × max(weight, 1)).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 00:40:10 +02:00
Benjamin Admin d3f311a32e Wrap score-director config in ScoreDirectorFactoryConfig
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 2m31s
CI / test-python-agent-core (push) Successful in 17s
CI / test-nodejs-website (push) Successful in 21s
Timefold's SolverConfig expects a typed config object, not a dict —
plain dicts hit AttributeError when the wrapper tries to materialise
the Java side.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 00:33:34 +02:00
Benjamin Admin 77650e8092 Use 'timefold' (not 'timefold-solver') and revert to ARM platform
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 36s
CI / test-go-edu-search (push) Successful in 29s
CI / test-python-klausur (push) Failing after 2m32s
CI / test-python-agent-core (push) Successful in 18s
CI / test-nodejs-website (push) Successful in 21s
The Python distribution is published on PyPI as 'timefold' — only the
Java module is called 'timefold-solver'. With the correct package the
ARM64 wheel resolves cleanly, so revert the linux/amd64 workaround.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 00:28:13 +02:00
Benjamin Admin 22f08a232d Pin solver-service to linux/amd64 (no ARM wheels on PyPI)
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 28s
CI / test-go-edu-search (push) Successful in 29s
CI / test-python-klausur (push) Failing after 3m16s
CI / test-python-agent-core (push) Successful in 18s
CI / test-nodejs-website (push) Successful in 25s
timefold-solver only publishes x86_64 wheels — pip resolves to no
candidate on linux/arm64. Switch the compose entry to linux/amd64
so the image builds via QEMU/Rosetta on the Mac Mini. The cloud
target for this service is x86_64 anyway.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 00:22:34 +02:00
Benjamin Admin 1f2f304724 Use default-jdk-headless on python:slim instead of temurin:alpine
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 36s
CI / test-go-edu-search (push) Successful in 29s
CI / test-python-klausur (push) Failing after 2m34s
CI / test-python-agent-core (push) Successful in 17s
CI / test-nodejs-website (push) Successful in 23s
ARM64 has no eclipse-temurin:17-jdk-alpine manifest. Switch to the
Debian-based python:3.11-slim and install OpenJDK via apt so the
image builds on both Mac Mini (ARM) and x86_64 hosts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 00:19:03 +02:00
Benjamin Admin 53cfe9238f Apply gofmt to timetable_solution_migrations.go
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 38s
CI / test-go-edu-search (push) Successful in 30s
CI / test-python-klausur (push) Failing after 2m32s
CI / test-python-agent-core (push) Successful in 21s
CI / test-nodejs-website (push) Successful in 28s
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 00:18:00 +02:00
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
Benjamin Admin 082a5bb68c Strip orphan straight-quote pairings in JSX descriptions
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 30s
CI / test-go-edu-search (push) Successful in 31s
CI / test-python-klausur (push) Failing after 2m35s
CI / test-python-agent-core (push) Successful in 20s
CI / test-nodejs-website (push) Successful in 21s
The German „X" markers in the description prop combined a curly „
(U+201E) with a straight " (U+0022). The straight quote prematurely
terminated the JavaScript string inside the JSX expression. Removing
both markers around the example text keeps the description readable
and unambiguously valid JSX.

Test selector for the UnavailableWindow description updated to match
the new wording.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 23:52:09 +02:00
Benjamin Admin a315db0388 Fix JSX attribute syntax in constraint editor descriptions
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 28s
CI / test-go-edu-search (push) Successful in 33s
CI / test-python-klausur (push) Failing after 2m32s
CI / test-python-agent-core (push) Successful in 19s
CI / test-nodejs-website (push) Successful in 24s
German curly quotes („…") combined with a closing straight " inside
JSX attribute values were terminating the attribute prematurely, e.g.
`description="Beispiel: „X" (jugendgerecht)."` lost everything after
the inner straight quote. Switch all such descriptions to the JSX
expression form `description={"…"}` so the inner quotes are part of
a JavaScript string literal and parsed correctly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 23:41:24 +02:00
Benjamin Admin 7c96d89927 Stundenplan Phase 3d: all 15 constraint editors via shared shell
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 30s
CI / test-go-edu-search (push) Successful in 29s
CI / test-python-klausur (push) Failing after 2m38s
CI / test-python-agent-core (push) Successful in 18s
CI / test-nodejs-website (push) Successful in 20s
Backend was already complete in Phase 2; this finishes the UI.

  - regeln/_shell.tsx introduces useConstraintCrud (handles list/create/
    delete state + reload), ConstraintShell (header, prereq banner,
    form toggle, error display, empty/loading/table render), and
    useShellStyles for the recurring theme tokens. Each editor now
    only carries its schema-specific bits.
  - Existing 4 editors (TeacherUnavailableDay/Window, SubjectMax
    Consecutive/PreferredPeriod) refactored onto the shell — every
    Playwright selector preserved.
  - 11 new editors covering the remaining constraint tables:
      TeacherMaxHours{Day,Week}, TeacherExcluded{Subject,Room},
      Subject{MinDayGap,ContiguousWhenRepeated,DoubleLesson},
      Class{MaxHoursDay,NoGaps},
      Room{RequiresType,Unavailable}.
  - RegelnHub now references all 15 editors directly — no more 'soon'
    placeholders. The two duplicate 'Max. Stunden / Tag' entries
    (teacher + class) are intentional and disambiguated by group.

Tests:
  - e2e/stundenplan.spec.ts: mock routes added for all 11 new constraint
    endpoints. RegelnHub suite gains a single test that switches
    through 13 uniquely-labelled editors, plus a dedicated test for
    the two duplicate 'Max. Stunden / Tag' labels.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 23:27:34 +02:00
Benjamin Admin c2c09e1cd9 Stundenplan Phase 3c: complete Stammdaten + RegelnHub with 4 editors
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 31s
CI / test-go-edu-search (push) Successful in 30s
CI / test-python-klausur (push) Failing after 3m31s
CI / test-python-agent-core (push) Successful in 18s
CI / test-nodejs-website (push) Successful in 22s
Frontend additions in studio-v2:
  - PeriodsManager renders the weekly grid as a Mo–So table with one
    row per period_index. New entries auto-increment period_index so
    the user can hit Anlegen repeatedly for a full day's slots.
  - CurriculumManager joins classes + subjects; new entries refuse to
    open when either prerequisite list is empty (banner instead).
  - AssignmentsManager joins teacher × class × subject with the same
    prerequisite-banner pattern.
  - regeln/RegelnHub: vertical sidebar grouping all 15 constraint
    types by parent entity (Lehrer/Fach/Klasse/Raum). Implemented
    editors are clickable, the other 11 are visibly disabled with
    a 'soon' tag.
  - Three new editors:
      TeacherUnavailableWindowEditor (time-window pattern),
      SubjectMaxConsecutiveEditor (number-input pattern),
      SubjectPreferredPeriodEditor (number range pattern).
  - page.tsx wires every tab to its manager; the not-implemented
    placeholder is gone (no more empty tabs).

Test coverage:
  - e2e/stundenplan.spec.ts rewritten: 23 tests across 7 suites,
    covering all 8 tabs, the new managers' prerequisite banners,
    sub-tab switching in the RegelnHub, and the disabled state of
    not-yet-implemented constraint rules. Each test mocks the
    backend via page.route() so the suite stays hermetic.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 23:08:15 +02:00
Benjamin Admin 4657589b89 Fix Playwright selector ambiguities in stundenplan.spec.ts
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 29s
CI / test-go-edu-search (push) Successful in 31s
CI / test-python-klausur (push) Failing after 2m36s
CI / test-python-agent-core (push) Successful in 20s
CI / test-nodejs-website (push) Successful in 22s
- 'Lehrer' label collided with a Sidebar entry; scope to <main nav>.
- 'Schmidt, Anna' lived in a closed <option>; assert on count via
  select.locator() instead of toBeVisible().

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 22:53:10 +02:00
Benjamin Admin 73636f76a2 Stundenplan Phase 3b: 3 more Stammdaten managers, first constraint editor, full test coverage
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 30s
CI / test-go-edu-search (push) Successful in 30s
CI / test-python-klausur (push) Failing after 3m6s
CI / test-python-agent-core (push) Successful in 20s
CI / test-nodejs-website (push) Successful in 21s
Frontend additions in studio-v2:
  - LehrerManager / FaecherManager / RaeumeManager — same CRUD pattern as
    Klassen, with entity-specific form fields and table columns.
  - regeln/TeacherUnavailableDayEditor — first constraint editor, joins
    against teachersApi to render a readable name in the dropdown and
    list. Falls back to a guidance banner when no teachers exist yet.
  - page.tsx wires up the new tabs; data-testid attributes added across
    managers so the Playwright suite can target them deterministically.

Tests:
  - school-service: timetable_constraints_more_test.go fills the
    remaining 9 constraint DTOs (TeacherMaxHoursDay/Week,
    TeacherExcludedSubject/Room, SubjectMinDayGap,
    SubjectContiguousWhenRepeated, SubjectDoubleLesson, ClassNoGaps,
    RoomRequiresType). 66 subtests total, all green.
  - studio-v2: e2e/stundenplan.spec.ts covers the page shell, tab
    navigation, Klassen CRUD with mocked backend, constraint editor's
    empty-teacher fallback, sidebar entry. All school-service calls
    intercepted via page.route() so the suite is hermetic.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 22:44:39 +02:00
Benjamin Admin f21ecf293b Add Stundenplan frontend scaffolding in studio-v2
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 31s
CI / test-go-edu-search (push) Successful in 29s
CI / test-python-klausur (push) Failing after 2m36s
CI / test-python-agent-core (push) Successful in 20s
CI / test-nodejs-website (push) Successful in 22s
Phase 3 — initial UI for the timetable scheduler:
  - app/stundenplan/page.tsx with tab navigation (Klassen / Lehrer /
    Faecher / Raeume / Zeitraster / Stundentafel / Lehrauftraege /
    Regeln) and a dev-mode JWT entry to authenticate against
    school-service until full auth is wired up.
  - app/stundenplan/_components/KlassenManager.tsx as the working
    prototype for one entity (list / create / delete). Pattern can be
    copied for the other 6 stammdaten + 15 constraint editors.
  - lib/stundenplan/api.ts exposing typed clients for all 22 endpoints
    (7 stammdaten + 15 constraint tables). Constraints use a factory
    to keep the file tight.
  - app/api/school/[...path]/route.ts proxies the browser through
    Next.js to school-service so HTTPS studio-v2 can reach the plain
    HTTP backend.
  - Sidebar.tsx gains a Stundenplan entry with 26-language labels.
  - docker-compose.yml exposes SCHOOL_SERVICE_URL to studio-v2 and
    declares the school-service dependency.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 22:25:18 +02:00
Benjamin Admin 64e7176267 Apply gofmt to timetable.go
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 39s
CI / test-go-edu-search (push) Successful in 29s
CI / test-python-klausur (push) Failing after 2m35s
CI / test-python-agent-core (push) Successful in 19s
CI / test-nodejs-website (push) Successful in 21s
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 22:13:17 +02:00
Benjamin Admin e958f88a2d Add timetable scheduler Phases 1 + 2 to school-service
Phase 1 — Stammdaten (7 tables):
  tt_class, tt_period, tt_room, tt_subject, tt_teacher,
  tt_curriculum, tt_assignment with CRUD endpoints.

Phase 2 — Constraints (15 typed tables):
  Teacher (6): unavailable_day, unavailable_window, max_hours_day,
    max_hours_week, excluded_subject, excluded_room
  Subject (5): min_day_gap, max_consecutive, contiguous_when_repeated,
    preferred_period, double_lesson
  Class (2): max_hours_day, no_gaps
  Room (2): requires_type, unavailable

Each constraint row carries is_hard / weight / active / note /
created_by_user_id; ownership enforced via WHERE EXISTS against the
parent tt_teacher/tt_class/tt_subject/tt_room row.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 22:12:23 +02:00
Benjamin Admin a1488b2fec Fix: enrich non-EN Kaikki search results with translations from EN hub
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 33s
CI / test-go-edu-search (push) Successful in 30s
CI / test-python-klausur (push) Failing after 2m33s
CI / test-python-agent-core (push) Successful in 20s
CI / test-nodejs-website (push) Successful in 24s
When searching for DE/FR/ES/etc. words, the Kaikki entries have empty
translations. Now does a reverse lookup to find the EN entry and copies
its 24-language translations. This ensures wordInNative() works for
all languages, not just the original 7.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-29 18:40:29 +02:00
Benjamin Admin 8d53b1f6b9 Translate Sidebar nav labels into 26 languages
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 28s
CI / test-go-edu-search (push) Successful in 30s
CI / test-python-klausur (push) Failing after 2m22s
CI / test-python-agent-core (push) Successful in 19s
CI / test-nodejs-website (push) Successful in 20s
- Lernmodule, Eltern, Woerterbuch, Meet, KI-Assistent now translated
- Uses NAV_LABELS dict with fallback chain (lang → en → de)
- Fixed "nav_eltern" → shows "Eltern"/"Parents"/"Ebeveyn" etc.
- Fixed "Lernmodule" hardcoded → uses nav_lernmodule key
- Woerterbuch link now points to /vocabulary (not /vocab-worksheet)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-29 18:33:11 +02:00
Benjamin Admin 399ab88f5f Translate language dropdown tabs + add info box in 26 languages
CI / test-go-edu-search (push) Waiting to run
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 40s
CI / test-python-klausur (push) Failing after 2m35s
CI / test-python-agent-core (push) Successful in 18s
CI / test-nodejs-website (push) Successful in 28s
- Tab labels (Muttersprache/Schulsprache) now translate with selected language
  e.g. Turkish: "Ana dilim" / "Okul dili"
- Info box below tabs explains each setting in the user's native language
  e.g. "Evde konustunuz dil. Tum menuler bu dilde gorunecektir."
- Wider dropdown (w-72) to fit translated text
- All 26 European languages covered

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-29 18:23:05 +02:00
Benjamin Admin d52eb43a32 Fix: auto-fill target translation with best match (not only single result)
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 30s
CI / test-go-edu-search (push) Successful in 29s
CI / test-python-klausur (push) Failing after 2m18s
CI / test-python-agent-core (push) Successful in 19s
CI / test-nodejs-website (push) Successful in 22s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-29 18:00:19 +02:00
Benjamin Admin bde0d57b5a Add Schulsprache (school language) as second language setting
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 27s
CI / test-go-edu-search (push) Successful in 28s
CI / test-python-klausur (push) Failing after 2m21s
CI / test-python-agent-core (push) Successful in 18s
CI / test-nodejs-website (push) Successful in 22s
- LanguageContext: new schoolLanguage + setSchoolLanguage
- LanguageDropdown: two tabs (Muttersprache / Schulsprache) with flag selection
- UnitBuilder: defaults target language to schoolLanguage
- Stored in bp_school_language localStorage (default: de)
- Shows school flag badge next to main language when different

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-29 17:49:58 +02:00
Benjamin Admin fc49d87928 Add exercise translations for all 26 European languages
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 32s
CI / test-go-edu-search (push) Successful in 29s
CI / test-python-klausur (push) Failing after 2m26s
CI / test-python-agent-core (push) Successful in 34s
CI / test-nodejs-website (push) Successful in 23s
Buttons (Richtig/Falsch/Weiter), instructions, labels — now translated
for FR, ES, IT, PT, NL, RO, EL, BG, HR, CS, HU, SV, DA, FI, SK, SL, LT, LV, ET.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-29 17:30:35 +02:00
Benjamin Admin 0018076ed5 Unify language system: one setting for all modules
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 41s
CI / test-go-edu-search (push) Successful in 30s
CI / test-python-klausur (push) Failing after 2m29s
CI / test-python-agent-core (push) Successful in 19s
CI / test-nodejs-website (push) Successful in 26s
- Merge two separate language systems (bp_language + bp_native_language) into one
- NativeLanguageContext now reads from LanguageContext (same localStorage key)
- Extend i18n.ts to 26 languages with flags (UI falls back to EN/DE)
- Replace LanguageSwitcher with LanguageDropdown (flags) in learn + parent layouts
- Migration: old bp_native_language value auto-migrates to bp_language
- Onboarding page writes to bp_language (unified key)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-29 16:54:27 +02:00
Benjamin Admin a30f10a467 Widen AudioButton lang prop to string for multi-language support
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 43s
CI / test-go-edu-search (push) Successful in 35s
CI / test-python-klausur (push) Failing after 2m30s
CI / test-python-agent-core (push) Successful in 22s
CI / test-nodejs-website (push) Successful in 26s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-29 16:19:45 +02:00
Benjamin Admin a44d360cbc Fix useRef initial value for React 19 compatibility
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 39s
CI / test-go-edu-search (push) Successful in 31s
CI / test-python-klausur (push) Failing after 2m33s
CI / test-python-agent-core (push) Successful in 22s
CI / test-nodejs-website (push) Successful in 26s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-29 16:04:43 +02:00
Benjamin Admin 52a15b24fe Add custom word entry + language pair support for learning units
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 31s
CI / test-go-edu-search (push) Successful in 31s
CI / test-python-klausur (push) Failing after 2m29s
CI / test-python-agent-core (push) Successful in 24s
CI / test-nodejs-website (push) Successful in 22s
- New UnitBuilder component with language pair selector (DE⇄EN, ES, FR, etc.)
- Manual word entry form with auto-suggest from Kaikki dictionary (6M words)
- "No results" prompt to add multi-word terms (e.g. "schottisches Hochland")
- New backend endpoint GET /vocabulary/lookup-translation (any→any via EN hub)
- Updated POST /vocabulary/units: accepts custom_words + source_lang/target_lang
- Split unit endpoints into vocabulary/unit_api.py (500 LOC budget)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-29 15:24:13 +02:00
Benjamin Admin 855cc4caf4 Fix: make 'Alle zur Unit' button visible — full-width layout below word tags
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 43s
CI / test-go-edu-search (push) Successful in 30s
CI / test-python-klausur (push) Failing after 2m37s
CI / test-python-agent-core (push) Successful in 19s
CI / test-nodejs-website (push) Successful in 26s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-29 13:47:28 +02:00
Benjamin Admin c09fc6c7bc Add 'Alle zur Unit' button + fix topic display
Two buttons on topic cards:
- "Anzeigen": Shows words in search results (for review)
- "Alle zur Unit": Adds all topic words to the unit builder directly

Both buttons load from Kaikki and respect selected language.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-29 13:17:38 +02:00
Benjamin Admin 387219682d Fix: Topic word labels translate to selected language
Topics API now accepts lang= parameter. When lang=de, the word
labels are translated from English via Kaikki translations:
"eye, pupil, iris" → "Auge, Pupille, Iris"

Frontend sends searchLang to /topics endpoint and displays
display_words (translated) instead of words (English).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-29 12:38:35 +02:00
Benjamin Admin 6f43224fda Simplify Sidebar: Remove Woerterbuch, rename to "Lernmodule"
Sidebar: Only "Lernmodule" link (no separate Woerterbuch).
/learn page: "Neue Lernunit erstellen" button links to /vocabulary
for the word selection flow. Teacher stays in one flow.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-29 12:08:13 +02:00
Benjamin Admin 9b96998654 Fix: Topic "Alle laden" always searches in EN (topics are English word lists)
When user selects DE and types "Auge", the topic "Eye/Auge" is found
correctly. But "Alle laden" must search words with lang=en because
the topic word list is English (eye, pupil, iris...).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-29 11:57:18 +02:00
Benjamin Admin 91e8b92bdc Add topic suggestions: search "banana" → suggests "Fruit/Obst" topic
31 curated topics with 683 words (Fruit, Animals, Body, Eye, Sports,
School, Family, Weather, etc.). When user types a word that belongs
to a topic, the topic appears as a suggestion with "Alle laden" button.

Clicking "Alle laden" fetches all words from that topic via Kaikki
and displays them for easy selection into a learning unit.

New endpoint: GET /api/vocabulary/topics?q=banana
New table: vocabulary_topics (topic, words[], word_count)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-29 10:56:36 +02:00
Benjamin Admin c2efb9934c Fix: Vocabulary search sends lang parameter + language dropdown
Search now sends lang= to API (was always defaulting to EN).
Users can select any of the 24 languages in the search bar dropdown.
Placeholder text changes based on selected language.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-29 09:10:00 +02:00
Benjamin Admin 0d2e79da66 Fix: Hardcode Kaikki stats (COUNT on 6M rows took 100s, blocked server)
SELECT COUNT(*) FROM vocabulary_kaikki was 100+ seconds without index,
blocking the entire backend. Hardcoded to 6,271,749 / 24 languages.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-28 22:47:00 +02:00
Benjamin Admin cb4ea8e49a Connect frontend to Kaikki dictionary (6.27M words, 24 languages)
Search endpoint now defaults to source=kaikki, searching the
vocabulary_kaikki table with 6.27M Wiktionary entries.

/filters returns kaikki_total and kaikki_languages count.
/vocabulary header shows "6,271,749 Woerter in 24 Sprachen".

Manual vocabulary_words (27 entries) still accessible via source=manual.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-28 17:49:28 +02:00
Benjamin Admin d14826b199 Fix: Build error + explanation above exercise + aligned columns + impressum link
1. Removed stale 'explanations' export that broke build
2. Explanation banner now spans full width ABOVE the 2/3+1/3 layout
   so exercise cards and native words start at the same height
3. Both columns use items-start for visual alignment
4. Impressum link added at bottom of learn layout

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-28 14:54:12 +02:00
Benjamin Admin 693989c1a6 Add exercise explanations in all 26 languages
exerciseExplanations.ts: Match and Flashcard explanations translated
into DE, EN, TR, AR, UK, RU, PL, FR, ES, IT, PT, NL, RO, EL, BG,
HR, CS, HU, SV, DA, FI, SK, SL, LT, LV, ET.

Each exercise type gets a parent-facing explanation in the user's
selected language. No more German fallback for unsupported languages.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-28 14:14:48 +02:00
Benjamin Admin bd24fa6ba6 Fix: Cast entry to avoid TypeScript 'never' inference
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-28 13:58:09 +02:00
Benjamin Admin ef821831a4 Fix: Fallback to English (not German) for unsupported languages
When a language like PT, FR, IT is selected but has no translation
in exerciseTranslations.ts, the system now falls back to English
instead of German. English is more universally understood.

Applies to: exercise explanations, button labels, instructions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-28 13:37:19 +02:00
Benjamin Admin 93f7ef88e3 Add Impressum with attribution + expand language dropdown to 26 languages
Impressum page (/impressum) with rechtskonform attribution for:
- Wiktionary/Kaikki.org (CC BY-SA 3.0 + GFDL) — dictionary data
- ipa-dict (MIT) — IPA phonetic transcriptions
- Wikimedia Commons (CC BY-SA) — vocabulary images
- Piper TTS (MIT) — speech synthesis
- pyspellchecker (MIT) — spell checking

Language dropdown expanded from 7 to 26 European languages:
DE, EN, TR, AR, UK, RU, PL, FR, ES, IT, PT, NL, RO, EL, BG,
HR, CS, HU, SV, DA, FI, SK, SL, LT, LV, ET.

Dropdown now shows full name: "TR — Turkce", "AR — العربية", etc.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-28 12:49:03 +02:00
Benjamin Admin 6ea20fa1a3 Add persistent volume for ~/Arbeitsblaetter (learning units, QA, audio cache)
lehrer_arbeitsblaetter volume mounted at /root/Arbeitsblaetter.
Survives container restarts — learning units, QA items, translations,
and audio cache are no longer lost on rebuild.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-28 09:11:01 +02:00
Benjamin Admin bf2f7daaeb Fix: Wrap ternary else-branch in Fragment for SelectedImage
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-28 08:37:57 +02:00
Benjamin Admin fc2fe98bd9 Fix: Extract SelectedImage as component (IIFE breaks JSX parser)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-28 08:24:19 +02:00
Benjamin Admin 1a272371f4 Add image preview under selected word in Match exercise
When user clicks an EN word, the corresponding image (Wikipedia
photo or emoji) appears below the match grid. Emoji shown as
large text (6xl), Wikipedia photos as max-h 160px image.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-28 07:52:16 +02:00
Benjamin Admin fdde5d43b3 Fix: Wikipedia User-Agent (was 403 Forbidden)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 23:51:46 +02:00
Benjamin Admin f6caa3091f Add image service: Wikipedia photos + emoji fallback for vocabulary
image_service.py: Fetches thumbnail from Wikipedia REST API (free,
no account). Falls back to emoji for abstract words (40+ mapped).

Auto-enrichment: When a learning unit is created, images are
automatically fetched for all words that don't have one yet.

Manual endpoint: POST /api/vocabulary/enrich-images fills images
for existing words without images.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 23:36:48 +02:00
Benjamin Admin 91d6918e2c Fix: Explanation card visible + visual divider between 2/3 and 1/3
- exerciseType prop for correct explanation lookup (was using title)
- Vertical divider line between work area and native helper
- Cyan-tinted explanation card with lightbulb icon
- Padding between sections (pr-6 / pl-6 around divider)
- Explanation card has distinct background for visibility

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 23:07:57 +02:00
Benjamin Admin 82f5b4fbba Redesign: 2/3 + 1/3 layout for exercises with native helper panel
ExerciseLayout.tsx: Reusable layout component for all exercises.
- Left 2/3: Standard exercise area (EN + DE)
- Right 1/3: Native language helper (explanation + word list)
- Only shows right panel for non-DE/EN speakers
- Explanation card describes what the child should do
- Column headers are trilingual (TR · English · Deutsch)

Match page rebuilt using ExerciseLayout:
- EN+DE cards in 2/3 left area with equal height + audio
- Native words in 1/3 right panel with audio buttons
- Highlights native word when EN word is selected
- Progress bar with count, score counter

ExerciseLayout can be reused for flashcards, quiz, type, etc.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 22:30:07 +02:00
Benjamin Admin afe7a983d1 Fix Match: Equal card heights + progress bar with count
All cards (EN/DE/native) now have min-h-[48px] for consistent height.
Progress bar shows "4/12" count next to the fill bar.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 19:26:53 +02:00
Benjamin Admin 6d54ee8178 Fix Match: Progress bar + counter, audio on EN column too
- Progress bar under header (fills as pairs are matched)
- Counter with symbols: ✓ first-try, ↻ retry, ✗ errors
- EN column now also has audio buttons (small speaker icon)
- All 3 columns have consistent height (flex layout)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 18:35:00 +02:00
Benjamin Admin a1664ab12c Redesign Match exercise: 3 columns, audio, scoring, native language
Major improvements:
1. Third column shows native language translation (TR/AR/UK/RU)
2. Clicking EN word flashes native translation briefly (2s overlay)
3. German column has audio button on each word (speaker icon)
4. Native column has audio button for each translation
5. Scoring: tracks first-try correct vs retry vs errors separately
6. Full points only for error-free completion
7. "Nochmal" button always available to repeat the unit
8. Header shows live score: green/yellow/red counters
9. All buttons use translation system (t('back'), t('match'), etc.)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 18:03:35 +02:00
Benjamin Admin 9f21bd070a Fix: Language switch takes effect immediately (React Context)
Replaced localStorage-only hook with React Context Provider.
Layout and page components now share the same state — switching
language in the dropdown instantly updates all text on screen
without requiring a page reload.

NativeLanguageProvider added to root layout.tsx.
useNativeLanguage() re-exported from Context for backward compat.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 14:45:53 +02:00
Benjamin Admin 5012699aaf Add persistent language switcher across all learn/parent pages
useNativeLanguage: Now has setNativeLang() that persists to localStorage.
Language selection carries across all pages automatically.

LanguageSwitcher: Compact dropdown component added to learn/layout.tsx
and parent/layout.tsx — visible on every sub-page (top-right).

Parent portal: Language dropdown syncs both UI language and native
language. Parents can switch language mid-session (e.g. when both
parents speak different languages).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 09:23:01 +02:00
Benjamin Admin d8771bb509 Make /learn the central landing page for parents + students
Non-DE/EN users (parents) see a guide panel explaining:
- What this page is (vocabulary exercises from teacher)
- How each exercise type works (cards, quiz, listen, speak)
- All text in parent's native language (TR/AR/UK/RU/PL)

German/English users see the original layout without the guide.
This is the single entry point — no need to duplicate explanations
in every exercise sub-page.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 08:46:58 +02:00
Benjamin Admin 7f8743d1e3 Redesign Parent Quiz: explanation panel + trilingual buttons
Major improvements for non-DE/EN speaking parents:

1. Right panel: Explains in parent's native language what this
   exercise is about and how it works (TR/AR/UK/RU/PL translations)

2. Trilingual buttons: "Dogru" (primary) with "Richtig / Correct"
   subtitle so parents understand even if language detection is wrong

3. Native word shown prominently: "= elma" next to "apple"

4. Audio buttons labeled EN/DE/TR with language codes

5. Answer card shows all 3 languages: English, Deutsch, native

6. Progress tracker in the explanation panel

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 08:16:30 +02:00
Benjamin Admin 9de26701dd Fix: Remove duplicate Sidebar from /learn (layout.tsx provides it)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 07:54:53 +02:00
Benjamin Admin c252556528 Fix: Quiz fallback to QA data when MC not generated
Vocab units created via /vocabulary/units only have QA items, no
pre-generated MC questions. Quiz now falls back to generating MC
questions client-side from QA items (EN word → 4 DE options).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 07:43:16 +02:00
Benjamin Admin 68d1679294 Wire all 7 learn pages to central translation system
All exercise pages now use useNativeLanguage() hook:
- Buttons show text in user's native language (Richtig→Dogru, etc.)
- Instructions translated (Geschafft→Bitti, Nochmal→Tekrar, etc.)
- wordInNative() available for vocab translations (needs data)

Pages updated: flashcards, quiz, type, listen, match, pronounce, story.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 00:23:38 +02:00
Benjamin Admin 9e63b09cb7 Wire Parent Quiz to central translation system + native language audio
Uses useNativeLanguage() hook for all UI text. Shows native language
translation of vocab word + audio button for native pronunciation.
Removed inline pt translations dict (now in exerciseTranslations.ts).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 00:22:39 +02:00
Benjamin Admin bd3ca854ef Add central exercise translation system (7 languages, 30+ keys)
useNativeLanguage.ts: Hook that reads bp_native_language from
localStorage and provides t(key) for translated UI text and
wordInNative() for vocabulary translations.

exerciseTranslations.ts: All exercise UI strings in DE/EN/TR/AR/UK/RU/PL.
Buttons (Richtig/Falsch), instructions, labels, result texts.

Next: Wire into all 9 exercise pages for trilingual display.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 00:18:12 +02:00
Benjamin Admin b495e63e6f Add TTS abbreviation expansion (sth→something, sb→somebody, etc.)
Text is preprocessed before TTS to expand abbreviations like
sth., sb., etc., z.B., usw. so the speaker says the full word.

40+ abbreviations covered (EN + DE). Applied to all languages.
Audio cache cleared to regenerate with correct pronunciation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 00:07:46 +02:00
Benjamin Admin 198a0b2a0d Fix: Use /synthesize-direct for correct language selection
/synthesize always used the German model. /synthesize-direct uses
Edge TTS (with language-aware voice selection) and falls back to
Piper with the correct model (Thorsten DE / Lessac EN).

Also cleared audio cache to purge wrongly-generated files.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-26 23:46:40 +02:00
Benjamin Admin 6b3bff48f0 Fix: Proxy uses arrayBuffer for audio/image responses (not text)
Binary data (MP3 audio) was corrupted by resp.text(). Now detects
content-type and uses arrayBuffer() for audio/* and image/* responses.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-26 23:33:57 +02:00
Benjamin Admin 0f0bbc3dc0 Switch AudioButton to Piper TTS (Thorsten/Lessac voices)
AudioButton now tries Piper TTS via /api/vocabulary/tts endpoint
first, falls back to Browser Web Speech API if unavailable.

Backend: New GET /api/vocabulary/tts?text=...&lang=de endpoint.
audio_service.py: Fixed presigned URL flow for MinIO download.

This gives the same high-quality voice as the Investor Agent
in the pitch deck (Thorsten DE / Lessac EN, MIT license).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-26 23:17:39 +02:00
Benjamin Admin 3cdab5a967 Fix: Hide sidebar scrollbar
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-26 22:57:00 +02:00
Benjamin Admin f2300219d7 Fix: Hide scrollbar on content area (scroll still works)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-26 21:23:14 +02:00
Benjamin Admin aaa52a8901 Fix: Remove LearnLayout from parent/quiz — layout.tsx handles it
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-26 21:08:04 +02:00
Benjamin Admin 1fb6702bf4 Fix: Replace extra </div> with </> for fragment closing
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-26 20:58:02 +02:00
Benjamin Admin 6210ceb05e Add central layout.tsx for /learn/* and /parent/* routes
Next.js route-level layouts provide Sidebar + gradient background
automatically for all sub-pages. Individual pages no longer need
their own wrapper divs or Sidebar imports.

- learn/layout.tsx: Sidebar + purple gradient for all learning pages
- parent/layout.tsx: Same for all parent portal pages
- LearnLayout.tsx: Reusable component for other pages
- Fixed broken <LearnLayout>}> artifacts from previous refactoring
- Removed duplicate Sidebar/wrapper code from 9 sub-pages

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-26 20:44:40 +02:00
Benjamin Admin 3619ddfdad Fix: destructure setLanguage from useLanguage
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-26 20:15:10 +02:00
Benjamin Admin f2346b88cd Fix: Parent portal language selector as dropdown instead of onboarding redirect
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-26 20:08:07 +02:00
Benjamin Admin eecb5472dd Fix: Update all old-style imports inside packages to new paths
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 1m7s
CI / test-go-edu-search (push) Successful in 46s
CI / test-python-klausur (push) Failing after 2m32s
CI / test-python-agent-core (push) Successful in 33s
CI / test-nodejs-website (push) Successful in 34s
65 files in klausur-service packages + 3 in backend-lehrer packages
had stale imports referencing deleted shim modules.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-26 00:19:13 +02:00
Benjamin Admin 5f2ed44654 Cleanup: Delete ALL 242 shims, update ALL consumer imports
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 41s
CI / test-go-edu-search (push) Successful in 32s
CI / test-python-klausur (push) Failing after 2m41s
CI / test-python-agent-core (push) Successful in 34s
CI / test-nodejs-website (push) Successful in 39s
klausur-service: 183 shims deleted, 26 test files + 8 source files updated
backend-lehrer: 59 shims deleted, main.py + 8 source files updated

All imports now use the new package paths directly.
Zero shims remaining in the entire codebase.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-26 00:11:33 +02:00
Benjamin Admin d093a4d388 Restructure: Move final 12 root files into packages (klausur-service)
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 28s
CI / test-go-edu-search (push) Successful in 28s
CI / test-python-klausur (push) Failing after 2m23s
CI / test-python-agent-core (push) Successful in 19s
CI / test-nodejs-website (push) Successful in 19s
ocr/spell/  (3): smart_spell, core, text
upload/     (3): api, chunked, mobile
crawler/    (3): github, github_core, github_parsers
+ unified_grid → grid/, tesseract_extractor → ocr/engines/, htr_api → ocr/pipeline/

12 shims added. Only main.py, config.py, storage + RAG files remain at root.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 23:19:11 +02:00
Benjamin Admin cba877c65a Restructure: Move final 16 root files into packages (backend-lehrer)
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 35s
CI / test-python-klausur (push) Failing after 2m41s
CI / test-python-agent-core (push) Successful in 30s
CI / test-nodejs-website (push) Successful in 38s
classroom/ (+2): state_engine_api, state_engine_models
vocabulary/ (2): api, db
worksheets/ (2): api, models
services/  (+6): audio, email, translation, claude_vision, ai_processor, story_generator
api/        (4): school, klausur_proxy, progress, user_language

Only main.py + config.py remain at root. 16 shims added.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 22:50:37 +02:00
Benjamin Admin 6be555fb7c Add shim cleanup tracker for future import migration
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 22:38:13 +02:00
519 changed files with 25749 additions and 2015 deletions
+29
View File
@@ -243,6 +243,35 @@ ssh macmini "cd /Users/benjaminadmin/Projekte/breakpilot-lehrer && git push all
---
## Stundenplan + Schulkalender (Mai 2026, alle Phasen deployed)
Zwei groesse Feature-Strange, vollstaendig live auf Mac Mini:
| Pfad | Beschreibung |
|------|--------------|
| `/stundenplan` (studio-v2) | Lehrer-UI mit 9 Tabs (Plan + 7 Stammdaten + Regeln), 15 Constraint-Editoren, Pin/Unpin im Wochengrid |
| `/schulkalender` (studio-v2) | Bundesland-Wizard, Monatsansicht mit Ferien (16 BL × 3 Jahre), Schul-Events, Schuljahres-Rollover, Eltern-Manager |
| `/eltern` (studio-v2) | Eltern-Sicht: Wochengrid des eigenen Kindes in Eltern-Sprache, Magic-Link-Login |
| `school-service` (Go, :8084) | Beide Backends — 30+ Tabellen, JWT-Auth (Dev-Bypass aktiv), Cron fuer Notifications |
| `timetable-solver-service` (Python+JVM, :8095) | Timefold-basierter Solver, 14 Constraints implementiert |
**Wichtigste Memo-Dateien fuer Wiedereinstieg:**
- `~/.claude/projects/-Users-benjaminadmin-Projekte-breakpilot-lehrer/memory/session_summary_2026_05_22.md` — vollstaendiges Inventar
- `~/.claude/projects/-Users-benjaminadmin/memory/project_timetable_scheduler.md` — Stundenplan-Status
- `~/.claude/projects/-Users-benjaminadmin-Projekte-breakpilot-lehrer/memory/project_schulkalender.md` — Schulkalender-Status
**Pitfalls (vermeidet diese):**
- Timefold Python-Package heisst `timefold` (NICHT `timefold-solver`), v1.24.0b0
- Production-Auth + Matrix/Email-Services baut Kollege — Frontend-Hooks nutzen, kein eigener Service-Code
- JSX-Attribute mit deutschen Quotes `„X"` brechen, Loesung: `description={"..."}` Expression-Form
- LOC-Budget 500 pro File — bei specs mit shared Helpers arbeiten (`e2e/_helpers.ts`)
**Test-Status (Stand 2026-05-22):** 89 Go + 21 Playwright im Schulkalender + 42 Playwright im Stundenplan = **152 grun**
**Offen:** Seed-Daten fuer Demo-Schule, Vollschuljahr-ICS mit RRULE+EXDATE, Untis-Import (Phase 4 geparkt).
---
## Wichtige Dateien (Referenz)
| Datei | Beschreibung |
+70
View File
@@ -0,0 +1,70 @@
# Shim Cleanup Tracker
**Status:** Shims aktiv — Consumer-Imports noch auf alten Pfaden
**Erstellt:** 2026-04-25
**Ziel:** Shims schrittweise loeschen sobald Consumer auf neue Pfade aktualisiert sind
## Was sind Shims?
Beim Restructuring wurden Dateien in Packages verschoben (z.B. `cv_layout.py``ocr/layout/layout.py`). Am alten Pfad bleibt ein 4-Zeilen Redirect:
```python
# cv_layout.py (shim)
import importlib as _importlib
import sys as _sys
_sys.modules[__name__] = _importlib.import_module("ocr.layout.layout")
```
Damit brechen keine bestehenden `from cv_layout import ...` Imports.
## Cleanup-Prozess (pro Shim)
1. `grep -rn "from <old_module> import\|import <old_module>" --include="*.py"` — finde alle Consumer
2. Consumer-Imports auf neuen Pfad aktualisieren (z.B. `from ocr.layout.layout import ...`)
3. Shim-Datei loeschen
4. Tests ausfuehren
## Shim-Inventar
### klausur-service/backend/ (171 Shims)
| Gruppe | Anzahl | Alter Pfad | Neuer Pfad |
|--------|--------|------------|------------|
| cv_* (OCR Core) | 47 | `cv_layout.py` etc. | `ocr/layout/layout.py` etc. |
| ocr_pipeline_* | 30 | `ocr_pipeline_api.py` etc. | `ocr/pipeline/api.py` etc. |
| ocr_labeling_* | 5 | `ocr_labeling_api.py` etc. | `ocr/labeling/api.py` etc. |
| ocr related | 11 | `page_crop.py` etc. | `ocr/pipeline/page_crop.py` etc. |
| grid_* | 16 | `grid_build_core.py` etc. | `grid/build/core.py` etc. |
| vocab_* | 10 | `vocab_worksheet_api.py` etc. | `vocab/worksheet/api.py` etc. |
| korrektur | 11 | `eh_templates.py` etc. | `korrektur/eh_templates.py` etc. |
| zeugnis_* | 10 | `zeugnis_api.py` etc. | `zeugnis/api.py` etc. |
| admin_* | 4 | `admin_api.py` etc. | `admin/api.py` etc. |
| compliance/rbac | 8 | `rbac.py` etc. | `compliance/rbac.py` etc. |
| worksheet/nru | 9 | `worksheet_editor_api.py` etc. | `worksheet/editor_api.py` etc. |
| training_* | 6 | `training_api.py` etc. | `training/api.py` etc. |
| metrics_* | 4 | `metrics_db.py` etc. | `metrics/db.py` etc. |
### backend-lehrer/ (43 Shims)
| Gruppe | Anzahl | Alter Pfad | Neuer Pfad |
|--------|--------|------------|------------|
| abitur_docs_* | 3 | `abitur_docs_api.py` etc. | `abitur/api.py` etc. |
| correction_* | 4 | `correction_api.py` etc. | `correction/api.py` etc. |
| messenger_* | 5 | `messenger_api.py` etc. | `messenger/api.py` etc. |
| recording_* | 6 | `recording_api.py` etc. | `recording/api.py` etc. |
| unit_* + learning_* | 13 | `unit_api.py` etc. | `units/api.py` etc. |
| teacher_dashboard_* | 3 | `teacher_dashboard_api.py` etc. | `dashboard/api.py` etc. |
| game_* | 5 | `game_api.py` etc. | `game/api.py` etc. |
| letters/certificates | 4 | `letters_api.py` etc. | `letters/api.py` etc. |
## Prioritaet
1. **Hoch:** Shims die von `main.py` importiert werden (Router-Registrierung)
2. **Mittel:** Shims die von anderen Modulen importiert werden
3. **Niedrig:** Shims die nur von Tests importiert werden
## Wann loeschen?
- Bei der naechsten groesseren Aenderung an einem Modul → gleich die Consumer-Imports mit aktualisieren
- Oder als dedizierte Cleanup-Session wenn alle Tests gruen sind
- NICHT alle auf einmal — Modul fuer Modul vorgehen
-4
View File
@@ -1,4 +0,0 @@
# Backward-compat shim -- module moved to abitur/api.py
import importlib as _importlib
import sys as _sys
_sys.modules[__name__] = _importlib.import_module("abitur.api")
-4
View File
@@ -1,4 +0,0 @@
# Backward-compat shim -- module moved to abitur/models.py
import importlib as _importlib
import sys as _sys
_sys.modules[__name__] = _importlib.import_module("abitur.models")
@@ -1,4 +0,0 @@
# Backward-compat shim -- module moved to abitur/recognition.py
import importlib as _importlib
import sys as _sys
_sys.modules[__name__] = _importlib.import_module("abitur.recognition")
+1 -1
View File
@@ -158,7 +158,7 @@ def _analyze_with_openai(input_path: Path) -> Path:
def _analyze_with_claude(input_path: Path) -> Path:
"""Strukturierte JSON-Analyse mit Claude Vision API."""
from claude_vision import analyze_worksheet_with_claude
from services.claude_vision import analyze_worksheet_with_claude
if not input_path.exists():
raise FileNotFoundError(f"Eingabedatei nicht gefunden: {input_path}")
+1 -1
View File
@@ -8,7 +8,7 @@ A modular AI-powered worksheet processing system for:
- Mindmap visualization
Usage:
from ai_processor import analyze_scan_structure_with_ai, generate_mc_from_analysis
from services.ai_processor import analyze_scan_structure_with_ai, generate_mc_from_analysis
"""
# Configuration
@@ -157,7 +157,7 @@ def _analyze_with_claude(input_path: Path) -> Path:
Uses Claude 3.5 Sonnet for better OCR and layout detection.
"""
from claude_vision import analyze_worksheet_with_claude
from services.claude_vision import analyze_worksheet_with_claude
if not input_path.exists():
raise FileNotFoundError(f"Eingabedatei nicht gefunden: {input_path}")
+6
View File
@@ -0,0 +1,6 @@
# API Module — thin proxy routers and standalone API endpoints
#
# api/school.py — Proxy to Go school-service
# api/klausur_proxy.py — Proxy to klausur-service
# api/progress.py — Student learning progress tracking
# api/user_language.py — User language preferences
-4
View File
@@ -1,4 +0,0 @@
# Backward-compat shim -- module moved to letters/certificates_api.py
import importlib as _importlib
import sys as _sys
_sys.modules[__name__] = _importlib.import_module("letters.certificates_api")
-4
View File
@@ -1,4 +0,0 @@
# Backward-compat shim -- module moved to letters/certificates_models.py
import importlib as _importlib
import sys as _sys
_sys.modules[__name__] = _importlib.import_module("letters.certificates_models")
@@ -24,7 +24,7 @@ from state_engine import (
Event,
get_phase_info,
)
from state_engine_models import (
from .state_engine_models import (
MilestoneRequest,
TransitionRequest,
ContextResponse,
-4
View File
@@ -1,4 +0,0 @@
# Backward-compat shim -- module moved to correction/api.py
import importlib as _importlib
import sys as _sys
_sys.modules[__name__] = _importlib.import_module("correction.api")
-4
View File
@@ -1,4 +0,0 @@
# Backward-compat shim -- module moved to correction/endpoints.py
import importlib as _importlib
import sys as _sys
_sys.modules[__name__] = _importlib.import_module("correction.endpoints")
-4
View File
@@ -1,4 +0,0 @@
# Backward-compat shim -- module moved to correction/helpers.py
import importlib as _importlib
import sys as _sys
_sys.modules[__name__] = _importlib.import_module("correction.helpers")
-4
View File
@@ -1,4 +0,0 @@
# Backward-compat shim -- module moved to correction/models.py
import importlib as _importlib
import sys as _sys
_sys.modules[__name__] = _importlib.import_module("correction.models")
+1 -1
View File
@@ -7,7 +7,7 @@
# - game_extended_routes.py (Phase 5: achievements, progress, parent, class)
#
# The `router` object is assembled here by including all sub-routers.
# Importers that did `from game_api import router` continue to work.
# Importers that did `from game.api import router` continue to work.
from fastapi import APIRouter
-4
View File
@@ -1,4 +0,0 @@
# Backward-compat shim -- module moved to game/api.py
import importlib as _importlib
import sys as _sys
_sys.modules[__name__] = _importlib.import_module("game.api")
-4
View File
@@ -1,4 +0,0 @@
# Backward-compat shim -- module moved to game/extended_routes.py
import importlib as _importlib
import sys as _sys
_sys.modules[__name__] = _importlib.import_module("game.extended_routes")
-4
View File
@@ -1,4 +0,0 @@
# Backward-compat shim -- module moved to game/game_models.py
import importlib as _importlib
import sys as _sys
_sys.modules[__name__] = _importlib.import_module("game.game_models")
-4
View File
@@ -1,4 +0,0 @@
# Backward-compat shim -- module moved to game/routes.py
import importlib as _importlib
import sys as _sys
_sys.modules[__name__] = _importlib.import_module("game.routes")
-4
View File
@@ -1,4 +0,0 @@
# Backward-compat shim -- module moved to game/session_routes.py
import importlib as _importlib
import sys as _sys
_sys.modules[__name__] = _importlib.import_module("game.session_routes")
-4
View File
@@ -1,4 +0,0 @@
# Backward-compat shim -- module moved to units/learning.py
import importlib as _importlib
import sys as _sys
_sys.modules[__name__] = _importlib.import_module("units.learning")
-4
View File
@@ -1,4 +0,0 @@
# Backward-compat shim -- module moved to units/learning_api.py
import importlib as _importlib
import sys as _sys
_sys.modules[__name__] = _importlib.import_module("units.learning_api")
-4
View File
@@ -1,4 +0,0 @@
# Backward-compat shim -- module moved to letters/api.py
import importlib as _importlib
import sys as _sys
_sys.modules[__name__] = _importlib.import_module("letters.api")
-4
View File
@@ -1,4 +0,0 @@
# Backward-compat shim -- module moved to letters/models.py
import importlib as _importlib
import sys as _sys
_sys.modules[__name__] = _importlib.import_module("letters.models")
+23 -19
View File
@@ -50,7 +50,7 @@ async def lifespan(app: FastAPI):
logger.info("Backend-Lehrer starting up (DB search_path=lehrer,core,public)")
# Initialize vocabulary tables
try:
from vocabulary_db import init_vocabulary_tables
from vocabulary.db import init_vocabulary_tables
await init_vocabulary_tables()
except Exception as e:
logger.warning(f"Vocabulary tables init failed (non-critical): {e}")
@@ -97,70 +97,74 @@ from classroom_api import router as classroom_router
app.include_router(classroom_router, prefix="/api/classroom")
# --- 2. State Engine (Begleiter-Modus mit Phasen und Antizipation) ---
from state_engine_api import router as state_engine_router
from classroom.state_engine_api import router as state_engine_router
app.include_router(state_engine_router, prefix="/api")
# --- 3. Worksheets & Corrections ---
from worksheets_api import router as worksheets_router
from worksheets.api import router as worksheets_router
app.include_router(worksheets_router, prefix="/api")
from correction_api import router as correction_router
from correction.api import router as correction_router
app.include_router(correction_router, prefix="/api")
# --- 4. Learning Units ---
from learning_units_api import router as learning_units_router
from units.learning_api import router as learning_units_router
app.include_router(learning_units_router, prefix="/api")
# --- 4b. Learning Progress ---
from progress_api import router as progress_router
from api.progress import router as progress_router
app.include_router(progress_router, prefix="/api")
# --- 4c. Vocabulary Catalog ---
from vocabulary_api import router as vocabulary_router
from vocabulary.api import router as vocabulary_router
app.include_router(vocabulary_router, prefix="/api")
# --- 4c2. Vocabulary Unit Creation + Translation ---
from vocabulary.unit_api import router as vocab_unit_router
app.include_router(vocab_unit_router, prefix="/api")
# --- 4d. User Language Preferences ---
from user_language_api import router as user_language_router
from api.user_language import router as user_language_router
app.include_router(user_language_router, prefix="/api")
from unit_api import router as unit_router
from units.api import router as unit_router
app.include_router(unit_router) # Already has /api/units prefix
from unit_analytics_api import router as unit_analytics_router
from units.analytics_api import router as unit_analytics_router
app.include_router(unit_analytics_router) # Already has /api/analytics prefix
from recording_api import router as recording_api_router
from recording.api import router as recording_api_router
app.include_router(recording_api_router) # Already has /api/recordings prefix
# --- 6. Messenger ---
from messenger_api import router as messenger_router
from messenger.api import router as messenger_router
app.include_router(messenger_router) # Already has /api/messenger prefix
# --- 7. Klausur & School Proxies ---
from klausur_service_proxy import router as klausur_service_router
from api.klausur_proxy import router as klausur_service_router
app.include_router(klausur_service_router, prefix="/api")
from school_api import router as school_api_router
from api.school import router as school_api_router
app.include_router(school_api_router, prefix="/api")
# --- 8. Teacher Dashboard & Abitur Docs ---
from abitur_docs_api import router as abitur_docs_router
from abitur.api import router as abitur_docs_router
app.include_router(abitur_docs_router, prefix="/api")
from teacher_dashboard_api import router as teacher_dashboard_router
from dashboard.api import router as teacher_dashboard_router
app.include_router(teacher_dashboard_router) # Already has /api/teacher prefix
# --- 9. Certificates & Letters ---
from certificates_api import router as certificates_router
from letters.certificates_api import router as certificates_router
app.include_router(certificates_router, prefix="/api")
from letters_api import router as letters_router
from letters.api import router as letters_router
app.include_router(letters_router, prefix="/api")
# --- 10. Game System ---
from game_api import router as game_router
from game.api import router as game_router
app.include_router(game_router) # Already has /api/game prefix
# --- 11. AI Processor (OCR + Content generation) ---
+1 -1
View File
@@ -266,7 +266,7 @@ async def send_message(conversation_id: str, message: MessageBase):
if contact and contact.get("email"):
try:
from email_service import email_service
from services.email import email_service
result = email_service.send_messenger_notification(
to_email=contact["email"],
-4
View File
@@ -1,4 +0,0 @@
# Backward-compat shim -- module moved to messenger/api.py
import importlib as _importlib
import sys as _sys
_sys.modules[__name__] = _importlib.import_module("messenger.api")
-4
View File
@@ -1,4 +0,0 @@
# Backward-compat shim -- module moved to messenger/contacts.py
import importlib as _importlib
import sys as _sys
_sys.modules[__name__] = _importlib.import_module("messenger.contacts")
@@ -1,4 +0,0 @@
# Backward-compat shim -- module moved to messenger/conversations.py
import importlib as _importlib
import sys as _sys
_sys.modules[__name__] = _importlib.import_module("messenger.conversations")
-4
View File
@@ -1,4 +0,0 @@
# Backward-compat shim -- module moved to messenger/helpers.py
import importlib as _importlib
import sys as _sys
_sys.modules[__name__] = _importlib.import_module("messenger.helpers")
-4
View File
@@ -1,4 +0,0 @@
# Backward-compat shim -- module moved to messenger/models.py
import importlib as _importlib
import sys as _sys
_sys.modules[__name__] = _importlib.import_module("messenger.models")
-4
View File
@@ -1,4 +0,0 @@
# Backward-compat shim -- module moved to recording/api.py
import importlib as _importlib
import sys as _sys
_sys.modules[__name__] = _importlib.import_module("recording.api")
-4
View File
@@ -1,4 +0,0 @@
# Backward-compat shim -- module moved to recording/helpers.py
import importlib as _importlib
import sys as _sys
_sys.modules[__name__] = _importlib.import_module("recording.helpers")
-4
View File
@@ -1,4 +0,0 @@
# Backward-compat shim -- module moved to recording/minutes.py
import importlib as _importlib
import sys as _sys
_sys.modules[__name__] = _importlib.import_module("recording.minutes")
-4
View File
@@ -1,4 +0,0 @@
# Backward-compat shim -- module moved to recording/models.py
import importlib as _importlib
import sys as _sys
_sys.modules[__name__] = _importlib.import_module("recording.models")
-4
View File
@@ -1,4 +0,0 @@
# Backward-compat shim -- module moved to recording/routes.py
import importlib as _importlib
import sys as _sys
_sys.modules[__name__] = _importlib.import_module("recording.routes")
@@ -1,4 +0,0 @@
# Backward-compat shim -- module moved to recording/transcription.py
import importlib as _importlib
import sys as _sys
_sys.modules[__name__] = _importlib.import_module("recording.transcription")
+8
View File
@@ -19,4 +19,12 @@ except (ImportError, OSError) as e:
FileProcessor = None # type: ignore
_file_processor_available = False
# Lazy-loaded service modules (imported on demand to avoid heavy deps at startup):
# .audio — TTS audio generation for vocabulary words
# .email — Email/SMTP service
# .translation — Batch vocabulary translation via Ollama
# .claude_vision — Claude Vision API for worksheet analysis
# .ai_processor — Legacy shim for ai_processor/ package
# .story_generator — Story generation from vocabulary words
__all__ = ["PDFService", "FileProcessor"]
@@ -5,14 +5,14 @@ This file provides backward compatibility for code that imports from ai_processo
All functionality has been moved to the ai_processor/ module.
Usage (new):
from ai_processor import analyze_scan_structure_with_ai
from services.ai_processor import analyze_scan_structure_with_ai
Usage (legacy, still works):
from ai_processor import analyze_scan_structure_with_ai
from services.ai_processor import analyze_scan_structure_with_ai
"""
# Re-export everything from the new modular structure
from ai_processor import (
from services.ai_processor import (
# Configuration
BASE_DIR,
EINGANG_DIR,
@@ -46,7 +46,7 @@ from ai_processor import (
)
# Legacy function alias
from ai_processor import get_openai_api_key as _get_api_key
from services.ai_processor import get_openai_api_key as _get_api_key
__all__ = [
# Configuration
@@ -23,6 +23,53 @@ TTS_SERVICE_URL = os.getenv("TTS_SERVICE_URL", "http://bp-compliance-tts:8095")
# Local cache directory for generated audio
AUDIO_CACHE_DIR = os.path.expanduser("~/Arbeitsblaetter/audio-cache")
# Abbreviations expanded before TTS (so the speaker says the full word)
_TTS_EXPANSIONS = {
"sth.": "something",
"sth": "something",
"sb.": "somebody",
"sb": "somebody",
"smth.": "something",
"smb.": "somebody",
"sbd.": "somebody",
"etc.": "etcetera",
"e.g.": "for example",
"i.e.": "that is",
"esp.": "especially",
"approx.": "approximately",
"vs.": "versus",
"nr.": "number",
"no.": "number",
"p.": "page",
"adj.": "adjective",
"adv.": "adverb",
"prep.": "preposition",
"pron.": "pronoun",
"pl.": "plural",
"sg.": "singular",
"syn.": "synonym",
"ant.": "antonym",
# DE
"usw.": "und so weiter",
"bzw.": "beziehungsweise",
"z.B.": "zum Beispiel",
"d.h.": "das heisst",
"vgl.": "vergleiche",
"ca.": "circa",
"evtl.": "eventuell",
"ggf.": "gegebenenfalls",
}
def _expand_abbreviations(text: str) -> str:
"""Expand abbreviations so TTS speaks the full word."""
import re
for abbr, full in _TTS_EXPANSIONS.items():
# Word-boundary aware replacement (case-insensitive)
pattern = re.escape(abbr)
text = re.sub(rf'\b{pattern}', full, text, flags=re.IGNORECASE)
return text
def _ensure_cache_dir():
os.makedirs(AUDIO_CACHE_DIR, exist_ok=True)
@@ -56,48 +103,17 @@ async def synthesize_word(
if os.path.exists(cached):
return cached
# Call Piper TTS service
# Expand abbreviations before speaking
speak_text = _expand_abbreviations(text)
# Call Piper TTS service via /synthesize-direct (returns MP3, selects language correctly)
try:
async with httpx.AsyncClient(timeout=30.0) as client:
resp = await client.post(
f"{TTS_SERVICE_URL}/synthesize",
f"{TTS_SERVICE_URL}/synthesize-direct",
json={
"text": text,
"text": speak_text,
"language": language,
"voice": "thorsten-high" if language == "de" else "lessac-high",
"module_id": "vocabulary",
"content_id": word_id or _cache_key(text, language),
},
)
if resp.status_code != 200:
logger.warning(f"TTS service returned {resp.status_code} for '{text}'")
return None
data = resp.json()
audio_url = data.get("audio_url") or data.get("presigned_url")
if audio_url:
# Download the audio file
audio_resp = await client.get(audio_url)
if audio_resp.status_code == 200:
with open(cached, "wb") as f:
f.write(audio_resp.content)
logger.info(f"TTS cached: '{text}' ({language}) → {cached}")
return cached
except Exception as e:
logger.warning(f"TTS service unavailable: {e}")
# Fallback: try direct MP3 endpoint
try:
async with httpx.AsyncClient(timeout=30.0) as client:
resp = await client.post(
f"{TTS_SERVICE_URL}/synthesize/mp3",
json={
"text": text,
"language": language,
"voice": "thorsten-high" if language == "de" else "lessac-high",
"module_id": "vocabulary",
},
)
if resp.status_code == 200 and resp.headers.get("content-type", "").startswith("audio"):
+116
View File
@@ -0,0 +1,116 @@
"""
Image Service — Fetches vocabulary images from Wikipedia + Emoji fallback.
On-demand: Images are fetched when a learning unit is created,
then cached in the vocabulary_words.image_url field.
Sources (in priority order):
1. Wikipedia REST API (free, no account needed, CC license)
2. Emoji fallback for abstract words
Later: Unsplash API (needs account), Stable Diffusion (local batch)
"""
import logging
import os
from typing import Optional
import httpx
logger = logging.getLogger(__name__)
# Emoji map for common abstract words that don't have good photos
EMOJI_FALLBACK: dict[str, str] = {
"strong": "💪", "weak": "😩", "hard-working": "📚", "skinny": "🦴",
"female": "👩", "male": "👨", "definite": "", "definitely": "",
"even": "⚖️", "violent": "", "opinion": "💭", "message": "💬",
"beginning": "🏁", "mention": "🗣️", "summarize": "📋", "mark": "✏️",
"throw": "🤾", "take": "🤲", "sum": "", "on the one hand": "👐",
"apple": "🍎", "gym": "🏋️", "medal": "🏅", "sportswoman": "🏃‍♀️",
"role model": "", "tourist office": "🏨", "the olympics": "🏅",
"box": "🥊", "football": "", "footballer": "",
}
async def fetch_wikipedia_image(word: str) -> Optional[str]:
"""Fetch thumbnail image URL from Wikipedia for a word."""
# Clean word for Wikipedia lookup
query = word.split(",")[0].strip() # "throw, threw, thrown" → "throw"
query = query.replace("sth.", "").replace("sb.", "").strip()
if query.startswith("the "):
query = query[4:]
try:
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.get(
f"https://en.wikipedia.org/api/rest_v1/page/summary/{query}",
headers={"User-Agent": "BreakPilot/1.0 (https://breakpilot.com; education platform; contact@breakpilot.com)"},
follow_redirects=True,
)
if resp.status_code == 200:
data = resp.json()
thumb = data.get("thumbnail", {})
url = thumb.get("source")
if url:
logger.info(f"Wikipedia image for '{word}': {url}")
return url
except Exception as e:
logger.debug(f"Wikipedia image lookup failed for '{word}': {e}")
return None
def get_emoji_for_word(word: str) -> str:
"""Get an emoji representation for a word."""
lower = word.lower()
for key, emoji in EMOJI_FALLBACK.items():
if key in lower:
return emoji
# Generic fallback by part of speech could be added here
return "📝"
async def get_image_for_word(word: str) -> str:
"""Get the best available image for a vocabulary word.
Returns a URL (Wikipedia) or emoji string.
Result should be stored in vocabulary_words.image_url.
"""
# Try Wikipedia first
url = await fetch_wikipedia_image(word)
if url:
return url
# Fallback to emoji
return get_emoji_for_word(word)
async def enrich_words_with_images(word_ids: list[str]) -> int:
"""Fetch and store images for vocabulary words that don't have one yet."""
from vocabulary.db import get_pool
import uuid
pool = await get_pool()
updated = 0
async with pool.acquire() as conn:
rows = await conn.fetch(
"SELECT id, english, image_url FROM vocabulary_words WHERE id = ANY($1::uuid[])",
[uuid.UUID(wid) for wid in word_ids],
)
for row in rows:
if row["image_url"]:
continue # Already has an image
image = await get_image_for_word(row["english"])
if image:
await conn.execute(
"UPDATE vocabulary_words SET image_url = $1 WHERE id = $2",
image, row["id"],
)
updated += 1
logger.info(f"Image for '{row['english']}': {image[:60]}...")
logger.info(f"Enriched {updated} words with images")
return updated
@@ -113,7 +113,7 @@ async def translate_and_store(
Returns count of newly translated words.
"""
from vocabulary_db import get_pool
from vocabulary.db import get_pool
pool = await get_pool()
async with pool.acquire() as conn:
@@ -1,4 +0,0 @@
# Backward-compat shim -- module moved to dashboard/analytics.py
import importlib as _importlib
import sys as _sys
_sys.modules[__name__] = _importlib.import_module("dashboard.analytics")
-4
View File
@@ -1,4 +0,0 @@
# Backward-compat shim -- module moved to dashboard/api.py
import importlib as _importlib
import sys as _sys
_sys.modules[__name__] = _importlib.import_module("dashboard.api")
@@ -1,4 +0,0 @@
# Backward-compat shim -- module moved to dashboard/models.py
import importlib as _importlib
import sys as _sys
_sys.modules[__name__] = _importlib.import_module("dashboard.models")
-4
View File
@@ -1,4 +0,0 @@
# Backward-compat shim -- module moved to units/analytics_api.py
import importlib as _importlib
import sys as _sys
_sys.modules[__name__] = _importlib.import_module("units.analytics_api")
-4
View File
@@ -1,4 +0,0 @@
# Backward-compat shim -- module moved to units/analytics_export.py
import importlib as _importlib
import sys as _sys
_sys.modules[__name__] = _importlib.import_module("units.analytics_export")
-4
View File
@@ -1,4 +0,0 @@
# Backward-compat shim -- module moved to units/analytics_helpers.py
import importlib as _importlib
import sys as _sys
_sys.modules[__name__] = _importlib.import_module("units.analytics_helpers")
-4
View File
@@ -1,4 +0,0 @@
# Backward-compat shim -- module moved to units/analytics_models.py
import importlib as _importlib
import sys as _sys
_sys.modules[__name__] = _importlib.import_module("units.analytics_models")
-4
View File
@@ -1,4 +0,0 @@
# Backward-compat shim -- module moved to units/analytics_routes.py
import importlib as _importlib
import sys as _sys
_sys.modules[__name__] = _importlib.import_module("units.analytics_routes")
-4
View File
@@ -1,4 +0,0 @@
# Backward-compat shim -- module moved to units/api.py
import importlib as _importlib
import sys as _sys
_sys.modules[__name__] = _importlib.import_module("units.api")
-4
View File
@@ -1,4 +0,0 @@
# Backward-compat shim -- module moved to units/content_routes.py
import importlib as _importlib
import sys as _sys
_sys.modules[__name__] = _importlib.import_module("units.content_routes")
-4
View File
@@ -1,4 +0,0 @@
# Backward-compat shim -- module moved to units/definition_routes.py
import importlib as _importlib
import sys as _sys
_sys.modules[__name__] = _importlib.import_module("units.definition_routes")
-4
View File
@@ -1,4 +0,0 @@
# Backward-compat shim -- module moved to units/helpers.py
import importlib as _importlib
import sys as _sys
_sys.modules[__name__] = _importlib.import_module("units.helpers")
-4
View File
@@ -1,4 +0,0 @@
# Backward-compat shim -- module moved to units/models.py
import importlib as _importlib
import sys as _sys
_sys.modules[__name__] = _importlib.import_module("units.models")
-4
View File
@@ -1,4 +0,0 @@
# Backward-compat shim -- module moved to units/routes.py
import importlib as _importlib
import sys as _sys
_sys.modules[__name__] = _importlib.import_module("units.routes")
+1 -1
View File
@@ -8,7 +8,7 @@
# - unit_content_routes.py (H5P, worksheet, PDF routes)
#
# The `router` object is assembled here by including all sub-routers.
# Importers that did `from unit_api import router` continue to work.
# Importers that did `from units.api import router` continue to work.
from fastapi import APIRouter
+1 -1
View File
@@ -363,7 +363,7 @@ def api_generate_story(unit_id: str, payload: StoryGeneratePayload):
raise HTTPException(status_code=404, detail="Lerneinheit nicht gefunden.")
try:
from story_generator import generate_story
from services.story_generator import generate_story
result = generate_story(
vocabulary=payload.vocabulary,
language=payload.language,
+33
View File
@@ -0,0 +1,33 @@
# Vocabulary Module
# vocabulary/api.py — API router (search, browse, import, translate)
# vocabulary/db.py — PostgreSQL storage for vocabulary word catalog
from .api import router
from .db import (
VocabularyWord,
get_pool,
init_vocabulary_tables,
search_words,
get_word,
browse_words,
insert_word,
insert_words_bulk,
count_words,
get_all_tags,
get_all_pos,
)
__all__ = [
"router",
"VocabularyWord",
"get_pool",
"init_vocabulary_tables",
"search_words",
"get_word",
"browse_words",
"insert_word",
"insert_words_bulk",
"count_words",
"get_all_tags",
"get_all_pos",
]
@@ -12,7 +12,7 @@ from typing import Any, Dict, List, Optional
from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel
from vocabulary_db import (
from .db import (
search_words,
get_word,
browse_words,
@@ -22,11 +22,6 @@ from vocabulary_db import (
get_all_pos,
VocabularyWord,
)
from learning_units import (
LearningUnitCreate,
create_learning_unit,
get_learning_unit,
)
logger = logging.getLogger(__name__)
@@ -41,14 +36,22 @@ router = APIRouter(prefix="/vocabulary", tags=["vocabulary"])
@router.get("/search")
async def api_search_words(
q: str = Query("", description="Search query"),
lang: str = Query("en", pattern="^(en|de)$"),
lang: str = Query("en"),
limit: int = Query(20, ge=1, le=100),
offset: int = Query(0, ge=0),
source: str = Query("kaikki", description="Source: kaikki (6M words) or manual (27 words)"),
):
"""Full-text search for vocabulary words."""
"""Full-text search for vocabulary words.
source=kaikki searches the 6.27M Kaikki/Wiktionary dictionary.
source=manual searches the manually curated vocabulary_words table.
"""
if not q.strip():
return {"words": [], "query": q, "total": 0}
if source == "kaikki":
return await _search_kaikki(q.strip(), lang, limit, offset)
words = await search_words(q.strip(), lang=lang, limit=limit, offset=offset)
return {
"words": [w.to_dict() for w in words],
@@ -57,6 +60,77 @@ async def api_search_words(
}
async def _search_kaikki(q: str, lang: str, limit: int, offset: int):
"""Search the vocabulary_kaikki table (6.27M Wiktionary entries)."""
from vocabulary.db import get_pool
pool = await get_pool()
async with pool.acquire() as conn:
rows = await conn.fetch(
"""
SELECT id, word, lang, pos, ipa, translations, example
FROM vocabulary_kaikki
WHERE lang = $1 AND lower(word) LIKE $2
ORDER BY length(word), lower(word)
LIMIT $3 OFFSET $4
""",
lang, f"{q.lower()}%", limit, offset,
)
words = []
for r in rows:
tr = r["translations"]
if isinstance(tr, str):
import json as _json
tr = _json.loads(tr)
en_word = ""
en_ipa = ""
if r["lang"] == "en":
en_word = r["word"]
en_ipa = r["ipa"] or ""
else:
# Non-EN entries have empty translations — enrich from EN via reverse lookup
if not tr or len(tr) < 3:
async with pool.acquire() as conn2:
en_row = await conn2.fetchrow(
"""SELECT word, ipa, translations FROM vocabulary_kaikki
WHERE lang = 'en' AND translations->'%s'->>'text' ILIKE $1
ORDER BY length(word) LIMIT 1""" % lang,
r["word"],
)
if en_row:
en_word = en_row["word"]
en_ipa = en_row["ipa"] or ""
en_tr = en_row["translations"]
if isinstance(en_tr, str):
en_tr = _json.loads(en_tr)
tr = en_tr
words.append({
"id": str(r["id"]),
"english": en_word if r["lang"] != "en" else r["word"],
"german": tr.get("de", {}).get("text", "") if r["lang"] != "de" else r["word"],
"word": r["word"],
"lang": r["lang"],
"ipa_en": en_ipa if r["lang"] != "en" else (r["ipa"] or ""),
"ipa_de": r["ipa"] if r["lang"] == "de" else "",
"part_of_speech": r["pos"],
"syllables_en": [],
"syllables_de": [],
"example_en": r["example"] if r["lang"] == "en" else "",
"example_de": r["example"] if r["lang"] == "de" else "",
"image_url": "",
"audio_url_en": "",
"audio_url_de": "",
"difficulty": 0,
"tags": [],
"translations": tr,
})
return {"words": words, "query": q, "total": len(words), "source": "kaikki"}
@router.get("/browse")
async def api_browse_words(
pos: str = Query("", description="Part of speech filter"),
@@ -92,10 +166,13 @@ async def api_get_filters():
tags = await get_all_tags()
pos_list = await get_all_pos()
total = await count_words()
# Kaikki stats (hardcoded to avoid slow COUNT on 6M rows)
return {
"tags": tags,
"parts_of_speech": pos_list,
"total_words": total,
"kaikki_total": 6271749,
"kaikki_languages": 24,
}
@@ -121,7 +198,7 @@ async def api_get_word_audio(word_id: str, lang: str = "en"):
if not text:
raise HTTPException(status_code=400, detail=f"Kein Text fuer Sprache '{lang}'")
from audio_service import get_or_generate_audio
from services.audio import get_or_generate_audio
audio_bytes = await get_or_generate_audio(text, language=lang, word_id=word_id)
if not audio_bytes:
@@ -151,7 +228,7 @@ async def api_get_syllable_audio(word_id: str, lang: str = "en"):
# Join syllables with pauses (Piper handles "..." as pause)
slow_text = " ... ".join(syllables)
from audio_service import get_or_generate_audio
from services.audio import get_or_generate_audio
cache_key = f"{word_id}_syl_{lang}"
audio_bytes = await get_or_generate_audio(slow_text, language=lang, word_id=cache_key)
@@ -161,128 +238,28 @@ async def api_get_syllable_audio(word_id: str, lang: str = "en"):
return FastAPIResponse(content=audio_bytes, media_type="audio/mpeg")
@router.get("/tts")
async def api_tts(text: str = Query("", min_length=1), lang: str = Query("de")):
"""Text-to-Speech endpoint. Returns MP3 audio for any text.
Uses Piper TTS (Thorsten DE / Lessac EN). Cached by text+lang.
"""
from fastapi.responses import Response as FastAPIResponse
from services.audio import get_or_generate_audio
audio_bytes = await get_or_generate_audio(text, language=lang)
if not audio_bytes:
raise HTTPException(status_code=503, detail="TTS Service nicht verfuegbar")
return FastAPIResponse(content=audio_bytes, media_type="audio/mpeg")
# ---------------------------------------------------------------------------
# Learning Unit Creation from Word Selection
# ---------------------------------------------------------------------------
class CreateUnitFromWordsPayload(BaseModel):
title: str
word_ids: List[str]
grade: Optional[str] = None
language: Optional[str] = "de"
@router.post("/units")
async def api_create_unit_from_words(payload: CreateUnitFromWordsPayload):
"""Create a learning unit from selected vocabulary word IDs.
Fetches full word details, creates a LearningUnit in the
learning_units system, and stores the vocabulary data.
"""
if not payload.word_ids:
raise HTTPException(status_code=400, detail="Keine Woerter ausgewaehlt")
# Fetch all selected words
words = []
for wid in payload.word_ids:
word = await get_word(wid)
if word:
words.append(word)
if not words:
raise HTTPException(status_code=404, detail="Keine der Woerter gefunden")
# Create learning unit
lu = create_learning_unit(LearningUnitCreate(
title=payload.title,
topic="Vocabulary",
grade_level=payload.grade or "5-8",
language=payload.language or "de",
status="raw",
))
# Save vocabulary data as analysis JSON for generators
import os
analysis_dir = os.path.expanduser("~/Arbeitsblaetter/Lerneinheiten")
os.makedirs(analysis_dir, exist_ok=True)
vocab_data = [w.to_dict() for w in words]
analysis_path = os.path.join(analysis_dir, f"{lu.id}_vocab.json")
with open(analysis_path, "w", encoding="utf-8") as f:
json.dump({"words": vocab_data, "title": payload.title}, f, ensure_ascii=False, indent=2)
# Also save as QA items for flashcards/type trainer
qa_items = []
for i, w in enumerate(words):
qa_items.append({
"id": f"qa_{i+1}",
"question": w.english,
"answer": w.german,
"question_type": "knowledge",
"key_terms": [w.english],
"difficulty": w.difficulty,
"source_hint": w.part_of_speech,
"leitner_box": 0,
"correct_count": 0,
"incorrect_count": 0,
"last_seen": None,
"next_review": None,
# Extra fields for enhanced flashcards
"ipa_en": w.ipa_en,
"ipa_de": w.ipa_de,
"syllables_en": w.syllables_en,
"syllables_de": w.syllables_de,
"example_en": w.example_en,
"example_de": w.example_de,
"image_url": w.image_url,
"audio_url_en": w.audio_url_en,
"audio_url_de": w.audio_url_de,
"part_of_speech": w.part_of_speech,
"translations": w.translations,
})
qa_path = os.path.join(analysis_dir, f"{lu.id}_qa.json")
with open(qa_path, "w", encoding="utf-8") as f:
json.dump({
"qa_items": qa_items,
"metadata": {
"subject": "English Vocabulary",
"grade_level": payload.grade or "5-8",
"source_title": payload.title,
"total_questions": len(qa_items),
},
}, f, ensure_ascii=False, indent=2)
logger.info(f"Created vocab unit {lu.id} with {len(words)} words")
return {
"unit_id": lu.id,
"title": payload.title,
"word_count": len(words),
"status": "created",
}
@router.get("/units/{unit_id}")
async def api_get_unit_words(unit_id: str):
"""Get all words for a learning unit."""
import os
vocab_path = os.path.join(
os.path.expanduser("~/Arbeitsblaetter/Lerneinheiten"),
f"{unit_id}_vocab.json",
)
if not os.path.exists(vocab_path):
raise HTTPException(status_code=404, detail="Unit nicht gefunden")
with open(vocab_path, "r", encoding="utf-8") as f:
data = json.load(f)
return {
"unit_id": unit_id,
"title": data.get("title", ""),
"words": data.get("words", []),
}
# Unit creation and translation lookup moved to vocabulary/unit_api.py
# ---------------------------------------------------------------------------
@@ -302,7 +279,7 @@ async def api_bulk_import(payload: BulkImportPayload):
Optional: ipa_en, ipa_de, part_of_speech, syllables_en, syllables_de,
example_en, example_de, difficulty, tags, translations.
"""
from vocabulary_db import insert_words_bulk
from .db import insert_words_bulk
words = []
for w in payload.words:
@@ -331,6 +308,83 @@ async def api_bulk_import(payload: BulkImportPayload):
# ---------------------------------------------------------------------------
@router.post("/enrich-images")
async def api_enrich_images(word_ids: List[str] = None):
"""Fetch and store images for vocabulary words (Wikipedia + emoji fallback)."""
from services.image_service import enrich_words_with_images
from vocabulary.db import get_pool
import uuid as _uuid
if not word_ids:
pool = await get_pool()
async with pool.acquire() as conn:
rows = await conn.fetch("SELECT id FROM vocabulary_words WHERE image_url = '' OR image_url IS NULL")
word_ids = [str(r["id"]) for r in rows]
if not word_ids:
return {"enriched": 0, "message": "All words already have images"}
count = await enrich_words_with_images(word_ids)
return {"enriched": count, "total": len(word_ids)}
@router.get("/topics")
async def api_get_topics(
q: str = Query("", description="Search topic or word"),
lang: str = Query("en", description="Display language for word labels"),
):
"""Find topics matching a search word. Returns related word lists.
If q matches a topic name returns that topic.
If q matches a word in any topic returns all topics containing that word.
Words are returned with translations if lang != en.
"""
from vocabulary.db import get_pool
pool = await get_pool()
async with pool.acquire() as conn:
if not q.strip():
rows = await conn.fetch("SELECT topic, words, word_count FROM vocabulary_topics ORDER BY topic LIMIT 50")
else:
q_lower = q.strip().lower()
rows = await conn.fetch("""
SELECT topic, words, word_count FROM vocabulary_topics
WHERE lower(topic) LIKE $1 OR $2 = ANY(words)
ORDER BY word_count DESC
""", f"%{q_lower}%", q_lower)
# Translate word labels if not English
topics = []
for r in rows:
en_words = list(r["words"])
display_words = en_words
if lang != "en":
# Batch-lookup translations from Kaikki
translated = []
for w in en_words[:20]: # Limit to 20 for speed
tr_row = await conn.fetchrow(
"SELECT translations FROM vocabulary_kaikki WHERE lang = 'en' AND lower(word) = $1 LIMIT 1",
w.lower(),
)
if tr_row and tr_row["translations"]:
import json as _json
tr = tr_row["translations"]
if isinstance(tr, str):
tr = _json.loads(tr)
tr_text = tr.get(lang, {}).get("text", "")
translated.append(tr_text if tr_text else w)
else:
translated.append(w)
display_words = translated + en_words[20:]
topics.append({
"topic": r["topic"],
"words": en_words,
"display_words": display_words,
"word_count": r["word_count"],
})
return {"topics": topics, "query": q, "lang": lang}
class TranslateRequest(BaseModel):
word_ids: List[str]
target_language: str
@@ -343,7 +397,7 @@ async def api_translate_words(payload: TranslateRequest):
Uses local LLM (Ollama) for translation. Results are cached in the
vocabulary_words.translations JSONB field.
"""
from translation_service import translate_and_store
from services.translation import translate_and_store
if payload.target_language not in {"tr", "ar", "uk", "ru", "pl", "fr", "es"}:
raise HTTPException(status_code=400, detail=f"Sprache '{payload.target_language}' nicht unterstuetzt")
+356
View File
@@ -0,0 +1,356 @@
"""
Vocabulary Unit API — Create learning units, translate words, manage language pairs.
Endpoints for teachers to build vocabulary learning units with custom words,
auto-translation via Kaikki dictionary, and flexible language pair support.
"""
import json
import logging
import os
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel
from .db import get_word, VocabularyWord, get_pool
from units.learning import LearningUnitCreate, create_learning_unit
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/vocabulary", tags=["vocabulary"])
# All supported language codes
SUPPORTED_LANGS = {
"en", "de", "fr", "es", "it", "pt", "nl", "tr", "ru", "ar",
"uk", "pl", "sv", "fi", "da", "ro", "el", "hu", "cs", "bg",
"lv", "lt", "sk", "et", "sl", "hr",
}
# ---------------------------------------------------------------------------
# Translation Lookup (auto-suggest)
# ---------------------------------------------------------------------------
@router.get("/lookup-translation")
async def api_lookup_translation(
word: str = Query("", min_length=1, description="Word to translate"),
source: str = Query("en", description="Source language code"),
target: str = Query("de", description="Target language code"),
limit: int = Query(5, ge=1, le=20),
):
"""Look up translations between any two languages via Kaikki dictionary.
Uses EN entries as a hub: all EN words have translations to 24 languages.
- EN → X: direct lookup (word in EN, translation from JSONB)
- X → EN: reverse lookup (search EN entries where translations.X matches)
- X → Y: bridge via EN (find EN word via X, then get Y translation)
"""
if source not in SUPPORTED_LANGS or target not in SUPPORTED_LANGS:
raise HTTPException(status_code=400, detail="Sprache nicht unterstuetzt")
if source == target:
return {"results": [], "word": word, "source": source, "target": target}
pool = await get_pool()
q = word.strip()
results = []
async with pool.acquire() as conn:
if source == "en":
# Direct: search EN word, return target translation
rows = await conn.fetch(
"""SELECT word, pos, ipa, translations
FROM vocabulary_kaikki
WHERE lang = 'en' AND lower(word) LIKE $1
ORDER BY length(word), lower(word)
LIMIT $2""",
f"{q.lower()}%", limit,
)
for r in rows:
tr = _parse_translations(r["translations"])
target_text = tr.get(target, {}).get("text", "")
if target_text:
results.append({
"source_text": r["word"],
"target_text": target_text,
"pos": r["pos"],
"ipa": r["ipa"] or "",
})
elif target == "en":
# Reverse: search EN entries where translations.source matches
rows = await conn.fetch(
"""SELECT word, pos, ipa, translations->'%s'->>'text' as src_text
FROM vocabulary_kaikki
WHERE lang = 'en'
AND translations->'%s'->>'text' ILIKE $1
ORDER BY length(word)
LIMIT $2""" % (source, source),
f"{q}%", limit,
)
for r in rows:
results.append({
"source_text": r["src_text"],
"target_text": r["word"],
"pos": r["pos"],
"ipa": r["ipa"] or "",
})
else:
# Bridge via EN: find EN word via source, then get target translation
rows = await conn.fetch(
"""SELECT word, pos, ipa, translations
FROM vocabulary_kaikki
WHERE lang = 'en'
AND translations->'%s'->>'text' ILIKE $1
ORDER BY length(word)
LIMIT $2""" % source,
f"{q}%", limit,
)
for r in rows:
tr = _parse_translations(r["translations"])
src_text = tr.get(source, {}).get("text", "")
target_text = tr.get(target, {}).get("text", "")
if src_text and target_text:
results.append({
"source_text": src_text,
"target_text": target_text,
"pos": r["pos"],
"ipa": "",
})
return {"results": results, "word": q, "source": source, "target": target}
def _parse_translations(tr) -> dict:
"""Parse translations field (may be JSONB dict or JSON string)."""
if isinstance(tr, str):
return json.loads(tr)
return tr or {}
# ---------------------------------------------------------------------------
# Unit Creation (with custom words + language pair)
# ---------------------------------------------------------------------------
class CustomWord(BaseModel):
source_text: str
target_text: str
class CreateUnitPayload(BaseModel):
title: str
word_ids: List[str] = []
custom_words: List[CustomWord] = []
source_lang: str = "en"
target_lang: str = "de"
grade: Optional[str] = None
@router.post("/units")
async def api_create_unit_from_words(payload: CreateUnitPayload):
"""Create a learning unit from dictionary words and/or custom word pairs.
Supports any language pair. Words can come from:
1. word_ids — looked up in Kaikki dictionary
2. custom_words — manually entered source/target pairs
"""
if not payload.word_ids and not payload.custom_words:
raise HTTPException(status_code=400, detail="Keine Woerter ausgewaehlt")
qa_items = []
vocab_data = []
idx = 0
# 1. Process dictionary words
for wid in payload.word_ids:
word = await get_word(wid)
if not word:
# Try Kaikki lookup
kaikki_word = await _get_kaikki_word(wid, payload.source_lang, payload.target_lang)
if kaikki_word:
qa_items.append(_make_qa_item(idx, kaikki_word, payload.source_lang, payload.target_lang))
vocab_data.append(kaikki_word)
idx += 1
continue
# Manual vocabulary_words entry
source_text, target_text = _get_word_pair(word, payload.source_lang, payload.target_lang)
qa_items.append({
"id": f"qa_{idx+1}",
"question": source_text,
"answer": target_text,
"question_type": "knowledge",
"key_terms": [source_text],
"difficulty": word.difficulty,
"source_hint": word.part_of_speech,
"leitner_box": 0,
"correct_count": 0,
"incorrect_count": 0,
"last_seen": None,
"next_review": None,
"ipa_en": word.ipa_en,
"ipa_de": word.ipa_de,
"syllables_en": word.syllables_en,
"syllables_de": word.syllables_de,
"example_en": word.example_en,
"example_de": word.example_de,
"image_url": word.image_url,
"audio_url_en": word.audio_url_en,
"audio_url_de": word.audio_url_de,
"part_of_speech": word.part_of_speech,
"translations": word.translations,
})
vocab_data.append(word.to_dict())
idx += 1
# 2. Process custom words (manually entered by teacher)
for cw in payload.custom_words:
qa_items.append({
"id": f"qa_{idx+1}",
"question": cw.source_text,
"answer": cw.target_text,
"question_type": "knowledge",
"key_terms": [cw.source_text],
"difficulty": 1,
"source_hint": "",
"leitner_box": 0,
"correct_count": 0,
"incorrect_count": 0,
"last_seen": None,
"next_review": None,
"part_of_speech": "",
"translations": {},
})
vocab_data.append({
"english": cw.source_text if payload.source_lang == "en" else cw.target_text if payload.target_lang == "en" else "",
"german": cw.source_text if payload.source_lang == "de" else cw.target_text if payload.target_lang == "de" else "",
"word": cw.source_text,
"translation": cw.target_text,
"source_lang": payload.source_lang,
"target_lang": payload.target_lang,
})
idx += 1
if not qa_items:
raise HTTPException(status_code=400, detail="Keine gültigen Woerter")
# Create learning unit
lang_label = f"{payload.source_lang.upper()}{payload.target_lang.upper()}"
lu = create_learning_unit(LearningUnitCreate(
title=payload.title,
topic="Vocabulary",
grade_level=payload.grade or "5-8",
language=payload.target_lang,
status="raw",
))
# Save files
analysis_dir = os.path.expanduser("~/Arbeitsblaetter/Lerneinheiten")
os.makedirs(analysis_dir, exist_ok=True)
with open(os.path.join(analysis_dir, f"{lu.id}_vocab.json"), "w", encoding="utf-8") as f:
json.dump({"words": vocab_data, "title": payload.title}, f, ensure_ascii=False, indent=2)
with open(os.path.join(analysis_dir, f"{lu.id}_qa.json"), "w", encoding="utf-8") as f:
json.dump({
"qa_items": qa_items,
"metadata": {
"subject": f"Vocabulary {lang_label}",
"grade_level": payload.grade or "5-8",
"source_title": payload.title,
"total_questions": len(qa_items),
"source_lang": payload.source_lang,
"target_lang": payload.target_lang,
},
}, f, ensure_ascii=False, indent=2)
# Auto-enrich images for dictionary words
dict_ids = [wid for wid in payload.word_ids]
if dict_ids:
try:
from services.image_service import enrich_words_with_images
await enrich_words_with_images(dict_ids)
except Exception as e:
logger.warning(f"Image enrichment failed (non-critical): {e}")
logger.info(f"Created vocab unit {lu.id} ({lang_label}) with {len(qa_items)} words")
return {
"unit_id": lu.id,
"title": payload.title,
"word_count": len(qa_items),
"source_lang": payload.source_lang,
"target_lang": payload.target_lang,
"status": "created",
}
def _get_word_pair(word: VocabularyWord, source_lang: str, target_lang: str):
"""Extract source/target text from a VocabularyWord for the given language pair."""
lang_map = {"en": word.english, "de": word.german}
# Check translations for other languages
if source_lang not in lang_map:
tr = word.translations or {}
lang_map[source_lang] = tr.get(source_lang, {}).get("text", word.english)
if target_lang not in lang_map:
tr = word.translations or {}
lang_map[target_lang] = tr.get(target_lang, {}).get("text", word.german)
return lang_map.get(source_lang, word.english), lang_map.get(target_lang, word.german)
async def _get_kaikki_word(word_id: str, source_lang: str, target_lang: str) -> Optional[dict]:
"""Look up a word by ID in the Kaikki table and return a vocab dict."""
pool = await get_pool()
async with pool.acquire() as conn:
row = await conn.fetchrow(
"SELECT id, word, lang, pos, ipa, translations, example FROM vocabulary_kaikki WHERE id = $1",
_to_uuid(word_id),
)
if not row:
return None
tr = _parse_translations(row["translations"])
src = row["word"] if row["lang"] == source_lang else tr.get(source_lang, {}).get("text", "")
tgt = tr.get(target_lang, {}).get("text", "") if row["lang"] != target_lang else row["word"]
return {
"id": str(row["id"]),
"word": row["word"],
"lang": row["lang"],
"source_text": src or row["word"],
"target_text": tgt,
"pos": row["pos"],
"ipa": row["ipa"] or "",
"example": row["example"] or "",
"translations": tr,
}
def _make_qa_item(idx: int, kw: dict, source_lang: str, target_lang: str) -> dict:
"""Create a QA item from a Kaikki word dict."""
return {
"id": f"qa_{idx+1}",
"question": kw.get("source_text", kw.get("word", "")),
"answer": kw.get("target_text", ""),
"question_type": "knowledge",
"key_terms": [kw.get("source_text", kw.get("word", ""))],
"difficulty": 0,
"source_hint": kw.get("pos", ""),
"leitner_box": 0,
"correct_count": 0,
"incorrect_count": 0,
"last_seen": None,
"next_review": None,
"ipa_en": kw.get("ipa", "") if source_lang == "en" else "",
"ipa_de": kw.get("ipa", "") if source_lang == "de" else "",
"part_of_speech": kw.get("pos", ""),
"translations": kw.get("translations", {}),
}
def _to_uuid(s: str):
"""Convert string to UUID, return as-is if already valid."""
import uuid
try:
return uuid.UUID(s)
except (ValueError, AttributeError):
return s
+37
View File
@@ -0,0 +1,37 @@
# Worksheets Module
# worksheets/api.py — API router (generate MC, cloze, mindmap, quiz)
# worksheets/models.py — Pydantic models and helpers
from .api import router
from .models import (
ContentType,
GenerateRequest,
MCGenerateRequest,
ClozeGenerateRequest,
MindmapGenerateRequest,
QuizGenerateRequest,
BatchGenerateRequest,
WorksheetContent,
GenerateResponse,
BatchGenerateResponse,
parse_difficulty,
parse_cloze_type,
parse_quiz_types,
)
__all__ = [
"router",
"ContentType",
"GenerateRequest",
"MCGenerateRequest",
"ClozeGenerateRequest",
"MindmapGenerateRequest",
"QuizGenerateRequest",
"BatchGenerateRequest",
"WorksheetContent",
"GenerateResponse",
"BatchGenerateResponse",
"parse_difficulty",
"parse_cloze_type",
"parse_quiz_types",
]
@@ -27,7 +27,7 @@ from generators import (
QuizGenerator
)
from worksheets_models import (
from .models import (
ContentType,
GenerateRequest,
MCGenerateRequest,
+24
View File
@@ -20,6 +20,7 @@ volumes:
transcription_models:
transcription_temp:
lehrer_backend_data:
lehrer_arbeitsblaetter:
opensearch_data:
# Communication (Jitsi + Matrix)
synapse_data:
@@ -108,8 +109,10 @@ services:
environment:
NODE_ENV: production
BACKEND_URL: http://backend-lehrer:8001
SCHOOL_SERVICE_URL: http://school-service:8084
depends_on:
- backend-lehrer
- school-service
restart: unless-stopped
networks:
- breakpilot-network
@@ -159,6 +162,7 @@ services:
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- lehrer_backend_data:/app/data
- lehrer_arbeitsblaetter:/root/Arbeitsblaetter
environment:
PORT: 8001
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-breakpilot}:${POSTGRES_PASSWORD:-breakpilot123}@bp-core-postgres:5432/${POSTGRES_DB:-breakpilot_db}?options=-csearch_path%3Dlehrer,core,public
@@ -285,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
@@ -0,0 +1,64 @@
# Architektur
## Datenmodell
### Phase 9a — Kalender-Stammdaten
| Tabelle | Inhalt | Owner |
|---------|--------|-------|
| `cal_public_event` | Ferien + Feiertage (region, type, name, start, end) | global (alle Bundeslaender) |
| `cal_school_config` | Bundesland-Auswahl + Schuljahr-Daten | 1 row per user_id |
### Phase 9b — Schul-Events
| Tabelle | Inhalt | Owner |
|---------|--------|-------|
| `cal_school_event` | Titel + Typ + Datum/Zeit + affected_class_ids + Notification-Flags | created_by_user_id |
Event-Typen (CHECK constraint): `fortbildung`, `schulfeier`, `klassenfahrt`, `projekttag`, `eltern_info`, `andere`.
### Phase 9c — Parent-Accounts
| Tabelle | Inhalt |
|---------|--------|
| `parent_account` | Email + preferred_language, UNIQUE pro (Lehrer, Email) |
| `parent_child` | Vorname/Nachname + FK auf tt_class |
| `parent_magic_link` | Einmal-Token (SHA-256 in DB), expires_at 7 Tage |
| `parent_session` | Browser-Session-Token (SHA-256 in DB), expires_at 30 Tage |
### Phase 9d — Notifications
| Tabelle | Inhalt |
|---------|--------|
| `notification_log` | Idempotenz: UNIQUE(event_id, lead_days, audience, channel) |
## Auth-Modell
**Zwei voneinander unabhaengige Auth-Wege:**
1. **Lehrer:** JWT in Authorization-Header (oder Dev-Bypass mit Default-User wenn `ENVIRONMENT != "production"`). Routen unter `/api/v1/school/...`.
2. **Eltern:** Session-Cookie `bp_parent_session` (HttpOnly, SameSite=Lax), gesetzt vom `/api/v1/parent/auth/redeem` Endpoint. ParentSessionMiddleware resolved Cookie → parent_account.
Eltern sehen **nie** Daten anderer Eltern. Privacy-Check via `ChildBelongsToParent` in jedem GET, Plus Filterung der Lessons gegen tt_solution des einladenden Lehrers.
## Bundesland-Wizard
Erster Aufruf von `/schulkalender` → kein `cal_school_config``BundeslandWizard` UI → POST `/calendar/config` mit `{bundesland: "DE-NI"}` → MonthView lädt für die naechsten ~6 Wochen.
## Schuljahres-Rollover
POST `/calendar/school-year-rollover` (optional `{new_year_start, new_year_end}`):
1. `DELETE FROM tt_class WHERE grade_level >= 13` (Abschlusskohorte)
2. `UPDATE tt_class SET grade_level = grade_level + 1`
3. `UPDATE cal_school_config SET school_year_start/end = ...`
Alles in einer Transaction. Stundenplan-Lehrer-Faecher-Raum-Bestand bleibt unangetastet.
## Auth + Messaging outsourced
Production-Auth, Matrix-Bridge und Email-Gateway werden vom Kollegen gepflegt — siehe globale Memory `stundenplan_auth_and_messaging.md`. Wir definieren nur:
- Dispatch-Payload-Struct (siehe [notifications.md](notifications.md))
- Env-Vars `MATRIX_SERVICE_URL`, `EMAIL_SERVICE_URL` (leer = Stub-Mode)
- Endpoint-Vertrag (POST mit JSON-Body, HTTP 2xx = sent)
@@ -0,0 +1,71 @@
# Ferien + Feiertage
## Quelle
[openholidaysapi.org](https://openholidaysapi.org) — EU-Initiative, MIT-Lizenz
fuer den API-Code, ODbL fuer die Daten. Liefert sowohl `PublicHolidays` als
auch `SchoolHolidays` je Bundesland mit ISO-Codes `DE-BW`, `DE-BY`, ...
## Build-Time-Snapshot
Statt zur Laufzeit zu pollen wird ein JSON-Snapshot committed:
```bash
bash scripts/calendar-snapshot.sh 2026 2030
```
Schreibt nach `school-service/internal/seed/calendar_holidays.json`. Das
Dockerfile kopiert die Datei ins Image; bei jedem Container-Start importiert
`CalendarService.SeedFromSnapshot()` die Eintraege idempotent (UNIQUE auf
region, event_type, name_de, start_date).
**Stand 2026-05-22:** 854 Events fuer alle 16 Bundeslaender × 3 Schuljahre.
## Aktualisierungs-Workflow
1. Jaehrlich (z.B. im Mai vor neuem Schuljahr):
```bash
bash scripts/calendar-snapshot.sh 2027 2031
```
2. Diff im Git pruefen — sollte nur neue Eintraege haben, nicht alte ueberschreiben.
3. Commit + push + Container-Rebuild.
4. Beim ersten Boot werden neue Eintraege in `cal_public_event` eingefuegt; bestehende bleiben.
## API
```
GET /api/v1/school/calendar/holidays?region=DE-NI&from=2026-08-01&to=2027-07-31
```
Liefert Array sortiert nach `start_date`. Beispiel-Antwort:
```json
[
{"id":"…","region":"DE-NI","event_type":"school_holiday","name_de":"Sommerferien","start_date":"2026-07-02","end_date":"2026-08-12"},
{"id":"…","region":"DE-NI","event_type":"public_holiday","name_de":"Tag der Deutschen Einheit","start_date":"2026-10-03","end_date":"2026-10-03"},
...
]
```
## Format-Mapping (Snapshot-Script)
OpenHolidaysAPI gibt:
```json
{"id":"...","startDate":"2026-10-03","endDate":"2026-10-03","type":"Public",
"name":[{"language":"DE","text":"Tag der Deutschen Einheit"}]}
```
`scripts/calendar-snapshot.sh` normalisiert via jq:
```json
{"region":"DE-NI","event_type":"public_holiday","name_de":"Tag der Deutschen Einheit",
"name_en":null,"start_date":"2026-10-03","end_date":"2026-10-03"}
```
## Lizenz-Compliance
- API-Code: MIT
- Daten: ODbL (Open Database License)
Beides ist fuer kommerzielle Nutzung erlaubt. Die Quelle muss in einer
Lizenz-Aufstellung (SBOM) genannt werden — bereits in
`sbom/stundenplan/README.md` dokumentiert.
+51
View File
@@ -0,0 +1,51 @@
# Schulkalender
Bundeslandweit kalibrierter Schulkalender mit Ferien, Feiertagen, Schul-
Events, Eltern-Sicht und mehrsprachigen Benachrichtigungen.
## Auf einen Blick
```
studio-v2 /schulkalender → Lehrer-Sicht (CRUD Events, Eltern einladen, Rollover)
studio-v2 /eltern → Eltern-Sicht (Wochengrid des Kindes in eigener Sprache)
│ HTTP /api/school/* und /api/parent/* (zwei separate Auth-Gruppen)
school-service (Go, :8084)
├── cal_public_event — Ferien/Feiertage-Snapshot (OpenHolidaysAPI)
├── cal_school_config — Bundesland pro Rektor
├── cal_school_event — Schulfeier, Fortbildung, Klassenfahrt etc.
├── parent_account/_child/_magic_link/_session — Eltern-Auth
└── notification_log — Idempotenter Versand-Log
▼ POST DispatchPayload
Matrix-Bridge + Email-Gateway (vom Kollegen gepflegt, nicht in diesem Repo)
```
## Module
| Bereich | Doku |
|---------|------|
| [Architektur](architecture.md) | DB-Modell, Auth-Ablauf, Phase-Reihenfolge |
| [Ferien-Snapshot](holidays.md) | OpenHolidaysAPI-Pipeline, jaehrliche Aktualisierung |
| [Eltern-Workflow](parent-flow.md) | Magic-Link, Cookie-Session, i18n-Fachnamen |
| [Notifications](notifications.md) | Cron, Templates, Dispatcher-Vertrag |
## Phasen-Stand
**Alle vier Phasen abgeschlossen (2026-05-22):**
- 9a — Bundesland-Wizard + Monatsansicht
- 9b — Schul-Events + Schuljahres-Rollover
- 9c — Parent-Accounts + Magic-Link + Wochengrid in 8 Sprachen
- 9d — Notification-Cron + Templates + Status-Badges
**Offen:** Vollschuljahr-ICS, Seed-Daten fuer Demo-Schule.
## Test-Status
| Suite | Tests |
|------|-------|
| Go (services + notifications) | 89 / 89 |
| Playwright Schulkalender | 16 / 16 |
| Playwright Eltern | 7 / 7 |
@@ -0,0 +1,96 @@
# Notifications
## Pipeline
```
06:00 Uhr (Berlin-Zeit, Container TZ=Europe/Berlin)
NotificationService.RunForDate(today)
dueEvents() findet cal_school_event mit
(start_date - today) ∈ notification_lead_days
Pro Event: fuer jede Audience (parents/students) und jeden Channel
(matrix für alle, email zusaetzlich nur fuer parents):
dispatchOne()
1. Idempotenz-Check (UNIQUE notification_log)
2. recipientsFor() — JOIN parent_account+parent_child fuer
betroffene Klassen, gibt Email-Liste + bevorzugte Sprache zurueck
3. Render-Template (templates.go, 8 Sprachen)
4. POST {MATRIX,EMAIL}_SERVICE_URL mit DispatchPayload
5. notification_log writeLog (sent/failed/skipped)
```
## Cron-Mechanik
`main.go` startet einen Goroutine-Ticker mit 1h-Intervall. Sobald `time.Now().Hour() == 6` wird `RunForDate` aufgerufen. Idempotent — die UNIQUE auf notification_log filtert Doppel-Calls am selben Tag.
Bei Container-Restart vor 06:00 läuft trotzdem alles korrekt: der naechste 06-Tick fired bis spaetestens 06:59:59. Bei Restart nach 06:00: erste Notification erst am Folgetag (acceptable trade-off gegen einen 1-Min-Ticker).
## Manueller Trigger
```bash
# Heute jetzt scannen
curl -X POST http://localhost:8084/api/v1/school/calendar/notifications/run-now
# Backfill (z.B. nach langem Container-Down)
curl -X POST 'http://localhost:8084/api/v1/school/calendar/notifications/run-now?date=2026-05-20'
```
Antwort: `{"date":"2026-05-22","sent":N,"failed":N,"skipped":N,"already_logged":N}`.
## Template-Engine
Datei: `school-service/internal/notifications/templates.go`. Schema:
```
templates[lang][event_type][audience][bucket] → {Subject, Body}
```
- `lang` ∈ de/en/tr/ar/uk/ru/pl/fr (Fallback `de`)
- `event_type` ∈ fortbildung/schulfeier/klassenfahrt/projekttag/eltern_info/andere (Fallback `andere`)
- `audience` ∈ parents/students (Fallback `parents`)
- `bucket` ∈ today/tomorrow/days (Fallback `days`)
Placeholders: `{{title}}`, `{{date}}`, `{{date_pretty}}`, `{{class_name}}`, `{{class_suffix}}`, `{{teacher_name}}`, `{{lead}}`.
Beispiel-Render (TR / schulfeier / parents / 1-Tag-Vorlauf):
```
Subject: Yarın: Sommerfest (5a)
Body: Sayın veliler, yarın (15.06.2026) Sommerfest gerçekleşiyor (5a).
```
## DispatchPayload (Endpoint-Vertrag mit Matrix/Email Service)
```json
{
"channel": "matrix",
"recipient": "mama@example.de",
"language": "tr",
"subject": "Yarın: Sommerfest",
"body": "Sayın veliler, ...",
"event_id": "uuid-…",
"lead_days": 1
}
```
Erwartete Antwort vom Upstream: HTTP 2xx = sent. 4xx/5xx = failed. Wir leiten **keine** Empfaenger-Identifier-Aufloesung weiter ans Upstream — die Matrix-Bridge mapt Email → Matrix-Handle in der eigenen Logik.
Bei `MATRIX_SERVICE_URL` oder `EMAIL_SERVICE_URL` leer: status='skipped', kein Versandversuch. Erlaubt lokales Testen ohne Upstream.
## Status-Anzeige im Lehrer-UI
`DayDetail` mountet `NotificationStatus` fuer jedes Event mit `notify_parents` oder `notify_students`. Lädt `GET /api/v1/school/calendar/events/:id/notifications` und zeigt Badges:
- ✓ gruen = sent
- ✗ rot = failed (Hover zeigt error_message)
- ⏱ amber = skipped (Upstream noch nicht konfiguriert)
## Privacy
`notification_log` ist nur über JOIN cal_school_event sichtbar — Lehrer sieht nur Logs seiner eigenen Events. Eltern haben gar keine UI fuer Logs.
@@ -0,0 +1,54 @@
# Eltern-Workflow
## Einladung (Lehrer)
1. Lehrer offnet `/schulkalender`, scrollt zu `ParentManager`.
2. Klick "+ Eltern einladen" → Form mit Email, Vorname/Nachname Kind, Klasse, Sprache.
3. `POST /api/v1/school/calendar/parents/invite` legt parent_account (upsert), parent_child + parent_magic_link an, gibt Klartext-Token + voll qualifizierten Link zurueck.
4. Lehrer kopiert Link aus der UI und schickt ihn ueber Matrix oder Email (Versand-Automation kommt mit Phase 9d Notification-Pipeline).
## Login (Eltern)
1. Eltern klicken den Link `https://app/eltern/login?token=…`.
2. Browser laedt die Login-Page, sendet `POST /api/v1/parent/auth/redeem {token}`.
3. school-service validiert Token (Hash-Lookup + expires_at + used_at), markiert used_at, mintet Session-Token (32-Byte URL-safe Base64), setzt HttpOnly Cookie `bp_parent_session`.
4. Redirect auf `/eltern`. Folgende API-Calls senden Cookie automatisch.
## Wochengrid
`/eltern` ruft:
- `GET /api/v1/parent/me` → Account + Kinder-Liste (Name, Klasse via JOIN tt_class)
- `GET /api/v1/parent/me/timetable?class_id=…` → letzte completed tt_solution der Klasse
Filter laeuft strikt: ParentService prueft `ChildBelongsToParent(parent_id, class_id)` vor jeder Timetable-Query.
## Fach-Uebersetzung
`lib/calendar/subject-i18n.ts` hat 22 Standardfaecher in 8 Sprachen:
```typescript
mathematik: { de: 'Mathematik', en: 'Mathematics', tr: 'Matematik',
ar: 'الرياضيات', uk: 'Математика', ru: 'Математика',
pl: 'Matematyka', fr: 'Mathématiques' }
```
`translateSubject(germanName, lang)`:
1. Lowercase + trim → `key`
2. `SUBJECTS[key]` lookup
3. Wenn key nicht in Map: Original-Deutsch zurueck (z.B. "Imkern AG")
4. Wenn lang nicht in Sprachen: `de`-Fallback
## Logout
`POST /api/v1/parent/auth/logout` setzt Cookie auf max-age=-1. Session-Row bleibt in DB (laeuft selber ab nach 30 Tagen) — vereinfacht Tracking.
## Was die Eltern NICHT sehen
- Andere Eltern oder Kinder
- Stundenplan-Versionen die nicht "completed" sind
- Schul-Events mit `visible_to_parents=false`
- Lehrer-internes wie Stundentafel oder Lehrauftrag-Konfiguration
Privacy-Garantien sind auf SQL-Ebene durchgesetzt (JOIN-Pfade + WHERE-Klauseln), nicht nur im Application-Layer.
@@ -0,0 +1,76 @@
# Architektur + Datenmodell
## Verantwortung pro Service
### school-service (Go/Gin)
- Persistenz fuer alle Stammdaten + Constraints + Solutions
- API-Gateway fuer studio-v2: validiert, ownership-checked, faked Auth im
Dev-Mode
- Trigger-Aufruf an solver-service nach POST /solutions
### timetable-solver-service (Python/FastAPI + Timefold)
- Liest Problem aus PG via asyncpg
- Baut Timefold-Domain (Lessons, Timeslots, Rooms, Rules)
- Loest im ThreadPoolExecutor (Solver ist CPU-gebunden)
- Schreibt Loesung direkt nach tt_lesson, updated tt_solution.status
### studio-v2 (Next.js)
- `/stundenplan` Tab-Page mit 9 Tabs
- Next.js API-Route `/api/school/*` proxied zu school-service
- Solution-Polling alle 4 s wenn Solve laeuft
## Datenmodell
### Stammdaten (7 Tabellen)
| Tabelle | Inhalt | Owner |
|---------|--------|-------|
| tt_class | Klassen (Name, Klassenstufe) | created_by_user_id |
| tt_period | Zeitraster (Mo-So × Stunde × Start/Ende) | created_by_user_id |
| tt_room | Raeume (Name, Typ, Kapazitaet, Aufzug) | created_by_user_id |
| tt_subject | Faecher (Name, Kuerzel, Farbe, RoomType) | created_by_user_id |
| tt_teacher | Lehrer als planbare Ressource | created_by_user_id |
| tt_curriculum | Klasse × Fach → Wochenstunden | indirect via tt_class |
| tt_assignment | Lehrer × Klasse × Fach | indirect via tt_teacher |
### Constraints (15 Tabellen)
Pro Tabelle die Felder: `is_hard` (bool), `weight` (0-100), `active` (bool),
`note` (TEXT), `created_by_user_id`. Aufgeteilt nach Parent-Entitaet:
- Lehrer: `unavailable_day`, `unavailable_window`, `max_hours_day`,
`max_hours_week`, `excluded_subject`, `excluded_room`
- Fach: `min_day_gap`, `max_consecutive`, `contiguous_when_repeated`,
`preferred_period`, `double_lesson`
- Klasse: `max_hours_day`, `no_gaps`
- Raum: `requires_type`, `unavailable`
### Solutions (2 Tabellen, Phase 5+7)
| Tabelle | Inhalt |
|---------|--------|
| tt_solution | Solve-Run: Status, hard/soft Score, parent_solution_id, seconds_limit |
| tt_lesson | Eine Stunde im Plan (class, subject, teacher, room, day, period, pinned) |
`tt_lesson` hat drei `UNIQUE`-Constraints, die der DB-Layer selbst Konflikt-
Lessons ablehnen laesst:
- `(solution_id, class_id, day, period)` — Klasse nicht doppelt
- `(solution_id, teacher_id, day, period)` — Lehrer nicht doppelt
- `(solution_id, room_id, day, period)` — Raum nicht doppelt
Damit kann ein fehlerhafter Solver-Output nicht in Daten landen, die das UI
inkonsistent darstellt.
## Ownership-Modell
Alles ist single-tenant pro `created_by_user_id`. CRUD-Endpoints filtern via
`WHERE EXISTS (SELECT 1 FROM tt_<parent> WHERE id = $X AND created_by_user_id
= $user)`. Cross-Tenant-Zugriff ist auf SQL-Ebene ausgeschlossen.
Im Dev-Mode injiziert `AuthMiddleware` einen festen UUID, damit Tests ohne
JWT laufen koennen. Production-Build (`ENVIRONMENT=production`) deaktiviert
den Bypass — JWT wird Pflicht.
@@ -0,0 +1,71 @@
# Constraint-Referenz
Jeder Constraint-Eintrag im UI legt eine Row in der korrespondierenden
`tt_constraint_*` Tabelle an. Der Solver liest sie als Problem-Facts und
joined sie gegen die Lessons.
## Gemeinsame Felder
| Feld | Typ | Bedeutung |
|------|-----|-----------|
| `is_hard` | bool | true = Solver muss einhalten (HardScore -1 pro Verstoss). false = Soft-Penalty (SoftScore -weight pro Verstoss) |
| `weight` | int 0-100 | Multiplikator fuer Soft-Penalties; bei Hard ignoriert |
| `active` | bool | inaktive Rows werden vom Solver ignoriert |
| `note` | TEXT | Freier Begruendungstext fuer den Rektor |
## Constraint-Typen
### Universal (immer aktiv, nicht abschaltbar)
| Constraint | Bedeutung |
|------------|-----------|
| `class_conflict` | Eine Klasse hat nur eine Lesson pro Timeslot |
| `teacher_conflict` | Ein Lehrer haelt nur eine Lesson pro Timeslot |
| `room_conflict` | Ein Raum hostet nur eine Lesson pro Timeslot |
### DB-Driven (vom Rektor konfigurierbar)
Jeder Typ existiert als `_hard` und `_soft` Constraint im Provider:
| Typ | Tabelle | Beispiel |
|-----|---------|----------|
| Lehrer Tag nicht verfuegbar | `tt_constraint_teacher_unavailable_day` | „Anna nie Montags" |
| Lehrer Zeitfenster nicht verfuegbar | `tt_constraint_teacher_unavailable_window` | „Bob Dienstag 1317 Uhr nicht" |
| Lehrer Max h/Tag | `tt_constraint_teacher_max_hours_day` | Anti-Burnout |
| Lehrer Max h/Woche | `tt_constraint_teacher_max_hours_week` | Teilzeit-Cap |
| Lehrer Fach ausgeschlossen | `tt_constraint_teacher_excluded_subject` | Qualifikationsluecke |
| Lehrer Raum ausgeschlossen | `tt_constraint_teacher_excluded_room` | Rollstuhl, kein Fahrstuhl |
| Fach Mindest-Tagesabstand | `tt_constraint_subject_min_day_gap` | Mathe nicht 2 Tage hintereinander |
| Fach Max Stunden am Stueck | `tt_constraint_subject_max_consecutive` | Keine Dreifachstunde |
| Fach Mehrfach=zusammen | `tt_constraint_subject_contiguous_when_repeated` | Wenn 2× am Tag, dann benachbart |
| Fach Bevorzugte Stunden | `tt_constraint_subject_preferred_period` | Hauptfaecher morgens |
| Fach Doppelstunde bevorzugt | `tt_constraint_subject_double_lesson` | Sport als 90-min-Block |
| Klasse Max h/Tag | `tt_constraint_class_max_hours_day` | Jugendgerecht |
| Klasse Keine Freistunden | `tt_constraint_class_no_gaps` | Soft, minimiert Loecher |
| Raumtyp erforderlich | `tt_constraint_room_requires_type` | Sport → Sporthalle |
| Raum nicht verfuegbar | `tt_constraint_room_unavailable` | Wartung, Renovierung |
## Hard vs. Soft — Faustregel
- **Hard** wenn die Schule den Plan rechtlich oder physisch nicht
ausfuehren kann (Lehrervertrag, Behinderung, Raum existiert nicht).
- **Soft** wenn es nur eine Praeferenz ist („Mathe lieber morgens",
„keine Freistunden").
Score-Bewertung im UI:
- `hard_score = 0` → Plan ist gueltig
- `hard_score < 0` → mindestens eine harte Regel ist verletzt (Solver
meldet das als `infeasible`)
- `soft_score` → wird in den UI angezeigt; je naeher an 0, desto besser
## Erweitern um einen 16. Constraint-Typ
1. Neue Tabelle in `school-service/internal/database/timetable_constraints_migrations.go`
2. Model + DTO in `models/timetable_constraints.go`
3. Service + Handler im gleichen Paket-Pattern wie die existierenden 15
4. Route in `cmd/server/main.go`
5. Rule-Dataclass in `timetable-solver-service/app/rules.py`
6. ProblemFactCollection in `domain.py`
7. ConstraintProvider-Funktion in `constraints.py` (Hard + Soft Variante)
8. Frontend: Editor-Komponente in `_components/regeln/`, dann in
`RegelnHub.tsx` registrieren
+72
View File
@@ -0,0 +1,72 @@
# Export
Drei Export-Formate fuer fertige Solutions, alle als GET-Endpoints im
school-service.
## CSV
```
GET /api/v1/school/timetable/solutions/{id}/export.csv
Content-Type: text/csv; charset=utf-8
```
Spalten: `day_of_week,period_index,start_time,end_time,class,subject,
subject_code,teacher,room,pinned`.
Komma in Feldwerten (z.B. „Schmidt, Anna") wird automatisch escaped.
Sortierung: by `(day_of_week, period_index, class_name)`.
Anwendungsfaelle:
- Import in Excel oder Google Sheets fuer Reports
- Datenuebergabe an externes Schulverwaltungs-System
- Datenarchivierung pro Schuljahr
## ICS (iCalendar, RFC 5545)
```
GET /api/v1/school/timetable/solutions/{id}/export.ics
GET /api/v1/school/timetable/solutions/{id}/export.ics?start=2026-08-24
Content-Type: text/calendar; charset=utf-8
```
Emittiert ein VEVENT pro Lesson, anchored auf die naechste Montag-Woche
(oder via `?start=YYYY-MM-DD` ueberschreibbar). Lehrer kann die Datei
direkt im Apple Calendar, Google Calendar oder Outlook importieren.
Strukturbeispiel:
```
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//BreakPilot//Timetable//DE
BEGIN:VEVENT
UID:lesson-0-d1p1-20260824@breakpilot
DTSTAMP:20260522T144800Z
DTSTART:20260824T080000
DTEND:20260824T084500
SUMMARY:Mathe (5a)
LOCATION:A101
DESCRIPTION:Lehrer: Schmidt, Anna\nSchuljahr 26/27
END:VEVENT
...
END:VCALENDAR
```
**Aktuell nur eine Kalender-Woche** — fuer ganzes Schuljahr braeuchte es
RRULE + Ferien-Exceptions, ist als Phase 9 vorgemerkt.
## Drucken (HTML print view)
Im PlanView gibt es einen „Drucken"-Button. Der Druck-Dialog des Browsers
oeffnet sich; eigene `@media print`-Stylesheet in `globals.css` blendet
Sidebar, Tabs, Help-Panel und Token-Banner aus und zwingt das Wochengrid auf
weisses A4-Format.
Vorteil ueber serverseitiges PDF: kein zusaetzliches Backend-Tool, keine
Headless-Browser-Container, der User waehlt selbst Drucker/PDF/Format.
## Aufruf vom Frontend
`lib/stundenplan/api.ts:downloadSolutionExport(solutionId, 'csv' | 'ics')`
laedt das Blob ueber den Next.js-Proxy, sodass der JWT (falls gesetzt) im
Authorization-Header weitergegeben wird. Im Dev-Mode ohne Token funktioniert
es ebenfalls.
+47
View File
@@ -0,0 +1,47 @@
# Stundenplaner
Schulweiter Stundenplan-Generator fuer den Rektor. Erfasst Klassen, Lehrer,
Faecher, Raeume + Constraints und ruft einen Timefold-basierten Solver auf,
um einen konfliktfreien Wochenplan zu produzieren.
## Architektur auf einen Blick
```
studio-v2 /stundenplan (Next.js)
│ HTTP über Next.js Proxy /api/school/*
school-service (Go/Gin, :8084)
│ ─ CRUD Stammdaten + Constraints + Solutions in PostgreSQL
│ ─ Fire-and-forget Trigger an Solver
timetable-solver-service (Python/FastAPI + Timefold, :8095)
│ ─ Liest Problem aus PG, rechnet im Worker-Thread
│ ─ Schreibt Lessons direkt nach tt_lesson
PostgreSQL (Schema `public` in `breakpilot_db`)
24 Tabellen: 7 Stammdaten + 15 Constraints + tt_solution + tt_lesson
```
## Module
| Bereich | Doku |
|---------|------|
| [Architektur + Datenmodell](architecture.md) | DB-Schema, Ownership-Modell |
| [Constraints](constraints.md) | 15 Constraint-Typen, hard/soft Semantik |
| [Solver-Tuning](solver-tuning.md) | Timefold-Konfiguration, Zeit-Budgets |
| [Export](export.md) | CSV, ICS, Drucken |
## Status
**Phasen 1-3 + 5-8 fertig** (Stand 2026-05-22, Phase 4 Untis übersprungen).
- 24 DB-Tabellen, alle 22 CRUD-Endpoints + Solve + Export-Endpoints live
- Frontend: 9 Tabs (Plan + 7 Stammdaten + Regeln), 15 Constraint-Editoren,
Wochengrid mit Pin-Funktion, 3 Perspektiven (Klasse/Lehrer/Raum)
- Tests: 73 Go + 36 Playwright + 4 Export-Unit-Tests
## Offene Punkte
- Phase 4 (Untis-Import) — verschoben, kein Kunde fordert es aktuell
- Seed-Daten fuer Demo-Schule
- Echte Auth-Integration ablöst Dev-Bypass
@@ -0,0 +1,70 @@
# Solver-Tuning
## Timefold-Konfiguration
Der `SolverFactory` wird in `runner.py` pro Solve gebaut so dass jedes
Job-Spent-Limit aus `tt_solution.seconds_limit` einzeln zur Geltung kommt.
```python
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),
),
)
```
Default Timeout: 60 s. Per Solve ueberschreibbar im UI (5600 s).
## Score-Modell
`HardSoftScore` — Hard-Komponente ist die wichtige:
- `hard_score < 0` → Solution ist `infeasible`, UI markiert in Amber.
- `hard_score == 0` → Solution gueltig, `soft_score` minimiert Praeferenz-
Verletzungen.
## Pinning fuer iterative Verbesserung
Workflow:
1. Initial-Solve laeuft → Plan A.
2. Rektor pinnt 510 Cells im UI, die ihm gefallen.
3. Neuer Solve mit `parent_solution_id = Plan A`. Der Solver nimmt die
gepinnten Cells als Fixpunkte (`@PlanningPin`) und rechnet die restlichen
Lessons neu.
4. Optional Sekunden-Limit erhoehen (z.B. 180 s) wenn die Solution-Qualitaet
wichtiger ist als die Wartezeit.
Implementierung in `repository._inherit_pinned_from_parent()`:
- Greedy First-Fit-Matching by `(class_id, subject_id)`
- Surplus pinned Rows aus dem Parent (z.B. weil curriculum-Stunden gekuerzt)
werden silently uebersprungen
- Mismatch wird in Logs ausgegeben, fuehrt aber nicht zu failed Status
## Was tun wenn der Solver `infeasible` meldet
Reihenfolge der Diagnose:
1. **Lessons-Count vs. Slots-Count**: Wenn die Summe der Wochenstunden ueber
alle Klassen > Anzahl Slots pro Woche × Anzahl Raeume ist, kann es
physisch keine Loesung geben. Stundentafel kuerzen oder mehr Raeume.
2. **Lehrer-Auslastung**: Wenn ein Lehrer mit 28 h Cap in der Stundentafel
30 h zugewiesen bekommt, ist es unloesbar. Lehrauftraege anpassen.
3. **Harte Constraints widerspruechlich**: Mathe muss morgens UND ist
`excluded_room` fuer alle Vormittags-Raeume → Konflikt. Constraints von
Hard auf Soft umstellen wo moeglich.
4. **Sekunden-Limit zu kurz**: Bei sehr restriktiven Modellen braucht der
Solver laenger zum ersten Fit-finden. 300 s probieren.
## Performance-Charakteristik
- Kleine Schule (3 Klassen, 8 Lehrer, 6 Faecher, ~80 Lessons): meist <5 s
- Mittlere Schule (15 Klassen, 30 Lehrer, ~400 Lessons): 3060 s fuer
hard_score=0, weitere Minuten fuer soft-Optimierung
- Sehr grosse Schule (>800 Lessons): Solver kommt mit 60 s Default nicht
konvergent, hoeheres Limit oder Multi-Threading evaluieren (Timefold
Enterprise)
+1 -1
View File
@@ -2,5 +2,5 @@
admin package — admin APIs for NiBiS, RAG, templates.
Backward-compatible re-exports: consumers can still use
``from admin_api import ...`` etc. via the shim files in backend/.
``from admin.api import ...`` etc. via the shim files in backend/.
"""
+1 -1
View File
@@ -7,7 +7,7 @@ This module was split into:
- admin_templates.py (Legal templates ingestion, search)
The `router` object is assembled here by including all sub-routers.
Importers that did `from admin_api import router` continue to work.
Importers that did `from admin.api import router` continue to work.
"""
from fastapi import APIRouter
+1 -1
View File
@@ -17,7 +17,7 @@ from nibis_ingestion import (
DOCS_BASE_PATH,
)
from qdrant_service import QdrantService, search_nibis_eh, get_qdrant_client
from eh_pipeline import generate_single_embedding
from korrektur.eh_pipeline import generate_single_embedding
router = APIRouter(prefix="/api/v1/admin", tags=["Admin"])
+1 -1
View File
@@ -28,7 +28,7 @@ except ImportError:
MINIO_AVAILABLE = False
try:
from metrics_db import (
from metrics.db import (
init_metrics_tables, store_feedback, log_search, log_upload,
calculate_metrics, get_recent_feedback, get_upload_history
)
+1 -1
View File
@@ -11,7 +11,7 @@ from pydantic import BaseModel
from typing import Optional, List, Dict
from datetime import datetime
from eh_pipeline import generate_single_embedding
from korrektur.eh_pipeline import generate_single_embedding
# Import legal templates modules
try:
-4
View File
@@ -1,4 +0,0 @@
# Backward-compat shim -- module moved to admin/api.py
import importlib as _importlib
import sys as _sys
_sys.modules[__name__] = _importlib.import_module("admin.api")
-4
View File
@@ -1,4 +0,0 @@
# Backward-compat shim -- module moved to admin/nibis.py
import importlib as _importlib
import sys as _sys
_sys.modules[__name__] = _importlib.import_module("admin.nibis")
-4
View File
@@ -1,4 +0,0 @@
# Backward-compat shim -- module moved to admin/rag.py
import importlib as _importlib
import sys as _sys
_sys.modules[__name__] = _importlib.import_module("admin.rag")
@@ -1,4 +0,0 @@
# Backward-compat shim -- module moved to admin/templates.py
import importlib as _importlib
import sys as _sys
_sys.modules[__name__] = _importlib.import_module("admin.templates")
@@ -2,5 +2,5 @@
compliance package — compliance pipeline, RBAC/ABAC policy engine.
Backward-compatible re-exports: consumers can still use
``from compliance_models import ...`` etc. via the shim files in backend/.
``from compliance.models import ...`` etc. via the shim files in backend/.
"""
@@ -1,4 +0,0 @@
# Backward-compat shim -- module moved to compliance/extraction.py
import importlib as _importlib
import sys as _sys
_sys.modules[__name__] = _importlib.import_module("compliance.extraction")
@@ -1,4 +0,0 @@
# Backward-compat shim -- module moved to compliance/models.py
import importlib as _importlib
import sys as _sys
_sys.modules[__name__] = _importlib.import_module("compliance.models")
@@ -1,4 +0,0 @@
# Backward-compat shim -- module moved to compliance/pipeline.py
import importlib as _importlib
import sys as _sys
_sys.modules[__name__] = _importlib.import_module("compliance.pipeline")
@@ -0,0 +1,6 @@
"""
Crawler package — GitHub repository crawler for legal templates.
Moved from backend/ flat modules (github_crawler*.py).
Backward-compatible shim files remain at the old locations.
"""

Some files were not shown because too many files have changed in this diff Show More