# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## What this app is

An AI-powered language-training / roleplay platform. Trainers author **Scenarios** (each backed by an OpenAI Assistant + a persona prompt). Students hold **chat** conversations (text or voice) against the assistant persona; the conversation is then auto-closed and graded by a separate **review assistant** that returns a grade + written review. Students can be reached either as authenticated users or anonymously via a **QR-code / public chat** link. Default UI language is German.

## Commands

```bash
composer run dev      # runs `php artisan serve`, `queue:listen`, and `npm run dev` concurrently — the normal dev loop
npm run dev           # Vite dev server only
npm run build         # production asset build

php artisan test                      # run full test suite (phpunit)
php artisan test --filter SomeTest    # run a single test class/method
./vendor/bin/phpunit                  # equivalent

./vendor/bin/pint     # format PHP (Laravel Pint) — run before committing PHP

php artisan migrate                   # DB is MySQL (see .env DB_CONNECTION)
php artisan schedule:work             # exercise the scheduler locally (see scheduled jobs below)
php artisan queue:listen --tries=1    # process queued jobs (student/public chat replies)
```

There is **no separate lint/typecheck step for the Vue frontend** — only `vite build` validates it.

## Architecture

### Stack
Laravel 11 (PHP 8.2) · Inertia.js + Vue 3 (SPA, no separate API for the UI) · Vite · vue-i18n (locale `de`). Frontend lives in `resources/js/Pages/**` (one Vue page per Inertia route), resolved by `resources/js/app.js`. Server shares `auth.user` + `auth.permissions` + `flash` to every page via `app/Http/Middleware/HandleInertiaRequests.php`.

### OpenAI Assistants integration — the core of the app
All AI calls go through the **legacy OpenAI Assistants API v2** (`OpenAI-Beta: assistants=v2`), wrapped in `app/Traits/OpenAiTrait.php` (create/update/delete assistant, create thread, create message+run, fetch run results, Whisper STT, TTS). Scenarios store an `assistant_id`; review uses either the scenario's `rating_review_assistant_id` or the global `REVIEW_ASSISTANT_ID`.

**Message flow is asynchronous and poll/run-based — this is the key thing to understand:**
1. `ChatController::sendMessage` / `audioMessage` (and `PublicChatController`) create an OpenAI message + **run**, persist a `Message` (role `user`), set `Thread.status = 1` (pending), and **return immediately without the AI answer**.
2. The completed run's reply is fetched later — either by the frontend polling `GET /fetch-message/{ulid}`, by the scheduled `app:fetch-message` command, or by queued Jobs.
3. `app/Traits/FetchMessageTrait.php` holds the per-context handlers (`handleStudentChatMessage`, `handleTrainerChatMessage`, `handleReviewRating`, `handlePublicChatMessage`) that read the run output via `fetchLatestMessages`, persist the assistant `Message`, and update thread state. **These four handlers are near-duplicates** — when changing reply parsing/persistence, check whether all of them need the same edit.

### Thread / Message state machine (magic numbers — there are no enum constants)
`Thread.type`: `1` trainer chat · `3` student chat · `4` review/rating. `Thread.status`: `0` done/idle · `1` pending (awaiting AI run) · `2` in progress. `Thread.chat_status`: `1` open · `2` closed/awaiting review · `3` reviewed (carries `grade` + `review`). `Message.type`: `1` text, `2` audio (`Message::TYPE_TEXT/TYPE_AUDIO` constants exist).

### AI response contract
Assistants are instructed (German prompt in `config/constant.php` → `default_instruction`) to return **single-line JSON** with keys `correction`, `answer`, and optional `links{label,url}`. `answer` is rendered as Markdown via `app/Custom/CustomParsedown.php`. Review assistants instead return JSON with `grade` + `review`. If JSON parsing fails, handlers fall back to a German error string and set `answer_status = 1`.

### Scheduled jobs (`routes/console.php`)
- `app:fetch-message` — **every 5 seconds**; drains type-4 (review) threads with `status=1` and `fetch_response_try < 2`.
- `app:auto-close-chat` — daily 03:00; closes open student chats (`chat_status=1`, `type=3`) and kicks off the review thread.

### Realtime
`app/Jobs/HandleStudentChatMessage.php` (and siblings) POST replies to an external **Node.js socket server** at `constant.SOCKET_URL` + `/broadcast` (not part of this repo) for live updates.

### Audio
Whisper STT + OpenAI TTS (voice configurable per scenario, default `alloy`). FFmpeg work (duration, merging a chat's audio into one file) goes through `app/Helpers/FFmpegManager.php` (php-ffmpeg). Audio files live on the `public` storage disk under `messages/audios/*` (see `Message::*_AUDIO_PATH`).

### Auth & RBAC (custom — not Spatie)
Roles/permissions are bespoke models (`Role`, `Permission`, `PermissionGroup`, `RolePermission`). `role_id == 1` is the **admin** (bypasses all permission checks — see `CheckPermission::ADMIN_ID`). The `checkpermission` middleware matches the **route name** against `Permission.route` rows; routes with no matching permission row are open. Public/QR student access uses `validate.public.session` (`EnsurePublicSession`) and `ensureuserisauthenticatedforqr` middleware instead of `auth`. Middleware aliases are registered in `bootstrap/app.php`.

### Config gotcha
OpenAI config is split across **two overlapping files**: `config/constant.php` (`constant.*` — API key, assistant IDs, socket URL, default instruction) and `config/const.php` (`const.openai.*` — endpoints, models, voices). Both read the same env var **`OPEN_AI_API_KEY`**. Know which prefix a given call uses before editing.

## Conventions
- Most domain controllers are **resource-style** (`index/create/edit/store/update/destroy`) grouped by `Route::controller(...)->prefix(...)` in `routes/web.php`, gated by `auth` + `checkpermission`.
- Models are keyed externally by **ULID** (`ulid` column) in routes/URLs, not auto-increment IDs.
- Shared logic lives in **Traits** (`app/Traits/*`) mixed into controllers/commands/jobs — prefer extending the relevant trait over duplicating in a controller.
- Error reporting goes to **Sentry** (only enabled when `APP_ENV=production`).
