> ## 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 — LMS_ai_bot × Backend × Frontend

> Plan to connect the LMS_ai_bot RAG service to the Laravel backend and the Flutter course player: a per-training token that Laravel only gives to owners, a service-key training catalog for the bot, slug-keyed knowledge feeding, the Flutter wiring, rollout and acceptance checks.

**Superseded on 04/11/2026.** The maintained plan is [AI Assistant Integration](https://theteam-13fc7f49.mintlify.site/ai_assistant_integration.md)
(Development Guide). It keys the bot on the Laravel training **id** instead of the slug, has the super
admin generate the application key and the signing secret from the dashboard, adds daily quotas, and
carries mermaid flow diagrams. This page stays as the history of the 14/10 version.

Mirror of **`LMS_Project/AI_ASSISTANT_INTEGRATION.md`** (written 14/10/2026). Repos touched: `LMS_Backend` →
`LMS_Documentation` → `LMS_ai_bot` → `LMS_Frontend` (in that order). All paths below are relative to each repo root.

**Status: PLAN ONLY. Nothing below is implemented yet.**

Goal: the course-player assistant answers from each training's uploaded Markdown, and **only students
who own the training** can use it.

* **Key in the bot:** the training **slug**. The bot's `training_id` becomes the Laravel `trainings.slug`.
* **Knowledge feeding: unchanged.** You keep uploading `.md` files per training in the bot's admin UI.

**Security specifics are not mirrored here.** Section 4 (Phase 0) of the workspace plan lists security
fixes that must land before the new endpoints go live. This site is public, so those details stay in
`LMS_Project/AI_ASSISTANT_INTEGRATION.md` and will be added here once they are fixed.

***

## Table of contents

1. [TL;DR](#0-tldr)
2. [What exists today](#1-what-exists-today)
3. [Target architecture](#2-target-architecture)
4. [Design decisions and why](#3-design-decisions-and-why)
5. [Phase 0: prerequisites](#4-phase-0-prerequisites)
6. [Phase 1: backend (Laravel)](#5-phase-1-backend-laravel)
7. [Phase 2: AI bot (FastAPI)](#6-phase-2-ai-bot-fastapi)
8. [Phase 3: frontend (Flutter)](#7-phase-3-frontend-flutter)
9. [Phase 4: streaming and polish](#8-phase-4-streaming-and-polish)
10. [Environment variables checklist](#9-environment-variables-checklist)
11. [End-to-end acceptance checklist](#10-end-to-end-acceptance-checklist)
12. [Decisions to confirm](#11-decisions-to-confirm)
13. [Suggested order of work](#12-suggested-order-of-work)

***

## 0. TL;DR

1. **Laravel mints a short-lived "AI access token" per training.** A student calls
   `POST /api/ai-assistant/trainings/{slug}/token`. Laravel checks that the student owns that
   training (paid order, not revoked) and returns a JWT signed with a secret it shares with the
   bot. The token carries `sub` (user id) and `trn` (the slug) and lasts 30 minutes.
2. **The bot verifies that token and enforces the slug.** Every chat route checks
   `token.trn == conversation.training_id`. Without a valid token for that exact slug, the request
   gets a 403. The bot never sees the student's Sanctum token and never calls Laravel during a chat.
3. **The bot fetches the training catalog from Laravel with a service key.** `GET /api/ai-bot/trainings`
   is protected by an `X-AI-Bot-Key` header (a second shared secret, used server-to-server only).
   The bot's admin UI uses it to replace the free-text "Training ID" box with a searchable
   training dropdown (slug + label + company).
4. **Flutter talks to the bot directly** with the AI token, using a separate Dio instance. Laravel
   returns the bot's base URL with the token, so there is no new per-environment constant to edit.
   v1 uses the blocking `/messages` endpoint. Streaming comes in phase 4.
5. **There are two secrets, and they are different values:**
   * `AI_BOT_JWT_SECRET`: Laravel signs student tokens, the bot verifies them.
   * `AI_BOT_SERVICE_KEY`: the bot calls Laravel's catalog endpoints with it.

***

## 1. What exists today

Verified by reading the code of each repo.

> [tab] LMS\_ai\_bot
>
> | Fact | Where |
> | :- | :- |
> | Every table keys content by `training_id` (`String(64)`): documents, chunks, bot configs, conversations | `backend/app/models/tables.py` |
> | The bot was **designed** to trust "a JWT signed by the LMS" (`LMS_JWT_SECRET`), but nothing issues one yet | `backend/app/core/security.py`, `api_documentation.md` §Authentication |
> | ⚠️ Chat routes accept **any** valid principal for **any** `training_id`. There is no ownership check at all | `backend/app/api/chat.py:40-202` (`Depends(get_principal)`) |
> | ⚠️ One secret signs both the bot's own session tokens and the LMS tokens (`_signing_secret()`). An LMS token that claims admin scope becomes a **bot admin** | `security.py:56-57, 104-122` |
> | ⚠️ Owner-check bug: `start_session` stores `principal.email or principal.subject` but `_load_session` compares with `principal.subject`. Any token that carries an `email` claim gets **403 "Not your session"** on its own messages | `chat.py:66` vs `chat.py:80` |
> | `trainings_ref` (cached training name) exists but **nothing ever writes it** | `models/tables.py:32`, read only in `repositories/configs.py:63` and `services/ingestion.py:22` |
> | The admin UI takes a free-text Training ID (default `"1"`) | `frontend/src/features/admin/AdminPage.tsx:8`, `features/chat/ChatPage.tsx:7` |
> | Chat API: `POST /api/chat/sessions`, `POST /sessions/{id}/messages` (blocking), `POST /sessions/{id}/messages/stream` (SSE events `token` / `sources` / `done` / `error`), `GET /sessions/{id}/messages` | `chat.py`, `services/rag.py:151-204` |
> | The bot only answers in **French or English** (`language: "fr" \| "en" \| null`) | `schemas/schemas.py:86` |
> | Multi-turn memory (`recent_turns`) exists but is never called, so follow-up questions are answered with no context | `repositories/conversations.py:156` |
> | `httpx` is already a dependency, so no new package is needed to call Laravel | `backend/requirements.txt` |

> [tab] LMS\_Backend
>
> | Fact | Where |
> | :- | :- |
> | `trainings.slug` is `varchar(255)`, **nullable**, **unique platform-wide**, typed by the admin (not generated), lowercased, and **can be changed later** | migration `2025_07_22_131906_add_slug_to_trainings_table.php`, `TrainingsController::store` :133-176, `update` :281-354 |
> | Training title column is `label`; company name column is `companies.label` | `create_trainings_table`, `create_companies_table` |
> | **Ownership = paid order item, not revoked.** `orders.type = 3`, `order_status_id IN (4, 5)`, not archived/deleted, `order_items.access_revoked = false`. Ready-made helper: `MarketplacePreviewService::ownedTrainingIds($user, [$id])` | `app/Services/MarketplacePreviewService.php:136-168` |
> | Do **not** use `users.owned_trainings` (JSON slug cache; it can contain unpaid slugs and goes stale on slug rename), `User::trainings()` (wrong join) or `GET /user/{id}/trainings_by_student` (broken and unauthorized) | `User.php:194-212, 356-449` |
> | There is no "free training": free access is a Paid-Offline order at price 0 (admin assignment or voucher), so the same rule covers it | `OrdersController.php:486-530` |
> | No machine-to-machine auth exists anywhere. The pattern to copy is `StorageInspectorController::authorizeAccess` (`hash_equals` on a header token from `config/custom_config.php`) | `StorageInspectorController.php:115-133` |
> | Sanctum tokens **never expire** and have every ability (`*`), so they must never be forwarded to the bot | `config/sanctum.php`, `AuthController.php:287` |
> | `lcobucci/jwt` 5.5.0 is installed, but only as a dependency of `php-open-source-saver/jwt-auth`, which is configured but unused | `composer.lock` |
> | Conventions: config in `config/custom_config.php`, inline `request()->validate()`, no response envelope (errors are `{message}`), `Model::shouldBeStrict()` (always eager-load), tests in `tests/Feature` with `tests/Support/CreatesLmsFixtures.php` | `LMS_Backend/CLAUDE.md` |

> [tab] LMS\_Frontend
>
> | Fact | Where |
> | :- | :- |
> | The assistant is a **static mockup**: seeded messages, input disabled, Send / chips / "New conversation" all show "coming soon". The wiring points are marked `TODO(ai)` at lines 38, 61, 68, 191, 353 | `lib/ui/widgets/training/training_ai_assistant_widget.dart` |
> | It lives **only in the course player** (web side panel, mobile end drawer), so it is per-training by design | `course_player_assistant.dart`, `training_details_screen.dart:110-151`, `mobile/course_player_mobile.dart:143-156` |
> | The current training (with `id`, `slug`, `label`), lesson and chapter are already available in `StudentTrainingProvider` | `core/providers/student_training_provider.dart:25-31` |
> | A per-training on/off toggle exists, but **only in SharedPreferences**. `major_code_updates.md:3550` already plans a backend column `trainings.ai_assistant_enabled` | `core/utils/resources/ai_assistant_local_settings.dart` |
> | There is one Dio instance, built in `locator.dart:19-54` (Bearer + Accept-Language interceptor, no 401 handling), and no pattern for a second host | `lib/locator.dart` |
> | Base URLs are hand-edited per environment (TEST is active); there is no `--dart-define` | `lib/core/constants/constants.dart:10-28` |
> | No SSE or streaming code anywhere. `dio` 5.11.1 and `http` 1.6.0 are direct dependencies; `markdown` 7.3.1 is only transitive (via `flutter_quill`); `flutter_widget_from_html` is direct (`AppHtmlContent`) | `pubspec.yaml`, `pubspec.lock` |

***

## 2. Target architecture

![Target architecture: LMS\_Frontend asks LMS\_Backend for an AI token, then calls LMS\_ai\_bot with it; LMS\_ai\_bot pulls the training catalog from LMS\_Backend; the bot admin uploads Markdown per training through the React admin UI](https://mintcdn.com/theteam-13fc7f49/wTpcJ-m8wXRRM2AO/images/ai-assistant-integration/target-architecture-light.png?fit=max\&auto=format\&n=wTpcJ-m8wXRRM2AO\&q=85\&s=2bab94b333f8cb0861d598cd11d362c0)

① Flutter gets a 30-minute AI token from Laravel · ② Flutter calls the bot with that token · ③ the bot pulls the training catalog from Laravel with its service key

> [accordion] Mermaid source
>
> ```text Mermaid theme={null}
> ---
> config:
>   themeCSS: ".labelBkg { max-width: none !important; }"
>   flowchart:
>     wrappingWidth: 420
> ---
> flowchart TB
>     fe["<b>LMS_Frontend</b> (Flutter)<br/>course player panel"]
>     be["<b>LMS_Backend</b> (Laravel)<br/>checks ownership"]
>     bot["<b>LMS_ai_bot</b> (FastAPI)<br/>verifies JWT (AI_BOT_JWT_SECRET)<br/>enforces token.trn == training slug<br/>pgvector: documents / chunks / bot_configs / conversations keyed by slug<br/>trainings_ref synced"]
>
>     fe -- "① POST /api/ai-assistant/trainings/{slug}/token<br/>Bearer #lt;Sanctum token#gt;" --> be
>     be -. "{ token (JWT, 30 min), ai_base_url, … }" .-> fe
>     fe -- "② /api/chat/*<br/>Bearer #lt;AI token#gt;" --> bot
>     be <-- "③ the bot calls GET /api/ai-bot/trainings<br/>X-AI-Bot-Key: #lt;service key#gt;" --> bot
>
>     subgraph feeding["Knowledge feeding (unchanged)"]
>         admin(["You (bot admin)"]) --> adminUi["React admin UI"]
>     end
>     adminUi -- "upload .md per training (by slug)" --> bot
> ```

### Flow ①②: a student asks a question

![Sequence: Flutter gets a token from Laravel, checks the training status on the bot, creates a session, sends a question and gets the answer; on a 401 it fetches a new token and retries once](https://mintcdn.com/theteam-13fc7f49/wTpcJ-m8wXRRM2AO/images/ai-assistant-integration/student-question-flow-light.png?fit=max\&auto=format\&n=wTpcJ-m8wXRRM2AO\&q=85\&s=79994722d221a9c0e3c1603143dbc034)

After 30 minutes the bot returns 401: Flutter fetches a new token from Laravel and retries once

> [accordion] Mermaid source
>
> ```text Mermaid theme={null}
> sequenceDiagram
>     participant F as Flutter
>     participant L as Laravel
>     participant B as AI bot
>
>     Note over F: open course player (slug)
>     F->>L: ① POST /api/ai-assistant/trainings/{slug}/token
>     Note right of L: training by slug? same company?<br/>owns it (paid and not revoked)?
>     L-->>F: 200 {token, ai_base_url} (or 403 / 404)
>     F->>B: ② GET /api/chat/trainings/{slug}/status
>     Note right of B: verify JWT, trn == slug
>     B-->>F: {has_content, name, …}
>     F->>B: POST /api/chat/sessions {training_id: slug}
>     Note right of B: trn == slug → create
>     F->>B: POST /api/chat/sessions/{sid}/messages {question}
>     Note right of B: trn == convo.training_id<br/>RAG on that slug only
>     B-->>F: {answer, sources, fallback, …}
>
>     opt token expired (after 30 min)
>         B-->>F: 401
>         F->>L: POST /api/ai-assistant/trainings/{slug}/token
>         L-->>F: new token
>         F->>B: retry the request once
>     end
> ```

> [!NOTE]
>
> **Revocation:** when an admin revokes access or refunds an order, Laravel stops issuing new tokens at
> once. A token that was already issued keeps working until it expires (30 minutes at most). To cut that
> window, lower `AI_BOT_JWT_TTL_MINUTES`.

***

## 3. Design decisions and why

### 3.1 The bot's `training_id` is the Laravel slug

This works because slugs are unique across the whole platform, so no company prefix is needed. Four
caveats need handling:

| Caveat | Handling |
| :- | :- |
| Slug is **nullable** | A training without a slug cannot have an assistant: the catalog skips it, and the token endpoint returns 404. Make the slug required in practice (it already is required on `store`). |
| Slug **can be renamed** in `TrainingsController::update`, which would orphan its documents, chunks and config in the bot | The bot stores the immutable Laravel `id` next to the slug in `trainings_ref.lms_training_id`. The catalog sync detects "same id, new slug" and **re-keys** every bot table in one transaction (§6.6). A manual re-key admin endpoint covers the existing sample data. |
| Bot columns are `String(64)`, Laravel slugs go up to 255 | Alembic migration `0004` widens them to 255 (§6.5) |
| Allowed slug characters include `( ) [ ] : , = .` | Clients must percent-encode the slug **once** in URL paths. Test with a slug containing brackets. |

### 3.2 Auth: a Laravel-minted, short-lived, per-training JWT

| Option | Verdict |
| :- | :- |
| **A. Laravel mints a JWT per training, bot verifies it (recommended)** | Fits what the bot was designed for. No call to Laravel per message, so it is fast. Least privilege: one training, 30 minutes. SSE streaming works directly from bot to client. Cost: up to TTL of delay after a revocation. |
| B. Flutter sends its Sanctum token to the bot, which asks Laravel "does this user own X?" on each message | Revocation is instant, but every message costs a Laravel round-trip, and a **never-expiring, all-powerful** Sanctum token would reach a third service. Rejected. |
| C. Proxy all chat through Laravel | Laravel would have to relay SSE streams through PHP workers (buffering, long-held workers). Rejected for chat. Laravel stays the gatekeeper, not the pipe. |

**Per-training, not one token listing all trainings:** the assistant only exists inside a training's
course player, so one token = one slug is the smallest privilege. It also keeps the token small, and
the bot's check is a single comparison.

### 3.3 Two separate secrets

| Secret | Direction | Laravel `.env` | Bot `backend/.env` |
| :- | :- | :- | :- |
| JWT signing secret | Laravel signs, bot verifies | `AI_BOT_JWT_SECRET` | `LMS_JWT_SECRET` (same value) |
| Service key | Bot calls Laravel | `AI_BOT_SERVICE_KEY` | `LMS_SERVICE_KEY` (same value) |

Generate each with `openssl rand -hex 32`. Use different values per environment (dev, test, prod).
The bot's own `JWT_SECRET` (admin login sessions) must be a **third**, different value; the bot should
refuse to start if `LMS_JWT_SECRET == JWT_SECRET` outside development.

### 3.4 Who may get a token

Default rule: a **student who owns the training**.

Recommended addition (confirm in §11): also allow **Admin / Super Admin of the training's company** and
the **training's instructor**, so staff can test the assistant from the course player. They still get a
plain chat token, never bot-admin rights. The token's `role` claim lets the bot's History page show
who was testing.

***

## 4. Phase 0: prerequisites

**Security fixes first.** A short list of fixes in `LMS_Backend` (and secret rotation in `LMS_Backend`
and `LMS_ai_bot`) must land **before** any new endpoint from this plan is exposed. The exact items are
in §4 of `LMS_Project/AI_ASSISTANT_INTEGRATION.md`. They are not repeated on this public site until they
are fixed.

**Root workspace housekeeping:** `LMS_ai_bot/` currently shows as untracked in `LMS_Project`. Treat it
like the other three: add it to the root `.gitignore`, add the `.gitkeep` placeholder, and add it to the
repo table and the prompt-prefix table (`LMS_ai_bot >`) in the root `CLAUDE.md`.

***

## 5. Phase 1: backend (Laravel)

### 5.1 Config and env

`config/custom_config.php`:

```php theme={null}
'ai_bot' => [
    'service_key'     => env('AI_BOT_SERVICE_KEY'),            // bot → Laravel (catalog)
    'jwt_secret'      => env('AI_BOT_JWT_SECRET'),             // Laravel → bot (student tokens)
    'jwt_ttl_minutes' => (int) env('AI_BOT_JWT_TTL_MINUTES', 30),
    'public_url'      => env('AI_BOT_PUBLIC_URL'),             // returned to Flutter, e.g. https://ai-test.theteam.com.tn
    'issuer'          => 'lms-backend',
    'audience'        => 'lms-ai-bot',
],
```

Add the four `AI_BOT_*` keys to `.env.example` with **empty** values.

### 5.2 Service-key middleware

`app/Http/Middleware/VerifyAiBotServiceKey.php`, registered in `Kernel::$middlewareAliases` as
`'ai_bot_service_key'`:

```php theme={null}
public function handle(Request $request, Closure $next)
{
    $expected = (string) config('custom_config.ai_bot.service_key', '');
    $given    = (string) $request->header('X-AI-Bot-Key', '');

    // Header only, never a query string: query strings end up in access logs.
    if ($expected === '' || $given === '' || ! hash_equals($expected, $given)) {
        return response()->json(['message' => 'Unauthorized.'], 401);
    }

    return $next($request);
}
```

In `RouteServiceProvider::boot()` add a limiter:
`RateLimiter::for('ai-bot', fn () => Limit::perMinute(60)->by('ai-bot'));`

### 5.3 Catalog endpoints (bot → Laravel)

These routes sit **outside** the `auth:sanctum` group:

```php theme={null}
Route::prefix('ai-bot')->middleware(['ai_bot_service_key', 'throttle:ai-bot'])->group(function () {
    Route::get('trainings', [AiBotController::class, 'trainings']);       // paginated catalog
    Route::get('trainings/{slug}', [AiBotController::class, 'training']); // one training
});
```

`GET /api/ai-bot/trainings` accepts these query parameters, all optional:

| Parameter | Effect |
| :- | :- |
| `search` | Matches `label` or `slug` |
| `company_id` | One tenant only |
| `updated_since` (ISO date) | Lets the bot do incremental syncs |
| `include_inactive=1` | Adds archived and soft-deleted trainings, so the bot can deactivate them. Implemented with `withoutGlobalScope('archived')->withTrashed()`. |
| `per_page` | Maximum 200 |

The query always applies `whereNotNull('slug')`, eager-loads `company:id,label` (strict mode), and
returns a standard Laravel paginator whose rows are **whitelisted arrays**, never full models:

```json theme={null}
{
  "data": [
    {
      "id": 42,
      "slug": "pmp-coaching-2026",
      "label": "PMP Coaching",
      "company_id": 3,
      "company_label": "THE TEAM",
      "is_public": true,
      "is_active": true,
      "updated_at": "2026-10-10T09:12:00+01:00"
    }
  ],
  "current_page": 1, "per_page": 100, "total": 57, "last_page": 1
}
```

`is_active` is true when the training is neither archived nor soft-deleted.
`GET /api/ai-bot/trainings/{slug}` returns one row of the same shape, or `{message}` with 404.

### 5.4 Student token endpoint (Flutter → Laravel)

This route goes **inside** the existing
`['auth:sanctum', 'check_if_user_is_blocked_middleware', 'company_policy_middleware']` group:

```php theme={null}
Route::post('ai-assistant/trainings/{slug}/token', [AiAssistantController::class, 'token'])
    ->middleware('throttle:20,1');
```

Logic, in `app/Http/Controllers/AiAssistantController.php`:

```php theme={null}
public function token(string $slug, MarketplacePreviewService $marketplace)
{
    $user = auth()->user();

    // The archived global scope and SoftDeletes already hide inactive trainings.
    $training = Training::query()->where('slug', strtolower($slug))->first();

    if (! $training || (int) $training->company_id !== (int) $user->company_id) {
        return response()->json(['message' => 'Training not found.'], 404);
    }

    $owns = ! empty($marketplace->ownedTrainingIds($user, [$training->id]));
    // Optional staff rule (see §3.4 / §11):
    // $isStaff = $user->isAdmin() || $user->isSuperAdmin() || $training->instructor_id === $user->id;

    if (! $owns /* && ! $isStaff */) {
        return response()->json(['message' => 'You need to own this training to use its AI assistant.'], 403);
    }

    // Phase 4: once trainings.ai_assistant_enabled exists, also return 403 when it is false.

    $cfg = config('custom_config.ai_bot');
    $now = time();
    $exp = $now + $cfg['jwt_ttl_minutes'] * 60;

    $jwt = AiBotToken::encode([
        'iss'  => $cfg['issuer'],
        'aud'  => $cfg['audience'],
        'sub'  => (string) $user->id,
        'typ'  => 'lms_student',
        'trn'  => $training->slug,
        'tid'  => $training->id,
        'cid'  => $training->company_id,
        'role' => $user->role_id,
        'name' => $user->fullname,
        'iat'  => $now,
        'exp'  => $exp,
        'jti'  => (string) Str::uuid(),
    ], $cfg['jwt_secret']);

    return response()->json([
        'token'       => $jwt,
        'token_type'  => 'Bearer',
        'expires_in'  => $exp - $now,
        'expires_at'  => date(DATE_ATOM, $exp),
        'ai_base_url' => $cfg['public_url'],
        'training'    => ['id' => $training->id, 'slug' => $training->slug, 'label' => $training->label],
    ]);
}
```

Example responses:

> [tab] 200
>
> ✅ 200 OK
>
> ```json theme={null}
> {
>   "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJsbXMtYmFja2VuZCJ9.c2lnbmF0dXJl",
>   "token_type": "Bearer",
>   "expires_in": 1800,
>   "expires_at": "2026-10-14T15:30:00+01:00",
>   "ai_base_url": "https://ai-test.example.com",
>   "training": { "id": 42, "slug": "pmp-coaching-2026", "label": "PMP Coaching" }
> }
> ```

> [tab] 403
>
> ⚠️ 403 Forbidden: the student doesn't own the training (no paid order, pending order, or access revoked)
>
> ```json theme={null}
> { "message": "You need to own this training to use its AI assistant." }
> ```

> [tab] 404
>
> ⚠️ 404 Not Found: unknown, archived or deleted slug, or a training of another company
>
> ```json theme={null}
> { "message": "Training not found." }
> ```

> [tab] 503
>
> ❌ 503 Service Unavailable: `AI_BOT_JWT_SECRET` or `AI_BOT_PUBLIC_URL` is not set
>
> ```json theme={null}
> { "message": "AI assistant is not configured." }
> ```

Notes:

* **No `email` claim, no `scopes`/`is_admin` claim.** That avoids the bot's owner-check bug and any
  chance of granting bot-admin rights (the bot also ignores both claims; see §6.2).
* `POST`, not `GET`, because it mints a credential, which must not be cached or prefetched.
* If `jwt_secret` or `public_url` is empty, return the 503 above instead of minting a token with an
  empty key.

### 5.5 JWT signing helper (no new package)

`app/Support/AiBotToken.php`. Laravel only **signs**, and HS256 signing is a few lines of native PHP.
That avoids depending on `lcobucci/jwt`, which is only present through the unused `jwt-auth` package:

```php theme={null}
final class AiBotToken
{
    public static function encode(array $claims, string $secret): string
    {
        if (strlen($secret) < 32) {
            throw new RuntimeException('AI_BOT_JWT_SECRET must be at least 32 bytes.');
        }
        $header  = self::b64(json_encode(['alg' => 'HS256', 'typ' => 'JWT']));
        $payload = self::b64(json_encode($claims, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
        $sig     = self::b64(hash_hmac('sha256', "$header.$payload", $secret, true));
        return "$header.$payload.$sig";
    }

    private static function b64(string $s): string
    {
        return rtrim(strtr(base64_encode($s), '+/', '-_'), '=');
    }
}
```

If you prefer a library, `lcobucci/jwt` 5.x can do the same. Add it to `composer.json` explicitly
rather than relying on the transitive copy.

### 5.6 Tests (`tests/Feature/AiAssistantTokenTest.php`, `AiBotCatalogTest.php`)

* Owner with a Paid-Online order gets 200, and the token decodes with the secret to the expected
  `sub`, `trn`, `aud`, `iss` and `exp`.
* Owner via Paid-Offline at price 0 (admin assignment or voucher) gets 200.
* Each of these gets **403**: an order that is Pending, Cancelled or Payment Failed; an item with
  `access_revoked = true`; an order of type Cart or Wishlist.
* A training in another company gets 404; an unknown, archived or soft-deleted slug gets 404.
* Unauthenticated gets 401; a blocked user gets the blocked-middleware response.
* Catalog: no header gives 401, a wrong key gives 401, the right key gives 200. Rows contain only
  whitelisted fields, slug-less trainings are excluded, and `include_inactive` returns archived rows
  with `is_active: false`.

### 5.7 Documentation (cross-repo rule)

Add pages in `api-reference/` for the token endpoint (student) and the two `ai-bot` catalog endpoints
(service-to-service), and add their rows to the controller → page map in this repo's `CLAUDE.md`.

***

## 6. Phase 2: AI bot (FastAPI)

### 6.1 Settings (`backend/app/core/config.py` + `.env.example`)

```python theme={null}
# --- LMS integration ---
lms_api_url: str = ""            # e.g. https://backend-test.theteam.com.tn/api (in Docker locally: http://host.docker.internal:8020/api)
lms_service_key: str = ""        # == Laravel AI_BOT_SERVICE_KEY
lms_jwt_secret: str = ""         # == Laravel AI_BOT_JWT_SECRET (no longer falls back to jwt_secret)
lms_jwt_issuer: str = "lms-backend"
lms_jwt_audience: str = "lms-ai-bot"
```

Add a startup guard in `main.py` `lifespan`: when `environment != "development"`, refuse to boot if
`lms_jwt_secret` is empty or equal to `jwt_secret`. Remember that the bot caches its settings:
**restart the backend after editing `.env`**.

### 6.2 Verify LMS tokens strictly (`core/security.py`)

Split the two token kinds so neither can impersonate the other:

```python theme={null}
def decode_lms_token(token: str) -> Principal:
    payload = jwt.decode(
        token,
        settings.lms_jwt_secret,
        algorithms=["HS256"],
        audience=settings.lms_jwt_audience,
        issuer=settings.lms_jwt_issuer,
        options={"require_exp": True, "require_iat": True, "require_sub": True},
    )
    if payload.get("typ") != "lms_student" or not payload.get("trn"):
        raise JWTError("Not an LMS assistant token.")
    return Principal(
        subject=f"lms:{payload['sub']}",   # prefix: never collides with bot user ids ("1")
        scopes=[],
        is_admin=False,                    # LMS tokens NEVER grant bot-admin, whatever they claim
        raw=payload,
        token_type="lms_student",
        training_id=str(payload["trn"]),   # new Principal field
        display_name=payload.get("name"),  # new Principal field
    )


def try_decode(token: str) -> Optional[Principal]:
    # 1) the bot's own session tokens (JWT_SECRET, typ == "session")
    # 2) LMS assistant tokens (LMS_JWT_SECRET, iss/aud/typ checked)
    # Bare dev tokens from create_token(): accept only when environment == "development".
    ...
```

`_signing_secret()` becomes `settings.jwt_secret` for the bot's own tokens only.

### 6.3 Enforce training access on every chat route (`api/deps.py` + `api/chat.py`)

```python theme={null}
def ensure_training_access(principal: Principal, training_id: str) -> None:
    if principal.is_admin:  # the bot admin session or ADMIN_API_KEY, for testing
        return
    if principal.token_type == "lms_student" and principal.training_id == training_id:
        return
    raise HTTPException(
        status_code=403,
        detail={"code": "training_access_denied",
                "message": "You don't have access to this training's assistant."},
    )
```

Each route checks against the following training id:

| Route | Checked against |
| :- | :- |
| `GET /chat/trainings/{training_id}/seances` | the path `training_id` |
| `POST /chat/sessions` | `payload.training_id` |
| `POST /chat/sessions/{id}/messages` | `convo.training_id` |
| `POST /chat/sessions/{id}/messages/stream` | `convo.training_id` |
| `GET /chat/sessions/{id}/messages` | `convo.training_id` |
| **new** `GET /chat/trainings/{training_id}/status` | the path `training_id` |

Checking `convo.training_id` on **every** message (not only at session creation) means a token for
training A can never be used to continue a session of training B.

### 6.4 Fix the conversation-owner check

* Add a single helper, `owner_key(principal) -> principal.subject` (`lms:123` for students), and use it
  in **both** `start_session` (`chat.py:66`) and `_load_session` (`chat.py:80`).
* Store a display label separately in a new `conversations.student_label` column
  (`principal.display_name or principal.email or principal.subject`), so the History page still shows a
  readable name.
* Rate limiting already keys on `principal.subject`, so it becomes per LMS user automatically.

### 6.5 Alembic migration `0004_lms_integration`

* Widen `training_id` to `String(255)` on `trainings_ref`, `documents`, `chunks`, `bot_configs` and
  `conversations`.
* `trainings_ref`: add `lms_training_id BIGINT NULL UNIQUE`, `company_id BIGINT NULL`,
  `company_label VARCHAR(255)` and `synced_at TIMESTAMPTZ NULL`.
* `conversations`: add `student_label VARCHAR(255) NOT NULL DEFAULT ''`.

### 6.6 LMS client, catalog sync and re-key

`app/services/lms_client.py` (httpx; 10 s timeout; headers `X-AI-Bot-Key` and `Accept: application/json`)
with `list_trainings(search, company_id, page, per_page, include_inactive, updated_since)` and
`get_training(slug)`.

New admin routes (`api/admin.py`, already behind `require_admin`):

| Route | Purpose |
| :- | :- |
| `GET /api/admin/lms/trainings?search=&company_id=&page=` | Proxies the Laravel catalog and merges local stats per slug (`documents`, `chunks`, `has_content`). This feeds the admin dropdown. **The service key stays on the server**: the browser never sees it. |
| `POST /api/admin/lms/sync` | Pages through the full catalog (`include_inactive=1`) and upserts `trainings_ref`. A row with the same `lms_training_id` but a different slug is **re-keyed**. A row with `is_active: false` is deactivated. Returns `{created, updated, renamed: [{from, to}], deactivated}`. |
| `POST /api/admin/trainings/{training_id}/rekey` `{new_training_id}` | Manual re-key, e.g. to move the sample PMP data from `"1"` to its real slug. Refuses with 409 if the target already has documents. |

`rekey_training(db, old, new)` is a single transaction that updates `training_id` in `documents`,
`chunks`, `bot_configs`, `conversations` and `trainings_ref`, and refreshes `chunks.training_name`, which
the prompts and the fallback message use. None of these tables reference each other through
`training_id`, so plain `UPDATE`s are enough.

Run the sync when the admin clicks "Sync from LMS", and optionally once a day (cron or an APScheduler
job). Ingestion should also call `get_training(slug)` once to fill `trainings_ref` when the slug isn't
cached yet, so `training_name` is correct from the first upload.

### 6.7 New student endpoint: training status

`GET /api/chat/trainings/{slug}/status`, protected by `ensure_training_access`:

```json theme={null}
{ "training_id": "pmp-coaching-2026", "name": "PMP Coaching", "has_content": true, "documents": 8 }
```

Flutter calls it first, so the panel can say "No course material has been added to the assistant yet"
instead of returning fallback answers to every question.

### 6.8 Admin UI (React): pick trainings instead of typing IDs

* `AdminPage.tsx` / `ChatPage.tsx`: replace the "Training ID" text input with a searchable combobox fed
  by `GET /api/admin/lms/trainings`. Each option shows `label`, then `slug · company_label`, then an
  "N docs" badge. The selected value is the **slug**.
* Add a "Sync from LMS" button on the admin page that calls `POST /api/admin/lms/sync` and shows the
  result, including renames.
* History and Analytics: show `trainings_ref.name` and the slug instead of a bare id.
* **Knowledge feeding stays the same**: `UploadDocs` and `GuardrailEditor` keep working per training.
  Only the way you pick the training changes.

### 6.9 Knowledge-feeding workflow after the change

**1. The training exists in LMS\_Backend**

It has a slug, e.g. `pmp-coaching-2026`.

**2. Pick it in the bot admin**

Click "Sync from LMS" (or just search), then pick "PMP Coaching (pmp-coaching-2026)".

**3. Upload the .md files**

Re-uploading the same filename replaces it, exactly as today.

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

Adjust the system prompt and the RAG knobs for that training.

**5. Students use it**

Students who own `pmp-coaching-2026` can now use the assistant in its course player.

One-time migration of the existing sample data:
`POST /api/admin/trainings/1/rekey` with body `{"new_training_id": "the-real-pmp-slug"}`.

### 6.10 CORS and reverse proxy

* `CORS_ORIGINS` must list the Flutter web origins: `http://localhost:5000`,
  `https://elearning-test.theteam.com.tn`, `https://elearning-dev.the-team.tn`,
  `https://elearning.theteam.com.tn`. Mobile apps are not affected by CORS.
* For SSE (phase 4), make sure the proxy in front of the bot (Plesk nginx) does not buffer
  `/api/chat/.../stream` (`proxy_buffering off;`).

### 6.11 Tests (`backend/tests/test_lms_auth.py`, `test_training_access.py`)

* A valid LMS token for slug A can create a session for A.
* The same token gets 403 on: a session for B, `seances` for B, and posting to an existing B session.
* An expired token, the wrong `aud` or `iss`, `typ` missing, or the wrong secret each give 401.
* An LMS token claiming admin scope still gets 403 on `/api/admin/*`.
* Owner check: user `lms:1` cannot read user `lms:2`'s session, and the bot admin session can.
* `rekey_training` moves every table and refuses to overwrite a target that has content.
* Sync with a mocked `httpx` transport: create, update, rename and deactivate.

### 6.12 Docs to update in the bot repo

`api_documentation.md` (new auth section with the claims table, the new endpoints, the 403 body),
`README.md` (the security block: the new variables, "two different secrets"), and `CLAUDE.md` (auth
section: LMS tokens are per-training and verified separately; `training_id` is the LMS slug).

***

## 7. Phase 3: frontend (Flutter)

### 7.1 Laravel call: get an AI token

Add to `lib/core/services/retrofit/api_client.dart`:

```dart theme={null}
@POST('/ai-assistant/trainings/{slug}/token')
Future<AiAccessToken> getAiAssistantToken(@Path('slug') String slug);
```

Add the model `lib/core/data/remote/ai_assistant/ai_access_token.dart` (`token`, `expiresAt`,
`aiBaseUrl`, `training {id, slug, label}`), then run `dart run build_runner build -d`.

### 7.2 Bot client: a second Dio, not the shared one

Create `lib/core/services/ai_assistant/ai_bot_client.dart`, registered in `locator.dart`:

* Its own `Dio`, with `connectTimeout` 10 s and `receiveTimeout` 90 s (LLM answers are slow), and
  **no** Laravel Sanctum interceptor.
* `baseUrl` = the `ai_base_url` returned with the token. This means **no new constant in
  `constants.dart`**: switching `Constants.baseUrl` to another environment automatically points the
  assistant at that environment's bot.
* An interceptor that adds `Authorization: Bearer` with the AI token for the current slug, and on
  **401** fetches a fresh token from Laravel **once** and retries the request.
* JSON endpoints can be a small Retrofit `@RestApi()` client, `AiBotApiClient(dio, baseUrl: …)`, to
  match the codebase style:
  * `GET  /api/chat/trainings/{slug}/status`
  * `GET  /api/chat/trainings/{slug}/seances`
  * `POST /api/chat/sessions`
  * `POST /api/chat/sessions/{id}/messages`
  * `GET  /api/chat/sessions/{id}/messages`
* Models in `lib/core/data/remote/ai_assistant/`: `AiTrainingStatus`, `AiSeance`, `AiSession`,
  `AiMessageOut` (answer, sources, fallback, grounded) and `AiHistoryMessage`.

### 7.3 State: `AiAssistantProvider` (ChangeNotifier)

`lib/core/providers/ai_assistant_provider.dart`, registered in `locator.dart` + `providers.dart`:

```text theme={null}
status: idle | loading | ready | notOwned | noContent | unavailable | rateLimited | error
messages: List<AiChatMessage>   (text, isUser, sources, isFallback, isPending)
seances:  List<AiSeance>        (suggestion chips)
sending:  bool

openFor(Training t)    → token (Laravel) → status (bot) → resume the stored session or stay empty → seances
send(String text)      → optimistic user bubble + pending bubble → POST messages → replace the pending bubble
newConversation()      → POST /chat/sessions, clear messages, store the new session id
```

* **Resume across reloads:** keep the last `session_id` per slug in SharedPreferences
  (`aiAssistantSession_<slug>`) and load its history with `GET .../messages`. If that returns 403 or
  404, forget it and start fresh.
* Cache the token per slug in memory and refresh it when it is within 60 s of `expiresAt`.
* **Don't create a session until the first message is sent**, so opening the panel costs nothing.

### 7.4 Wire the widget (`training_ai_assistant_widget.dart`)

| `TODO(ai)` | Change |
| :- | :- |
| :38 static seed | Show `provider.messages`, with the welcome bubble only when the list is empty |
| :61 suggestion chip | `provider.send(chipText)`. Make the chips contextual: `Explain the lesson «${currentLesson.label}»`, `Summarize chapter ${currentChapterIndex + 1}`, and when `seances` is non-empty, `Résume la séance N` (the bot routes these to the right document). |
| :68 send | `provider.send(_controller.text)`, then clear the field |
| :191 New conversation | `provider.newConversation()` |
| :353 input disabled | `enabled: provider.status == ready && !provider.sending`, using the existing unused `aiAssistantInputHint` key |

`course_player_assistant.dart:31`: keep the `AiAssistantLocalSettings` gate for now (it becomes the
backend `ai_assistant_enabled` flag in phase 4), and call `provider.openFor(training)` when the panel
opens.

### 7.5 Error states → UI

| Bot / Laravel response | Panel shows |
| :- | :- |
| Laravel 403 on token | "Buy / enroll in this training to use its AI assistant" (`notOwned`) |
| Laravel 404 on token | "Assistant not available for this training" |
| Bot status `has_content: false` | "The instructor hasn't added material to the assistant yet" (`noContent`) |
| Bot 401 after one refresh, or bot unreachable | "Assistant temporarily unavailable", with a Retry button (`unavailable`) |
| Bot 429 | "You've reached the question limit, try again in a minute" (`rateLimited`) |
| Answer with `fallback: true` | Normal bubble, styled as "not found in the course material" |

### 7.6 Language

The app supports en/fr/ar, but the bot answers in **French or English only**. Map the app locale
`en → "en"` and `fr → "fr"`, and send `null` (auto-detect) for `ar`. In Arabic, show a one-line note
under the input: "The assistant answers in French or English". Supporting Arabic is a bot-side change
(see §11).

### 7.7 Rendering answers as Markdown

The bot answers in Markdown (headings, lists, bold). The no-new-download option: convert it with
`markdownToHtml()` from the `markdown` package (already in `pubspec.lock` through `flutter_quill`) and
render it with the existing `AppHtmlContent`. That requires listing `markdown: ^7.3.1` as a **direct**
dependency (same version, so nothing new is downloaded). Confirm this in §11. Sources
(`source_file › heading_path`) appear as small chips under the bubble.

### 7.8 Translations

Add the new keys to `assets/translations/{en,fr,ar}.json`: `aiAssistantNotOwned`,
`aiAssistantNoContent`, `aiAssistantUnavailable`, `aiAssistantRateLimited`, `aiAssistantRetry`,
`aiAssistantLanguageNote`, `aiAssistantNotInMaterial`, `aiAssistantSources`.

Then run both `easy_localization:generate` commands. While there, fix the "Off" badge, which reuses the
discount `off` key; it shows "Rem." in French and "خصم" in Arabic (`course_player_assistant.dart:230`,
`course_player_header.dart:357`).

### 7.9 Changelog

Append (never rewrite) an entry to `LMS_Frontend/major_code_updates.md`.

***

## 8. Phase 4: streaming and polish

After v1 works:

1. **Streaming answers (SSE).** The bot already streams (`/messages/stream`).
   * **Mobile:** Dio with `ResponseType.stream` streams natively. Parse the `data:` frames, which carry
     the `token`, `sources`, `done` and `error` events.
   * **Web (main target):** Dio's web adapter (and `http`'s `BrowserClient`) use XHR and only hand over
     the body once the request completes, so there is no token-by-token output. Use a small fetch +
     `ReadableStream` reader written with `package:web` / `dart:js_interop`, behind a conditional
     import. No new package is needed, but this needs a quick spike first.
2. **`trainings.ai_assistant_enabled`** (Laravel migration, default `true`), exposed in the training
   payload and editable by admins. The token endpoint returns 403 when it is off. Flutter then deletes
   `AiAssistantLocalSettings`, as already planned in `major_code_updates.md:3550`.
3. **Multi-turn memory in the bot:** wire `recent_turns()` into `rag._prepare()`, so a follow-up like
   "and the second one?" works.
4. **Student conversation list:** a new bot route, `GET /api/chat/sessions?training_id=the-slug`,
   guarded by `ensure_training_access` and filtered by `owner_key`. It feeds a "previous conversations"
   menu in the panel.
5. **Slug renames pushed from Laravel (optional):** when `TrainingsController::update` changes a slug,
   call the bot's re-key endpoint (or just rely on the sync from §6.6).

***

## 9. Environment variables checklist

| Service | Variable | Example / rule |
| :- | :- | :- |
| LMS\_Backend | `AI_BOT_SERVICE_KEY` | `openssl rand -hex 32`; same value as the bot's `LMS_SERVICE_KEY` |
| LMS\_Backend | `AI_BOT_JWT_SECRET` | `openssl rand -hex 32`; same value as the bot's `LMS_JWT_SECRET` |
| LMS\_Backend | `AI_BOT_JWT_TTL_MINUTES` | `30` |
| LMS\_Backend | `AI_BOT_PUBLIC_URL` | Local: `http://localhost:8000` · test: the bot's public URL |
| LMS\_ai\_bot | `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` |
| LMS\_ai\_bot | `LMS_SERVICE_KEY` | = `AI_BOT_SERVICE_KEY` |
| LMS\_ai\_bot | `LMS_JWT_SECRET` | = `AI_BOT_JWT_SECRET`; **must differ** from `JWT_SECRET` |
| LMS\_ai\_bot | `JWT_SECRET` | Its own long random value (bot admin sessions) |
| LMS\_ai\_bot | `CORS_ORIGINS` | Add `http://localhost:5000` and the Flutter web domains |
| LMS\_Frontend | none | The bot URL comes from Laravel's token response |

> [!NOTE]
>
> Run **one bot per Laravel environment** (at least test and prod). Each one has its own database and
> secrets, because the same slug can hold different content (or test data) in different environments.

***

## 10. End-to-end acceptance checklist

1. Calling `GET /api/ai-bot/trainings` with the `X-AI-Bot-Key` header returns the catalog; without the
   header it returns 401.
2. In the bot admin, "Sync from LMS" lists the trainings. Pick one by slug and upload `.md` files. The
   status shows `ready`.
3. As a student who **owns** that training, open the course player and ask a question. You get a
   grounded answer with sources, and the bot History shows the student's name and the slug.
4. As a student who **doesn't own** it, calling the token endpoint directly returns 403, and the panel
   shows the "enroll" state.
5. Reuse student 1's AI token on another training's slug: the bot returns 403. Reuse it after 30
   minutes: the bot returns 401, and the app refreshes the token silently.
6. Revoke the student's access in the admin. Within one TTL the assistant stops working, and the token
   endpoint returns 403 immediately.
7. Rename the training's slug in Laravel, then run "Sync from LMS". The documents follow the new slug
   and the assistant still answers.
8. Arabic UI: questions are accepted with auto-detect, and the language note is shown.
9. Send more than 15 messages per minute: you get 429, and the panel shows the rate-limit state.

***

## 11. Decisions to confirm

To settle before implementing:

| # | Question | Recommendation |
| :- | :- | :- |
| 1 | Who can get a token: only owning students, or also company Admin/Super Admin and the training's instructor (for testing)? | Owners **plus** staff of the same company (§3.4) |
| 2 | Token TTL, which trades revocation delay against refresh traffic | 30 min |
| 3 | Promote `markdown` (already in the lock file) to a direct dependency to render answers? | Yes, no new download |
| 4 | Streaming in v1, or blocking answers first? | Blocking in v1, SSE in phase 4 (the web needs a fetch-based reader) |
| 5 | Arabic: auto-detect plus a note, or extend the bot's language gate to `ar`? | Note for v1. Extending the gate means prompt and template work in the bot. |
| 6 | Slug renames: auto re-key on sync, or block slug edits in Laravel once a training has AI content? | Auto re-key on sync (§6.6) |
| 7 | Should the per-training on/off toggle move to Laravel (`ai_assistant_enabled`) in v1 or phase 4? | Phase 4. The local toggle keeps working until then. |

***

## 12. Suggested order of work

The root `CLAUDE.md` scopes prompts by repo prefix, so each step fits in one prompt:

**1. Backend**

`Backend >` Phase 0 (security prerequisites) and §5 (config, middleware, catalog, token endpoint,
tests).

**2. Documentation**

`Documentation >` §5.7 API pages.

**3. AI bot**

`LMS_ai_bot >` (add this prefix to the root `CLAUDE.md` first) §6.1–§6.7 backend, then §6.8 admin UI,
then §6.11 tests and §6.12 docs.

**4. Frontend**

`Frontend >` §7.1–§7.9.

**5. Phase 4**

The phase 4 items, one at a time.


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