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
217 lines
No EOL
12 KiB
Markdown
217 lines
No EOL
12 KiB
Markdown
|
||
# 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
|
||
|
||
```bash
|
||
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
|
||
``` |