---
title: Configuration Excel — chaîne eval
description: Un script Office qui réécrit les commentaires d'une grille d'évaluation cellule par cellule, via l'API Rukh : mise en place, quota, reprise et limites.
date: 2026-09-08
lang: fr-FR
author: Julien Béranger
model: Claude Opus 5
source: https://julienberanger.com/rukh-eval-excel
---

# Configuration Excel — chaîne eval

Un seul script, un seul contexte : **`eval-excel`**, appelé **cellule par
cellule**. Une cellule de la colonne Commentaires = un appel = une réponse d'une
ligne, écrite directement dans la feuille.

Rien à copier-coller, aucun bloc de code à recopier, et surtout **plus aucun
problème de calage** : chaque réponse est écrite dans la cellule dont elle
provient, il n'y a pas de colonne à aligner.

En contrepartie, le quota de l'API se compte en cellules. Voir
[Quota et reprise](#quota-et-reprise) — c'est la section qui gouverne l'usage
réel.

## Prérequis

- Excel **sur le web**, licence Microsoft 365 **business ou entreprise**
  (l'onglet Automatiser est absent des comptes personnels).
- Rien à installer : pas de `.xlsm`, pas de complément, pas d'invite de
  sécurité macro.
- Le contexte `eval-excel` déployé sur l'API (voir
  [Déploiement](#déploiement-du-contexte-eval-excel)).

## Mise en place (une fois)

1. Ouvrir la grille dans Excel sur le web.
2. Onglet **Automatiser → Nouveau script**.
3. Coller le script ci-dessous, le nommer `Formaliser`,
   **Enregistrer**.
4. Facultatif : **Insertion → Formes**, puis clic droit sur la forme →
   **Affecter un script**.

## Utilisation

**C'est la sélection qui décide de la taille du lot.** Une cellule, dix lignes,
la colonne entière : le geste est le même, seule l'étendue change.

- **Une seule cellule** : cliquer sur la ligne de critère voulue, lancer. Elle
  est réécrite, **même si elle l'était déjà** — c'est ainsi qu'on refait une
  cellule dont la réponse ne convient pas, sans avoir à la vider d'abord.
- **Un lot** : sélectionner les lignes voulues, lancer. Le script traite les
  lignes de critère qu'elles contiennent, dans l'ordre, en sautant celles qui
  sont déjà écrites.
- **Tout le chapitre** : sélectionner la colonne entière (clic sur sa lettre),
  lancer. Le script s'arrête de lui-même à `MAX_CALLS` cellules.
- **Reprendre** : relancer sur la même sélection. Ce qui est déjà écrit n'est
  jamais refait — sauf, précisément, sur une sélection d'une seule cellule.

Attention à ce que la sélection compte : des **lignes de feuille**, pas des
appels. Seules les lignes de critère sont traitées, les lignes `E.E.` sont
sautées ; sur le chapitre de référence, dix lignes sélectionnées ne font souvent
que deux ou trois cellules à réécrire. Le compte rendu affiché en fin
d'exécution donne le nombre réel.

`MAX_CALLS` n'est pas la taille du lot : c'est un garde-fou à 45, sous le quota
horaire de 50, qui n'intervient que sur une sélection large.

Les résultats vont dans une colonne **`Commentaires réécrits`**, créée
automatiquement à droite du tableau. La colonne d'origine n'est jamais
modifiée : elle reste disponible pour comparaison, et c'est le remplissage de
la colonne de sortie qui sert de mémoire de reprise.

## Le script

```typescript
const RUKH_URL = "https://rukh.w3hc.org/ask";
const CONTEXT = "eval-excel";
const MAX_CALLS = 45;                        // garde-fou par exécution, marge sous le quota de 50/heure
const TARGET_HEADER = "Commentaires réécrits";
const OPENING_CHARS = 32;                    // longueur des débuts renvoyés au modèle
const OPENING_MEMORY = 2;                    // nombre de cellules mémorisées

async function main(workbook: ExcelScript.Workbook) {
  const sheet = workbook.getActiveWorksheet();
  const used = sheet.getUsedRange();
  if (!used) throw new Error("Feuille vide.");

  const values = used.getValues();
  const rowOffset = used.getRowIndex();
  const colOffset = used.getColumnIndex();

  // --- repérage des colonnes par intitulé, jamais par position ---
  const headerRow = values.findIndex((r) => norm(r[0]) === "chapitre");
  if (headerRow < 0) throw new Error("Ligne d'en-tête introuvable (colonne A = « Chapitre »).");
  const head = values[headerRow].map(norm);

  const cChapitre = head.findIndex((h) => h === "chapitre");
  const cCotation = head.findIndex((h) => h.startsWith("cotation"));
  const cComm = head.findIndex((h) => h.startsWith("commentaires"));
  const cQuestion = head.findIndex((h) => h.startsWith("is question"));
  if (cComm < 0 || cQuestion < 0 || cChapitre < 0) {
    throw new Error("Colonnes « Chapitre », « Commentaires » ou « Is Question? » introuvables.");
  }

  // --- colonne de sortie : c'est elle qui rend la reprise possible ---
  let cTarget = head.findIndex((h) => h === norm(TARGET_HEADER));
  if (cTarget < 0) {
    cTarget = used.getColumnCount();
    sheet.getCell(rowOffset + headerRow, colOffset + cTarget).setValue(TARGET_HEADER);
  }

  // --- périmètre : c'est la sélection qui dit quoi traiter ---
  // Une seule ligne sélectionnée = « celle-ci, et elle seule », et on la refait
  // même si elle est déjà écrite : c'est le seul geste qui permette de reprendre
  // une cellule sans la vider d'abord. Plusieurs lignes = cette zone, dans
  // l'ordre, en s'arrêtant sur ce qui reste à faire.
  const selection = workbook.getSelectedRange();
  const selStart = selection !== undefined ? selection.getRowIndex() : rowOffset;
  const selRows = selection !== undefined ? selection.getRowCount() : values.length;
  const selEnd = selStart + selRows - 1;
  const single = selRows === 1;

  // --- cellules à traiter : lignes de critère, commentaire non vide, pas déjà faites ---
  const todo: number[] = [];
  for (let i = headerRow + 1; i < values.length; i++) {
    if (!String(values[i][cChapitre] ?? "").startsWith("Chapitre ")) continue;
    if (String(values[i][cQuestion] ?? "").trim()) continue;   // ligne E.E. : colonne J vide
    if (!String(values[i][cComm] ?? "").trim()) continue;      // rien à réécrire
    const abs = rowOffset + i;
    if (abs < selStart || abs > selEnd) continue;
    const already = cTarget < values[i].length ? String(values[i][cTarget] ?? "").trim() : "";
    if (already && !single) continue;                          // déjà réécrite : reprise
    todo.push(i);
  }

  if (todo.length === 0) {
    console.log(
      single
        ? `Ligne ${selStart + 1} : rien à réécrire ici. Sélectionner une ligne de critère (colonne « Chapitre » renseignée, « Is Question? » vide, commentaire non vide).`
        : "Rien à faire : toutes les cellules de la sélection sont déjà réécrites.",
    );
    return;
  }

  const batch = todo.slice(0, MAX_CALLS);

  // --- mémoire des débuts, réamorcée depuis la colonne de sortie ---
  // Sans cela chaque lot repartirait sans connaissance du précédent et
  // rouvrirait par « La personne indique que ». Les lignes situées avant
  // batch[0] datent d'une exécution antérieure : l'instantané `values` y est
  // donc à jour.
  const openings: string[] = [];
  for (let i = batch[0] - 1; i >= headerRow + 1 && openings.length < OPENING_MEMORY; i--) {
    const prev = cTarget < values[i].length ? String(values[i][cTarget] ?? "").trim() : "";
    if (prev) openings.unshift(prev.slice(0, OPENING_CHARS));
  }

  let done = 0;

  for (const i of batch) {
    // cotations des lignes E.E. rattachées à ce critère, pour la règle « cependant »
    const cot: string[] = [];
    for (let j = i + 1; j < values.length; j++) {
      if (!String(values[j][cQuestion] ?? "").trim()) break;    // critère suivant
      const v = cCotation >= 0 ? String(values[j][cCotation] ?? "").trim() : "";
      if (v) cot.push(v);
    }

    const lines: string[] = [];
    if (cot.length > 0) lines.push(`Cotations : ${cot.join(";")}`);
    if (openings.length > 0) lines.push(`Débuts déjà utilisés : ${openings.join(" | ")}`);
    lines.push(String(values[i][cComm]));

    const res = await fetch(RUKH_URL, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ message: lines.join("\n"), model: "anthropic", context: CONTEXT }),
    });

    if (res.status === 429) {
      console.log(`Quota atteint après ${done} cellule(s). Reste ${todo.length - done}. Relancer dans une heure.`);
      return;
    }
    if (!res.ok) {
      console.log(`Erreur ${res.status} ligne ${rowOffset + i + 1}. ${done} cellule(s) écrite(s), conservées.`);
      return;
    }

    const data = (await res.json()) as { output: string };
    const text = stripFence(data.output).replace(/\s*\n\s*/g, " ").trim();

    // écriture immédiate : une interruption ne fait jamais perdre ce qui précède
    sheet.getCell(rowOffset + i, colOffset + cTarget).setValue(text);
    done++;

    openings.push(text.slice(0, OPENING_CHARS));
    if (openings.length > OPENING_MEMORY) openings.shift();
    if (done % 5 === 0) console.log(`${done}/${batch.length}…`);
  }

  const left = todo.length - done;
  console.log(
    single
      ? `Ligne ${rowOffset + batch[0] + 1} réécrite.`
      : left > 0
        ? `${done} cellule(s) réécrite(s). Reste ${left} dans la sélection : relancer (dans une heure si le quota est épuisé).`
        : `${done} cellule(s) réécrite(s). Sélection terminée.`,
  );
}

function norm(v: string | number | boolean): string {
  return String(v ?? "").replace(/\s+/g, " ").trim().toLowerCase();
}

function stripFence(text: string): string {
  const lines = text.replace(/\r\n/g, "\n").split("\n");
  while (lines.length && lines[0].trim() === "") lines.shift();
  while (lines.length && lines[lines.length - 1].trim() === "") lines.pop();
  if (lines.length && /^`{3,}/.test(lines[0].trim())) lines.shift();
  if (lines.length && /^`{3,}$/.test(lines[lines.length - 1].trim())) lines.pop();
  return lines.join("\n");
}
```

## Ce qui est envoyé

Rien du référentiel, rien du reste du fichier. Un appel contient au plus trois
éléments :

```
Cotations : 4;3;4
Débuts déjà utilisés : La personne indique qu'elle est ac | Les professionnels précisent
AT2 : la personne explique qu'elle est accompagnée depuis 2021 suite à un accident…
```

- **`Cotations :`** — les cotations des lignes `E.E.` rattachées au critère,
  relevées dans la feuille. Elles ne sont jamais recopiées dans la réponse :
  elles servent uniquement à déclencher la règle « cependant » quand l'une
  d'elles est ≤ 3. Sur le chapitre de référence, 84 cotations sur 123 sont
  inférieures à 4 : la règle sert constamment.
- **`Débuts déjà utilisés :`** — les 32 premiers caractères des deux cellules
  précédemment réécrites, relevées dans la colonne de sortie au démarrage : la
  mémoire franchit donc les lots, y compris pour une cellule traitée seule.
  Sans cette ligne, chaque appel étant indépendant, la
  quasi-totalité des cellules s'ouvrirait par « La personne indique que » : le
  modèle ne voit rien de ses voisines. C'est le seul mécanisme qui préserve la
  règle de variation des verbes déclaratifs dans un fonctionnement cellule par
  cellule.
- **Le commentaire d'origine**, tel quel — fautes de frappe, abréviations et
  préfixe `AT2 :` compris. Les instructions s'en chargent.

La cellule médiane fait 240 signes, la plus longue 1 268 : chaque appel est
minuscule, la limite de taille de requête n'entre jamais en jeu.

## Quota et reprise

C'est la contrainte structurante de ce fonctionnement.

| | |
|---|---|
| Cellules à réécrire, chapitre de référence | **57** |
| Quota API | **50 requêtes/heure, par adresse IP** |
| Appels par exécution du script | la sélection, plafonnée à 45 (`MAX_CALLS`) |
| Exécutions pour un chapitre | **2** en sélectionnant large, davantage en lots courts |
| Durée effective | ~6 s par cellule, ~6 min de calcul par chapitre |
| Tokens d'entrée | ~228 000 (les instructions, ~3 100 tokens, sont renvoyées à chaque appel) |
| Coût d'entrée estimé | ~0,46 $ par chapitre |

Un chapitre ne passe donc pas en une heure, et le quota est partagé par toutes
les personnes derrière la même adresse IP. Trois propriétés du script rendent
cela vivable :

1. **Reprise automatique.** Le script ne traite que les cellules dont la
   colonne `Commentaires réécrits` est vide. Relancer reprend exactement là où
   il s'est arrêté. La mémoire des débuts est relue dans cette même colonne :
   une reprise ne repart donc pas non plus à zéro sur la variation des verbes.
   C'est ce qui rend la taille des lots libre — une cellule ou quarante donnent
   le même résultat, seul le rythme de relecture change.
2. **Écriture immédiate.** Chaque réponse est écrite dès sa réception, pas en
   fin de parcours. Une coupure réseau, un `429` ou une fermeture d'onglet ne
   font jamais perdre les cellules déjà traitées. C'est pour cela que le script
   écrit cellule par cellule au lieu de grouper les écritures : le coût d'un
   aller-retour est négligeable devant les secondes d'attente de chaque appel.
3. **Arrêt propre.** Sur `429`, le script s'arrête en indiquant combien de
   cellules ont été écrites et combien restent, au lieu d'échouer.

Si le quota devient le facteur limitant à l'usage, la piste est une clé d'API
disposant de son propre compteur, plutôt qu'une limite anonyme relevée pour
tout le monde.

## Limites

- **Pas de vue d'ensemble.** Chaque cellule est réécrite sans connaissance des
  autres. La variation des verbes est traitée par `Débuts déjà utilisés :` ; en
  revanche les acronymes sont développés à leur première occurrence **de chaque
  cellule**, et non une seule fois pour le chapitre. Sur une colonne entière,
  cela fait des développements répétés — c'est le prix du fonctionnement
  cellule par cellule, et c'est assumé dans les instructions.
- **Pas de contrôle de calage à faire.** `eval-verif` contrôlait le calage
  d'une colonne collée en bloc ; ici il n'y a rien à caler. Le contrôle qui
  reste utile est celui des cotations non justifiées, qui suppose de repasser
  sur le fichier complet.
- **Durée.** Environ 6 secondes par cellule : ~1 min pour un lot de 10, ~4 min
  pour une sélection large. Le plafond d'exécution d'un script Office et le
  `proxy_read_timeout` du nginx placé devant l'API restent **à vérifier** — des
  lots courts les éloignent d'autant, et en cas de coupure la reprise
  automatique limite les dégâts à la cellule en cours.
- **Coût.** Les instructions (~11 Ko) sont renvoyées à chaque appel : c'est la
  quasi-totalité des tokens d'entrée. Alléger le fichier d'instructions est le
  seul levier de coût réel.

## Déploiement du contexte `eval-excel`

`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, le script renverra des réponses hors contexte.

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.
