---
title: Configuration LibreOffice / OpenOffice Calc — chaîne eval, mode colonne
description: Configuration de la macro Rukh Eval dans Open Calc.
date: 2026-09-08
lang: fr-FR
author: Julien Béranger
model: Claude Opus 5
source: https://julienberanger.com/rukh-eval-libreoffice-calc
---

# Configuration LibreOffice / OpenOffice Calc — chaîne eval, mode colonne

Variante « par lots » de la configuration cellule par cellule
(`RUKH_EVAL_OPEN_CALC_CONFIG.md`). Même grille,
même colonne de sortie, même bouton — mais **un appel traite une vingtaine de
critères au lieu d'un seul**. La macro construit un CSV à partir de la feuille,
le colle dans le message, reçoit une ligne par enregistrement et les réécrit
dans la colonne de sortie.

Contexte dédié : **`eval-oc-full`**.

C'est la transposition du contexte `eval` — CSV brut envoyé en une fois — à un
tableur qui sait le fabriquer lui-même. Ce que Calc apporte par rapport au
copier-coller manuel : le CSV est propre par construction, et le **calage est
vérifié automatiquement** avant écriture.

## Quel mode choisir

| | `eval-oc` (cellule par cellule) | `eval-oc-full` (mode colonne) |
|---|---|---|
| Appels pour un chapitre de 57 critères | **57** | **3** |
| Quota 50/h | atteint au bout d'un chapitre | ~16 chapitres possibles par heure |
| Coût d'entrée par chapitre | ~0,46 $ | **~0,04 $** |
| Cohérence entre cellules | verbes seulement, via `Débuts déjà utilisés` | **le modèle voit tout le lot** |
| Acronymes | redéveloppés dans chaque cellule | développés une fois par lot |
| Granularité de reprise | la cellule | **le lot** : un lot rejeté est à refaire en entier |
| Durée d'un appel | ~6 s | ~1 à 2 min (estimation, à mesurer) |
| Risque de dépassement de délai | négligeable | **réel** — c'est la contrainte du mode |
| Refaire une cellule isolée | naturel | possible, mais l'autre mode est plus adapté |

En pratique : **`eval-oc-full` pour traiter un chapitre**, `eval-oc` pour
reprendre les quelques cellules dont la réécriture ne convient pas. Les deux
écrivent dans la même colonne `Commentaires réécrits` et se relaient sans
précaution particulière.

## Ce que la macro envoie

Un message par lot, construit à partir de la feuille — jamais un fichier joint,
jamais un export manuel :

```
Enregistrements : 20
Débuts déjà utilisés : La personne indique qu'elle est ac | Les professionnels précisent

Chapitre;Critère;Cotations;Commentaires
"Chapitre 1";"1.1.1";"4;3;4";"AT2 : la personne explique qu'elle est accompagnée depuis 2021…"
"Chapitre 1";"1.1.2";"3";"les professionnels disent qu'il n'y a pas eu de formation"
```

Trois écarts délibérés par rapport au CSV que traite le contexte `eval` :

- **Seules les lignes de critère sont envoyées.** Les lignes `E.E.` ne portent
  aucun commentaire à réécrire ; dans `eval` elles n'existent dans la réponse
  que pour produire un `-` de calage, parce qu'un humain colle la colonne
  entière dans la feuille. Ici la macro écrit cellule par cellule à des
  positions qu'elle connaît : ces lignes de remplissage n'ont plus de raison
  d'être. Sur le chapitre de référence, cela fait **57 lignes de sortie au lieu
  de 180**, donc autant de risque de décalage et de jetons de sortie en moins.
- **Les cotations des lignes `E.E.` sont regroupées** dans une colonne
  `Cotations` sur la ligne du critère. Elles restent nécessaires à la règle
  « cependant », elles ne coûtent plus une ligne chacune.
- **Un enregistrement = une ligne physique.** La macro remplace les retours à
  la ligne des cellules par des espaces avant d'écrire le CSV. La « première
  cause de décalage » signalée dans les instructions du contexte `eval` — une
  cellule entre guillemets qui s'étale sur plusieurs lignes — disparaît à la
  source.

La ligne `Enregistrements : N` annonce le nombre de lignes attendues en retour.
`Débuts déjà utilisés :` transmet les deux derniers débuts du lot précédent,
pour que la variation des verbes franchisse les bornes de lot.

## Le garde-fou de calage

C'est ce qui rend le mode colonne utilisable sans relecture ligne à ligne.

À la réception, la macro retire l'encadrement ```` ``` ````, ignore les lignes
vides, et **compte les lignes**. Si le compte ne correspond pas exactement au
nombre d'enregistrements envoyés, **rien n'est écrit pour ce lot** : une
relance automatique est tentée (`RETRIES`), puis la macro s'arrête en indiquant
le lot en cause. Les lots précédents restent écrits.

Autrement dit, un décalage ne peut pas s'installer silencieusement dans la
colonne. C'est le contrôle que `eval-verif` effectuait a posteriori sur une
colonne collée à la main, ramené à l'intérieur de la boucle et exécuté avant
toute écriture.

Ce que le garde-fou ne vérifie pas : que la ligne *n* corresponde bien au
critère *n*. Un modèle qui produirait le bon nombre de lignes dans le mauvais
ordre passerait le contrôle. C'est peu probable — l'ordre du CSV est explicite
et les identifiants de critère sont transmis — mais cela reste la raison de
relire un chapitre avant de le rendre.

## Prérequis

Identiques à ceux du mode cellule par cellule, à un point près : c'est le
contexte **`eval-oc-full`** qui doit être déployé.

- **LibreOffice 7.x ou plus récent, avec le fournisseur de scripts Python.**
  Inclus par défaut sous Windows et macOS ; sous Linux, installer au besoin
  `libreoffice-script-provider-python` (Debian/Ubuntu) ou `libreoffice-pyuno`
  (Fedora).
- **Accès réseau direct vers `rukh.w3hc.org`.** `urllib` lit `http_proxy` /
  `https_proxy`, pas le proxy configuré dans LibreOffice.
- Aucune authentification, aucun jeton.
- Le contexte `eval-oc-full` déployé sur l'API (voir « Déploiement du contexte
  `eval-oc-full` », en fin de page).
- **Apache OpenOffice** : voir « Variante Apache OpenOffice », plus bas.

## Mise en place (une fois)

### 1. Le pont Python

Déposer `rukh.py` dans `Scripts/python` du profil utilisateur :

| Système | Chemin |
|---|---|
| macOS | `~/Library/Application Support/LibreOffice/4/user/Scripts/python/` |
| Linux | `~/.config/libreoffice/4/user/Scripts/python/` |
| Windows | `%APPDATA%\LibreOffice\4\user\Scripts\python\` |

```python
import json
import urllib.request
import urllib.error

RUKH_URL = "https://rukh.w3hc.org/ask"
TIMEOUT = 300  # secondes ; ~6 s en mode cellule, 1 à 2 min en mode colonne

def ask(message, context):
    """Renvoie "OK:<texte>" ou "ERR:<code>". Les exceptions Python ne
    traversent pas proprement le pont UNO : le code d'erreur voyage donc dans
    la chaîne de retour, ce qui permet à Basic de distinguer un 429 d'une
    vraie panne."""
    body = json.dumps(
        {"message": message, "model": "anthropic", "context": context}
    ).encode("utf-8")
    req = urllib.request.Request(
        RUKH_URL, data=body, headers={"Content-Type": "application/json"}
    )
    try:
        with urllib.request.urlopen(req, timeout=TIMEOUT) as r:
            return "OK:" + json.loads(r.read().decode("utf-8"))["output"]
    except urllib.error.HTTPError as e:
        return "ERR:%d" % e.code
    except Exception as e:
        return "ERR:0 %s" % e

g_exportedScripts = (ask,)
```

**Le nom du contexte n'est pas figé dans ce fichier** : il arrive en paramètre
de `ask`. La valeur, elle, est bien une constante — mais côté Basic, une par
macro : `CONTEXT_FULL = "eval-oc-full"` ici, `CONTEXT_CELL = "eval-oc"` dans la
macro cellule par cellule. Un seul pont Python sert donc les deux chaînes. Si
le `rukh.py` de la configuration cellule par cellule est déjà en place, le
remplacer par celui-ci.

**Redémarrer LibreOffice** après le dépôt.

### 2. La macro Basic

`Outils → Macros → Modifier les macros`, puis **Mes macros → Standard →
Module1** — impérativement là, et non dans le classeur, pour éviter l'invite de
confiance à chaque ouverture du `.ods`.

Les deux macros peuvent cohabiter dans le même module : elles partagent
`RukhAsk`, `Norm`, `StartsWith`, `JoinOpenings`, `StripFence` et `OneLine`,
strictement identiques dans les deux documents.

**Si la macro cellule par cellule est déjà installée**, ne coller ci-dessous
que `FormaliserColonne`, `Csv` et `SplitLines`, plus les constantes
`CONTEXT_FULL`, `CHUNK` et `RETRIES`. Coller le bloc entier provoquerait une
erreur de compilation : `Option Explicit`, `RUKH_SCRIPT`, `TARGET_HEADER`,
`MAX_CALLS`, `OPENING_CHARS`, `oRukhScript` et les six fonctions partagées
seraient déclarés deux fois.

### 3. Le bouton

`Affichage → Barres d'outils → Contrôles de formulaire`, dessiner un bouton,
propriétés → **Événements → Exécuter l'action** → `FormaliserColonne`.

## La macro

```basic
Option Explicit

Const RUKH_SCRIPT = "vnd.sun.star.script:rukh.py$ask?language=Python&location=user"
Const CONTEXT_FULL = "eval-oc-full"
Const TARGET_HEADER = "Commentaires réécrits"
Const CHUNK = 20              ' enregistrements par appel — le seul réglage à connaître
Const RETRIES = 1             ' relances automatiques sur écart de compte
Const MAX_CALLS = 45          ' garde-fou par exécution, marge sous le quota de 50/heure
Const OPENING_CHARS = 32      ' longueur des débuts transmis au lot suivant

Dim oRukhScript As Object     ' résolu une fois, réutilisé pour toute l'exécution

Sub FormaliserColonne
    Dim oDoc As Object, oSheet As Object, oCur As Object, oSel As Object, oAddr As Object
    Dim oInd As Object
    Dim lastRow As Long, lastCol As Long, headerRow As Long
    Dim cChapitre As Long, cCritere As Long, cCotation As Long, cComm As Long
    Dim cQuestion As Long, cTarget As Long
    Dim selStart As Long, selEnd As Long, bSingle As Boolean
    Dim aTodo() As Long, nTodo As Long, nDone As Long, nCalls As Long
    Dim aOpen(1) As String
    Dim aLines() As String, nOut As Long
    Dim nPos As Long, nIn As Long, nTry As Long
    Dim r As Long, c As Long, j As Long, k As Long
    Dim h As String, s As String, sCot As String, sPayload As String, sRes As String

    oDoc = ThisComponent
    oSheet = oDoc.CurrentController.ActiveSheet

    oCur = oSheet.createCursor()
    oCur.gotoEndOfUsedArea(False)
    lastRow = oCur.RangeAddress.EndRow
    lastCol = oCur.RangeAddress.EndColumn

    ' --- ligne d'en-tête : colonne A = « Chapitre » ---
    headerRow = -1
    For r = 0 To lastRow
        If Norm(oSheet.getCellByPosition(0, r).getString()) = "chapitre" Then
            headerRow = r
            Exit For
        End If
    Next r
    If headerRow < 0 Then
        MsgBox "Ligne d'en-tête introuvable (colonne A = « Chapitre »).", 16, "Rukh"
        Exit Sub
    End If

    ' --- repérage des colonnes par intitulé, jamais par position ---
    cChapitre = -1 : cCritere = -1 : cCotation = -1 : cComm = -1 : cQuestion = -1 : cTarget = -1
    For c = 0 To lastCol
        h = Norm(oSheet.getCellByPosition(c, headerRow).getString())
        If h = "chapitre" And cChapitre < 0 Then cChapitre = c
        If h = "critère" And cCritere < 0 Then cCritere = c   ' « Critère ID » exclu : identifiant interne
        If StartsWith(h, "cotation") And cCotation < 0 Then cCotation = c
        If h = Norm(TARGET_HEADER) Then
            cTarget = c
        ElseIf StartsWith(h, "commentaires") And cComm < 0 Then
            cComm = c        ' la colonne de sortie commence aussi par « Commentaires » : à exclure
        End If
        If StartsWith(h, "is question") And cQuestion < 0 Then cQuestion = c
    Next c
    If cChapitre < 0 Or cComm < 0 Or cQuestion < 0 Then
        MsgBox "Colonnes « Chapitre », « Commentaires » ou « Is Question? » introuvables.", 16, "Rukh"
        Exit Sub
    End If

    If cTarget < 0 Then
        cTarget = lastCol + 1
        oSheet.getCellByPosition(cTarget, headerRow).setString(TARGET_HEADER)
    End If

    ' --- périmètre : c'est la sélection qui dit quoi traiter ---
    oSel = oDoc.CurrentSelection
    If IsNull(oSel) Then
        MsgBox "Sélectionner d'abord une cellule ou une plage.", 16, "Rukh"
        Exit Sub
    End If
    If oSel.supportsService("com.sun.star.sheet.SheetCellRanges") Then
        oAddr = oSel.getRangeAddresses()(0)   ' sélection multiple : première plage seulement
    ElseIf oSel.supportsService("com.sun.star.sheet.SheetCellRange") Then
        oAddr = oSel.RangeAddress
    Else
        MsgBox "Sélectionner d'abord une cellule ou une plage.", 16, "Rukh"
        Exit Sub
    End If
    selStart = oAddr.StartRow
    selEnd = oAddr.EndRow
    bSingle = (selStart = selEnd)

    ' --- enregistrements à traiter : lignes de critère non encore réécrites ---
    ReDim aTodo(lastRow)
    nTodo = 0
    For r = headerRow + 1 To lastRow
        If r >= selStart And r <= selEnd Then
            If StartsWith(oSheet.getCellByPosition(cChapitre, r).getString(), "Chapitre ") Then
                If Trim(oSheet.getCellByPosition(cQuestion, r).getString()) = "" Then
                    If Trim(oSheet.getCellByPosition(cComm, r).getString()) <> "" Then
                        If bSingle Or Trim(oSheet.getCellByPosition(cTarget, r).getString()) = "" Then
                            aTodo(nTodo) = r
                            nTodo = nTodo + 1
                        End If
                    End If
                End If
            End If
        End If
    Next r

    If nTodo = 0 Then
        MsgBox "Rien à faire : toutes les cellules de la sélection sont déjà réécrites.", 64, "Rukh"
        Exit Sub
    End If

    ' --- mémoire des débuts, réamorcée depuis la colonne de sortie ---
    aOpen(0) = "" : aOpen(1) = ""
    For r = aTodo(0) - 1 To headerRow + 1 Step -1
        s = Trim(oSheet.getCellByPosition(cTarget, r).getString())
        If s <> "" Then
            If aOpen(1) = "" Then
                aOpen(1) = Left(s, OPENING_CHARS)
            Else
                aOpen(0) = Left(s, OPENING_CHARS)
                Exit For
            End If
        End If
    Next r

    oInd = oDoc.CurrentController.Frame.createStatusIndicator()
    oInd.start("Rukh : réécriture par lots…", nTodo)
    nPos = 0 : nDone = 0 : nCalls = 0

    Do While nPos < nTodo
        If nCalls >= MAX_CALLS Then Exit Do

        nIn = nTodo - nPos
        If nIn > CHUNK Then nIn = CHUNK

        ' --- construction du CSV du lot ---
        sPayload = "Enregistrements : " & nIn & Chr(10)
        If aOpen(0) <> "" Or aOpen(1) <> "" Then
            sPayload = sPayload & "Débuts déjà utilisés : " & JoinOpenings(aOpen) & Chr(10)
        End If
        sPayload = sPayload & Chr(10) & "Chapitre;Critère;Cotations;Commentaires" & Chr(10)

        For k = 0 To nIn - 1
            r = aTodo(nPos + k)

            ' cotations des lignes E.E. rattachées à ce critère, pour la règle « cependant »
            sCot = ""
            For j = r + 1 To lastRow
                If Trim(oSheet.getCellByPosition(cQuestion, j).getString()) = "" Then Exit For
                If cCotation >= 0 Then
                    s = Trim(oSheet.getCellByPosition(cCotation, j).getString())
                    If s <> "" Then
                        If sCot <> "" Then sCot = sCot & ";"
                        sCot = sCot & s
                    End If
                End If
            Next j

            s = ""
            If cCritere >= 0 Then s = oSheet.getCellByPosition(cCritere, r).getString()

            ' OneLine() sur le commentaire : un enregistrement tient sur une ligne physique,
            ' ce qui supprime à la source la principale cause de décalage.
            sPayload = sPayload & _
                Csv(oSheet.getCellByPosition(cChapitre, r).getString()) & ";" & _
                Csv(s) & ";" & Csv(sCot) & ";" & _
                Csv(OneLine(oSheet.getCellByPosition(cComm, r).getString())) & Chr(10)
        Next k

        ' --- appel, avec relance sur écart de compte ---
        nOut = -1
        For nTry = 0 To RETRIES
            sRes = RukhAsk(sPayload, CONTEXT_FULL)
            nCalls = nCalls + 1

            If Left(sRes, 7) = "ERR:429" Then
                oInd.end()
                MsgBox "Quota atteint après " & nDone & " cellule(s). Reste " & (nTodo - nDone) & _
                       ". Relancer dans une heure.", 48, "Rukh"
                Exit Sub
            ElseIf Left(sRes, 4) = "ERR:" Then
                oInd.end()
                MsgBox "Erreur " & Mid(sRes, 5) & " sur le lot commençant ligne " & (aTodo(nPos) + 1) & _
                       ". " & nDone & " cellule(s) écrite(s), conservées.", 16, "Rukh"
                Exit Sub
            End If

            nOut = SplitLines(StripFence(Mid(sRes, 4)), aLines())
            If nOut = nIn Then Exit For
        Next nTry

        ' --- garde-fou de calage : sur écart, ce lot n'est pas écrit du tout ---
        If nOut <> nIn Then
            oInd.end()
            MsgBox nOut & " ligne(s) reçue(s) pour " & nIn & " enregistrement(s) envoyé(s) : " & _
                   "ce lot n'a pas été écrit, pour ne pas décaler la colonne." & Chr(10) & _
                   "Lot commençant ligne " & (aTodo(nPos) + 1) & ". " & nDone & _
                   " cellule(s) écrite(s) avant, conservées." & Chr(10) & _
                   "Réduire CHUNK puis relancer : la reprise repartira de ce lot.", 16, "Rukh"
            Exit Sub
        End If

        ' --- écriture du lot ---
        For k = 0 To nIn - 1
            oSheet.getCellByPosition(cTarget, aTodo(nPos + k)).setString(OneLine(aLines(k)))
            nDone = nDone + 1
        Next k
        oInd.setValue(nDone)

        ' --- débuts transmis au lot suivant ---
        If nIn >= 2 Then
            aOpen(0) = Left(OneLine(aLines(nIn - 2)), OPENING_CHARS)
        Else
            aOpen(0) = aOpen(1)
        End If
        aOpen(1) = Left(OneLine(aLines(nIn - 1)), OPENING_CHARS)

        nPos = nPos + nIn
    Loop

    oInd.end()

    If nTodo - nDone > 0 Then
        MsgBox nDone & " cellule(s) réécrite(s) en " & nCalls & " appel(s). Reste " & _
               (nTodo - nDone) & " : relancer.", 64, "Rukh"
    Else
        MsgBox nDone & " cellule(s) réécrite(s) en " & nCalls & " appel(s). Sélection terminée.", 64, "Rukh"
    End If
End Sub

' --- pont vers le script Python ---------------------------------------------

Function RukhAsk(sMessage As String, sContext As String) As String
    Dim oFactory As Object
    Dim aArgs(1) As Variant
    Dim aOutIdx(), aOutArgs()

    If IsNull(oRukhScript) Then
        oFactory = createUnoService("com.sun.star.script.provider.MasterScriptProviderFactory")
        oRukhScript = oFactory.createScriptProvider("").getScript(RUKH_SCRIPT)
    End If

    aArgs(0) = sMessage
    aArgs(1) = sContext
    RukhAsk = oRukhScript.invoke(aArgs(), aOutIdx(), aOutArgs())
End Function

' --- utilitaires propres au mode colonne -------------------------------------

' Champ CSV : toujours entre guillemets, guillemet interne doublé.
Function Csv(sText As String) As String
    Csv = """" & Join(Split(sText, """"), """""") & """"
End Function

' Découpe la réponse en lignes non vides. Les lignes vides sont ignorées plutôt
' que comptées : les instructions les interdisent, et les ignorer évite de
' rejeter un lot par ailleurs correct.
Function SplitLines(sText As String, aOut() As String) As Long
    Dim a() As String, k As Long, n As Long, s As String
    a = Split(Join(Split(sText, Chr(13) & Chr(10)), Chr(10)), Chr(10))
    ReDim aOut(UBound(a))
    n = 0
    For k = 0 To UBound(a)
        s = Trim(a(k))
        If s <> "" Then
            aOut(n) = s
            n = n + 1
        End If
    Next k
    SplitLines = n
End Function

' --- utilitaires partagés avec la macro cellule par cellule -------------------

Function Norm(v As String) As String
    Dim s As String
    s = LCase(Trim(v))
    s = Join(Split(s, Chr(10)), " ")
    s = Join(Split(s, Chr(13)), " ")
    s = Join(Split(s, Chr(9)), " ")
    Do While InStr(s, "  ") > 0
        s = Join(Split(s, "  "), " ")
    Loop
    Norm = Trim(s)
End Function

Function StartsWith(sText As String, sPrefix As String) As Boolean
    StartsWith = (Left(sText, Len(sPrefix)) = sPrefix)
End Function

Function JoinOpenings(aOpen() As String) As String
    If aOpen(0) <> "" And aOpen(1) <> "" Then
        JoinOpenings = aOpen(0) & " | " & aOpen(1)
    Else
        JoinOpenings = aOpen(0) & aOpen(1)
    End If
End Function

Function StripFence(sText As String) As String
    Dim a() As String, i0 As Long, i1 As Long, k As Long, sOut As String
    a = Split(Join(Split(sText, Chr(13) & Chr(10)), Chr(10)), Chr(10))
    i0 = LBound(a)
    i1 = UBound(a)
    Do While i0 <= i1 And Trim(a(i0)) = ""
        i0 = i0 + 1
    Loop
    Do While i1 >= i0 And Trim(a(i1)) = ""
        i1 = i1 - 1
    Loop
    If i0 <= i1 And Left(Trim(a(i0)), 3) = "```" Then i0 = i0 + 1
    If i0 <= i1 And Trim(a(i1)) = "```" Then i1 = i1 - 1
    For k = i0 To i1
        If sOut <> "" Then sOut = sOut & Chr(10)
        sOut = sOut & a(k)
    Next k
    StripFence = sOut
End Function

Function OneLine(sText As String) As String
    Dim s As String
    s = Join(Split(sText, Chr(13)), " ")
    s = Join(Split(s, Chr(10)), " ")
    Do While InStr(s, "  ") > 0
        s = Join(Split(s, "  "), " ")
    Loop
    OneLine = Trim(s)
End Function
```

## Utilisation

Comme en mode cellule par cellule, **c'est la sélection qui décide du
périmètre** — la différence est qu'elle est ensuite découpée en lots de
`CHUNK` enregistrements.

- **Tout le chapitre** : cliquer sur l'en-tête de la colonne, lancer. Trois
  appels environ, aucune intervention entre les lots.
- **Un lot précis** : sélectionner les lignes voulues.
- **Reprendre** : relancer sur la même sélection. Les cellules déjà écrites
  sont sautées, la reprise repart au premier lot non écrit.
- **Refaire une cellule** : sélectionner une seule ligne. La cellule est
  réécrite même si elle l'était déjà — mais pour ce geste, la macro
  `Formaliser` du mode cellule par cellule est plus économique.

Le compte rendu final indique le nombre de cellules écrites **et le nombre
d'appels consommés**, ce dernier étant ce qui se déduit du quota horaire.

## Quota, coût et durée

| | |
|---|---|
| Critères à réécrire, chapitre de référence | **57** |
| Enregistrements par appel | `CHUNK`, 20 par défaut |
| Appels pour un chapitre | **3** |
| Quota API | 50 requêtes/heure, par adresse IP |
| Garde-fou par exécution | `MAX_CALLS` = 45 appels |
| Tokens d'entrée par chapitre | ~18 000 (estimation : ~3 900 d'instructions + ~2 000 de CSV par appel) |
| Coût d'entrée estimé | **~0,04 $** par chapitre, contre ~0,46 $ cellule par cellule |
| Durée d'un appel | ~1 à 2 min (estimation, à mesurer) |
| Durée d'un chapitre | ~4 à 6 min |

Le coût de sortie, lui, ne change pas : c'est le même texte qui est produit
dans les deux modes. L'économie porte entièrement sur les instructions, qui
sont renvoyées 3 fois au lieu de 57.

Les trois propriétés de reprise sont conservées, mais **à la maille du lot** :

1. **Reprise automatique** — seules les cellules dont `Commentaires réécrits`
   est vide sont traitées, et la mémoire des débuts est relue dans cette même
   colonne.
2. **Écriture par lot** — chaque lot est écrit dès sa validation. Une coupure
   fait perdre au plus le lot en cours, soit ~20 cellules.
3. **Arrêt propre** — sur `429`, sur erreur HTTP et sur écart de compte, la
   macro s'arrête en indiquant ce qui a été écrit et où reprendre.

## Limites et risques

- **Le dépassement de délai est le vrai risque du mode colonne.** Une réponse
  de vingt commentaires demande une à deux minutes ; le `proxy_read_timeout`
  du nginx placé devant l'API **reste à vérifier**, et c'est lui qui décidera
  du `CHUNK` praticable. En cas de coupure systématique au même endroit,
  descendre `CHUNK` à 10 avant toute autre hypothèse. Le `TIMEOUT` de 300 s
  côté Python est délibérément large pour que ce soit l'API qui tranche.
- **L'interface est figée pendant chaque appel**, une à deux minutes durant.
  Basic est synchrone : ni `await`, ni interruption par Échap. Enregistrer le
  fichier avant de lancer.
- **Un lot rejeté est à refaire en entier.** C'est le prix du garde-fou : mieux
  vaut vingt cellules à refaire qu'une colonne décalée.
- **Le contrôle porte sur le nombre de lignes, pas sur leur ordre.** Voir
  « Le garde-fou de calage », plus haut.
- **Sélection multiple ignorée au-delà de la première plage.**
- **La cohérence s'arrête à la borne du lot.** Un acronyme est développé une
  fois par lot, donc trois fois par chapitre au lieu de cinquante-sept — mieux
  qu'en mode cellule, pas parfait pour autant. Augmenter `CHUNK` améliore ce
  point et aggrave le risque de délai : c'est le seul arbitrage à faire.

## Variante Apache OpenOffice

Toute la macro fonctionne telle quelle : UNO et StarBasic sont partagés. Seul
le pont ne transfère pas — AOO 4.1.x embarque Python 2.7. Route pragmatique :
remplacer `RukhAsk` par un appel `curl` synchrone (4ᵉ argument de `Shell` à
`True`) écrivant le corps JSON dans un fichier temporaire, avec les utilitaires
`JsonEscape` et `JsonStringValue` de
`docs/SPREADSHEETS.md` transposés en remplaçant
`AscW`/`ChrW$` par `Asc`/`Chr$`.

Le mode colonne rend cette variante **plus fragile** que le mode cellule : le
corps JSON à échapper contient tout un CSV, et la réponse à extraire fait
plusieurs milliers de signes — soit exactement ce que l'extracteur artisanal
gère le moins bien. Si le poste peut recevoir LibreOffice, l'y installer.

## Déploiement du contexte `eval-oc-full`

`data/` et `docs/private/` sont dans le `.gitignore` : le contexte créé en
local **ne part pas avec un `git push`**. Pour le rendre disponible sur
`rukh.w3hc.org`, le déposer via l'API de contextes (`POST /context` puis
`POST /context/upload` avec `instruction-file.md`), signature SIWE à l'appui —
l'adresse doit correspondre au `creatorAddress` de `index.json`. Tant que ce
n'est pas fait, la macro renverra des réponses hors contexte, et le garde-fou
de calage rejettera probablement tous les lots.

Après toute modification de `instruction-file.md`, refaire le dépôt : c'est le
fichier déployé qui fait foi, pas la copie locale.
