erplibre/script/todo/assistant/README.fr.md
Mathieu Benoit 8fc026c5c7 [ADD] assistant : serveur LLM reconnu, catalogue gpt, sessions locales
L'entrée « Question IA » envoyait toute question à une seule API distante, sur
un modèle figé et sans historique : aucun serveur local n'était atteignable,
et rien ne disait où partait le texte.

Deux règles gouvernent ce qui la remplace. Un port ne dit jamais qui répond —
un seul en héberge jusqu'à trois, et une famille réémet l'API d'une autre en
entier — donc l'identité se lit dans le CORPS, par une échelle ordonnée. Et
une adresse ne devient jamais du texte d'invite : le détecteur du dépôt ne
voit pas les noms d'hôte, d'où une poignée opaque à sa place.

Vérifié : 284 tests, et contre un serveur du réseau — reconnaissance,
capacités lues, conversation multi-tours, triage juste.

--- EN ---

The "AI question" entry sent every question to a single remote API, on a
frozen model and with no history: no local server was reachable, and nothing
said where the text was going.

Two rules govern what replaces it. A port never says who answers — one hosts
up to three products, and one family re-serves another's native API in full —
so identity is read from the BODY, by an ordered ladder. And an address never
becomes prompt text: the repository's detector does not see host names, hence
an opaque handle in its place.

Checked: 284 tests, and against a server on the network — recognition,
capabilities read, a multi-turn conversation, a correct triage.

Assisted-by: Claude Opus 5
2026-09-09 07:35:15 -04:00

12 KiB
Raw Blame History

L'assistant LLM — qui répond, et ce qu'on a le droit de lui dire

script/todo/assistant/ est ce que lance Assistant › LLM : il reconnaît le serveur de modèle qui écoute sur un port, lit ce que ce serveur annonce savoir faire, garde ceux qu'on a choisis, et tient la conversation.

Un port dit où frapper, jamais qui répond

C'est la seule chose à comprendre avant de lire une ligne du code. Le port 8080 héberge llama.cpp, LocalAI et Open WebUI ; le port 5000 héberge text-generation-webui et TabbyAPI ; /v1/models est servi par onze des douze familles. Un port ouvre donc la question — il n'y répond jamais.

L'identité se lit dans le corps d'une réponse, par une échelle de treize étages sur onze ports, arrêt au premier accord. L'ordre de cette échelle porte tout le raisonnement. LocalAI réémet l'API native d'Ollama en entier — /api/tags, /api/show, /api/ps, /api/version — jusqu'à la chaîne Ollama is running sur /. Les points de terminaison qui ressemblent à ceux d'Ollama n'identifient donc pas Ollama. LocalAI s'écarte le premier, par GET /readyz, qu'Ollama ne possède pas et où il rend 404 ; l'étage Ollama n'est atteignable que parce que cet écart a déjà eu lieu. Déplacer l'étage LocalAI plus bas nomme « ollama » toutes les machines LocalAI.

identify() est pure — elle ne reçoit que des octets déjà lus — donc l'ordre de l'échelle se vérifie sans ouvrir une socket. collect() ne fait que le transport, et n'émet que des GET, sans corps et sans en-tête Authorization : un balayage ne doit pouvoir ni charger un modèle, ni dépenser un jeton.

Une adresse ne devient jamais du texte de prompt

Un alias SSH, un nom d'hôte, une adresse IP, un nom de VM désignent des machines qui ne s'annoncent nulle part ailleurs. Chaque serveur retenu porte donc une poignée opaque — server-1 — attribuée par son rang au chargement, et c'est la seule forme qui a le droit de circuler : redacted() rend server-1 (ollama), et c'est cela qui peut atteindre une invite, un argument de commande ou un fichier que le dépôt suit. L'hôte, le port et le libellé servent à l'affichage du menu et à la configuration privée, et s'arrêtent là.

La retenue est structurelle parce qu'elle ne peut pas être un filtre. Le détecteur du dépôt reconnaît les adresses, les courriels et les chemins de compte, et rend une liste vide devant un nom d'hôte, un alias SSH ou un nom de base de données. Ce qu'aucun garde-fou ne voit passer ne doit pas être en position de passer.

Le menu

Assistant › LLM porte cinq entrées.

Entrée Ce qu'elle fait
Question libre la conversation avec le serveur en usage, ou le repli distant quand aucun local n'a répondu
Outils gpt le catalogue, les compatibles en tête, choisis par lettre
Serveurs connus lister, choisir, ajouter à la main, supprimer
Chercher un serveur… six sources, de la boucle locale à un réseau saisi
Fiche du serveur ce que le serveur en usage annonce savoir faire

Les entrées se choisissent par numéro, le catalogue par LETTRE. Une seconde liste numérotée juste après un menu numéroté invite à retaper une entrée de menu, et ce dépôt l'a déjà payé une fois. Un chiffre y reste accepté comme rang, parce que le doigt vient d'en taper un.

Une destination tierce doit être retapée avant le premier envoi — une fois par session, et pour cette destination seulement. Une frappe sur « o » se donne par réflexe ; recopier l'adresse oblige à regarder où part le texte.

L'historique de la conversation vit en mémoire et nulle part ailleurs — rien dans chat.py n'ouvre un fichier. Il meurt avec le menu, et /save est le seul moyen d'en garder une trace, ce que l'en-tête dit AVANT la conversation qui méritait d'être gardée. Toute commande porte une barre oblique initiale et aucun nombre nu n'en est une : une question collée sur plusieurs lignes devient autant de tours, et une ligne collée valant 0 déclencherait sinon une entrée de menu.

La découverte d'hôtes au-delà de la boucle locale — domaines QEMU, ~/.ssh/config, balayage du /24 local — repose sur quatre sources, dont deux sont INJECTÉES : l'énumération des domaines libvirt et la résolution d'un alias SSH existent déjà comme méthodes de la classe du CLI, que ce paquet n'a pas le droit d'importer.

Un serveur vit souvent sur un réseau que cette machine ne PORTE pas, joignable par la passerelle : quand le CLI tourne dans une machine virtuelle, le « réseau local » qu'il voit est celui de l'hyperviseur. Deux sources y répondent — un CIDR saisi, et les réseaux lus en SSH sur une autre machine puis balayés d'ici. La table de voisinage, elle, ne peut pas : elle est link-local, donc un hôte routé n'y figure jamais.

Plus large qu'un /24 est refusé, et le refus précède l'énumération — mesurer un /8 en le matérialisant coûterait seize millions d'adresses. La piscine se dimensionne par NOMBRE DE VAGUES et jamais par nombre de cœurs : ces fils attendent le réseau. Et le délai de connexion est le seul réglage d'ici qui fabrique des FAUX NÉGATIFS — un hôte joignable en une milliseconde au repos se manque à cinq centièmes sous mille connexions simultanées, donc il ne se déduit pas de la latence mesurée.

Le catalogue d'outils gpt

Un gpt est un fichier Markdown : en-tête YAML, invite système, contexte READ-ONLY déclaré. Rien n'y s'exécute au nom du modèle.

Ses exigences sont confrontées à ce que le serveur annonce, et une règle gouverne l'affichage : L'INCONNU NE GRISE JAMAIS. Seule une exigence contredite par un champ réellement lu sur le serveur grise — griser sur l'inconnu viderait le catalogue devant un serveur qui n'annonce rien, c'est-à-dire devant la plupart. Une valeur estimée ne grise pas non plus.

yaml.safe_load n'est pas un validateur, et cela façonne le chargeur. Un en-tête qui est une liste rend une liste, un scalaire rend une chaîne, un fichier vide ne rend rien, et une clé RÉPÉTÉE est résolue en silence sur la dernière — deux blocs requires changeaient donc la classe de sûreté d'un gpt sans un mot. D'où un contrôle de type et une relecture du texte brut.

Un gpt hors du dépôt est forcé en boucle locale et ne peut déclarer aucune commande : un fichier que personne n'a relu est de la configuration, pas une donnée.

Ce qu'un contexte déclaré a le droit de lire

La liste de refus passe la première et se résout sur le chemin RÉEL : ni « suivi par git » ni « ignoré par git » n'est une porte utilisable, puisque private/ est partiellement suivi et que tasks/ n'est dans aucun fichier d'exclusion. Un lien symbolique est donc résolu avant d'être comparé.

Une commande est un argv, jamais une chaîne d'interpréteur, et elle est confrontée à une liste d'autorisation qui vit dans le dépôt. La substitution d'une entrée saisie précède ce contrôle, jamais l'inverse : vérifier un gabarit puis y injecter une valeur vérifierait ce qu'on n'exécute pas.

Chaque octet assemblé passe par le détecteur du dépôt — et sa limite est dite plutôt que cachée. Il reconnaît les adresses, les courriels et les chemins de compte. Il ne reconnaît PAS les noms, sauf si une liste les énumère, et cette liste n'existe pas d'ordinaire. Une absence de trouvaille ne prouve donc rien sur les noms, et un envoi vers un tiers est REFUSÉ dans ce cas, même sans aucune trouvaille.

Où s'écrit un serveur, et ce qui ne s'écrit pas

L'écriture passe par config_file.set_config_value(), et par lui seul. Des trois fichiers que la lecture fusionne, c'est le seul qui soit gitignored :

Chemin État
script/todo/todo.json suivi, il suit le dépôt en amont
private/todo/todo_override.json non ignoré — commitable, et public sur un fork rendu public
private/todo/todo_override_private.json gitignored — là où s'écrit un serveur

set_config_value() fusionne au lieu d'écraser, et écrit atomiquement : un temporaire créé en 0600 dans le même dossier, puis os.replace. Une seule section est écrite, sous le chemin de clés « assistant › servers », sept champs par serveur, tous fournis par l'utilisateur ou lus sur le serveur qu'il a désigné. La poignée n'est pas écrite : elle se rattribue par le rang au chargement, et un rang figé sur le disque survivrait à la suppression d'un voisin.

Rien d'autre n'est écrit — ni rapport de balayage, ni table de qui est vivant, ni résultat négatif, ni journal horodaté. La liste de qui a répondu parmi les 254 adresses d'un /24 décrit des machines que personne n'a désignées, là où un serveur retenu en désigne une seule, volontairement.

Les sessions Claude Code de la machine

Une session ouverte ailleurs porte déjà le contexte d'un travail, et lui poser une question sans le retaper vaut le détour. Elle vit sous « GPT code » et non sous le sous-menu LLM : une session est un processus adressé par identifiant, un serveur est un hôte adressé par port, et les mêler dans une seule liste numérotée ferait partager les mêmes chiffres à deux modèles mentaux.

Deux dangers ont dû être mesurés avant de le proposer. Un pid ne prouve pas qu'une session vit — les pids se recyclent, donc la vivacité exige le pid ET le moment de démarrage du processus. Et l'outil ne REFUSE pas de reprendre une session qu'un terminal tient, son garde-fou écartant les détenteurs interactifs ; deux écritures scindent alors la transcription et une branche est orpheline. Une copie est branchée par défaut, et écrire dans une session tenue exige de retaper le pid du détenteur.

La frontière de vie privée est celle que le système a déjà tracée. Le registre est lisible par tous, donc pid, répertoire, nom et identifiant n'y sont pas des secrets. Les transcriptions ne le sont pas : il n'en sort que deux champs de STRUCTURE — le répertoire de travail et la branche git — jamais un titre, une invite ou un message. Le répertoire y est LU plutôt que dérivé du nom du répertoire qui la contient, parce que cette transformation change les séparateurs, les points et les tirets bas en tirets et ne s'inverse donc pas.

Les modules

Fichier Ce qu'il porte
__init__.py les deux règles qui gouvernent tout ce qui suit
fingerprint.py qui répond sur un port : l'échelle, et son transport
capabilities.py ce qu'un serveur annonce savoir faire, en trois paliers d'honnêteté, et l'appariement d'une exigence
servers.py les serveurs retenus : poignées opaques, lecture et écriture
backends.py parler à une destination : un serveur HTTP, ou le CLI claude
chat.py les tours d'une conversation, et les commandes qui la pilotent
discover.py quels couples (hôte, port) méritent une reconnaissance, et la frappe
gpt.py le catalogue : charger, refuser, et ne jamais casser le menu
context.py ce qu'un contexte déclaré peut lire, et ce que la porte autorise
claude_sessions.py les sessions Claude Code de la machine : lesquelles vivent
../assistant_menu.py le mixin : demander et afficher, hors du paquet

Aucun de ces modules n'importe todo.py, qui coûte près d'une seconde et imprime en arrivant. C'est le mixin du menu qui les branche sur le CLI, et un test par sous-processus dans test_assistant_menu.py tombe en rouge le jour où l'un d'eux y touche.

Tests

make test_unit_file F=test/test_assistant_fingerprint.py
PYTHONPATH=. ./.venv.erplibre/bin/python test/test_assistant_servers.py
for f in test/test_assistant_*.py; do
  PYTHONPATH=. ./.venv.erplibre/bin/python "$f"
done