Configuration Excel — chaîne eval

Julien Béranger

+ Claude Opus 5

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 — 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).

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

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érence57
Quota API50 requêtes/heure, par adresse IP
Appels par exécution du scriptla sélection, plafonnée à 45 (MAX_CALLS)
Exécutions pour un chapitre2 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.