Skip to main content
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

3. What exists today (verified in the code)

LMS_ai_bot (FastAPI + pgvector + React admin)

LMS_Backend (Laravel 12)

LMS_Frontend (Flutter)

4. Target architecture

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

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:

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: 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)

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

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

  • 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)

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”)

  • 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

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

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

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

12. Decisions to confirm before coding

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

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): 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): 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). 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.” C. “Email as the unique id.” 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: 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: Tokens per day: Cost per model (list prices per million tokens, USD; “d” = how many days 10 USD last in that scenario): 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.