Prompts : le niveau du joueur seulement à partir de 20 décisions, son taux d'erreur dans le seul bilan

Recette de 1f (F3). En dessous de 20 décisions analysées, le prompt dit
« Je débute au backgammon. ». Test du seuil ; outil prompt-sample pour
relire des prompts remplis par le moteur. Plan de 1f clos.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Pierre MARTIN
2026-11-11 21:52:23 +01:00
co-authored by Claude Opus 5.5
parent f509f8746e
commit f2df1b2e7f
10 changed files with 143 additions and 71 deletions
+27 -16
View File
@@ -14,6 +14,7 @@
// au moteur la position à décrire.
import {
type ErrorRate,
gameTurns,
levelOfErrorRate,
type ReviewedDecision,
@@ -85,14 +86,25 @@ export function plyText(ply: Ply): string {
return `analyse à ${ply} ${ply > 1 ? "plis" : "pli"}`;
}
/** Le niveau du joueur d'après son erreur par décision dans une partie
* (comme le bilan) ; « pas encore mesuré » sans décision comptée. */
export function playerLevel(journal: GameJournal | null): string {
const rate = journal === null ? null : summarizeAnalysis(journal).player.errorRate;
if (rate === null || rate.decisions === 0) {
return "pas encore mesuré (aucune de mes décisions n'a encore été analysée)";
/** Nombre de décisions analysées à partir duquel le niveau du joueur se
* dit dans un prompt : en dessous, l'erreur par décision dit peu de chose. */
export const LEVEL_MIN_DECISIONS = 20;
/** Ce que le joueur dit de lui-même dans un prompt : son niveau, d'après
* son erreur par décision mesurée par GNU Backgammon, s'il est mesuré sur
* au moins LEVEL_MIN_DECISIONS décisions ; sinon, qu'il débute (pas encore
* d'historique des parties : étape 1h). Jamais le taux lui-même, qui n'est
* que dans le bilan. */
export function playerSentence(rate: Pick<ErrorRate, "decisions" | "level"> | null): string {
if (rate === null || rate.decisions < LEVEL_MIN_DECISIONS) {
return "Je débute au backgammon.";
}
return `${LEVEL_NAMES[rate.level]}, d'après mon erreur par décision dans ma partie, mesurée par GNU Backgammon (${formatEquity(-rate.perDecision)} sur ${plural(rate.decisions, "décision", "décisions")})`;
return `Je suis un joueur de niveau ${LEVEL_NAMES[rate.level]}.`;
}
/** playerSentence, sur les décisions d'une partie. */
function playerOf(journal: GameJournal | null): string {
return playerSentence(journal === null ? null : summarizeAnalysis(journal).player.errorRate);
}
/** Les repères de la position, communs à tous les gabarits. */
@@ -130,7 +142,7 @@ export function decisionPrompt(
): Prompt {
const common = {
...positionValues(data.position),
niveau: playerLevel(data.journal),
joueur: playerOf(data.journal),
niveau_eval: plyText(data.advice.ply),
appreciation: masterWords(phrases, data.advice).word,
};
@@ -309,7 +321,7 @@ export function summaryPrompt(
const toReview = summary.toReview;
return fillPrompt(templates.summary, {
...positionValues(data.position),
niveau: playerLevel(data.journal),
joueur: playerOf(data.journal),
niveau_eval: plyText(summaryPly(data.journal, toReview)),
ordinateur: computerLevelName(data.journal),
resultat: resultText(data.journal),
@@ -381,7 +393,7 @@ export function conceptPrompt(templates: PromptTemplates, data: ConceptPromptDat
: "";
return fillPrompt(templates.concept, {
...positionValues(example.position),
niveau: playerLevel(data.journal),
joueur: playerOf(data.journal),
niveau_eval: plyText(example.ply),
concept: `« ${data.term} »${english}`,
definition: data.brief,
@@ -439,24 +451,23 @@ export function exercisesPrompt(
if (worst === undefined) {
throw new Error("exercices : aucune erreur dans ces parties");
}
// Le niveau, sur toutes les décisions de ces parties.
let total = 0;
let decisions = 0;
const rates = data.journals.map((journal) => {
for (const journal of data.journals) {
const rate = summarizeAnalysis(journal).player.errorRate;
total += rate.total;
decisions += rate.decisions;
return `${formatEquity(-rate.perDecision)} (${LEVEL_NAMES[rate.level]})`;
});
const perDecision = decisions > 0 ? total / decisions : 0;
}
const level = levelOfErrorRate(decisions > 0 ? total / decisions : 0);
const listed = mistakes
.slice(0, MAX_ERRORS)
.map((m) => mistakeLine(phrases, m, `Partie ${m.game}, coup ${m.turn}`));
return fillPrompt(templates.exercises, {
...positionValues(data.position),
niveau: `${LEVEL_NAMES[levelOfErrorRate(perDecision)]}, d'après mon erreur par décision sur ces parties, mesurée par GNU Backgammon (${formatEquity(-perDecision)} sur ${plural(decisions, "décision", "décisions")})`,
joueur: playerSentence({ decisions, level }),
niveau_eval: plyText(worst.advice.ply),
parties: String(data.journals.length),
taux_parties: rates.join(", "),
erreurs: listed.join("\n"),
position_titre: `La plus coûteuse (partie ${worst.game}, coup ${worst.turn}) : la position avant ma décision`,
});
+27 -4
View File
@@ -36,7 +36,9 @@ import {
decisionPrompt,
exercisesPosition,
exercisesPrompt,
LEVEL_MIN_DECISIONS,
LEVEL_NAMES,
playerSentence,
summaryPosition,
summaryPrompt,
} from "../src/domain/prompts/prompts.js";
@@ -86,7 +88,10 @@ function assertComplete(prompt: string, position: { positionId: string; matchId:
assert.ok(!prompt.includes(hole), `« ${hole} » dans le prompt :\n${prompt}`);
}
assert.ok(prompt.startsWith("Tu es un coach de backgammon"), "rôle absent");
assert.match(prompt, /Je suis un joueur de niveau \S/);
assert.match(
prompt,
/^Tu es un coach de backgammon, bienveillant et précis\. (Je débute au backgammon\.|Je suis un joueur de niveau [A-ZÉ]\S+\.)\n/,
);
assert.ok(prompt.includes(`Position ID : ${position.positionId}`), "Position ID absent");
assert.ok(prompt.includes(`Match ID : ${position.matchId}`), "Match ID absent");
assert.ok(prompt.includes(`${PUBLIC_SITE}#/position/`), "lien absent");
@@ -101,7 +106,7 @@ test("les cinq gabarits se lisent dans leur source, avec les repères du cadrage
for (const kind of PROMPT_KINDS) {
const names = placeholders(templates[kind]);
for (const name of [
"niveau",
"joueur",
"position_id",
"match_id",
"au_trait",
@@ -133,6 +138,22 @@ test("les mots du prompt sont ceux de l'écran", () => {
assert.deepEqual(CUBE_ROW_WORDS, ADVICE_TEXTS.cubeRows);
});
test("le niveau du joueur ne se dit qu'à partir de 20 décisions, sans son taux", () => {
assert.equal(LEVEL_MIN_DECISIONS, 20);
assert.equal(playerSentence(null), "Je débute au backgammon.");
for (const level of COMPUTER_LEVELS) {
assert.equal(playerSentence({ decisions: 19, level }), "Je débute au backgammon.");
assert.equal(
playerSentence({ decisions: 20, level }),
`Je suis un joueur de niveau ${MATCH_TEXTS.levels[level].name}.`,
);
}
assert.equal(
playerSentence({ decisions: 120, level: "advanced" }),
"Je suis un joueur de niveau Avancé.",
);
});
test("le diagramme recopie le plateau décodé par le moteur", async () => {
const engine = await loadNodeEngine();
const start = await positionNow(engine, null);
@@ -371,7 +392,7 @@ test("prompt d'un concept : la position de départ, ou celle de la partie en cou
assertComplete(prompt, alone.position);
assert.ok(prompt.includes("le concept « Videau » (en anglais : Cube)."), prompt);
assert.ok(prompt.includes("voici la position de départ"), prompt);
assert.ok(prompt.includes("niveau pas encore mesuré"), prompt);
assert.ok(prompt.includes("précis. Je débute au backgammon.\n"), prompt);
assert.match(prompt, /chances de gagner \d+ %, équité [+−]?\d,\d{3}/);
const played = await playOpening(engine, [3, 1], "24/23 13/10");
@@ -388,7 +409,9 @@ test("prompt d'un concept : la position de départ, ou celle de la partie en cou
assertComplete(second, during.position);
assert.ok(second.includes("le concept « Blot »."), second);
assert.ok(second.includes("une position de ma partie en cours"), second);
assert.ok(second.includes("Je suis un joueur de niveau Débutant, d'après mon erreur"), second);
// Une seule décision analysée : pas de niveau, pas de taux d'erreur.
assert.ok(second.includes("Je débute au backgammon."), second);
assert.ok(!second.includes("erreur par décision"), second);
});
test("prompt des exercices : les erreurs de plusieurs parties, la plus coûteuse en position", async () => {
+82
View File
@@ -0,0 +1,82 @@
// Affiche des prompts remplis sur de vraies analyses du moteur, pour les
// relire (étape 1f) : node build/web/tests/tools/prompt-sample.js
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import { readFileSync } from "node:fs";
import {
missingAnalyses,
runAnalysis,
summarizeAnalysis,
} from "../../src/domain/analysis/game-analysis.js";
import type { Engine } from "../../src/domain/game/engine.js";
import type { GameJournal } from "../../src/domain/game/events.js";
import { Game } from "../../src/domain/game/game.js";
import { positionBefore } from "../../src/domain/prompts/position.js";
import {
decisionPrompt,
summaryPosition,
summaryPrompt,
} from "../../src/domain/prompts/prompts.js";
import {
PROMPT_FILES,
type PromptTemplates,
parsePromptTemplate,
} from "../../src/domain/prompts/templates.js";
import { adviceOn, loadPhrases, playOpening } from "../support/advice.js";
import { loadNodeEngine } from "../support/node-engine.js";
import { playToTheEnd, seededDice } from "../support/play.js";
const root = new URL("../../../../", import.meta.url);
const read = (file: string) =>
parsePromptTemplate(readFileSync(new URL(`docs/prompts/${file}`, root), "utf8"));
const templates: PromptTemplates = {
move: read(PROMPT_FILES.move),
cube: read(PROMPT_FILES.cube),
summary: read(PROMPT_FILES.summary),
concept: read(PROMPT_FILES.concept),
exercises: read(PROMPT_FILES.exercises),
};
const phrases = loadPhrases();
const engine: Engine = await loadNodeEngine();
const played = await playOpening(engine, [3, 1], "24/23 13/10");
const advice = await adviceOn(engine, played);
played.game.recordAdvice(advice);
const position = await positionBefore(engine, played.game.journal, played.eventId);
console.log(decisionPrompt(templates, phrases, { journal: played.game.journal, advice, position }));
async function analyseAll(journal: GameJournal): Promise<GameJournal> {
let current = journal;
for (;;) {
const next = missingAnalyses(current)[0];
if (next === undefined) {
return current;
}
current = await runAnalysis(engine, current, next);
}
}
for (const [seed, choice] of [
[7, 1],
[7, 2],
[11, 2],
[3, 3],
]) {
const game = Game.start(engine, {
setup: { position: "trsXAADs7mwAAA", firstTurn: "player" },
level: "intermediate",
dice: seededDice(seed ?? 0),
});
await playToTheEnd(game, engine, choice);
const journal = await analyseAll(game.journal);
const summary = summarizeAnalysis(journal);
console.log(
`\n=== graine ${seed}, choix ${choice} : coup à revoir ${summary.toReview?.turn ?? "aucun"}`,
);
if (seed === 3) {
const where = await summaryPosition(engine, journal);
console.log(summaryPrompt(templates, phrases, { journal, position: where }));
}
}
-42
View File
@@ -1,42 +0,0 @@
# Prompts à copier (1f)
- **Date** : 2026-11-03
- **Étape** : 1f (`docs/etape-1.md`)
- **Statut** : prévu (après 1e)
- **Exécutant** : sous-agent general-purpose, dans un worktree
## Objectif
Mettre en place la section « IA générative en V1 » du cadrage. Un bouton « Demander à une IA » copie un prompt rempli avec le contexte exact. Le moteur décide, l'IA explique. L'app n'appelle aucune IA.
## Consignes
1. **Gabarits.** Les cinq gabarits du cadrage sont versionnés dans une source unique sous `docs/prompts/`, sous licence CC BY-SA 4.0 :
- « pourquoi ce coup est meilleur » ;
- « expliquer cette décision de videau » ;
- « bilan de ma partie » ;
- « expliquer ce concept » ;
- « créer des exercices », à garder pour la progression.
Chaque gabarit contient :
- le rôle et le niveau du joueur ;
- le Position ID et le Match ID ;
- un diagramme texte de la position, produit à partir du plateau décodé par le moteur ;
- les chiffres du moteur, avec le niveau d'évaluation ;
- la consigne de ne pas contredire ces chiffres ;
- la langue.
Ajouter aussi le lien `#/position/...` de l'ADR 0013, avec l'adresse publique du site.
2. **Remplissage.** Une fonction pure remplit les gabarits. Elle est testée sur des analyses réelles du moteur : aucun champ vide ni `{inconnu}`, et les chiffres sont identiques à ceux du moteur.
3. **Boutons.** Ils sont placés sur le conseil du maître (feuille d'explication), le bilan, l'analyse et la fiche d'un terme du glossaire. La copie passe par `navigator.clipboard`, avec un repli de sélection du texte. Un bandeau « Prompt copié » l'accompagne, et le texte reste visible pour qui veut le relire.
4. **Tests.**
- Scénarios Gherkin : copier le prompt d'une erreur, et celui d'un bilan.
- `make lint`, `make test` et `make test-browser`, avec lecture du presse-papiers dans Chromium et captures.
## Critères de réussite
Le prompt copié après une erreur contient la position, les identifiants gnubg et le top 3 du moteur avec les mêmes chiffres que l'app. Les trois commandes passent.
## Résultat
À remplir à la clôture.
+2 -2
View File
@@ -27,7 +27,7 @@ d'après le cadrage, **à relire** par Pierre.
Chaque prompt contient :
- le rôle de l'IA (un coach) et le niveau du joueur ;
- le rôle de l'IA (un coach) et, quand il est mesuré, le niveau du joueur ;
- la position : Position ID et Match ID de GNU Backgammon, le lien qui
l'ouvre dans l'application (`https://ustagammon.sans.pub/#/position/…`,
ADR 0013) et un diagramme texte dessiné d'après le plateau décodé par le
@@ -47,7 +47,7 @@ Repères communs à plusieurs gabarits :
| Repère | Ce qu'il devient |
| --- | --- |
| `{niveau}` | le niveau du joueur, d'après son taux d'erreur mesuré par GNU Backgammon, ou « pas encore mesuré » |
| `{joueur}` | « Je suis un joueur de niveau Avancé. », le niveau que donne GNU Backgammon d'après l'erreur par décision, seulement à partir de 20 décisions analysées ; en dessous, « Je débute au backgammon. ». Le taux d'erreur lui-même n'apparaît que dans le bilan. |
| `{position_id}`, `{match_id}` | les identifiants de GNU Backgammon de la position |
| `{au_trait}` | « moi » ou « l'ordinateur » : celui dont les identifiants décrivent le point de vue |
| `{lien}` | l'adresse de la position dans l'application publiée |
+1 -1
View File
@@ -20,7 +20,7 @@ Repères propres à ce gabarit :
## Gabarit
```text
Tu es un coach de backgammon, bienveillant et précis. Je suis un joueur de niveau {niveau}.
Tu es un coach de backgammon, bienveillant et précis. {joueur}
Je viens de finir une partie contre l'ordinateur (niveau {ordinateur}), analysée par GNU Backgammon ({niveau_eval}).
Résultat : {resultat}.
+1 -1
View File
@@ -17,7 +17,7 @@ Repères propres à ce gabarit :
## Gabarit
```text
Tu es un coach de backgammon, bienveillant et précis. Je suis un joueur de niveau {niveau}.
Tu es un coach de backgammon, bienveillant et précis. {joueur}
Explique-moi le concept {concept}.
Mon application le définit ainsi : {definition}
+1 -1
View File
@@ -17,7 +17,7 @@ Repères propres à ce gabarit :
## Gabarit
```text
Tu es un coach de backgammon, bienveillant et précis. Je suis un joueur de niveau {niveau}.
Tu es un coach de backgammon, bienveillant et précis. {joueur}
Voici la position avant mon coup (GNU Backgammon, Position ID : {position_id}, Match ID : {match_id} ; ces identifiants la décrivent du point de vue du joueur qui a la main : {au_trait}).
Elle s'ouvre aussi à cette adresse : {lien}
+1 -3
View File
@@ -10,17 +10,15 @@ Repères propres à ce gabarit :
| Repère | Ce qu'il devient |
| --- | --- |
| `{parties}` | le nombre de parties analysées |
| `{taux_parties}` | votre erreur par décision et votre niveau, partie par partie, de la plus ancienne à la plus récente |
| `{erreurs}` | vos erreurs les plus coûteuses sur ces parties, avec leur coût |
| `{position_titre}` | la plus coûteuse d'entre elles |
## Gabarit
```text
Tu es un coach de backgammon, bienveillant et précis. Je suis un joueur de niveau {niveau}.
Tu es un coach de backgammon, bienveillant et précis. {joueur}
Voici mes {parties} dernières parties contre l'ordinateur, analysées par GNU Backgammon ({niveau_eval}).
Mon erreur par décision, de la plus ancienne à la plus récente : {taux_parties}.
Mes erreurs les plus coûteuses, avec leur coût en équité :
{erreurs}
+1 -1
View File
@@ -20,7 +20,7 @@ Repères propres à ce gabarit :
## Gabarit
```text
Tu es un coach de backgammon, bienveillant et précis. Je suis un joueur de niveau {niveau}.
Tu es un coach de backgammon, bienveillant et précis. {joueur}
Voici la position au moment d'une décision de videau (GNU Backgammon, Position ID : {position_id}, Match ID : {match_id} ; ces identifiants la décrivent du point de vue du joueur qui a la main : {au_trait}).
Elle s'ouvre aussi à cette adresse : {lien}