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.dartand switchingConstants.baseUrlto another environment switches the bot too. - One bot instance per Laravel environment (test, prod): separate database, separate keys.
5. The three credentials
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 byPOST /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 issubstr(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) andresets_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 bykey_prefix, thenhash_equalson the hash;last_used_atupdated at most once a minute (no write per request).platform_settings: no migration, three rows withcompany_id = null.trainings.ai_assistant_enabled: boolean defaulttrue,$fillable,nullable|booleanonPOST /training/{id}guarded byrequest()->has(), present inGET /training/{id},GET /trainings,GET /preview-training/{idOrSlug}and the student’smy_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
MessageOutand thestatusresponse gain ausageobject: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
limclaim 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 ofLMS_Backend(behindAPP_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.gitignorewith a.gitkeep, and addLMS_ai_bot >/ai_bot >to the prompt-prefix table of the rootCLAUDE.md. - Bot branch: decide where the integration lands (
master, or a newlms-integrationbranch frombefore-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,
PlatformSettingrows forai_bot.*.
- casts,
- Middleware
VerifyAiBotApplicationKey(header only, prefix lookup,hash_equals, revoked and expiry checks,last_used_atthrottled),RateLimiter::for('ai-bot'). -
AiBotController:ping,trainings,training. Whitelisted arrays,whereNotNull('slug')not required any more (id-keyed),withoutGlobalScope('archived')->withTrashed()only withinclude_inactive=1, eager-loadcompany:id,label(strict mode). -
AiAssistantController:token(§5.3) and the super-adminkeys,settings,rotateSigningSecret; every method starts withisSuperAdmin()excepttoken. -
AiBotToken::encode()(native HS256).config/custom_config.phpai_bot.issuer/ai_bot.audience. -
TrainingsController@update: acceptai_assistant_enabled;AuthUserPayloadServiceandMarketplacePreviewService::trainingCardexpose 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_tableon key creation, revocation and secret rotation.
Phase 2 · LMS_ai_bot backend (FastAPI), 2 to 3 days
- Alembic
0004_lms_integration(§7.2):trainings_refcolumns,conversations.student_label,usage_counters.tokens_used,integration_settings. -
services/integration_settings.py: Fernet encryption with a key derived fromJWT_SECRET(cryptographyis 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_decodeorder, startup guard inmain.py. -
api/deps.py:ensure_training_access;api/chat.py: apply it on every route, owner key fix,statusandme/usageroutes,usagein answers,recent_turns()intorag._prepare(). -
services/guardrails.py:check_quota(minute, day, tokens) +record_tokens; limits read from settings with the §8 defaults; 429 body withcode,scope,resets_at. -
api/admin.py:lms/settings,lms/test,lms/trainings,lms/sync,limits; history and analytics responses carrystudent_label,training_name,slug. - Ingestion: fill
trainings_reffrom the catalog cache when the row is missing. -
CORS_ORIGINS:http://localhost:5000and the Flutter web domains. - Tests: token for
tid=42creates a session for 42 and gets 403 on 43 (session, seances, status, posting to a 43 session); expired / wrongaud/ wrongiss/ missingtyp/ wrong secret give 401;scopes: ["admin"]in an LMS token still gets 403 on/api/admin/*;lms:1cannot readlms: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. -
TrainingPickercombobox (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')+ modelAiAccessToken(token,expiresAt,aiBaseUrl,training,limits);build_runner. -
lib/core/services/ai_assistant/ai_bot_client.dart: a secondDio(10 s connect, 90 s receive, no Sanctum interceptor), base URL from the token response, Bearer AI token, one silent refresh on 401. Retrofit clientAiBotApiClientforstatus,seances,sessions,messages,me/usage. Models underlib/core/data/remote/ai_assistant/. -
AiAssistantProvider(ChangeNotifier,locator.dart+providers.dart): statesidle · 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’sseances, Send, New conversation, input enabled whenready, “N questions left today” line, sources as small chips, Markdown rendered throughmarkdownToHtml()+AppHtmlContent(promotemarkdownto a direct dependency, same version, nothing new downloaded). -
course_player_assistant.dart: readtraining.aiAssistantEnabledfrom the payload instead ofAiAssistantLocalSettings; callopenForwhen 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
offkey (“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.toggleAiAssistantForTrainingsendsai_assistant_enabledtoPOST /training/{id}and re-fetches, as Update 38 planned; deleteai_assistant_local_settings.dartand its test; re-add the field to theTrainingmodel.
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 indocs.jsonand add the two controllers to the controller-to-page map inCLAUDE.md. -
api-reference/trainings.mdx: theai_assistant_enabledfield on create, update, show, list and preview. -
system-design/class-diagram.mdx:AiBotApiKeyand the newTrainingcolumn. - Changelog mirrors once the Flutter entries exist.
Phase 7 · Later
- Streaming answers. The bot already streams. Mobile:
DiowithResponseType.stream. Web (the main target): XHR gives the body only at the end, so a smallfetch+ReadableStreamreader throughdart:js_interopbehind 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
limclaim. - Feeding from Laravel: a “Course material for the assistant” upload in the Flutter admin that
pushes
.mdfiles 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
curl -H "X-AI-Bot-Key: <key>" $LARAVEL/api/ai-bot/pinganswersokand the fingerprint; without the header or with a revoked key it answers 401.- In the bot admin, Test connection says “signing secret matches”, Fetch trainings lists
the catalog with public / private and AI badges. Pick one, upload
.mdfiles, statusready. - 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.
- A student who does not own it gets 403 from the token endpoint and the panel’s enroll state.
- Reuse student A’s token on another training id: 403. Reuse it after 30 minutes: 401, refreshed silently once.
- Revoke the student’s access or switch the assistant off: the token endpoint refuses at once; the assistant stops within one TTL.
- Rename the training’s slug, run Fetch trainings: the dropdown shows the new slug, documents and conversations are untouched.
- 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.
- Ask the 61st question of the day: 429, the panel shows the reset time, history still readable.
- Arabic UI: questions accepted with auto-detect, the language note is shown.
15. Suggested order of prompts
The rootCLAUDE.md scopes work by repo prefix, so each step is one prompt:
Backend >Phase 0 (debug routes, placeholders) then Phase 1.Documentation >Phase 6 (the API pages can be written from the Phase 1 code).LMS_ai_bot >Phase 2, then Phase 3 (add the prefix to the rootCLAUDE.mdfirst).Frontend >Phase 5 first (pairing page + backend toggle, small), then Phase 4 (student panel).- 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:- The course player asks Laravel for a token for training 42 (Sanctum session).
- Laravel checks the paid order item (
MarketplacePreviewService::ownedTrainingIds), the company, andai_assistant_enabled, then signs a token withtid = "42"for 30 minutes. - The bot verifies the signature with the shared secret and, on every chat call, compares
tidwith the training of the session or path. No Laravel call, no ownership table in the bot.
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-5for the answers. Grounded question answering over supplied context is its home ground, and it is 20 times cheaper than Sonnet.claude-sonnet-5-5is the quality step for a premium training;claude-opus-5-5is 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-minifor quality,gpt-5-nanowhen 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-chatid 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 oneChatOpenAI(base_url=...)branch inservices/llm.pyadds 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_KEYset 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.