« Demander à une IA » : bouton, feuille du prompt à son adresse, copie

Le bouton, secondaire, dans la feuille du maître, sur le bilan, dans
l'analyse et sur la fiche ouverte du glossaire. Il ouvre la feuille du
prompt (…/ia, amendement de l'ADR 0013), où le texte se relit avant la
copie : navigator.clipboard, repli par sélection du texte, bandeau
« Prompt copié » (2 s). Gabarits copiés par make site (amendement de
l'ADR 0008), scénario « Demander à une IA », 1f marquée faite.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Pierre MARTIN
2026-11-10 20:56:56 +01:00
co-authored by Claude Opus 5.5
parent 4f0611c24c
commit 2a1fe1b04c
27 changed files with 1593 additions and 48 deletions
+230
View File
@@ -0,0 +1,230 @@
// Étapes du scénario docs/scenarios/demander-a-une-ia.feature (ADR 0008) :
// les décisions se jouent par le domaine sur le vrai moteur, leur analyse
// par le domaine, et les prompts sont remplis par le domaine
// (domain/prompts/) avec les gabarits de docs/prompts/, comme dans
// l'application.
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { BeforeAll, Given, setDefaultTimeout, Then, When } from "@cucumber/cucumber";
import { analyseDecision } from "../../src/domain/analysis/analyse.js";
import {
missingAnalyses,
runAnalysis,
summarizeAnalysis,
} from "../../src/domain/analysis/game-analysis.js";
import { formatEquity, rankingRows } from "../../src/domain/analysis/numbers.js";
import { diceWords } from "../../src/domain/analysis/phrases.js";
import type { Advice } from "../../src/domain/game/advice.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 { parseHalfMoves } from "../../src/domain/game/position.js";
import { fixedDice } from "../../src/domain/game/roll.js";
import {
type PromptPosition,
PUBLIC_SITE,
positionBefore,
} from "../../src/domain/prompts/position.js";
import {
conceptExample,
conceptPrompt,
decisionPrompt,
summaryPosition,
summaryPrompt,
} from "../../src/domain/prompts/prompts.js";
import {
PROMPT_FILES,
type PromptTemplates,
parsePromptTemplate,
} from "../../src/domain/prompts/templates.js";
import { parseGlossaryEntry } from "../../src/ui/help-text.js";
import { MATCH_TEXTS } from "../../src/ui/match-texts.js";
import { loadPhrases, playOpening } from "../support/advice.js";
import { loadNodeEngine } from "../support/node-engine.js";
setDefaultTimeout(60_000);
const root = new URL("../../../../", import.meta.url);
const read = (path: string) => readFileSync(new URL(path, root), "utf8");
const phrases = loadPhrases();
const templates: PromptTemplates = {
move: parsePromptTemplate(read(`docs/prompts/${PROMPT_FILES.move}`)),
cube: parsePromptTemplate(read(`docs/prompts/${PROMPT_FILES.cube}`)),
summary: parsePromptTemplate(read(`docs/prompts/${PROMPT_FILES.summary}`)),
concept: parsePromptTemplate(read(`docs/prompts/${PROMPT_FILES.concept}`)),
exercises: parsePromptTemplate(read(`docs/prompts/${PROMPT_FILES.exercises}`)),
};
/** Le joueur au trait : un pion sur son point 6, un sur son point 1 ;
* l'ordinateur : un sur son point 1, un sur son point 2. */
const BEAROFF = "BQAACAIAAAAAAA";
let engine: Engine;
let game: Game | null;
let journal: GameJournal;
let advice: Advice;
let position: PromptPosition;
let prompt: string;
BeforeAll(async () => {
engine = await loadNodeEngine();
});
Given(/^vous avez joué (.+) avec un (\d)-(\d) d'ouverture$/, async (move, a, b) => {
const played = await playOpening(engine, [Number(a), Number(b)], move);
const analysed = await analyseDecision(engine, played.game.journal, played.eventId);
assert.ok(analysed !== null);
// Le conseil rattaché au journal, comme dans l'application.
played.game.recordAdvice(analysed);
game = played.game;
journal = played.game.journal;
advice = analysed;
});
When("vous demandez à une IA d'expliquer ce coup", async () => {
position = await positionBefore(engine, journal, advice.eventId);
prompt = decisionPrompt(templates, phrases, { journal, advice, position });
});
Then("la question donne la position, avec son Position ID, son Match ID et son lien", () => {
assert.equal(position.positionId, advice.position);
assert.ok(prompt.includes(`Position ID : ${position.positionId}`), prompt);
assert.ok(prompt.includes(`Match ID : ${position.matchId}`), prompt);
assert.ok(prompt.includes(`${PUBLIC_SITE}#/position/`), prompt);
assert.ok(prompt.includes("Mes pions (X), case (nombre) : 24 (2), 13 (5), 8 (3), 6 (5)."));
});
Then(
"elle donne les trois meilleurs coups du maître et le vôtre, avec les chiffres de l'explication",
() => {
assert.equal(advice.kind, "move");
if (advice.kind !== "move") {
return;
}
// Les lignes de « Voir les chiffres du moteur » : coup et écart.
const rows = rankingRows(advice).filter((row) => row.rank <= 3 || row.played);
assert.ok(rows.length >= 3);
for (const row of rows) {
assert.ok(
prompt.includes(`${row.rank}. ${row.move.notation}`) &&
prompt.includes(`écart ${formatEquity(row.loss)}`),
`${row.move.notation} absent :\n${prompt}`,
);
}
assert.ok(prompt.includes(`J'ai joué ${advice.played.notation} : `), prompt);
},
);
Then("elle demande à l'IA de ne pas contredire ces chiffres, et de répondre en français", () => {
assert.ok(prompt.includes("ne les recalcule pas et ne les contredis pas"), prompt);
assert.ok(prompt.includes("Réponds en français"), prompt);
assert.ok(!/[{}]/.test(prompt.replace(/```/g, "")), prompt);
});
/** Toutes les analyses qui manquent, une à une, comme l'application en
* arrière-plan. */
async function analyseAll(start: GameJournal): Promise<GameJournal> {
let current = start;
for (let i = 0; i < 100; i++) {
const next = missingAnalyses(current)[0];
if (next === undefined) {
return current;
}
current = await runAnalysis(engine, current, next);
}
throw new Error("l'analyse ne finit pas");
}
Given(
/^une partie finie où vous avez joué (.+) avec (\d)-(\d), puis passé le double de l'ordinateur$/,
async (move: string, a: string, b: string) => {
const played = Game.start(engine, {
setup: { position: BEAROFF, firstTurn: "player" },
level: "2ply",
dice: fixedDice([Number(a), Number(b)]),
});
await played.roll();
for (const halfMove of parseHalfMoves(move)) {
assert.ok(await played.playHalfMove(halfMove), `demi-coup refusé : ${move}`);
}
await played.confirmMove();
await played.playComputerTurn();
assert.equal(played.state.phase, "doubled", "l'ordinateur ne double pas");
await played.drop();
assert.equal(played.state.phase, "over");
journal = await analyseAll(played.journal);
},
);
When("vous demandez à une IA le bilan de votre partie", async () => {
position = await summaryPosition(engine, journal);
prompt = summaryPrompt(templates, phrases, { journal, position });
});
Then("la question donne vos décisions, votre niveau et la chance des dés, comme le bilan", () => {
const summary = summarizeAnalysis(journal);
const { counts, player } = summary;
assert.ok(prompt.includes(`Mes décisions : ${counts.good} bon`), prompt);
assert.ok(prompt.includes(`${counts.bad + counts.veryBad} erreur`), prompt);
assert.ok(
prompt.includes(`soit le niveau ${MATCH_TEXTS.levels[player.errorRate.level].name}`),
prompt,
);
assert.ok(prompt.includes(`(${diceWords(phrases, player.luck.rating)})`), prompt);
assert.ok(prompt.includes("Résultat : l'ordinateur a gagné"), prompt);
});
Then(/^elle cite votre coup (.+), le coup du maître (.+) et leur écart$/, (mine, best) => {
const toReview = summarizeAnalysis(journal).toReview;
assert.ok(toReview !== null);
assert.ok(
prompt.includes(
`j'ai joué ${mine}, GNU Backgammon préférait ${best} ; écart ${formatEquity(toReview.advice.equityLoss)} (Grosse erreur).`,
),
prompt,
);
});
Then("elle donne la position du coup à revoir", () => {
const toReview = summarizeAnalysis(journal).toReview;
assert.equal(position.positionId, toReview?.advice.position);
assert.ok(prompt.includes(`Position ID : ${position.positionId}`), prompt);
assert.ok(prompt.includes("Le coup à revoir, ma plus grosse erreur (coup 1)"), prompt);
});
Given("aucune partie n'est en cours", () => {
game = null;
});
When(/^vous demandez à une IA d'expliquer le mot « (.+) »$/, async (term: string) => {
const entry = parseGlossaryEntry(read(`docs/glossaire/${term.toLowerCase()}.md`));
assert.ok(entry !== null);
const example = await conceptExample(engine, game?.journal ?? null, 2);
position = example.position;
prompt = conceptPrompt(templates, {
term: entry.term,
english: entry.english,
brief: entry.brief,
journal: game?.journal ?? null,
example,
});
});
Then("la question reprend la définition de sa fiche", () => {
const entry = parseGlossaryEntry(read("docs/glossaire/videau.md"));
assert.ok(entry !== null && prompt.includes(entry.brief), prompt);
assert.ok(prompt.includes("le concept « Videau » (en anglais : Cube)"), prompt);
});
Then(
"elle l'illustre par la position de départ, avec les chances de gagner selon GNU Backgammon",
() => {
assert.ok(prompt.includes("voici la position de départ"), prompt);
assert.ok(prompt.includes("Position ID : 4HPwATDgc/ABMA"), prompt);
assert.match(prompt, /chances de gagner \d+ %/);
},
);