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-exceldéployé sur l'API (voir Déploiement).
Mise en place (une fois)
- Ouvrir la grille dans Excel sur le web.
- Onglet Automatiser → Nouveau script.
- Coller le script ci-dessous, le nommer
Formaliser, Enregistrer. - 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_CALLScellules. - 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 lignesE.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 :
- Reprise automatique. Le script ne traite que les cellules dont la
colonne
Commentaires réécritsest 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. - Écriture immédiate. Chaque réponse est écrite dès sa réception, pas en
fin de parcours. Une coupure réseau, un
429ou 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. - 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-verifcontrô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_timeoutdu 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.