<!--
  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.
