> ## Documentation Index
> Fetch the complete documentation index at: https://theteam-13fc7f49.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# AI Assistant Integration

> How LMS_ai_bot (the FastAPI RAG chatbot) plugs into LMS_Backend (Laravel) and LMS_Frontend (Flutter): pairing keys, student access tokens, training catalog sync, quotas, and the phased plan.

> [!NOTE]
>
> **Status: plan, nothing implemented yet.** Written on 04/11/2026 from the code of the four repos
> (`LMS_Backend`, `LMS_Frontend` and `LMS_Documentation` on `chat+ai`; `LMS_ai_bot` on
> `before-integration`). It supersedes `LMS_Project/AI_ASSISTANT_INTEGRATION.md` (14/10/2026); §13
> lists what changed between the two.

## 1. The goal in one paragraph

A student who owns a training opens its course player
(`/student-my-content/student-training-details/:id`), pulls out the right side panel and asks a
question. The bot answers only from the Markdown files you uploaded for that training. You keep
feeding the bot from its own React admin (`http://localhost:5174/admin`), but instead of typing a
training id you pick the training from a dropdown filled from the Laravel catalog. The link between
the two systems is set up once by the **super admin**: an **application key** so the bot can read
Laravel's catalog, and a **signing secret** so Laravel can issue short-lived student tokens the bot
trusts. Every conversation is stored in the bot with the student's identity, and every student has
a daily quota.

## 2. Your mental model, checked

| # | What you described | Verdict | What to adjust |
| :- | :- | :- | :- |
| 1 | The bot works on the student side, inside the training page's right side panel. | ✅ Correct | The panel already exists as a mockup (`TrainingAiAssistantWidget`, markers `TODO(ai)`). Only the wiring is missing. |
| 2 | From the super admin dashboard I generate an **application key** that replaces `dev-admin-key`. | ⚠️ Two different keys | `dev-admin-key` is the bot's **own** bootstrap admin key (`ADMIN_API_KEY`): it lets *you* call the bot's admin API. The key the super admin generates is for the **bot to call Laravel**. Keep both ideas, but as two credentials: retire `dev-admin-key` in favour of the bot's existing email + password login, and add the Laravel application key as a new "LMS connection" setting in the bot admin. |
| 3 | The bot gets **limited** access to a few Laravel endpoints, no security leak. | ✅ Correct | Three read-only endpoints under `/api/ai-bot/*`, outside Sanctum, protected by the hashed application key, throttled, returning whitelisted fields only (no lesson content, no PII). |
| 4 | *(not in your description)* How does the **student** prove to the bot who they are and that they own the training? | ❌ Missing piece | The application key cannot do this: it is one key for the whole bot. Laravel must mint a **per-student, per-training token** (JWT, 30 minutes) after checking ownership. Flutter sends that token to the bot; the bot verifies it offline. See §5.3. |
| 5 | I feed the RAG manually with `.md` files per training. | ✅ Unchanged | Same upload flow; only the way you pick the training changes. |
| 6 | Save every conversation in the bot's history with the **email as the unique id**. | ⚠️ Nearly | Key conversations on the **Laravel user id** (`lms:123`) and store the email next to it as the display label. Emails are unique today but can be edited; the id never changes. The History page still shows and filters by email. |
| 7 | A **default usage cap per user** (tokens or questions per day), the same for everyone. | ✅ Mostly there | The bot already counts questions per minute and per day (`usage_counters`) and tokens per answer (`messages.tokens`). Add a **daily token budget**, make the three limits editable in the bot admin, and return "questions left today" to Flutter so the panel can show it. |
| 8 | After adding the key, a **Fetch trainings** button fills a dropdown with names, slugs and ids. | ✅ Correct, one change | The React admin must not hold the Laravel key in the browser. The button calls the **bot backend**, which calls Laravel with the key stored server-side, then caches the catalog in `trainings_ref`. The dropdown shows label · slug · id · company · public/private · AI on/off · number of documents. |
| 9 | The catalog tells the bot which trainings are **public or not**. | ✅ Correct | Rows carry `is_public`, `published`, `is_active` (not archived or deleted) and `ai_assistant_enabled`. Private trainings keep the assistant: access is decided per student by ownership, not by `is_public`. |
| 10 | *(implicit)* Which value is the bot's `training_id`: the Laravel **id** or the **slug**? | 💡 Recommendation: the **id** | Slugs are editable (`POST /training/{id}`), so keying on them needs rename detection and a re-key routine (the 14/10 plan spent a whole section on it). The id is immutable and already what the course player route uses. The slug stays visible everywhere through the synced catalog. Decision #1 in §12. |

## 3. What exists today (verified in the code)

### LMS\_ai\_bot (FastAPI + pgvector + React admin)

| Fact | Where |
| :- | :- |
| Every table is keyed by `training_id` (`String(64)`): documents, chunks, bot\_configs, conversations. | `backend/app/models/tables.py` |
| Three auth paths: `ADMIN_API_KEY`, a JWT "signed by the LMS" (`LMS_JWT_SECRET`, falls back to `JWT_SECRET`), and the admin login session. Nothing issues LMS tokens yet. | `backend/app/core/security.py`, `backend/app/api/deps.py` |
| Chat routes authenticate the caller but do not yet check which training a token may use. The integration adds that check on every route (§9.2). | `backend/app/api/chat.py` |
| The conversation owner is recorded from the token's `email` when present and compared with `sub` later. The integration settles on one owner key, `lms:<sub>`, for both. | `backend/app/api/chat.py` |
| LMS tokens and the bot's own session tokens are verified with the same secret and the same claim rules. The integration verifies them separately (own secret, `iss` / `aud` / `typ`) and never lets an LMS token carry admin scope. | `backend/app/core/security.py` |
| `trainings_ref` (cached training name) is never written; prompts fall back to the raw id. | `backend/app/repositories/configs.py`, `backend/app/services/ingestion.py` |
| Rate limit per student: 15 questions/minute, 300/day, Postgres-backed. Tokens are counted per answer but there is no token budget. | `backend/app/services/guardrails.py`, `backend/app/services/llm.py` |
| `recent_turns()` exists but is never called: follow-up questions have no memory. | `backend/app/repositories/conversations.py`, `services/rag.py` |
| Admin UI: free-text "Training ID" (default `"1"`) and a manual "Admin token / key" field on Admin, Chat, Analytics. The login session already drives the API; the field is only an override. | `frontend/src/features/admin/AdminPage.tsx`, `lib/api.ts` |
| SSE streaming exists (`POST .../messages/stream`), answers are French or English only, conversations carry a `CONV-000042` reference. | `backend/app/api/chat.py`, `schemas/schemas.py` |
| `httpx` (HTTP client) and `cryptography` (via `python-jose`) are already installed: no new dependency for calling Laravel or encrypting settings. | `backend/requirements.txt` |
| Secrets-hygiene items for the bot repo are tracked privately (workspace plan §4). | `LMS_Project/AI_ASSISTANT_INTEGRATION.md` |
| The Plesk recipe exists (`proxy_buffering off` for SSE, systemd unit, pgvector install). | `hosting_in_plesk.md` |

### LMS\_Backend (Laravel 12)

| Fact | Where |
| :- | :- |
| Sanctum tokens never expire and carry every ability: they must **never** be forwarded to the bot. | `config/sanctum.php` |
| **Ownership = paid order item**: `orders.type = 3`, status 4 or 5, order not archived/deleted, `order_items.access_revoked = false`. Ready-made helper: `MarketplacePreviewService::ownedTrainingIds($user, [$id])`. Free assignments and vouchers create Paid-Offline orders at price 0, so the same rule covers them. | `app/Services/MarketplacePreviewService.php` |
| Do **not** use `users.owned_trainings` (JSON slug cache, goes stale) or `User::trainings()` (miswired join). | `LMS_Backend/CLAUDE.md` §12 |
| `trainings.slug`: nullable, unique, server-generated, editable on update. `trainings.id` is immutable. | `app/Models/Training.php` |
| No machine-to-machine auth exists. The pattern to copy is `StorageInspectorController::authorizeAccess` (`hash_equals` on a header). | `app/Http/Controllers/StorageInspectorController.php` |
| `platform_settings` (`key` unique, `encrypted` flag with transparent encrypt/decrypt accessors) is a ready home for the bot URL and the signing secret. | `app/Models/PlatformSetting.php` |
| Roles: 5 = Super Admin (`isSuperAdmin()`); `CompanyPolicyMiddleware` lets super admins bypass the company check. | `app/Models/Role.php`, `app/Http/Middleware/CompanyPolicyMiddleware.php` |
| `ai_assistant_enabled` does **not** exist in the backend yet (frontend-only flag, Flutter Update 38). | grep of `app/` and `database/` |
| A few dev-only convenience routes still sit outside the auth groups. Locking them down is a Phase 0 item, tracked privately. | `LMS_Project/AI_ASSISTANT_INTEGRATION.md` §4 |
| `jwt-auth` is installed but unused; HS256 signing is a few lines of native PHP, no package needed. | `composer.json` |
| Conventions: config in `config/custom_config.php`, inline `validate()`, errors as `{message}`, tests in `tests/Feature` with `tests/Support/CreatesLmsFixtures.php`. | `LMS_Backend/CLAUDE.md` |

### LMS\_Frontend (Flutter)

| Fact | Where |
| :- | :- |
| The assistant panel: `CoursePlayerAssistantPanel` (web side panel 320/380 px, phone end drawer) hosting `TrainingAiAssistantWidget`, a static mockup: seeded bubbles, disabled input, chips and Send show "coming soon". Wiring points are marked `TODO(ai)`. | `lib/ui/pages/student_pages/training/training/course_player_assistant.dart`, `lib/ui/widgets/training/training_ai_assistant_widget.dart` |
| Per-training on/off toggle, **frontend-only** (SharedPreferences). The backend move is already written down in `major_code_updates.md` Update 38. | `lib/core/utils/resources/ai_assistant_local_settings.dart` |
| The open training (`id`, `slug`, `label`, `instructorId`), lesson and chapter are in `StudentTrainingProvider`. | `lib/core/providers/student_training_provider.dart` |
| One `Dio` built in `locator.dart` (Bearer + Accept-Language interceptor). Base URL hand-edited in `constants.dart` (TEST is active). No SSE code. | `lib/locator.dart`, `lib/core/constants/constants.dart` |
| `markdown` is in `pubspec.lock` through `flutter_quill`; `AppHtmlContent` renders HTML. | `pubspec.lock` |
| The super admin shell already has a platform configuration route: `/super-admin-admin-platform-configuration`. | `lib/core/routes/app_routes/super_admin_app_routes.dart` |
| App languages en / fr / ar; the bot answers in fr / en. | `assets/translations/` |

## 4. Target architecture

```mermaid theme={null}
flowchart LR
    subgraph SA["Super admin (Flutter)"]
        P["AI Assistant settings page<br/>generate application key<br/>rotate signing secret<br/>set bot URL and TTL"]
    end
    subgraph L["LMS_Backend (Laravel)"]
        K["ai_bot_api_keys<br/>platform_settings"]
        C["GET /api/ai-bot/*<br/>catalog, application key"]
        T["POST /api/ai-assistant/trainings/:idOrSlug/token<br/>Sanctum + ownership check"]
    end
    subgraph B["LMS_ai_bot (FastAPI + pgvector)"]
        A["React admin<br/>LMS connection, training picker,<br/>.md upload, limits, history"]
        S["lms_client sync<br/>trainings_ref cache"]
        CH["/api/chat/*<br/>verifies the student token,<br/>tid == training, quota, RAG"]
    end
    subgraph ST["Student (Flutter course player)"]
        W["Assistant side panel"]
    end
    P -->|Sanctum| K
    A -->|paste key + secret once| S
    S -->|X-AI-Bot-Key| C
    W -->|1. Sanctum| T
    T -->|2. JWT 30 min + bot URL| W
    W -->|3. Bearer JWT| CH
```

**Who talks to whom**

* The bot calls Laravel only for the **catalog** (admin actions), never during a chat.
* Laravel never calls the bot. It only signs tokens the bot can verify offline.
* Flutter talks to **both**: Laravel for the token, the bot for the chat. The bot's public URL comes
  back with the token, so there is no new constant in `constants.dart` and switching
  `Constants.baseUrl` to another environment switches the bot too.
* One bot instance per Laravel environment (test, prod): separate database, separate keys.

## 5. The three credentials

```mermaid theme={null}
flowchart TB
    subgraph one["1 · Application key (new)"]
        d1["bot ➜ Laravel catalog<br/>created by the super admin, shown once<br/>Laravel keeps a sha256 hash<br/>the bot keeps it encrypted, server-side"]
    end
    subgraph two["2 · Token signing secret (new)"]
        d2["Laravel signs ➜ bot verifies<br/>student tokens, HS256, 30 min<br/>rotated from the super admin page<br/>pasted once into the bot admin"]
    end
    subgraph three["3 · Bot admin login (exists)"]
        d3["you ➜ bot admin API<br/>email + password, device sessions<br/>ADMIN_API_KEY (dev-admin-key) becomes a dev-only fallback"]
    end
```

| Credential | Format | Laravel side | Bot side |
| :- | :- | :- | :- |
| Application key | `lmsbot_<8 char prefix>_<40 hex>` | `ai_bot_api_keys`: `key_prefix` (indexed), `key_hash` = sha256, `scopes`, `last_used_at`, `revoked_at` | `integration_settings.lms_service_key` (Fernet-encrypted) or `LMS_SERVICE_KEY` env |
| Signing secret | 64 hex (`random_bytes(32)`) | `platform_settings` key `ai_bot.jwt_secret`, `encrypted = 1` | `integration_settings.lms_jwt_secret` or `LMS_JWT_SECRET` env |
| Bot admin login | email + password | none | `users` + `auth_sessions` (exists) |

> [!WARNING]
>
> The bot's own `JWT_SECRET` (admin sessions) must be a **different** value from the signing
> secret. The bot should refuse to start outside development when the two are equal, and an LMS
> token must never grant bot-admin rights whatever it claims (§9.2).

### 5.1 Why the secrets live in the bot's database and not only in `.env`

Your flow is "paste the key in the admin page, click Fetch". The bot's settings are read once at
startup (`get_settings()` is cached), so an `.env`-only design would need an SSH restart every time
you pair or rotate. A small `integration_settings` table (key, encrypted value) makes the UI flow
work and `.env` stays as the override for production hosts that prefer it: when an env value is
set, the UI shows the field as read-only.

### 5.2 Why a per-student token and not the application key

The application key identifies the **bot**, not a student. A chat request must prove (a) which
user is asking and (b) that this user owns this training. Three ways to do it were compared:

| Option | Verdict |
| :- | :- |
| **A. Laravel mints a short-lived JWT per student and training; the bot verifies it offline** | ✅ Chosen. No Laravel call per message, least privilege (one training, 30 min), streaming works bot-to-client. Cost: a revoked student keeps a token for at most 30 min. |
| B. Flutter sends its Sanctum token to the bot, the bot asks Laravel on every message | ❌ A never-expiring, all-powerful token reaches a third service, plus one Laravel round-trip per message. |
| C. Proxy the whole chat through Laravel | ❌ PHP workers would hold SSE streams open. Laravel stays the gatekeeper, not the pipe. |

### 5.3 The student token

Issued by `POST /api/ai-assistant/trainings/{idOrSlug}/token` (Sanctum, blocked-user and company
middlewares), signed HS256 with the signing secret:

| Claim | Value | Used by the bot for |
| :- | :- | :- |
| `iss` | `lms-backend` | strict verification |
| `aud` | `lms-ai-bot` | strict verification |
| `typ` | `lms_student` | tells it apart from the bot's own `session` tokens |
| `sub` | Laravel `users.id` as a string | owner key `lms:<sub>`, rate limit key |
| `tid` | Laravel `trainings.id` as a string | **the only training this token may touch** (`tid == training_id`) |
| `slg` | training slug | display only |
| `cid` | `company_id` | analytics, future per-company limits |
| `role` | `role_id` | History page shows who was testing |
| `name`, `email` | full name, email | `conversations.student_label`; the bot ignores any `scopes` / `is_admin` claim |
| `iat`, `exp`, `jti` | now, now + TTL, uuid | expiry, audit |

Laravel refuses to mint when: the training is unknown, archived or deleted (404); it belongs to
another company and the caller is not a super admin (404); `ai_assistant_enabled` is false (403
`assistant_disabled`); the caller does not own it (403 `not_owned`); the pairing is incomplete,
no secret or no bot URL (503 `assistant_not_configured`). Recommended addition: company Admin,
Super Admin and the training's instructor also get a token so staff can test from the player
(decision #2).

## 6. Flows

### 6.1 Pairing (once per environment)

```mermaid theme={null}
sequenceDiagram
    autonumber
    actor SA as Super admin
    participant FA as Flutter super admin page
    participant L as LMS_Backend
    participant BA as Bot admin UI
    participant BB as Bot backend
    SA->>FA: Generate application key ("prod bot")
    FA->>L: POST /api/ai-assistant/admin/keys
    L-->>FA: plain key shown once, prefix, scopes
    SA->>FA: Rotate signing secret, set bot URL, TTL
    FA->>L: POST .../signing-secret/rotate, PUT .../settings
    L-->>FA: secret shown once, fingerprint
    SA->>BA: Paste LMS API URL, key and secret in "LMS connection"
    BA->>BB: PUT /api/admin/lms/settings
    BB->>L: GET /api/ai-bot/ping (X-AI-Bot-Key)
    L-->>BB: ok, key label, signing-secret fingerprint
    BB-->>BA: Connected, "signing secret matches"
    SA->>BA: Fetch trainings
    BA->>BB: POST /api/admin/lms/sync
    BB->>L: GET /api/ai-bot/trainings?include_inactive=1&page=n
    L-->>BB: id, slug, label, company, flags, updated_at
    BB-->>BA: created / updated / deactivated counts
```

The fingerprint is `substr(sha256(secret), 0, 12)`. Laravel returns it in `ping`, the bot computes
the same from the pasted secret and compares: you learn about a typo at pairing time, not from a
student's 401.

### 6.2 Feeding a training (unchanged, except the picker)

**1. Pick the training**

In the bot admin, the "Training ID" box becomes a searchable dropdown fed by
`GET /api/admin/lms/trainings`. Each row shows the label, then `slug · #id · company`, badges for
public/private, AI on/off, and the number of documents already uploaded. The selected value is
the Laravel **id**.

**2. Upload the Markdown**

Same `UploadDocs` component: `.md` / `.markdown` / `.txt`, 10 MB max, re-uploading the same
filename replaces its chunks. Ingestion now fills `trainings_ref` from the catalog so prompts
and the fallback message use the training label from the first upload.

**3. Tune the bot (optional)**

System prompt, model, temperature, top-k, similarity threshold: unchanged `GuardrailEditor`.

**4. Students can ask**

Any student who owns that training, when `ai_assistant_enabled` is true in Laravel.

### 6.3 A student asks a question

```mermaid theme={null}
sequenceDiagram
    autonumber
    participant F as Flutter course player
    participant L as LMS_Backend
    participant B as LMS_ai_bot
    F->>L: POST /api/ai-assistant/trainings/:idOrSlug/token (Sanctum)
    alt owns the training, assistant enabled
        L-->>F: 200 token (30 min), ai_base_url, training, limits
    else not owned / disabled / unknown / not paired
        L-->>F: 403 · 404 · 503 with a code (panel state)
    end
    F->>B: GET /api/chat/trainings/:tid/status (Bearer token)
    B-->>F: name, has_content, documents, usage left today
    F->>B: POST /api/chat/sessions (on the first question only)
    B-->>F: session_id, reference CONV-000042
    F->>B: POST /api/chat/sessions/:sid/messages
    B->>B: verify JWT · tid == convo.training_id · quota · RAG on tid only
    B-->>F: answer, sources, fallback, model, usage
    Note over F,B: 401 (expired): Flutter asks Laravel for a new token once, then retries
    Note over F,B: 429: the panel shows "quota reached" with the reset time
```

### 6.4 Revocation, renames and quotas

* **Access revoked or refunded** (`access_revoked = true`): Laravel stops minting at once; a token
  already issued works until it expires (30 min at most). Lower the TTL to shrink the window.
* **Assistant switched off** for a training: same, 30 min at most. The Flutter header shows the
  **Off** pill from the training payload right away.
* **Slug renamed**: nothing to do. The bot is keyed on the id; the next sync refreshes the slug
  shown in the dropdown.
* **Quota reached**: the bot answers 429 with the scope (`minute`, `day`, `tokens`) and
  `resets_at`; the panel shows the message and keeps the history readable.

## 7. Data model changes

### 7.1 Laravel

```mermaid theme={null}
erDiagram
    users ||--o{ ai_bot_api_keys : "created_by"
    ai_bot_api_keys {
        bigint id PK
        string label "e.g. prod bot"
        string key_prefix "8 chars, indexed"
        string key_hash "sha256 of the full key"
        json scopes "trainings:read"
        timestamp last_used_at
        timestamp expires_at "nullable"
        timestamp revoked_at "nullable"
        bigint created_by FK
    }
    platform_settings {
        string key PK "ai_bot.public_url · ai_bot.jwt_secret (encrypted) · ai_bot.token_ttl_minutes"
    }
    trainings {
        bigint id PK
        string slug
        boolean ai_assistant_enabled "NEW · default true"
    }
```

* `ai_bot_api_keys`: one migration. Lookup by `key_prefix`, then `hash_equals` on the hash;
  `last_used_at` updated at most once a minute (no write per request).
* `platform_settings`: no migration, three rows with `company_id = null`.
* `trainings.ai_assistant_enabled`: boolean default `true`, `$fillable`, `nullable|boolean` on
  `POST /training/{id}` guarded by `request()->has()`, present in `GET /training/{id}`,
  `GET /trainings`, `GET /preview-training/{idOrSlug}` and the student's `my_trainings[]` cards
  (`AuthUserPayloadService`, whitelist-based: add the field there).

### 7.2 Bot (Alembic `0004_lms_integration`)

```mermaid theme={null}
erDiagram
    trainings_ref ||--o{ documents : "training_id (soft)"
    trainings_ref ||--o{ bot_configs : "training_id (soft)"
    trainings_ref ||--o{ conversations : "training_id (soft)"
    documents ||--o{ chunks : document_id
    conversations ||--o{ messages : conversation_id
    trainings_ref {
        string training_id PK "Laravel trainings.id as text"
        string slug "NEW"
        string name "label (exists)"
        bigint lms_company_id "NEW"
        string company_label "NEW"
        boolean is_public "NEW"
        boolean is_active "exists"
        boolean ai_enabled "NEW"
        timestamp synced_at "NEW"
    }
    conversations {
        string session_id
        string training_id
        string student_ref "lms:ID (owner key)"
        string student_label "NEW · email or name"
    }
    usage_counters {
        string student_ref
        string window "minute or day"
        int request_count "exists"
        int tokens_used "NEW"
    }
    integration_settings {
        string key PK "lms_api_url · lms_service_key · lms_jwt_secret"
        text value "Fernet-encrypted"
        timestamp updated_at
    }
    bot_limits {
        int questions_per_minute "default 10"
        int questions_per_day "default 60"
        int tokens_per_day "default 450000"
    }
```

`training_id` stays `String(64)`: a Laravel id fits, so the column widening of the 14/10 plan is
dropped. `bot_limits` can be three rows of `integration_settings` instead of a table.

## 8. Quotas (your "default usage per user")

| Limit | Default | Who sees it | Why |
| :- | :- | :- | :- |
| Questions per minute | 10 | nobody (429 only) | anti-abuse |
| Questions per day | 60 | **students**: "N questions left today" under the composer | predictable for learners |
| Tokens per day | 450 000 | admin analytics only | the real cost guard: a question costs about 7 k tokens (§16, Q5), so 60 questions are about 420 k |

* Defaults live in the **bot**, editable in a "Limits" card of the admin page. They apply to every
  student of every training (what you asked for). Per-training overrides can come later through
  `bot_configs`.
* The bot enforces both counters before calling the LLM and adds the answer's tokens after. The
  `MessageOut` and the `status` response gain a `usage` object: `questions_left_today`,
  `tokens_left_today`, `resets_at`. The 429 body is
  `{"code": "quota_exceeded", "scope": "day", "resets_at": "..."}`.
* Later, Laravel can override the defaults per company or per plan with a `lim` claim in the
  token; the bot would read it when present. Not needed for v1.

## 9. API contract summary

### 9.1 New in LMS\_Backend

| Method and path | Auth | Purpose |
| :- | :- | :- |
| `GET /api/ai-bot/ping` | `X-AI-Bot-Key` | `{ok, key_label, scopes, signing_secret_fingerprint, app_version}` |
| `GET /api/ai-bot/trainings` | `X-AI-Bot-Key`, `throttle:ai-bot` (60/min) | Paginated catalog. Query: `search`, `company_id`, `updated_since`, `include_inactive=1`, `per_page` (max 200). Row: `id, slug, label, company_id, company_label, is_public, published, is_active, ai_assistant_enabled, updated_at`. Never a model, never lesson content or instructor PII. |
| `GET /api/ai-bot/trainings/{id}` | same | One row, 404 `{message}` otherwise |
| `POST /api/ai-assistant/trainings/{idOrSlug}/token` | Sanctum group, `throttle:20,1` | Student (or staff) token, see §5.3. POST because it mints a credential. |
| `GET /api/ai-assistant/admin/keys` | Sanctum, `isSuperAdmin()` | List keys (prefix, label, scopes, last used, revoked) |
| `POST /api/ai-assistant/admin/keys` | same | `{label, expires_at?}`; answers the plain key **once** |
| `DELETE /api/ai-assistant/admin/keys/{id}` | same | Revoke (sets `revoked_at`) |
| `GET /api/ai-assistant/admin/settings` | same | `{public_url, token_ttl_minutes, has_signing_secret, signing_secret_fingerprint}` |
| `PUT /api/ai-assistant/admin/settings` | same | `{public_url, token_ttl_minutes}` |
| `POST /api/ai-assistant/admin/signing-secret/rotate` | same | New secret, answered **once** with its fingerprint. Rotating invalidates every live student token (30 min of 401s at most, Flutter refreshes). |

Code lives in `app/Http/Controllers/AiBotController.php` (catalog), `AiAssistantController.php`
(token + super admin management), `app/Http/Middleware/VerifyAiBotApplicationKey.php` (alias
`ai_bot_key`), `app/Support/AiBotToken.php` (native HS256 encode, refuses secrets under 32
bytes), `config/custom_config.php` for `issuer` / `audience`. Tests: `tests/Feature/AiBotCatalogTest.php`,
`AiAssistantTokenTest.php`, `AiBotAdminKeysTest.php`.

### 9.2 Changed or new in LMS\_ai\_bot

| Method and path | Auth | Change |
| :- | :- | :- |
| every `/api/chat/*` route | LMS token or bot admin | `ensure_training_access(principal, training_id)`: admins pass; an LMS token passes only when `tid == training_id` (the path id, `payload.training_id`, or `convo.training_id` on every message). 403 `training_access_denied` otherwise. |
| `GET /api/chat/trainings/{tid}/status` | same | **New.** `{training_id, name, slug, has_content, documents, usage}` |
| `GET /api/chat/me/usage` | same | **New.** Remaining quota for the caller |
| `POST /api/chat/sessions` | same | Owner key `lms:<sub>` in `student_ref`, email in `student_label`: one owner key everywhere |
| `POST .../messages`, `.../messages/stream` | same | Quota check (minute, day, tokens), `usage` in the answer, 429 body with `scope` and `resets_at`; `recent_turns()` finally passed to the prompt (6 turns) |
| `GET /api/admin/lms/settings`, `PUT` | bot admin | **New.** LMS API URL, key and secret (masked when read); env values shown read-only |
| `POST /api/admin/lms/test` | bot admin | **New.** Calls Laravel `ping`, compares the secret fingerprint |
| `GET /api/admin/lms/trainings?search=&company_id=` | bot admin | **New.** Catalog from `trainings_ref` merged with local counts (`documents`, `chunks`, `has_content`). Feeds the dropdown. The key never reaches the browser. |
| `POST /api/admin/lms/sync` | bot admin | **New.** Pages through the Laravel catalog with `include_inactive=1`, upserts `trainings_ref`, answers `{created, updated, deactivated}` |
| `GET /api/admin/limits`, `PUT` | bot admin | **New.** The three quota defaults |
| `GET /api/admin/trainings/{tid}/conversations`, analytics | bot admin | Show `student_label`, training label and slug instead of raw ids |

Token verification (`core/security.py`): `decode_lms_token` checks `iss`, `aud`, `typ`, `exp`,
`iat`, `sub`, `tid` with the signing secret only; `is_admin` is always `False` for LMS tokens;
`Principal` gains `training_id`, `display_name`, `email`. `try_decode` tries the bot's own session
secret first, then the LMS secret. Startup guard: refuse to boot outside development when the
secret is empty or equals `JWT_SECRET`.

### 9.3 Error codes the Flutter panel maps to a state

| Response | Panel state | Copy (en, to translate fr / ar) |
| :- | :- | :- |
| Laravel 403 `not_owned` | `notOwned` | "Enroll in this training to use its AI assistant" |
| Laravel 403 `assistant_disabled` | `disabled` (existing Off panel) | "AI assistant is not available here" |
| Laravel 404 | `unavailable` | "Assistant not available for this training" |
| Laravel 503 `assistant_not_configured` | `unavailable` | "Assistant temporarily unavailable" + Retry |
| Bot `has_content: false` | `noContent` | "The instructor hasn't added material to the assistant yet" |
| Bot 401 after one refresh, or unreachable | `unavailable` | "Assistant temporarily unavailable" + Retry |
| Bot 429 | `quotaExceeded` | "You've reached today's question limit, back at HH:MM" |
| Answer with `fallback: true` | normal bubble, muted style | "Not found in the course material" footnote |

## 10. Phased plan

### Phase 0 · Prerequisites (do first, half a day)

* [ ] **Security prerequisites, tracked privately** in `LMS_Project/AI_ASSISTANT_INTEGRATION.md` §4
  (this site is public): lock down the dev-only routes of `LMS_Backend` (behind `APP_ENV=local`
  * a token, like the storage inspector) and finish the secrets-hygiene pass on both repos'
    example files. Git history keeps old values, so rotation is what matters, not deletion.
* [ ] **Workspace:** add `LMS_ai_bot/` to the root `.gitignore` with a `.gitkeep`, and add
  `LMS_ai_bot >` / `ai_bot >` to the prompt-prefix table of the root `CLAUDE.md`.
* [ ] **Bot branch:** decide where the integration lands (`master`, or a new `lms-integration`
  branch from `before-integration`).

### Phase 1 · LMS\_Backend (Laravel), 2 days

* [ ] Migrations: `create_ai_bot_api_keys_table`, `add_ai_assistant_enabled_to_trainings_table`.
* [ ] Model `AiBotApiKey` (`generate(): [plain, model]`, `verify(plain)`), `Training::$fillable`
  * casts, `PlatformSetting` rows for `ai_bot.*`.
* [ ] Middleware `VerifyAiBotApplicationKey` (header only, prefix lookup, `hash_equals`, revoked
  and expiry checks, `last_used_at` throttled), `RateLimiter::for('ai-bot')`.
* [ ] `AiBotController`: `ping`, `trainings`, `training`. Whitelisted arrays, `whereNotNull('slug')`
  not required any more (id-keyed), `withoutGlobalScope('archived')->withTrashed()` only with
  `include_inactive=1`, eager-load `company:id,label` (strict mode).
* [ ] `AiAssistantController`: `token` (§5.3) and the super-admin `keys`, `settings`,
  `rotateSigningSecret`; every method starts with `isSuperAdmin()` except `token`.
* [ ] `AiBotToken::encode()` (native HS256). `config/custom_config.php` `ai_bot.issuer` /
  `ai_bot.audience`.
* [ ] `TrainingsController@update`: accept `ai_assistant_enabled`; `AuthUserPayloadService` and
  `MarketplacePreviewService::trainingCard` expose it.
* [ ] Tests (SQLite, fixtures): owner Paid-Online 200 and the token decodes; Paid-Offline at 0
  TND 200; Pending / Cancelled / Failed / revoked / Cart / Wishlist 403; other company 404;
  archived or deleted 404; disabled 403; not paired 503; blocked user; catalog 401 without
  header, 401 revoked, 200 whitelisted rows; super-admin-only management (admin gets 403).
* [ ] `insert_in_history_table` on key creation, revocation and secret rotation.

### Phase 2 · LMS\_ai\_bot backend (FastAPI), 2 to 3 days

* [ ] Alembic `0004_lms_integration` (§7.2): `trainings_ref` columns, `conversations.student_label`,
  `usage_counters.tokens_used`, `integration_settings`.
* [ ] `services/integration_settings.py`: Fernet encryption with a key derived from `JWT_SECRET`
  (`cryptography` is already installed), env override, 60 s cache.
* [ ] `services/lms_client.py` (`httpx`, 10 s timeout, `X-AI-Bot-Key`, `Accept: application/json`):
  `ping`, `list_trainings`, `get_training`; `services/lms_sync.py`: `sync_catalog`.
* [ ] `core/security.py`: `decode_lms_token`, `Principal.training_id / display_name / email`,
  `try_decode` order, startup guard in `main.py`.
* [ ] `api/deps.py`: `ensure_training_access`; `api/chat.py`: apply it on every route, owner key fix,
  `status` and `me/usage` routes, `usage` in answers, `recent_turns()` into `rag._prepare()`.
* [ ] `services/guardrails.py`: `check_quota` (minute, day, tokens) + `record_tokens`; limits
  read from settings with the §8 defaults; 429 body with `code`, `scope`, `resets_at`.
* [ ] `api/admin.py`: `lms/settings`, `lms/test`, `lms/trainings`, `lms/sync`, `limits`; history and
  analytics responses carry `student_label`, `training_name`, `slug`.
* [ ] Ingestion: fill `trainings_ref` from the catalog cache when the row is missing.
* [ ] `CORS_ORIGINS`: `http://localhost:5000` and the Flutter web domains.
* [ ] Tests: token for `tid=42` creates a session for 42 and gets 403 on 43 (session, seances,
  status, posting to a 43 session); expired / wrong `aud` / wrong `iss` / missing `typ` / wrong
  secret give 401; `scopes: ["admin"]` in an LMS token still gets 403 on `/api/admin/*`;
  `lms:1` cannot read `lms:2`'s session; quota: the 61st question of the day is 429, tokens
  accumulate; sync with a mocked transport: create, update, deactivate.
* [ ] Docs in the bot repo: `api_documentation.md` (claims table, new routes, 403 / 429 bodies),
  `README.md` (pairing, two secrets), `CLAUDE.md` (auth section, `training_id` = Laravel id).

### Phase 3 · LMS\_ai\_bot admin UI (React), 1 to 2 days

* [ ] "LMS connection" card on `/admin`: LMS API URL, application key, signing secret (masked),
  **Test connection**, **Fetch trainings** (sync) with the result and "last synced" time.
* [ ] `TrainingPicker` combobox (search, label · slug · #id · company, badges) replacing the free
  text on Admin, Chat, History, Analytics. Stores the id.
* [ ] "Limits" card (three numbers). History and Analytics show emails and training labels.
* [ ] Hide the manual "Admin token / key" field behind a "Developer" toggle, or remove it: the
  login session already authenticates the UI.
* [ ] Chat page keeps working as your test console (the admin session bypasses the training check).

### Phase 4 · LMS\_Frontend student side (Flutter), 3 days

* [ ] `api_client.dart`: `@POST('/ai-assistant/trainings/{idOrSlug}/token')` + model
  `AiAccessToken` (`token`, `expiresAt`, `aiBaseUrl`, `training`, `limits`); `build_runner`.
* [ ] `lib/core/services/ai_assistant/ai_bot_client.dart`: a **second** `Dio` (10 s connect, 90 s
  receive, no Sanctum interceptor), base URL from the token response, Bearer AI token, one
  silent refresh on 401. Retrofit client `AiBotApiClient` for `status`, `seances`, `sessions`,
  `messages`, `me/usage`. Models under `lib/core/data/remote/ai_assistant/`.
* [ ] `AiAssistantProvider` (ChangeNotifier, `locator.dart` + `providers.dart`): states
  `idle · loading · ready · notOwned · disabled · noContent · unavailable · quotaExceeded · error`;
  `openFor(training)`, `send(text)`, `newConversation()`; token cached per training and
  refreshed 60 s before expiry; session id per training in SharedPreferences
  (`aiAssistantSession_<id>`), history reloaded on open, forgotten on 403 / 404; **no session
  created until the first message**.
* [ ] Wire `training_ai_assistant_widget.dart`: live messages, chips built from the current lesson
  and chapter plus the bot's `seances`, Send, New conversation, input enabled when `ready`,
  "N questions left today" line, sources as small chips, Markdown rendered through
  `markdownToHtml()` + `AppHtmlContent` (promote `markdown` to a direct dependency, same
  version, nothing new downloaded).
* [ ] `course_player_assistant.dart`: read `training.aiAssistantEnabled` from the payload instead
  of `AiAssistantLocalSettings`; call `openFor` when the panel opens.
* [ ] Language: `en → "en"`, `fr → "fr"`, `ar → null` (auto) with a one-line note.
* [ ] Translations en / fr / ar for the §9.3 states; fix the **Off** pill reusing the discount
  `off` key ("Rem." in French, "خصم" in Arabic).
* [ ] Append the entry to `major_code_updates.md`.

### Phase 5 · LMS\_Frontend admin and super admin (Flutter), 1 to 2 days

* [ ] Super admin: an **AI Assistant** section on the platform configuration page
  (`/super-admin-admin-platform-configuration`) or a sibling route: bot URL, token TTL,
  signing secret (Rotate, shown once with Copy), application keys table (label, prefix, last
  used, Revoke) and Generate (shown once with Copy). Retrofit methods for the §9.1 admin routes.
* [ ] Admin trainings: `TrainingProvider.toggleAiAssistantForTraining` sends `ai_assistant_enabled`
  to `POST /training/{id}` and re-fetches, as Update 38 planned; delete
  `ai_assistant_local_settings.dart` and its test; re-add the field to the `Training` model.

### Phase 6 · LMS\_Documentation, half a day

* [ ] `api-reference/ai-assistant.mdx`: the student token endpoint, the super admin management
  endpoints and the service-key catalog, with request and response examples; register it in
  `docs.json` and add the two controllers to the controller-to-page map in `CLAUDE.md`.
* [ ] `api-reference/trainings.mdx`: the `ai_assistant_enabled` field on create, update, show, list
  and preview.
* [ ] `system-design/class-diagram.mdx`: `AiBotApiKey` and the new `Training` column.
* [ ] Changelog mirrors once the Flutter entries exist.

### Phase 7 · Later

* **Streaming answers.** The bot already streams. Mobile: `Dio` with `ResponseType.stream`. Web
  (the main target): XHR gives the body only at the end, so a small `fetch` + `ReadableStream`
  reader through `dart:js_interop` behind a conditional import; no new package.
* **Conversation list** per student and training (`GET /api/chat/sessions?training_id=`) for a
  "previous conversations" menu.
* **Arabic** in the bot's language gate and prompts.
* **Per-company or per-plan quotas** through the `lim` claim.
* **Feeding from Laravel**: a "Course material for the assistant" upload in the Flutter admin that
  pushes `.md` files to the bot through a Laravel proxy, so instructors never need the React admin.
* **Analytics in Flutter**: the bot's per-training analytics shown on the admin training page.

## 11. Settings checklist

| Where | Setting | Value |
| :- | :- | :- |
| Laravel `platform_settings` | `ai_bot.public_url` | Local `http://localhost:8000`; TEST the bot's public URL |
| Laravel `platform_settings` | `ai_bot.jwt_secret` (encrypted) | generated by Rotate |
| Laravel `platform_settings` | `ai_bot.token_ttl_minutes` | `30` |
| Laravel `config/custom_config.php` | `ai_bot.issuer`, `ai_bot.audience` | `lms-backend`, `lms-ai-bot` |
| Bot `integration_settings` or env | `lms_api_url` / `LMS_API_URL` | Local, bot in Docker + `artisan serve` on the host: `http://host.docker.internal:8020/api`; TEST: `https://backend-test.theteam.com.tn/api` |
| Bot | `lms_service_key` / `LMS_SERVICE_KEY` | the application key |
| Bot | `lms_jwt_secret` / `LMS_JWT_SECRET` | the signing secret, **different** from `JWT_SECRET` |
| Bot `.env` | `JWT_SECRET` | its own random value (admin sessions) |
| Bot `.env` | `ADMIN_API_KEY` | rotate away from `dev-admin-key`; unset in production |
| Bot `.env` | `CORS_ORIGINS` | `http://localhost:5000` + the Flutter web domains |
| Bot `.env` | `OPENAI_API_KEY` | required even with Gemini as chat provider (embeddings) |
| Flutter | none | the bot URL comes with the token |

## 12. Decisions to confirm before coding

| # | Question | Recommendation |
| :- | :- | :- |
| 1 | Bot `training_id` = Laravel **id** or **slug**? | **id**. Immutable, already the route parameter, no re-key logic. The slug is displayed everywhere from the synced catalog. Existing sample data keyed `"1"` moves with one SQL update to the real id. |
| 2 | Who gets a token: owning students only, or also same-company Admin / Super Admin and the training's instructor? | Owners **plus** staff, so you can test from the course player without buying the training. Staff get a plain chat token, never bot-admin rights. |
| 3 | Token TTL | 30 minutes |
| 4 | Quota defaults and what students see | 10 / min, 60 / day, 450 k tokens / day; students see questions left, tokens stay internal |
| 5 | Secrets in the bot: database (UI flow) with env override, or env only? | Database with env override |
| 6 | Blocking answers in v1, streaming later? | Yes: v1 blocking, streaming in Phase 7 |
| 7 | Arabic: auto-detect + note, or extend the bot's gate? | Note for v1 |
| 8 | Promote `markdown` to a direct dependency to render answers? | Yes, no new download |
| 9 | Pull `ai_assistant_enabled` into Phase 1 (it was "later" in the 14/10 plan)? | Yes: one column, and the toggle UI already exists |
| 10 | Bot repo branch for the work | new `lms-integration` branch from `before-integration`, merged to `master` when the acceptance list passes |

## 13. What changed since the 14/10/2026 plan

| Topic | 14/10 plan | This page |
| :- | :- | :- |
| Bot key | training slug, with rename detection, a re-key endpoint and a column widening | Laravel **id**; slug cached for display |
| Secrets | two `.env` variables set by hand on each side | generated from the **super admin dashboard**, stored hashed / encrypted in Laravel, pasted once into the bot admin, verified by `ping` + fingerprint; `.env` stays as override |
| `email` claim | left out, so the bot would not record it as the owner | kept for the History label; the owner key is `sub` only |
| Quotas | existing request limits only | + daily token budget, editable defaults, `usage` returned to Flutter |
| `ai_assistant_enabled` | Phase 4 | Phase 1 |
| Staff access for testing | to confirm | recommended |
| Admin "Training ID" box | searchable combobox by slug | searchable combobox by id, with public / AI / documents badges |

## 14. End-to-end acceptance

1. `curl -H "X-AI-Bot-Key: <key>" $LARAVEL/api/ai-bot/ping` answers `ok` and the fingerprint;
   without the header or with a revoked key it answers 401.
2. In the bot admin, **Test connection** says "signing secret matches", **Fetch trainings** lists
   the catalog with public / private and AI badges. Pick one, upload `.md` files, status `ready`.
3. A student who **owns** the training asks a question in the course player: grounded answer with
   sources, "59 questions left today", and the bot History shows the student's email and the
   training label.
4. A student who does **not** own it gets 403 from the token endpoint and the panel's enroll state.
5. Reuse student A's token on another training id: 403. Reuse it after 30 minutes: 401, refreshed
   silently once.
6. Revoke the student's access or switch the assistant off: the token endpoint refuses at once; the
   assistant stops within one TTL.
7. Rename the training's slug, run Fetch trainings: the dropdown shows the new slug, documents and
   conversations are untouched.
8. Rotate the signing secret in the super admin page and paste it in the bot: live tokens fail
   with 401 until refreshed, new tokens work.
9. Ask the 61st question of the day: 429, the panel shows the reset time, history still readable.
10. Arabic UI: questions accepted with auto-detect, the language note is shown.

## 15. Suggested order of prompts

The root `CLAUDE.md` scopes work by repo prefix, so each step is one prompt:

1. `Backend >` Phase 0 (debug routes, placeholders) then Phase 1.
2. `Documentation >` Phase 6 (the API pages can be written from the Phase 1 code).
3. `LMS_ai_bot >` Phase 2, then Phase 3 (add the prefix to the root `CLAUDE.md` first).
4. `Frontend >` Phase 5 first (pairing page + backend toggle, small), then Phase 4 (student panel).
5. Phase 7 items one at a time.

## 16. Questions and answers (04/11/2026)

Questions raised while reviewing the plan, with the answers and the reasoning behind them.

### Q1. What is the latency of a student question, end to end?

**Measured on the local Docker stack (this Mac, 04/11/2026):**

| Call | Measured | What it shows |
| :- | :- | :- |
| `GET /api/health` | 3 ms | FastAPI overhead |
| `POST /api/chat/sessions` | 14 ms | one Postgres insert and commit |
| `POST .../messages` up to the retrieval step | 14 ms | auth, session load, rate limit, DB reads |

The LLM step could **not** be measured here: the local database holds no documents and no
conversations, `OPENAI_API_KEY` is empty (embeddings need it, so every semantic question answers
500\), the configured chat model `gemini-1.5-flash` was shut down by Google on 29/09/2025, and the
Google key in `backend/.env` is rejected by Google ("API key not valid"). The bot has never
answered a question on this machine. Before measuring: set an OpenAI key, move
`GOOGLE_CHAT_MODEL` to `gemini-2.5-flash-lite` or switch `LLM_PROVIDER`, and restart the backend.

**Estimated budget per hop** (typical values for a bot hosted in Europe, students in Tunisia):

| Hop | Typical | Note |
| :- | :- | :- |
| Flutter to Laravel, `POST .../token` | 150 to 400 ms | once per 30 minutes per training; 2 or 3 SQL queries plus HS256 signing |
| Flutter to bot, `GET .../status` | 60 to 150 ms | one count query; once per panel opening |
| Bot: auth, session, quota | 10 to 20 ms | measured above |
| Question embedding (OpenAI `text-embedding-3-small`) | 100 to 300 ms | one HTTPS call from the bot's server |
| pgvector top-k search | 5 to 30 ms | a few thousand chunks per training |
| **LLM generation, about 450 tokens** | **1.5 to 5 s** on Haiku 5.5, gpt-5-mini or Gemini 2.5 Flash-Lite; 4 to 8 s on a Sonnet-class model | the dominant cost; first token after 0.4 to 1.5 s |
| Persist the two messages | 10 to 20 ms | |
| Network, student to bot | 40 to 80 ms per round trip | Tunisia to a European host |
| Flutter: decode JSON, Markdown to HTML, rebuild | 5 to 30 ms | the app is not the bottleneck |

**Totals.** A question in blocking mode (v1) takes about **2 to 6 seconds** (2 000 to 6 000 ms)
wall clock, of which more than 90 % is the model writing its answer. The first question after
opening the panel adds about half a second (token, status, session). The Flutter side adds well
under 100 ms. With streaming (Phase 7) the first words appear after about 1 to 1.5 s and the rest
flows in.

**How to measure for real.** Every answer stores `messages.latency_ms` (the bot's internal time,
embedding plus generation); `GET /api/admin/trainings/{id}/analytics` returns `avg_latency_ms`.
Add a stopwatch log around `send()` in the Flutter provider: the difference between the two is the
network share. **Levers** if it feels slow: a smaller model, `top_k` 4 instead of 6, `max_tokens`
500, streaming, and hosting the bot next to Laravel so the student pays one European round trip.

### Q2. How does the bot know which students own a training?

It does not, and it never needs to. **Ownership is a Laravel task.** The bot only trusts the
token Laravel issues:

1. The course player asks Laravel for a token for training 42 (Sanctum session).
2. Laravel checks the paid order item (`MarketplacePreviewService::ownedTrainingIds`), the company,
   and `ai_assistant_enabled`, then signs a token with `tid = "42"` for 30 minutes.
3. The bot verifies the signature with the shared secret and, on **every** chat call, compares
   `tid` with the training of the session or path. No Laravel call, no ownership table in the bot.

Why not the alternatives: asking Laravel on every message costs a round trip per question and would
send the never-expiring Sanctum token to a third service; copying an ownership list into the bot
duplicates data that changes with every order, refund or voucher and goes stale (the catalog sync
is for trainings, not students). The cost of the chosen design is the revocation window: a student
whose access is revoked keeps working for at most the remaining token lifetime.

### Q3. How to fix the three flagged points

**A. "The application key replaces dev-admin-key" (two different keys).**

| Step | Where | What |
| :- | :- | :- |
| 1 | Bot `core/security.py` | `principal_from_admin_key` returns `None` when `ADMIN_API_KEY` is empty. Production `.env` leaves it empty; development keeps a random value. |
| 2 | Bot React admin | Remove the "Admin token / key" field from Admin, Chat and Analytics (or move it behind a "Developer" switch in Profile). The login session already drives every call (`lib/api.ts`). |
| 3 | Bot | New `integration_settings` table and an "LMS connection" card: LMS API URL, application key, signing secret, **Test connection**, **Fetch trainings**. Values encrypted with Fernet, key derived from `JWT_SECRET`. The browser never sees the application key: the bot backend calls Laravel. |
| 4 | Laravel | `ai_bot_api_keys` table, `VerifyAiBotApplicationKey` middleware, super-admin endpoints (§9.1). Key format `lmsbot_<prefix>_<secret>`, sha256 stored, shown once. |
| 5 | Flutter super admin | Generate, copy once, revoke; set the bot URL and the token TTL (§10 Phase 5). |

Result: three credentials, each with one job (§5). Nothing of the old flow is lost; the dev key
simply stops being the way you log in.

**B. "How a student proves identity and ownership to the bot."**

| Step | Where | What |
| :- | :- | :- |
| 1 | Laravel `AiAssistantController::token` | `POST /api/ai-assistant/trainings/{idOrSlug}/token` behind Sanctum. Ownership through orders, company check, `ai_assistant_enabled`, then a 30-minute HS256 token with the claims of §5.3. 403 or 404 with a `code` otherwise. |
| 2 | Bot `core/security.py` | `decode_lms_token`: verify with the signing secret, require `iss`, `aud`, `typ = lms_student`, `exp`, `sub`, `tid`; `is_admin` always false. |
| 3 | Bot `api/deps.py` + `api/chat.py` | `ensure_training_access(principal, training_id)` on every chat route, compared with the session's training, not only at session creation. |
| 4 | Flutter `AiBotClient` | Second Dio, Bearer AI token, one silent refresh on 401 then retry; the panel maps 403 / 404 / 429 to its states (§9.3). |
| 5 | Tests | Token for 42 cannot touch 43; expired, wrong audience, wrong secret give 401; an LMS token claiming admin scope still gets 403 on `/api/admin/*`. |

**C. "Email as the unique id."**

| Step | Where | What |
| :- | :- | :- |
| 1 | Bot `api/chat.py` | One helper `owner_key(principal)` returning `lms:<sub>`, used by `start_session` **and** `_load_session` (today one stores the email, the other compares the subject). |
| 2 | Bot migration `0004` | `conversations.student_label` (email, falls back to the name) next to `student_ref`. |
| 3 | Bot History page | Show and filter by `student_label`; the id stays the join key. |
| 4 | Rate limits and quotas | Already keyed on `student_ref`, so they follow the user id automatically. |

Side benefit: if a student asks for their data to be removed, the label column can be blanked
without breaking any key.

### Q4. "Token lifetime 30 minutes": which token?

The word is used for four different things in this plan:

| Token | Issued by | Lifetime | Where it travels |
| :- | :- | :- | :- |
| **Student AI access token** (the one that lives 30 minutes) | Laravel, `POST .../token` | 30 min, refreshed silently by Flutter | Flutter to the bot only |
| Sanctum login token | Laravel, `POST /auth/login` | never expires | Flutter to Laravel only, never to the bot |
| Application key | Laravel super admin page | until revoked (optional expiry) | bot backend to Laravel only |
| LLM tokens | the model provider | not a credential: the pieces of text providers bill for | the bot to the provider; §8 and Q5 count these |

30 minutes is the trade-off between revocation delay (a revoked student keeps the assistant for at
most 30 minutes) and refresh traffic (one small Laravel call per half hour per training).

### Q5. Token consumption and cost: 10 vs 100 students, and how long 10 USD lasts

**One question, measured with the bot's own tokenizer (`cl100k_base`) on 04/11/2026:**

| Part of the prompt | Tokens | Source |
| :- | :- | :- |
| System prompt (base rules + default admin prompt, French lock) | 862 | measured |
| Retrieved context, `top_k` 6 chunks of up to 800 tokens | up to 4 800 | `CHUNK_SIZE_TOKENS` × `DEFAULT_TOP_K` |
| Question wrapper and the question itself | about 90 | measured 37 + question |
| Conversation memory, 6 turns (Phase 2) | about 750 | estimate |
| **Input per question** | **about 6 500** | |
| **Answer** | **about 450** (cap `DEFAULT_MAX_TOKENS` 800) | |
| Question embedding | about 50 | `text-embedding-3-small`, negligible cost |

**Tokens per day:**

| Scenario | Questions / day | Input tokens / day | Output tokens / day |
| :- | :- | :- | :- |
| 10 students, typical 8 questions each | 80 | 0.52 M | 0.04 M |
| 100 students, typical 8 questions each | 800 | 5.2 M | 0.36 M |
| 10 students maxing the 60 / day cap | 600 | 3.9 M | 0.27 M |
| 100 students maxing the 60 / day cap | 6 000 | 39 M | 2.7 M |

**Cost per model** (list prices per million tokens, USD; "d" = how many days 10 USD last in that scenario):

| Model | \$ / M in · out | \$ per question | Questions for \$10 | 10 typical | 100 typical | 10 maxed | 100 maxed |
| :- | :- | :- | :- | :- | :- | :- | :- |
| Claude Haiku 5.5 | 0.10 · 0.50 | 0.00087 | 11 400 | \$0.07/day · 143 d | \$0.70/day · 14 d | \$0.52/day · 19 d | \$5.25/day · 1.9 d |
| Claude Sonnet 5.5 | 2.00 · 10.00 | 0.0175 | 570 | \$1.40/day · 7 d | \$14/day · 0.7 d | \$10.50/day · 1 d | \$105/day · 0.1 d |
| Claude Opus 5.5 | 4.00 · 20.00 | 0.035 | 285 | \$2.80/day · 3.6 d | \$28/day · 0.4 d | \$21/day · 0.5 d | \$210/day · 0.05 d |
| gpt-5-nano | 0.05 · 0.40 | 0.00051 | 19 800 | \$0.04/day · 248 d | \$0.40/day · 25 d | \$0.30/day · 33 d | \$3.03/day · 3.3 d |
| gpt-5-mini | 0.25 · 2.00 | 0.0025 | 3 960 | \$0.20/day · 50 d | \$2.02/day · 5 d | \$1.51/day · 6.6 d | \$15.15/day · 0.7 d |
| Gemini 2.5 Flash-Lite | 0.10 · 0.40 | 0.00083 | 12 000 | \$0.07/day · 151 d | \$0.66/day · 15 d | \$0.50/day · 20 d | \$4.98/day · 2 d |
| Gemini 2.5 Flash | 0.30 · 2.50 | 0.0031 | 3 250 | \$0.25/day · 41 d | \$2.46/day · 4 d | \$1.84/day · 5.4 d | \$18.45/day · 0.5 d |
| DeepSeek V4.1 Flash, peak hours | 0.30 · 1.20 | 0.0025 | 4 000 | \$0.20/day · 50 d | \$1.99/day · 5 d | \$1.49/day · 6.7 d | \$14.94/day · 0.7 d |
| DeepSeek V4.1 Flash, off-peak | 0.15 · 0.60 | 0.0012 | 8 000 | \$0.10/day · 100 d | \$1.00/day · 10 d | \$0.75/day · 13 d | \$7.47/day · 1.3 d |

Embeddings are not in the table because they do not matter: a question costs about one
ten-thousandth of a cent, and ingesting a 300 000-token training costs about 0.6 cent once.

**Recommendations**

* **Anthropic:** `claude-haiku-5-5` for the answers. Grounded question answering over supplied
  context is its home ground, and it is 20 times cheaper than Sonnet. `claude-sonnet-5-5` is the
  quality step for a premium training; `claude-opus-5-5` is the general default elsewhere but 40
  times the price of Haiku for this job. Prices from the Claude API reference (06/10/2026); Haiku's
  rate applies to prompts under 100 K tokens, ours are about 7 K.
* **OpenAI:** `gpt-5-mini` for quality, `gpt-5-nano` when cost matters most. Prices come from
  aggregator sites and must be checked on OpenAI's pricing page before relying on them.
* **DeepSeek:** V4.1 Flash. The old `deepseek-chat` id was retired in July 2026, prices halve
  outside its UTC peak hours (which overlap Tunisian mornings), and the bot does not know DeepSeek
  yet: its API is OpenAI-compatible, so one `ChatOpenAI(base_url=...)` branch in `services/llm.py`
  adds it. Course content and student questions would be processed on servers in China; weigh that
  before choosing it. Prices from aggregators that disagree with each other, verify first.
* **Google:** Gemini 2.5 Flash-Lite costs the same as Haiku 5.5. The current configuration must
  change anyway (Q1).
* **Keep `OPENAI_API_KEY` set whatever the chat provider**: embeddings always use OpenAI.
* **Prompt caching** will not save much here: the stable prefix (862 tokens) is under the minimum
  cacheable size of most providers and the retrieved chunks change with every question. Expect
  10 % at best; not worth engineering now.

**So how long does 10 USD last?** With Haiku 5.5 or Gemini 2.5 Flash-Lite, 10 USD buys about
11 000 to 12 000 questions: 10 students for about five months, or 100 students for about two weeks
of typical use. If 100 students all hit the 60-question cap every day, 10 USD lasts two days,
which is exactly why the daily cap exists. With gpt-5-nano the figures roughly double; with a
Sonnet-class model they shrink by 20.

The 450 000 tokens-per-day default in §8 follows from this: 60 questions × 7 000 tokens.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.