xpeditis2.0/docs/features/trade-assistant.md
David 8e393b611a docs: documenter l assistant et son systeme de RAG
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018BAUeCFpDkRD6tU5wGsc1C
2026-09-07 21:40:52 +02:00

18 KiB
Raw Blame History

Assistant commerce international

Branche : bot_ai_help, créée depuis preparation_prod.

Fonctionnement

Page : /fr/dashboard/assistant ou /en/dashboard/assistant, accessible depuis les ressources du dashboard pour tout compte authentifié actif, y compris Bronze. L’API est protégée par le garde JWT global. L’utilisateur et l’organisation viennent exclusivement de la session validée, jamais du corps de requête.

Offre de l’organisation Questions par utilisateur et par jour
Bronze 3
Silver 10
Gold 15
Platinium illimité

Platinium est une offre sur devis et n’est pas plafonnée : la limite vaut -1, la convention déjà utilisée dans le domaine pour maxLicenses et maxShipmentsPerYear. La consommation reste comptée — c’est la base du suivi de coût — mais elle ne bloque jamais. Le décompte renvoyé vaut alors remaining: -1 et non 0, qui se lirait partout comme un quota épuisé.

Un compte ADMIN dispose de l’offre Platinium, donc d’un quota illimité, quel que soit l’abonnement de son organisation. Ce n’est pas une règle propre à l’assistant : SubscriptionService.getSubscriptionOverview() renvoie déjà PLATINIUM à tout compte ADMIN, ce que lit toute l’interface (badge d’offre, licences illimitées, absence d’échéance). L’assistant lisait auparavant l’abonnement brut : un administrateur voyait « Platinium » partout et n’obtenait que les trois questions Bronze de son organisation. La règle vit désormais dans domain/services/subscription-access.ts, appelée par les deux services, pour qu’elles ne puissent plus diverger.

Portée : ADMIN est le rôle d’administration de la plateforme, pas celui d’un client — les comptes créés par inscription sont MANAGER. L’exemption ne s’étend donc pas aux organisations clientes. À reconsidérer si ADMIN devait un jour être attribué côté client.

Sans abonnement actif, le quota Bronze s’applique. Une offre stockée inconnue (ancienne valeur, ligne écrite à la main) retombe sur Bronze, l’offre la plus restrictive, plutôt que de produire un NaN. Les limites sont dans domain/services/trade-assistant-policy.ts.

L’offre appliquée est affichée à côté du décompte, dans le composer : c’est ce qui rend un quota inattendu compréhensible sans ouvrir la page d’abonnement.

Les quotas sont individuels, persistés dans PostgreSQL, renouvelés à minuit Europe/Paris (changements d’heure compris). Une réservation atomique avant chaque appel empêche les dépassements entre onglets ou instances serveur. Un changement d’offre ne remet pas à zéro les questions déjà consommées dans la journée. Une erreur OpenAI rend la place réservée ; un arrêt brutal du serveur peut conserver une place consommée jusqu’au lendemain. Une requête reçue à la frontière de minuit peut inviter à réessayer après rafraîchissement du quota.

Après épuisement, l’envoi IA est bloqué côté serveur. L’aide guidée gratuite et support@xpeditis.com n’apparaissent qu’à ce moment-là, insérés dans le fil de discussion : tant que du quota reste, ils n’occupent pas l’écran. Le support est disponible même sans clé OpenAI. Le lien ouvre un email : aucun email automatique n’est envoyé.

Le décompte est affiché en permanence en bas à droite du champ de saisie, et rafraîchi après chaque envoi, au retour dans l’onglet et toutes les 30 secondes. La pastille porte une barre proportionnelle, de largeur fixe : une jauge d’une tranche par question ne tenait à aucune échelle — trois filets de 4 px sur Bronze, un bloc rayé de quinze barres sur Gold, et rien de représentable en illimité. Sur Platinium, la barre disparaît au profit de « Questions illimitées ».

Conversations

Les échanges sont organisés en conversations persistées (trade_conversations, trade_messages), listées par ancienneté dans un rail latéral, renommables et supprimables. Une conversation n’est créée qu’à la première question : ouvrir « Nouvelle conversation » n’écrit rien. Si le fournisseur échoue sur cette première question, la conversation est supprimée et le quota rendu — aucune conversation vide ne subsiste.

Chaque question consomme une unité de quota, quelle que soit la conversation. Les huit derniers tours de la conversation sont retransmis au modèle ; au-delà, l’historique coûte plus qu’il n’apporte. Toutes les requêtes du dépôt portent l’identifiant de l’utilisateur : une conversation appartenant à quelqu’un d’autre répond « introuvable », sans se distinguer d’une conversation inexistante, et la propriété est vérifiée avant toute réservation de quota.

Les questions et les réponses sont désormais enregistrées en base, contrairement à la première version où rien n’était persisté. La suppression d’une conversation, ou d’un utilisateur, supprime ses messages par cascade. Ce point est à refléter dans la politique de confidentialité et le registre des traitements.

Recherche documentaire (RAG)

Le modèle reçoit, avec la question, les extraits du wiki Xpeditis les plus proches. Le corpus n’est pas une base séparée à maintenir : il est extrait du wiki du site lui-même (dashboard.wikiPages dans apps/frontend/messages/{fr,en}.json) par npm run knowledge:build, qui écrit apps/backend/src/infrastructure/ai/knowledge/wiki-corpus.json — 89 fragments par langue, versionnés pour que l’image backend reste autonome. Une réponse et la page wiki citée ne peuvent donc pas diverger.

L’interface affiche sous chaque réponse les pages d’où proviennent les extraits, en liens cliquables vers le wiki.

Deux caches évitent de refaire le même calcul :

  1. L’index est vectorisé une seule fois. La clé Redis contient l’empreinte SHA-256 du corpus : tant que le wiki ne change pas, aucun appel d’embedding n’est refait, même après un redémarrage ou un déploiement. L’index est aussi gardé en mémoire du processus.
  2. Les questions sont vectorisées une fois par formulation — insensible à la casse, aux accents et à la ponctuation — et pour tous les utilisateurs.

Sans clé OpenAI, ou si l’API d’embeddings échoue, la recherche bascule sur un score lexical ; si la recherche échoue entièrement, la réponse est produite sans extrait. Aucun de ces cas n’empêche de répondre. PostgreSQL 15 est utilisé sans pgvector : l’index tient en mémoire (89 vecteurs de 512 dimensions par langue) et transite en base64 de Float32Array.

Les extraits sont introduits comme de la documentation, pas comme des consignes, et l’instruction système rappelle que toute directive contenue dans la question ou la documentation reste une demande utilisateur. Le modèle ne reçoit jamais les dossiers clients ni les identifiants. Les réponses sont affichées comme du texte, sans exécution HTML. L’assistant explique son absence de vérification en temps réel et oriente les dossiers spécifiques et questions incertaines vers le support.

Configuration et lancement

  1. Appliquer les migrations habituelles depuis apps/backend : npm run migration:run. Deux migrations concernent l’assistant : trade_assistant_usage (compteurs) et trade_conversations / trade_messages (conversations).
  2. Configurer OPENAI_API_KEY dans l’environnement backend uniquement. Ne pas utiliser une variable NEXT_PUBLIC_*, ni committer une clé.
  3. OPENAI_MODEL=gpt-4.1-mini et OPENAI_EMBEDDING_MODEL=text-embedding-3-small par défaut. Toute modification de modèle demande de revoir le prix, les paramètres compatibles et la qualité des réponses. Changer le modèle d’embedding invalide l’index (la clé de cache le contient) : il est reconstruit au premier appel.
  4. Redémarrer le backend et le frontend.

Après toute modification du wiki dans messages/{fr,en}.json, relancer npm run knowledge:build depuis apps/backend et committer le corpus régénéré. L’empreinte du corpus changeant, l’index est revectorisé automatiquement au premier appel suivant.

Sans clé, l’application démarre normalement et propose l’aide guidée. Aucune clé réelle n’est nécessaire aux tests automatisés. L’intégration utilise l’API Responses, store: false, un délai maximal de 30 secondes et aucune relance automatique. Limites : 2 000 caractères par question et 800 tokens de sortie. store: false ne constitue pas une promesse de rétention nulle chez le fournisseur ; consulter les conditions OpenAI applicables au projet. Les questions et réponses sont, elles, conservées dans la base Xpeditis (voir « Conversations »).

API, toutes protégées par le garde JWT global :

Méthode Route Effet
GET /api/v1/trade-assistant/quota Quota du jour, offre, disponibilité
GET /api/v1/trade-assistant/conversations Conversations de l’utilisateur, plus récente d’abord
GET /api/v1/trade-assistant/conversations/:id Messages d’une conversation
PATCH /api/v1/trade-assistant/conversations/:id Renommer ({ "title": "..." }, 60 caractères)
DELETE /api/v1/trade-assistant/conversations/:id Supprimer la conversation et ses messages
POST /api/v1/trade-assistant/questions { "question": "...", "language": "fr", "conversationId"?: "uuid" }

Sans conversationId, la question ouvre une conversation titrée d’après ses 60 premiers caractères, coupés sur un mot entier. Le POST renvoie mode: ai | guided | unavailable, le quota à jour et, en mode ai, conversationId, les deux messages créés, answer et sources.

Les compteurs de tokens permettent un suivi du coût observé (la facturation OpenAI fait foi). Une coupure réseau peut être facturée par OpenAI sans qu’une réponse parvienne au serveur ; une nouvelle tentative est alors un nouvel appel. Les coûts correspondants ne sont pas inclus dans le compteur local. La suppression d’un utilisateur supprime ses compteurs par cascade.

Chiffrage au 5 septembre 2026

Source officielle : GPT-4.1 mini. Prix standard par million de tokens : 0,40 USD en entrée, 1,60 USD en sortie, hors taxes. Le calcul ignore les remises de cache. Intégration : génération de texte / Responses.

Formule : (tokens entrée × 0,40 + tokens sortie × 1,60) / 1 000 000 par appel.

Les conversations et le RAG augmentent l’entrée. Une question ne coûte plus seulement l’instruction : s’y ajoutent les extraits du wiki (4 fragments, environ 450 tokens) et jusqu’à huit tours d’historique. Les chiffres ci-dessous remplacent ceux de la première version.

Hypothèse centrale, à mesurer sur de vraies questions : 1 500 tokens d’entrée (instruction ~300, cadre documentaire ~110, extraits ~450, historique court, question) et 500 tokens de sortie, soit 0,0014 USD par question.

Offre Appels / 30 jours à quota plein Coût IA / utilisateur / mois 1 000 utilisateurs / mois
Bronze 90 0,126 USD 126 USD
Silver 300 0,420 USD 420 USD
Gold 450 0,630 USD 630 USD
Platinium non plafonné — —

Platinium n’a plus de plafond, donc plus de coût maximal calculable. À titre de repère, 50 questions par jour et par utilisateur représentent 1 500 appels sur 30 jours, soit 2,10 USD par utilisateur et par mois dans le scénario central. Ce poste est à chiffrer au devis, à partir d’un usage estimé avec le client, et à surveiller sur la consommation réelle — les compteurs trade_assistant_usage restent alimentés pour cela. Les contrôles de dépense du projet OpenAI sont le seul plafond effectif.

Un mois de 31 jours coûte 3,33 % de plus. À 25 % d’utilisation des quotas : 0,032 / 0,105 / 0,158 USD par utilisateur et par mois.

Scénario prudent — conversation longue, historique plein et sortie au plafond : 3 000 tokens d’entrée et 800 de sortie, soit 0,00248 USD/question : 0,2232 USD Bronze, 0,744 USD Silver, 1,116 USD Gold par utilisateur sur 30 jours. La longueur en caractères ne correspond pas exactement aux tokens : ce scénario n’est pas un plafond mathématique. Une provision très conservatrice à 9 000 tokens d’entrée et 800 en sortie représente 0,00488 USD/question, soit 0,4392 / 1,464 / 2,196 USD par mois.

Les embeddings sont négligeables, et c’est l’effet recherché. text-embedding-3-small coûte 0,02 USD par million de tokens. L’index complet (178 fragments, environ 21 000 tokens) représente 0,0004 USD une seule fois — pas par requête, pas par démarrage : la clé de cache est l’empreinte du corpus. Une question vectorisée coûte moins d’un millionième de dollar, et rien du tout si elle a déjà été posée. Sans ces deux caches, revectoriser l’index à chaque redémarrage aurait suffi à rendre ce poste visible en facturation.

Viabilité avec les offres du dépôt

Les montants ci-dessous reprennent les offres présentes dans le code, pas une vérification de vos tarifs commerciaux effectivement publiés : Bronze gratuit (1 utilisateur), Silver 299 EUR/mois (5 utilisateurs), Gold 799 EUR/mois (20 utilisateurs).

  • Silver, 5 utilisateurs consommant tout leur quota : 2,10 USD/mois d’IA dans le scénario central ; 3,72 USD dans le scénario prudent.
  • Gold, 20 utilisateurs consommant tout leur quota : 12,60 USD/mois d’IA dans le scénario central ; 22,32 USD dans le scénario prudent.
  • Bronze gratuit : coût d’acquisition/service, sans revenu d’abonnement. 10 000 utilisateurs actifs à quota plein représentent 1 260 USD/mois dans le scénario central.
  • Platinium : quota illimité et licences illimitées — le coût est proportionnel à l’usage réel, sans borne technique. C’est le seul poste du chiffrage qui doit être négocié au devis plutôt que déduit du code.

Le coût de génération paraît compatible avec Silver et Gold, mais ne suffit pas à démontrer la rentabilité globale. Les prix d’abonnement sont en EUR et les coûts OpenAI en USD : appliquer le taux réellement facturé. Ne sont inclus ni taxes, hébergement, développement, maintenance, ni temps humain du support. Exemple purement illustratif : si 10 % des utilisateurs sollicitent 10 minutes de support par mois à 30 EUR/heure de coût interne, cela ajoute 0,50 EUR par utilisateur par mois en moyenne.

Suivre séparément l’adoption, les tokens, les erreurs, le taux de recours au support et son temps de traitement. Les quotas par compte ne constituent pas un plafond global de dépense : les inscriptions multiples ou la croissance Bronze augmentent le total. Configurer les contrôles de dépense disponibles dans le projet OpenAI avant la mise en service et surveiller la facturation.

Vérification

Backend : npm test -- --runInBand trade-assistant.service.spec.ts openai-trade.adapter.spec.ts wiki-retriever.spec.ts depuis apps/backend.

Frontend : npm test -- trade-assistant assistant-answer-text depuis apps/frontend.

Test PostgreSQL réel, uniquement sur une instance jetable :

docker run --detach --rm --name xpeditis-trade-quota-test --tmpfs /var/lib/postgresql/data:rw,size=256m -e POSTGRES_PASSWORD=trade_test_only -p 127.0.0.1:55439:5432 postgres:15-alpine
# Depuis apps/backend, une fois PostgreSQL prêt :
TRADE_TEST_DATABASE_URL=postgres://postgres:trade_test_only@127.0.0.1:55439/postgres npm test -- --runInBand typeorm-trade-quota.repository.spec.ts
docker stop xpeditis-trade-quota-test

Le test crée puis supprime un schéma dédié, vérifie 20 réservations concurrentes pour 3 places, l’isolation utilisateur, le jour précédent, la restitution, la comptabilisation de tokens et la suppression par cascade. Il est ignoré sans variable explicite. Validation avant activation réelle : appliquer la migration sur un environnement de test, configurer une clé, vérifier quelques questions représentatives et la pertinence des réponses avec l’équipe métier. Aucun appel OpenAI réel n’a été nécessaire à l’implémentation.

Validation effectuée : 44 tests backend (service, validation, adaptateur OpenAI et recherche documentaire), 4 tests sur PostgreSQL 15 réel et 17 tests d’interface réussis ; compilation backend et contrôles TypeScript frontend réussis. Suites complètes vertes : 281 tests backend, 161 tests frontend.

Les tests backend couvrent notamment : la propriété d’une conversation vérifiée avant toute dépense de quota, la suppression d’une conversation créée pour une question qui a échoué, le rejeu de l’historique, les sources dédoublonnées par page, la réponse rendue même quand la recherche échoue, la vectorisation du corpus une seule fois par processus, sa réutilisation après redémarrage sans nouvel appel, et l’absence de revectorisation d’une question déjà posée.

Les tests d’interface couvrent l’envoi (clic et Ctrl+Entrée), le quota affiché en bas à droite, la conservation de la question en cas d’erreur réseau, l’affichage du fil avec ses sources cliquables, la liste des conversations groupée par ancienneté, le renommage en place, la confirmation avant suppression, et le fait que l’aide guidée et le support n’apparaissent qu’une fois le quota atteint ou le fournisseur indisponible.

Les réponses OpenAI et les embeddings sont simulés : aucun appel réel n’a été nécessaire. Vérification visuelle effectuée en pilotant Chrome sur le serveur de développement, en 1440, 1024 et 390 px, avec API simulée.