Compare commits

..

104 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
Benjamin Admin dde45b29db Restructure: Move 43 files into 8 domain 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 27s
CI / test-go-edu-search (push) Successful in 40s
CI / test-python-klausur (push) Failing after 2m30s
CI / test-python-agent-core (push) Successful in 28s
CI / test-nodejs-website (push) Successful in 20s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 22:32:45 +02:00
Benjamin Admin 165c493d1e Restructure: Move 52 files into 7 domain packages
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 2m22s
CI / test-python-agent-core (push) Successful in 21s
CI / test-nodejs-website (push) Successful in 23s
korrektur/ zeugnis/ admin/ compliance/ worksheet/ training/ metrics/
52 shims, relative imports, RAG untouched.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 22:10:48 +02:00
Benjamin Admin 0504d22b8e Restructure: Move ocr_pipeline + labeling + crop into ocr/ package
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 20s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 21:51:43 +02:00
Benjamin Admin 59c400b9aa Restructure: Move grid_* + vocab_* 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 29s
CI / test-python-klausur (push) Failing after 2m31s
CI / test-python-agent-core (push) Successful in 20s
CI / test-nodejs-website (push) Successful in 23s
grid/ package (16 files):
  grid/build/   — core, zones, cleanup, text_ops, cell_ops, finalize
  grid/editor/  — api, helpers, columns, filters, headers, zones

vocab/ package (10 files):
  vocab/worksheet/ — api, models, extraction, generation, ocr, upload, analysis, compare
  vocab/           — session_store, learn_bridge

26 backward-compat shims. Internal imports relative. RAG untouched.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 21:30:20 +02:00
Benjamin Admin 098a2ff092 Fix: Resolve all lint errors from ocr/ restructure
- Added ocr_region import to cell_grid/build.py and legacy.py
- Fixed circular import in engines.py via lazy import
- Auto-fixed 22 unused imports via ruff --fix

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 21:15:53 +02:00
Benjamin Admin cb1be59e46 Restructure: Move 47 cv_* files into ocr/ package
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 2m34s
CI / test-python-agent-core (push) Successful in 20s
CI / test-nodejs-website (push) Successful in 26s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 21:03:54 +02:00
Benjamin Admin 45287b3541 Fix: Sidebar scrollable + add Eltern-Portal nav link
overflow-hidden → overflow-y-auto so all nav items are reachable.
Added /parent (Eltern-Portal) link with people icon.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 20:49:44 +02:00
Benjamin Admin d87645ffce Fix: Cast language selection to Language type
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 20:30:11 +02:00
Benjamin Admin d4959172a9 Add migration learning platform: Onboarding, Translation, Parent Portal
Phase 1.1 — user_language_api.py: Stores native language preference
per user (TR/AR/UK/RU/PL/DE/EN). Onboarding page with flag-based
language selection for students and parents.

Phase 1.2 — translation_service.py: Batch-translates vocabulary words
into target languages via Ollama LLM. Stores in translations JSONB.
New endpoint POST /vocabulary/translate triggers translation.

Phase 2.1 — Parent Portal (/parent): Simplified UI in parent's native
language showing child's learning progress. Daily tips translated.

Phase 2.2 — Parent Quiz (/parent/quiz/[unitId]): Parents can quiz
their child on vocabulary WITHOUT speaking DE or EN. Shows word in
child's learning language + parent's native language as hint.
Answer hidden by default, revealed on tap.

All UI text translated into 7 languages (DE/EN/TR/AR/UK/RU/PL).
Arabic gets RTL layout support.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 20:17:25 +02:00
Benjamin Admin b49ee3467e Fix: Revert to inline shared types (Turbopack can't resolve path aliases)
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 2m56s
CI / test-python-agent-core (push) Successful in 19s
CI / test-nodejs-website (push) Successful in 25s
Turbopack doesn't support tsconfig path aliases pointing outside
the project root. Reverted to copying shared types directly into
each service. The canonical source remains shared/types/*.ts,
synced via scripts/sync-shared-types.sh.

Changes:
- Reverted docker-compose.yml contexts to ./service
- Reverted Dockerfiles to simple COPY . .
- Removed @shared/* from tsconfigs
- Removed symlinks + .gitignore hacks
- Added scripts/sync-shared-types.sh for keeping copies in sync

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 17:13:18 +02:00
Benjamin Admin 2eb17fd349 Fix: Copy shared/ inside project dir for Turbopack + add symlinks for dev
Turbopack only resolves tsconfig paths within the project root.
Changed @shared/* from ../shared/* to ./shared/* in all tsconfigs.
Docker copies shared/ into the project dir at build time.
Local dev uses symlinks (gitignored).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 16:58:03 +02:00
Benjamin Admin 06ea9f7073 Fix: COPY shared/ to ../shared/ (relative to WORKDIR /app) for tsconfig path resolution
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 16:42:45 +02:00
Benjamin Admin f3b9617fc3 Add 6 Anton-inspired features for vocabulary learning
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 34s
CI / test-python-klausur (push) Failing after 2m28s
CI / test-python-agent-core (push) Successful in 20s
CI / test-nodejs-website (push) Successful in 25s
Feature 1 — StarRating: 1-3 stars per exercise (100%=3, 70%=2, <70%=1)
Feature 2 — Progress bar in UnitCard with Leitner box distribution
Feature 3 — Listening exercise: hear word via TTS, choose correct translation
Feature 4 — Matching game: tap-to-match EN↔DE pairs (6 per round)
Feature 5 — Pronunciation: word with syllable bows + mic → STT comparison
Feature 6 — Syllable bows in FlashCards (SyllableBow under word + IPA)

UnitCard now shows 6 exercise types: Karten, Quiz, Tippen, Hoeren,
Zuordnen, Sprechen. Progress bar and star count displayed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 16:10:58 +02:00
Benjamin Admin 8efffe8c52 Fix: Use @shared/* alias instead of relative paths for Docker compat
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 16:08:14 +02:00
Benjamin Admin a317bd6164 [interface-change] Phase 4: Extract shared types + fix Docker context
Shared types extracted to shared/types/:
- companion.ts (33+ types, was 100% duplicated admin-lehrer ↔ studio-v2)
- klausur.ts (18+ types, was 95% duplicated across 4 locations)
- ocr-labeling.ts (11 types, was 100% duplicated admin-lehrer ↔ website)

Original type files replaced with re-exports for backward compat.
tsconfig.json paths updated with @shared/* alias in all 3 services.

Docker: Changed build context from ./service to . (root) so shared/
is accessible. Dockerfiles updated to COPY service/ + shared/.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 15:52:19 +02:00
503 changed files with 29988 additions and 1859 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 |
+3
View File
@@ -27,9 +27,11 @@
# Algorithmic monolith — detect_column_geometry() allein 411 LOC, nicht weiter teilbar
**/cv_layout_columns.py | owner=klausur | reason=detect_column_geometry ist eine einzelne 411-LOC Funktion (Whitespace-Gap-Analyse) | review=2026-10-01
**/ocr/layout/columns.py | owner=klausur | reason=Same file moved to ocr/ package | review=2026-10-01
# Two indivisible route handlers (~230 LOC each) that cannot be split further
**/vocab_worksheet_compare_api.py | owner=klausur | reason=compare_ocr_methods (234 LOC) + analyze_grid (255 LOC), each a single cohesive handler | review=2026-10-01
**/vocab/worksheet/compare_api.py | owner=klausur | reason=Same file moved to vocab/ package | review=2026-10-01
# TypeScript Data Catalogs (admin-lehrer/lib/sdk/)
# Pure exported const arrays/objects with type definitions, no business logic.
@@ -45,6 +47,7 @@
# Single SSE generator orchestrating 6 pipeline steps — cannot split generator context
**/ocr_pipeline_auto_steps.py | owner=klausur | reason=run_auto is a single async generator yielding SSE events across 6 steps (528 LOC) | review=2026-10-01
**/ocr/pipeline/auto_steps.py | owner=klausur | reason=Same file moved to ocr/ package | review=2026-10-01
# Legacy — TEMPORAER bis Refactoring abgeschlossen
# Dateien hier werden Phase fuer Phase abgearbeitet und entfernt.
+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
@@ -1,5 +1,9 @@
/**
* TypeScript types for OCR Labeling UI
* Shared TypeScript types for OCR Labeling UI.
*
* Single source of truth used by:
* - admin-lehrer (ai/ocr-labeling)
* - website (admin/ocr-labeling)
*/
/**
@@ -1,93 +1,24 @@
/**
* Types and constants for the Korrektur-Workspace page.
*
* Domain types are re-exported from the shared module.
* Only the API_BASE constant remains local (uses Next.js rewrite proxy).
*/
import type { CriteriaScores } from '../../../types'
export type {
ExaminerInfo,
ExaminerResult,
ExaminerWorkflow,
ActiveTab,
GradeTotals,
CriteriaScores,
} from '../../../../types'
// ---- Examiner workflow types ----
export interface ExaminerInfo {
id: string
assigned_at: string
notes?: string
}
export interface ExaminerResult {
grade_points: number
criteria_scores?: CriteriaScores
notes?: string
submitted_at: string
}
export interface ExaminerWorkflow {
student_id: string
workflow_status: string
visibility_mode: string
user_role: 'ek' | 'zk' | 'dk' | 'viewer'
first_examiner?: ExaminerInfo
second_examiner?: ExaminerInfo
third_examiner?: ExaminerInfo
first_result?: ExaminerResult
first_result_visible?: boolean
second_result?: ExaminerResult
third_result?: ExaminerResult
grade_difference?: number
final_grade?: number
consensus_reached?: boolean
consensus_type?: string
einigung?: {
final_grade: number
notes: string
type: string
submitted_by: string
submitted_at: string
ek_grade: number
zk_grade: number
}
drittkorrektur_reason?: string
}
// ---- Active tab ----
export type ActiveTab = 'kriterien' | 'gutachten' | 'annotationen' | 'eh-vorschlaege'
// ---- Totals from grade calculation ----
export interface GradeTotals {
raw: number
weighted: number
gradePoints: number
}
// ---- Constants ----
export {
WORKFLOW_STATUS_LABELS,
ROLE_LABELS,
GRADE_LABELS,
} from '../../../../types'
/** Same-origin proxy to avoid CORS issues */
export const API_BASE = '/klausur-api'
export const GRADE_LABELS: Record<number, string> = {
15: '1+', 14: '1', 13: '1-', 12: '2+', 11: '2', 10: '2-',
9: '3+', 8: '3', 7: '3-', 6: '4+', 5: '4', 4: '4-',
3: '5+', 2: '5', 1: '5-', 0: '6',
}
export const WORKFLOW_STATUS_LABELS: Record<string, { label: string; color: string }> = {
not_started: { label: 'Nicht gestartet', color: 'bg-slate-100 text-slate-700' },
ek_in_progress: { label: 'EK in Arbeit', color: 'bg-blue-100 text-blue-700' },
ek_completed: { label: 'EK abgeschlossen', color: 'bg-blue-200 text-blue-800' },
zk_assigned: { label: 'ZK zugewiesen', color: 'bg-amber-100 text-amber-700' },
zk_in_progress: { label: 'ZK in Arbeit', color: 'bg-amber-200 text-amber-800' },
zk_completed: { label: 'ZK abgeschlossen', color: 'bg-amber-300 text-amber-900' },
einigung_required: { label: 'Einigung erforderlich', color: 'bg-orange-100 text-orange-700' },
einigung_completed: { label: 'Einigung abgeschlossen', color: 'bg-green-100 text-green-700' },
drittkorrektur_required: { label: 'DK erforderlich', color: 'bg-red-100 text-red-700' },
drittkorrektur_assigned: { label: 'DK zugewiesen', color: 'bg-red-200 text-red-800' },
drittkorrektur_in_progress: { label: 'DK in Arbeit', color: 'bg-red-300 text-red-900' },
completed: { label: 'Abgeschlossen', color: 'bg-green-200 text-green-800' },
}
export const ROLE_LABELS: Record<string, { label: string; color: string }> = {
ek: { label: 'Erstkorrektor', color: 'bg-blue-500' },
zk: { label: 'Zweitkorrektor', color: 'bg-amber-500' },
dk: { label: 'Drittkorrektor', color: 'bg-purple-500' },
viewer: { label: 'Betrachter', color: 'bg-slate-500' },
}
@@ -1,34 +1,11 @@
/**
* Local form types for Klausur-Korrektur page
* Local form types for Klausur-Korrektur page.
* Re-exported from the shared module.
*/
export interface CreateKlausurForm {
title: string
subject: string
year: number
semester: string
modus: 'abitur' | 'vorabitur'
}
export interface VorabiturEHForm {
aufgabentyp: string
titel: string
text_titel: string
text_autor: string
aufgabenstellung: string
}
export interface EHTemplate {
aufgabentyp: string
name: string
description: string
category: string
}
export interface DirektuploadForm {
files: File[]
ehFile: File | null
ehText: string
aufgabentyp: string
klausurTitle: string
}
export type {
CreateKlausurForm,
VorabiturEHForm,
EHTemplate,
DirektuploadForm,
} from '../../types'
@@ -1,4 +1,16 @@
// TypeScript Interfaces für Klausur-Korrektur
/**
* Shared Klausur-Korrektur types and constants.
*
* This is the single source of truth used by:
* - admin-lehrer (education/klausur-korrektur)
* - studio-v2 (korrektur)
* - website/admin (klausur-korrektur)
* - website/lehrer (klausur-korrektur)
*/
// ---------------------------------------------------------------------------
// Core domain interfaces
// ---------------------------------------------------------------------------
export interface Klausur {
id: string
@@ -6,7 +18,7 @@ export interface Klausur {
subject: string
year: number
semester: string
modus: 'abitur' | 'vorabitur'
modus: KlausurModus
eh_id?: string
created_at: string
student_count?: number
@@ -14,6 +26,9 @@ export interface Klausur {
status?: 'draft' | 'in_progress' | 'completed'
}
/** Union of all modus values used across services */
export type KlausurModus = 'abitur' | 'vorabitur' | 'landes_abitur'
export interface StudentWork {
id: string
klausur_id: string
@@ -65,6 +80,10 @@ export interface GradeInfo {
criteria: Record<string, Criterion>
}
// ---------------------------------------------------------------------------
// Annotations
// ---------------------------------------------------------------------------
export interface Annotation {
id: string
student_work_id: string
@@ -96,6 +115,10 @@ export type AnnotationType =
| 'comment'
| 'highlight'
// ---------------------------------------------------------------------------
// Fairness analysis
// ---------------------------------------------------------------------------
export interface FairnessAnalysis {
klausur_id: string
student_count: number
@@ -123,13 +146,35 @@ export interface CriteriaStats {
std_deviation: number
}
// ---------------------------------------------------------------------------
// EH suggestions
// ---------------------------------------------------------------------------
export interface EHSuggestion {
criterion: string
excerpt: string
relevance_score: number
source_chunk_id: string
// Attribution fields (CTRL-SRC-002)
source_document?: string
source_url?: string
license?: string
license_url?: string
publisher?: string
}
/** Default Attribution for NiBiS documents (CTRL-SRC-002) */
export const NIBIS_ATTRIBUTION = {
publisher: 'Niedersaechsischer Bildungsserver (NiBiS)',
license: 'DL-DE-BY-2.0',
license_url: 'https://www.govdata.de/dl-de/by-2-0',
source_url: 'https://nibis.de',
} as const
// ---------------------------------------------------------------------------
// Gutachten
// ---------------------------------------------------------------------------
export interface GutachtenSection {
title: string
content: string
@@ -145,7 +190,10 @@ export interface Gutachten {
generated_at?: string
}
// API Response Types
// ---------------------------------------------------------------------------
// API response types
// ---------------------------------------------------------------------------
export interface KlausurenResponse {
klausuren: Klausur[]
total: number
@@ -160,7 +208,22 @@ export interface AnnotationsResponse {
annotations: Annotation[]
}
// Color mapping for annotation types
// ---------------------------------------------------------------------------
// Create / update types
// ---------------------------------------------------------------------------
export interface CreateKlausurData {
title: string
subject?: string
year?: number
semester?: string
modus?: KlausurModus
}
// ---------------------------------------------------------------------------
// Constants — annotation colors
// ---------------------------------------------------------------------------
export const ANNOTATION_COLORS: Record<AnnotationType, string> = {
rechtschreibung: '#dc2626', // Red
grammatik: '#2563eb', // Blue
@@ -171,7 +234,10 @@ export const ANNOTATION_COLORS: Record<AnnotationType, string> = {
highlight: '#eab308', // Yellow
}
// Status colors
// ---------------------------------------------------------------------------
// Constants — status colors & labels
// ---------------------------------------------------------------------------
export const STATUS_COLORS: Record<StudentStatus, string> = {
UPLOADED: '#6b7280',
OCR_PROCESSING: '#eab308',
@@ -193,3 +259,174 @@ export const STATUS_LABELS: Record<StudentStatus, string> = {
COMPLETED: 'Abgeschlossen',
ERROR: 'Fehler',
}
// ---------------------------------------------------------------------------
// Constants — criteria & grades
// ---------------------------------------------------------------------------
/** Default criteria with weights (Niedersachsen standard) */
export const DEFAULT_CRITERIA: Record<string, { name: string; weight: number }> = {
rechtschreibung: { name: 'Rechtschreibung', weight: 15 },
grammatik: { name: 'Grammatik', weight: 15 },
inhalt: { name: 'Inhalt', weight: 40 },
struktur: { name: 'Struktur', weight: 15 },
stil: { name: 'Stil', weight: 15 },
}
/** Grade thresholds (15-point system) */
export const GRADE_THRESHOLDS: Record<number, number> = {
15: 95, 14: 90, 13: 85, 12: 80, 11: 75,
10: 70, 9: 65, 8: 60, 7: 55, 6: 50,
5: 45, 4: 40, 3: 33, 2: 27, 1: 20, 0: 0,
}
// ---------------------------------------------------------------------------
// Helper functions
// ---------------------------------------------------------------------------
/** Calculate grade points from a percentage (0-100). */
export function calculateGrade(percentage: number): number {
for (const [grade, threshold] of Object.entries(GRADE_THRESHOLDS).sort(
(a, b) => Number(b[0]) - Number(a[0]),
)) {
if (percentage >= threshold) {
return Number(grade)
}
}
return 0
}
/** Human-readable label for a 15-point grade value. */
export function getGradeLabel(points: number): string {
const labels: Record<number, string> = {
15: '1+', 14: '1', 13: '1-',
12: '2+', 11: '2', 10: '2-',
9: '3+', 8: '3', 7: '3-',
6: '4+', 5: '4', 4: '4-',
3: '5+', 2: '5', 1: '5-',
0: '6',
}
return labels[points] || String(points)
}
// ---------------------------------------------------------------------------
// Examiner workflow types (workspace)
// ---------------------------------------------------------------------------
export interface ExaminerInfo {
id: string
assigned_at: string
notes?: string
}
export interface ExaminerResult {
grade_points: number
criteria_scores?: CriteriaScores
notes?: string
submitted_at: string
}
export interface ExaminerWorkflow {
student_id: string
workflow_status: string
visibility_mode: string
user_role: 'ek' | 'zk' | 'dk' | 'viewer'
first_examiner?: ExaminerInfo
second_examiner?: ExaminerInfo
third_examiner?: ExaminerInfo
first_result?: ExaminerResult
first_result_visible?: boolean
second_result?: ExaminerResult
third_result?: ExaminerResult
grade_difference?: number
final_grade?: number
consensus_reached?: boolean
consensus_type?: string
einigung?: {
final_grade: number
notes: string
type: string
submitted_by: string
submitted_at: string
ek_grade: number
zk_grade: number
}
drittkorrektur_reason?: string
}
export type ActiveTab = 'kriterien' | 'gutachten' | 'annotationen' | 'eh-vorschlaege'
export interface GradeTotals {
raw: number
weighted: number
gradePoints: number
}
// ---------------------------------------------------------------------------
// Constants — workflow status & roles
// ---------------------------------------------------------------------------
export const GRADE_LABELS: Record<number, string> = {
15: '1+', 14: '1', 13: '1-', 12: '2+', 11: '2', 10: '2-',
9: '3+', 8: '3', 7: '3-', 6: '4+', 5: '4', 4: '4-',
3: '5+', 2: '5', 1: '5-', 0: '6',
}
export const WORKFLOW_STATUS_LABELS: Record<string, { label: string; color: string }> = {
not_started: { label: 'Nicht gestartet', color: 'bg-slate-100 text-slate-700' },
ek_in_progress: { label: 'EK in Arbeit', color: 'bg-blue-100 text-blue-700' },
ek_completed: { label: 'EK abgeschlossen', color: 'bg-blue-200 text-blue-800' },
zk_assigned: { label: 'ZK zugewiesen', color: 'bg-amber-100 text-amber-700' },
zk_in_progress: { label: 'ZK in Arbeit', color: 'bg-amber-200 text-amber-800' },
zk_completed: { label: 'ZK abgeschlossen', color: 'bg-amber-300 text-amber-900' },
einigung_required: { label: 'Einigung erforderlich', color: 'bg-orange-100 text-orange-700' },
einigung_completed: { label: 'Einigung abgeschlossen', color: 'bg-green-100 text-green-700' },
drittkorrektur_required: { label: 'DK erforderlich', color: 'bg-red-100 text-red-700' },
drittkorrektur_assigned: { label: 'DK zugewiesen', color: 'bg-red-200 text-red-800' },
drittkorrektur_in_progress: { label: 'DK in Arbeit', color: 'bg-red-300 text-red-900' },
completed: { label: 'Abgeschlossen', color: 'bg-green-200 text-green-800' },
}
export const ROLE_LABELS: Record<string, { label: string; color: string }> = {
ek: { label: 'Erstkorrektor', color: 'bg-blue-500' },
zk: { label: 'Zweitkorrektor', color: 'bg-amber-500' },
dk: { label: 'Drittkorrektor', color: 'bg-purple-500' },
viewer: { label: 'Betrachter', color: 'bg-slate-500' },
}
// ---------------------------------------------------------------------------
// Form types (create / upload)
// ---------------------------------------------------------------------------
export interface CreateKlausurForm {
title: string
subject: string
year: number
semester: string
modus: 'abitur' | 'vorabitur'
}
export interface VorabiturEHForm {
aufgabentyp: string
titel: string
text_titel: string
text_autor: string
aufgabenstellung: string
}
export interface EHTemplate {
aufgabentyp: string
name: string
description: string
category: string
}
export interface DirektuploadForm {
files: File[]
ehFile: File | null
ehText: string
aufgabentyp: string
klausurTitle: string
}
export type TabId = 'willkommen' | 'klausuren' | 'erstellen' | 'direktupload' | 'statistiken'
+1
View File
@@ -0,0 +1 @@
# abitur — Abitur document management (exam docs, recognition).
@@ -24,7 +24,7 @@ from pathlib import Path
from fastapi import APIRouter, HTTPException, UploadFile, File, Form, BackgroundTasks
from fastapi.responses import FileResponse
from abitur_docs_models import (
from .models import (
Bundesland, Fach, Niveau, DokumentTyp, VerarbeitungsStatus,
DokumentCreate, DokumentUpdate, DokumentResponse, ImportResult,
RecognitionResult, AbiturDokument,
@@ -32,7 +32,7 @@ from abitur_docs_models import (
# Backwards-compatibility re-exports
AbiturFach, Anforderungsniveau, DocumentMetadata, AbiturDokumentCompat,
)
from abitur_docs_recognition import parse_nibis_filename, to_dokument_response
from .recognition import parse_nibis_filename, to_dokument_response
logger = logging.getLogger(__name__)
@@ -8,7 +8,7 @@ import re
from typing import Dict, Any
from pathlib import Path
from abitur_docs_models import (
from .models import (
Bundesland, Fach, Niveau, DokumentTyp, VerarbeitungsStatus,
RecognitionResult, AbiturDokument, DokumentResponse,
FACH_NAME_MAPPING,
+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
+86
View File
@@ -0,0 +1,86 @@
"""
User Language Preferences API — Stores native language + learning level.
Each user (student, parent, teacher) can set their native language.
This drives: UI language, third-language display in flashcards,
parent portal language, and translation generation.
Supported languages: de, en, tr, ar, uk, ru, pl
"""
import logging
import os
from typing import Any, Dict, Optional
from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/user", tags=["user-language"])
# Supported native languages with metadata
SUPPORTED_LANGUAGES = {
"de": {"name": "Deutsch", "name_native": "Deutsch", "flag": "de", "rtl": False},
"en": {"name": "English", "name_native": "English", "flag": "gb", "rtl": False},
"tr": {"name": "Tuerkisch", "name_native": "Turkce", "flag": "tr", "rtl": False},
"ar": {"name": "Arabisch", "name_native": "\u0627\u0644\u0639\u0631\u0628\u064a\u0629", "flag": "sy", "rtl": True},
"uk": {"name": "Ukrainisch", "name_native": "\u0423\u043a\u0440\u0430\u0457\u043d\u0441\u044c\u043a\u0430", "flag": "ua", "rtl": False},
"ru": {"name": "Russisch", "name_native": "\u0420\u0443\u0441\u0441\u043a\u0438\u0439", "flag": "ru", "rtl": False},
"pl": {"name": "Polnisch", "name_native": "Polski", "flag": "pl", "rtl": False},
}
# In-memory store (will be replaced with DB later)
_preferences: Dict[str, Dict[str, Any]] = {}
class LanguagePreference(BaseModel):
native_language: str # ISO 639-1 code
role: str = "student" # student, parent, teacher
learning_level: str = "A1" # A1, A2, B1, B2, C1
@router.get("/languages")
def get_supported_languages():
"""List all supported native languages with metadata."""
return {
"languages": [
{"code": code, **meta}
for code, meta in SUPPORTED_LANGUAGES.items()
]
}
@router.get("/language-preference")
def get_language_preference(user_id: str = Query("default")):
"""Get user's language preference."""
pref = _preferences.get(user_id)
if not pref:
return {"user_id": user_id, "native_language": "de", "role": "student", "learning_level": "A1", "is_default": True}
return {**pref, "is_default": False}
@router.put("/language-preference")
def set_language_preference(
pref: LanguagePreference,
user_id: str = Query("default"),
):
"""Set user's native language and learning level."""
if pref.native_language not in SUPPORTED_LANGUAGES:
raise HTTPException(
status_code=400,
detail=f"Sprache '{pref.native_language}' nicht unterstuetzt. "
f"Verfuegbar: {', '.join(SUPPORTED_LANGUAGES.keys())}",
)
_preferences[user_id] = {
"user_id": user_id,
"native_language": pref.native_language,
"role": pref.role,
"learning_level": pref.learning_level,
}
lang_meta = SUPPORTED_LANGUAGES[pref.native_language]
logger.info(f"Language preference set: user={user_id} lang={pref.native_language} ({lang_meta['name']})")
return {**_preferences[user_id], "language_meta": lang_meta}
@@ -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,
+1
View File
@@ -0,0 +1 @@
# correction — Klassenarbeits-Korrektur (grading, feedback, OCR).
@@ -4,8 +4,8 @@ Correction API - REST API fuer Klassenarbeits-Korrektur.
Barrel re-export: router and all public symbols.
"""
from correction_endpoints import router # noqa: F401
from correction_models import ( # noqa: F401
from .endpoints import router # noqa: F401
from .models import ( # noqa: F401
CorrectionStatus,
AnswerEvaluation,
CorrectionCreate,
@@ -15,7 +15,7 @@ from correction_models import ( # noqa: F401
OCRResponse,
AnalysisResponse,
)
from correction_helpers import ( # noqa: F401
from .helpers import ( # noqa: F401
corrections_store,
calculate_grade,
generate_ai_feedback,
@@ -18,7 +18,7 @@ from pathlib import Path
from fastapi import APIRouter, HTTPException, UploadFile, File, BackgroundTasks
from correction_models import (
from .models import (
CorrectionStatus,
AnswerEvaluation,
CorrectionCreate,
@@ -28,7 +28,7 @@ from correction_models import (
AnalysisResponse,
UPLOAD_DIR,
)
from correction_helpers import (
from .helpers import (
corrections_store,
calculate_grade,
generate_ai_feedback,
@@ -5,7 +5,7 @@ Correction API - Helper functions for grading, feedback, and OCR processing.
import logging
from typing import List, Dict
from correction_models import AnswerEvaluation, CorrectionStatus, Correction
from .models import AnswerEvaluation, CorrectionStatus, Correction
logger = logging.getLogger(__name__)
+1
View File
@@ -0,0 +1 @@
# dashboard — Teacher dashboard, unit assignments, analytics.
@@ -7,7 +7,7 @@ from typing import List, Optional, Dict, Any
from datetime import datetime, timedelta
import logging
from teacher_dashboard_models import (
from .models import (
UnitAssignmentStatus, TeacherControlSettings,
UnitAssignment, StudentUnitProgress, ClassUnitProgress,
MisconceptionReport, ClassAnalyticsSummary, ContentResource,
@@ -14,14 +14,14 @@ from datetime import datetime, timedelta
import uuid
import logging
from teacher_dashboard_models import (
from .models import (
UnitAssignmentStatus, TeacherControlSettings, AssignUnitRequest,
UnitAssignment,
get_current_teacher, get_teacher_database,
get_classes_for_teacher,
REQUIRE_AUTH,
)
from teacher_dashboard_analytics import (
from .analytics import (
router as analytics_router,
set_assignments_store,
)
@@ -7,16 +7,16 @@
# - 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
from game_routes import router as _core_router
from game_session_routes import router as _session_router
from game_extended_routes import router as _extended_router
from .routes import router as _core_router
from .session_routes import router as _session_router
from .extended_routes import router as _extended_router
# Re-export models for any direct importers
from game_models import ( # noqa: F401
from .game_models import ( # noqa: F401
LearningLevel,
GameDifficulty,
QuizQuestion,
@@ -28,7 +28,7 @@ from game_models import ( # noqa: F401
)
# Re-export helpers/state for any direct importers
from game_routes import ( # noqa: F401
from .routes import ( # noqa: F401
get_optional_current_user,
get_user_id_from_auth,
get_game_database,
@@ -9,7 +9,7 @@ from fastapi import APIRouter, HTTPException, Query, Depends, Request
from typing import List, Optional, Dict, Any
import logging
from game_routes import (
from .routes import (
get_optional_current_user,
get_user_id_from_auth,
get_game_database,
@@ -13,7 +13,7 @@ import uuid
import os
import logging
from game_models import (
from .game_models import (
LearningLevel,
GameDifficulty,
QuizQuestion,
@@ -11,7 +11,7 @@ from datetime import datetime
import uuid
import logging
from game_models import (
from .game_models import (
LearningLevel,
QuizQuestion,
GameSession,
@@ -23,7 +23,7 @@ logger = logging.getLogger(__name__)
# Import shared state and helpers from game_routes
# (these are the canonical instances)
from game_routes import (
from .routes import (
get_optional_current_user,
get_user_id_from_auth,
get_game_database,
+1
View File
@@ -0,0 +1 @@
# letters — Elternbriefe and Zeugnisse (certificates).
@@ -30,7 +30,7 @@ except (ImportError, OSError):
SchoolInfo = None # type: ignore
_pdf_available = False
from letters_models import (
from .models import (
LetterType,
LetterTone,
LetterStatus,
@@ -22,7 +22,7 @@ except (ImportError, OSError):
SchoolInfo = None # type: ignore
_pdf_available = False
from certificates_models import (
from .certificates_models import (
CertificateType,
CertificateStatus,
BehaviorGrade,
+26 -18
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,66 +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")
from unit_api import router as unit_router
# --- 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 api.user_language import router as user_language_router
app.include_router(user_language_router, prefix="/api")
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
View File
@@ -0,0 +1 @@
# messenger — Kontakte, Konversationen, Nachrichten, Gruppen.
@@ -13,8 +13,8 @@ Split into:
from fastapi import APIRouter
from messenger_contacts import router as _contacts_router
from messenger_conversations import router as _conversations_router
from .contacts import router as _contacts_router
from .conversations import router as _conversations_router
router = APIRouter(prefix="/api/messenger", tags=["Messenger"])
router.include_router(_contacts_router)
@@ -13,13 +13,13 @@ from typing import List, Optional
from fastapi import APIRouter, HTTPException, UploadFile, File, Query
from fastapi.responses import StreamingResponse
from messenger_models import (
from .models import (
Contact,
ContactCreate,
ContactUpdate,
CSVImportResult,
)
from messenger_helpers import get_contacts, save_contacts
from .helpers import get_contacts, save_contacts
router = APIRouter(tags=["Messenger"])
@@ -10,14 +10,14 @@ from typing import List, Optional
from fastapi import APIRouter, HTTPException, Query
from messenger_models import (
from .models import (
Conversation,
Group,
GroupCreate,
Message,
MessageBase,
)
from messenger_helpers import (
from .helpers import (
DATA_DIR,
DEFAULT_TEMPLATES,
get_contacts,
@@ -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"],
+1
View File
@@ -0,0 +1 @@
# recording — Meeting recordings, transcription, minutes.
@@ -12,9 +12,9 @@ Split into:
from fastapi import APIRouter
from recording_routes import router as _routes_router
from recording_transcription import router as _transcription_router
from recording_minutes import router as _minutes_router
from .routes import router as _routes_router
from .transcription import router as _transcription_router
from .minutes import router as _minutes_router
router = APIRouter(prefix="/api/recordings", tags=["Recordings"])
router.include_router(_routes_router)
@@ -10,7 +10,7 @@ from typing import Optional
from fastapi import APIRouter, HTTPException, Query
from fastapi.responses import PlainTextResponse, HTMLResponse
from recording_helpers import (
from .helpers import (
_recordings_store,
_transcriptions_store,
_minutes_store,
@@ -11,7 +11,7 @@ from typing import Optional
from fastapi import APIRouter, HTTPException, Query, Request
from fastapi.responses import JSONResponse
from recording_models import (
from .models import (
JibriWebhookPayload,
RecordingResponse,
RecordingListResponse,
@@ -19,7 +19,7 @@ from recording_models import (
MINIO_BUCKET,
DEFAULT_RETENTION_DAYS,
)
from recording_helpers import (
from .helpers import (
_recordings_store,
_transcriptions_store,
_audit_log,
@@ -11,11 +11,11 @@ from typing import Optional
from fastapi import APIRouter, HTTPException
from fastapi.responses import PlainTextResponse
from recording_models import (
from .models import (
TranscriptionRequest,
TranscriptionStatusResponse,
)
from recording_helpers import (
from .helpers import (
_recordings_store,
_transcriptions_store,
log_audit,
+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
+179
View File
@@ -0,0 +1,179 @@
"""
Translation Service — Batch-translates vocabulary words into target languages.
Uses Ollama (local LLM) to translate EN/DE word pairs into TR, AR, UK, RU, PL.
Translations are cached in vocabulary_words.translations JSONB field.
All processing happens locally — no external API calls, GDPR-compliant.
"""
import json
import logging
import os
from typing import Any, Dict, List
import httpx
logger = logging.getLogger(__name__)
OLLAMA_BASE_URL = os.getenv("OLLAMA_BASE_URL", "http://host.docker.internal:11434")
TRANSLATION_MODEL = os.getenv("TRANSLATION_MODEL", "qwen3:30b-a3b")
LANGUAGE_NAMES = {
"tr": "Turkish",
"ar": "Arabic",
"uk": "Ukrainian",
"ru": "Russian",
"pl": "Polish",
"fr": "French",
"es": "Spanish",
}
async def translate_words_batch(
words: List[Dict[str, str]],
target_language: str,
batch_size: int = 30,
) -> List[Dict[str, str]]:
"""
Translate a batch of EN/DE word pairs into a target language.
Args:
words: List of dicts with 'english' and 'german' keys
target_language: ISO 639-1 code (tr, ar, uk, ru, pl)
batch_size: Words per LLM request
Returns:
List of dicts with 'english', 'translation', 'example' keys
"""
lang_name = LANGUAGE_NAMES.get(target_language, target_language)
all_translations = []
for i in range(0, len(words), batch_size):
batch = words[i:i + batch_size]
word_list = "\n".join(
f"{j+1}. {w['english']} = {w.get('german', '')}"
for j, w in enumerate(batch)
)
prompt = f"""Translate these English/German word pairs into {lang_name}.
For each word, provide the translation and a short example sentence in {lang_name}.
Words:
{word_list}
Reply ONLY with a JSON array, no explanation:
[
{{"english": "word", "translation": "...", "example": "..."}},
...
]"""
try:
async with httpx.AsyncClient(timeout=120.0) as client:
resp = await client.post(
f"{OLLAMA_BASE_URL}/api/generate",
json={
"model": TRANSLATION_MODEL,
"prompt": prompt,
"stream": False,
"options": {"temperature": 0.2, "num_predict": 4096},
},
)
resp.raise_for_status()
response_text = resp.json().get("response", "")
# Parse JSON from response
import re
match = re.search(r'\[[\s\S]*\]', response_text)
if match:
batch_translations = json.loads(match.group())
all_translations.extend(batch_translations)
logger.info(
f"Translated batch {i//batch_size + 1}: "
f"{len(batch_translations)} words → {lang_name}"
)
else:
logger.warning(f"No JSON array in LLM response for {lang_name}")
except Exception as e:
logger.error(f"Translation batch failed ({lang_name}): {e}")
return all_translations
async def translate_and_store(
word_ids: List[str],
target_language: str,
) -> int:
"""
Translate vocabulary words and store in the database.
Fetches words from DB, translates via LLM, stores in translations JSONB.
Skips words that already have a translation for the target language.
Returns count of newly translated words.
"""
from vocabulary.db import get_pool
pool = await get_pool()
async with pool.acquire() as conn:
# Fetch words that need translation
rows = await conn.fetch(
"""
SELECT id, english, german, translations
FROM vocabulary_words
WHERE id = ANY($1::uuid[])
""",
[__import__('uuid').UUID(wid) for wid in word_ids],
)
words_to_translate = []
word_map = {}
for row in rows:
translations = row["translations"] or {}
if isinstance(translations, str):
translations = json.loads(translations)
if target_language not in translations:
words_to_translate.append({
"english": row["english"],
"german": row["german"],
})
word_map[row["english"].lower()] = str(row["id"])
if not words_to_translate:
logger.info(f"All {len(rows)} words already translated to {target_language}")
return 0
# Translate
results = await translate_words_batch(words_to_translate, target_language)
# Store results
updated = 0
async with pool.acquire() as conn:
for result in results:
en = result.get("english", "").lower()
word_id = word_map.get(en)
if not word_id:
continue
translation = result.get("translation", "")
example = result.get("example", "")
if not translation:
continue
await conn.execute(
"""
UPDATE vocabulary_words
SET translations = translations || $1::jsonb
WHERE id = $2
""",
json.dumps({target_language: {
"text": translation,
"example": example,
}}),
__import__('uuid').UUID(word_id),
)
updated += 1
logger.info(f"Stored {updated} translations for {target_language}")
return updated
+1
View File
@@ -0,0 +1 @@
# units — Learning units, analytics, definitions, content generation.
@@ -17,8 +17,8 @@ Split into:
from fastapi import APIRouter
from unit_analytics_routes import router as _routes_router
from unit_analytics_export import router as _export_router
from .analytics_routes import router as _routes_router
from .analytics_export import router as _export_router
router = APIRouter(prefix="/api/analytics", tags=["Unit Analytics"])
router.include_router(_routes_router)
@@ -11,8 +11,8 @@ from typing import Optional, Dict, Any
from fastapi import APIRouter, Query
from fastapi.responses import Response
from unit_analytics_models import TimeRange, ExportFormat
from unit_analytics_helpers import get_analytics_database
from .analytics_models import TimeRange, ExportFormat
from .analytics_helpers import get_analytics_database
logger = logging.getLogger(__name__)
@@ -76,7 +76,7 @@ async def export_misconceptions(
Export misconception data for further analysis.
"""
# Import here to avoid circular dependency
from unit_analytics_routes import get_misconception_report
from .analytics_routes import get_misconception_report
report = await get_misconception_report(
class_id=class_id, unit_id=None,
@@ -12,7 +12,7 @@ from typing import Optional, Dict, Any, List
from fastapi import APIRouter, Query
from unit_analytics_models import (
from .analytics_models import (
TimeRange,
LearningGainData,
LearningGainSummary,
@@ -23,7 +23,7 @@ from unit_analytics_models import (
StudentProgressTimeline,
ClassComparisonData,
)
from unit_analytics_helpers import (
from .analytics_helpers import (
get_analytics_database,
calculate_gain_distribution,
calculate_trend,
@@ -8,16 +8,16 @@
# - 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
from unit_routes import router as _routes_router
from unit_definition_routes import router as _definition_router
from unit_content_routes import router as _content_router
from .routes import router as _routes_router
from .definition_routes import router as _definition_router
from .content_routes import router as _content_router
# Re-export models for any direct importers
from unit_models import ( # noqa: F401
from .models import ( # noqa: F401
UnitDefinitionResponse,
CreateSessionRequest,
SessionResponse,
@@ -36,7 +36,7 @@ from unit_models import ( # noqa: F401
)
# Re-export helpers for any direct importers
from unit_helpers import ( # noqa: F401
from .helpers import ( # noqa: F401
get_optional_current_user,
get_unit_database,
create_session_token,
@@ -8,8 +8,8 @@ from fastapi import APIRouter, HTTPException, Query, Depends
from typing import Optional, Dict, Any
import logging
from unit_models import UnitDefinitionResponse
from unit_helpers import get_optional_current_user, get_unit_database
from .models import UnitDefinitionResponse
from .helpers import get_optional_current_user, get_unit_database
logger = logging.getLogger(__name__)
@@ -9,13 +9,13 @@ from typing import Optional, Dict, Any
from datetime import datetime
import logging
from unit_models import (
from .models import (
UnitDefinitionResponse,
CreateUnitRequest,
UpdateUnitRequest,
ValidationResult,
)
from unit_helpers import (
from .helpers import (
get_optional_current_user,
get_unit_database,
validate_unit_definition,
@@ -11,7 +11,7 @@ import os
import logging
import jwt
from unit_models import ValidationError, ValidationResult
from .models import ValidationError, ValidationResult
logger = logging.getLogger(__name__)
@@ -8,7 +8,7 @@ import logging
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from learning_units import (
from .learning import (
LearningUnit,
LearningUnitCreate,
LearningUnitUpdate,
@@ -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,
@@ -12,7 +12,7 @@ from datetime import datetime, timedelta
import uuid
import logging
from unit_models import (
from .models import (
UnitDefinitionResponse,
CreateSessionRequest,
SessionResponse,
@@ -23,7 +23,7 @@ from unit_models import (
UnitListItem,
RecommendedUnit,
)
from unit_helpers import (
from .helpers import (
get_optional_current_user,
get_unit_database,
create_session_token,
+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",
]
+406
View File
@@ -0,0 +1,406 @@
"""
Vocabulary API — Search, browse, and build learning units from the word catalog.
Endpoints for teachers to find words and create learning units,
and for students to access word details with audio/images/syllables.
"""
import logging
import json
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel
from .db import (
search_words,
get_word,
browse_words,
insert_word,
count_words,
get_all_tags,
get_all_pos,
VocabularyWord,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/vocabulary", tags=["vocabulary"])
# ---------------------------------------------------------------------------
# Search & Browse
# ---------------------------------------------------------------------------
@router.get("/search")
async def api_search_words(
q: str = Query("", description="Search query"),
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.
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],
"query": q,
"total": len(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"),
difficulty: int = Query(0, ge=0, le=5, description="Difficulty 1-5, 0=all"),
tag: str = Query("", description="Tag filter"),
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
):
"""Browse vocabulary words with filters."""
words = await browse_words(
pos=pos, difficulty=difficulty, tag=tag,
limit=limit, offset=offset,
)
return {
"words": [w.to_dict() for w in words],
"filters": {"pos": pos, "difficulty": difficulty, "tag": tag},
"total": len(words),
}
@router.get("/word/{word_id}")
async def api_get_word(word_id: str):
"""Get a single word with all details."""
word = await get_word(word_id)
if not word:
raise HTTPException(status_code=404, detail="Wort nicht gefunden")
return word.to_dict()
@router.get("/filters")
async def api_get_filters():
"""Get available filter options (tags, parts of speech, word count)."""
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,
}
# ---------------------------------------------------------------------------
# Audio TTS for Words
# ---------------------------------------------------------------------------
@router.get("/word/{word_id}/audio/{lang}")
async def api_get_word_audio(word_id: str, lang: str = "en"):
"""Get or generate TTS audio for a vocabulary word.
Returns MP3 audio. Generated on first request, cached after.
Uses Piper TTS (MIT license) with Thorsten (DE) and Lessac (EN) voices.
"""
from fastapi.responses import Response as FastAPIResponse
word = await get_word(word_id)
if not word:
raise HTTPException(status_code=404, detail="Wort nicht gefunden")
text = word.english if lang == "en" else word.german
if not text:
raise HTTPException(status_code=400, detail=f"Kein Text fuer Sprache '{lang}'")
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:
raise HTTPException(status_code=503, detail="TTS Service nicht verfuegbar")
return FastAPIResponse(content=audio_bytes, media_type="audio/mpeg")
@router.get("/word/{word_id}/audio-syllables/{lang}")
async def api_get_syllable_audio(word_id: str, lang: str = "en"):
"""Get TTS audio with slow syllable pronunciation.
Generates audio like "ap ... ple" with pauses between syllables.
"""
from fastapi.responses import Response as FastAPIResponse
word = await get_word(word_id)
if not word:
raise HTTPException(status_code=404, detail="Wort nicht gefunden")
syllables = word.syllables_en if lang == "en" else word.syllables_de
if not syllables:
# Fallback to full word
text = word.english if lang == "en" else word.german
syllables = [text]
# Join syllables with pauses (Piper handles "..." as pause)
slow_text = " ... ".join(syllables)
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)
if not audio_bytes:
raise HTTPException(status_code=503, detail="TTS Service nicht verfuegbar")
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
# ---------------------------------------------------------------------------
# Unit creation and translation lookup moved to vocabulary/unit_api.py
# ---------------------------------------------------------------------------
# Bulk Import (for seeding the dictionary)
# ---------------------------------------------------------------------------
class BulkImportPayload(BaseModel):
words: List[Dict[str, Any]]
@router.post("/import")
async def api_bulk_import(payload: BulkImportPayload):
"""Bulk import vocabulary words (for seeding the dictionary).
Each word dict should have at minimum: english, german.
Optional: ipa_en, ipa_de, part_of_speech, syllables_en, syllables_de,
example_en, example_de, difficulty, tags, translations.
"""
from .db import insert_words_bulk
words = []
for w in payload.words:
words.append(VocabularyWord(
english=w.get("english", ""),
german=w.get("german", ""),
ipa_en=w.get("ipa_en", ""),
ipa_de=w.get("ipa_de", ""),
part_of_speech=w.get("part_of_speech", ""),
syllables_en=w.get("syllables_en", []),
syllables_de=w.get("syllables_de", []),
example_en=w.get("example_en", ""),
example_de=w.get("example_de", ""),
difficulty=w.get("difficulty", 1),
tags=w.get("tags", []),
translations=w.get("translations", {}),
))
count = await insert_words_bulk(words)
logger.info(f"Bulk imported {count} vocabulary words")
return {"imported": count}
# ---------------------------------------------------------------------------
# Translation Generation
# ---------------------------------------------------------------------------
@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
@router.post("/translate")
async def api_translate_words(payload: TranslateRequest):
"""Generate translations for vocabulary words into a target language.
Uses local LLM (Ollama) for translation. Results are cached in the
vocabulary_words.translations JSONB field.
"""
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")
count = await translate_and_store(payload.word_ids, payload.target_language)
return {"translated": count, "target_language": payload.target_language}
+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
-326
View File
@@ -1,326 +0,0 @@
"""
Vocabulary API — Search, browse, and build learning units from the word catalog.
Endpoints for teachers to find words and create learning units,
and for students to access word details with audio/images/syllables.
"""
import logging
import json
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel
from vocabulary_db import (
search_words,
get_word,
browse_words,
insert_word,
count_words,
get_all_tags,
get_all_pos,
VocabularyWord,
)
from learning_units import (
LearningUnitCreate,
create_learning_unit,
get_learning_unit,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/vocabulary", tags=["vocabulary"])
# ---------------------------------------------------------------------------
# Search & Browse
# ---------------------------------------------------------------------------
@router.get("/search")
async def api_search_words(
q: str = Query("", description="Search query"),
lang: str = Query("en", pattern="^(en|de)$"),
limit: int = Query(20, ge=1, le=100),
offset: int = Query(0, ge=0),
):
"""Full-text search for vocabulary words."""
if not q.strip():
return {"words": [], "query": q, "total": 0}
words = await search_words(q.strip(), lang=lang, limit=limit, offset=offset)
return {
"words": [w.to_dict() for w in words],
"query": q,
"total": len(words),
}
@router.get("/browse")
async def api_browse_words(
pos: str = Query("", description="Part of speech filter"),
difficulty: int = Query(0, ge=0, le=5, description="Difficulty 1-5, 0=all"),
tag: str = Query("", description="Tag filter"),
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
):
"""Browse vocabulary words with filters."""
words = await browse_words(
pos=pos, difficulty=difficulty, tag=tag,
limit=limit, offset=offset,
)
return {
"words": [w.to_dict() for w in words],
"filters": {"pos": pos, "difficulty": difficulty, "tag": tag},
"total": len(words),
}
@router.get("/word/{word_id}")
async def api_get_word(word_id: str):
"""Get a single word with all details."""
word = await get_word(word_id)
if not word:
raise HTTPException(status_code=404, detail="Wort nicht gefunden")
return word.to_dict()
@router.get("/filters")
async def api_get_filters():
"""Get available filter options (tags, parts of speech, word count)."""
tags = await get_all_tags()
pos_list = await get_all_pos()
total = await count_words()
return {
"tags": tags,
"parts_of_speech": pos_list,
"total_words": total,
}
# ---------------------------------------------------------------------------
# Audio TTS for Words
# ---------------------------------------------------------------------------
@router.get("/word/{word_id}/audio/{lang}")
async def api_get_word_audio(word_id: str, lang: str = "en"):
"""Get or generate TTS audio for a vocabulary word.
Returns MP3 audio. Generated on first request, cached after.
Uses Piper TTS (MIT license) with Thorsten (DE) and Lessac (EN) voices.
"""
from fastapi.responses import Response as FastAPIResponse
word = await get_word(word_id)
if not word:
raise HTTPException(status_code=404, detail="Wort nicht gefunden")
text = word.english if lang == "en" else word.german
if not text:
raise HTTPException(status_code=400, detail=f"Kein Text fuer Sprache '{lang}'")
from audio_service import get_or_generate_audio
audio_bytes = await get_or_generate_audio(text, language=lang, word_id=word_id)
if not audio_bytes:
raise HTTPException(status_code=503, detail="TTS Service nicht verfuegbar")
return FastAPIResponse(content=audio_bytes, media_type="audio/mpeg")
@router.get("/word/{word_id}/audio-syllables/{lang}")
async def api_get_syllable_audio(word_id: str, lang: str = "en"):
"""Get TTS audio with slow syllable pronunciation.
Generates audio like "ap ... ple" with pauses between syllables.
"""
from fastapi.responses import Response as FastAPIResponse
word = await get_word(word_id)
if not word:
raise HTTPException(status_code=404, detail="Wort nicht gefunden")
syllables = word.syllables_en if lang == "en" else word.syllables_de
if not syllables:
# Fallback to full word
text = word.english if lang == "en" else word.german
syllables = [text]
# Join syllables with pauses (Piper handles "..." as pause)
slow_text = " ... ".join(syllables)
from audio_service 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)
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", []),
}
# ---------------------------------------------------------------------------
# Bulk Import (for seeding the dictionary)
# ---------------------------------------------------------------------------
class BulkImportPayload(BaseModel):
words: List[Dict[str, Any]]
@router.post("/import")
async def api_bulk_import(payload: BulkImportPayload):
"""Bulk import vocabulary words (for seeding the dictionary).
Each word dict should have at minimum: english, german.
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
words = []
for w in payload.words:
words.append(VocabularyWord(
english=w.get("english", ""),
german=w.get("german", ""),
ipa_en=w.get("ipa_en", ""),
ipa_de=w.get("ipa_de", ""),
part_of_speech=w.get("part_of_speech", ""),
syllables_en=w.get("syllables_en", []),
syllables_de=w.get("syllables_de", []),
example_en=w.get("example_en", ""),
example_de=w.get("example_de", ""),
difficulty=w.get("difficulty", 1),
tags=w.get("tags", []),
translations=w.get("translations", {}),
))
count = await insert_words_bulk(words)
logger.info(f"Bulk imported {count} vocabulary words")
return {"imported": count}
+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)
@@ -0,0 +1,6 @@
"""
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/.
"""
@@ -7,23 +7,23 @@ 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
from admin_nibis import router as _nibis_router
from admin_rag import router as _rag_router
from admin_templates import router as _templates_router
from .nibis import router as _nibis_router
from .rag import router as _rag_router
from .templates import router as _templates_router
# Re-export internal state for test importers
from admin_nibis import ( # noqa: F401
from .nibis import ( # noqa: F401
_ingestion_status,
NiBiSSearchRequest,
search_nibis,
)
from admin_rag import _upload_history # noqa: F401
from admin_templates import _templates_ingestion_status # noqa: F401
from .rag import _upload_history # noqa: F401
from .templates import _templates_ingestion_status # noqa: F401
# Assemble the combined router.
# All sub-routers use prefix="/api/v1/admin", so include without extra prefix.
@@ -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"])
@@ -18,7 +18,7 @@ import os
from nibis_ingestion import run_ingestion, DOCS_BASE_PATH
# Import ingestion status from nibis module for auto-ingest
from admin_nibis import _ingestion_status
from .nibis import _ingestion_status
# Optional: MinIO and PostgreSQL integrations
try:
@@ -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
)
@@ -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:

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