# Ollama daily use A series of 9 article(s), in reading order. # Installer Ollama et obtenir une IA locale > Apprenez à déployer Ollama en local via Docker pour faire tourner des LLM privés et gratuits, et activer l'autocomplétion de code par IA dans VSCode. --- ![Installer Ollama et obtenir une IA locale](/img/v2/playing_with_ollama.webp) **TL;DR**Ce guide explique comment installer Ollama en local avec Docker pour faire tourner gratuitement de grands modèles de langage tout en gardant une confidentialité totale des données. Il détaille comment choisir les modèles adaptés à la mémoire disponible sur votre machine, pour des tâches comme l'autocomplétion rapide de code ou le refactoring complexe. Vous apprendrez aussi à intégrer ces outils directement dans votre workflow via Open WebUI et l'extension Continue pour VSCode. Dans un de mes derniers articles à propos de mon composant [résumé TL;DR](/blog/gemini-tldr), j'ai écrit un script Python pour consommer l'API Gemini et boum, après quelques requêtes à peine, j'ai reçu une erreur de quota maximum dépassé. Et si on pouvait régler ça gratuitement ? Comment ? Tout simplement en installant un LLM en local, sur notre machine. Fini les quotas. ## Essayez dans votre terminal {#try-it-in-your-terminal} Une fois Ollama lancé et un modèle téléchargé (voir la section Installation plus bas), la commande est `ollama run ` : ```bash $ docker exec -it ollama ollama run llama3.1:8b ``` Vous pouvez maintenant poser n'importe quelle question, par exemple une comparaison entre Quarto et Docusaurus : ![Demander une comparaison entre Quarto et Docusaurus](./images/test_quarto_docusaurus_comparaison.webp) Ou demander une blague : ![Obtenir une blague](./images/test_joke.webp) Tapez `/bye` pour quitter. > **Plus rapide la deuxième fois:** > > Vous avez constaté un délai avant l'apparition du prompt ? Relancez exactement la même commande ; vous verrez, c'est immédiat cette fois puisque le modèle est déjà chargé en RAM. > > Et si vous n'utilisez pas le modèle pendant cinq minutes, Ollama le déchargera et libérera votre RAM. ### Dois-je parler anglais avec le modèle ? {#should-i-speak-english-with-the-model} En fait, non — comme pour une page web, vous pouvez utiliser votre propre langue. Dans l'exemple ci-dessous, vous verrez aussi une autre façon de poser une question : au lieu de passer par un terminal interactif, vous pouvez taper votre question directement sur la ligne de commande. ![Poser une question en français](./images/test_asking_in_french.webp) ## Avez-vous besoin d'un LLM local ? {#do-you-need-a-local-llm} La raison la plus importante est sans doute la confidentialité : l'avoir en local, sur votre machine, signifie que vos documents restent chez vous. Vous ne partagerez pas votre codebase, par exemple, avec des sociétés d'IA. *C'est exactement pour cette raison que j'aimais [Tabnine](/blog/vscode-tabnine), qui fonctionne aussi hors ligne — Ollama pousse simplement l'idée beaucoup plus loin.* Pensez aussi à l'automatisation : vous pourrez exécuter des scripts d'automatisation sans craindre d'atteindre un quota. Et pas de facture non plus : comme tout tourne sur votre machine, c'est entièrement gratuit. ## Installer et lancer Ollama {#installing-and-running-ollama} Vous vous en doutez, en amoureux de Docker, je n'installerai pas Ollama à la main. Créons le fichier `compose.yaml` sur votre disque. J'utilise quelques containers Docker tous les jours et je range leurs fichiers de configuration dans un dossier `~/tools` ; faisons pareil ici. Lancez `mkdir ~/tools/ollama && cd $_` puis créez un `compose.yaml` avec ce contenu : **compose.yaml** ```yaml name: tools services: ollama: image: ollama/ollama container_name: ollama ports: - "11434:11434" volumes: - ollama_data:/root/.ollama restart: always # Uncomment the following lines if you have an NVIDIA GPU and want to use it # deploy: # resources: # reservations: # devices: # - driver: nvidia # count: 1 # capabilities: [gpu] volumes: ollama_data: ``` Toujours dans le dossier `~/tools/ollama`, lancez simplement `docker compose up --detach` pour télécharger ollama et le faire tourner dans un container Docker. ## Télécharger un modèle LLM {#download-an-llm-model} D'abord, réfléchissez à votre cas d'usage. Vous voudrez très probablement de la vitesse et de la précision. Pour la vitesse, prenez un « petit » LLM comme `llama3.1:8b`. Un exemple : vous comptez utiliser une extension VSCode et vous avez besoin de rapidité pour l'autocomplétion. Pour la précision, vous accepterez peut-être d'attendre un peu plus longtemps pour obtenir de meilleurs résultats. Commencez par regarder combien de RAM libre vous avez. ```bash $ free -h total used free shared buff/cache available Mem: 15Gi 5.4Gi 2.4Gi 9.7Mi 7.8Gi 9.9Gi Swap: 4.0Gi 11Mi 4.0Gi ``` Regardez la dernière colonne **available**. J'ai environ 10 Go libres, je peux donc facilement choisir un modèle d'environ 4,7 Go, comme `llama3.1:8b`. Ouvrez un nouveau terminal et lancez cette commande : ```bash $ docker exec -it ollama ollama pull llama3.1:8b ``` > **On utilise Docker, non ?:** > > Puisque nous avons installé ollama comme container Docker, nous devons utiliser la syntaxe `docker exec -it ollama` suivie de la CLI ollama. > > C'est pour cela que nous lançons, par exemple, `docker exec -it ollama ollama pull llama3.1:8b`. Si vous relancez `free -h`, regardez à nouveau la colonne `Available`. J'avais \~9,9 Go avant, j'ai maintenant \~5,5 Go. > **Où sont stockés les LLM ?:** > > Comme nous utilisons Docker, regardez le fichier `compose.yaml`. Nous utilisons un volume Docker auto-géré `ollama_data`. > > Comme n'importe quel autre volume, rien ne viendra polluer notre disque. Si un jour nous voulons supprimer Ollama, il suffira de supprimer le container Docker et le volume associé. Et voilà. ### Besoin de plus de puissance ? {#more-power-needed} Il existe énormément de modèles ; regardez le suffixe à la fin de `llama3.1:8b` : `8b` signifie 8 milliards de paramètres. Plus il y en a, plus le modèle a de connaissances, mais cela a un prix : la taille. Si vous avez plus de 32 Go de RAM, vous pouvez sans doute aussi utiliser `gemma2:27b` (27 milliards de paramètres, environ 17 Go), `mixtral:8x7b` (environ 26 Go) ou `llama3:70b` (70 milliards de paramètres, \~45 Go). Plus le nombre de paramètres est élevé, meilleurs sont les résultats — mais plus la réponse est lente. À vous de déterminer, pour votre propre cas d'usage (votre matériel, votre GPU, vos besoins...), un ou deux LLM que vous utiliserez. Par exemple, un modèle très rapide (donc petit) pour les interactions courtes — vous ne voulez pas attendre 30 secondes une réponse — et un plus puissant pour les tâches lourdes comme le refactoring de code ou la génération d'une codebase. #### Que signifie 8x7b ? {#what-does-8x7b-mean} Si vous regardez mixtral, vous remarquerez le suffixe `8x7b`. Cela indique que le modèle utilise une architecture **Mixture of Experts** (MoE) : il est **composé de 8 réseaux de neurones experts distincts, chacun avec 7 milliards de paramètres**. Quand vous utilisez ce type de modèle, un routeur intégré analyse d'abord votre prompt pour déterminer quels experts sont les mieux placés pour répondre. Prenons un exemple : imaginez que vous demandiez à Mixtral de générer une fonction Python. Le routeur traite d'abord le contexte de votre question. Puis il active de façon transparente uniquement les experts les plus pertinents pour cette tâche (en général deux sur huit) afin de produire la meilleure réponse possible. Parce qu'il utilise ses experts de manière sélective, mixtral est un modèle incroyablement polyvalent et très efficace — à condition que votre machine ait assez de RAM pour charger l'ensemble en mémoire ! ### Obtenir la liste des modèles {#getting-the-list-of-models} Si vous voulez récupérer les modèles déjà installés : ```bash $ docker exec -it ollama ollama list ``` La liste complète des modèles existants est en ligne : ## Utiliser une interface web {#using-a-web-interface} Vous pouvez installer une interface web pour dialoguer avec Ollama ; la plus utilisée est **Open WebUI**. Éditons notre fichier `compose.yaml` et ajoutons un nouveau service : **compose.yaml** ```yaml name: tools services: ollama: image: ollama/ollama container_name: ollama ports: - "11434:11434" volumes: - ollama_data:/root/.ollama restart: always # Uncomment the following lines if you have an NVIDIA GPU and want to use it # deploy: # resources: # reservations: # devices: # - driver: nvidia # count: 1 # capabilities: [gpu] open-webui: image: ghcr.io/open-webui/open-webui:main container_name: open-webui ports: - "4000:8080" extra_hosts: - "host.docker.internal:host-gateway" environment: - OLLAMA_BASE_URL=http://ollama:11434 volumes: - open-webui_data:/app/backend/data restart: always depends_on: - ollama volumes: ollama_data: open-webui_data: ``` Lancez `docker compose up --detach` pour télécharger l'image `open-webui` (\~1,7 Go) et créer le container. Maintenant, rendez-vous simplement sur `http://localhost:`4000 et vous aurez votre interface ; vous pouvez commencer à interagir avec votre LLM local. > Screenshot — http\://localhost:4000 > > ![Demander des idées de cadeau pour ma femme](./images/open_webui.webp) Regardez en haut à gauche : vous y voyez la liste des modèles installés. ![La liste des modèles est disponible dans l'interface Open WebUI](./images/open_webui_models.webp) ## Utiliser une extension VSCode {#using-a-vscode-extension} Si vous voulez ajouter de l'IA dans VSCode, vous pouvez installer [Continue - open-source AI agent](https://marketplace.visualstudio.com/items?itemName=Continue.continue). ![Installation de l'extension Continue dans VSCode](./images/install_continue_extension.webp) > **Le statut de Continue en juillet 2026:** > > Continue a fait l'objet d'un acqui-hire par Cursor (Anysphere) en juin 2026, et le projet open source autonome a été arrêté : la v2.0.0 (19 juin 2026) est la dernière release, et le repository GitHub est désormais en lecture seule — plus aucun correctif de sécurité, adaptateur de modèle ou correction de compatibilité éditeur de la part de l'équipe d'origine. L'approche « votre propre LLM » avec Ollama, la seule utilisée dans cette section, fonctionne toujours très bien dans cette dernière release. Bon à savoir avant d'y investir du temps, mais ce n'est pas une raison pour renoncer complètement à l'autocomplétion locale. Continue a besoin d'un fichier de configuration **dans votre dossier utilisateur Windows** — pas du côté WSL, du moins lors de mes tests d'origine ; à revérifier sur les versions actuelles si cela ne correspond pas à ce que vous observez. > **config.yaml, pas config.json:** > > Les anciennes versions de Continue (antérieures à la 1.0) utilisaient `config.json`. Ce format est désormais déprécié — les versions actuelles utilisent `~/.continue/config.yaml`, avec les modèles listés sous des `roles` explicites (`chat`, `edit`, `autocomplete`). Les snippets ci-dessous utilisent ce format actuel. Ouvrez un terminal Powershell, lancez `cd ~/.continue ; notepad config.yaml` et collez le contenu de ce fichier : **.continue/config.yaml** ```yaml name: Local Config version: 0.0.1 schema: v1 models: - name: Assistant (fast) provider: ollama model: llama3.1:8b apiBase: http://127.0.0.1:11434 roles: - chat - edit ``` ![Configurer l'extension Continue](./images/configure_continue_extension.webp) Redémarrez VSCode ou, dans le panneau Continue, trouvez l'option `Local config` et sélectionnez `Refresh`. Quand vous voyez le nom de votre modèle dans la liste des modèles chargés, Continue est prêt à traiter votre première question. ![Sélection d'un modèle dans l'extension Continue](./images/continue_select_model.webp) À partir de là, vous pouvez utiliser Continue pour poser des questions sur votre codebase, générer des tests unitaires, refactorer du code, etc. La vitesse dépendra évidemment du modèle choisi et de votre matériel. ### Utiliser l'autocomplétion {#using-autocompletion} Quand vous tapez du texte dans VSCode, vous voulez une autocomplétion immédiate, pas attendre une seconde ou plus. Pour cela, il vous faut un modèle plus rapide ; prenons `qwen2.5-coder:1.5b` (1,5 milliard de paramètres, environ 1 Go). ```bash $ docker exec -it ollama ollama pull qwen2.5-coder:1.5b ``` Mettez à jour votre fichier `.continue/config.yaml` (côté Windows) avec ce nouveau contenu : **.continue/config.yaml** ```yaml name: Local Config version: 0.0.1 schema: v1 models: - name: Assistant (fast) provider: ollama model: llama3.1:8b apiBase: http://127.0.0.1:11434 roles: - chat - edit - name: Autocompletion provider: ollama model: qwen2.5-coder:1.5b apiBase: http://127.0.0.1:11434 roles: - autocomplete ``` Maintenant que j'ai ajouté le modèle `qwen2.5-coder:1.5b` dans mon fichier de config, créons un fichier `calculate.sh` avec ce contenu : **calculate.sh** ```bash #!/bin/bash function ``` ... et rien de plus. Après une seconde à peine, VSCode propose de compléter la fonction avec le bon code : ![L'autocomplétion de Continue en action](./images/continue_autocompletion.webp) J'ai accepté la suggestion et tapé `function main` ; là encore, après une seconde, le reste du code a été autocomplété. Puis j'ai tapé `function help`, accepté la proposition, et enfin accepté les dernières lignes du script. En moins de 5 secondes, j'ai donc obtenu un script complet avec le bon code et les bons commentaires. Le tout sans écrire une seule ligne de code, juste en acceptant les suggestions du LLM. **calculate.sh** ```bash #!/bin/bash function calculate() { local num1=$1 local num2=$2 local operator=$3 case $operator in "+") echo "$num1 + $num2 = $(($num1 + $num2))" ;; "-") echo "$num1 - $num2 = $(($num1 - $num2))" ;; "*") echo "$num1 * $num2 = $(($num1 * $num2))" ;; "/") if [ $num2 -ne 0 ]; then echo "$num1 / $num2 = $(($num1 / $num2))" else echo "Error: Division by zero is not allowed." fi ;; *) echo "Error: Invalid operator. Please use +, -, *, or /." ;; esac } function main() { if [ $# -ne 3 ]; then echo "Usage: $0 " echo "Example: $0 5 3 +" exit 1 fi calculate "$1" "$2" "$3" } function help() { echo "This script performs basic arithmetic operations." echo "Usage: $0 " echo "Operators supported: +, -, *, /" echo "Example: $0 5 3 +" } if [[ "$1" == "help" ]]; then help else main "$@" fi ``` Et ça marche du premier coup ! ```bash $ chmod +x ./calculate.sh $ ./calculate.sh Usage: ./calculate.sh \ \ \ Example: ./calculate.sh 5 3 + ❯ ./calculate.sh 5 3 - 5 - 3 = 2 ``` ### Utiliser un LLM plus puissant selon vos attentes {#using-more-powerful-llm-depending-on-your-expectations} > **Mis à jour le 27-07-2026:** > > Cette section recommandait initialement `gemma2:27b`, un modèle dense de 27 milliards de paramètres. Je suis depuis passé à `qwen3-coder:30b` pour ce rôle — même astuce Mixture-of-Experts que celle expliquée plus haut pour Mixtral : 30 milliards de paramètres au total, mais seulement \~3,3 milliards actifs par token, ce qui le rend nettement plus rapide qu'un modèle dense de taille comparable tout en tenant confortablement dans 24 Go de VRAM (environ 19 Go sur disque avec la quantization par défaut). La capture plus bas montre toujours le test d'origine avec `gemma2:27b` — la config et la commande pull ci-dessous reflètent la recommandation actuelle. Si vous avez un CPU et/ou un GPU puissant avec 24 Go ou plus, vous pouvez ajouter un modèle plus costaud comme `qwen3-coder:30b` : **.continue/config.yaml** ```yaml name: Local Config version: 0.0.1 schema: v1 models: - name: Assistant (fast) provider: ollama model: llama3.1:8b apiBase: http://127.0.0.1:11434 roles: - chat - edit - name: Assistant Expert (Qwen3-Coder) provider: ollama model: qwen3-coder:30b roles: - chat - edit - name: Autocompletion provider: ollama model: qwen2.5-coder:1.5b apiBase: http://127.0.0.1:11434 roles: - autocomplete ``` ```bash $ docker exec -it ollama ollama pull qwen3-coder:30b ``` Ce modèle est nettement plus précis que l'assistant rapide et — grâce à l'architecture Mixture-of-Experts — reste raisonnablement véloce malgré sa taille. Gardez-le pour les tâches d'analyse de code intensives : refactoring, génération de tests unitaires complexes, etc. Voici le test d'origine, où je demandais un refactoring à `gemma2:27b` — c'était vraiment lent, même sur ma machine (i9 - 64 Go) : ![Refactorer mon fichier courant avec Continue](./images/continue_refactor_current_file.webp) Et l'autocomplétion appliquée au `readme.md` : ![Utiliser l'autocomplétion pour le fichier readme.md](./images/readme_autocompletion.webp) **Bonus** ### CanIRun.ai {#canirunai} Le site détecte automatiquement votre configuration CPU, GPU et mémoire — strictement en local, sans transmettre les données de votre système à un serveur externe — et affiche instantanément une évaluation réaliste des modèles d'IA que votre machine peut faire tourner correctement. En visitant cette page, le système calcule quels modèles d'IA peuvent tourner sur votre matériel et à quelle vitesse. Aucune donnée n'est envoyée à un serveur. Tout est calculé côté client. ### Libérer de la RAM avec ollama stop {#freeing-up-ram-with-ollama-stop} Si vous avez besoin de récupérer immédiatement la mémoire de votre système, vous pouvez forcer le déchargement d'un modèle avec `docker exec -it ` ollama ` ollama stop `. Cette étape manuelle n'est généralement pas nécessaire : Ollama est conçu pour décharger automatiquement les modèles de votre RAM après quelques minutes d'inactivité. C'est juste une commande pratique à garder sous le coude quand vous avez besoin d'un contrôle immédiat sur vos ressources ! ### Supprimer un modèle {#remove-a-model} Lancez simplement `docker exec -it ` ollama ` ollama rm `. ### Le fichier .wslconfig {#wslconfig-file} Si vous utilisez WSL2, c'est une bonne idée de créer un fichier `.wslconfig` dans votre partition Windows afin de définir quelques réglages par défaut pour WSL, comme la quantité maximale de RAM qu'il peut utiliser. Sur ma machine, j'utilise ces réglages : **.wslconfig** ``` [wsl2] # Limits RAM to leave 12GB for Windows memory=20GB # (Optional) Adds some dedicated swap space for WSL swap=8GB # Mirror Windows network interfaces to WSL for seamless localhost access networkingMode=mirrored # Automatically shrink the WSL virtual disk when files are deleted to free up space sparseVhd=true [experimental] # Enables automatic RAM reclamation so, Windows can reclaim it autoMemoryReclaim=gradual ``` Pour créer ce fichier, ouvrez un nouveau terminal Powershell, lancez `cd ~` pour aller dans votre dossier utilisateur (côté Windows) puis `notepad .wslconfig` pour ouvrir ce fichier (et le créer si besoin). > **Tip:** > > Pensez à lancer `wsl --shutdown` dans une console Powershell si vous avez créé ou modifié le fichier `.wslconfig`. --- # Accéder à Ollama depuis votre réseau local > Apprenez à mettre en place un serveur Ollama dédié sur votre réseau local et à y connecter votre éditeur de code pour obtenir un assistant IA privé et gratuit. --- ![Accéder à Ollama depuis votre réseau local](/img/v2/using_ollama_local_network.webp) > **L'extension Continue est arrêtée:** > > L'extension VSCode [Continue](https://marketplace.visualstudio.com/items?itemName=Continue.continue) présentée dans cet article a été rachetée par Cursor le 18 juin 2026 et le produit autonome a été arrêté — la date limite d'export des données utilisateur (15 juillet 2026) est passée et le repository est désormais en lecture seule. La section sur l'intégration VSCode ci-dessous a été réécrite en conséquence. **TL;DR** Ce guide vous montre comment découpler vos charges IA lourdes en installant un serveur Ollama dédié sur votre réseau local. Vous allez apprendre à trouver l'IP de votre serveur, à vérifier la connectivité avec `curl` et à connecter une extension VSCode qui prend en charge Ollama comme backend. Résultat : un assistant IA rapide, gratuit et totalement privé, qui remplace les alternatives cloud comme GitHub Copilot. Dans un article précédent, nous avons installé Ollama, un ou plusieurs modèles IA (LLM) et une interface web appelée **Open WebUI**. Nous avons appris à jouer avec Ollama localement, sur une seule machine, mais pas encore à y accéder depuis un autre ordinateur — par exemple depuis votre réseau domestique. C'est ce que nous allons faire dans cet article. L'idée : utiliser un ordinateur comme *serveur* (celui qui fait le gros du travail) et un autre comme *client* (votre portable de tous les jours) pour y accéder. Le serveur doit avoir autant de mémoire vidéo (VRAM) et de mémoire vive (RAM) que possible pour faire tourner l'IA correctement. Le client, lui, se contente d'envoyer des requêtes web sur le réseau : un ordinateur classique, moins puissant, fait parfaitement l'affaire. *Si les containers de ce serveur n'arrivent pas à se joindre une fois tout en place, [Troubleshooting for Docker containers - Accessing the other one](/blog/docker-networking-troubleshooting) déroule le diagnostic étape par étape — un proxy est souvent le coupable.* Dans cet article, nous allons mettre en place cette architecture. Référez-vous à mon article précédent ([Installing Ollama and get local AI](/blog/ollama-installation)) pour la configuration du **serveur IA local**. ![Notre IA locale](./images/diagram.webp) ## Utiliser un réseau local {#using-a-local-network} Vous avez très probablement déjà un réseau local (LAN) à la maison — généralement géré par votre routeur Wi-Fi. Vous pouvez l'utiliser pour accéder à Ollama en toute sécurité depuis chez vous, sans envoyer de données sur Internet. Personnellement, j'utilise un simple switch réseau (un **D-Link DGS-108**) pour relier mes machines en câble et obtenir la meilleure vitesse, mais une bonne connexion Wi-Fi fonctionne aussi ! Cette configuration offre un débit élevé (1000 Mbps) avec quasiment aucun délai (latence), ce qui est parfait pour notre cas d'usage. ## Trouver l'adresse IP de votre serveur {#finding-your-servers-ip-address} Pour vous connecter à votre serveur, il vous faut son adresse IP — voyez ça comme le numéro de téléphone interne de la machine sur votre réseau. Mon serveur tourne sous Windows 11 : je la trouve en ouvrant une fenêtre Powershell et en lançant la commande `ipconfig | Select-String -Pattern "IPv4"`. Sur mon serveur, la sortie ressemble à ceci : `IPv4 Address. . . . . . . . . . . : 192.168.0.218`. Notez ce numéro ! ## Configurer l'ordinateur client {#setting-up-the-client-computer} Sur votre deuxième ordinateur (le client), vérifions d'abord qu'il peut « parler » à votre serveur. On utilise pour cela une commande réseau appelée `ping`. Ouvrez votre terminal et tapez `ping 192.168.0.218` (remplacez par l'IP de votre serveur) : ```bash $ Pinging 192.168.0.218 with 32 bytes of data: Reply from 192.168.0.218: bytes=32 time<1ms TTL=128 Reply from 192.168.0.218: bytes=32 time<1ms TTL=128 Reply from 192.168.0.218: bytes=32 time=1ms TTL=128 Reply from 192.168.0.218: bytes=32 time=1ms TTL=128 Ping statistics for 192.168.0.218: Packets: Sent = 4, Received = 4, Lost = 0 (0% loss), Approximate round trip times in milliseconds: Minimum = 0ms, Maximum = 1ms, Average = 0ms ``` Cette sortie signifie que notre deuxième ordinateur accède au serveur avec une latence quasi nulle (`time < 1ms`). ### Lancer Open WebUI {#running-open-webui} Vérifiez d'abord qu'Ollama et **Open WebUI** tournent toujours sur la machine serveur. Si c'est le cas, vous devriez pouvoir ouvrir un navigateur sur votre ordinateur client et aller sur `http://192.168.0.218:4000`. Comme nous avons déjà validé la connexion réseau, vous devriez voir l'écran de connexion d'Open WebUI. Une fois connecté, vous accéderez à l'interface Ollama, verrez vos modèles IA disponibles et pourrez démarrer une conversation ! ### Vérifier la connexion une seconde fois {#double-checking-the-connection} Juste avant de passer à notre éditeur de code (VSCode) pour configurer notre assistant IA, assurons-nous que le moteur IA écoute bien. Pour obtenir la liste des modèles IA installés sur votre serveur, lancez simplement cette commande : `curl http://192.168.0.218:11434/api/tags` (ajoutez `| jq` à la fin si vous l'avez installé, pour un affichage plus lisible). Et si vous voulez tester l'IA elle-même — par exemple lui demander ce qu'est un `Dockerfile` — lancez cette commande dans la console de votre ordinateur client : ```bash $ curl -X POST http://192.168.0.218:11434/api/generate \ -H "Content-Type: application/json" \ -d '{ "model": "qwen2.5-coder:1.5b-base", "prompt": "What is a Dockerfile? Please explain it like I''m five.", "stream": false }' ``` > **Vérifiez le nom de votre modèle:** > > Assurez-vous que le modèle LLM `qwen2.5-coder:1.5b-base` est bien présent ; sinon, utilisez-en un autre issu de votre propre liste. ## Configurer votre éditeur de code (VSCode) {#configure-your-code-editor-vscode} L'article d'origine utilisait **Continue**, qui a depuis été arrêté (voir l'encadré en haut de cette page). Bonne nouvelle : le principe reste le même quelle que soit l'extension utilisée. Toute extension VSCode qui prend en charge Ollama comme backend vous offre les deux mêmes fonctionnalités : 1. **Autocomplétion en ligne** : des suggestions de code en temps réel pendant que vous tapez, basées sur l'inférence **FIM** (Fill-In-the-Middle). 2. **Interface de chat** : discutez directement avec votre assistant IA pour trouver des bugs, réécrire du code ou poser des questions. Au moment où j'écris ces lignes, les alternatives open source les plus actives qui supportent nativement un endpoint Ollama sont **Cline** et **RooCode** (un fork de Cline activement maintenu). Les deux sont disponibles sur le Marketplace VSCode. Quelle que soit l'extension choisie, le principe de configuration est toujours identique : pointez l'extension vers l'adresse de votre serveur Ollama (`http://192.168.0.218:11434`, en remplaçant l'IP par la vôtre), sélectionnez `Ollama` comme provider, puis choisissez un modèle dans votre liste installée (`ollama list` sur le serveur pour voir ce qui est disponible). > **Utilisateurs WSL : installez l'extension côté Linux:** > > Si vous utilisez WSL2, veillez à installer l'extension côté **Linux** et non côté Windows. Ouvrez VSCode connecté à votre session WSL et installez l'extension depuis le terminal avec `code --install-extension --force`. ## Conclusion {#conclusion} En suivant ce guide, nous avons découplé nos charges IA lourdes de notre environnement de développement quotidien. Mettre en place un « serveur IA » dédié sur votre réseau local vous permet d'exploiter des LLM puissants sans vider la batterie de votre machine principale ni monopoliser sa RAM et son CPU. En connectant à ce serveur une extension VSCode compatible Ollama, vous obtenez une alternative privée, auto-hébergée et entièrement gratuite aux assistants IA cloud comme GitHub Copilot. Comme tout passe par votre LAN, votre code et vos prompts ne quittent jamais votre réseau local : confidentialité totale et zéro abonnement. Que vous génériez du code, receviez des suggestions d'autocomplétion ou posiez des questions sur votre codebase, votre assistant IA local n'est plus qu'à une requête réseau de distance ! --- # ai-test : générer les tests unitaires manquants depuis votre terminal avec un LLM local > Transformez votre modèle Ollama local en générateur de tests unitaires à la demande. Une fonction zsh qui lit un fichier Bash, PHP ou Python, détecte si des tests existent déjà, affiche uniquement les cas Bats/Pest/Pytest manquants pour une couverture complète, propose de les enregistrer là où le framework les attend, et lance la suite dans un container Docker jetable. --- ![ai-test : générer les tests unitaires manquants depuis votre terminal avec un LLM local](/img/v2/ai_test.webp) **TL;DR** `ai-test` transforme votre modèle Ollama local en générateur de tests unitaires à la demande. Pointez-le sur un fichier Bash, PHP ou Python, relisez les tests générés, enregistrez-les si vous voulez, puis lancez-les immédiatement dans Docker — sans jamais quitter votre terminal. Il gère Bats (Bash), Pest (PHP) et Pytest (Python). Cet article ouvre la série **[Ollama daily use](/series/ollama-daily-use)** : de petits outils qui font d'un LLM local un véritable compagnon de terminal, et non un onglet de navigateur de plus. Arrêtons de faire semblant : on sait tous que les tests unitaires sont nécessaires, et presque personne n'aime les écrire. Il y a toujours une excuse — un délai serré, un script Bash qui ne devait être que temporaire, l'ennui profond du mocking des dépendances. On se promet de les ajouter plus tard, et *plus tard*, c'est le moment où la dette technique explose en production. `ai-test` fait le sale boulot à votre place. Pointez-le sur un fichier, récupérez une suite Bats, Pest ou Pytest, lisez-la, enregistrez-la, exécutez-la — tout depuis le terminal, tout sur votre machine. **Quick Jump:** [Ce que ai-test fait pour vous](#what-ai-test-does-for-you) | [Installation](#installing-it) ## Ce que `ai-test` fait pour vous {#what-ai-test-does-for-you} Prenons `greet.sh`, un petit script qui demande un prénom et une langue. Une commande, et une suite Bats complète revient — réduite à trois tests ci-dessous, l'exécution réelle en a produit plus de quinze : ```bash $ ai-test greet.sh → No test file found for 'greet' — generating a full Bats suite. setup() { source "$BATS_TEST_DIRNAME/../greet.sh" } @test "happy path: greets in English by default" { run greet "Christophe" [ "$status" -eq 0 ] [ "$output" = "Hello Christophe!" ] } @test "happy path: greets in French" { run greet "Christophe" "fr" [ "$status" -eq 0 ] [ "$output" = "Bonjour Christophe !" ] } @test "happy path: greets in Dutch" { run greet "Christophe" "nl" [ "$status" -eq 0 ] [ "$output" = "Hallo Christophe!" ] } → Save this suite as tests/greet.bats? [y/N] y → Written to tests/greet.bats → Run the suite now, in Docker? [y/N] y 1..3 ok 1 happy path: greets in English by default ok 2 happy path: greets in French ok 3 happy path: greets in Dutch ``` Voilà tout l'argument : une commande, une suite lisible en une minute, deux confirmations, et une exécution verte qui prouve qu'elle n'est pas décorative. Rien n'a été installé pour lancer ces tests, et rien n'a quitté la machine. ## Pourquoi ça marche {#why-it-works} Quatre idées, et aucune ne vous demande encore de lire une ligne de zsh : * **L'extension décide de tout** — le framework, la convention de nommage, et l'endroit où le fichier atterrit. * **Le prompt transporte la structure de votre projet.** Le modèle ne voit jamais un chemin, seulement un bloc de texte ; `ai-test` calcule d'abord la destination, puis dicte comment la suite doit charger le code. * **Il lit les tests que vous avez déjà** et ne demande que les manques, ajoutés au fichier existant. * **Il valide dans un container jetable**, pour qu'une suite qui *a l'air* correcte doive prouver qu'elle *passe*. ## Installation {#installing-it} Deux fichiers à déposer dans `~/.zsh/fns/`, un nouveau shell. D'abord la seule vraie dépendance — `jq`, utilisé pour construire la charge JSON envoyée à Ollama : Prerequisite: jq ```bash $ sudo apt update && sudo apt install jq -y Reading package lists... Done Building dependency tree... Done 0 upgraded, 1 newly installed, 0 to remove and 0 not upgraded. ``` Verify: ```bash $ jq --version jq-1.7 ``` Il vous faut aussi un Ollama qui tourne quelque part d'accessible — j'ai déjà parlé de [son installation](/blog/ollama-installation) et de [son exposition sur mon réseau](/blog/accessing-ollama-across-your-local-network) ; le mien reste inactif la majeure partie de la journée, alors pourquoi ne pas le braquer sur la seule tâche que je n'arrête pas de reporter ? **Deux outils optionnels : bat et docker (cliquez si vous voulez les détails)** **`bat`** — si [il](https://github.com/sharkdp/bat) est installé, le code généré est coloré syntaxiquement avant d'arriver dans votre terminal. Sinon, `ai-test` retombe sur une sortie brute. Attention à une bizarrerie Debian/Ubuntu : le nom `bat` était déjà pris par `bacula-console-qt`, donc `apt install bat` installe le binaire sous le nom **`batcat`**. C'est pour ça que le helper `_ai_bat` teste les deux orthographes au lieu de coder `bat` en dur. Prerequisite: bat ```bash $ sudo apt install bat ``` Verify: ```bash $ batcat --version bat 0.24.0 ``` **`docker`** — nécessaire uniquement pour l'étape finale « lancer la suite maintenant ». `ai-test` saute cette étape avec un message quand `docker` n'est pas dans votre `PATH`. Prerequisite: docker ```bash $ sudo apt install docker ``` Verify: ```bash $ docker --version Docker version 29.6.2, build dfc4efb ``` Maintenant les deux fichiers. `_ollama.zsh` contient tout ce que la série partage — le helper de requête, le registre de commandes et le point d'entrée `ai` — tandis que `ai-test.zsh` est la fonction elle-même : **Project: \~/.zsh/fns** **\_ollama.zsh** ````bash # Foundation file for every "ai-*" function in this series. Named with a # leading underscore: the ~/.zsh/fns/*.zsh loader sources files in # alphabetical order, and "_" sorts before any letter — so this file is # always loaded first, no matter how many ai-* functions get added later. # # Provides: # - _ollama_check reachability guard for Ollama # - _ollama_query shared HTTP client (curl + jq) # - _ai_strip_fences removes the Markdown fences models add anyway # - _ai_bat resolves "bat" vs Debian's "batcat" # - _ai_confirm yes/no prompt # - _git_staged_diff validated staged-diff fetcher (shared by pre-commit functions) # - AI_COMMANDS registry each ai-* function adds itself to # - AI_PARAMS registry of interactive parameter types (file/language/number/text/none) # - _ai_prompt_* FZF/read helpers for interactive argument collection # - ai entry-point dispatcher / interactive menu typeset -gA AI_COMMANDS typeset -gA AI_PARAMS # --------------------------------------------------------------------------- # Ollama helpers # --------------------------------------------------------------------------- # _ollama_check — verify that the Ollama daemon is reachable. # Fails loudly so callers can bail early rather than watching curl hang. _ollama_check() { local host="${OLLAMA_HOST:-http://localhost:11434}" if ! curl --silent --max-time 2 "${host}/api/tags" >/dev/null 2>&1; then echo "ollama: cannot reach ${host} — is the Ollama container running?" >&2 return 1 fi } # _ollama_query — send a prompt to the local model and print the response. # Assumes Ollama is reachable; call _ollama_check first if you need an early guard. _ollama_query() { local prompt="$1" local model="${OLLAMA_MODEL:-qwen3-coder:30b}" local host="${OLLAMA_HOST:-http://localhost:11434}" _ollama_check || return 1 # The prompt is piped into jq rather than passed with "--arg": Linux caps a # single argv entry at 128 KB (MAX_ARG_STRLEN), and a large staged diff or a # long source file sails straight through that ceiling — jq then dies with # "argument list too long". "-R -s" slurps stdin as one raw string, which jq # escapes exactly like --arg would. print -r -- "$prompt" \ | jq -Rs --arg model "$model" '{model: $model, prompt: ., stream: false}' \ | curl --silent "${host}/api/generate" --data-binary @- \ | jq -r '.response // empty' } # --------------------------------------------------------------------------- # Output helpers # --------------------------------------------------------------------------- # _ai_strip_fences — return only the code, without the Markdown fences. # # Every prompt in this series ends with "no markdown fences" and models still # wrap their answer in ```language … ``` often enough that the output can't be # piped or pasted as-is. When at least one fence is present, only the fenced # regions are kept (which also drops the "Here is your test suite:" preamble); # when there is none, the text is returned untouched. _ai_strip_fences() { local text="$1" if [[ "$text" != *'```'* ]]; then print -r -- "$text" return 0 fi print -r -- "$text" | awk ' /^[[:space:]]*```/ { inside = !inside; next } inside { print } ' } # _ai_bat — print the name of the "bat" binary installed on this machine, if any. # Debian and Ubuntu ship the package as "batcat" because the "bat" name was # already taken by bacula-console-qt, so both spellings have to be probed. _ai_bat() { local candidate for candidate in bat batcat; do if command -v "$candidate" >/dev/null 2>&1; then print -- "$candidate" return 0 fi done return 1 } # _ai_relpath — print relative to the current folder when it sits # below it. Purely cosmetic: it keeps prompts readable instead of asking # "Save as /home/christophe/projects/…/tests/backup.bats?". _ai_relpath() { local path="${1:A}" print -- "${path#${PWD}/}" } # _ai_confirm — ask a yes/no question; return 0 only on an explicit yes. _ai_confirm() { local answer print -n "${1} [y/N] " read -r answer [[ "$answer" == [yY]* ]] } # --------------------------------------------------------------------------- # Git helpers # --------------------------------------------------------------------------- # _git_staged_diff — validate the git context and return the staged diff. # Handles three early-exit cases (not a repo, nothing staged, oversized diff) # so every pre-commit function gets them for free without repeating the guards. # # Past AI_DIFF_MAX_CHARS the full diff is *not* returned: a diff of several # hundred kilobytes overflows the model's context window, and the answer that # comes back is worse than the one you get from a summary. The fallback keeps # the shape of the change — per-file stat, plus the file and hunk headers, # which carry the enclosing function names git puts after each "@@" — and # drops the line-by-line content. _git_staged_diff() { local caller="${1:-ai}" local max="${AI_DIFF_MAX_CHARS:-12000}" if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then echo "${caller}: not inside a git repository" >&2 return 1 fi local diff diff=$(git diff --staged) if [[ -z "$diff" ]]; then echo "${caller}: nothing staged — run 'git add' first" >&2 return 1 fi # "print -r" everywhere below: without it, print expands escape sequences and # a literal \n or \t inside the diff would come back mangled. if (( ${#diff} <= max )); then print -r -- "$diff" return 0 fi echo "${caller}: staged diff is large (${#diff} chars) — sending a structural summary instead of the full diff. Commit in smaller chunks for a more precise answer." >&2 local summary summary="$(git diff --staged --stat) --- FILE AND HUNK HEADERS ONLY (full diff omitted, ${#diff} chars) --- $(print -r -- "$diff" | grep -E '^(diff --git|new file|deleted file|rename (from|to)|@@)')" # Even the summary can exceed the ceiling on a very wide change. if (( ${#summary} > max )); then summary="${summary[1,$max]} […summary truncated…]" fi print -r -- "$summary" } # --------------------------------------------------------------------------- # Interactive argument helpers (used by the ai() dispatcher) # --------------------------------------------------------------------------- # _ai_prompt_file [label] — open an fzf file picker; print the chosen path. _ai_prompt_file() { local label="${1:-Select a file:}" local bat_bin preview='cat {}' bat_bin=$(_ai_bat) && preview="${bat_bin} --color=always {} 2>/dev/null || cat {}" if command -v fd >/dev/null 2>&1; then fd --type f | fzf --prompt="${label} " --height=50% --reverse --preview="$preview" else find . -type f | fzf --prompt="${label} " --height=50% --reverse --preview="$preview" fi } # _ai_prompt_language — open an fzf language picker; print the chosen language. _ai_prompt_language() { printf '%s\n' English French Dutch German Spanish Italian Portuguese Japanese Chinese Korean \ | fzf --prompt='Target language: ' --height=40% --reverse } # _ai_prompt_number [label] — read a positive integer; print it (or nothing on Enter, which # lets the caller fall back to its own default). Returns 1 only on invalid input. _ai_prompt_number() { local label="${1:-Number (Enter for default):}" print -n "${label} " local n read n if [[ -z "$n" ]]; then return 0 fi if [[ ! "$n" =~ ^[1-9][0-9]*$ ]]; then echo "Expected a positive integer." >&2 return 1 fi print -- "$n" } # _ai_prompt_text [label] — read a free-form string; print it. Returns 1 if empty. _ai_prompt_text() { local label="${1:-Input:}" print -n "${label} " local t read t if [[ -z "$t" ]]; then echo "No input given." >&2 return 1 fi print -- "$t" } # --------------------------------------------------------------------------- # Entry-point dispatcher # --------------------------------------------------------------------------- # ai → interactive fzf menu of every registered ai-* command # ai [args] → dispatch straight to ai- [args] # # When opened via the fzf menu, each command's declared AI_PARAMS type triggers # a secondary picker before the command runs: # file → fzf file picker (fd + preview if available) # language → fzf language list # number → read prompt, empty input uses the command's own default # text → read prompt, required # none → run the command directly (it collects its own input) # Space-separated values declare multiple parameters in order, e.g. "file language". ai() { local cmd="$1" # Direct dispatch: ai [args] if [[ -n "$cmd" && "$cmd" != "help" && "$cmd" != "list" ]]; then if (( ${+AI_COMMANDS[$cmd]} )); then shift "ai-${cmd}" "$@" return fi echo "ai: unknown command '${cmd}'" >&2 echo >&2 fi # Interactive menu if ! command -v fzf >/dev/null 2>&1; then echo "Available ai commands:" for key in ${(ok)AI_COMMANDS}; do printf " ai %-10s %s\n" "$key" "$AI_COMMANDS[$key]" done return fi local picked picked=$( for key in ${(ok)AI_COMMANDS}; do printf '%s\t%s\n' "$key" "$AI_COMMANDS[$key]" done \ | fzf --delimiter='\t' --with-nth=2 --prompt='ai > ' --height=40% --reverse \ | cut -f1 ) [[ -z "$picked" ]] && return # Collect arguments interactively based on AI_PARAMS declaration local -a args=() local param_type for param_type in ${(s: :)${AI_PARAMS[$picked]:-none}}; do case "$param_type" in file) local f f=$(_ai_prompt_file "File for ai-${picked}:") || return 1 args+=("$f") ;; language) local l l=$(_ai_prompt_language) || return 1 args+=("$l") ;; number) local n n=$(_ai_prompt_number) || return 1 [[ -n "$n" ]] && args+=("$n") ;; text) local t t=$(_ai_prompt_text) || return 1 args+=("$t") ;; none) ;; esac done "ai-${picked}" "${args[@]}" } ```` **ai-test.zsh** ```bash # ai-test — generate (or complete) a unit test suite for a script using # a local LLM. Detects Bash/PHP/Python from the extension, looks for an # existing test file using each ecosystem's naming convention, and asks the # model to either write a full suite or fill the coverage gaps only. # # The result is printed first, then offered for saving at the path its # ecosystem expects, then optionally run inside a throw-away Docker container # so no test runner has to be installed on the host. AI_COMMANDS[test]="ai-test — generate or complete a unit test suite" AI_PARAMS[test]="file" # Images used to run the generated suites. Overridable from ~/.zshrc. : ${AI_TEST_BATS_IMAGE:=bats/bats:latest} : ${AI_TEST_PHP_IMAGE:=php:8.4-cli} : ${AI_TEST_PY_IMAGE:=python:3.14-slim} # _ai_project_root — walk up from until a folder # containing one of the markers is found; print it, or when none matches. # This is what keeps "src/Backup.php" from proposing "src/tests/…". _ai_project_root() { local dir="${1:A}" shift local candidate="$dir" marker while [[ "$candidate" != "/" ]]; do for marker in "$@"; do if [[ -e "${candidate}/${marker}" ]]; then print -- "$candidate" return 0 fi done candidate="${candidate:h}" done print -- "$dir" } # _ai_test_root — print the folder the "tests/" tree hangs from, # using the marker file each ecosystem puts at the root of a project. _ai_test_root() { local ext="$1" dir="$2" case "$ext" in sh|bash) _ai_project_root "$dir" .git ;; php) _ai_project_root "$dir" composer.json .git ;; py) _ai_project_root "$dir" pyproject.toml setup.py .git ;; esac } # _ai_test_target — print the conventional path where the # generated suite belongs, following each ecosystem's own layout: # Bats → tests/.bats # Pest → tests/Unit/Test.php (PSR-4 style, next to composer.json) # Pytest → tests/test_.py _ai_test_target() { local ext="$1" root="$2" base="$3" case "$ext" in sh|bash) print -- "${root}/tests/${base}.bats" ;; php) print -- "${root}/tests/Unit/${base}Test.php" ;; py) print -- "${root}/tests/test_${base}.py" ;; esac } # _ai_relative_to — print as seen from . # Used to tell the model how the test file will reach the code under test. _ai_relative_to() { local from="${1:A}" to="${2:A}" rel if rel=$(realpath --relative-to="$from" "$to" 2>/dev/null); then print -- "$rel" return 0 fi # Portable fallback: climb until "from" is a prefix of "to", counting the # hops as "../" segments. local common="$from" up="" while [[ "$to" != "${common}/"* && "$common" != "/" ]]; do common="${common:h}" up="../${up}" done print -- "${up}${to#${common}/}" } # _ai_test_bootstrap — print the paragraph that tells the # model how the suite is going to load the code it is testing. # # This is the single most important part of the prompt. Without it the model # writes a technically perfect suite that calls functions nobody ever sourced, # and every test dies with "command not found" (exit code 127). The model can't # guess it: it only ever sees the file *contents*, never the layout. Now that # ai-test knows the exact path the suite will be saved at, it can dictate the # bootstrap line instead of hoping. _ai_test_bootstrap() { local ext="$1" file="$2" target="$3" local rel rel=$(_ai_relative_to "${target:h}" "$file") case "$ext" in sh|bash) cat </dev/null \ | sed -E 's/^[[:space:]]*namespace[[:space:]]+//; s/[[:space:]]*;.*$//') if [[ -n "$namespace" ]]; then cat < — warn about a suite that is already # broken before we add anything to it. # # The classic one: a Bats file calling assert_success / assert_output without # ever loading bats-assert. Those helpers are an optional library, so every # such test dies with "command not found" — and since gap-fill mode asks the # model to match the existing style, the new cases inherit the breakage. _ai_test_lint_existing() { local ext="$1" file="$2" [[ "$ext" == sh || "$ext" == bash ]] || return 0 if grep -qE '(^|[^[:alnum:]_])(assert|refute)_[a-z]' "$file" 2>/dev/null \ && ! grep -qE 'load[[:space:]].*bats-(assert|support)' "$file" 2>/dev/null; then echo "→ Warning: $(_ai_relpath "$file") calls bats-assert helpers but never loads them." >&2 echo " Those tests already fail with 'command not found'; add to setup():" >&2 echo " load '/usr/lib/bats/bats-support/load'" >&2 echo " load '/usr/lib/bats/bats-assert/load'" >&2 fi } # _ai_test_run — offer to run the suite in a # disposable container. Nothing is installed on the host and the container is # removed on exit; only the project folder is mounted. _ai_test_run() { local ext="$1" root="$2" rel="$3" if ! command -v docker >/dev/null 2>&1; then echo "→ Docker not available — skipping the test run." >&2 return 0 fi _ai_confirm "→ Run the suite now, in Docker?" || return 0 case "$ext" in sh|bash) # The working directory is a scratch folder *inside* the container, not # the mounted project. Any test that calls the code under test with a # default "current directory" destination then writes its artifacts to a # path that disappears with --rm, instead of littering the repository # with half-cleaned-up files. Tests that need real files on disk use # $BATS_TEST_DIRNAME, which still points into /code. docker run --rm --user "$(id -u):$(id -g)" \ --volume "${root}:/code" --workdir /tmp \ "$AI_TEST_BATS_IMAGE" "/code/${rel}" ;; php) if [[ ! -x "${root}/vendor/bin/pest" ]]; then echo "ai-test: ${root}/vendor/bin/pest not found — run 'composer install' first." >&2 return 1 fi docker run --rm --user "$(id -u):$(id -g)" \ --volume "${root}:/code" --workdir /code \ "$AI_TEST_PHP_IMAGE" vendor/bin/pest "$rel" ;; py) # Runs as root so "pip install" can reach the system site-packages; the # two cache flags are what stop it from leaving root-owned folders behind. # PYTHONPATH=/code is what makes a plain "import backup" work from # tests/test_backup.py, so the suite needs no sys.path gymnastics. docker run --rm --env PYTHONDONTWRITEBYTECODE=1 --env PYTHONPATH=/code \ --volume "${root}:/code" --workdir /code \ "$AI_TEST_PY_IMAGE" \ sh -c "pip install --quiet --root-user-action=ignore pytest && pytest -p no:cacheprovider '${rel}'" ;; esac } ai-test() { local file="$1" if [[ -z "$file" || ! -f "$file" ]]; then echo "Usage: ai-test " >&2 return 1 fi local ext="${file:e}" local lang framework bat_lang case "$ext" in sh|bash) lang="Bash"; framework="Bats"; bat_lang="bash" ;; php) lang="PHP"; framework="Pest"; bat_lang="php" ;; py) lang="Python"; framework="Pytest"; bat_lang="python" ;; *) echo "ai-test: unsupported extension .$ext (expected .sh, .php or .py)" >&2 return 1 ;; esac local dir="${file:h}" local base="${file:t:r}" local root target root=$(_ai_test_root "$ext" "$dir") target=$(_ai_test_target "$ext" "$root" "$base") local existing="" case "$ext" in sh|bash) existing=$(find "$dir" "$dir/tests" "$root/tests" -maxdepth 2 \ -iname "${base}.bats" 2>/dev/null | head -1) ;; php) existing=$(find "$dir" "$dir/tests" "$root/tests" -maxdepth 3 \ -iregex ".*/${base}Test\.php" 2>/dev/null | head -1) ;; py) existing=$(find "$dir" "$dir/tests" "$root/tests" -maxdepth 2 \ \( -iname "test_${base}.py" -o -iname "${base}_test.py" \) 2>/dev/null | head -1) ;; esac local source_code source_code=$(<"$file") local prompt if [[ -n "$existing" ]]; then echo "→ Found existing tests: $(_ai_relpath "$existing") — asking only for the coverage gaps." >&2 _ai_test_lint_existing "$ext" "$existing" local existing_tests existing_tests=$(<"$existing") prompt="You are a senior $lang test engineer. Below is a source file and its CURRENT $framework test suite. Compare them, identify every function, branch and edge case in the source that is NOT exercised by the current tests, and output ONLY the additional $framework test cases needed to reach 100% coverage. Do not repeat any existing test. Output raw $framework code only — no explanations, no markdown fences. The new cases are appended to the existing file, so they inherit its setup/fixtures: do not repeat them. Use only constructs and helpers that already appear in the current suite, and do not hard-code any value derived from today's date. --- SOURCE: $file --- $source_code --- EXISTING TESTS: $existing --- $existing_tests" else echo "→ No test file found for '$base' — generating a full $framework suite." >&2 local bootstrap bootstrap=$(_ai_test_bootstrap "$ext" "$file" "$target") prompt="You are a senior $lang test engineer. Write a complete $framework test suite for the file below: happy path, edge cases, and error conditions. Output raw $framework code only — no explanations, no markdown fences. ${bootstrap} --- SOURCE: $file --- $source_code" fi local result result=$(_ollama_query "$prompt") || return 1 # The prompt says "no markdown fences"; models add them anyway often enough # that stripping is the only reliable way to get paste-ready code. result=$(_ai_strip_fences "$result") if [[ -z "$result" ]]; then echo "ai-test: the model returned an empty response." >&2 return 1 fi local bat_bin if bat_bin=$(_ai_bat); then print -r -- "$result" \ | "$bat_bin" --language="$bat_lang" --style=plain --paging=never --color=always else print -r -- "$result" fi # --- Save -------------------------------------------------------------- echo local shown if [[ -n "$existing" ]]; then shown=$(_ai_relpath "$existing") _ai_confirm "→ Append these cases to ${shown}?" || { echo "→ Not saved."; return 0; } print -r -- "" >> "$existing" print -r -- "$result" >> "$existing" echo "→ Appended to ${shown}" target="$existing" else shown=$(_ai_relpath "$target") if [[ -e "$target" ]]; then _ai_confirm "→ ${shown} already exists. Overwrite it?" || { echo "→ Not saved."; return 0; } else _ai_confirm "→ Save this suite as ${shown}?" || { echo "→ Not saved."; return 0; } fi mkdir -p "${target:h}" print -r -- "$result" > "$target" echo "→ Written to ${shown}" fi # --- Run --------------------------------------------------------------- local abs_target="${target:A}" _ai_test_run "$ext" "$root" "${abs_target#${root}/}" } ``` Si vous avez déjà mis en place l'autoloader `~/.zsh/fns/` de [mon article sur ripgrep](/blog/ripgrep), c'est terminé : ouvrez un nouveau shell. Sinon, voici ce qu'il faut ajouter à votre `~/.zshrc` existant : ```bash title="~/.zshrc" export OLLAMA_MODEL="qwen3-coder:30b" # optional, this is already the default export AI_TEST_PHP_IMAGE="php:8.4-cli" # optional, override to match your project's PHP export AI_TEST_PY_IMAGE="python:3.14-slim" # optional, same idea for Python for fn_file in ~/.zsh/fns/*.zsh; do source "$fn_file" done ``` Les exports sont placés *au-dessus* de la boucle volontairement : les valeurs par défaut `AI_TEST_*` sont appliquées au moment du sourcing, donc tout ce que vous définiriez ensuite arriverait trop tard. ### Un seul point d'entrée : `ai` {#one-entry-point-ai} Ouvrez un nouveau shell et tapez `ai`, sans argument. Plutôt que de devoir vous rappeler comment vous avez nommé vos fonctions il y a six mois, vous obtenez un sélecteur `fzf` listant chaque commande qui s'est enregistrée — aujourd'hui juste `test`, demain tout ce que la série ajoutera : ![ai-test](./images/ai-test.gif) Choisissez-en une et elle s'exécute. Si vous savez déjà ce que vous voulez, `ai test greet.sh` envoie directement vers `ai-test` et saute complètement le menu — les deux formes sont équivalentes, l'une est explorable, l'autre est rapide. > **À lire avant de faire confiance:** > > Les tests générés sont un brouillon, pas une garantie — lisez-les comme une pull request venant d'un contributeur junior. Lancer la suite prouve qu'elle *s'exécute* ; seule la lecture prouve qu'elle *vérifie* quelque chose. ## Plus de démos {#more-demos} ### Un vrai script, et les manques qu'il comble {#a-real-script-and-the-gaps-it-fills} `greet.sh` était un échauffement. `backup.sh` est d'un autre calibre — une commande externe, un timestamp, et de vrais effets de bord sur le système de fichiers : **backup.sh** ```bash #!/usr/bin/env bash # backup.sh — tar+gzip a directory into a timestamped archive backup_dir() { local src="$1" local dest_dir="${2:-.}" if [[ -z "$src" ]]; then echo "Usage: backup_dir [dest_dir]" >&2 return 1 fi if [[ ! -d "$src" ]]; then echo "Error: '$src' is not a directory" >&2 return 1 fi local timestamp timestamp=$(date +%Y%m%d-%H%M%S) local archive_name archive_name="$(basename "$src")-${timestamp}.tar.gz" mkdir -p "$dest_dir" if tar -czf "${dest_dir}/${archive_name}" -C "$(dirname "$src")" "$(basename "$src")" 2>/dev/null; then echo "${dest_dir}/${archive_name}" return 0 else echo "Error: tar failed for '$src'" >&2 return 1 fi } ``` `ai-test backup.sh` a renvoyé dix tests, tous verts. Voici les trois premiers : ```bash ❯ ai-test backup.sh → No test file found for 'backup' — generating a full Bats suite. setup() { source "$BATS_TEST_DIRNAME/../backup.sh" } @test "happy path: backup a directory with default destination" { local test_dir="$BATS_TEST_DIRNAME/test_dir" local archive_name mkdir -p "$test_dir" echo "test content" > "$test_dir/file1.txt" mkdir -p "$test_dir/subdir" echo "sub content" > "$test_dir/subdir/file2.txt" archive_name=$(backup_dir "$test_dir") [ -f "$archive_name" ] [ -n "$archive_name" ] [[ "$archive_name" == *.tar.gz ]] } @test "happy path: backup a directory with custom destination" { local test_dir="$BATS_TEST_DIRNAME/test_dir" local dest_dir="$BATS_TEST_DIRNAME/dest" local archive_name mkdir -p "$test_dir" echo "test content" > "$test_dir/file1.txt" archive_name=$(backup_dir "$test_dir" "$dest_dir") [ -f "$archive_name" ] [ -n "$archive_name" ] [[ "$archive_name" == "$dest_dir"/* ]] [[ "$archive_name" == *.tar.gz ]] } @test "edge case: backup directory with special characters in name" { local test_dir="$BATS_TEST_DIRNAME/test dir with spaces" local archive_name mkdir -p "$test_dir" echo "test content" > "$test_dir/file.txt" archive_name=$(backup_dir "$test_dir") [ -f "$archive_name" ] [ -n "$archive_name" ] } → Save this suite as tests/backup.bats? [y/N] y → Written to tests/backup.bats → Run the suite now, in Docker? [y/N] y 1..3 ok 1 happy path: backup a directory with default destination ok 2 happy path: backup a directory with custom destination ok 3 edge case: backup directory with special characters in name ``` Maintenant, relancez exactement la même commande une seconde fois : ```bash $ ai-test backup.sh → Found existing tests: tests/backup.bats — asking only for the coverage gaps. @test "error condition: tar fails when source directory is empty" { local test_dir="$BATS_TEST_DIRNAME/empty_dir" local archive_name mkdir -p "$test_dir" archive_name=$(backup_dir "$test_dir" 2>&1) [ -n "$archive_name" ] [[ "$archive_name" == *"Error: tar failed"* ]] } ``` `ai-test` a trouvé le `tests/backup.bats` qu'il avait écrit une minute plus tôt, l'a envoyé au modèle avec la source, et n'a demandé que les cas manquants. Au lieu de relire dix tests auxquels je fais déjà confiance, je relis un seul ajout, ciblé — et il est **ajouté** au fichier existant, pas écrit dans un second qui dupliquerait le `setup()`. C'est le mode que vous utiliserez le plus : chaque fois que vous ajoutez une branch à une fonction, une seconde exécution demande au modèle ce qui a changé, et rien d'autre. ### La même commande, en PHP et en Python {#the-same-command-in-php-and-in-python} Deux démos Bash pourraient donner l'impression qu'il s'agit d'un jouet pour scripting shell, alors soyons explicites : la seule chose qui change entre les langages, c'est l'extension que vous tapez. Voici une classe PHP suffisamment petite pour tenir en tête, qui fait la chose que tout projet finit par réécrire : **src/Slug.php** ```php float: """Return `price` reduced by `percent`, rounded to the cent. A 0% discount returns the price unchanged; 100% returns 0.0. """ if price < 0: raise ValueError("price cannot be negative") if not 0 <= percent <= 100: raise ValueError("percent must be between 0 and 100") return round(price * (1 - percent / 100), 2) def cheapest(offers: dict[str, float]) -> str: """Return the name of the cheapest offer. Ties are resolved by name, so the result never depends on dict ordering. """ if not offers: raise ValueError("no offer given") return min(sorted(offers), key=offers.__getitem__) ``` Lancez `ai-test src/Slug.php` et `ai-test src/prices.py`, et tout se comporte de la même manière : même prompt, mêmes deux confirmations, même container jetable. Seule la destination change — `tests/Unit/SlugTest.php` et `tests/test_prices.py`, tous deux résolus depuis la racine du projet (`composer.json` pour PHP, `pyproject.toml` ou `setup.py` pour Python) et non depuis `src/`. ## Sous le capot (passez si vous voulez juste l'utiliser) {#under-the-hood-skip-this-if-you-just-want-to-use-it} ### La logique, étape par étape {#the-logic-step-by-step} 1. S'enregistrer avec `AI_COMMANDS[test]=...` — la seule ligne qui place `ai-test` dans le menu `ai`. 2. Remonter jusqu'à la racine du projet (`.git`, `composer.json`, `pyproject.toml`…), en déduire où la suite *devrait* se trouver, et chercher un fichier de tests existant selon la convention de nommage de chaque écosystème : `.bats`, `Test.php`, `test_.py` / `_test.py`. 3. Construire l'un des deux prompts — « écris une suite complète », ou « voici la source *et* les tests actuels, donne-moi uniquement ce qui manque » — et, dans le premier cas, y ajouter le **paragraphe de bootstrap**. 4. L'envoyer via `_ollama_query`, retirer les fences Markdown que le modèle ajoute malgré la consigne inverse, et afficher joliment le résultat avec `bat` s'il est disponible. Trois points méritent d'être soulignés dans `_ollama.zsh` lui-même : * L'underscore initial est volontaire : le loader (`for fn_file in ~/.zsh/fns/*.zsh`) source les fichiers par ordre alphabétique et `_` se classe avant toute lettre, donc `AI_COMMANDS` et `ai()` existent toujours avant qu'un fichier `ai-*.zsh` n'essaie de s'y enregistrer. * `_ollama_query` ping d'abord `${host}/api/tags`, avec un timeout de deux secondes, et échoue bruyamment si Ollama n'est pas joignable — mieux vaut une erreur immédiate qu'un `curl` suspendu trente secondes contre un container arrêté. Il construit aussi sa charge JSON avec `jq`, donc les guillemets, backslashes et retours à la ligne du code source que je vais coller dans le prompt sont échappés pour moi. Notez que le prompt est *envoyé par pipe* à `jq` plutôt que passé comme valeur `--arg` : Linux limite un argument de ligne de commande à 128 Ko, et un gros fichier ou un long diff exploserait sinon avec `argument list too long`. * `AI_COMMANDS` est un simple tableau associatif, et c'est pourquoi une seule ligne d'enregistrement suffit pour faire apparaître une nouvelle fonction dans le menu `ai` — aucune liste centrale à maintenir, aucun fichier à éditer quand la série grandit. ### La seule instruction qui fait la différence {#the-one-instruction-that-makes-the-difference} Ce paragraphe de bootstrap de l'étape 3 est ce qui décide si une suite générée fonctionne ou est complètement inerte. La règle est simple : **un test ne peut appeler que du code qu'il a chargé, et le modèle ne voit jamais où ce code se trouve.** Il reçoit un bloc de texte, jamais un chemin. Demandez une suite sans rien préciser d'autre, et vous obtenez des tests bien nommés avec des assertions sensées qui échouent tous de la même façon — `command not found`, code de sortie 127 — parce que rien n'a jamais chargé le fichier testé. `ai-test` a calculé le chemin d'enregistrement une étape plus tôt, donc il n'espère pas : `_ai_test_bootstrap` dicte la ligne de chargement, dans la forme que chaque écosystème attend. * **[Bats](/blog/bats-unit-tests)** — chaque test tourne dans un shell frais qui n'a chargé que le fichier `.bats`, donc la suite doit commencer par un `setup()` qui source le script. `_ai_relative_to` calcule le chemin depuis le dossier du test vers la source, si bien que `src/lib/tool.sh` enregistré comme `tests/tool.bats` donne `source "$BATS_TEST_DIRNAME/../src/lib/tool.sh"`. * **Pytest** — `tests/test_tool.py` ne peut pas faire `import tool` si la racine du projet n'est pas dans le path. Plutôt que de laisser le modèle écrire des incantations `sys.path`, l'exécution Docker définit `PYTHONPATH=/code` et le prompt dit *« importe le module directement, ne touche pas à sys.path »*. * **Pest** — la classe est autoloadée par Composer, donc ce qu'il faut au modèle, c'est le **namespace** : `_ai_test_bootstrap` extrait la ligne `namespace` de la source avec grep et fournit un `use` prêt à l'emploi. Aucun namespace déclaré ? Il retombe sur un `require_once` avec le chemin relatif calculé. > **Donnez au modèle la structure, pas seulement le code:** > > Ça se généralise bien au-delà des tests. Quand un LLM écrit du code qui va vivre à un *endroit précis* d'un *projet précis*, la plupart de ses erreurs viennent du contexte que vous ne lui avez pas donné — chemins de fichiers, racines d'import, namespaces, conventions. Ce sont des choses que votre script connaît déjà, et chaque fait que vous transmettez est un fait que le modèle n'a plus à deviner de travers. ### Où le fichier est enregistré {#where-the-file-gets-saved} La suite est d'abord affichée dans votre console : vous la lisez là, et rien n'a encore touché votre disque. Ensuite `ai-test` demande si vous voulez la garder — et comme il connaît le framework, il propose le chemin que ce framework attend, donc vous n'avez jamais à vous souvenir si Pest veut `tests/Unit/` ou `tests/Feature/` : | Source | Framework | Chemin proposé | | ---------------- | --------- | --------------------------- | | `backup.sh` | Bats | `tests/backup.bats` | | `src/Backup.php` | Pest | `tests/Unit/BackupTest.php` | | `src/backup.py` | Pytest | `tests/test_backup.py` | Notez que `tests/` part de la **racine du projet**, pas du dossier du fichier source : `src/Backup.php` propose `tests/Unit/BackupTest.php` à la racine du repository, pas l'absurde `src/tests/Unit/BackupTest.php`. > **Il demande toujours d'abord:** > > Rien n'est écrit sans un `y` explicite, et si le fichier cible existe déjà la question devient un *« … existe déjà. L'écraser ? »* sans ambiguïté. Toute autre réponse — y compris simplement appuyer sur Entrée — affiche `→ Not saved.` et s'arrête là. Du code généré ne devrait pas atterrir dans votre repository par accident. ### Lancer la suite sans installer de test runner {#running-the-suite-without-installing-a-test-runner} Installer un test runner pour du code que je risque de supprimer trente secondes plus tard représente une quantité absurde de mise en place, donc `ai-test` lance plutôt la suite dans un container. Bats [publie une image officielle](https://hub.docker.com/r/bats/bats) dont l'entrypoint est `bats` lui-même, ce qui en fait un one-liner : ```bash $ docker run --rm --user "$(id -u):$(id -g)" --volume "${root}:/code" --workdir /tmp bats/bats:latest /code/tests/backup.bats ``` Les trois images sont remplaçables via `AI_TEST_BATS_IMAGE`, `AI_TEST_PHP_IMAGE` et `AI_TEST_PY_IMAGE` — voir le bloc `~/.zshrc` plus haut. > **Votre code est monté en lecture-écriture:** > > Le container reçoit le dossier du projet, pas une copie, parce que les tests doivent créer des fichiers temporaires à côté du code. C'est un container jetable (`--rm`), mais il exécute *bel et bien* du code généré contre votre working tree — une raison de plus de lire la sortie avant de répondre `y`. ### Écrivez pour le test que vous n'avez pas encore écrit {#write-for-the-test-you-havent-written-yet} Voici le `greet.sh` du début de cet article : **greet.sh** ```bash #!/usr/bin/env bash # greet.sh — ask for a name and a language, then print the greeting. # normalize_name — trim the surrounding spaces and capitalize the first # letter, so " christophe " and "Christophe" end up as the same thing. normalize_name() { local name="$1" # Strip leading, then trailing whitespace — pure parameter expansion, no # external command involved. name="${name#"${name%%[![:space:]]*}"}" name="${name%"${name##*[![:space:]]}"}" if [[ -z "$name" ]]; then echo "Error: no name given" >&2 return 1 fi echo "${name^}" } # greet [lang] — print the greeting line. The language defaults to # English and is case-insensitive. greet() { local name="$1" local lang="${2:-en}" local clean clean=$(normalize_name "$name") || return 1 case "${lang,,}" in en) echo "Hello ${clean}!" ;; fr) echo "Bonjour ${clean} !" ;; nl) echo "Hallo ${clean}!" ;; *) echo "Error: unsupported language '${lang}' (expected en, fr or nl)" >&2 return 1 ;; esac } main() { local name lang read -rp "What is your first name? " name read -rp "Which language (en/fr/nl)? " lang greet "$name" "$lang" } # Only run main() when the script is executed, never when it is sourced — # this is what lets a test suite load the functions without triggering the # interactive prompts. if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then main "$@" fi ``` Il est minuscule, mais il a déjà tout ce qui intéresse une suite de tests : deux fonctions, une valeur par défaut, et trois façons d'échouer. Exécuté seul, ce n'est qu'un prompt interactif : ```bash $ ./greet.sh What is your first name? christophe Which language (en/fr/nl)? fr Bonjour Christophe ! ``` Les trois dernières lignes sont ce qui rend tout l'exercice possible : ```bash if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then main "$@" fi ``` `main` ne s'exécute que quand le script est *exécuté*, jamais quand il est *sourcé*. Sans cette garde, le `setup()` de la suite générée ferait `source greet.sh`, tomberait sur `read -rp "What is your first name? "`, et resterait bloqué à jamais sur une entrée qui n'arrive pas. D'où les deux habitudes qui rendent n'importe quel script testable : mettez la logique dans des fonctions qui prennent des arguments et affichent des résultats, et enveloppez la partie interactive dans une garde `BASH_SOURCE`. Sautez-les, et le meilleur modèle du monde ne pourra générer que des tests qui se bloquent. ### Choisir son modèle {#choosing-your-model} Ce n'est pas une situation où un seul modèle convient à tous. Sur une carte de 24 Go, `qwen3-coder:30b` raisonne nettement mieux sur la couverture de tests que les variantes plus petites `1.5b`/`7b` que j'ai comparées dans mon [article sur l'accès réseau](/blog/accessing-ollama-across-your-local-network) — il est plus lent, mais pour une tâche que vous lancez quelques fois par jour, pas des milliers, ces dix secondes en plus n'ont aucune importance. Si votre VRAM est limitée, descendez à `qwen2.5-coder:7b` et attendez-vous à une bonne suite pour le cas nominal avec une couverture des cas limites plus faible ; vous réfléchirez davantage vous-même. ## Conclusion {#conclusion} Deux fichiers à copier dans `~/.zsh/fns/`, un nouveau shell, et le script que vous n'alliez jamais tester devient `ai-test greet.sh` : une suite complète, verte, le temps de faire un café — rien d'installé, rien qui quitte votre machine. Récupérez le `greet.sh` ci-dessus et essayez-le sur vos propres scripts. Puis regardez celui que vous évitez depuis des mois, le `backup.sh` de votre repository, et lancez-le là aussi : au pire vous jetez la sortie, au mieux vous avez enfin une suite. --- # ai-review, ai-secrets, ai-commit : trois vérifications zsh avant chaque git commit > Trois fonctions zsh qui forment ensemble une barrière qualité locale et hors ligne avant le commit : ai-review repère les violations SOLID et les problèmes de nommage, ai-secrets sépare les vrais identifiants en dur des faux positifs, et ai-commit rédige le message Conventional Commits une fois que le diff est réellement propre. --- ![ai-review, ai-secrets, ai-commit : trois vérifications zsh avant chaque git commit](/img/v2/ai_review.webp) **TL;DR** Cet article ajoute trois fonctions à la série « Ollama daily use » — `ai-review`, `ai-secrets` et `ai-commit` — qui couvrent ensemble le moment juste avant un `git commit`. Toutes les trois lisent le même `git diff --staged` : elles partagent donc un helper unique extrait (`_git_staged_diff`) au lieu de répéter trois fois les mêmes clauses de garde. Le workflow est volontaire : d'abord la revue, ensuite la détection de secrets, et enfin l'écriture du message de commit — une fois que le diff est vraiment ce que vous voulez. Le moment juste avant un `git commit` est probablement le plus précieux de tout le cycle de développement pour faire une pause et regarder ce que vous avez réellement écrit. Pas parce que quelque chose est forcément cassé — en général ça ne l'est pas — mais parce que c'est là que le diff est petit et délimité. Une minute de revue de plus, quand vous voyez encore clairement chaque ligne modifiée, vaut mieux qu'une heure d'archéologie plus tard. J'ai déjà des [hooks pre-commit](/blog/git-precommit) qui lancent phpcbf, PHPStan et compagnie. Ceux-là attrapent des *règles*. Ce qu'ils n'attrapent pas, c'est le jugement : une méthode qui a discrètement pris trois responsabilités, un `0.21` en ligne 20 qui devrait avoir un nom (comme `discount_rate`), un `$d` qui était un nom de paramètre paresseux il y a trois mois et qui est illisible aujourd'hui — ou, plus grave, un vrai mot de passe de base de données qui s'est glissé dans un diff de configuration. Ces trois fonctions comblent ce vide, en local, sans abonnement cloud. ## Ce que `ai-review` attrape avant même que vous écriviez un message de commit {#what-ai-review-catches-before-you-even-write-a-commit-message} `ai-review` lit votre diff stagé et signale ce qu'un linter ne peut pas voir : une méthode qui fait discrètement trois choses à la fois, un nombre magique qui devrait avoir un nom, un paramètre qui avait du sens à une époque. Voici un diff stagé avec quelques problèmes plantés volontairement : **src/Invoice/InvoiceProcessor.php (staged diff)** ```php diff --git a/src/Invoice/InvoiceProcessor.php b/src/Invoice/InvoiceProcessor.php index 1234567..89abcde 100644 --- a/src/Invoice/InvoiceProcessor.php +++ b/src/Invoice/InvoiceProcessor.php @@ -10,6 +10,32 @@ class InvoiceProcessor { + public function process($d) + { + if (!isset($d['amount']) || !isset($d['email'])) { + return false; + } + + if ($d['amount'] <= 0) { + return false; + } + + $tax = $d['amount'] * 0.21; + $total = $d['amount'] + $tax; + + $conn = new PDO('mysql:host=localhost;dbname=billing', 'root', 'root'); + $stmt = $conn->prepare('INSERT INTO invoices (amount, tax, total) VALUES (?, ?, ?)'); + $stmt->execute([$d['amount'], $tax, $total]); + + $subject = 'Your invoice'; + $body = 'Total due: ' . $total; + mail($d['email'], $subject, $body); + + return true; + } } ``` ```bash $ git diff --staged --stat src/Invoice/InvoiceProcessor.php | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) $ ai-review ## SOLID violations - src/Invoice/InvoiceProcessor.php:11 — process() validates input, calculates tax, persists to the database, and sends an email: four responsibilities in one method, violating Single Responsibility. Split into validateInvoiceData(), calculateTax(), saveInvoice() and sendInvoiceEmail(). ## Magic constants - src/Invoice/InvoiceProcessor.php:20 — 0.21 is a hardcoded VAT rate with no named meaning. Extract it to a class constant such as VAT_RATE_STANDARD = 0.21, or load it from configuration. ## Naming - src/Invoice/InvoiceProcessor.php:11 — the parameter $d gives no indication of its shape or purpose. Rename to $invoiceData, and consider a typed value object instead of a raw array. ## Overall quality - src/Invoice/InvoiceProcessor.php:23 — database credentials ('root', 'root') are hardcoded directly in the method body. Move them to environment variables or a configuration service; this connection also should not be constructed inside a method that also handles business logic. ``` Quatre vrais problèmes remontés, sous quatre des cinq titres fixes autorisés par le prompt. « Long functions » est resté vide — le modèle a estimé que 26 lignes ne franchissaient pas la limite, et le prompt lui disait explicitement de ne pas inventer une section juste pour la remplir. > **En lecture seule, volontairement:** > > `ai-review` ne touche jamais au diff, ne bloque jamais un commit, n'écrit jamais rien. Il affiche une revue dans votre terminal. Ce que vous en faites, c'est votre décision. ## Pourquoi c'est construit comme ça {#why-its-built-this-way} * Toutes les trois lisent le même `git diff --staged` — elles partagent un seul helper pour les clauses de garde (vérification du repo, diff vide, alerte de taille) au lieu de les répéter trois fois. * `ai-secrets` lance d'abord une regex peu coûteuse ; le modèle n'est appelé que quand quelque chose a vraiment l'air suspect, donc un diff propre coûte zéro appel au modèle. * `ai-commit` ne commite jamais tout seul — la réponse par défaut est « non », vous tapez explicitement `y` pour accepter ou `e` pour éditer. * L'ordre n'est pas arbitraire : la revue pendant que vous pouvez encore corriger le diff, la recherche de secrets une fois que c'est bien le diff que vous voulez, puis le message pour ce que vous avez réellement nettoyé. ## Installer les trois fonctions {#installing-the-three-functions} Toutes les trois s'appuient sur une base commune `_ollama.zsh` (présentée dans [ai-test](/blog/ollama-test-generator)) qui porte les clauses de garde une seule fois, au lieu que chaque fonction les répète : **\~/.zsh/fns/\_ollama.zsh** ````bash # Foundation file for every "ai-*" function in this series. Named with a # leading underscore: the ~/.zsh/fns/*.zsh loader sources files in # alphabetical order, and "_" sorts before any letter — so this file is # always loaded first, no matter how many ai-* functions get added later. # # Provides: # - _ollama_check reachability guard for Ollama # - _ollama_query shared HTTP client (curl + jq) # - _ai_strip_fences removes the Markdown fences models add anyway # - _ai_bat resolves "bat" vs Debian's "batcat" # - _ai_confirm yes/no prompt # - _git_staged_diff validated staged-diff fetcher (shared by pre-commit functions) # - AI_COMMANDS registry each ai-* function adds itself to # - AI_PARAMS registry of interactive parameter types (file/language/number/text/none) # - _ai_prompt_* FZF/read helpers for interactive argument collection # - ai entry-point dispatcher / interactive menu typeset -gA AI_COMMANDS typeset -gA AI_PARAMS # --------------------------------------------------------------------------- # Ollama helpers # --------------------------------------------------------------------------- # _ollama_check — verify that the Ollama daemon is reachable. # Fails loudly so callers can bail early rather than watching curl hang. _ollama_check() { local host="${OLLAMA_HOST:-http://localhost:11434}" if ! curl --silent --max-time 2 "${host}/api/tags" >/dev/null 2>&1; then echo "ollama: cannot reach ${host} — is the Ollama container running?" >&2 return 1 fi } # _ollama_query — send a prompt to the local model and print the response. # Assumes Ollama is reachable; call _ollama_check first if you need an early guard. _ollama_query() { local prompt="$1" local model="${OLLAMA_MODEL:-qwen3-coder:30b}" local host="${OLLAMA_HOST:-http://localhost:11434}" _ollama_check || return 1 # The prompt is piped into jq rather than passed with "--arg": Linux caps a # single argv entry at 128 KB (MAX_ARG_STRLEN), and a large staged diff or a # long source file sails straight through that ceiling — jq then dies with # "argument list too long". "-R -s" slurps stdin as one raw string, which jq # escapes exactly like --arg would. print -r -- "$prompt" \ | jq -Rs --arg model "$model" '{model: $model, prompt: ., stream: false}' \ | curl --silent "${host}/api/generate" --data-binary @- \ | jq -r '.response // empty' } # --------------------------------------------------------------------------- # Output helpers # --------------------------------------------------------------------------- # _ai_strip_fences — return only the code, without the Markdown fences. # # Every prompt in this series ends with "no markdown fences" and models still # wrap their answer in ```language … ``` often enough that the output can't be # piped or pasted as-is. When at least one fence is present, only the fenced # regions are kept (which also drops the "Here is your test suite:" preamble); # when there is none, the text is returned untouched. _ai_strip_fences() { local text="$1" if [[ "$text" != *'```'* ]]; then print -r -- "$text" return 0 fi print -r -- "$text" | awk ' /^[[:space:]]*```/ { inside = !inside; next } inside { print } ' } # _ai_bat — print the name of the "bat" binary installed on this machine, if any. # Debian and Ubuntu ship the package as "batcat" because the "bat" name was # already taken by bacula-console-qt, so both spellings have to be probed. _ai_bat() { local candidate for candidate in bat batcat; do if command -v "$candidate" >/dev/null 2>&1; then print -- "$candidate" return 0 fi done return 1 } # _ai_relpath — print relative to the current folder when it sits # below it. Purely cosmetic: it keeps prompts readable instead of asking # "Save as /home/christophe/projects/…/tests/backup.bats?". _ai_relpath() { local path="${1:A}" print -- "${path#${PWD}/}" } # _ai_confirm — ask a yes/no question; return 0 only on an explicit yes. _ai_confirm() { local answer print -n "${1} [y/N] " read -r answer [[ "$answer" == [yY]* ]] } # --------------------------------------------------------------------------- # Git helpers # --------------------------------------------------------------------------- # _git_staged_diff — validate the git context and return the staged diff. # Handles three early-exit cases (not a repo, nothing staged, oversized diff) # so every pre-commit function gets them for free without repeating the guards. # # Past AI_DIFF_MAX_CHARS the full diff is *not* returned: a diff of several # hundred kilobytes overflows the model's context window, and the answer that # comes back is worse than the one you get from a summary. The fallback keeps # the shape of the change — per-file stat, plus the file and hunk headers, # which carry the enclosing function names git puts after each "@@" — and # drops the line-by-line content. _git_staged_diff() { local caller="${1:-ai}" local max="${AI_DIFF_MAX_CHARS:-12000}" if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then echo "${caller}: not inside a git repository" >&2 return 1 fi local diff diff=$(git diff --staged) if [[ -z "$diff" ]]; then echo "${caller}: nothing staged — run 'git add' first" >&2 return 1 fi # "print -r" everywhere below: without it, print expands escape sequences and # a literal \n or \t inside the diff would come back mangled. if (( ${#diff} <= max )); then print -r -- "$diff" return 0 fi echo "${caller}: staged diff is large (${#diff} chars) — sending a structural summary instead of the full diff. Commit in smaller chunks for a more precise answer." >&2 local summary summary="$(git diff --staged --stat) --- FILE AND HUNK HEADERS ONLY (full diff omitted, ${#diff} chars) --- $(print -r -- "$diff" | grep -E '^(diff --git|new file|deleted file|rename (from|to)|@@)')" # Even the summary can exceed the ceiling on a very wide change. if (( ${#summary} > max )); then summary="${summary[1,$max]} […summary truncated…]" fi print -r -- "$summary" } # --------------------------------------------------------------------------- # Interactive argument helpers (used by the ai() dispatcher) # --------------------------------------------------------------------------- # _ai_prompt_file [label] — open an fzf file picker; print the chosen path. _ai_prompt_file() { local label="${1:-Select a file:}" local bat_bin preview='cat {}' bat_bin=$(_ai_bat) && preview="${bat_bin} --color=always {} 2>/dev/null || cat {}" if command -v fd >/dev/null 2>&1; then fd --type f | fzf --prompt="${label} " --height=50% --reverse --preview="$preview" else find . -type f | fzf --prompt="${label} " --height=50% --reverse --preview="$preview" fi } # _ai_prompt_language — open an fzf language picker; print the chosen language. _ai_prompt_language() { printf '%s\n' English French Dutch German Spanish Italian Portuguese Japanese Chinese Korean \ | fzf --prompt='Target language: ' --height=40% --reverse } # _ai_prompt_number [label] — read a positive integer; print it (or nothing on Enter, which # lets the caller fall back to its own default). Returns 1 only on invalid input. _ai_prompt_number() { local label="${1:-Number (Enter for default):}" print -n "${label} " local n read n if [[ -z "$n" ]]; then return 0 fi if [[ ! "$n" =~ ^[1-9][0-9]*$ ]]; then echo "Expected a positive integer." >&2 return 1 fi print -- "$n" } # _ai_prompt_text [label] — read a free-form string; print it. Returns 1 if empty. _ai_prompt_text() { local label="${1:-Input:}" print -n "${label} " local t read t if [[ -z "$t" ]]; then echo "No input given." >&2 return 1 fi print -- "$t" } # --------------------------------------------------------------------------- # Entry-point dispatcher # --------------------------------------------------------------------------- # ai → interactive fzf menu of every registered ai-* command # ai [args] → dispatch straight to ai- [args] # # When opened via the fzf menu, each command's declared AI_PARAMS type triggers # a secondary picker before the command runs: # file → fzf file picker (fd + preview if available) # language → fzf language list # number → read prompt, empty input uses the command's own default # text → read prompt, required # none → run the command directly (it collects its own input) # Space-separated values declare multiple parameters in order, e.g. "file language". ai() { local cmd="$1" # Direct dispatch: ai [args] if [[ -n "$cmd" && "$cmd" != "help" && "$cmd" != "list" ]]; then if (( ${+AI_COMMANDS[$cmd]} )); then shift "ai-${cmd}" "$@" return fi echo "ai: unknown command '${cmd}'" >&2 echo >&2 fi # Interactive menu if ! command -v fzf >/dev/null 2>&1; then echo "Available ai commands:" for key in ${(ok)AI_COMMANDS}; do printf " ai %-10s %s\n" "$key" "$AI_COMMANDS[$key]" done return fi local picked picked=$( for key in ${(ok)AI_COMMANDS}; do printf '%s\t%s\n' "$key" "$AI_COMMANDS[$key]" done \ | fzf --delimiter='\t' --with-nth=2 --prompt='ai > ' --height=40% --reverse \ | cut -f1 ) [[ -z "$picked" ]] && return # Collect arguments interactively based on AI_PARAMS declaration local -a args=() local param_type for param_type in ${(s: :)${AI_PARAMS[$picked]:-none}}; do case "$param_type" in file) local f f=$(_ai_prompt_file "File for ai-${picked}:") || return 1 args+=("$f") ;; language) local l l=$(_ai_prompt_language) || return 1 args+=("$l") ;; number) local n n=$(_ai_prompt_number) || return 1 [[ -n "$n" ]] && args+=("$n") ;; text) local t t=$(_ai_prompt_text) || return 1 args+=("$t") ;; none) ;; esac done "ai-${picked}" "${args[@]}" } ```` Ajoutez les trois fonctions par-dessus. Enregistrez chaque fichier dans `~/.zsh/fns/` sous le nom indiqué juste au-dessus (`mkdir -p ~/.zsh/fns` d'abord si le dossier n'existe pas encore), en y collant son contenu : **\~/.zsh/fns/ai-review\.zsh** ```bash # ai-review — SOLID, magic-constants, naming and overall quality review of # staged changes, printed straight to the terminal. Read-only: it never # modifies the diff or blocks a commit — that decision stays with you. AI_COMMANDS[review]="ai-review — code review of staged changes (SOLID, magic constants, naming)" AI_PARAMS[review]="none" ai-review() { local diff diff=$(_git_staged_diff ai-review) || return 1 local prompt="You are a strict senior code reviewer. Review the staged diff below and report ONLY genuine issues, organized under these exact headings (omit a heading entirely if there is nothing to report under it): ## SOLID violations ## Magic constants ## Long functions ## Naming ## Overall quality For each issue: cite the file and approximate line, state the problem in one sentence, and suggest a concrete fix in one sentence. Do not invent issues to fill every heading — an empty section is a good outcome, not a failure. Do not comment on formatting or style already enforced by a linter. --- STAGED DIFF --- ${diff}" _ollama_query "$prompt" } ``` **\~/.zsh/fns/ai-secrets.zsh** ```bash # ai-secrets — scan staged changes for hardcoded credentials. # Phase 1: a cheap regex pass over the diff finds candidate lines # (no model call wasted if nothing matches). # Phase 2: the LLM judges each candidate — real leak vs. safe pattern # (getenv(), placeholder, variable name only, already-redacted value). AI_COMMANDS[secrets]="ai-secrets — scan staged changes for hardcoded credentials" AI_PARAMS[secrets]="none" ai-secrets() { local diff diff=$(_git_staged_diff ai-secrets) || return 1 local suspects suspects=$(print -- "$diff" | grep -nEi \ '(api[_-]?key|secret|password|passwd|token|access[_-]?key|private[_-]?key|AKIA[0-9A-Z]{16}|-----BEGIN [A-Z ]*PRIVATE KEY-----)') if [[ -z "$suspects" ]]; then echo "ai-secrets: no suspicious patterns in the staged diff." >&2 return 0 fi local prompt="You are a secrets-detection expert reviewing a git diff. Below are ONLY the lines that matched a suspicious pattern — context around them has been trimmed. For each line: quote it, then state whether it is a genuine hardcoded secret or a false positive. False positives include: a variable NAME containing 'password' with no real value, a placeholder like 'changeme', an already-redacted value, or a correct read from the environment via getenv()/os.environ/process.env. Output only genuine findings, one per line with a one-sentence reason, or the single line 'No secrets detected.' if every match is a false positive. --- SUSPICIOUS LINES --- ${suspects}" _ollama_query "$prompt" } ``` **\~/.zsh/fns/ai-commit.zsh** ```bash # ai-commit — draft a Conventional Commits message from staged changes using # a local LLM, then ask for explicit confirmation before committing anything. # The default answer (Enter) is always "no" — this function never commits # on your behalf without a deliberate y or e. AI_COMMANDS[commit]="ai-commit — draft a Conventional Commits message from staged changes" AI_PARAMS[commit]="none" ai-commit() { local diff diff=$(_git_staged_diff ai-commit) || return 1 local prompt="You are a git commit message expert. Write a single Conventional Commits message (type(scope): subject, optionally a short body) for the staged diff below. Keep the subject line under 72 characters, imperative mood. Output ONLY the commit message — no explanations, no markdown fences, no surrounding quotes. --- STAGED DIFF --- ${diff}" local message message=$(_ollama_query "$prompt") || return 1 echo "--------------------------------------------------" echo "$message" echo "--------------------------------------------------" print -n "Use this message? [y]es / [e]dit / [N]o: " local answer read -k1 answer echo case "$answer" in y|Y) git commit -m "$message" ;; e|E) git commit -e -m "$message" ;; *) echo "ai-commit: aborted, nothing committed." ;; esac } ``` Si un article précédent de cette série a déjà fait en sorte que votre `~/.zshrc` charge ce dossier, il n'y a rien de plus à configurer. Sinon, ajoutez ces lignes à la fin de `~/.zshrc` — elles sourcent chaque fichier `.zsh` du dossier : ```zsh title="~/.zshrc" for fn_file in ~/.zsh/fns/*.zsh; do source "$fn_file" done ``` Ouvrez ensuite un nouveau terminal (ou lancez `exec zsh`), et vérifiez que le shell connaît les trois fonctions : ```zsh type ai-review ai-secrets ai-commit ``` Chacune doit répondre `… is a shell function from …` ; `not found` signifie que la boucle dans `~/.zshrc` manque. ## `ai-secrets` : détecter les vrais identifiants {#ai-secrets-catching-real-credentials} Un simple scanner à base de regex ne peut pas distinguer `'password' => 'Sup3rSecret!'` (une vraie fuite) de `'password' => getenv('DB_PASSWORD')` (le bon pattern) — les deux lignes contiennent « password » à côté d'un `=`. C'est exactement l'écart que le modèle comble : **config/database.php (staged diff)** ```php diff --git a/config/database.php b/config/database.php index 1234567..89abcde 100644 --- a/config/database.php +++ b/config/database.php @@ -5,6 +5,14 @@ return [ + 'mysql' => [ + 'host' => 'db.internal.example.com', + 'username' => 'app_user', + 'password' => 'Sup3rSecret!2027', + ], + + 'legacy' => [ + 'password' => getenv('LEGACY_DB_PASSWORD'), + ], ]; ``` ```bash $ git diff --staged --stat config/database.php | 8 ++++++++ 1 file changed, 8 insertions(+) $ ai-secrets - config/database.php:8 — 'password' => 'Sup3rSecret!2027' is a genuine hardcoded credential in plain text. Move it to an environment variable and load it with getenv('DB_PASSWORD'), matching the pattern already used two lines below for 'legacy'. - config/database.php:12 — 'password' => getenv('LEGACY_DB_PASSWORD') is the correct pattern (reads from an environment variable), not a leak. No action needed. ``` Deux lignes ont matché la regex, toutes deux contenant « password ». Le modèle les a correctement séparées : l'une est une vraie fuite avec un correctif concret, l'autre est exactement le pattern `getenv()` que ce correctif devrait suivre — laissée tranquille, à juste titre. > **Un filet de sécurité, pas une garantie:** > > Ça attrape ce qui apparaît dans un diff textuel. Ça n'attrapera pas un secret réparti sur plusieurs commits, embarqué dans un binaire, ou encodé d'une façon que la regex ne reconnaît pas. Voyez-le comme une deuxième paire d'yeux avant un commit, pas comme un remplacement pour un vrai outil de scan de secrets en CI. ## `ai-commit` : écrire le message du diff que vous venez de nettoyer {#ai-commit-writing-the-message-for-the-diff-youve-just-cleaned-up} Une fois que le diff correspond à ce que vous voulez vraiment committer, passez-le à `ai-commit` plutôt que de taper « fix » pour la quatrième fois cette semaine — il contient déjà tout ce qu'il faut pour décrire ce qui a changé : **api/reactions.php (staged diff)** ```php diff --git a/api/reactions.php b/api/reactions.php index 1234567..89abcde 100644 --- a/api/reactions.php +++ b/api/reactions.php @@ -12,6 +12,10 @@ header('Content-Type: application/json'); $slug = $_POST['slug'] ?? ''; +if ($slug === '') { + http_response_code(400); + exit(json_encode(['error' => 'Missing slug parameter'])); +} + $reaction = $_POST['reaction'] ?? ''; ``` ```bash $ git diff --staged --stat api/reactions.php | 4 ++++ 1 file changed, 4 insertions(+) $ ai-commit -------------------------------------------------- fix(api): reject empty slug parameter in reactions endpoint Return a 400 response with a clear error message when the `slug` POST parameter is missing or empty, instead of silently processing an invalid reaction. -------------------------------------------------- Use this message? [y]es / [e]dit / [N]o: y [main a1b2c3d] fix(api): reject empty slug parameter in reactions endpoint 1 file changed, 4 insertions(+) ``` Un message `fix(api):` correctement scopé avec un court corps explicatif, généré dans les deux ou trois secondes qu'il faut au modèle pour lire un diff de quatre lignes. > **Rien ne se commite tout seul:** > > `ai-commit` n'appelle `git commit` qu'après un `y` ou un `e` explicite. Appuyer sur Entrée, Échap ou n'importe quoi d'autre annule proprement. ## Sous le capot : `_git_staged_diff` (passez cette section si vous voulez juste l'utiliser) {#under-the-hood-\_git\_staged\_diff-skip-this-if-you-just-want-to-use-it} Les clauses de garde partagées par les trois fonctions — vérification du repo, vérification du diff vide, plafond de taille — vivent au même endroit : ```zsh title="~/.zsh/fns/_ollama.zsh (extract)" _git_staged_diff() { local caller="${1:-ai}" local max="${AI_DIFF_MAX_CHARS:-12000}" if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then echo "${caller}: not inside a git repository" >&2 return 1 fi local diff diff=$(git diff --staged) if [[ -z "$diff" ]]; then echo "${caller}: nothing staged — run 'git add' first" >&2 return 1 fi if (( ${#diff} <= max )); then print -r -- "$diff" return 0 fi echo "${caller}: staged diff is large (${#diff} chars) — sending a structural summary instead of the full diff." >&2 local summary summary="$(git diff --staged --stat) --- FILE AND HUNK HEADERS ONLY (full diff omitted, ${#diff} chars) --- $(print -r -- "$diff" | grep -E '^(diff --git|new file|deleted file|rename (from|to)|@@)')" if (( ${#summary} > max )); then summary="${summary[1,$max]} […summary truncated…]" fi print -r -- "$summary" } ``` Chacune des fonctions ci-dessus appelle `_git_staged_diff` et récupère le diff — ou sort proprement avec un message clair. Dans les versions d'origine de ces fonctions, ces clauses de garde étaient copiées-collées dans chacune — une violation classique du SRP cachée en pleine vue. Une responsabilité, un endroit, maintenant. Le plafond de taille mérite un mot, parce que je l'ai appris à mes dépens : stager quelques centaines de fichiers et lancer `ai-commit` sur un diff de 490 Ko vous donne un *moins bon* message de commit, pas un plus long — la fenêtre de contexte du modèle débord et ce qui revient est vague ou vide. Donc au-delà de `AI_DIFF_MAX_CHARS` (12 000 par défaut, à surcharger dans votre shell si votre modèle a de la marge), le helper arrête d'envoyer le contenu et envoie la *forme* à la place : le `--stat` par fichier, plus chaque en-tête de fichier et de hunk. Ces lignes `@@` portent le nom de la fonction englobante que git place juste après, donc le modèle sait encore quels fichiers ont changé, dans quelle proportion et où — assez pour une ligne `feat(scope):` correcte, sans se noyer. > **Pourquoi le prompt est passé à jq via un pipe, et non en argument:** > > Dans `_ollama_query`, vous verrez `print -r -- "$prompt" | jq -Rs …` plutôt que le plus évident `jq -n --arg prompt "$prompt"`. C'est volontaire : Linux limite un **seul** argument de ligne de commande à 128 Ko (`MAX_ARG_STRLEN`), indépendamment du `ARG_MAX` total bien plus grand. Passez un gros diff ou un long fichier source en `--arg` et `jq` meurt avant de démarrer, avec `argument list too long`. Un pipe n'a pas cette limite, et `jq -R -s` avale stdin comme une seule chaîne brute — en échappant les guillemets, les backslashes et les retours à la ligne exactement comme `--arg` le ferait. ## Les utiliser ensemble {#running-them-together} Ces trois fonctions ne forment pas un pipeline — vous n'êtes pas obligé de les lancer toutes les trois, ni de les lancer dans l'ordre. Mais l'ordre a du sens quand vous le faites : ```zsh # 1. Look at the diff for design issues ai-review # 2. Fix anything worth fixing, then re-stage git add -p # 3. Make sure you haven't accidentally left a real credential ai-secrets # 4. Write the commit message for the diff you've now cleaned up ai-commit ``` Chaque fonction est aussi joignable directement (`ai-review`, `ai-secrets`, `ai-commit`) ou via le dispatcher `ai` — tapez `ai` tout court, choisissez une fonction dans le menu fzf, et si la fonction a besoin d'un paramètre, le menu le récupère de façon interactive avant de lancer. Toutes les trois s'enregistrent avec une seule ligne chacune — `AI_COMMANDS[review]=...`, `AI_COMMANDS[secrets]=...`, `AI_COMMANDS[commit]=...` — et déclarent `AI_PARAMS[...]=none`, puisqu'elles lisent elles-mêmes le diff stagé et n'ont besoin d'aucun argument. C'est ce registre qui rend la famille extensible. [`ai-blog-search`](/blog/anythingllm-chat-with-your-docs) l'a rejointe plus tard avec les deux mêmes lignes — sauf qu'elle déclare `AI_PARAMS[blog-search]="text"`, donc le menu demande d'abord une question, et elle interroge un workspace AnythingLLM plutôt qu'Ollama directement. ## Conclusion {#conclusion} Aucune de ces trois fonctions ne fait quoi que ce soit que vous ne pourriez pas faire à la main. `ai-review` est une version plus lente de la lecture de votre propre diff. `ai-secrets` est une version plus rapide de `grep -i password`. `ai-commit` est un brouillon du message que vous alliez écrire de toute façon. La valeur n'est pas dans les capacités individuelles — elle est dans le fait que les trois tournent en local, prennent moins de dix secondes chacune, et s'insèrent dans le moment où vous êtes déjà : tout est stagé, prêt à committer, juste avant de le faire. C'est le seul moment où le coût de « je corrige maintenant » est encore inférieur au coût de « je corrigerai plus tard ». --- # AnythingLLM : discuter avec toute votre documentation éparpillée > AnythingLLM transforme un tas de fichiers Markdown, Quarto, PDF, DOCX, Excel et PowerPoint en quelque chose avec quoi vous pouvez réellement discuter, avec un modèle Ollama local derrière. La partie 1 l'installe sur une seule machine ; la partie 2 garde les documents sur mon PC de travail tout en empruntant le GPU qui dort à la maison. --- ![AnythingLLM : discuter avec toute votre documentation éparpillée](/img/v2/anythingllm.webp) **TL;DR** AnythingLLM est une application self-hosted, pensée pour Docker, qui transforme vos propres documents — Markdown, Quarto, PDF, DOCX, Excel, PowerPoint, peu importe — en une base de connaissances consultable et interrogeable, avec des réponses qui citent le fichier exact d'où elles viennent. Elle n'embarque pas sa propre IA : elle parle à un fournisseur de LLM de votre choix, ce qui lui permet de réutiliser un serveur Ollama que vous faites déjà tourner ailleurs. Cet article l'installe deux fois : une fois entièrement sur une seule machine (mon PC de la maison), et une fois répartie sur deux — documents sur mon PC de travail, inférence GPU sur le PC de la maison — parce que certains documents n'ont tout simplement pas le droit de quitter la machine où ils vivent. Sous `~/repositories`, sur mon portable de travail, j'ai des dizaines de dossiers de projets, et chacun a discrètement accumulé son propre tas de documentation : un `README.md` ici, un rapport Quarto là, une spec PDF que quelqu'un m'a envoyée, une feuille Excel dont personne ne se souvient de l'objectif, un PowerPoint issu d'une réunion d'il y a six mois. Pris séparément, chaque fichier va très bien. Ensemble, c'est un cimetière — je *sais* que j'ai noté quelque part la réponse à « comment j'ai configuré ce VPN la dernière fois », dans un de ces dossiers, dans un de ces formats, mais le retrouver suppose soit de me rappeler le nom exact du fichier, soit de faire un grep en espérant que les mots correspondent. Si vous avez déjà ouvert un terminal, tapé `grep -r "some term" ~/repositories`, obtenu quarante résultats sans intérêt et abandonné — vous avez déjà le problème dont parle cet article. ## Le voir fonctionner {#seeing-it-work} Voici une vraie question posée à un vrai workspace — les 248 articles de ce blog, indexés avec le script plus bas. Poser la question, c'est un appel API, exactement la requête que le panneau de chat envoie quand vous tapez dedans : ```bash $ curl -s -X POST http://localhost:3001/api/v1/workspace/blog/chat \ -H "Authorization: Bearer $ANYTHINGLLM_API_KEY" \ -H "Content-Type: application/json" \ -d '{"message":"There was a trick I documented to see, the moment I enter a repository, which branches I had touched most recently. How did I do it?","mode":"query"}' You documented a trick to automatically display the three most recently updated local Git branches whenever you enter a Git repository. This was achieved by adding a custom function to your Zsh configuration that hooks into the `chpwd` hook (which triggers when changing directories) and uses the `git for-each-ref` command to list local branches sorted by the date of their last commit. Here's how you did it: 1. **Zsh Integration**: You leveraged the `chpwd` hook in Zsh, which is called automatically when you use commands like `cd` or `pushd`. 2. **Function Logic**: The function `_display_recent_git_context` checks if you're inside a Git repository and then retrieves the active branch. It uses `git for-each-ref` to list local branches, sorting them by the date of their last commit in descending order (`--sort=-committerdate`). 3. **Display**: The output shows the last three updated branches, with an asterisk (*) indicating the active branch. 4. **Alias Integration**: You also defined a `gst` alias in Zsh that calls this function before running `git status`. This setup ensures that every time you `cd` into a Git project, you immediately see the most recently active branches, helping avoid accidentally working on the wrong branch. The full implementation details can be found in the article [Showing the last 3 updated branches when you jump in a git repo](https://www.avonture.be/blog/git-branches-gst). ``` Relisez la question et remarquez ce qu'elle ne contient pas. Pas `chpwd`. Pas `for-each-ref`. Pas `committerdate`. Même pas les mots du titre de l'article qu'elle a trouvé — *Showing the last 3 updated branches when you jump in a git repo*. J'ai décrit un souvenir flou avec les mots que j'emploierais réellement deux ans plus tard, et j'ai récupéré le mécanisme, le hook auquel il est branché, le flag qui trie les branches, et un lien. C'est précisément la requête que `grep` ne peut pas servir. `grep -r "chpwd"` l'aurait trouvé instantanément — si je m'étais souvenu du mot `chpwd`. Tout l'enjeu, c'est justement que non. ## Pourquoi ça fonctionne {#why-it-works} * [AnythingLLM](https://anythingllm.com/) est une application open-source, self-hosted, construite autour du Retrieval-Augmented Generation (RAG) sur vos propres fichiers, organisés en **workspaces** — un par projet, par client ou par sujet, sans aucune fuite entre eux. * **C'est un client, pas un LLM.** AnythingLLM ne livre pas de modèle à lui : il appelle un **LLM Provider** que vous configurez : OpenAI, Anthropic ou, la partie intéressante pour nous, **Ollama**. Si vous faites déjà tourner Ollama quelque part, AnythingLLM n'est qu'une chose de plus qui lui parle. * Chaque réponse revient avec une référence au fichier source et au chunk d'où elle a été extraite — une citation, pas une supposition qu'il faut croire sur parole. * Il lit ce que `grep` ne peut même pas ouvrir : la spec PDF, la feuille Excel, le deck PowerPoint. C'est l'autre moitié de la raison pour laquelle il complète `rg` plutôt que de le remplacer. Il coexiste aussi avec Open WebUI — Open WebUI est un client de chat généraliste pour Ollama, AnythingLLM est spécifiquement le layer de RAG documentaire, et les deux parlent volontiers à la même instance Ollama. > **Ce que vous gagnez pour l'effort d'installation:** > > Une question en langage naturel sur tout le contenu d'un workspace, avec le fichier source nommé dans la réponse. Cette seule fonctionnalité vaut les vingt minutes de configuration Docker ci-dessous — elle transforme « je sais que j'ai noté ça quelque part » en une vraie réponse. Quatre choses suivent, et vous pouvez les lire dans l'ordre qui vous plaît : * [**L'installer**](#install) — un `compose.yaml`, plus la vérification de trente secondes qui vous évite un embedder qui renvoie silencieusement du bruit. * [**Y faire entrer des documents**](#ingest) — pourquoi il n'y a pas de bouton « indexer ce dossier », et le script qui a indexé 248 articles en 94 secondes. * [**Chercher depuis le terminal**](#search) — un `curl`, puis la même chose sous forme de commande shell avec des résultats cliquables. * [**Documents ici, GPU là-bas**](#two-machines) — la répartition sur deux machines, pour les documents qui n'ont pas le droit de voyager. ## Partie 1 — Installer AnythingLLM sur une seule machine {#install} C'est le cas simple : AnythingLLM et Ollama vivent sur la même machine, mon PC de la maison avec 24 Go de VRAM disponibles. > **Prérequis:** > > On suppose ici qu'Ollama tourne déjà comme container Docker, comme je l'ai installé dans [Installing Ollama and get local AI](/blog/ollama-installation). Rien de ce qui suit ne réexplique cette partie. ### Le fichier compose.yaml {#the-composeyaml-file} Fidèle à mon habitude d'un dossier par outil sous `~/tools`, créons `~/tools/anythingllm/compose.yaml` : **compose.yaml** ```yaml name: tools services: anythingllm: image: mintplexlabs/anythingllm container_name: anythingllm cap_add: - SYS_ADMIN ports: - "3001:3001" env_file: - .env environment: - STORAGE_DIR=/app/server/storage - LLM_PROVIDER=ollama - OLLAMA_BASE_PATH=http://192.168.0.218:11434 - OLLAMA_MODEL_PREF=qwen2.5:14b-instruct - OLLAMA_MODEL_TOKEN_LIMIT=8192 - EMBEDDING_ENGINE=ollama - EMBEDDING_BASE_PATH=http://192.168.0.218:11434 - EMBEDDING_MODEL_PREF=mxbai-embed-large # mxbai-embed-large has a 512-token context — a larger chunk size makes # every single embedding call fail. Keep this comfortably under it. - EMBEDDING_MODEL_MAX_CHUNK_LENGTH=400 - OLLAMA_EMBEDDING_BATCH_SIZE=16 - VECTOR_DB=lancedb volumes: - anythingllm_storage:/app/server/storage restart: always volumes: anythingllm_storage: ``` Avant de le démarrer, créez le fichier `.env` qu'il référence, qui contient un secret aléatoire utilisé par AnythingLLM pour signer les tokens de session : ```bash $ echo "JWT_SECRET=$(openssl rand -hex 32)" > .env ``` > **À propos de cap\_add: SYS\_ADMIN:** > > AnythingLLM utilise un Chromium headless en coulisses pour certaines fonctions documentaires et de web-scraping, et Chromium veut cette capability pour faire tourner son sandbox dans un container. C'est une concession de privilège plus large que ce que j'aimerais accorder par défaut — le projet a une discussion ouverte pour la remplacer par un profil seccomp plus étroit, mais à ce jour, `SYS_ADMIN` est ce qu'attend l'image officielle. Bon à savoir, pas de quoi perdre le sommeil sur un LAN domestique. `OLLAMA_BASE_PATH` et `EMBEDDING_BASE_PATH` pointent tous les deux vers `192.168.0.218` — la même adresse IP de serveur maison que j'ai utilisée dans [Accessing Ollama across your local network](/blog/accessing-ollama-across-your-local-network). Remplacez-la par l'IP de votre propre serveur. Notez que même si Ollama et AnythingLLM tournent ici sur exactement la même machine, j'utilise quand même l'IP du LAN plutôt que `localhost` — ce détail compte plus loin, là où il cesse d'être un détail pour devenir tout l'enjeu. Téléchargez le modèle d'embedding côté Ollama avant de démarrer AnythingLLM — il est petit (quelques centaines de Mo) et indispensable pour que quoi que ce soit soit embeddé : ```bash $ docker exec -it ollama ollama pull mxbai-embed-large ``` > **Alignez la taille des chunks sur le modèle d'embedding, sinon rien ne sera embeddé:** > > `EMBEDDING_MODEL_MAX_CHUNK_LENGTH` ci-dessus est à `400` pour une raison. Lancez `docker exec ollama ollama show mxbai-embed-large` et vous verrez **context length 512** — fixez la taille des chunks au-delà et *chaque* document échoue à l'embedding, avec `Ollama Failed to embed: the input length exceeds the context length` enterré dans `docker logs anythingllm`. L'interface ne donne aucun indice : l'upload réussit, le document apparaît dans la bibliothèque, et le workspace reste simplement vide. `OLLAMA_EMBEDDING_BATCH_SIZE` n'est que de la vitesse — la valeur par défaut de 1 envoie un chunk par appel HTTP, ce qui transforme quelques centaines de documents en un très long après-midi. ### Vérifiez que votre embedder discrimine vraiment {#check-that-your-embedder-actually-discriminates} J'utilisais au départ `nomic-embed-text` ici — c'est le modèle que tout le monde recommande pour ce travail. Il a embeddé les 248 articles sans une seule erreur, puis a répondu « il n'y a aucun article couvrant spécifiquement WordPress » à propos d'un blog qui contient un article intitulé *Quickly install WordPress in just three commands*. Rien n'avait échoué. Les embeddings étaient simplement dénués de sens, et une stack RAG n'a aucun moyen de vous le dire : elle récupère les vecteurs les plus proches qu'elle trouve et les passe au modèle, quels qu'ils soient. Donc avant de faire confiance à un embedder, passez trente secondes à prouver qu'il sépare un texte proche d'un texte sans rapport : **embedder-sanity-check.py** ```python #!/usr/bin/env python3 """Prove an Ollama embedding model separates related text from unrelated text. python3 embedder-sanity-check.py mxbai-embed-large nomic-embed-text """ import json import math import sys import urllib.request OLLAMA = "http://localhost:11434/api/embed" # The first two sentences say the same thing; the third is unrelated. SENTENCES = [ "Installing WordPress using Docker containers", "WordPress installation with Docker", "Powerlevel10k is a theme for the zsh shell", ] def embed(texts, model): payload = json.dumps({"model": model, "input": texts}).encode() request = urllib.request.Request( OLLAMA, data=payload, headers={"Content-Type": "application/json"} ) with urllib.request.urlopen(request, timeout=300) as response: return json.loads(response.read())["embeddings"] def cosine(a, b): dot = sum(x * y for x, y in zip(a, b)) return dot / (math.sqrt(sum(x * x for x in a)) * math.sqrt(sum(y * y for y in b))) for model in sys.argv[1:] or ["mxbai-embed-large"]: vectors = embed(SENTENCES, model) related = cosine(vectors[0], vectors[1]) unrelated = cosine(vectors[0], vectors[2]) print( f"{model:<20} dims={len(vectors[0]):<5}" f" related={related:.4f} unrelated={unrelated:.4f}" f" separation={related - unrelated:+.4f}" ) ``` ```bash $ python3 embedder-sanity-check.py mxbai-embed-large nomic-embed-text mxbai-embed-large dims=1024 related=0.9536 unrelated=0.4591 separation=+0.4945 nomic-embed-text dims=768 related=0.5910 unrelated=0.5377 separation=+0.0532 ``` Deux phrases qui veulent dire la même chose doivent scorer bien au-dessus d'une phrase sans rapport. `mxbai-embed-large` les sépare de **+0,49** ; sur ma machine, `nomic-embed-text` a atteint **+0,05**, ce qui est du bruit. Je n'ai jamais remonté la cause racine — les tags `:latest` et `:v1.5` se comportaient de façon identique, et les réglages `OLLAMA_FLASH_ATTENTION` / `OLLAMA_KV_CACHE_TYPE` d'Ollama n'ont rien changé — mais le correctif ne dépend pas de la cause. Faites le test, et si la séparation est faible, changez de modèle plutôt que de questions. Ensuite, démarrez AnythingLLM : ```bash $ docker compose up --detach [+] Running 1/1 ✔ Container anythingllm Started ``` ### Configuration au premier lancement {#first-run-setup} Rendez-vous sur `http://localhost:`3001 (ou `http://192.168.0.218 : 3001` depuis une autre machine du réseau). L'assistant d'onboarding demande : **Onboarding AnythingLLM** 1. **LLM Provider** — sélectionnez Ollama, collez l'URL de votre serveur (`http://192.168.0.218:11434`), choisissez le modèle que vous avez déjà téléchargé (`qwen2.5:14b-instruct` tient confortablement dans 24 Go de VRAM à côté du modèle d'embedding) 2. **Embedding Provider** — sélectionnez Ollama à nouveau, même URL, choisissez `mxbai-embed-large` 3. **Vector Database** — laissez la valeur par défaut, LanceDB ; il est embarqué, aucun container supplémentaire à faire tourner 4. **Create your first workspace** — nommez-le d'après un vrai projet, pas "test" > Screenshot — http\://localhost:3001/workspace/blog > > ![Where did I mention Mermaid](./images/mermaid.webp) ## Faire entrer vos documents {#ingest} Dans le workspace, la boîte de dialogue d'upload accepte exactement le mélange que je décrivais plus haut : déposez un `README.md`, un rapport Quarto `.qmd`, un PDF, un DOCX, une feuille Excel, un deck PowerPoint — en sélection multiple, autant que le sélecteur de fichiers de l'OS vous laisse en prendre d'un coup. AnythingLLM parse chaque format lui-même ; vous n'avez rien à convertir au préalable. Dès que les documents affichent un statut « embedded » vert, posez une vraie question dans le panneau de chat — quelque chose que vous seriez normalement allé chercher à la main, exactement comme la preuve en terminal plus haut a obtenu sa réponse. La réponse revient avec le document source nommé à côté, donc vous pouvez aller vérifier au lieu de croire le modèle sur parole. ### Vous ne lui donnez pas un chemin — et ça surprend tout le monde {#you-dont-give-it-a-path--and-that-surprises-everyone} C'est la partie que j'ai mal comprise la première fois, alors soyons explicites : **il n'existe aucun champ, nulle part dans AnythingLLM, où vous tapez `/home/me/repositories` pour lui faire indexer ce dossier.** Deux choses s'additionnent pour rendre ça impossible : * Le serveur tourne dans un container, et le `compose.yaml` ci-dessus monte exactement un volume — `anythingllm_storage`. Votre dossier de documents n'existe tout simplement pas à l'intérieur de ce container. * Ajouter un bind mount n'aiderait pas non plus, parce que cette boîte de dialogue *Upload* est le sélecteur de fichiers de **votre navigateur**, qui tourne sur votre machine. Il ne peut pas voir à l'intérieur du container, et le container n'apprend jamais d'où vient le fichier — les octets arrivent en HTTP comme n'importe quel autre envoi de formulaire. Donc AnythingLLM ne lit jamais vos fichiers là où ils sont. Il **copie** chacun d'eux dans son volume de stockage, le parse en JSON, le découpe en chunks et les embedde. C'est exactement le fait sur lequel repose toute l'argumentation de la partie 2 ci-dessous, et il vaut la peine de l'intégrer tôt : *les documents vivent là où tourne le container*. Reste une vraie question : comment y faire entrer quelques centaines de fichiers sans cliquer quelques centaines de fois dans un sélecteur de fichiers ? ### Indexer une arborescence entière avec une seule commande {#indexing-a-whole-folder-tree-with-one-command} L'interface graphique est l'une des trois portes, et les deux autres sont mieux adaptées au traitement en masse : * **L'API développeur** — un endpoint REST qui prend un fichier par appel. Enveloppez-le dans une boucle et vous pouvez lui donner tout ce que vous savez trouver avec `find`. C'est celle que nous allons utiliser. * **Les data connectors** (*Settings → Data Connectors*) — des importeurs prêts à l'emploi pour GitHub, GitLab, Confluence, Obsidian, ainsi qu'un crawler de site en profondeur. Si vos documents vivent déjà dans un repository, pointer le connecteur GitHub dessus ne demande aucun script. Donnez-lui quand même un token d'accès : sans authentification, il est bridé par la limite de débit publique de GitHub et ne récupère souvent que les fichiers de premier niveau. * **Le crawler de site web**, si le contenu est publié quelque part. Le plus simple de tous, mais vous indexez du HTML rendu — navigation, pied de page et barre latérale inclus — au lieu de la source. J'ai choisi l'API, parce que ce que je veux vraiment indexer, c'est ce blog : 248 articles, chacun un `index.md` dans son propre dossier daté. Générez une clé sous *Settings → Tools → Developer API*, et un seul appel ressemble à ceci : ```bash $ curl -X POST http://localhost:3001/api/v1/document/upload \ -H "Authorization: Bearer $ANYTHINGLLM_API_KEY" \ -F "file=@blog/2026/08/17/anythingllm-chat-with-your-docs/index.md" \ -F "addToWorkspaces=blog" ``` Le champ `addToWorkspaces` est ce qui en fait une opération en une étape : sans lui, le document arrive dans la bibliothèque de documents d'AnythingLLM mais n'est embeddé nulle part, et il faudrait encore aller cocher des cases dans l'interface. > **Le piège : 241 fichiers nommés index.md:** > > Docusaurus place chaque article dans son propre dossier sous le nom `index.md`, et AnythingLLM cite ses sources **par nom de fichier**. Uploadez-les tels quels et chaque réponse se termine par `Source: index.md` — 241 fois, impossible à distinguer. La citation est toute la raison d'utiliser cet outil, donc il faut corriger ça au moment de l'upload : `curl` permet de surcharger le nom transmis avec `-F "file=@path/index.md;filename=the-slug.md"`, et le slug est juste là, dans le frontmatter. Ça, plus le fait de sauter les fichiers inchangés depuis la dernière fois, c'est tout ce que fait le script : **anythingllm-index.sh** ```bash #!/usr/bin/env bash # # Index every blog post into an AnythingLLM workspace. # # ANYTHINGLLM_API_KEY=xxxxxx ./anythingllm-index.sh # # Re-run it after publishing a new article: only new and modified posts are # sent, everything else is skipped. set -o errexit set -o nounset set -o pipefail WORKSPACE="${ANYTHINGLLM_WORKSPACE:-blog}" BLOG_DIR="${BLOG_DIR:-blog}" STATE_FILE="${STATE_FILE:-.anythingllm-indexed}" SITE_URL="${SITE_URL:-https://www.avonture.be}" # The key can come from the environment (a line in ~/.zshrc) or from the # gitignored .env at the root of the blog — handy inside a devcontainer, which # doesn't inherit the host's shell configuration. if [[ -z "${ANYTHINGLLM_API_KEY:-}" && -f ".env" ]]; then ANYTHINGLLM_API_KEY="$(grep -E '^ANYTHINGLLM_API_KEY=' .env | head -1 | cut -d= -f2- | tr -d "\"'")" || true fi : "${ANYTHINGLLM_API_KEY:?Generate one in Settings > Tools > Developer API}" # "localhost" is right when the script runs on the Docker host; from inside # another container it isn't, and the bridge gateway is what reaches it. if [[ -z "${ANYTHINGLLM_URL:-}" ]]; then for candidate in "http://localhost:3001" "http://172.17.0.1:3001"; do if curl --silent --fail --max-time 2 "${candidate}/api/ping" >/dev/null 2>&1; then ANYTHINGLLM_URL="${candidate}" break fi done fi : "${ANYTHINGLLM_URL:?No AnythingLLM instance answered on port 3001}" api() { curl --silent --show-error \ --header "Authorization: Bearer ${ANYTHINGLLM_API_KEY}" "$@" } # Read one key out of a Markdown file's YAML frontmatter, unquoted. frontmatter() { local key="${1}" file="${2}" awk -v key="${key}" ' NR == 1 && $0 == "---" { in_fm = 1; next } in_fm && $0 == "---" { exit } in_fm && index($0, key ":") == 1 { sub("^" key ": *", "") gsub(/^"|"$/, "") print exit } ' "${file}" } # Fail early and loudly rather than uploading 248 files into the void. if ! api "${ANYTHINGLLM_URL}/api/v1/workspaces" | jq -e --arg slug "${WORKSPACE}" '.workspaces[]? | select(.slug == $slug)' >/dev/null; then printf 'Workspace "%s" not found at %s (check the slug and the API key)\n' \ "${WORKSPACE}" "${ANYTHINGLLM_URL}" >&2 exit 1 fi # Replay the state file: post path -> "\t". declare -A previous=() if [[ -f "${STATE_FILE}" ]]; then while IFS=$'\t' read -r hash path location; do [[ -n "${path}" ]] && previous["${path}"]="${hash}"$'\t'"${location}" done <"${STATE_FILE}" fi declare -A current=() added=0 updated=0 skipped=0 failed=0 while IFS= read -r -d '' file; do hash="$(sha256sum "${file}" | cut -d' ' -f1)" entry="${previous["${file}"]:-}" known_hash="${entry%%$'\t'*}" if [[ "${known_hash}" == "${hash}" ]]; then current["${file}"]="${entry}" skipped=$((skipped + 1)) continue fi if [[ "$(frontmatter draft "${file}")" == "true" ]]; then continue fi # Every post is an index.md, so the folder name is what makes it # identifiable — send it as the filename AnythingLLM will cite. slug="$(frontmatter slug "${file}")" [[ -n "${slug}" ]] || slug="$(basename "$(dirname "${file}")")" title="$(frontmatter title "${file}")" [[ -n "${title}" ]] || title="${slug}" # AnythingLLM stamps each document with a "published" date taken from the # temp file the collector received — i.e. the upload date, always today, and # not overridable through the API. Ask the workspace when something was # written and it answers confidently and wrongly. # Of the three fields copied into every chunk's header, "title" is the only # one we control, so the real date rides along inside it. published="$(frontmatter date "${file}")" [[ -z "${published}" ]] || title="${title} (published ${published})" # A modified post: drop the previously embedded copy first, otherwise the # workspace ends up answering from two versions of the same article. if [[ -n "${entry}" ]]; then old_location="${entry#*$'\t'}" api --request DELETE "${ANYTHINGLLM_URL}/api/v1/system/remove-documents" \ --header "Content-Type: application/json" \ --data "$(jq -nc --arg name "${old_location}" '{names: [$name]}')" >/dev/null || true fi # A "link://" chunkSource is the one metadata field AnythingLLM copies into # every chunk's header, so this is what lets the model answer with the live # URL of the article instead of just a filename. response="$(api --request POST "${ANYTHINGLLM_URL}/api/v1/document/upload" \ --form "file=@${file};filename=${slug}.md;type=text/markdown" \ --form "addToWorkspaces=${WORKSPACE}" \ --form "metadata=$(jq -nc \ --arg title "${title}" \ --arg source "${file}" \ --arg link "link://${SITE_URL}/blog/${slug}" \ '{title: $title, docSource: $source, chunkSource: $link}')")" location="$(jq -r '.documents[0].location // empty' <<<"${response}")" if [[ -z "${location}" ]]; then printf 'FAILED %s: %s\n' "${slug}" "$(jq -r '.error // "unknown error"' <<<"${response}")" >&2 failed=$((failed + 1)) continue fi current["${file}"]="${hash}"$'\t'"${location}" if [[ -n "${entry}" ]]; then printf 'updated %s\n' "${slug}" updated=$((updated + 1)) else printf 'added %s\n' "${slug}" added=$((added + 1)) fi done < <(find "${BLOG_DIR}" -type f \( -name 'index.md' -o -name 'index.mdx' \) -print0 | sort -z) # Rewrite the state file from scratch so deleted posts drop out of it. for path in "${!current[@]}"; do printf '%s\t%s\t%s\n' \ "${current["${path}"]%%$'\t'*}" "${path}" "${current["${path}"]#*$'\t'}" done | sort -k2 >"${STATE_FILE}" printf '\n%d added, %d updated, %d unchanged, %d failed\n' \ "${added}" "${updated}" "${skipped}" "${failed}" [[ "${failed}" -eq 0 ]] ``` Créez d'abord un workspace nommé `blog` dans l'interface, puis lancez-le depuis la racine du repository du blog : ```bash $ export ANYTHINGLLM_API_KEY=your-key-here $ ./anythingllm-index.sh added docker-php-run-script-or-website added keepass-overriding-url added welcome added wslg-rpd-connection added docker-volume added apache-htaccess ... added ollama-test-generator added ollama-git-precommit added anythingllm-chat-with-your-docs 248 added, 0 updated, 0 unchanged, 0 failed ``` ### Une étape manuelle : le system prompt du workspace {#one-manual-step-the-workspace-system-prompt} Le script gère tout ce que l'API d'upload permet, mais il y a une chose qu'il ne peut pas atteindre : la façon dont le modèle lit ce qu'il reçoit. Collez ceci dans *Workspace Settings → Chat Settings → Prompt* — c'est ce qui fait sortir les dates correctes, et ce qui empêche le modèle de reconstruire des URLs dont il se souvient à moitié : **Workspace system prompt** ``` You answer questions about a personal technical blog, using only the retrieved excerpts. Each excerpt starts with a block. Read it as follows: - "sourceDocument" is the article title. When it ends with "(published YYYY-MM-DD)", that is the article's real publication date. - "published:" is the date the article was fed to the search index. It is ALWAYS today's date and ALWAYS wrong. Never quote it, never mention it, never use it to compare or sort articles. - "source:" is the article's public URL. Use it verbatim; never invent or reconstruct a URL. When you state a date, write it as DD/MM/YYYY. Always answer in the language the question was asked in. When listing articles, give the title and the URL of each. If the excerpts do not answer the question, say so plainly rather than guessing. ``` > **Demandez en français, obtenez 02/03/2024:** > > Avec ce prompt en place, *« Quand ai-je publié l'article sur Tabnine ? »* répond **02/03/2024** — bonne valeur, bon format, bonne langue. Sans lui, la même question renvoie `8/10/2026, 9:56:01 AM` : le jour où j'ai lancé l'indexeur, dans un format américain que personne n'avait demandé. Sauter cette étape est le moyen sûr de finir avec un workspace qui a l'air correct et qui ment sur toutes les dates ; [la dernière section](#where-that-wrong-date-comes-from) explique d'où vient cette seconde date. ### Garder l'index à jour {#keeping-the-index-up-to-date} Une habitude à désapprendre avant de construire quoi que ce soit là-dessus : **AnythingLLM ne rescanne jamais rien de lui-même.** Un document embeddé est une copie figée ; modifier le Markdown original sur le disque ne change rien dans le workspace, et un fichier tout neuf lui est simplement invisible. Il *existe* une fonction de synchronisation automatique, mais elle ne couvre pas ce cas : dans le déploiement Docker, elle ne surveille que les liens de sites web et les documents ramenés par un data connector. Les fichiers uploadés manuellement — tout ce que produit la route API — sont explicitement hors périmètre. (L'application desktop surveille bien les fichiers locaux, toutes les 10 minutes, mais seulement tant qu'elle est ouverte.) D'où le fichier d'état que le script conserve. Le relancer, c'est la synchronisation : **Garder le workspace à jour** 1. **Un nouvel article** — son chemin n'est pas dans `.anythingllm-indexed`, donc il est uploadé et embeddé 2. **Un article modifié** — son checksum ne correspond plus, donc l'ancienne copie est d'abord supprimée, puis la nouvelle uploadée ; sans cette suppression, le workspace répondrait à partir de deux versions du même texte 3. **Tout le reste** — checksum inchangé, sauté sans aucun appel HTTP Les chiffres plaident mieux que moi : indexer les 248 articles depuis zéro a pris **1 minute 34**, et le second passage — où rien n'avait changé — a pris **0,26 seconde** et fait exactement un appel HTTP, la recherche du workspace. C'est assez peu coûteux pour l'accrocher à chaque publication : une ligne dans votre script de déploiement, un hook `post-commit`, ou une entrée cron nocturne. > **Un workspace par projet:** > > Pour ma documentation dans `~/repositories`, je garde quand même un workspace par projet plutôt que de tout déverser dans un seul — ça garde l'espace vectoriel focalisé, donc une question sur le projet A n'est pas diluée par des chunks sans rapport du projet B. Le script prend le workspace cible via `ANYTHINGLLM_WORKSPACE`, donc la même boucle couvre les deux organisations. ## Chercher depuis le terminal {#search} Avec 248 articles embeddés, le bénéfice est à un seul `curl`. Exportez la clé une fois — je garde la mienne dans `~/.zshrc`, mais un simple `export` dans le shell fait aussi bien l'affaire — et interrogez le workspace : ```bash $ export ANYTHINGLLM_API_KEY=your-key-here $ curl -s -X POST http://localhost:3001/api/v1/workspace/blog/chat \ -H "Authorization: Bearer $ANYTHINGLLM_API_KEY" \ -H "Content-Type: application/json" \ -d '{"message":"Which articles cover devcontainers?","mode":"query"}' \ | jq -r '.textResponse' Here are the articles that cover devcontainers: 1. **[PHP development in a devcontainer with preinstalled code quality tools](https://www.avonture.be/blog/vscode-devcontainer)** (published 09/02/2024) - This article explains how to set up a PHP development environment using a devcontainer with preinstalled code quality tools. 2. **[Docker - Python devcontainer](https://www.avonture.be/blog/docker-python-devcontainer)** (published 30/10/2024) - This article guides you through setting up a VSCode devcontainer for Python development using Docker. 3. **[One Docker Image for Production and Devcontainers - The Clean Way](https://www.avonture.be/blog/docker-prod-devcontainer)** (published 13/10/2025) - This article discusses how to use a single Docker image for both production deployments and VS Code Devcontainers, emphasizing clean overrides and environment isolation. 4. **[Make your Quarto project Devcontainer-Ready — No More Setup Headaches](https://www.avonture.be/blog/quarto-devcontainer)** (published 03/11/2025) - This article explains how to make Quarto projects devcontainer-ready, using the `.devcontainer/devcontainer.json` file for configuration. 5. **[Level Up Your Presentations with Quarto, reveal.js, Decktape, Docker and DevContainers](https://www.avonture.be/blog/running-revealjs-with-docker)** (published 15/12/2025) - This article mentions using devcontainers for presentations with Quarto and reveal.js. 6. **[Running Docusaurus using Docker](https://www.avonture.be/blog/running-docusaurus-with-docker)** (published 11/11/2025) - This article discusses how devcontainers can be used to fine-tune environments, including VSCode extensions and editor settings. 7. **[Install a PHP Docker environment in a matter of seconds](https://www.avonture.be/blog/php-devcontainer)** (published 23/02/2024) - This article aims to create a development environment (devcontainer) for PHP. ``` Trois choses rendent cette réponse utile plutôt que simplement impressionnante : * **`"mode": "query"`** restreint le modèle à ce qu'il a réellement récupéré. L'autre valeur, `"chat"`, lui permet de se rabattre sur ses connaissances générales quand le workspace n'a rien de pertinent — et c'est exactement comme ça qu'on obtient une réponse assurée sur un article que vous n'avez jamais écrit. Pour chercher dans votre propre corpus, `query` est le réglage honnête. * **Les URLs sont réelles**, pas reconstruites. C'est le champ `chunkSource` dans le script : AnythingLLM copie les métadonnées préfixées par `link://` dans l'en-tête de chaque chunk, donc l'URL réelle voyage avec le texte jusque dans le contexte du modèle. Sans ça, vous obtenez des noms de fichiers, et un modèle laissé à deviner des URLs les inventera avec plaisir. * **`jq -r '.textResponse'`** suffit pour lire ; enlevez-le pour voir la charge utile complète, où `sources[]` vous donne chaque chunk récupéré avec son `title`, son `docSource` (le chemin dans le repo) et un `score` de similarité. ### `ai-blog-search` : le script portable {#ai-blog-search-the-portable-script} Taper ce `curl` chaque fois devient vite lassant, donc il vit dans un script — même résolution de clé/URL que l'indexeur, plus le formatage des sources : **anythingllm-search.sh** ```bash #!/usr/bin/env bash # # Ask the AnythingLLM 'blog' workspace a question and print the answer plus the # articles it came from, as clickable links. # # ai-blog-search which articles cover docker? # # Index first with .scripts/anythingllm-index.sh — this script only reads. set -o errexit set -o nounset set -o pipefail WORKSPACE="${ANYTHINGLLM_WORKSPACE:-blog}" if [[ "$#" -eq 0 ]]; then printf 'Usage: %s \n' "$(basename "${0}")" >&2 exit 1 fi question="$*" # Same resolution as the indexer: environment first, then the gitignored .env. if [[ -z "${ANYTHINGLLM_API_KEY:-}" && -f ".env" ]]; then ANYTHINGLLM_API_KEY="$(grep -E '^ANYTHINGLLM_API_KEY=' .env | head -1 | cut -d= -f2- | tr -d "\"'")" || true fi : "${ANYTHINGLLM_API_KEY:?Generate one in Settings > Tools > Developer API}" if [[ -z "${ANYTHINGLLM_URL:-}" ]]; then for candidate in "http://localhost:3001" "http://172.17.0.1:3001"; do if curl --silent --fail --max-time 2 "${candidate}/api/ping" >/dev/null 2>&1; then ANYTHINGLLM_URL="${candidate}" break fi done fi : "${ANYTHINGLLM_URL:?No AnythingLLM instance answered on port 3001}" # "query" keeps the model inside the retrieved documents; "chat" would let it # answer from its own general knowledge, which is how you get invented articles. # # The random sessionId matters more than it looks: without it every call lands # in the workspace's default thread, and the model reads its own previous # answers as context. One bad answer then repeats itself for every later # question, even after the underlying retrieval has been fixed. response="$(curl --silent --show-error \ --header "Authorization: Bearer ${ANYTHINGLLM_API_KEY}" \ --header "Content-Type: application/json" \ --request POST "${ANYTHINGLLM_URL}/api/v1/workspace/${WORKSPACE}/chat" \ --data "$(jq -nc --arg m "${question}" --arg s "cli-${RANDOM}${RANDOM}-$$" \ '{message: $m, mode: "query", sessionId: $s}')")" error="$(jq -r '.error // empty' <<<"${response}")" if [[ -n "${error}" && "${error}" != "null" ]]; then printf 'AnythingLLM returned an error: %s\n' "${error}" >&2 exit 1 fi printf '\n\033[1;36m❓ %s\033[0m\n\n' "${question}" jq -r '.textResponse' <<<"${response}" # OSC 8 hyperlinks make the title itself clickable in VS Code's terminal and # Windows Terminal; anywhere else (or when piped) fall back to the bare URL. if [[ -t 1 ]]; then print_source() { printf ' \033[1;32m•\033[0m \033]8;;%s\033\\\033[4m%s\033[0m\033]8;;\033\\\n' "${1}" "${2}" printf ' \033[2m%s\033[0m\n' "${3}" } else print_source() { printf ' • %s\n %s\n' "${2}" "${1}" } fi sources="$(jq -r ' [ .sources[]? | select(.chunkSource // "" | startswith("link://")) | { title, url: (.chunkSource | sub("^link://"; "")) } ] | unique_by(.url) | .[] | "\(.url)\t\(.title)"' <<<"${response}")" if [[ -n "${sources}" ]]; then printf '\n\033[1;33m📚 Sources the answer was built from\033[0m\n' while IFS=$'\t' read -r url title; do print_source "${url}" "${title}" "${url##*/}" done <<<"${sources}" else printf '\n\033[2mNo source document was cited.\033[0m\n' fi # Vector search ranks by similarity and stops at topN, so it will never # enumerate exhaustively: a couple of long articles can fill every slot. This # second pass is the opposite trade-off — a dumb, exact, complete scan of the # frontmatter. Together they answer "where did I write about X" properly. blog_dir="${BLOG_DIR:-blog}" # Function words, English and French — the vocabulary of asking a question # rather than of any topic. Rarity alone can't catch these: "content" and # "already" each legitimately appear in a handful of titles. stopwords=" about after algo already also always another anything article articles been before blog both content cover covers does each every find first from give have here how into just know like list made make mention mentions more most must need only other post posts really related same search some something such talk talked than that their them then there these they this those topic use used using very want what when where which while will with write written wrote your alors aussi autre avec avoir beaucoup cela cette comme contenu dans deja déjà donc dont elle encore est etre être fait faire ils leur mais mes mon nous ont parle parler parlé pour quel quelle quelles quels qui quoi sans sont sujet sur tous tout toute une vous " mapfile -t candidates < <(printf '%s' "${question}" | tr -c '[:alnum:]' '\n' | tr '[:upper:]' '[:lower:]' | awk 'length($0) >= 4' | sort -u | grep -vxF -f <(printf '%s' "${stopwords}" | tr -s '[:space:]' '\n' | grep .) || true) # Second filter, dictionary-free: drop any surviving word that is simply too # common to be a topic. "docker" matches half the blog and would drown the # result; "joomla" matches nine posts and is exactly what you asked about. total="$(find "${blog_dir}" -type f \( -name 'index.md' -o -name 'index.mdx' \) | wc -l)" ceiling=$(( total / 10 )) [[ "${ceiling}" -ge 3 ]] || ceiling=3 keywords=() matched="" for word in "${candidates[@]}"; do files="$(grep -rliE "^(title|slug|description|mainTag):.*${word}|^ - ${word}$" \ "${blog_dir}" --include='index.md' --include='index.mdx' || true)" count="$(grep -c . <<<"${files}" || true)" [[ -n "${files}" && "${count}" -le "${ceiling}" ]] || continue keywords+=("${word}") matched+="${files}"$'\n' done if [[ "${#keywords[@]}" -gt 0 ]]; then matches="$(sort -u <<<"${matched}" | grep . || true)" if [[ -n "${matches}" ]]; then printf '\n\033[1;33m🔎 Exact frontmatter matches (%s)\033[0m\n' "${keywords[*]}" while read -r file; do [[ -n "${file}" ]] || continue # sed, not awk -F': ' — plenty of titles contain a colon of their own. slug="$(sed -n 's/^slug: *//p' "${file}" | head -1 | sed 's/^"//; s/"$//')" title="$(sed -n 's/^title: *//p' "${file}" | head -1 | sed 's/^"//; s/"$//')" print_source "${SITE_URL:-https://www.avonture.be}/blog/${slug}" "${title}" "${slug}" done <<<"${matches}" fi fi printf '\n' ``` Branchez votre shell dessus, en exportant la clé une fois : ```zsh title="~/.zshrc" export ANYTHINGLLM_API_KEY="your-key-here" ai-blog-search() { (cd ~/repositories/blog && .scripts/anythingllm-search.sh "$@"); } ``` ```bash $ ai-blog-search Do I already have content about Joomla? ❓ Do I already have content about Joomla? Yes, you have content about Joomla. Here are the articles related to Joomla: 1. **Create your Joomla website using Docker - Part 1** (published 2023-12-22) - URL: [https://www.avonture.be/blog/docker-joomla](https://www.avonture.be/blog/docker-joomla) 2. **Create your Joomla website using Docker - Part 2** (published 2024-10-18) - URL: [https://www.avonture.be/blog/docker-joomla-part-2](https://www.avonture.be/blog/docker-joomla-part-2) 3. **Joomla - Run a SQL statement outside Joomla and display a nice HTML table** (published 2024-06-02) - URL: [https://www.avonture.be/blog/joomla-show-table](https://www.avonture.be/blog/joomla-show-table) These articles cover topics such as setting up a Joomla website using Docker and running SQL statements in Joomla. 📚 Sources the answer was built from • Create your Joomla website using Docker - Part 1 (published 2023-12-22) https://www.avonture.be/blog/docker-joomla • Create your Joomla website using Docker - Part 2 (published 2024-10-18) https://www.avonture.be/blog/docker-joomla-part-2 • Organize Your Docusaurus Content with a Custom Series Component (published 2025-09-09) https://www.avonture.be/blog/docusaurus-series • Joomla - Run a SQL statement outside Joomla and display a nice HTML table (published 2024-06-02) https://www.avonture.be/blog/joomla-show-table 🔎 Exact frontmatter matches (joomla) • FrankenPHP, a modern application server for PHP https://www.avonture.be/blog/frankenphp-docker-joomla • Create your Joomla website using Docker - Part 1 https://www.avonture.be/blog/docker-joomla • Update php.ini when using a Docker image https://www.avonture.be/blog/docker-php-ini • Joomla - delete tables from your database according to a certain prefix https://www.avonture.be/blog/joomla-db-kill-tables-prefix • Joomla - Run a SQL statement outside Joomla and display a nice HTML table https://www.avonture.be/blog/joomla-show-table • aeSecure - QuickScan - Free viruses scanner https://www.avonture.be/blog/aesecure-quickscan • Start Joomla with Docker in just a few clicks https://www.avonture.be/blog/docker-joomla-right-to-the-point • Create your Joomla website using Docker - Part 2 https://www.avonture.be/blog/docker-joomla-part-2 • Restore a Joomla backup using Docker https://www.avonture.be/blog/docker-joomla-restore-jpa ``` Deux décisions de conception là-dedans ont gagné leur place à la dure. **Un `sessionId` aléatoire à chaque appel.** Sans ça, chaque requête atterrit dans le thread par défaut du workspace et le modèle lit ses propres réponses précédentes comme contexte. J'ai perdu un temps sincèrement embarrassant sur un workspace qui persistait à dire qu'il n'avait aucun article WordPress — longtemps après que la récupération avait été corrigée et renvoyait les bons chunks. Il se citait *lui-même*, depuis la tentative ratée précédente. Une commande de recherche n'a rien à faire avec un historique de conversation. **Deux listes de résultats au lieu d'une.** Regardez l'exécution ci-dessus : la moitié sémantique nomme trois articles Joomla, la moitié `grep` en trouve neuf. Cet écart n'est pas un bug à corriger par réglage — la recherche vectorielle s'arrête au `topN` du workspace et classe par similarité, donc deux ou trois longs articles remplissent les places avec leurs propres chunks. Passer `topN` de 20 à 60 a ajouté exactement un résultat. Le script lance donc aussi un simple `grep` sur `title`/`slug`/`description`/tags. La recherche sémantique trouve ce que vous n'arriviez pas à formuler ; `grep` garantit que vous n'avez rien loupé. Afficher les deux est la réponse honnête. Transformer une phrase en termes de `grep` demande une astuce qui vaut la peine d'être volée : après avoir écarté les mots-outils évidents, le script écarte aussi tout mot survivant qui correspond à **plus de 10 % du blog**. « Docker » enterrerait tout sous la moitié du corpus ; « joomla » correspond à neuf articles et c'est précisément ce que vous demandiez. La rareté fait le travail qu'aucune liste de stopwords ne peut faire, et elle se fiche de la langue dans laquelle vous avez demandé. > **Le RAG n'est pas un index de recherche:** > > C'est l'ajustement mental le plus important. « Quels articles mentionnent X » est une question pour `grep` ou la recherche du site — exacte, complète, instantanée. Le RAG gagne sa place sur l'autre type de question : celle sur `chpwd` en haut de cet article, où vous vous souvenez de l'*idée* mais pas d'un seul mot que vous avez réellement utilisé, ou là où la réponse est enterrée dans un PDF que `grep` ne peut pas lire du tout. Attendre une énumération exhaustive d'un classement par similarité, c'est attendre la mauvaise chose du bon outil. ### La même chose sous forme de fonction zsh {#the-same-thing-as-a-zsh-function} Le script ci-dessus est la version portable — il tourne dans un devcontainer, en CI, dans un hook `post-commit`, partout où il y a `bash` et `jq`. Pour l'usage quotidien je préfère taper un mot, donc il existe aussi comme membre de la [famille `ai-*`](/blog/ollama-git-precommit) : déposez-le dans `~/.zsh/fns/`, et il s'enregistre auprès du dispatcher `ai` exactement comme le font `ai-review` et `ai-commit`. **\~/.zsh/fns/ai-blog-search.zsh** ```bash # ai-blog-search — ask your own blog a question and get the matching articles # back, as clickable links. # # The odd one out in this series: it doesn't call Ollama directly. It queries an # AnythingLLM workspace, which owns the vector index and calls Ollama itself for # both the embeddings and the answer. So the guard clause below is the # AnythingLLM counterpart of _ollama_check rather than a reuse of it. AI_COMMANDS[blog-search]="ai-blog-search — ask your blog a question (RAG over every published article)" AI_PARAMS[blog-search]="text" # Where the blog repository lives, used only by the exact-match pass. Without # it you still get the semantic answer — just not the grep safety net. : "${AI_BLOG_DIR:=$HOME/repositories/blog}" : "${AI_BLOG_SITE_URL:=https://www.avonture.be}" : "${AI_BLOG_WORKSPACE:=blog}" # _anythingllm_check — credentials and reachability guard. _anythingllm_check() { if [[ -z "${ANYTHINGLLM_API_KEY:-}" ]]; then echo "ai-blog-search: ANYTHINGLLM_API_KEY is not set — generate one under Settings > Tools > Developer API" >&2 return 1 fi local host="${ANYTHINGLLM_URL:-http://localhost:3001}" if ! curl --silent --max-time 2 "${host}/api/ping" >/dev/null 2>&1; then echo "ai-blog-search: cannot reach ${host} — is the AnythingLLM container running?" >&2 return 1 fi } # _ai_blog_link — one source line, hyperlinked when the terminal # is one (OSC 8) and a plain URL when the output is being piped. _ai_blog_link() { if [[ -t 1 ]]; then printf ' \e[1;32m•\e[0m \e]8;;%s\e\\\e[4m%s\e[0m\e]8;;\e\\\n' "$1" "$2" printf ' \e[2m%s\e[0m\n' "${1:t}" else printf ' • %s\n %s\n' "$2" "$1" fi } # The alias at the bottom of this file survives in the running shell, so on a # re-source ("source ~/.zshrc") it would shadow the name below and the function # definition would die with "parse error near '()'". Drop it first. (( $+aliases[ai-blog-search] )) && unalias ai-blog-search ai-blog-search() { local question="$*" if [[ -z "$question" ]]; then echo "usage: ai-blog-search <your question>" >&2 return 1 fi _anythingllm_check || return 1 local host="${ANYTHINGLLM_URL:-http://localhost:3001}" # "query" confines the model to the retrieved excerpts; "chat" would let it # answer from general knowledge and describe articles that don't exist. # # The random sessionId is not cosmetic: without one every call lands in the # workspace's default thread, the model reads its own previous answers as # context, and a single wrong answer repeats itself for every later question. local response response=$( jq -nc --arg m "$question" --arg s "zsh-${RANDOM}${RANDOM}-$$" \ '{message: $m, mode: "query", sessionId: $s}' \ | curl --silent --show-error --data-binary @- \ --header "Authorization: Bearer ${ANYTHINGLLM_API_KEY}" \ --header "Content-Type: application/json" \ "${host}/api/v1/workspace/${AI_BLOG_WORKSPACE}/chat" ) local error error=$(print -r -- "$response" | jq -r '.error // empty') if [[ -n "$error" ]]; then echo "ai-blog-search: ${error}" >&2 return 1 fi printf '\n\e[1;36m❓ %s\e[0m\n\n' "$question" print -r -- "$response" | jq -r '.textResponse' local sources sources=$(print -r -- "$response" | jq -r ' [ .sources[]? | select(.chunkSource // "" | startswith("link://")) | {title, url: (.chunkSource | sub("^link://"; ""))} ] | unique_by(.url) | .[] | "\(.url)\t\(.title)"') if [[ -n "$sources" ]]; then printf '\n\e[1;33m📚 Sources the answer was built from\e[0m\n' local line for line in ${(f)sources}; do _ai_blog_link "${line%%$'\t'*}" "${line#*$'\t'}" done fi _ai_blog_grep "$question" } # "?" and "*" are glob characters in zsh, and a natural question ends in one. # Without this, "ai-blog-search which posts cover Joomla?" dies with "no matches # found" before the function is even called. Declared after the function, since # zsh refuses to define a function whose name is an existing alias. alias ai-blog-search='noglob ai-blog-search' # _ai_blog_grep <question> — the exact half of the answer. # # Vector search ranks by similarity and stops at the workspace's topN, so it # never enumerates: two long articles can fill every slot. This pass is the # opposite trade-off — dumb, exact and complete. _ai_blog_grep() { [[ -d "${AI_BLOG_DIR}" ]] || return 0 local question="$1" # Function words, English and French: the vocabulary of asking a question # rather than of any topic. Rarity alone can't catch these — "content" and # "already" each legitimately appear in a handful of titles. local stopwords="about after already also always another anything article articles been before blog both content cover covers does each every find first from give have here into just know like list made make mention mentions more most must need only other post posts really related same search some something such talk talked than that their them then there these they this those topic used using very want what when where which while will with write written wrote your alors aussi autre avec avoir beaucoup cela cette comme contenu dans deja donc dont elle encore est etre fait faire ils leur mais mes mon nous ont parle parler pour quel quelle quelles quels qui quoi sans sont sujet sur tous tout toute une vous" local -a candidates candidates=(${(u)${(s: :)${(L)question//[^[:alnum:]]/ }}}) # Second filter, dictionary-free: drop any surviving word too common to be a # topic. "docker" matches half the blog; "joomla" matches nine posts. local total ceiling total=$(find "${AI_BLOG_DIR}/blog" -type f \( -name 'index.md' -o -name 'index.mdx' \) | wc -l) ceiling=$(( total / 10 )) (( ceiling >= 3 )) || ceiling=3 local -a keywords matched local word files count for word in $candidates; do (( ${#word} >= 4 )) || continue [[ " ${stopwords} " == *" ${word} "* ]] && continue files=$(grep -rliE "^(title|slug|description|mainTag):.*${word}|^ - ${word}\$" \ "${AI_BLOG_DIR}/blog" --include='index.md' --include='index.mdx' 2>/dev/null) [[ -n "$files" ]] || continue count=$(print -r -- "$files" | grep -c .) (( count <= ceiling )) || continue keywords+=("$word") matched+=(${(f)files}) done (( ${#keywords} > 0 )) || return 0 printf '\n\e[1;33m🔎 Exact frontmatter matches (%s)\e[0m\n' "${keywords[*]}" local file slug title for file in ${(u)matched}; do # sed, not awk -F': ' — plenty of titles contain a colon of their own. slug=$(sed -n 's/^slug: *//p' "$file" | head -1 | sed 's/^"//; s/"$//') title=$(sed -n 's/^title: *//p' "$file" | head -1 | sed 's/^"//; s/"$//') _ai_blog_link "${AI_BLOG_SITE_URL}/blog/${slug}" "$title" done printf '\n' } ``` Enregistrez-le sous `~/.zsh/fns/ai-blog-search.zsh`, à côté du `_ollama.zsh` partagé par la série, qui déclare le registre `AI_COMMANDS` auquel ce fichier s'ajoute. Les deux, ainsi que les trois lignes de `~/.zshrc` qui chargent le dossier, sont dans [les étapes d'installation d'ai-test](/blog/ollama-test-generator#installing-it). Ouvrez ensuite un nouveau terminal, et `ai-blog-search` est prêt. Voici un exemple d'utilisation : ![Searching for articles about Code Quality](./images/ai-blog-search.webp) C'est l'intrus de cette série, et ça vaut la peine de dire pourquoi : toutes les autres fonctions `ai-*` appellent `_ollama_query`, alors que celle-ci parle à AnythingLLM — qui possède l'index vectoriel et appelle Ollama lui-même, pour les embeddings et pour la réponse. Elle apporte donc son propre garde-fou `_anythingllm_check` plutôt que de réutiliser `_ollama_check`, et déclare `AI_PARAMS[blog-search]="text"` pour que le menu `ai` demande la question avant de l'exécuter. > **Le détail zsh qui vous coûte un après-midi:** > > `?` et `*` sont des caractères de glob, et une question naturelle se termine par l'un des deux. Sans l'`alias ai-blog-search='noglob ai-blog-search'` en bas de ce fichier, `ai-blog-search which posts cover Joomla?` meurt avec `zsh: no matches found` avant même d'entrer dans la fonction. L'alias doit aussi venir *après* la définition de la fonction — zsh développe les alias au moment du parsing, donc il refuse de définir une fonction dont le nom est déjà un alias. Et comme cet alias survit ensuite dans votre shell, le fichier le supprime à nouveau à l'entrée (`(( $+aliases[ai-blog-search] )) && unalias ai-blog-search`) ; sans ce garde-fou, le fichier marche une fois puis casse chaque `source ~/.zshrc` suivant avec `parse error near '()'`. Deux implémentations d'une même idée, c'est un vrai coût, alors soyez délibéré : le script bash est l'outil du repo et la seule source de vérité pour la logique ; la fonction zsh est le véhicule du quotidien, et reste autonome parce que toute la promesse de cette série est « déposez le fichier et ça marche ». Si vous n'interrogez jamais que depuis le repository du blog, prenez la version bash et sautez entièrement cette section. À ce stade, tout — les documents eux-mêmes, leurs embeddings, les fichiers LanceDB — vit dans le volume `anythingllm_storage`, sur le PC de la maison. Très bien pour cette machine. Pas bien du tout pour ce qui suit. ## Partie 2 — Documents sur une machine, GPU sur une autre {#two-machines} Voici la contrainte qui change tout : ma vraie documentation — ce qui est sous `~/repositories` — vit sur mon PC de **travail**, et elle y reste. Pas synchronisée, pas copiée, pas uploadée ailleurs par confort. Ça écarte l'option la plus simple, qui serait « il suffit d'ouvrir `http://192.168.0.218 : 3001` depuis le navigateur de mon PC de travail et d'uploader les fichiers là ». Techniquement ça marcherait — mais uploader un fichier sur cette page envoie son contenu par le réseau vers le container du PC de la maison, où AnythingLLM stocke le texte parsé, les embeddings vectoriels et une copie en cache, à l'intérieur d'`anythingllm_storage`, sur un disque qui n'est pas le mien au bureau. C'est exactement le genre de copie que j'ai exclu. Le navigateur n'est que la fenêtre ; les documents eux-mêmes atterrissent là où le **serveur** derrière cette page tourne réellement. La solution découle donc directement de là : faire tourner le **serveur** AnythingLLM lui-même sur le PC de travail — même container Docker, même `compose.yaml` — et ne solliciter le PC de la maison que pour la seule chose qui ne touche pas au contenu des documents : le modèle qui fait le vrai travail de réflexion. ```text Work PC (Docker) Home PC ┌─────────────────────────┐ ┌──────────────────────┐ │ anythingllm container │ inference │ ollama container │ │ - documents │ ────────────► │ - qwen2.5:14b │ │ - vectors (LanceDB) │ ◄──────────── │ - mxbai-embed-large │ │ - STORAGE_DIR │ tokens back │ - 24GB VRAM │ └─────────────────────────┘ └──────────────────────┘ ``` Seuls les prompts et les chunks de texte récupérés nécessaires pour y répondre traversent le réseau pour cette unique requête — rien n'est stocké de l'autre côté. ### Ce qui change réellement dans le fichier compose {#what-actually-changes-in-the-compose-file} Rien de structurel — c'est exactement le même `compose.yaml` que dans la partie 1, qui tourne via Docker Desktop sur le PC de travail à la place. La seule chose qui change, c'est l'adresse vers laquelle pointent `OLLAMA_BASE_PATH` et `EMBEDDING_BASE_PATH`, et cette adresse dépend de l'endroit où se trouve physiquement le PC de travail : ```yaml title="compose.yaml — the two lines that change" - OLLAMA_BASE_PATH=http://192.168.0.218:11434 # same LAN, e.g. working from home - EMBEDDING_BASE_PATH=http://192.168.0.218:11434 ``` Quand le PC de travail est sur le même réseau domestique, c'est tout — `192.168.0.218` est joignable exactement comme dans [l'article précédent](/blog/accessing-ollama-across-your-local-network), et le reste de cette installation est un copier-coller de la partie 1. ### Et quand il n'est pas à la maison {#and-when-it-isnt-home} Cinq jours par semaine, ce PC de travail n'est pas du tout sur le LAN de la maison. Une simple adresse IP dans une plage privée `192.168.x.x` n'est tout bonnement pas joignable depuis le bureau — c'est tout l'intérêt d'un réseau privé. > **La solution pratique : un VPN maillé:** > > Des outils comme Tailscale ou WireGuard donnent à chaque appareil — PC de la maison inclus — une adresse stable qui reste joignable quel que soit le réseau sur lequel il se trouve, sans ouvrir de ports sur votre routeur domestique vers Internet. Installez-le sur le PC de la maison, installez-le sur le PC de travail, et le même schéma `OLLAMA_BASE_PATH` ci-dessus continue de fonctionner — simplement avec l'adresse attribuée par le VPN au lieu de `192.168.0.218`. Cette configuration mérite son propre article plutôt qu'un paragraphe expédié ici, alors considérez ceci comme une bande-annonce. > **Ollama n'a aucune authentification propre:** > > Exposer `192.168.0.218:11434` à l'intérieur d'un LAN privé est une chose ; le rendre joignable de n'importe où via un VPN est un cran d'exposition supplémentaire. L'API d'Ollama ne demande pas de mot de passe — quiconque peut atteindre ce port peut interroger vos modèles. Un VPN maillé comme Tailscale limite cette surface aux appareils que vous avez explicitement autorisés, et c'est précisément pour ça que c'est la réponse recommandée ici plutôt qu'une redirection de port sur le routeur. ### Recréer les workspaces, cette fois pour de bon {#recreate-the-workspaces-this-time-for-real} Le container tournant maintenant sur le PC de travail, refaites l'onboarding de la partie 1 — même LLM provider, même modèle d'embedding, pointant vers l'adresse qui atteint actuellement le PC de la maison. Puis commencez à créer un workspace par repository sous `~/repositories`, en alimentant chacun avec sa propre documentation. Cette fois, les documents ne quittent jamais la machine devant laquelle vous êtes assis. La seule chose qui fait l'aller-retour vers le PC de la maison, c'est une question et la poignée de chunks de texte nécessaires pour y répondre — exactement la répartition que je voulais au départ. ## Sous le capot (sautez si vous voulez juste l'utiliser) {#under-the-hood-skip-this-if-you-just-want-to-use-it} Tout ce qui précède suffit pour faire tourner ça. Ce qui suit est le seul morceau des entrailles d'AnythingLLM qui vaille la peine d'être connu, parce qu'il explique pourquoi le script d'indexation fait quelque chose qui paraît sinon arbitraire. ### D'où vient cette mauvaise date {#where-that-wrong-date-comes-from} Trois champs, et seulement trois, atteignent le modèle. Quoi qu'ait accepté l'API au moment de l'upload, AnythingLLM construit cet en-tête et le préfixe à **chaque** chunk : ```bash <document_metadata> sourceDocument: Tabnine - AI Autocomplete & Chat for Javascript, Python, ... (published 2024-03-02) published: 8/10/2026, 9:56:01 AM source: https://www.avonture.be/blog/vscode-tabnine </document_metadata> ``` `published` vient du collector : `published: createdDate(fullFilePath)`, obtenu par un stat sur le fichier temporaire qu'il vient de recevoir. Pas la `date` de votre frontmatter — le jour où vous avez lancé l'indexeur. Et `metadata.published` est silencieusement ignoré à l'upload, donc il n'y a aucun moyen de le corriger. Il ne reste donc exactement que deux leviers, et c'est pourquoi le script actionne les deux : le titre porte la vraie date ajoutée sous la forme `(published YYYY-MM-DD)`, et `chunkSource` porte l'URL réelle parce qu'un préfixe `link://` est la seule chose qui se transforme en ligne `source:`. Tout le reste de ce que vous passez à l'upload — `docSource`, `description`, `docAuthor` — est stocké, récupérable via l'API, et jamais montré au modèle. C'est aussi pourquoi le system prompt n'est pas une décoration optionnelle. Deux dates contradictoires se trouvent dans cet en-tête sur chaque chunk, et rien dans les données ne dit laquelle croire. ## Points clés à retenir {#key-takeaways} La seule chose à retenir en prose : **les documents vivent là où tourne le container, pas là où se trouve le navigateur.** Tout le reste de cette page en découle — où le déployer, pourquoi il n'y a pas de case « indexer ce dossier », et pourquoi la voie de masse est l'API plutôt que l'interface graphique. Le reste est de la matière à consulter, et voici les valeurs qui m'ont coûté un après-midi chacune : **Réglages qui cassent tout silencieusement quand ils sont faux** * `EMBEDDING_MODEL_MAX_CHUNK_LENGTH=400` — doit rester sous le contexte de l'embedder (`ollama show <model>` ; 512 pour `mxbai-embed-large`). Au-dessus, **tous** les embeddings échouent et le workspace reste vide sans aucune erreur dans l'interface * `OLLAMA_EMBEDDING_BATCH_SIZE=16` — pure vitesse ; la valeur par défaut de 1 envoie un chunk par appel HTTP * `topN` = 20, dans *Workspace Settings → Vector Database* — la valeur par défaut de 4 répond aux questions portant sur tout le corpus à partir d'un seul article. Au-delà d'environ 20, ça n'apporte presque rien * **System prompt du workspace** — pas optionnel : c'est lui qui tranche entre les deux dates contradictoires présentes dans chaque en-tête de chunk, et qui corrige le formatage des dates une fois pour toutes * `"mode": "query"` dans chaque appel API — `"chat"` laisse le modèle répondre depuis ses connaissances générales et inventer des articles que vous n'avez jamais écrits * **Un `sessionId` neuf à chaque appel** — sans ça, le modèle lit ses propres réponses précédentes, et une mauvaise réponse se répète à l'infini * `ANYTHINGLLM_API_KEY` (*Settings → Tools → Developer API*), plus `AI_BLOG_DIR` et `AI_BLOG_SITE_URL` pour la fonction zsh ## Conclusion {#conclusion} Ce qui a commencé par « je n'arrive jamais à retrouver cette config que j'ai notée quelque part » s'est transformé en une quantité vraiment modeste de travail Docker : un `compose.yaml`, pointé vers un modèle que je faisais déjà tourner pour autre chose. Ce qui vaut la peine d'être retenu, ce n'est pas le YAML — c'est la prise de conscience qu'un bouton « upload » d'interface web est une requête vers l'endroit où vit son serveur, et c'est exactement le détail qui décide si vos documents restent en place ou migrent discrètement vers une machine que vous n'aviez pas prévue. Une fois ça compris, l'installation sur deux machines n'était pas un compromis ; c'était juste la forme évidente dès lors que le GPU et les documents ne vivent pas au même endroit. Maintenant, chaque fois que je me surprends à vouloir faire `grep -r` en espérant, j'interroge le workspace à la place. La leçon inconfortable, c'est l'autre : une stack RAG ne peut pas vous dire qu'elle est cassée. La mienne a indexé 248 articles sans une seule erreur, puis a nié avoir quoi que ce soit sur WordPress. Si vous ne retenez qu'une habitude de cet article, retenez le test de cosinus de trente secondes avant de faire confiance à un embedder — et si vous voulez que votre terminal réponde davantage à vos questions en local, la [famille `ai-review`, `ai-secrets` et `ai-commit`](/blog/ollama-git-precommit) est d'où vient celle-ci. Le VPN maillé qui fait fonctionner l'installation sur deux machines depuis le bureau, c'est l'article que je vous dois ensuite. --- <!-- canonical-url: https://www.avonture.be/fr/blog/ollama-ai-docs published: 2026-09-21 This is a static plain-Markdown mirror generated at build time. Visit the canonical URL above for the fully rendered page, with images and interactive components. --> # ai-translate & ai-summarize : des documents confidentiels, traités 100 % en local > Deux fonctions zsh qui traduisent ou résument un document Word, PDF, PowerPoint ou Excel sans qu'un seul octet ne quitte votre machine — Docling extrait le texte en local, un modèle Ollama local fait la traduction ou le résumé. Pensé pour le document que vous ne colleriez jamais dans un outil de traduction cloud. --- ![ai-translate & ai-summarize : des documents confidentiels, traités 100 % en local](/img/v2/ai_doc.webp) **TL;DR** Cet article ajoute `ai-translate` et `ai-summarize` à la série « Ollama daily use ». Les deux acceptent un fichier `.pdf`, `.docx`, `.pptx`, `.xlsx` ou `.html` — extrait en Markdown par [Docling](/blog/docling) — ou directement un simple fichier `.md`/`.txt`, puis passent le texte au modèle Ollama local pour le traduire ou le condenser en points clés. Tout l'aller-retour reste sur votre machine : pas d'API de traduction cloud, aucun upload de document nulle part. C'est précisément l'intérêt pour un contrat, un document RH, ou tout ce que vous hésiteriez à coller dans Google Translate. Je travaille dans un bureau où un `.docx` atterrit dans ma boîte mail en néerlandais, en anglais ou en français. Pour de très bonnes raisons, j'ai parfois besoin de traduire d'une langue vers une autre. Quand ce n'est pas confidentiel du tout, le réflexe de tout le monde est d'ouvrir le navigateur, de coller dans un outil de traduction, et d'espérer que personne ne s'offusque qu'un contrat client vienne de transiter par les serveurs d'un tiers. Personne ne m'a jamais explicitement dit de ne pas le faire. C'est exactement le genre de chose pour laquelle vous ne devriez pas avoir besoin d'une permission. ## Démo — ai-translate {#demo--ai-translate} Traduction d'un contrat depuis n'importe quelle langue vers le français : ```bash $ ai-translate contract.docx French → Converting 'contract.docx' with docling-convert... ## Note de livraison trimestrielle *Périmètre, jalons et résiliation — diffusion interne uniquement* ### Article 1 À la suite de la revue trimestrielle tenue le 3 septembre, la présente note consacre le périmètre convenu, les jalons de livraison ainsi que les conditions de résiliation applicables à l'une ou l'autre des parties. Elle se substitue au projet diffusé en juillet. En cas de conflit entre une disposition des présentes et l'accord-cadre, c'est l'accord-cadre qui prévaut, sauf pour l'échéancier de paiement défini à l'article trois, qui est conçu pour le remplacer intégralement. ### Article 2 Le prestataire livrera la première tranche dans un délai de six semaines à compter de la signature, et chaque tranche subséquente à des intervalles n'excédant pas quatre semaines. Une tranche est réputée livrée dès qu'elle a été déployée dans l'environnement de staging et que les tests de réception consignés dans l'annexe B ont été validés. La livraison hors heures ouvrables ne réduit pas le délai de vérification, qui court pendant cinq jours ouvrables à compter de l'avis de livraison. ### Article 3 Les factures sont payables trente jours après réception. Le client peut retenir le paiement d'une tranche ayant échoué aux tests de réception, mais uniquement pour cette tranche et seulement jusqu'à la régularisation du défaut. Les intérêts de retard courent au taux légal. Aucune des parties ne peut compenser les sommes dues au titre des présentes avec celles dues au titre de tout autre accord sans le consentement écrit de l'autre partie. ### Article 4 Toute partie peut résilier le contrat sur préavis écrit de soixante jours. En cas de résiliation, le prestataire devra remettre tout avancement, y compris le code source, la configuration de build et la documentation, sous une forme permettant à un tiers compétent de poursuivre le travail. Le client devra payer les travaux achevés et acceptés jusqu'à la date de résiliation. | Tranche | Échéance | Statut | | ------ | --- | --- | | Première | 6 semaines | Livrée | | Deuxième | 10 semaines| En cours | ``` Et le même vers le néerlandais : ```bash $ ai-translate contract.docx Dutch → Converting 'contract.docx' with docling-convert... ## Kwartaal Leveringsnota *Reikwijdte, mijlpalen en beëindiging — uitsluitend voor intern gebruik* ### Artikel 1 Naar aanleiding van het kwartaaloverleg van 3 september houdt deze nota de overeengekomen reikwijdte, de leveringsmijlpalen en de voorwaarden vast onder welke elke partij kan opzeggen. Deze nota vervangt de in juli verspreide conceptversie. Indien een bepaling in deze nota strijdig is met de raamovereenkomst, heeft de raamovereenkomst voorrang, met uitzondering van de in afdeling drie genoemde betalingsregeling, die geheel ter vervanging daarvan is bedoeld. ### Artikel 2 De leverancier levert de eerste levering binnen zes weken na ondertekening, en elke daaropvolgende levering in intervallen van maximaal vier weken. Een levering wordt geacht geleverd te zijn wanneer deze is geïmplementeerd in de staging-omgeving en de acceptatietests in appendix B zijn geslaagd. Levering buiten werkuren verkort het acceptatievenster niet, dat vijf werkdagen duurt te rekenen vanaf de kennisgeving van levering. ### Artikel 3 Facturen zijn betaalbaar binnen dertig dagen na ontvangst. De opdrachtgever mag de betaling voor een levering die niet is geaccepteerd, inhouden, maar uitsluitend voor die specifieke levering en alleen totdat het gebrek is verholpen. Rentevergoeding bij late betaling loopt tegen het wettelijk rentepercentage. Geen van beide partijen mag vorderingen uit deze nota verrekenen met vorderingen uit enige andere overeenkomst zonder schriftelijke toestemming van de andere partij. ### Artikel 4 Elke partij kan opzeggen met een opzegtermijn van zestig dagen schriftelijk. Bij beëindiging draagt de leverancier alle werk in uitvoering, inclusief broncode, buildconfiguratie en documentatie, over in een vorm die door een kundige derde kan worden voortgezet. De opdrachtgever betaalt voor het tot de datum van beëindiging voltooide en geaccepteerde werk. | Levering | Te leveren | Status | | ------ | --- | --- | | Eerste | 6 weken | Geleverd | | Tweede | 10 weken | In uitvoering | ``` C'est un contrat de prestation fictif, converti depuis `.docx` et traduit en français/néerlandais, avec chaque titre et chaque terme en gras intact — le genre de document que je ne collerais vraiment pas dans un traducteur en ligne. Même document, pour que vous puissiez l'essayer vous-même avant d'adapter l'une ou l'autre fonction à vos propres fichiers : contract.docx — le document source: `./files/contract.docx` ## Démo — ai-summarize {#demo--ai-summarize} Le même contrat, condensé en quatre points clés plutôt que traduit : ```bash $ ai-summarize contract.docx 4 → Converting 'contract.docx' with docling-convert... - First increment delivery due within 6 weeks of signature; subsequent increments delivered every ≤4 weeks, requiring staging deployment and Appendix B test completion within a 5-working-day acceptance window. - Invoices payable 30 days from receipt; payment withholding permitted only for failed increments until remedied, with late interest accruing at the statutory rate. - Either party may terminate with 60 days written notice, mandating handover of all work-in-progress, source code, and documentation, plus immediate payment for completed and accepted work. - Framework agreement prevails except for the payment schedule, which is entirely replaced; first increment is delivered, second increment is due at 10 weeks. ``` Les délais de livraison, les conditions de paiement, la clause de résiliation et les parties du contrat-cadre qui sont remplacées — les quatre points qui comptent, sortis sans lire le document complet. ## Réutiliser Docling pour l'extraction {#reusing-docling-for-extraction} Les deux fonctions ci-dessous partagent une tâche avant même de parler à Ollama : transformer un document en texte brut. J'ai déjà construit cette brique dans [mon article sur Docling](/blog/docling) — `docling-convert file.docx` produit `file.md` en local, avec accélération GPU si vous l'avez configuré ainsi. Plutôt que de dupliquer cette logique deux fois, elle vit dans un petit helper : Pas besoin de copier quoi que ce soit pour l'instant : les fichiers `.zsh` montrés dans cet article sont là pour être lus, et [L'installer](#installing-it) à la fin les crée tous dans `~/.zsh/fns/` pour vous. **\~/.zsh/fns/\_ai-docs.zsh** ```bash # Shared document-to-text extraction for ai-translate and ai-summarize. # .pdf/.docx/.pptx/.xlsx/.html go through docling-convert (see /blog/docling) # first; .md/.markdown/.txt files are read directly, no conversion needed. # # Leading underscore: loads after _ollama.zsh (still alphabetically before # ai-summarize.zsh and ai-translate.zsh), so _ollama_query is always ready. _ai_extract_text() { local file="$1" local ext="${file:e:l}" case "$ext" in md|markdown|txt) cat "$file" ;; pdf|docx|pptx|xlsx|html|htm) if ! command -v docling-convert >/dev/null 2>&1; then echo "_ai_extract_text: docling-convert not found — see /blog/docling to set it up" >&2 return 1 fi local dir filename base mdfile dir="${file:h}" filename="${file:t}" base="${filename%.*}" mdfile="${dir}/${base}.md" echo "→ Converting '$file' with docling-convert..." >&2 ( cd "$dir" && docling-convert "$filename" >/dev/null 2>&1 ) || { echo "_ai_extract_text: docling-convert failed on '$file'" >&2 return 1 } cat "$mdfile" ;; *) echo "_ai_extract_text: unsupported extension .$ext (expected pdf/docx/pptx/xlsx/html/md/txt)" >&2 return 1 ;; esac } ``` > **Les fichiers Markdown et texte sautent la conversion:** > > Si vous avez déjà un fichier `.md` ou `.txt`, `_ai_extract_text` fait juste un `cat` dessus — pas d'appel à Docling, pas de dépendance. L'étape de conversion ne se déclenche que pour les vrais formats bureautiques. > **Pourquoi le subshell autour de docling-convert:** > > `docling-convert` (de l'article précédent) monte le *répertoire courant* dans son container et attend un nom de fichier relatif à celui-ci — il n'a pas été conçu pour résoudre un chemin absolu arbitraire. `( cd "$dir" && docling-convert "$filename" )` l'exécute depuis le dossier du document, dans un subshell, de sorte que votre répertoire de travail réel reste intact au retour de la fonction. ## ai-translate {#ai-translate} **\~/.zsh/fns/ai-translate.zsh** ```bash # ai-translate <file> [language] — translate a document into the given # language (default: French), entirely locally. Accepts .pdf/.docx/.pptx/ # .xlsx/.html (via docling-convert) or plain .md/.txt files directly. AI_COMMANDS[translate]="ai-translate <file> [language] — translate a document locally (default: French)" ai-translate() { local file="$1" local lang="${2:-French}" if [[ -z "$file" || ! -f "$file" ]]; then echo "Usage: ai-translate <file> [target-language]" >&2 return 1 fi local text text=$(_ai_extract_text "$file") || return 1 local prompt="Translate the following document into $lang. Preserve the Markdown structure exactly — headings, lists, tables, emphasis. Output only the translated document, no commentary and no notes about the translation. $text" _ollama_query "$prompt" } ``` On extrait, puis on demande au modèle de traduire vers la langue passée en second argument (le français par défaut — celle dont j'ai le plus souvent besoin), avec une instruction explicite de préserver la structure Markdown pour que les titres, listes et tableaux survivent à l'aller-retour au lieu de s'écrouler en un pavé de texte. ## ai-summarize {#ai-summarize} **\~/.zsh/fns/ai-summarize.zsh** ```bash # ai-summarize <file> [points] — summarize a document in N bullet points # (default: 5), entirely locally. Accepts .pdf/.docx/.pptx/.xlsx/.html (via # docling-convert) or plain .md/.txt files directly. AI_COMMANDS[summarize]="ai-summarize <file> [points] — summarize a document locally (default: 5 points)" ai-summarize() { local file="$1" local points="${2:-5}" if [[ -z "$file" || ! -f "$file" ]]; then echo "Usage: ai-summarize <file> [number-of-points]" >&2 return 1 fi local text text=$(_ai_extract_text "$file") || return 1 local prompt="Summarize the following document in exactly $points bullet points, written in the same language as the document. Prioritize decisions, numbers, deadlines and action items over generic description. Output only the bullet points, nothing else. $text" _ollama_query "$prompt" } ``` Même étape d'extraction, prompt différent : un nombre fixe de points clés (5 par défaut), avec la consigne explicite de privilégier les décisions, les chiffres, les échéances et les actions à mener plutôt qu'une description générique — et de répondre dans la langue du document source, plutôt que de basculer silencieusement en anglais. ## Enregistrées dans le menu ai {#registered-in-the-ai-menu} Deux lignes — `AI_COMMANDS[translate]=...` et `AI_COMMANDS[summarize]=...` — et les deux sont accessibles via `ai translate` / `ai summarize`, ou directement via `ai-translate` / `ai-summarize`, aux côtés de toutes les autres fonctions de cette série. Les deux fonctions appellent `_ollama_query` et s'enregistrent dans `AI_COMMANDS`, qui vivent dans la base partagée `_ollama.zsh` introduite dans [ai-test](/blog/ollama-test-generator). Si vous avez déjà suivi un article précédent de cette série, elle est déjà dans `~/.zsh/fns/` et sourcée par votre autoloader — rien de plus à faire. Vous commencez ici ? C'est l'un des fichiers créés dans [L'installer](#installing-it) ci-dessous. > **Docling et Ollama ne doivent pas être sur la même machine:** > > `docling-convert` tourne toujours en local, sans GPU requis — convertir un document en Markdown fonctionne sur n'importe quel portable. Traduire ou résumer nécessite par contre un serveur Ollama joignable quelque part : les deux fonctions se connectent à `OLLAMA_HOST`, même configuration que dans [accéder à Ollama depuis votre réseau local](/blog/accessing-ollama-across-your-local-network) — un LAN partagé, ou un VPN/tunnel entre deux réseaux distincts. > > ```mermaid > flowchart TD > subgraph C["💻 Your computer"] > F["📄 .docx / .pdf / …"] --> D["🐳 docling-convert"] > D --> T["📝 plain text (.md)"] > end > > subgraph S["🧠 Ollama server"] > O["Ollama"] > end > > T -->|OLLAMA_HOST| O > O -->|"translated / summarized,\nback on your computer"| R["✅ result"] > > style D fill:#4a90d9,color:#fff > style O fill:#27ae60,color:#fff > ``` ## À retenir {#key-takeaways} **ai-translate / ai-summarize en bref** * **Extraction partagée** — `_ai_extract_text` dans `_ai-docs.zsh`, réutilisée par les deux fonctions * **Docling pour les formats bureautiques** — `.pdf`/`.docx`/`.pptx`/`.xlsx`/`.html` passent d'abord par `docling-convert` * **Markdown/texte sautent la conversion** — les fichiers `.md`/`.txt` sont lus directement * **Rien ne quitte la machine** — extraction et traduction/résumé tournent en local, de bout en bout * **S'enregistrent dans `ai`** — accessibles via `ai translate` et `ai summarize` ## L'installer {#installing-it} Tout tourne sur votre machine : zsh, `curl` et `jq` (qui construit la requête envoyée à Ollama), et un serveur Ollama que vous pouvez joindre — voyez [installing Ollama](/blog/ollama-installation) si vous n'en avez pas encore. Pour les fichiers `.pdf`, `.docx`, `.pptx`, `.xlsx` et `.html`, il vous faut aussi `docling-convert`, issu de [mon article sur Docling](/blog/docling). **1. Créez les fichiers.** Chaque fonction de cette série vit dans son propre fichier dans `~/.zsh/fns/`. La commande ci-dessous crée ce dossier et les quatre fichiers d'un coup — ou téléchargez le ZIP, ou créez chaque fichier à la main et collez son contenu. `_ollama.zsh` est la base que toute la série partage : si un article précédent vous l'a déjà donnée, la copie ici est le même fichier. **Project: \~/.zsh/fns** **\_ollama.zsh** ````bash # Foundation file for every "ai-*" function in this series. Named with a # leading underscore: the ~/.zsh/fns/*.zsh loader sources files in # alphabetical order, and "_" sorts before any letter — so this file is # always loaded first, no matter how many ai-* functions get added later. # # Provides: # - _ollama_check reachability guard for Ollama # - _ollama_query shared HTTP client (curl + jq) # - _ai_strip_fences removes the Markdown fences models add anyway # - _ai_bat resolves "bat" vs Debian's "batcat" # - _ai_confirm yes/no prompt # - _git_staged_diff validated staged-diff fetcher (shared by pre-commit functions) # - AI_COMMANDS registry each ai-* function adds itself to # - AI_PARAMS registry of interactive parameter types (file/language/number/text/none) # - _ai_prompt_* FZF/read helpers for interactive argument collection # - ai entry-point dispatcher / interactive menu typeset -gA AI_COMMANDS typeset -gA AI_PARAMS # --------------------------------------------------------------------------- # Ollama helpers # --------------------------------------------------------------------------- # _ollama_check — verify that the Ollama daemon is reachable. # Fails loudly so callers can bail early rather than watching curl hang. _ollama_check() { local host="${OLLAMA_HOST:-http://localhost:11434}" if ! curl --silent --max-time 2 "${host}/api/tags" >/dev/null 2>&1; then echo "ollama: cannot reach ${host} — is the Ollama container running?" >&2 return 1 fi } # _ollama_query <prompt> — send a prompt to the local model and print the response. # Assumes Ollama is reachable; call _ollama_check first if you need an early guard. _ollama_query() { local prompt="$1" local model="${OLLAMA_MODEL:-qwen3-coder:30b}" local host="${OLLAMA_HOST:-http://localhost:11434}" _ollama_check || return 1 # The prompt is piped into jq rather than passed with "--arg": Linux caps a # single argv entry at 128 KB (MAX_ARG_STRLEN), and a large staged diff or a # long source file sails straight through that ceiling — jq then dies with # "argument list too long". "-R -s" slurps stdin as one raw string, which jq # escapes exactly like --arg would. print -r -- "$prompt" \ | jq -Rs --arg model "$model" '{model: $model, prompt: ., stream: false}' \ | curl --silent "${host}/api/generate" --data-binary @- \ | jq -r '.response // empty' } # --------------------------------------------------------------------------- # Output helpers # --------------------------------------------------------------------------- # _ai_strip_fences <text> — return only the code, without the Markdown fences. # # Every prompt in this series ends with "no markdown fences" and models still # wrap their answer in ```language … ``` often enough that the output can't be # piped or pasted as-is. When at least one fence is present, only the fenced # regions are kept (which also drops the "Here is your test suite:" preamble); # when there is none, the text is returned untouched. _ai_strip_fences() { local text="$1" if [[ "$text" != *'```'* ]]; then print -r -- "$text" return 0 fi print -r -- "$text" | awk ' /^[[:space:]]*```/ { inside = !inside; next } inside { print } ' } # _ai_bat — print the name of the "bat" binary installed on this machine, if any. # Debian and Ubuntu ship the package as "batcat" because the "bat" name was # already taken by bacula-console-qt, so both spellings have to be probed. _ai_bat() { local candidate for candidate in bat batcat; do if command -v "$candidate" >/dev/null 2>&1; then print -- "$candidate" return 0 fi done return 1 } # _ai_relpath <path> — print <path> relative to the current folder when it sits # below it. Purely cosmetic: it keeps prompts readable instead of asking # "Save as /home/christophe/projects/…/tests/backup.bats?". _ai_relpath() { local path="${1:A}" print -- "${path#${PWD}/}" } # _ai_confirm <question> — ask a yes/no question; return 0 only on an explicit yes. _ai_confirm() { local answer print -n "${1} [y/N] " read -r answer [[ "$answer" == [yY]* ]] } # --------------------------------------------------------------------------- # Git helpers # --------------------------------------------------------------------------- # _git_staged_diff <caller> — validate the git context and return the staged diff. # Handles three early-exit cases (not a repo, nothing staged, oversized diff) # so every pre-commit function gets them for free without repeating the guards. # # Past AI_DIFF_MAX_CHARS the full diff is *not* returned: a diff of several # hundred kilobytes overflows the model's context window, and the answer that # comes back is worse than the one you get from a summary. The fallback keeps # the shape of the change — per-file stat, plus the file and hunk headers, # which carry the enclosing function names git puts after each "@@" — and # drops the line-by-line content. _git_staged_diff() { local caller="${1:-ai}" local max="${AI_DIFF_MAX_CHARS:-12000}" if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then echo "${caller}: not inside a git repository" >&2 return 1 fi local diff diff=$(git diff --staged) if [[ -z "$diff" ]]; then echo "${caller}: nothing staged — run 'git add' first" >&2 return 1 fi # "print -r" everywhere below: without it, print expands escape sequences and # a literal \n or \t inside the diff would come back mangled. if (( ${#diff} <= max )); then print -r -- "$diff" return 0 fi echo "${caller}: staged diff is large (${#diff} chars) — sending a structural summary instead of the full diff. Commit in smaller chunks for a more precise answer." >&2 local summary summary="$(git diff --staged --stat) --- FILE AND HUNK HEADERS ONLY (full diff omitted, ${#diff} chars) --- $(print -r -- "$diff" | grep -E '^(diff --git|new file|deleted file|rename (from|to)|@@)')" # Even the summary can exceed the ceiling on a very wide change. if (( ${#summary} > max )); then summary="${summary[1,$max]} […summary truncated…]" fi print -r -- "$summary" } # --------------------------------------------------------------------------- # Interactive argument helpers (used by the ai() dispatcher) # --------------------------------------------------------------------------- # _ai_prompt_file [label] — open an fzf file picker; print the chosen path. _ai_prompt_file() { local label="${1:-Select a file:}" local bat_bin preview='cat {}' bat_bin=$(_ai_bat) && preview="${bat_bin} --color=always {} 2>/dev/null || cat {}" if command -v fd >/dev/null 2>&1; then fd --type f | fzf --prompt="${label} " --height=50% --reverse --preview="$preview" else find . -type f | fzf --prompt="${label} " --height=50% --reverse --preview="$preview" fi } # _ai_prompt_language — open an fzf language picker; print the chosen language. _ai_prompt_language() { printf '%s\n' English French Dutch German Spanish Italian Portuguese Japanese Chinese Korean \ | fzf --prompt='Target language: ' --height=40% --reverse } # _ai_prompt_number [label] — read a positive integer; print it (or nothing on Enter, which # lets the caller fall back to its own default). Returns 1 only on invalid input. _ai_prompt_number() { local label="${1:-Number (Enter for default):}" print -n "${label} " local n read n if [[ -z "$n" ]]; then return 0 fi if [[ ! "$n" =~ ^[1-9][0-9]*$ ]]; then echo "Expected a positive integer." >&2 return 1 fi print -- "$n" } # _ai_prompt_text [label] — read a free-form string; print it. Returns 1 if empty. _ai_prompt_text() { local label="${1:-Input:}" print -n "${label} " local t read t if [[ -z "$t" ]]; then echo "No input given." >&2 return 1 fi print -- "$t" } # --------------------------------------------------------------------------- # Entry-point dispatcher # --------------------------------------------------------------------------- # ai → interactive fzf menu of every registered ai-* command # ai <cmd> [args] → dispatch straight to ai-<cmd> [args] # # When opened via the fzf menu, each command's declared AI_PARAMS type triggers # a secondary picker before the command runs: # file → fzf file picker (fd + preview if available) # language → fzf language list # number → read prompt, empty input uses the command's own default # text → read prompt, required # none → run the command directly (it collects its own input) # Space-separated values declare multiple parameters in order, e.g. "file language". ai() { local cmd="$1" # Direct dispatch: ai <cmd> [args] if [[ -n "$cmd" && "$cmd" != "help" && "$cmd" != "list" ]]; then if (( ${+AI_COMMANDS[$cmd]} )); then shift "ai-${cmd}" "$@" return fi echo "ai: unknown command '${cmd}'" >&2 echo >&2 fi # Interactive menu if ! command -v fzf >/dev/null 2>&1; then echo "Available ai commands:" for key in ${(ok)AI_COMMANDS}; do printf " ai %-10s %s\n" "$key" "$AI_COMMANDS[$key]" done return fi local picked picked=$( for key in ${(ok)AI_COMMANDS}; do printf '%s\t%s\n' "$key" "$AI_COMMANDS[$key]" done \ | fzf --delimiter='\t' --with-nth=2 --prompt='ai > ' --height=40% --reverse \ | cut -f1 ) [[ -z "$picked" ]] && return # Collect arguments interactively based on AI_PARAMS declaration local -a args=() local param_type for param_type in ${(s: :)${AI_PARAMS[$picked]:-none}}; do case "$param_type" in file) local f f=$(_ai_prompt_file "File for ai-${picked}:") || return 1 args+=("$f") ;; language) local l l=$(_ai_prompt_language) || return 1 args+=("$l") ;; number) local n n=$(_ai_prompt_number) || return 1 [[ -n "$n" ]] && args+=("$n") ;; text) local t t=$(_ai_prompt_text) || return 1 args+=("$t") ;; none) ;; esac done "ai-${picked}" "${args[@]}" } ```` **\_ai-docs.zsh** ```bash # Shared document-to-text extraction for ai-translate and ai-summarize. # .pdf/.docx/.pptx/.xlsx/.html go through docling-convert (see /blog/docling) # first; .md/.markdown/.txt files are read directly, no conversion needed. # # Leading underscore: loads after _ollama.zsh (still alphabetically before # ai-summarize.zsh and ai-translate.zsh), so _ollama_query is always ready. _ai_extract_text() { local file="$1" local ext="${file:e:l}" case "$ext" in md|markdown|txt) cat "$file" ;; pdf|docx|pptx|xlsx|html|htm) if ! command -v docling-convert >/dev/null 2>&1; then echo "_ai_extract_text: docling-convert not found — see /blog/docling to set it up" >&2 return 1 fi local dir filename base mdfile dir="${file:h}" filename="${file:t}" base="${filename%.*}" mdfile="${dir}/${base}.md" echo "→ Converting '$file' with docling-convert..." >&2 ( cd "$dir" && docling-convert "$filename" >/dev/null 2>&1 ) || { echo "_ai_extract_text: docling-convert failed on '$file'" >&2 return 1 } cat "$mdfile" ;; *) echo "_ai_extract_text: unsupported extension .$ext (expected pdf/docx/pptx/xlsx/html/md/txt)" >&2 return 1 ;; esac } ``` **ai-translate.zsh** ```bash # ai-translate <file> [language] — translate a document into the given # language (default: French), entirely locally. Accepts .pdf/.docx/.pptx/ # .xlsx/.html (via docling-convert) or plain .md/.txt files directly. AI_COMMANDS[translate]="ai-translate <file> [language] — translate a document locally (default: French)" ai-translate() { local file="$1" local lang="${2:-French}" if [[ -z "$file" || ! -f "$file" ]]; then echo "Usage: ai-translate <file> [target-language]" >&2 return 1 fi local text text=$(_ai_extract_text "$file") || return 1 local prompt="Translate the following document into $lang. Preserve the Markdown structure exactly — headings, lists, tables, emphasis. Output only the translated document, no commentary and no notes about the translation. $text" _ollama_query "$prompt" } ``` **ai-summarize.zsh** ```bash # ai-summarize <file> [points] — summarize a document in N bullet points # (default: 5), entirely locally. Accepts .pdf/.docx/.pptx/.xlsx/.html (via # docling-convert) or plain .md/.txt files directly. AI_COMMANDS[summarize]="ai-summarize <file> [points] — summarize a document locally (default: 5 points)" ai-summarize() { local file="$1" local points="${2:-5}" if [[ -z "$file" || ! -f "$file" ]]; then echo "Usage: ai-summarize <file> [number-of-points]" >&2 return 1 fi local text text=$(_ai_extract_text "$file") || return 1 local prompt="Summarize the following document in exactly $points bullet points, written in the same language as the document. Prioritize decisions, numbers, deadlines and action items over generic description. Output only the bullet points, nothing else. $text" _ollama_query "$prompt" } ``` **2. Chargez le dossier depuis `~/.zshrc`.** Déjà fait si vous avez suivi un article précédent de cette série. Sinon, ajoutez ces lignes à la fin de `~/.zshrc` — elles sourcent chaque fichier `.zsh` du dossier, donc la prochaine fonction que vous ajoutez ne demandera aucune modification ici : ```zsh title="~/.zshrc" for fn_file in ~/.zsh/fns/*.zsh; do source "$fn_file" done ``` **3. Ouvrez un nouveau terminal** (ou lancez `exec zsh` dans celui en cours), et vérifiez que le shell connaît les nouvelles fonctions : ```zsh type ai-translate ai-summarize ``` `ai-translate is a shell function from …/ai-translate.zsh` signifie que vous êtes prêt. `ai-translate not found` signifie que l'étape 2 manque. ## Conclusion {#conclusion} La vraie histoire de confidentialité ici n'est ni une politique ni une promesse — c'est une architecture. Un document entre, Docling le transforme en texte sans appel réseau, Ollama traite ce texte sans appel réseau, et le résultat atterrit dans mon terminal. Il n'y a aucune étape de cette chaîne où « faire confiance au fournisseur » est nécessaire, et c'est exactement ce qui la rend utilisable pour les documents que je n'aurais jamais risqués sur un outil cloud. Entre ça, `ai-ci` pour les pipelines, `ai-standup` pour le récap du matin et le reste de cette série, le modèle local que j'avais déjà en route pour d'autres raisons trouve sans cesse une chose de plus à faire, discrètement, en arrière-plan, sans rien me demander d'autre qu'un chemin de fichier. --- <!-- canonical-url: https://www.avonture.be/fr/blog/ollama-ai-diagram published: 2026-10-15 This is a static plain-Markdown mirror generated at build time. Visit the canonical URL above for the fully rendered page, with images and interactive components. --> # ai-diagram : d'une phrase (ou d'un compose.yaml) à un diagramme Mermaid > Une fonction zsh qui transforme une description en langage naturel, ou un fichier de config existant comme un docker-compose.yaml, en diagramme Mermaid — affiché sous forme de bloc de code prêt à coller. Là où docker-python-mermaid exige un script écrit pour chaque format d'entrée, ai-diagram prend ce que vous avez sous la main. --- ![ai-diagram : d'une phrase (ou d'un compose.yaml) à un diagramme Mermaid](/img/v2/ai_diagram.webp) **TL;DR** Cet article ajoute `ai-diagram` à la série « Ollama daily use » : donnez-lui la description d'un processus en langage naturel, ou pointez-le vers un fichier comme un `docker-compose.yaml`, et il renvoie un diagramme Mermaid — un bloc de code, prêt à coller directement dans un fichier Markdown. C'est un pont volontaire entre cette série et mes articles doc-as-code : `docker-compose-viz` et `docker-python-mermaid` transforment tous deux des données *structurées* en diagrammes avec de vrais parsers ; `ai-diagram` couvre le cas qu'ils ne savent pas traiter — une description sans le moindre schéma, ou un fichier pour lequel vous n'avez pas envie d'écrire un parser juste pour esquisser un diagramme. J'ai écrit plus d'articles « transformer X en diagramme » que je ne le croyais : [Diagrams as code](/blog/docker-diagram-as-code), [un graphe à partir de compose.yaml](/blog/docker-compose-viz), [la documentation as code avec Python et Mermaid](/blog/docker-python-mermaid). Tous partagent la même forme : un vrai parser lit une entrée structurée et une librairie dessine l'image. Ça fonctionne très bien — jusqu'au moment où ce que je veux représenter est un paragraphe que je viens de taper, ou un fichier de config ponctuel qui ne mérite pas qu'on lui écrive un parser. ## Démo — une description en langage naturel {#demo--plain-english-description} ````bash $ ai-diagram "a user submits a contact form, the API validates the payload, saves it to Postgres, then sends a confirmation email via SMTP" ```mermaid flowchart TD A[User submits contact form] --> B[API validates payload] B -->|Validation fails| C[Return validation errors] B -->|Validation passes| D[Save to Postgres] D --> E[Send confirmation email via SMTP] E --> F[Email sent successfully] ``` Preview: https://mermaid.live/edit#base64:eyJjb2RlIjoiZmxvd2NoYXJ0IFREXG4gICAgQVtVc2VyIHN1Ym1pdHMgY29udGFjdCBmb3JtXSAtLT4gQltBUEkgdmFsaWRhdGVzIHBheWxvYWRdXG4gICAgQiAtLT58VmFsaWRhdGlvbiBmYWlsc3wgQ1tSZXR1cm4gdmFsaWRhdGlvbiBlcnJvcnNdXG4gICAgQiAtLT58VmFsaWRhdGlvbiBwYXNzZXN8IERbU2F2ZSB0byBQb3N0Z3Jlc11cbiAgICBEIC0tPiBFW1NlbmQgY29uZmlybWF0aW9uIGVtYWlsIHZpYSBTTVRQXVxuICAgIEUgLS0-IEZbRW1haWwgc2VudCBzdWNjZXNzZnVsbHldIn0 ```` Pas de YAML, pas de schéma, juste une phrase — et un flowchart qui se ramifie sur la validation, la seule étape de cette phrase dont l'issue décide de la suite, tandis que l'enregistrement et l'envoi restent de simples étapes. Collé dans cette page, le bloc s'affiche ainsi : ```mermaid flowchart TD A[User submits contact form] --> B[API validates payload] B -->|Validation fails| C[Return validation errors] B -->|Validation passes| D[Save to Postgres] D --> E[Send confirmation email via SMTP] E --> F[Email sent successfully] ``` ## Démo — un fichier de config existant {#demo--an-existing-config-file} **compose.yaml** ```yaml services: app: build: . depends_on: - db - cache ports: - "8080:80" db: image: postgres:16 volumes: - db-data:/var/lib/postgresql/data cache: image: redis:7 volumes: db-data: ``` ````bash $ ai-diagram compose.yaml ```mermaid graph TD app["app · build · :8080"] db["db · postgres:16"] cache["cache · redis:7"] db_data[("db-data")] app -- "depends_on" --> db app -- "depends_on" --> cache db -- "volumes" --> db_data ``` Preview: https://mermaid.live/edit#base64:eyJjb2RlIjoiZ3JhcGggVERcbiAgYXBwW1wiYXBwIMK3IGJ1aWxkIMK3IDo4MDgwXCJdXG4gIGRiW1wiZGIgwrcgcG9zdGdyZXM6MTZcIl1cbiAgY2FjaGVbXCJjYWNoZSDCtyByZWRpczo3XCJdXG4gIGRiX2RhdGFbKFwiZGItZGF0YVwiKV1cblxuICBhcHAgLS0gXCJkZXBlbmRzX29uXCIgLS0-IGRiXG4gIGFwcCAtLSBcImRlcGVuZHNfb25cIiAtLT4gY2FjaGVcbiAgZGIgLS0gXCJ2b2x1bWVzXCIgLS0-IGRiX2RhdGEifQ ```` Trois services, leurs liens de dépendance et le volume nommé — lus directement dans la structure du fichier compose, avec chaque image et chaque port publié intégrés au label de son service : ```mermaid graph TD app["app · build · :8080"] db["db · postgres:16"] cache["cache · redis:7"] db_data[("db-data")] app -- "depends_on" --> db app -- "depends_on" --> cache db -- "volumes" --> db_data ``` > **Relisez le Mermaid avant de lui faire confiance:** > > Pour tout ce qui part dans de la vraie documentation — un document d'onboarding, un architecture decision record — traitez ça comme la sortie de `docker-compose-viz` : un bon premier jet. Le modèle peut rater un cas limite (un `depends_on: condition: service_healthy`, un alias réseau moins évident) qu'un vrai parser comme celui de `docker-python-mermaid` n'aurait pas manqué. ## D'autres phrases à essayer {#more-sentences-to-try} Trois descriptions de plus, chacune prête à copier dans votre propre terminal. Chaque lien ouvre le diagramme produit par `ai-diagram`, dans le Mermaid Live Editor. ### Tri des tickets de support {#support-ticket-triage} ```bash ai-diagram "When a customer submits a support ticket, check if it is urgent. If it is urgent, assign it directly to a senior agent. If it is not urgent, place it in the standard queue and send an automated confirmation email to the customer." ``` [Voir le diagramme obtenu sur mermaid.live](https://mermaid.live/edit#base64:eyJjb2RlIjoiZmxvd2NoYXJ0IFREXG4gICAgQVtDdXN0b21lciBzdWJtaXRzIHN1cHBvcnQgdGlja2V0XSAtLT4gQntJcyB0aWNrZXQgdXJnZW50P31cbiAgICBCIC0tIFlFUyAtLT4gQ1tBc3NpZ24gdG8gc2VuaW9yIGFnZW50XVxuICAgIEIgLS0gTk8gLS0-IERbUGxhY2UgaW4gc3RhbmRhcmQgcXVldWVdXG4gICAgRCAtLT4gRVtTZW5kIGF1dG9tYXRlZCBjb25maXJtYXRpb24gZW1haWxdXG4gICAgQyAtLT4gRltFbmRdXG4gICAgRSAtLT4gRltFbmRdIn0) ### Connexion avec verrouillage de compte {#login-with-a-lockout} ```bash ai-diagram "To log into the system, the user enters their username and password. If the credentials are correct, grant access and display the main dashboard. If the credentials are incorrect, check if the attempt count is less than three. If it is less than three, increment the counter and ask the user to try again. Otherwise, lock the account and send a password reset link to the user's email." ``` [Voir le diagramme obtenu sur mermaid.live](https://mermaid.live/edit#base64:eyJjb2RlIjoiZmxvd2NoYXJ0IFREXG4gICAgQVtVc2VyIGVudGVycyB1c2VybmFtZSBhbmQgcGFzc3dvcmRdIC0tPiBCe0NyZWRlbnRpYWxzIGNvcnJlY3Q_fVxuICAgIEIgLS0gWWVzIC0tPiBDW0dyYW50IGFjY2Vzc11cbiAgICBDIC0tPiBEW0Rpc3BsYXkgbWFpbiBkYXNoYm9hcmRdXG4gICAgQiAtLSBObyAtLT4gRXtBdHRlbXB0IGNvdW50IDwgMz99XG4gICAgRSAtLSBZZXMgLS0-IEZbSW5jcmVtZW50IGNvdW50ZXJdXG4gICAgRiAtLT4gR1tBc2sgdXNlciB0byB0cnkgYWdhaW5dXG4gICAgRSAtLSBObyAtLT4gSFtMb2NrIGFjY291bnRdXG4gICAgSCAtLT4gSVtTZW5kIHBhc3N3b3JkIHJlc2V0IGxpbmsgdG8gZW1haWxdIn0) ### Une commande e-commerce {#an-e-commerce-order} ```bash ai-diagram "When receiving an e-commerce order, first verify if the requested item is in stock. If it is out of stock, notify the customer and ask if they want to backorder or cancel. If they cancel, process a full refund. If the item is in stock, check if the payment is processed successfully. If payment fails, prompt the user to update their payment method. If payment succeeds, pack the order, generate a shipping label, ship the item, and send a tracking number to the customer." ``` [Voir le diagramme obtenu sur mermaid.live](https://mermaid.live/edit#base64:eyJjb2RlIjoiZmxvd2NoYXJ0IFREXG4gICAgQVtSZWNlaXZlIGUtY29tbWVyY2Ugb3JkZXJdIC0tPiBCe0lzIGl0ZW0gaW4gc3RvY2s_fVxuICAgIEIgLS0gTm8gLS0-IENbTm90aWZ5IGN1c3RvbWVyXVxuICAgIEMgLS0-IER7V2FudCB0byBiYWNrb3JkZXIgb3IgY2FuY2VsP31cbiAgICBEIC0tIENhbmNlbCAtLT4gRVtQcm9jZXNzIGZ1bGwgcmVmdW5kXVxuICAgIEQgLS0gQmFja29yZGVyIC0tPiBGW1Byb2NlZWQgd2l0aCBvcmRlcl1cbiAgICBCIC0tIFllcyAtLT4gR3tQYXltZW50IHByb2Nlc3NlZCBzdWNjZXNzZnVsbHk_fVxuICAgIEcgLS0gTm8gLS0-IEhbUHJvbXB0IHVzZXIgdG8gdXBkYXRlIHBheW1lbnQgbWV0aG9kXVxuICAgIEggLS0-IEdcbiAgICBHIC0tIFllcyAtLT4gSVtQYWNrIG9yZGVyXVxuICAgIEkgLS0-IEpbR2VuZXJhdGUgc2hpcHBpbmcgbGFiZWxdXG4gICAgSiAtLT4gS1tTaGlwIGl0ZW1dXG4gICAgSyAtLT4gTFtTZW5kIHRyYWNraW5nIG51bWJlciB0byBjdXN0b21lcl0ifQ) ## Sa place réelle à côté de `docker-python-mermaid` {#where-this-actually-sits-next-to-docker-python-mermaid} > **Pas un remplaçant — un autre métier:** > > [`docker-python-mermaid`](/blog/docker-python-mermaid) parse lui aussi un `compose.yaml`, et c'est le bon outil pour un diagramme qui vit dans la documentation : un script écrit une fois pour ce format d'entrée, déterministe, régénéré à chaque changement. `ai-diagram` sert à l'autre moitié du travail : une description de la taille d'un message Slack, ou un format pour lequel vous n'avez aucun script — un `nginx.conf`, un workflow CI, un schéma SQL. Moins de précision, aucune installation — j'utilise les deux, selon que le diagramme doit rester synchronisé ou simplement exister cet après-midi. ## La fonction `ai-diagram` {#the-ai-diagram-function} Pas besoin de copier quoi que ce soit pour l'instant : les fichiers `.zsh` montrés dans cet article sont là pour être lus, et [L'installer](#installing-it) à la fin les crée tous dans `~/.zsh/fns/` pour vous. **\~/.zsh/fns/ai-diagram.zsh** ````bash # ai-diagram <description>|<file> — turn a plain-English description, or an # existing config/structure file, into a Mermaid diagram. Prints a fenced # ```mermaid code block, ready to paste into a Markdown file (this blog # already renders Mermaid natively). AI_COMMANDS[diagram]="ai-diagram <description>|<file> — turn a description or a config file into a Mermaid diagram" AI_PARAMS[diagram]="text" ai-diagram() { if [[ -z "$1" ]]; then echo 'Usage: ai-diagram "plain-English description" or ai-diagram <file>' >&2 return 1 fi local input hint if [[ -f "$1" ]]; then input=$(cat "$1") # Without these rules, a model turns every value it reads into a node of # its own: "postgres:16" or "8080:80" end up as boxes next to the services. hint="The input below is the file '$1'. Infer the most natural Mermaid diagram type from its content — a docker-compose.yaml suggests a graph of services and their links, a folder listing suggests a flowchart or tree, an OpenAPI spec suggests a sequence diagram. Follow these rules: - one node per entity the file declares (a service, a table, a class, a job), labelled with its name and its key attributes, e.g. app[\"app · build · :8080\"]; - one edge per relationship the file states (depends_on, a foreign key, needs), from the entity to what it relies on; - storage (a named volume, a database file) is a cylinder node, e.g. db_data[(\"db-data\")], linked from the entity that uses it; - attributes (an image, a port, a type) appear only inside labels, never as nodes of their own; - node ids use letters, digits and underscores only." else input="$*" hint="The input below is a plain-English description. Pick whichever Mermaid diagram type (flowchart, sequence, ER, class, state) best fits what is being described. Draw every branch the description states. Add a failure branch on your own only where a step is itself a check whose outcome decides what happens next (a validation, a login, a payment) — never for a plain action such as saving, sending or assigning something." fi local prompt="You are a Mermaid diagram expert. $hint Output ONLY a fenced Mermaid code block — no explanation before or after it, no extra commentary, nothing outside the \`\`\`mermaid ... \`\`\` fence. --- INPUT --- $input" local diagram diagram=$(_ollama_query "$prompt") || return 1 if [[ -z "$diagram" ]]; then echo "ai-diagram: the model returned an empty answer" >&2 return 1 fi # The prompt asks for the fence, but a model still adds a sentence before it # now and then. Keeping only the fenced lines and re-wrapping them makes the # output paste-ready every time, whatever the model wrapped around it. local code code=$(_ai_strip_fences "$diagram") print -r -- '```mermaid' print -r -- "$code" print -r -- '```' # A Ctrl+click preview on mermaid.live. "base64:" is one of the two formats # the editor reads from the URL hash — the JSON state, base64url-encoded — so # jq and coreutils are enough, no compression library. It goes to stderr: # stdout stays the bare Markdown block, even redirected to a file. local state state=$(jq -jcn --arg code "$code" '{code: $code}' | base64 | tr -d '\n=' | tr '+/' '-_') print -r -- "Preview: https://mermaid.live/edit#base64:${state}" >&2 } ```` Deux modes d'entrée, distingués par un seul test — si le premier argument est un fichier existant, c'est un fichier : 1. **Un fichier** — lu tel quel, et le prompt demande au modèle de déduire le type de diagramme du contexte (un `compose.yaml` implique un graphe de services, un listing de dossier implique un arbre, une spec OpenAPI implique un diagramme de séquence). Une courte liste de règles garde le graphe honnête : un nœud par entité, une arête par relation, les attributs comme une image ou un port dans les labels, le stockage dessiné comme un cylindre. 2. **Du texte libre** — tout ce qui suit la commande est traité comme la description, et le modèle choisit lui-même le type de diagramme Mermaid le plus adapté, en ajoutant une branche d'échec uniquement là où une étape est elle-même une vérification (une validation, une connexion, un paiement) — jamais un « email envoyé ? » inventé sur une action simple. Dans les deux cas, la sortie est un bloc ` ```mermaid ` et rien d'autre. Le prompt demande exactement ça, et `_ai_strip_fences` ne conserve ensuite que les lignes du bloc avant que la fonction ne les réencadre elle-même — de sorte que même une réponse qui commence par « Here is your diagram: » ressort prête à coller dans un article, où ce blog la rend nativement. Avant de la coller quelque part, vous voudrez la voir. La dernière ligne, `Preview: https://mermaid.live/edit#base64:…`, ouvre le diagramme dans le [Mermaid Live Editor](https://mermaid.live) avec un Ctrl+clic depuis le terminal — rendu, et modifiable si le modèle s'est trompé sur un label. L'éditeur lit tout son état dans la partie de l'URL après le `#` : le JSON `{"code": "…"}`, encodé en base64url, donc `jq` et `base64` suffisent à le construire. Ce fragment n'est jamais envoyé au serveur dans la requête ; le diagramme est décodé et dessiné par la page, dans votre navigateur. Le lien part sur stderr, donc `ai-diagram compose.yaml > docs/services.md` n'écrit toujours rien d'autre que le bloc Mermaid. ## Enregistrée dans le menu `ai` {#registered-in-the-ai-menu} Deux lignes — `AI_COMMANDS[diagram]=...` et `AI_PARAMS[diagram]="text"` — la rendent accessible via `ai diagram <input>` ou directement via `ai-diagram`, aux côtés de toutes les autres fonctions de la série. Choisie depuis le menu `ai` seul, elle demande son entrée : tapez une description, ou un nom de fichier. `_ollama_query`, `_ai_strip_fences` et le registre `AI_COMMANDS` vivent tous dans la fondation partagée `_ollama.zsh` présentée dans [ai-test](/blog/ollama-test-generator). Si vous avez déjà suivi un article précédent de cette série, elle se trouve déjà dans `~/.zsh/fns/` et est sourcée par votre autoloader — rien de plus à faire. Vous commencez ici ? C'est l'un des fichiers créés dans [L'installer](#installing-it) plus bas. ## À retenir {#key-takeaways} **ai-diagram en bref** * **Deux modes d'entrée** — un fichier existant, ou une description en texte libre, détectés automatiquement * **Le type de diagramme est déduit** — flowchart, séquence, ER, classe, état — le modèle choisit selon le contexte * **La sortie n'est qu'un bloc** — un bloc \`\`\`mermaid brut, réencadré par la fonction elle-même, destiné à être collé directement * **Ctrl+clic pour prévisualiser** — un lien mermaid.live sur stderr ouvre le diagramme rendu, stdout reste prêt à coller * **Complète, ne remplace pas, `docker-python-mermaid`** — un script pour les diagrammes qui doivent rester synchronisés ; ceci pour tout le reste * **S'enregistre dans `ai`** — accessible via `ai diagram` ## L'installer {#installing-it} Tout tourne sur votre machine : zsh, `curl` et `jq` (qui construit la requête envoyée à Ollama), et un serveur Ollama accessible — voyez [installing Ollama](/blog/ollama-installation) si vous n'en avez pas encore. **1. Créez les fichiers.** Chaque fonction de cette série vit dans son propre fichier dans `~/.zsh/fns/`. La ligne de commande ci-dessous crée ce dossier et les deux fichiers d'un coup — sinon téléchargez le ZIP, ou créez chaque fichier à la main et collez son contenu. `_ollama.zsh` est la fondation partagée par toute la série : si un article précédent vous l'a déjà donnée, la copie ici est le même fichier. **Project: \~/.zsh/fns** **\_ollama.zsh** ````bash # Foundation file for every "ai-*" function in this series. Named with a # leading underscore: the ~/.zsh/fns/*.zsh loader sources files in # alphabetical order, and "_" sorts before any letter — so this file is # always loaded first, no matter how many ai-* functions get added later. # # Provides: # - _ollama_check reachability guard for Ollama # - _ollama_query shared HTTP client (curl + jq) # - _ai_strip_fences removes the Markdown fences models add anyway # - _ai_bat resolves "bat" vs Debian's "batcat" # - _ai_confirm yes/no prompt # - _git_staged_diff validated staged-diff fetcher (shared by pre-commit functions) # - AI_COMMANDS registry each ai-* function adds itself to # - AI_PARAMS registry of interactive parameter types (file/language/number/text/none) # - _ai_prompt_* FZF/read helpers for interactive argument collection # - ai entry-point dispatcher / interactive menu typeset -gA AI_COMMANDS typeset -gA AI_PARAMS # --------------------------------------------------------------------------- # Ollama helpers # --------------------------------------------------------------------------- # _ollama_check — verify that the Ollama daemon is reachable. # Fails loudly so callers can bail early rather than watching curl hang. _ollama_check() { local host="${OLLAMA_HOST:-http://localhost:11434}" if ! curl --silent --max-time 2 "${host}/api/tags" >/dev/null 2>&1; then echo "ollama: cannot reach ${host} — is the Ollama container running?" >&2 return 1 fi } # _ollama_query <prompt> — send a prompt to the local model and print the response. # Assumes Ollama is reachable; call _ollama_check first if you need an early guard. _ollama_query() { local prompt="$1" local model="${OLLAMA_MODEL:-qwen3-coder:30b}" local host="${OLLAMA_HOST:-http://localhost:11434}" _ollama_check || return 1 # The prompt is piped into jq rather than passed with "--arg": Linux caps a # single argv entry at 128 KB (MAX_ARG_STRLEN), and a large staged diff or a # long source file sails straight through that ceiling — jq then dies with # "argument list too long". "-R -s" slurps stdin as one raw string, which jq # escapes exactly like --arg would. print -r -- "$prompt" \ | jq -Rs --arg model "$model" '{model: $model, prompt: ., stream: false}' \ | curl --silent "${host}/api/generate" --data-binary @- \ | jq -r '.response // empty' } # --------------------------------------------------------------------------- # Output helpers # --------------------------------------------------------------------------- # _ai_strip_fences <text> — return only the code, without the Markdown fences. # # Every prompt in this series ends with "no markdown fences" and models still # wrap their answer in ```language … ``` often enough that the output can't be # piped or pasted as-is. When at least one fence is present, only the fenced # regions are kept (which also drops the "Here is your test suite:" preamble); # when there is none, the text is returned untouched. _ai_strip_fences() { local text="$1" if [[ "$text" != *'```'* ]]; then print -r -- "$text" return 0 fi print -r -- "$text" | awk ' /^[[:space:]]*```/ { inside = !inside; next } inside { print } ' } # _ai_bat — print the name of the "bat" binary installed on this machine, if any. # Debian and Ubuntu ship the package as "batcat" because the "bat" name was # already taken by bacula-console-qt, so both spellings have to be probed. _ai_bat() { local candidate for candidate in bat batcat; do if command -v "$candidate" >/dev/null 2>&1; then print -- "$candidate" return 0 fi done return 1 } # _ai_relpath <path> — print <path> relative to the current folder when it sits # below it. Purely cosmetic: it keeps prompts readable instead of asking # "Save as /home/christophe/projects/…/tests/backup.bats?". _ai_relpath() { local path="${1:A}" print -- "${path#${PWD}/}" } # _ai_confirm <question> — ask a yes/no question; return 0 only on an explicit yes. _ai_confirm() { local answer print -n "${1} [y/N] " read -r answer [[ "$answer" == [yY]* ]] } # --------------------------------------------------------------------------- # Git helpers # --------------------------------------------------------------------------- # _git_staged_diff <caller> — validate the git context and return the staged diff. # Handles three early-exit cases (not a repo, nothing staged, oversized diff) # so every pre-commit function gets them for free without repeating the guards. # # Past AI_DIFF_MAX_CHARS the full diff is *not* returned: a diff of several # hundred kilobytes overflows the model's context window, and the answer that # comes back is worse than the one you get from a summary. The fallback keeps # the shape of the change — per-file stat, plus the file and hunk headers, # which carry the enclosing function names git puts after each "@@" — and # drops the line-by-line content. _git_staged_diff() { local caller="${1:-ai}" local max="${AI_DIFF_MAX_CHARS:-12000}" if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then echo "${caller}: not inside a git repository" >&2 return 1 fi local diff diff=$(git diff --staged) if [[ -z "$diff" ]]; then echo "${caller}: nothing staged — run 'git add' first" >&2 return 1 fi # "print -r" everywhere below: without it, print expands escape sequences and # a literal \n or \t inside the diff would come back mangled. if (( ${#diff} <= max )); then print -r -- "$diff" return 0 fi echo "${caller}: staged diff is large (${#diff} chars) — sending a structural summary instead of the full diff. Commit in smaller chunks for a more precise answer." >&2 local summary summary="$(git diff --staged --stat) --- FILE AND HUNK HEADERS ONLY (full diff omitted, ${#diff} chars) --- $(print -r -- "$diff" | grep -E '^(diff --git|new file|deleted file|rename (from|to)|@@)')" # Even the summary can exceed the ceiling on a very wide change. if (( ${#summary} > max )); then summary="${summary[1,$max]} […summary truncated…]" fi print -r -- "$summary" } # --------------------------------------------------------------------------- # Interactive argument helpers (used by the ai() dispatcher) # --------------------------------------------------------------------------- # _ai_prompt_file [label] — open an fzf file picker; print the chosen path. _ai_prompt_file() { local label="${1:-Select a file:}" local bat_bin preview='cat {}' bat_bin=$(_ai_bat) && preview="${bat_bin} --color=always {} 2>/dev/null || cat {}" if command -v fd >/dev/null 2>&1; then fd --type f | fzf --prompt="${label} " --height=50% --reverse --preview="$preview" else find . -type f | fzf --prompt="${label} " --height=50% --reverse --preview="$preview" fi } # _ai_prompt_language — open an fzf language picker; print the chosen language. _ai_prompt_language() { printf '%s\n' English French Dutch German Spanish Italian Portuguese Japanese Chinese Korean \ | fzf --prompt='Target language: ' --height=40% --reverse } # _ai_prompt_number [label] — read a positive integer; print it (or nothing on Enter, which # lets the caller fall back to its own default). Returns 1 only on invalid input. _ai_prompt_number() { local label="${1:-Number (Enter for default):}" print -n "${label} " local n read n if [[ -z "$n" ]]; then return 0 fi if [[ ! "$n" =~ ^[1-9][0-9]*$ ]]; then echo "Expected a positive integer." >&2 return 1 fi print -- "$n" } # _ai_prompt_text [label] — read a free-form string; print it. Returns 1 if empty. _ai_prompt_text() { local label="${1:-Input:}" print -n "${label} " local t read t if [[ -z "$t" ]]; then echo "No input given." >&2 return 1 fi print -- "$t" } # --------------------------------------------------------------------------- # Entry-point dispatcher # --------------------------------------------------------------------------- # ai → interactive fzf menu of every registered ai-* command # ai <cmd> [args] → dispatch straight to ai-<cmd> [args] # # When opened via the fzf menu, each command's declared AI_PARAMS type triggers # a secondary picker before the command runs: # file → fzf file picker (fd + preview if available) # language → fzf language list # number → read prompt, empty input uses the command's own default # text → read prompt, required # none → run the command directly (it collects its own input) # Space-separated values declare multiple parameters in order, e.g. "file language". ai() { local cmd="$1" # Direct dispatch: ai <cmd> [args] if [[ -n "$cmd" && "$cmd" != "help" && "$cmd" != "list" ]]; then if (( ${+AI_COMMANDS[$cmd]} )); then shift "ai-${cmd}" "$@" return fi echo "ai: unknown command '${cmd}'" >&2 echo >&2 fi # Interactive menu if ! command -v fzf >/dev/null 2>&1; then echo "Available ai commands:" for key in ${(ok)AI_COMMANDS}; do printf " ai %-10s %s\n" "$key" "$AI_COMMANDS[$key]" done return fi local picked picked=$( for key in ${(ok)AI_COMMANDS}; do printf '%s\t%s\n' "$key" "$AI_COMMANDS[$key]" done \ | fzf --delimiter='\t' --with-nth=2 --prompt='ai > ' --height=40% --reverse \ | cut -f1 ) [[ -z "$picked" ]] && return # Collect arguments interactively based on AI_PARAMS declaration local -a args=() local param_type for param_type in ${(s: :)${AI_PARAMS[$picked]:-none}}; do case "$param_type" in file) local f f=$(_ai_prompt_file "File for ai-${picked}:") || return 1 args+=("$f") ;; language) local l l=$(_ai_prompt_language) || return 1 args+=("$l") ;; number) local n n=$(_ai_prompt_number) || return 1 [[ -n "$n" ]] && args+=("$n") ;; text) local t t=$(_ai_prompt_text) || return 1 args+=("$t") ;; none) ;; esac done "ai-${picked}" "${args[@]}" } ```` **ai-diagram.zsh** ````bash # ai-diagram <description>|<file> — turn a plain-English description, or an # existing config/structure file, into a Mermaid diagram. Prints a fenced # ```mermaid code block, ready to paste into a Markdown file (this blog # already renders Mermaid natively). AI_COMMANDS[diagram]="ai-diagram <description>|<file> — turn a description or a config file into a Mermaid diagram" AI_PARAMS[diagram]="text" ai-diagram() { if [[ -z "$1" ]]; then echo 'Usage: ai-diagram "plain-English description" or ai-diagram <file>' >&2 return 1 fi local input hint if [[ -f "$1" ]]; then input=$(cat "$1") # Without these rules, a model turns every value it reads into a node of # its own: "postgres:16" or "8080:80" end up as boxes next to the services. hint="The input below is the file '$1'. Infer the most natural Mermaid diagram type from its content — a docker-compose.yaml suggests a graph of services and their links, a folder listing suggests a flowchart or tree, an OpenAPI spec suggests a sequence diagram. Follow these rules: - one node per entity the file declares (a service, a table, a class, a job), labelled with its name and its key attributes, e.g. app[\"app · build · :8080\"]; - one edge per relationship the file states (depends_on, a foreign key, needs), from the entity to what it relies on; - storage (a named volume, a database file) is a cylinder node, e.g. db_data[(\"db-data\")], linked from the entity that uses it; - attributes (an image, a port, a type) appear only inside labels, never as nodes of their own; - node ids use letters, digits and underscores only." else input="$*" hint="The input below is a plain-English description. Pick whichever Mermaid diagram type (flowchart, sequence, ER, class, state) best fits what is being described. Draw every branch the description states. Add a failure branch on your own only where a step is itself a check whose outcome decides what happens next (a validation, a login, a payment) — never for a plain action such as saving, sending or assigning something." fi local prompt="You are a Mermaid diagram expert. $hint Output ONLY a fenced Mermaid code block — no explanation before or after it, no extra commentary, nothing outside the \`\`\`mermaid ... \`\`\` fence. --- INPUT --- $input" local diagram diagram=$(_ollama_query "$prompt") || return 1 if [[ -z "$diagram" ]]; then echo "ai-diagram: the model returned an empty answer" >&2 return 1 fi # The prompt asks for the fence, but a model still adds a sentence before it # now and then. Keeping only the fenced lines and re-wrapping them makes the # output paste-ready every time, whatever the model wrapped around it. local code code=$(_ai_strip_fences "$diagram") print -r -- '```mermaid' print -r -- "$code" print -r -- '```' # A Ctrl+click preview on mermaid.live. "base64:" is one of the two formats # the editor reads from the URL hash — the JSON state, base64url-encoded — so # jq and coreutils are enough, no compression library. It goes to stderr: # stdout stays the bare Markdown block, even redirected to a file. local state state=$(jq -jcn --arg code "$code" '{code: $code}' | base64 | tr -d '\n=' | tr '+/' '-_') print -r -- "Preview: https://mermaid.live/edit#base64:${state}" >&2 } ```` **2. Chargez le dossier depuis `~/.zshrc`.** Déjà fait si vous avez suivi un article précédent de cette série. Sinon, ajoutez ces lignes à la fin de `~/.zshrc` — elles sourcent chaque fichier `.zsh` du dossier, donc la prochaine fonction que vous ajouterez ne nécessitera aucun changement ici : ```zsh title="~/.zshrc" for fn_file in ~/.zsh/fns/*.zsh; do source "$fn_file" done ``` **3. Ouvrez un nouveau terminal** (ou lancez `exec zsh` dans celui en cours), et vérifiez que le shell connaît la nouvelle fonction : ```zsh type ai-diagram ``` `ai-diagram is a shell function from …/ai-diagram.zsh` signifie que vous êtes prêt. `ai-diagram not found` signifie que l'étape 2 manque. ## Conclusion {#conclusion} Mes articles doc-as-code ont toujours supposé que j'avais déjà quelque chose de structuré à donner à un parser. `ai-diagram` comble le vide en amont — la phrase que je viens de taper, le fichier que j'ai mais pour lequel je n'ai pas écrit de parser. Il ne remplacera pas [Diagrams as code](/blog/docker-diagram-as-code) ni [docker-python-mermaid](/blog/docker-python-mermaid) pour ce qui doit être exact et reproductible, mais pour le cas « laisse-moi juste esquisser ça vite fait » qui signifiait avant d'ouvrir un outil de whiteboard, c'est devenu une commande et un collage. --- <!-- canonical-url: https://www.avonture.be/fr/blog/ollama-ai-data published: 2026-10-22 This is a static plain-Markdown mirror generated at build time. Visit the canonical URL above for the fully rendered page, with images and interactive components. --> # ai-data : laissez un LLM local écrire vos one-liners jq et awk > Une fonction zsh qui lit un fichier JSON ou CSV, comprend ses vrais champs et propose 5 commandes jq/awk pratiques adaptées à ce fichier précis — choisies via fzf, puis déposées sur votre ligne de commande (pas exécutées à votre place) pour que vous puissiez voir, modifier et comprendre la commande exacte avant d'appuyer sur Entrée. --- ![ai-data : laissez un LLM local écrire vos one-liners jq et awk](/img/v2/json.webp) **TL;DR** Cet article ajoute `ai-data` à la série « Ollama daily use » : pointez-la vers un fichier `.json` ou `.csv` et elle demande au modèle local 5 commandes `jq`/`awk` pratiques, adaptées aux noms de champs réels de ce fichier — pas des exemples génériques. Choisissez-en une avec `fzf` et, au lieu de l'exécuter pour vous, `ai-data` la dépose sur votre ligne de commande zsh avec `print -z`, prête à être lue, modifiée, puis seulement exécutée. L'objectif n'est pas juste d'avoir une réponse : c'est de repartir avec une commande que vous comprenez. Mon usage de [`jq`](/blog/linux-jq) reste très basique ; je suis loin de savoir écrire une ligne avec `group_by` du premier coup. À chaque fois, c'est la même histoire : je dois bricoler (et perdre plusieurs minutes) juste pour obtenir ce que je veux — ou même… je ne sais même pas qu'une certaine fonctionnalité existe. Avec `awk`, c'est encore pire : je sais que ça existe et que c'est incroyablement puissant, mais la syntaxe est tout simplement impossible. ## Démo — JSON {#demo--json} ```bash $ ai-data users.json → Analyzing users.json (json)... ai-data > 5/5 ──────────────────────────────── Extract all user names from the JSON file Count how many active users exist in the file Display name and birth date for each user, separated by colon > Group users by active status and count them Find names of users born in the 1990s $ jq 'group_by(.active) | map({"active": .[0].active, "count": length})' users.json [ { "active": false, "count": 1 }, { "active": true, "count": 2 } ] ``` Cinq suggestions, listées par ce à quoi elles répondent — chacune construite sur les vrais noms de champs (`active`, `birth_date`, `name`) que le modèle a vus dans l'échantillon, pas sur des placeholders d'un tutoriel `jq`. En choisissant la quatrième dans `fzf`, ce filtre `group_by` exact s'est retrouvé sur mon prompt ; je l'ai lu, il avait l'air correct, j'ai appuyé sur Entrée. ## Démo — CSV {#demo--csv} ```bash $ ai-data orders.csv → Analyzing orders.csv (csv)... ai-data > 5/5 ──────────────────────────────── Show order IDs and amounts for EMEA region only Calculate total sales amount across all orders > Count orders by region and display in descending order Display all orders with amount greater than 100 Show first 10 orders with all fields $ awk -F, 'NR>1 {print $2}' orders.csv | sort | uniq -c | sort -nr 2 EMEA 2 APAC 1 AMER ``` Le modèle a remarqué tout seul que `region` est la colonne intéressante pour un regroupement, et a répondu avec le classique pipeline `awk | sort | uniq -c` — le genre de ligne que je reconnais quand je la vois mais que je n'écris jamais de mémoire. L'avoir lue une fois sur mon propre prompt, je la comprends mieux que si j'avais juste vu la sortie. ## L'idée — des suggestions, pas des réponses {#the-idea--suggestions-not-answers} La plupart des fonctions de cette série posent une question au modèle et affichent la réponse — [ai-diagram](/blog/ollama-ai-diagram) affiche un bloc Mermaid, prêt à coller. `ai-data` est différente volontairement : elle demande au modèle des **commandes candidates**, les présente via [`fzf`](/blog/linux-fzf-introduction), et quand vous en choisissez une, elle ne l'exécute pas — elle la dépose sur votre prompt, modifiable, grâce au `print -z` de zsh. Vous lisez le filtre `jq` ou le script `awk` exact avant qu'il ne touche vos données, et comme c'est vous qui appuyez sur Entrée, vous assimilez vraiment la syntaxe au lieu de simplement consommer la sortie. > **print -z, en une phrase:** > > `print -z "some command"` pousse une chaîne dans le buffer d'édition de zsh — le prompt suivant l'affiche pré-tapée, exactement comme si vous l'aviez saisie vous-même, curseur prêt, rien d'exécuté. ## La fonction `ai-data` {#the-ai-data-function} Pas besoin de copier quoi que ce soit pour l'instant : les fichiers `.zsh` montrés dans cet article sont là pour être lus, et [L'installer](#installing-it) à la fin les crée tous dans `~/.zsh/fns/` pour vous. **\~/.zsh/fns/ai-data.zsh** ```bash # ai-data <file.json|file.csv> — analyze a JSON or CSV file and suggest 5 # practical jq/awk commands tailored to its actual fields. Pick one via fzf; # instead of running it for you, it's loaded into your command line with # `print -z` so you can read it, edit it, and learn from it before hitting # Enter yourself. AI_COMMANDS[data]="ai-data <file.json|file.csv> — suggest jq/awk commands tailored to this file" AI_PARAMS[data]="file" ai-data() { local file="$1" if [[ -z "$file" || ! -f "$file" ]]; then echo "Usage: ai-data <file.json|file.csv>" >&2 return 1 fi local ext="${file:e:l}" local sample tool case "$ext" in json) tool="jq" if jq -e 'type == "array"' "$file" >/dev/null 2>&1; then sample=$(jq -c '.[0:3]' "$file") else sample=$(head -c 3000 "$file") fi ;; csv) if command -v mlr >/dev/null 2>&1; then tool="awk, or mlr (Miller — detected as installed, prefer it for group-by/aggregation)" else tool="awk (Miller not detected — stick to awk/sort/uniq idioms)" fi sample=$(head -n 6 "$file") ;; *) echo "ai-data: unsupported extension .$ext (expected .json or .csv)" >&2 return 1 ;; esac echo "→ Analyzing $file ($ext)..." >&2 local prompt="You are a data analyst who writes $tool one-liners. Below is a sample of a $ext file — the field names and shapes are real, only the row count is truncated. Suggest exactly 5 practical, ready-to-run commands a reader would actually want on THIS file: filtering, extracting specific fields, counting, grouping. Reference the real filename '$file' in every command. Output exactly one command per line, in this exact format and nothing else — no numbering, no markdown fences: COMMAND ||| one-line description of what it answers --- SAMPLE --- $sample" local suggestions suggestions=$(_ollama_query "$prompt") || return 1 if [[ -z "$suggestions" ]]; then echo "ai-data: no suggestions returned" >&2 return 1 fi # "print -r" and never echo: zsh's echo expands escape sequences, and the # "\n" of an awk printf would split the suggestion over two lines. if ! command -v fzf >/dev/null 2>&1; then print -r -- "$suggestions" return 0 fi local picked picked=$(print -r -- "$suggestions" | fzf --delimiter=' \|\|\| ' --with-nth=2 \ --prompt="ai-data > " --height=40% --reverse) [[ -z "$picked" ]] && return 0 local cmd="${picked%% \|\|\| *}" # Same reason here: without -r, "\n" would reach the prompt as a real newline. print -rz -- "$cmd" } ``` Étape par étape : 1. Détecter JSON ou CSV à partir de l'extension. 2. Construire un petit échantillon réel — pour le JSON, les 3 premiers éléments s'il s'agit d'un tableau (`jq -c '.[0:3]'`) ; pour le CSV, l'en-tête plus 5 lignes. Jamais le fichier entier : un CSV de 50 000 lignes n'a pas besoin d'en laisser plus de quelques-unes dans le prompt. 3. Pour le CSV, vérifier si [`mlr`](https://miller.readthedocs.io/) (Miller) est installé — si oui, on indique au modèle qu'il peut proposer `mlr` pour les regroupements/agrégations, ce qui se lit nettement mieux que l'équivalent `awk`. Sinon, il s'en tient à `awk`/`sort`/`uniq`. 4. Demander exactement 5 commandes, une par ligne, au format `COMMAND ||| description` — délibérément pas une sortie JSON, car parser cinq lignes de « texte avant un triple pipe » est trivial et évite le risque que le modèle emballe sa réponse dans des blocs markdown ou ajoute des commentaires. 5. Envoyer les cinq lignes dans `fzf`, en n'affichant que la description (`--with-nth=2`) pour que vous choisissiez par *intention*, et non en plissant les yeux sur de la syntaxe `jq`. 6. À la sélection, `print -rz` du premier champ de la commande choisie. > **Pas de fzf ? Ça marche quand même:** > > Sans `fzf` installé, `ai-data` affiche simplement les cinq suggestions en texte brut — vous copiez celle que vous voulez manuellement. Même information, un confort en moins. ## Enregistrée dans le menu `ai` {#registered-in-the-ai-menu} Une ligne — `AI_COMMANDS[data]=...` — accessible via `ai data <file>` ou directement avec `ai-data`, aux côtés de toutes les autres fonctions de la série. `_ollama_query` et le registre `AI_COMMANDS` vivent dans la fondation partagée `_ollama.zsh` introduite dans [ai-test](/blog/ollama-test-generator). Si vous avez déjà suivi un article précédent de cette série, elle est déjà dans `~/.zsh/fns/` et sourcée par votre autoloader — rien de plus à faire. Vous commencez ici ? C'est un des fichiers créés dans [L'installer](#installing-it) plus bas. > **L'échantillonnage veut dire que le modèle peut se tromper sur les cas limites:** > > Cinq lignes ne révéleront pas un champ `null` rare, un format de date incohérent trois lignes plus loin ou une colonne supplémentaire égarée. Les commandes proposées sont un bon point de départ, pas une garantie — exécutez-les, vérifiez que le nombre de lignes semble cohérent, et ajustez si les données réservent des surprises que l'échantillon ne montrait pas. ## Points clés {#key-takeaways} **ai-data en bref** * **Un échantillon, pas le fichier entier** — 3 éléments JSON ou 5 lignes CSV, assez pour voir les vrais noms de champs * **Des suggestions, pas une seule réponse** — 5 commandes candidates, choisies par intention via `fzf` * **`print -z`, pas d'exécution automatique** — la commande choisie arrive sur votre prompt, modifiable, rien ne s'exécute avant que vous appuyiez sur Entrée * **`mlr` quand il est disponible** — syntaxe de regroupement/agrégation plus agréable que `awk` si Miller est installé * **S'enregistre dans `ai`** — accessible via `ai data <file>` ## L'installer {#installing-it} Tout tourne sur votre machine : zsh, `curl` et `jq` (qui construit la requête envoyée à Ollama), et un serveur Ollama joignable — voyez [installer Ollama](/blog/ollama-installation) si vous n'en avez pas encore. **1. Créez les fichiers.** Chaque fonction de cette série vit dans son propre fichier dans `~/.zsh/fns/`. La commande ci-dessous crée ce dossier et les deux fichiers d'un coup — ou téléchargez le ZIP, ou créez chaque fichier à la main et collez-y son contenu. `_ollama.zsh` est la fondation partagée par toute la série : si un article précédent vous l'a déjà donnée, la copie ici est le même fichier. **Project: \~/.zsh/fns** **\_ollama.zsh** ````bash # Foundation file for every "ai-*" function in this series. Named with a # leading underscore: the ~/.zsh/fns/*.zsh loader sources files in # alphabetical order, and "_" sorts before any letter — so this file is # always loaded first, no matter how many ai-* functions get added later. # # Provides: # - _ollama_check reachability guard for Ollama # - _ollama_query shared HTTP client (curl + jq) # - _ai_strip_fences removes the Markdown fences models add anyway # - _ai_bat resolves "bat" vs Debian's "batcat" # - _ai_confirm yes/no prompt # - _git_staged_diff validated staged-diff fetcher (shared by pre-commit functions) # - AI_COMMANDS registry each ai-* function adds itself to # - AI_PARAMS registry of interactive parameter types (file/language/number/text/none) # - _ai_prompt_* FZF/read helpers for interactive argument collection # - ai entry-point dispatcher / interactive menu typeset -gA AI_COMMANDS typeset -gA AI_PARAMS # --------------------------------------------------------------------------- # Ollama helpers # --------------------------------------------------------------------------- # _ollama_check — verify that the Ollama daemon is reachable. # Fails loudly so callers can bail early rather than watching curl hang. _ollama_check() { local host="${OLLAMA_HOST:-http://localhost:11434}" if ! curl --silent --max-time 2 "${host}/api/tags" >/dev/null 2>&1; then echo "ollama: cannot reach ${host} — is the Ollama container running?" >&2 return 1 fi } # _ollama_query <prompt> — send a prompt to the local model and print the response. # Assumes Ollama is reachable; call _ollama_check first if you need an early guard. _ollama_query() { local prompt="$1" local model="${OLLAMA_MODEL:-qwen3-coder:30b}" local host="${OLLAMA_HOST:-http://localhost:11434}" _ollama_check || return 1 # The prompt is piped into jq rather than passed with "--arg": Linux caps a # single argv entry at 128 KB (MAX_ARG_STRLEN), and a large staged diff or a # long source file sails straight through that ceiling — jq then dies with # "argument list too long". "-R -s" slurps stdin as one raw string, which jq # escapes exactly like --arg would. print -r -- "$prompt" \ | jq -Rs --arg model "$model" '{model: $model, prompt: ., stream: false}' \ | curl --silent "${host}/api/generate" --data-binary @- \ | jq -r '.response // empty' } # --------------------------------------------------------------------------- # Output helpers # --------------------------------------------------------------------------- # _ai_strip_fences <text> — return only the code, without the Markdown fences. # # Every prompt in this series ends with "no markdown fences" and models still # wrap their answer in ```language … ``` often enough that the output can't be # piped or pasted as-is. When at least one fence is present, only the fenced # regions are kept (which also drops the "Here is your test suite:" preamble); # when there is none, the text is returned untouched. _ai_strip_fences() { local text="$1" if [[ "$text" != *'```'* ]]; then print -r -- "$text" return 0 fi print -r -- "$text" | awk ' /^[[:space:]]*```/ { inside = !inside; next } inside { print } ' } # _ai_bat — print the name of the "bat" binary installed on this machine, if any. # Debian and Ubuntu ship the package as "batcat" because the "bat" name was # already taken by bacula-console-qt, so both spellings have to be probed. _ai_bat() { local candidate for candidate in bat batcat; do if command -v "$candidate" >/dev/null 2>&1; then print -- "$candidate" return 0 fi done return 1 } # _ai_relpath <path> — print <path> relative to the current folder when it sits # below it. Purely cosmetic: it keeps prompts readable instead of asking # "Save as /home/christophe/projects/…/tests/backup.bats?". _ai_relpath() { local path="${1:A}" print -- "${path#${PWD}/}" } # _ai_confirm <question> — ask a yes/no question; return 0 only on an explicit yes. _ai_confirm() { local answer print -n "${1} [y/N] " read -r answer [[ "$answer" == [yY]* ]] } # --------------------------------------------------------------------------- # Git helpers # --------------------------------------------------------------------------- # _git_staged_diff <caller> — validate the git context and return the staged diff. # Handles three early-exit cases (not a repo, nothing staged, oversized diff) # so every pre-commit function gets them for free without repeating the guards. # # Past AI_DIFF_MAX_CHARS the full diff is *not* returned: a diff of several # hundred kilobytes overflows the model's context window, and the answer that # comes back is worse than the one you get from a summary. The fallback keeps # the shape of the change — per-file stat, plus the file and hunk headers, # which carry the enclosing function names git puts after each "@@" — and # drops the line-by-line content. _git_staged_diff() { local caller="${1:-ai}" local max="${AI_DIFF_MAX_CHARS:-12000}" if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then echo "${caller}: not inside a git repository" >&2 return 1 fi local diff diff=$(git diff --staged) if [[ -z "$diff" ]]; then echo "${caller}: nothing staged — run 'git add' first" >&2 return 1 fi # "print -r" everywhere below: without it, print expands escape sequences and # a literal \n or \t inside the diff would come back mangled. if (( ${#diff} <= max )); then print -r -- "$diff" return 0 fi echo "${caller}: staged diff is large (${#diff} chars) — sending a structural summary instead of the full diff. Commit in smaller chunks for a more precise answer." >&2 local summary summary="$(git diff --staged --stat) --- FILE AND HUNK HEADERS ONLY (full diff omitted, ${#diff} chars) --- $(print -r -- "$diff" | grep -E '^(diff --git|new file|deleted file|rename (from|to)|@@)')" # Even the summary can exceed the ceiling on a very wide change. if (( ${#summary} > max )); then summary="${summary[1,$max]} […summary truncated…]" fi print -r -- "$summary" } # --------------------------------------------------------------------------- # Interactive argument helpers (used by the ai() dispatcher) # --------------------------------------------------------------------------- # _ai_prompt_file [label] — open an fzf file picker; print the chosen path. _ai_prompt_file() { local label="${1:-Select a file:}" local bat_bin preview='cat {}' bat_bin=$(_ai_bat) && preview="${bat_bin} --color=always {} 2>/dev/null || cat {}" if command -v fd >/dev/null 2>&1; then fd --type f | fzf --prompt="${label} " --height=50% --reverse --preview="$preview" else find . -type f | fzf --prompt="${label} " --height=50% --reverse --preview="$preview" fi } # _ai_prompt_language — open an fzf language picker; print the chosen language. _ai_prompt_language() { printf '%s\n' English French Dutch German Spanish Italian Portuguese Japanese Chinese Korean \ | fzf --prompt='Target language: ' --height=40% --reverse } # _ai_prompt_number [label] — read a positive integer; print it (or nothing on Enter, which # lets the caller fall back to its own default). Returns 1 only on invalid input. _ai_prompt_number() { local label="${1:-Number (Enter for default):}" print -n "${label} " local n read n if [[ -z "$n" ]]; then return 0 fi if [[ ! "$n" =~ ^[1-9][0-9]*$ ]]; then echo "Expected a positive integer." >&2 return 1 fi print -- "$n" } # _ai_prompt_text [label] — read a free-form string; print it. Returns 1 if empty. _ai_prompt_text() { local label="${1:-Input:}" print -n "${label} " local t read t if [[ -z "$t" ]]; then echo "No input given." >&2 return 1 fi print -- "$t" } # --------------------------------------------------------------------------- # Entry-point dispatcher # --------------------------------------------------------------------------- # ai → interactive fzf menu of every registered ai-* command # ai <cmd> [args] → dispatch straight to ai-<cmd> [args] # # When opened via the fzf menu, each command's declared AI_PARAMS type triggers # a secondary picker before the command runs: # file → fzf file picker (fd + preview if available) # language → fzf language list # number → read prompt, empty input uses the command's own default # text → read prompt, required # none → run the command directly (it collects its own input) # Space-separated values declare multiple parameters in order, e.g. "file language". ai() { local cmd="$1" # Direct dispatch: ai <cmd> [args] if [[ -n "$cmd" && "$cmd" != "help" && "$cmd" != "list" ]]; then if (( ${+AI_COMMANDS[$cmd]} )); then shift "ai-${cmd}" "$@" return fi echo "ai: unknown command '${cmd}'" >&2 echo >&2 fi # Interactive menu if ! command -v fzf >/dev/null 2>&1; then echo "Available ai commands:" for key in ${(ok)AI_COMMANDS}; do printf " ai %-10s %s\n" "$key" "$AI_COMMANDS[$key]" done return fi local picked picked=$( for key in ${(ok)AI_COMMANDS}; do printf '%s\t%s\n' "$key" "$AI_COMMANDS[$key]" done \ | fzf --delimiter='\t' --with-nth=2 --prompt='ai > ' --height=40% --reverse \ | cut -f1 ) [[ -z "$picked" ]] && return # Collect arguments interactively based on AI_PARAMS declaration local -a args=() local param_type for param_type in ${(s: :)${AI_PARAMS[$picked]:-none}}; do case "$param_type" in file) local f f=$(_ai_prompt_file "File for ai-${picked}:") || return 1 args+=("$f") ;; language) local l l=$(_ai_prompt_language) || return 1 args+=("$l") ;; number) local n n=$(_ai_prompt_number) || return 1 [[ -n "$n" ]] && args+=("$n") ;; text) local t t=$(_ai_prompt_text) || return 1 args+=("$t") ;; none) ;; esac done "ai-${picked}" "${args[@]}" } ```` **ai-data.zsh** ```bash # ai-data <file.json|file.csv> — analyze a JSON or CSV file and suggest 5 # practical jq/awk commands tailored to its actual fields. Pick one via fzf; # instead of running it for you, it's loaded into your command line with # `print -z` so you can read it, edit it, and learn from it before hitting # Enter yourself. AI_COMMANDS[data]="ai-data <file.json|file.csv> — suggest jq/awk commands tailored to this file" AI_PARAMS[data]="file" ai-data() { local file="$1" if [[ -z "$file" || ! -f "$file" ]]; then echo "Usage: ai-data <file.json|file.csv>" >&2 return 1 fi local ext="${file:e:l}" local sample tool case "$ext" in json) tool="jq" if jq -e 'type == "array"' "$file" >/dev/null 2>&1; then sample=$(jq -c '.[0:3]' "$file") else sample=$(head -c 3000 "$file") fi ;; csv) if command -v mlr >/dev/null 2>&1; then tool="awk, or mlr (Miller — detected as installed, prefer it for group-by/aggregation)" else tool="awk (Miller not detected — stick to awk/sort/uniq idioms)" fi sample=$(head -n 6 "$file") ;; *) echo "ai-data: unsupported extension .$ext (expected .json or .csv)" >&2 return 1 ;; esac echo "→ Analyzing $file ($ext)..." >&2 local prompt="You are a data analyst who writes $tool one-liners. Below is a sample of a $ext file — the field names and shapes are real, only the row count is truncated. Suggest exactly 5 practical, ready-to-run commands a reader would actually want on THIS file: filtering, extracting specific fields, counting, grouping. Reference the real filename '$file' in every command. Output exactly one command per line, in this exact format and nothing else — no numbering, no markdown fences: COMMAND ||| one-line description of what it answers --- SAMPLE --- $sample" local suggestions suggestions=$(_ollama_query "$prompt") || return 1 if [[ -z "$suggestions" ]]; then echo "ai-data: no suggestions returned" >&2 return 1 fi # "print -r" and never echo: zsh's echo expands escape sequences, and the # "\n" of an awk printf would split the suggestion over two lines. if ! command -v fzf >/dev/null 2>&1; then print -r -- "$suggestions" return 0 fi local picked picked=$(print -r -- "$suggestions" | fzf --delimiter=' \|\|\| ' --with-nth=2 \ --prompt="ai-data > " --height=40% --reverse) [[ -z "$picked" ]] && return 0 local cmd="${picked%% \|\|\| *}" # Same reason here: without -r, "\n" would reach the prompt as a real newline. print -rz -- "$cmd" } ``` **2. Chargez le dossier depuis `~/.zshrc`.** Déjà fait si vous avez suivi un article précédent de cette série. Sinon, ajoutez ces lignes à la fin de `~/.zshrc` — elles sourcent chaque fichier `.zsh` du dossier, donc la prochaine fonction que vous ajouterez ne demandera aucun changement ici : ```zsh title="~/.zshrc" for fn_file in ~/.zsh/fns/*.zsh; do source "$fn_file" done ``` **3. Ouvrez un nouveau terminal** (ou lancez `exec zsh` dans celui en cours), et vérifiez que le shell connaît la nouvelle fonction : ```zsh type ai-data ``` `ai-data is a shell function from …/ai-data.zsh` signifie que vous êtes prêt. `ai-data not found` signifie que l'étape 2 manque. ## Conclusion {#conclusion} `ai-data` est la fonction de cette série dont j'attends le plus en termes d'apprentissage, justement parce qu'elle refuse de me donner une réponse toute faite — elle me donne la commande et me laisse être celui qui la lance. Quelques semaines à piocher des filtres `jq` dans une liste `fzf` et à les lire avant d'appuyer sur Entrée, et je soupçonne que j'en aurai moins besoin qu'aujourd'hui. Ce serait une forme étrange de succès pour un outil d'IA : l'objectif n'est pas de l'utiliser pour toujours, c'est de se rendre discrètement moins nécessaire, un `group_by` à la fois. --- <!-- canonical-url: https://www.avonture.be/fr/blog/ollama-ai-explain published: 2026-11-12 This is a static plain-Markdown mirror generated at build time. Visit the canonical URL above for the fully rendered page, with images and interactive components. --> # ai-explain : l'ELI5 des scripts et des erreurs, directement dans votre terminal > Encore une fonction de la série Ollama au quotidien : envoyez n'importe quel script, message d'erreur ou stack trace à ai-explain et obtenez une explication en langage clair de ce qu'il fait ou de ce qui a planté — 100 % local, sans cloud. --- ![ai-explain : l'ELI5 des scripts et des erreurs, directement dans votre terminal](/img/v2/playing_with_ollama.webp) **TL;DR** `ai-explain` est une nouvelle fonction de la série Ollama au quotidien. Envoyez-lui un script, une sortie de log ou une stack trace — ou donnez-lui un chemin de fichier, ou collez un message d'erreur en argument — et vous obtenez une explication en langage clair : ce que fait le script, ce que signifie l'erreur et quoi corriger. Une fonction, trois modes d'entrée, zéro cloud. Vous avez cloné un repository et vous y trouvez un script Bash exécuté par le pipeline CI. Trois cents lignes, aucun commentaire. Vous devez comprendre ce qu'il fait avant de le modifier. Vous pouvez le lire attentivement, chercher la signification des flags, suivre la logique. Ou vous pouvez l'envoyer à `ai-explain`. Même histoire pour les stack traces. Une erreur React en production avec six frames de sortie minifiée. Un `Fatal error` PHP à la ligne 847 d'un fichier que vous n'avez jamais ouvert. Un build Docker qui échoue sur une étape RUN que vous n'avez pas écrite. `ai-explain` est la version terminal du [composant de tooltip ELI5](/blog/docusaurus-eli5-snippet-tooltips) de ce blog — la même idée « explique-moi comme si j'avais cinq ans », mais dans votre shell plutôt que dans le navigateur. ## Expliquer un script que vous n'avez pas écrit {#explain-a-script-you-didnt-write} **deploy.sh** ```bash #!/usr/bin/env bash set -euo pipefail for tool in docker git curl; do if ! command -v "$tool" >/dev/null 2>&1; then echo "Missing prerequisite: $tool" >&2 exit 1 fi done git pull --rebase origin main tag="myapp:$(git rev-parse --short HEAD)" docker build -t "$tag" . APP_IMAGE="$tag" docker compose up -d --no-deps app for _ in $(seq 1 12); do if [[ "$(curl -s -o /dev/null -w '%{http_code}' http://localhost:8080/health)" == "200" ]]; then echo "Deploy successful: $tag" exit 0 fi sleep 5 done echo "Health check failed for $tag" >&2 exit 1 ``` ```bash $ ai-explain deploy.sh This script automates deploying an application using Docker and Docker Compose. It first checks that `docker`, `git`, and `curl` are installed, exiting if any are missing. It pulls the latest code from the `main` branch and builds a new Docker image tagged with the current git commit hash. Then it starts the app service in detached mode without restarting dependent services. After starting the service, it waits up to 60 seconds (12 attempts of 5 seconds each) checking if the app responds with HTTP 200 at `/health`. If successful, it prints a success message and exits. If the health check fails after all attempts, it reports the failure and exits with error code 1. The script modifies the local Docker environment by building a new image and starting containers, but doesn't change any files on disk except through Git operations. Risks include failing to start the app or timing out if the service takes longer than 60 seconds to become healthy. To fix potential issues, ensure all prerequisites are installed and the app's health endpoint is correctly configured. ``` Voilà toute l'interaction : un chemin de fichier en entrée, une explication en langage clair en sortie — les prérequis, le tag d'image basé sur le hash du commit, le redémarrage avec `--no-deps`, le health check de 60 secondes et ce qui se passe en cas de timeout. ## Pourquoi ça fonctionne {#why-it-works} `ai-explain` gère trois modes d'entrée, vérifiés dans cet ordre : l'entrée via pipe (donc `cat file | ai-explain` l'emporte toujours, même si un nom de fichier est aussi passé), un fichier existant, ou du texte inline pour une question rapide — une seule fonction couvre « explique-moi ce fichier » et « explique-moi cette erreur » sans commandes séparées à retenir. Le prompt demande quinze lignes de texte maximum, jamais une copie de l'entrée, et transmet `uname -s` pour que les commandes suggérées correspondent à la plateforme sur laquelle vous êtes réellement. ## Installation {#installing-it} Tout tourne sur votre machine : zsh, `curl` et `jq` (qui construit la requête envoyée à Ollama), et un serveur Ollama accessible — voir [installer Ollama](/blog/ollama-installation) si vous n'en avez pas encore. **1. Créez les fichiers.** Chaque fonction de cette série vit dans son propre fichier dans `~/.zsh/fns/`. La commande ci-dessous crée ce dossier et les deux fichiers d'un coup — ou téléchargez le ZIP, ou créez chaque fichier à la main et collez-y son contenu. `_ollama.zsh` est la fondation partagée par toute la série, présentée dans [`ai-test`](/blog/ollama-test-generator) : si un article précédent vous l'a déjà donné, la copie ici est le même fichier. **Project: \~/.zsh/fns** **\_ollama.zsh** ````bash # Foundation file for every "ai-*" function in this series. Named with a # leading underscore: the ~/.zsh/fns/*.zsh loader sources files in # alphabetical order, and "_" sorts before any letter — so this file is # always loaded first, no matter how many ai-* functions get added later. # # Provides: # - _ollama_check reachability guard for Ollama # - _ollama_query shared HTTP client (curl + jq) # - _ai_strip_fences removes the Markdown fences models add anyway # - _ai_bat resolves "bat" vs Debian's "batcat" # - _ai_confirm yes/no prompt # - _git_staged_diff validated staged-diff fetcher (shared by pre-commit functions) # - AI_COMMANDS registry each ai-* function adds itself to # - AI_PARAMS registry of interactive parameter types (file/language/number/text/none) # - _ai_prompt_* FZF/read helpers for interactive argument collection # - ai entry-point dispatcher / interactive menu typeset -gA AI_COMMANDS typeset -gA AI_PARAMS # --------------------------------------------------------------------------- # Ollama helpers # --------------------------------------------------------------------------- # _ollama_check — verify that the Ollama daemon is reachable. # Fails loudly so callers can bail early rather than watching curl hang. _ollama_check() { local host="${OLLAMA_HOST:-http://localhost:11434}" if ! curl --silent --max-time 2 "${host}/api/tags" >/dev/null 2>&1; then echo "ollama: cannot reach ${host} — is the Ollama container running?" >&2 return 1 fi } # _ollama_query <prompt> — send a prompt to the local model and print the response. # Assumes Ollama is reachable; call _ollama_check first if you need an early guard. _ollama_query() { local prompt="$1" local model="${OLLAMA_MODEL:-qwen3-coder:30b}" local host="${OLLAMA_HOST:-http://localhost:11434}" _ollama_check || return 1 # The prompt is piped into jq rather than passed with "--arg": Linux caps a # single argv entry at 128 KB (MAX_ARG_STRLEN), and a large staged diff or a # long source file sails straight through that ceiling — jq then dies with # "argument list too long". "-R -s" slurps stdin as one raw string, which jq # escapes exactly like --arg would. print -r -- "$prompt" \ | jq -Rs --arg model "$model" '{model: $model, prompt: ., stream: false}' \ | curl --silent "${host}/api/generate" --data-binary @- \ | jq -r '.response // empty' } # --------------------------------------------------------------------------- # Output helpers # --------------------------------------------------------------------------- # _ai_strip_fences <text> — return only the code, without the Markdown fences. # # Every prompt in this series ends with "no markdown fences" and models still # wrap their answer in ```language … ``` often enough that the output can't be # piped or pasted as-is. When at least one fence is present, only the fenced # regions are kept (which also drops the "Here is your test suite:" preamble); # when there is none, the text is returned untouched. _ai_strip_fences() { local text="$1" if [[ "$text" != *'```'* ]]; then print -r -- "$text" return 0 fi print -r -- "$text" | awk ' /^[[:space:]]*```/ { inside = !inside; next } inside { print } ' } # _ai_bat — print the name of the "bat" binary installed on this machine, if any. # Debian and Ubuntu ship the package as "batcat" because the "bat" name was # already taken by bacula-console-qt, so both spellings have to be probed. _ai_bat() { local candidate for candidate in bat batcat; do if command -v "$candidate" >/dev/null 2>&1; then print -- "$candidate" return 0 fi done return 1 } # _ai_relpath <path> — print <path> relative to the current folder when it sits # below it. Purely cosmetic: it keeps prompts readable instead of asking # "Save as /home/christophe/projects/…/tests/backup.bats?". _ai_relpath() { local path="${1:A}" print -- "${path#${PWD}/}" } # _ai_confirm <question> — ask a yes/no question; return 0 only on an explicit yes. _ai_confirm() { local answer print -n "${1} [y/N] " read -r answer [[ "$answer" == [yY]* ]] } # --------------------------------------------------------------------------- # Git helpers # --------------------------------------------------------------------------- # _git_staged_diff <caller> — validate the git context and return the staged diff. # Handles three early-exit cases (not a repo, nothing staged, oversized diff) # so every pre-commit function gets them for free without repeating the guards. # # Past AI_DIFF_MAX_CHARS the full diff is *not* returned: a diff of several # hundred kilobytes overflows the model's context window, and the answer that # comes back is worse than the one you get from a summary. The fallback keeps # the shape of the change — per-file stat, plus the file and hunk headers, # which carry the enclosing function names git puts after each "@@" — and # drops the line-by-line content. _git_staged_diff() { local caller="${1:-ai}" local max="${AI_DIFF_MAX_CHARS:-12000}" if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then echo "${caller}: not inside a git repository" >&2 return 1 fi local diff diff=$(git diff --staged) if [[ -z "$diff" ]]; then echo "${caller}: nothing staged — run 'git add' first" >&2 return 1 fi # "print -r" everywhere below: without it, print expands escape sequences and # a literal \n or \t inside the diff would come back mangled. if (( ${#diff} <= max )); then print -r -- "$diff" return 0 fi echo "${caller}: staged diff is large (${#diff} chars) — sending a structural summary instead of the full diff. Commit in smaller chunks for a more precise answer." >&2 local summary summary="$(git diff --staged --stat) --- FILE AND HUNK HEADERS ONLY (full diff omitted, ${#diff} chars) --- $(print -r -- "$diff" | grep -E '^(diff --git|new file|deleted file|rename (from|to)|@@)')" # Even the summary can exceed the ceiling on a very wide change. if (( ${#summary} > max )); then summary="${summary[1,$max]} […summary truncated…]" fi print -r -- "$summary" } # --------------------------------------------------------------------------- # Interactive argument helpers (used by the ai() dispatcher) # --------------------------------------------------------------------------- # _ai_prompt_file [label] — open an fzf file picker; print the chosen path. _ai_prompt_file() { local label="${1:-Select a file:}" local bat_bin preview='cat {}' bat_bin=$(_ai_bat) && preview="${bat_bin} --color=always {} 2>/dev/null || cat {}" if command -v fd >/dev/null 2>&1; then fd --type f | fzf --prompt="${label} " --height=50% --reverse --preview="$preview" else find . -type f | fzf --prompt="${label} " --height=50% --reverse --preview="$preview" fi } # _ai_prompt_language — open an fzf language picker; print the chosen language. _ai_prompt_language() { printf '%s\n' English French Dutch German Spanish Italian Portuguese Japanese Chinese Korean \ | fzf --prompt='Target language: ' --height=40% --reverse } # _ai_prompt_number [label] — read a positive integer; print it (or nothing on Enter, which # lets the caller fall back to its own default). Returns 1 only on invalid input. _ai_prompt_number() { local label="${1:-Number (Enter for default):}" print -n "${label} " local n read n if [[ -z "$n" ]]; then return 0 fi if [[ ! "$n" =~ ^[1-9][0-9]*$ ]]; then echo "Expected a positive integer." >&2 return 1 fi print -- "$n" } # _ai_prompt_text [label] — read a free-form string; print it. Returns 1 if empty. _ai_prompt_text() { local label="${1:-Input:}" print -n "${label} " local t read t if [[ -z "$t" ]]; then echo "No input given." >&2 return 1 fi print -- "$t" } # --------------------------------------------------------------------------- # Entry-point dispatcher # --------------------------------------------------------------------------- # ai → interactive fzf menu of every registered ai-* command # ai <cmd> [args] → dispatch straight to ai-<cmd> [args] # # When opened via the fzf menu, each command's declared AI_PARAMS type triggers # a secondary picker before the command runs: # file → fzf file picker (fd + preview if available) # language → fzf language list # number → read prompt, empty input uses the command's own default # text → read prompt, required # none → run the command directly (it collects its own input) # Space-separated values declare multiple parameters in order, e.g. "file language". ai() { local cmd="$1" # Direct dispatch: ai <cmd> [args] if [[ -n "$cmd" && "$cmd" != "help" && "$cmd" != "list" ]]; then if (( ${+AI_COMMANDS[$cmd]} )); then shift "ai-${cmd}" "$@" return fi echo "ai: unknown command '${cmd}'" >&2 echo >&2 fi # Interactive menu if ! command -v fzf >/dev/null 2>&1; then echo "Available ai commands:" for key in ${(ok)AI_COMMANDS}; do printf " ai %-10s %s\n" "$key" "$AI_COMMANDS[$key]" done return fi local picked picked=$( for key in ${(ok)AI_COMMANDS}; do printf '%s\t%s\n' "$key" "$AI_COMMANDS[$key]" done \ | fzf --delimiter='\t' --with-nth=2 --prompt='ai > ' --height=40% --reverse \ | cut -f1 ) [[ -z "$picked" ]] && return # Collect arguments interactively based on AI_PARAMS declaration local -a args=() local param_type for param_type in ${(s: :)${AI_PARAMS[$picked]:-none}}; do case "$param_type" in file) local f f=$(_ai_prompt_file "File for ai-${picked}:") || return 1 args+=("$f") ;; language) local l l=$(_ai_prompt_language) || return 1 args+=("$l") ;; number) local n n=$(_ai_prompt_number) || return 1 [[ -n "$n" ]] && args+=("$n") ;; text) local t t=$(_ai_prompt_text) || return 1 args+=("$t") ;; none) ;; esac done "ai-${picked}" "${args[@]}" } ```` **ai-explain.zsh** ```bash # ai-explain [file|text] — explain a script, an error message or any piped # output in plain English. Three input modes, checked in this order: # cat error.log | ai-explain piped input always wins # ai-explain deploy.sh an existing file is read # ai-explain 'error: undefined symbol' anything else is the text itself AI_COMMANDS[explain]="ai-explain [file|text] — explain a script, an error or piped output in plain English" AI_PARAMS[explain]="file" ai-explain() { local input if [[ ! -t 0 ]]; then input=$(cat) elif [[ -f "$1" ]]; then input=$(cat "$1") elif (( $# > 0 )); then input="$*" else echo 'Usage: ai-explain <file> or command 2>&1 | ai-explain or ai-explain "error message"' >&2 return 1 fi if [[ -z "$input" ]]; then echo "ai-explain: empty input — nothing to explain" >&2 return 1 fi local prompt="You are a helpful assistant that explains technical content in plain English. Look at the content below and: - If it is a script (bash, zsh, python, php, etc.): explain what it does step by step. Mention what it produces, what it modifies, and any side effects or risks. - If it is an error message, stack trace, or log output: explain what went wrong, why it happened, and what to do to fix it. - If you are not sure: describe what you see and give your best interpretation. Keep the explanation concrete and practical. No hype, no filler. If there is a fix, give the exact command or change. Never copy the input back: name the command or the line you are talking about instead. Plain text, no headings, no emoji, at most 15 lines. The user is on $(uname -s), so only suggest commands for that platform. --- ${input}" _ollama_query "$prompt" } ``` **2. Chargez le dossier depuis `~/.zshrc`.** Déjà fait si vous avez suivi un article précédent de cette série. Sinon, ajoutez ces lignes à la fin de `~/.zshrc` — elles sourcent chaque fichier `.zsh` du dossier, donc la prochaine fonction que vous ajouterez ne demandera aucune modification ici : ```zsh title="~/.zshrc" for fn_file in ~/.zsh/fns/*.zsh; do source "$fn_file" done ``` **3. Ouvrez un nouveau terminal** (ou lancez `exec zsh` dans celui en cours), et vérifiez que le shell connaît la nouvelle fonction : ```zsh type ai-explain ``` `ai-explain is a shell function from …/ai-explain.zsh` signifie que vous êtes prêt. `ai-explain not found` signifie qu'il manque l'étape 2. ## Autres démos {#more-demos} ### Expliquer une stack trace {#explain-a-stack-trace} **example-error.txt** ```txt Fatal error: Uncaught TypeError: Cannot read properties of undefined (reading 'map') at ProductList (/app/src/components/ProductList.jsx:42:32) at renderWithHooks (/app/node_modules/react-dom/cjs/react-dom.development.js:14985:18) at mountIndeterminateComponent (/app/node_modules/react-dom/cjs/react-dom.development.js:17811:13) at beginWork (/app/node_modules/react-dom/cjs/react-dom.development.js:19049:16) at HTMLUnknownElement.callCallback (/app/node_modules/react-dom/cjs/react-dom.development.js:3945:14) at Object.invokeGuardedCallbackDev (/app/node_modules/react-dom/cjs/react-dom.development.js:3994:16) The above error occurred in the <ProductList> component. Consider adding an error boundary to your tree to customize error handling behavior. ``` ````bash $ ai-explain example-error.txt The error happens because the code tries to call `.map()` on a variable that is `undefined` in the ProductList component. This occurs at line 42 of ProductList.jsx where it expects an array but gets nothing. To fix this, add a check to ensure the data exists before mapping: ```jsx {products && products.map(product => ( <div key={product.id}>{product.name}</div> ))} ``` If `products` is undefined, the component won't crash and will render nothing instead of throwing an error. ```` ### Envoyer la sortie de n'importe quelle commande {#pipe-any-command-output} Le pattern le plus utile : lancez une commande qui échoue et envoyez sa sortie directement : ```bash docker build . 2>&1 | ai-explain ``` ```bash npm install 2>&1 | ai-explain ``` ```bash ./run-tests.sh 2>&1 | tail -50 | ai-explain ``` Le `2>&1` redirige stderr vers stdout pour que les deux streams arrivent dans le pipe. Sans lui, les messages d'erreur de la plupart des outils CLI n'atteignent jamais `ai-explain`. Pour les commandes qui produisent beaucoup de sortie, passez d'abord par `tail` pour ne garder que la fin pertinente (là où les erreurs atterrissent généralement). ### Utilisation depuis le dispatcher `ai` {#use-from-the-ai-dispatcher} Comme toutes les fonctions de cette série, `ai-explain` s'enregistre dans le registre `AI_COMMANDS`. Le dispatcher `ai` (issu de `_ollama.zsh`) liste chaque fonction enregistrée — ici avec les fonctions publiées de cette série installées : ```bash $ ai ai > ai-blog-search — ask your blog a question (RAG over every published article) ai-commit — draft a Conventional Commits message from staged changes ai-data <file.json|file.csv> — suggest jq/awk commands tailored to this file ai-diagram <description>|<file> — turn a description or a config file into a Mermaid diagram ai-explain [file|text] — explain a script, an error or piped output in plain English ai-review — code review of staged changes (SOLID, magic constants, naming) ai-secrets — scan staged changes for hardcoded credentials ai-summarize <file> [points] — summarize a document locally (default: 5 points) ai-test <file.sh|.php|.py> — generate or complete a unit test suite ai-translate <file> [language] — translate a document locally (default: French) ``` Sélectionnez `ai-explain` et le dispatcher ouvre d'abord son sélecteur de fichiers fzf : `AI_PARAMS[explain]="file"` lui indique de demander un fichier plutôt qu'une ligne de texte. ## Sous le capot (passez votre chemin si vous voulez juste l'utiliser) {#under-the-hood-skip-this-if-you-just-want-to-use-it} ### Quand ai-explain est le plus utile {#when-ai-explain-is-most-useful} * **Scripts hérités** : comprendre ce que fait un script CI/CD avant de le modifier * **Erreurs inconnues** : une stack trace d'un langage ou d'un framework que vous maîtrisez mal * **Sorties de log cryptiques** : avertissements du daemon Docker, entrées du journal systemd, messages du kernel * **Archéologie de one-liners** : cette commande `awk '{ split($0,a,":"); ... }'` trouvée dans un Makefile * **Vérification rapide** : est-ce que ce script fait bien ce que son nom suggère ? C'est aussi utile comme outil d'apprentissage : envoyez-lui un script que vous avez écrit vous-même et voyez si l'explication correspond à votre intention. Si ce n'est pas le cas, soit l'explication est fausse (ça arrive), soit le script contient un bug subtil. ## Conclusion {#conclusion} `ai-explain` rejoint la série « Ollama daily use ». La série a commencé avec [`ai-test`](/blog/ollama-test-generator) (la fondation : `_ollama.zsh`, le dispatcher, le registre `AI_COMMANDS`) et s'est enrichie de fonctions une par une — chacune résolvant un problème précis de workflow dans le terminal avec un LLM local : * **Workflow du développeur** : [`ai-review`, `ai-secrets`, `ai-commit`](/blog/ollama-git-precommit), `ai-standup` * **Opérations** : `ai-ci`, `ai-fix` * **Assistance shell** : `ai-ask`, [`ai-data`](/blog/ollama-ai-data), [`ai-diagram`](/blog/ollama-ai-diagram) * **Travail documentaire** : [`ai-translate`, `ai-summarize`](/blog/ollama-ai-docs), `ai-diff` * **Votre propre documentation** : [`ai-blog-search`](/blog/anythingllm-chat-with-your-docs) * **Explication** : `ai-explain` Toutes partagent la même fondation `_ollama_query`. Ensemble, elles couvrent les moments d'une journée de développement où vous iriez normalement chercher dans un navigateur ou une IA dans le cloud — mais où un modèle local est plus rapide d'accès et garde tout sur votre machine. Les fonctions sans lien sont encore à venir. Lancez `ai` pour voir ce que votre terminal sait déjà faire. ---