diff --git a/app/src/domain/analysis/numbers.ts b/app/src/domain/analysis/numbers.ts new file mode 100644 index 0000000..8c29335 --- /dev/null +++ b/app/src/domain/analysis/numbers.ts @@ -0,0 +1,58 @@ +// Les chiffres du moteur tels qu'on les écrit : à l'écran (feuille du +// maître, bilan, analyse) et dans les prompts pour une IA (étape 1f). Une +// seule écriture, pour que le prompt donne exactement les chiffres que +// l'application affiche. +// +// Copyright (C) 2026 les auteurs d'Ustagammon. +// Licence : GPLv3 ou ultérieure (voir LICENSE). + +import type { AdvisedMove, MoveAdvice } from "../game/advice.js"; + +/** Un nombre à la française, signé, à trois décimales : « −0,123 ». */ +export function formatEquity(value: number): string { + const text = Math.abs(value).toFixed(3).replace(".", ","); + return value < -0.0005 ? `−${text}` : value > 0.0005 ? `+${text}` : text; +} + +/** Des chances de 0 à 1, en pourcentage arrondi, avec une espace + * insécable : « 41 % ». */ +export function formatPercent(chance: number): string { + return `${Math.round(chance * 100)} %`; +} + +/** Une ligne du classement des coups montré par « Voir les chiffres du + * moteur ». */ +export interface RankingRow { + readonly move: AdvisedMove; + /** Rang dans le classement du moteur, à partir de 1. */ + readonly rank: number; + /** Écart d'équité avec le premier coup du classement : 0 ou négatif. */ + readonly loss: number; + /** C'est le coup joué. */ + readonly played: boolean; +} + +/** + * Le classement d'un conseil tel que l'affiche la feuille du maître : les + * coups du classement du moteur, chacun avec son écart par rapport au + * premier ; puis le coup joué, s'il n'y figure pas, avec l'écart de + * l'analyse du coup joué. + */ +export function rankingRows(advice: MoveAdvice): RankingRow[] { + const first = advice.ranking[0]?.equity ?? advice.best.equity; + const rows = advice.ranking.map((move, i) => ({ + move, + rank: i + 1, + loss: move.equity - first, + played: move.notation === advice.played.notation, + })); + if (!rows.some((row) => row.played)) { + rows.push({ + move: advice.played, + rank: advice.rank, + loss: advice.equityLoss, + played: true, + }); + } + return rows; +} diff --git a/app/src/domain/prompts/position.ts b/app/src/domain/prompts/position.ts new file mode 100644 index 0000000..3f422af --- /dev/null +++ b/app/src/domain/prompts/position.ts @@ -0,0 +1,195 @@ +// La position que décrit un prompt (étape 1f) : ses identifiants de GNU +// Backgammon, son plateau décodé par le moteur, le videau et le match ; et +// son diagramme texte. +// +// Copyright (C) 2026 les auteurs d'Ustagammon. +// Licence : GPLv3 ou ultérieure (voir LICENSE). +// +// Rien n'est décodé ici : le moteur lit la position (board) et écrit le +// Match ID (encodeMatch). Le diagramme ne fait que recopier le plateau +// qu'il a décodé. + +import type { Cube } from "../game/cube.js"; +import type { Engine, Evaluation, MatchId } from "../game/engine.js"; +import { currentLine, type GameJournal } from "../game/events.js"; +import { engineCube, type SideBoards } from "../game/game.js"; +import type { Match } from "../game/match.js"; +import type { PositionId } from "../game/position.js"; +import type { Roll } from "../game/roll.js"; +import type { Side } from "../game/side.js"; +import { type GameState, replay } from "../game/state.js"; + +/** L'adresse publique de l'application (ADR 0013 : les liens de position + * s'ouvrent sur elle). */ +export const PUBLIC_SITE = "https://ustagammon.sans.pub/"; + +/** Une position telle que la décrit un prompt. */ +export interface PromptPosition { + /** Position ID de gnubg, vue du camp au trait. */ + readonly positionId: PositionId; + /** Match ID écrit par le moteur, du point de vue du camp au trait, sans + * dés. */ + readonly matchId: MatchId; + readonly onRoll: Side; + /** Le plateau décodé par le moteur, chaque camp dans sa numérotation. */ + readonly sides: SideBoards; + readonly cube: Cube; + /** null en partie libre. */ + readonly match: Match | null; + /** Le jet à jouer, s'il est déjà tiré. */ + readonly roll: Roll | null; +} + +/** La position d'avant l'événement eventId du journal (la décision qu'on + * commente), lue par le moteur. */ +export async function positionBefore( + engine: Engine, + journal: GameJournal, + eventId: string, +): Promise { + const line = currentLine({ ...journal, head: eventId }); + return positionOf(engine, replay(line.slice(0, -1))); +} + +/** La position actuelle d'une partie, ou la position de départ sans + * partie, lue par le moteur. */ +export async function positionNow( + engine: Engine, + journal: GameJournal | null, +): Promise { + return positionOf(engine, replay(journal === null ? [] : currentLine(journal))); +} + +async function positionOf(engine: Engine, state: GameState): Promise { + // Pendant le jet d'ouverture, la position de départ, vue du joueur. + const onRoll = state.turn ?? "player"; + const cube = engineCube(state, onRoll); + const [board, matchId] = await Promise.all([ + engine.board(state.position, cube), + engine.encodeMatch(cube), + ]); + const sides = + onRoll === "player" + ? { player: board.onRoll, computer: board.opponent } + : { player: board.opponent, computer: board.onRoll }; + return { + positionId: state.position, + matchId, + onRoll, + sides, + cube: state.cube, + match: state.match, + roll: state.phase === "moving" ? state.roll : null, + }; +} + +/** L'évaluation par le moteur d'une position, pour le camp au trait avant + * de lancer les dés. */ +export function evaluatePosition( + engine: Engine, + position: PromptPosition, + ply: number, +): Promise { + const cube = engineCube({ cube: position.cube, match: position.match }, position.onRoll); + return engine.evaluate(position.positionId, cube, ply); +} + +/** L'adresse publique de la position (#/position//, + * ADR 0013, identifiants encodés avec encodeURIComponent). */ +export function positionAddress(position: PromptPosition): string { + const id = encodeURIComponent(position.positionId); + return `${PUBLIC_SITE}#/position/${id}/${encodeURIComponent(position.matchId)}`; +} + +/** Nombre de lignes de pions dessinées par moitié du plateau ; au-delà, la + * dernière ligne porte le nombre de pions. */ +const ROWS = 5; + +/** + * Le diagramme texte d'une position, vu du joueur : ses pions X, ceux de + * l'ordinateur O, dans la numérotation du joueur (sa case 1 est la plus + * proche de sa sortie, en bas à droite). Sous le dessin, la liste des pions + * case par case, la barre, les pions sortis et les comptes de points, tels + * que le moteur les donne. + */ +export function boardDiagram(sides: SideBoards): string { + // La case p du joueur est la case 25 - p de l'ordinateur. + const at = (point: number): { mark: string; count: number } => { + const mine = sides.player.points[point - 1] ?? 0; + const theirs = sides.computer.points[24 - point] ?? 0; + if (mine > 0) { + return { mark: "X", count: mine }; + } + return theirs > 0 ? { mark: "O", count: theirs } : { mark: ".", count: 0 }; + }; + const cell = (text: string) => text.padStart(3, " "); + const half = (points: readonly number[]) => + `${points.slice(0, 6).map(String).map(cell).join("")} |${points.slice(6).map(String).map(cell).join("")}`; + const row = (points: readonly number[], level: number) => { + const cells = points.map((p) => { + const { mark, count } = at(p); + if (count < level) { + return level === 1 ? cell(".") : cell(""); + } + return cell(level === ROWS && count > ROWS ? String(count) : mark); + }); + return `${cells.slice(0, 6).join("")} |${cells.slice(6).join("")}`.trimEnd(); + }; + const top = [13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24]; + const bottom = [12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1]; + const lines = [half(top)]; + for (let level = 1; level <= ROWS; level++) { + lines.push(row(top, level)); + } + lines.push(""); + for (let level = ROWS; level >= 1; level--) { + lines.push(row(bottom, level)); + } + lines.push(half(bottom)); + + const list = (mark: "X" | "O") => { + const parts: string[] = []; + for (let point = 24; point >= 1; point--) { + const here = at(point); + if (here.mark === mark) { + parts.push(`${point} (${here.count})`); + } + } + return parts.length === 0 ? "aucun" : parts.join(", "); + }; + return [ + "```", + ...lines, + "```", + "X : mes pions ; O : ceux de l'ordinateur. Les cases sont numérotées de mon point de vue : je joue de la case 24 vers la case 1, mon jan intérieur est en bas à droite (cases 1 à 6).", + `Mes pions (X), case (nombre) : ${list("X")}.`, + `Pions de l'ordinateur (O), dans ma numérotation : ${list("O")}.`, + `Sur la barre : moi ${sides.player.bar}, l'ordinateur ${sides.computer.bar}. Sortis : moi ${sides.player.off}, l'ordinateur ${sides.computer.off}.`, + `Compte de points : moi ${sides.player.pipCount}, l'ordinateur ${sides.computer.pipCount}.`, + ].join("\n"); +} + +/** Le camp, à la première personne du joueur : « moi », « l'ordinateur ». */ +export function sideName(side: Side): string { + return side === "player" ? "moi" : "l'ordinateur"; +} + +/** Le score : « partie libre » ou celui du match. */ +export function scoreText(position: Pick): string { + const match = position.match; + if (match === null) { + return "partie libre, sans match (règle Jacoby)"; + } + const crawford = match.crawford ? ", partie Crawford" : ""; + return `match en ${match.length} points, moi ${match.score.player}, l'ordinateur ${match.score.computer}${crawford}`; +} + +/** Le videau : sa valeur et qui le possède. */ +export function cubeText(position: Pick): string { + if (position.match?.crawford === true) { + return "aucun (partie Crawford : personne ne peut doubler)"; + } + const { value, owner } = position.cube; + const where = owner === null ? "au centre" : owner === "player" ? "à moi" : "à l'ordinateur"; + return `${value}, ${where}`; +} diff --git a/app/src/domain/prompts/prompts.ts b/app/src/domain/prompts/prompts.ts new file mode 100644 index 0000000..369bebf --- /dev/null +++ b/app/src/domain/prompts/prompts.ts @@ -0,0 +1,467 @@ +// Les prompts pour une IA (étape 1f ; cadrage, « IA générative en V1 ») : +// chaque gabarit de docs/prompts/ rempli avec la position, décodée par le +// moteur, et les chiffres du moteur, écrits comme l'application les +// affiche. Le moteur décide, l'IA explique : le prompt lui demande de ne +// pas contredire ces chiffres. +// +// Copyright (C) 2026 les auteurs d'Ustagammon. +// Licence : GPLv3 ou ultérieure (voir LICENSE). +// +// Les fonctions qui remplissent (decisionPrompt, summaryPrompt, +// conceptPrompt, exercisesPrompt) sont pures : elles reçoivent les +// analyses du moteur et la position déjà lue. Les fonctions asynchrones +// (summaryPosition, conceptExample, exercisesPosition) ne font que demander +// au moteur la position à décrire. + +import { + gameTurns, + levelOfErrorRate, + type ReviewedDecision, + summarizeAnalysis, +} from "../analysis/game-analysis.js"; +import { formatEquity, formatPercent, rankingRows } from "../analysis/numbers.js"; +import { diceWords, type MasterPhrases, masterWords } from "../analysis/phrases.js"; +import { + type Advice, + type CubeAdvice, + type MoveAdvice, + masterCubeAction, + verdictOf, +} from "../game/advice.js"; +import type { CubeAction, Engine, Evaluation, Ply } from "../game/engine.js"; +import { currentLine, type GameJournal } from "../game/events.js"; +import { type ComputerLevel, computerLevelOf } from "../game/level.js"; +import { replay } from "../game/state.js"; +import { + boardDiagram, + cubeText, + evaluatePosition, + type PromptPosition, + positionAddress, + positionBefore, + positionNow, + scoreText, + sideName, +} from "./position.js"; +import { fillPrompt, type Prompt, type PromptTemplates } from "./templates.js"; + +/** La langue des réponses demandées à l'IA. */ +export const PROMPT_LANGUAGE = "français"; + +/** Les noms des niveaux, les mêmes que l'écran « Nouvelle partie » et le + * bilan (vérifié par app/tests/prompts.test.ts). */ +export const LEVEL_NAMES: Readonly> = { + beginner: "Débutant", + amateur: "Amateur", + intermediate: "Intermédiaire", + advanced: "Avancé", + expert: "Expert", + champion: "Champion", +}; + +/** Les décisions de videau, comme le bilan les nomme. */ +export const CUBE_ACTION_WORDS: Readonly> = { + double: "doubler", + noDouble: "lancer sans doubler", + take: "prendre", + pass: "passer", +}; + +/** Les lignes des équités de videau, comme « Voir les chiffres du + * moteur ». */ +export const CUBE_ROW_WORDS = { + noDouble: "Pas de double", + doubleTake: "Double, prend", + doublePass: "Double, passe", +} as const; + +/** Les erreurs citées au plus dans un prompt (bilan, exercices). */ +const MAX_ERRORS = 8; + +const plural = (n: number, one: string, many: string) => `${n} ${n > 1 ? many : one}`; + +/** « analyse à 2 plis ». */ +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)"; + } + 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")})`; +} + +/** Les repères de la position, communs à tous les gabarits. */ +function positionValues(position: PromptPosition): Record { + return { + position_id: position.positionId, + match_id: position.matchId, + au_trait: sideName(position.onRoll), + lien: positionAddress(position), + diagramme: boardDiagram(position.sides), + score: scoreText(position), + videau: cubeText(position), + langue: PROMPT_LANGUAGE, + }; +} + +// ------------------------------------------------------ Une décision + +/** Ce que demande le prompt d'une décision du joueur. */ +export interface DecisionPromptData { + /** La partie : le niveau du joueur se lit sur ses décisions. */ + readonly journal: GameJournal; + /** Le conseil du moteur sur la décision. */ + readonly advice: Advice; + /** La position d'avant la décision (positionBefore). */ + readonly position: PromptPosition; +} + +/** Le prompt « Pourquoi ce coup est meilleur » ou « Expliquer cette + * décision de videau ». */ +export function decisionPrompt( + templates: PromptTemplates, + phrases: MasterPhrases, + data: DecisionPromptData, +): Prompt { + const common = { + ...positionValues(data.position), + niveau: playerLevel(data.journal), + niveau_eval: plyText(data.advice.ply), + appreciation: masterWords(phrases, data.advice).word, + }; + return data.advice.kind === "move" + ? fillPrompt(templates.move, { ...common, ...moveValues(data.advice) }) + : fillPrompt(templates.cube, { ...common, ...cubeValues(data.advice) }); +} + +function moveValues(advice: MoveAdvice): Record { + const count = plural(advice.moveCount, "coup possible", "coups possibles"); + const rank = + advice.moveCount <= 1 + ? "le seul coup possible" + : advice.rank === 1 + ? `le meilleur des ${count}` + : `le ${advice.rank}e choix sur ${count}`; + // Les trois meilleurs coups, et le vôtre s'il n'en est pas : les lignes + // de « Voir les chiffres du moteur », avec les mêmes chiffres. + const rows = rankingRows(advice).filter((row) => row.rank <= 3 || row.played); + const ranking = rows.map((row) => { + const ply = row.move.ply === advice.ply ? "" : `, évalué à ${row.move.ply} pli`; + const mine = row.played ? " (mon coup)" : ""; + return `${row.rank}. ${row.move.notation}${mine} : équité ${formatEquity(row.move.equity)}, écart ${formatEquity(row.loss)}${ply}`; + }); + return { + des: `${advice.roll[0]}-${advice.roll[1]}`, + coup_joue: advice.played.notation, + rang: rank, + classement: ranking.join("\n"), + }; +} + +function cubeValues(advice: CubeAdvice): Record { + const answering = advice.action === "take" || advice.action === "pass"; + // Les lignes de votre décision : doubler mène à l'une des deux lignes + // « Double », selon la réponse de l'ordinateur (comme la feuille). + const mine = { + noDouble: ["noDouble"], + double: ["doubleTake", "doublePass"], + take: ["doubleTake"], + pass: ["doublePass"], + }[advice.action]; + const rows = (["noDouble", "doubleTake", "doublePass"] as const).map((key) => { + const note = mine.length === 1 && mine.includes(key) ? " (ma décision)" : ""; + return `- ${CUBE_ROW_WORDS[key]}${note} : ${formatEquity(advice.equities[key])}`; + }); + return { + decision: answering + ? "prendre ou passer le double de l'ordinateur" + : "doubler ou lancer les dés sans doubler", + choix: CUBE_ACTION_WORDS[advice.action], + choix_moteur: CUBE_ACTION_WORDS[masterCubeAction(advice)], + qui_double: answering ? "l'ordinateur" : "moi", + equites: rows.join("\n"), + ecart: formatEquity(advice.equityLoss), + }; +} + +// ------------------------------------------------------------ Le bilan + +/** Une imprécision ou une erreur du joueur, dans son tour. */ +interface Mistake { + readonly turn: number; + readonly advice: Advice; + /** Le jet du tour, « 6-1 » (vide pour un tour sans jet). */ + readonly roll: string; +} + +/** Les imprécisions et erreurs du joueur sur la ligne courante, dans + * l'ordre de la partie. */ +function mistakesOf(journal: GameJournal): Mistake[] { + const advice = journal.advice ?? {}; + const mistakes: Mistake[] = []; + for (const turn of gameTurns(journal)) { + for (const event of turn.events) { + const a = advice[event.id]; + if (a !== undefined && verdictOf(a.skill) !== "good") { + const roll = turn.roll === null ? "" : `${turn.roll[0]}-${turn.roll[1]}`; + mistakes.push({ turn: turn.number, advice: a, roll }); + } + } + } + return mistakes; +} + +/** Une ligne de la liste des erreurs : ce qui a été joué, ce que le moteur + * préférait, l'écart et le mot du maître. */ +function mistakeLine(phrases: MasterPhrases, mistake: Mistake, where: string): string { + const a = mistake.advice; + const word = masterWords(phrases, a).word; + const cost = `écart ${formatEquity(a.equityLoss)} (${word})`; + if (a.kind === "move") { + return `- ${where}, ${mistake.roll} : j'ai joué ${a.played.notation}, GNU Backgammon préférait ${a.best.notation} ; ${cost}.`; + } + return `- ${where} : j'ai choisi de ${CUBE_ACTION_WORDS[a.action]}, GNU Backgammon aurait choisi de ${CUBE_ACTION_WORDS[masterCubeAction(a)]} ; ${cost}.`; +} + +/** Le niveau de l'ordinateur d'une partie, par son nom. */ +function computerLevelName(journal: GameJournal): string { + const level = replay(currentLine(journal)).level; + const name = computerLevelOf(level); + return name === null ? `GNU Backgammon, réglage ${level}` : LEVEL_NAMES[name]; +} + +/** Le résultat d'une partie finie, et le match après elle. */ +function resultText(journal: GameJournal): string { + const line = currentLine(journal); + const state = replay(line); + const result = state.result; + if (result === null) { + return "partie pas encore finie"; + } + const who = result.winner === "player" ? "j'ai gagné" : "l'ordinateur a gagné"; + const kind = { + single: "une partie simple", + gammon: "un gammon", + backgammon: "un backgammon", + }[result.kind]; + const dropped = line.some((e) => e.type === "CubeDropped") + ? result.winner === "player" + ? ", l'ordinateur ayant refusé mon double" + : ", après mon refus de son double" + : ""; + const cube = state.cube.value > 1 ? `, videau à ${state.cube.value}` : ""; + const text = `${who} ${kind}, ${plural(result.points, "point", "points")}${cube}${dropped}`; + const after = state.matchAfter; + if (state.match === null || after === null) { + return text; + } + const over = after.over ? ", match terminé" : ""; + return `${text} ; match en ${state.match.length} points, score après la partie : moi ${after.score.player}, l'ordinateur ${after.score.computer}${over}`; +} + +/** La position que montre le prompt du bilan : celle d'avant le coup à + * revoir, ou la position finale si le joueur n'a fait aucune erreur. */ +export async function summaryPosition( + engine: Engine, + journal: GameJournal, +): Promise { + const toReview = summarizeAnalysis(journal).toReview; + return toReview === null + ? positionNow(engine, journal) + : positionBefore(engine, journal, toReview.advice.eventId); +} + +/** Ce que demande le prompt du bilan. */ +export interface SummaryPromptData { + /** La partie finie, analysée (readyForSummary). */ + readonly journal: GameJournal; + /** summaryPosition. */ + readonly position: PromptPosition; +} + +/** Le prompt « Bilan de ma partie ». */ +export function summaryPrompt( + templates: PromptTemplates, + phrases: MasterPhrases, + data: SummaryPromptData, +): Prompt { + const summary = summarizeAnalysis(data.journal); + const { player, computer, counts } = summary; + const errors = counts.bad + counts.veryBad; + const veryBad = + counts.veryBad > 0 + ? ` (dont ${plural(counts.veryBad, "grosse erreur", "grosses erreurs")})` + : ""; + const mistakes = mistakesOf(data.journal); + const listed = [...mistakes] + .sort((a, b) => a.advice.equityLoss - b.advice.equityLoss) + .slice(0, MAX_ERRORS) + .sort((a, b) => a.turn - b.turn) + .map((m) => mistakeLine(phrases, m, `Coup ${m.turn}`)); + if (mistakes.length > listed.length) { + listed.push(`- et ${mistakes.length - listed.length} de moins coûteuses.`); + } + const toReview = summary.toReview; + return fillPrompt(templates.summary, { + ...positionValues(data.position), + niveau: playerLevel(data.journal), + niveau_eval: plyText(summaryPly(data.journal, toReview)), + ordinateur: computerLevelName(data.journal), + resultat: resultText(data.journal), + decomptes: `${plural(counts.good, "bon coup", "bons coups")}, ${plural(counts.doubtful, "imprécision", "imprécisions")}, ${plural(errors, "erreur", "erreurs")}${veryBad}`, + taux: formatEquity(-player.errorRate.perDecision), + decisions: plural(player.errorRate.decisions, "décision", "décisions"), + niveau_jeu: LEVEL_NAMES[player.errorRate.level], + taux_ordinateur: `${formatEquity(-computer.errorRate.perDecision)} sur ${plural(computer.errorRate.decisions, "décision", "décisions")}, soit le niveau ${LEVEL_NAMES[computer.errorRate.level]}`, + chance: formatEquity(player.luck.perRoll), + chance_mots: diceWords(phrases, player.luck.rating), + chance_ordinateur: formatEquity(computer.luck.perRoll), + erreurs: + listed.length === 0 + ? "- aucune : j'ai joué comme GNU Backgammon." + : listed.join("\n"), + position_titre: + toReview === null + ? "Je n'ai fait ni imprécision ni erreur. Voici la position finale" + : `Le coup à revoir, ma plus grosse erreur (coup ${toReview.turn}) : la position avant ma décision`, + }); +} + +/** Le niveau des analyses du bilan : celui du coup à revoir, ou de + * l'analyse de partie (2 plis). */ +function summaryPly(journal: GameJournal, toReview: ReviewedDecision | null): Ply { + return toReview?.advice.ply ?? journal.review?.ply ?? 2; +} + +// ------------------------------------------------------- Un concept + +/** La position d'exemple d'un concept : la partie en cours, ou la + * position de départ s'il n'y en a pas (ou si elle est finie), avec + * l'évaluation du moteur. */ +export interface ConceptExample { + readonly position: PromptPosition; + readonly evaluation: Evaluation; + readonly ply: Ply; + readonly fromGame: boolean; +} + +/** Lit la position d'exemple d'un concept et l'évalue, au niveau ply. */ +export async function conceptExample( + engine: Engine, + journal: GameJournal | null, + ply: Ply, +): Promise { + const playing = + journal !== null && !["opening", "over"].includes(replay(currentLine(journal)).phase); + const position = await positionNow(engine, playing ? journal : null); + const evaluation = await evaluatePosition(engine, position, ply); + return { position, evaluation, ply, fromGame: playing }; +} + +/** Ce que demande le prompt d'un concept. */ +export interface ConceptPromptData { + /** Le terme, son nom anglais (peut être vide) et sa phrase « En bref ». */ + readonly term: string; + readonly english: string; + readonly brief: string; + /** La partie en cours, pour le niveau du joueur (null : aucune). */ + readonly journal: GameJournal | null; + readonly example: ConceptExample; +} + +/** Le prompt « Expliquer ce concept ». */ +export function conceptPrompt(templates: PromptTemplates, data: ConceptPromptData): Prompt { + const { example } = data; + const english = + data.english !== "" && data.english.toLowerCase() !== data.term.toLowerCase() + ? ` (en anglais : ${data.english})` + : ""; + return fillPrompt(templates.concept, { + ...positionValues(example.position), + niveau: playerLevel(data.journal), + niveau_eval: plyText(example.ply), + concept: `« ${data.term} »${english}`, + definition: data.brief, + position_titre: example.fromGame + ? "une position de ma partie en cours" + : "la position de départ", + evaluation: `chances de gagner ${formatPercent(example.evaluation.win)}, équité ${formatEquity(example.evaluation.equity)}`, + }); +} + +// ------------------------------------------------------ Des exercices + +/** Une erreur dans une des parties. */ +interface GameMistake extends Mistake { + /** Rang de la partie, à partir de 1 (la plus ancienne). */ + readonly game: number; + readonly journal: GameJournal; +} + +/** Les imprécisions et erreurs de plusieurs parties, de la plus coûteuse à + * la moins coûteuse. */ +function costliest(journals: readonly GameJournal[]): GameMistake[] { + return journals + .flatMap((journal, i) => mistakesOf(journal).map((m) => ({ ...m, game: i + 1, journal }))) + .sort((a, b) => a.advice.equityLoss - b.advice.equityLoss); +} + +/** La position de la plus coûteuse des erreurs de ces parties (null s'il + * n'y en a aucune : pas d'exercice à proposer). */ +export async function exercisesPosition( + engine: Engine, + journals: readonly GameJournal[], +): Promise { + const worst = costliest(journals)[0]; + return worst === undefined + ? null + : positionBefore(engine, worst.journal, worst.advice.eventId); +} + +/** Ce que demande le prompt des exercices. */ +export interface ExercisesPromptData { + /** Les parties analysées, de la plus ancienne à la plus récente. */ + readonly journals: readonly GameJournal[]; + /** exercisesPosition (non null). */ + readonly position: PromptPosition; +} + +/** Le prompt « Créer des exercices » (progression, étape 1h). Erreur si + * les parties n'ont aucune imprécision ni erreur. */ +export function exercisesPrompt( + templates: PromptTemplates, + phrases: MasterPhrases, + data: ExercisesPromptData, +): Prompt { + const mistakes = costliest(data.journals); + const worst = mistakes[0]; + if (worst === undefined) { + throw new Error("exercices : aucune erreur dans ces parties"); + } + let total = 0; + let decisions = 0; + const rates = data.journals.map((journal) => { + 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 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")})`, + 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`, + }); +} diff --git a/app/src/domain/prompts/templates.ts b/app/src/domain/prompts/templates.ts new file mode 100644 index 0000000..c725b49 --- /dev/null +++ b/app/src/domain/prompts/templates.ts @@ -0,0 +1,73 @@ +// Les gabarits des prompts pour une IA (étape 1f) : lecture de leur source +// (docs/prompts/, ADR 0008 : une seule source) et remplissage des repères. +// Fonctions pures, testées sous Node sur de vraies analyses du moteur +// (app/tests/prompts.test.ts). +// +// Copyright (C) 2026 les auteurs d'Ustagammon. +// Licence : GPLv3 ou ultérieure (voir LICENSE). + +/** + * Un prompt : le texte que l'application prépare pour une IA, rempli avec + * la position et les chiffres du moteur, et que le joueur copie. + * + * Glossaire : docs/glossaire/prompt.md + */ +export type Prompt = string; + +/** Les cinq gabarits du cadrage (« IA générative en V1 »). */ +export type PromptKind = "move" | "cube" | "summary" | "concept" | "exercises"; + +export const PROMPT_KINDS: readonly PromptKind[] = [ + "move", + "cube", + "summary", + "concept", + "exercises", +]; + +/** Le fichier de chaque gabarit, dans docs/prompts/. */ +export const PROMPT_FILES: Readonly> = { + move: "coup.md", + cube: "videau.md", + summary: "bilan.md", + concept: "concept.md", + exercises: "exercices.md", +}; + +/** Les gabarits lus, par sorte. */ +export type PromptTemplates = Readonly>; + +/** Le gabarit d'un fichier de docs/prompts/ : le bloc de texte qui suit le + * titre « ## Gabarit ». Erreur s'il manque. */ +export function parsePromptTemplate(markdown: string): string { + const lines = markdown.split("\n"); + const heading = lines.findIndex((line) => line.trim() === "## Gabarit"); + const start = lines.findIndex((line, i) => i > heading && line.trim() === "```text"); + const end = lines.findIndex((line, i) => i > start && line.trim() === "```"); + if (heading < 0 || start < 0 || end < 0) { + throw new Error("gabarit de prompt : bloc « ## Gabarit » absent"); + } + return lines.slice(start + 1, end).join("\n"); +} + +/** Les repères d'un gabarit, dans l'ordre, sans doublon. */ +export function placeholders(template: string): string[] { + return [...new Set([...template.matchAll(/\{([a-z_]+)\}/g)].map((m) => m[1] as string))]; +} + +/** + * Remplit les repères {nom} d'un gabarit. Erreur sur un repère inconnu ou + * une valeur vide : un prompt n'a jamais de trou. + */ +export function fillPrompt(template: string, values: Readonly>): Prompt { + return template.replace(/\{([a-z_]+)\}/g, (_, name: string) => { + const value = values[name]; + if (value === undefined) { + throw new Error(`gabarit de prompt : repère {${name}} inconnu`); + } + if (value.trim() === "") { + throw new Error(`gabarit de prompt : repère {${name}} vide`); + } + return value; + }); +} diff --git a/app/src/ui/advice-texts.ts b/app/src/ui/advice-texts.ts index 37dc21a..cc309de 100644 --- a/app/src/ui/advice-texts.ts +++ b/app/src/ui/advice-texts.ts @@ -48,8 +48,6 @@ export const CUBE_GLOSSARY: Readonly 0.0005 ? `+${text}` : text; -} +/** Un nombre à la française, signé, à trois décimales : « −0,123 » ; la + * même écriture que les prompts (domain/analysis/numbers.ts). */ +export { formatEquity } from "../domain/analysis/numbers.js"; diff --git a/app/src/ui/advice-view.ts b/app/src/ui/advice-view.ts index 7b3fc6b..29d28d8 100644 --- a/app/src/ui/advice-view.ts +++ b/app/src/ui/advice-view.ts @@ -7,6 +7,7 @@ // Copyright (C) 2026 les auteurs d'Ustagammon. // Licence : GPLv3 ou ultérieure (voir LICENSE). +import { rankingRows } from "../domain/analysis/numbers.js"; import { type MasterPhrases, masterWords } from "../domain/analysis/phrases.js"; import { type Advice, @@ -276,18 +277,8 @@ export function createAdviceView(options: AdviceViewOptions): AdviceView { if (advice.kind === "move") { // Les écarts du classement se lisent par rapport à son premier // coup ; votre coup, s'il n'y figure pas, avec l'écart de - // l'analyse du coup joué. - const first = advice.ranking[0]?.equity ?? advice.best.equity; - const listed = advice.ranking.map((move, i) => ({ - move, - rank: i + 1, - loss: move.equity - first, - })); - if (!advice.ranking.some((m) => m.notation === advice.played.notation)) { - listed.push({ move: advice.played, rank: advice.rank, loss: advice.equityLoss }); - } - for (const { move, rank, loss } of listed) { - const mine = move.notation === advice.played.notation; + // l'analyse du coup joué (les mêmes lignes que le prompt). + for (const { move, rank, loss, played: mine } of rankingRows(advice)) { row( [ String(rank), diff --git a/app/src/ui/review-texts.ts b/app/src/ui/review-texts.ts index bc99509..ce4de4b 100644 --- a/app/src/ui/review-texts.ts +++ b/app/src/ui/review-texts.ts @@ -10,16 +10,12 @@ import type { Roll } from "../domain/game/roll.js"; const plural = (n: number, one: string, many: string) => (n > 1 ? many : one); -/** « 41 % ». */ -export function percent(chance: number): string { - return `${Math.round(chance * 100)} %`; -} - -/** Un nombre à la française, signé, à trois décimales : « +0,021 ». */ -export function signed(value: number): string { - const text = Math.abs(value).toFixed(3).replace(".", ","); - return value < -0.0005 ? `−${text}` : value > 0.0005 ? `+${text}` : text; -} +/** « 41 % » et « +0,021 » : la même écriture que les prompts + * (domain/analysis/numbers.ts). */ +export { + formatEquity as signed, + formatPercent as percent, +} from "../domain/analysis/numbers.js"; export const REVIEW_TEXTS = { // Bilan diff --git a/app/tests/prompts.test.ts b/app/tests/prompts.test.ts new file mode 100644 index 0000000..63748ee --- /dev/null +++ b/app/tests/prompts.test.ts @@ -0,0 +1,382 @@ +// Les prompts pour une IA (étape 1f) : gabarits lus dans leur source +// (docs/prompts/), remplis avec de vraies analyses du moteur. Aucun repère +// vide ni inconnu, et les chiffres sont ceux que l'application affiche. +// +// 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 { test } from "node:test"; +import { + missingAnalyses, + runAnalysis, + summarizeAnalysis, +} from "../src/domain/analysis/game-analysis.js"; +import { formatEquity, rankingRows } from "../src/domain/analysis/numbers.js"; +import { diceWords, masterWords } 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 { COMPUTER_LEVELS } from "../src/domain/game/level.js"; +import { START_POSITION } from "../src/domain/game/position.js"; +import { + boardDiagram, + PUBLIC_SITE, + positionAddress, + positionBefore, + positionNow, +} from "../src/domain/prompts/position.js"; +import { + CUBE_ACTION_WORDS, + CUBE_ROW_WORDS, + conceptExample, + conceptPrompt, + decisionPrompt, + exercisesPosition, + exercisesPrompt, + LEVEL_NAMES, + summaryPosition, + summaryPrompt, +} from "../src/domain/prompts/prompts.js"; +import { + fillPrompt, + PROMPT_FILES, + PROMPT_KINDS, + type PromptTemplates, + parsePromptTemplate, + placeholders, +} from "../src/domain/prompts/templates.js"; +import { ADVICE_TEXTS } from "../src/ui/advice-texts.js"; +import { MATCH_TEXTS } from "../src/ui/match-texts.js"; +import { REVIEW_TEXTS, signed } from "../src/ui/review-texts.js"; +import { parseRoute, routeHash } from "../src/ui/router.js"; +import { + adviceOn, + loadPhrases, + playerAnswers, + playerDoubles, + playerRollsWithoutDoubling, + 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 phrases = loadPhrases(); + +/** Les gabarits, lus dans leur source. */ +function loadTemplates(): PromptTemplates { + const read = (file: string) => + parsePromptTemplate(readFileSync(new URL(`docs/prompts/${file}`, root), "utf8")); + return { + 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 templates = loadTemplates(); + +/** Ce que chaque prompt doit contenir (cadrage, « IA générative en V1 »). */ +function assertComplete(prompt: string, position: { positionId: string; matchId: string }): void { + for (const hole of ["{", "}", "inconnu", "undefined", "NaN", "null", " :"]) { + 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.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"); + assert.ok(prompt.includes("X : mes pions"), "diagramme absent"); + assert.match(prompt, /GNU Backgammon \(analyse à \d plis?\)/); + assert.ok(prompt.includes("ne les contredis pas"), "consigne absente"); + assert.ok(prompt.includes("Réponds en français"), "langue absente"); +} + +test("les cinq gabarits se lisent dans leur source, avec les repères du cadrage", () => { + assert.equal(PROMPT_KINDS.length, 5); + for (const kind of PROMPT_KINDS) { + const names = placeholders(templates[kind]); + for (const name of [ + "niveau", + "position_id", + "match_id", + "au_trait", + "lien", + "diagramme", + "score", + "videau", + "niveau_eval", + "langue", + ]) { + assert.ok(names.includes(name), `{${name}} absent du gabarit ${kind}`); + } + assert.ok(templates[kind].includes("ne les contredis pas"), `consigne absente : ${kind}`); + } + assert.throws(() => parsePromptTemplate("# Sans gabarit\n"), /Gabarit/); +}); + +test("un repère inconnu ou vide est une erreur", () => { + assert.equal(fillPrompt("a {b} c", { b: "x" }), "a x c"); + assert.throws(() => fillPrompt("a {b}", {}), /inconnu/); + assert.throws(() => fillPrompt("a {b}", { b: " " }), /vide/); +}); + +test("les mots du prompt sont ceux de l'écran", () => { + for (const level of COMPUTER_LEVELS) { + assert.equal(LEVEL_NAMES[level], MATCH_TEXTS.levels[level].name); + } + assert.deepEqual(CUBE_ACTION_WORDS, REVIEW_TEXTS.cubeActions); + assert.deepEqual(CUBE_ROW_WORDS, ADVICE_TEXTS.cubeRows); +}); + +test("le diagramme recopie le plateau décodé par le moteur", async () => { + const engine = await loadNodeEngine(); + const start = await positionNow(engine, null); + assert.equal(start.positionId, START_POSITION); + const diagram = boardDiagram(start.sides); + assert.ok(diagram.includes("Mes pions (X), case (nombre) : 24 (2), 13 (5), 8 (3), 6 (5).")); + assert.ok( + diagram.includes( + "Pions de l'ordinateur (O), dans ma numérotation : 19 (5), 17 (3), 12 (5), 1 (2).", + ), + ); + assert.ok(diagram.includes("Compte de points : moi 167, l'ordinateur 167.")); + // La rangée du haut : 13 (X, 5 pions) à gauche, 24 (X, 2 pions) à + // droite ; 19 (O) juste après la barre. + const lines = diagram.split("\n"); + assert.equal(lines[1], " 13 14 15 16 17 18 | 19 20 21 22 23 24"); + assert.equal(lines[2], " X . . . O . | O . . . . X"); + assert.equal(lines[3], " X O | O X"); + assert.equal(lines[0], "```"); + assert.equal(lines[lines.indexOf("```", 1) - 1], " 12 11 10 9 8 7 | 6 5 4 3 2 1"); +}); + +test("le lien de la position est l'adresse publique de l'écran « position »", async () => { + const engine = await loadNodeEngine(); + const played = await playOpening(engine, [3, 1], "24/23 13/10"); + const position = await positionBefore(engine, played.game.journal, played.eventId); + const address = positionAddress(position); + const hash = routeHash({ + screen: "position", + position: position.positionId, + match: position.matchId, + }); + assert.equal(address, `${PUBLIC_SITE}${hash}`); + assert.deepEqual(parseRoute(new URL(address).hash), { + screen: "position", + position: position.positionId, + match: position.matchId, + }); +}); + +/** Les lignes du classement telles que la feuille du maître les affiche : + * rang, coup, écart. */ +function shownRanking(advice: Advice): string[] { + if (advice.kind !== "move") { + return []; + } + return rankingRows(advice).map((row) => `${row.move.notation}|${formatEquity(row.loss)}`); +} + +test("prompt d'un coup : la position, les identifiants et le top 3 du moteur, mêmes chiffres", async () => { + const engine = await loadNodeEngine(); + for (const [dice, move] of [ + [[3, 1], "24/23 13/10"], + [[3, 1], "8/5 6/5"], + [[2, 1], "13/11 6/5"], + [[6, 5], "24/18 13/8"], + ] as const) { + const played = await playOpening(engine, dice, move); + const advice = await adviceOn(engine, played); + assert.equal(advice.kind, "move"); + if (advice.kind !== "move") { + continue; + } + // Le conseil, rattaché au journal comme dans l'application. + played.game.recordAdvice(advice); + const position = await positionBefore(engine, played.game.journal, played.eventId); + assert.equal(position.positionId, advice.position); + const prompt = decisionPrompt(templates, phrases, { + journal: played.game.journal, + advice, + position, + }); + assertComplete(prompt, position); + assert.ok(prompt.includes(`Dés : ${dice[0]}-${dice[1]}.`), prompt); + assert.ok(prompt.includes(`J'ai joué ${advice.played.notation} : `), prompt); + assert.ok(prompt.includes(`« ${masterWords(phrases, advice).word} »`), prompt); + // Les trois premiers coups du moteur, et le vôtre, avec les écarts + // de « Voir les chiffres du moteur ». + const shown = shownRanking(advice); + for (const row of rankingRows(advice).filter((r) => r.rank <= 3 || r.played)) { + const line = `${row.rank}. ${row.move.notation}${row.played ? " (mon coup)" : ""} : équité ${formatEquity(row.move.equity)}, écart ${formatEquity(row.loss)}`; + assert.ok(prompt.includes(line), `ligne absente : ${line}\n${prompt}`); + assert.ok(shown.includes(`${row.move.notation}|${formatEquity(row.loss)}`)); + } + assert.ok(prompt.includes(`1. ${advice.best.notation}`), prompt); + assert.ok(prompt.includes("analyse à 2 plis"), prompt); + } +}); + +test("prompt d'une décision de videau : les équités de la feuille du maître", async () => { + const engine = await loadNodeEngine(); + const cases = [ + { play: playerDoubles, who: "moi", question: "doubler ou lancer" }, + { play: playerRollsWithoutDoubling, who: "moi", question: "doubler ou lancer" }, + { play: (e: Engine) => playerAnswers(e, true), who: "l'ordinateur", question: "prendre" }, + { play: (e: Engine) => playerAnswers(e, false), who: "l'ordinateur", question: "prendre" }, + ]; + for (const { play, who, question } of cases) { + const played = await play(engine); + const advice = await adviceOn(engine, played); + assert.equal(advice.kind, "cube"); + if (advice.kind !== "cube") { + continue; + } + played.game.recordAdvice(advice); + const position = await positionBefore(engine, played.game.journal, played.eventId); + assert.equal(position.positionId, advice.position); + const prompt = decisionPrompt(templates, phrases, { + journal: played.game.journal, + advice, + position, + }); + assertComplete(prompt, position); + assert.ok(prompt.includes(`Il fallait choisir : ${question}`), prompt); + assert.ok(prompt.includes(`(${who}), pour un videau de 1`), prompt); + assert.ok(prompt.includes(`J'ai choisi de ${CUBE_ACTION_WORDS[advice.action]},`), prompt); + for (const key of ["noDouble", "doubleTake", "doublePass"] as const) { + const value = formatEquity(advice.equities[key]); + assert.match(prompt, new RegExp(`- ${ADVICE_TEXTS.cubeRows[key]}( \\(ma décision\\))? : ${value.replace("+", "\\+")}\\n`)); + } + assert.ok(prompt.includes(`Écart de ma décision : ${formatEquity(advice.equityLoss)}.`)); + } +}); + +/** Toutes les analyses qui manquent, une à une, comme l'application en + * arrière-plan. */ +async function analyseAll(engine: Engine, journal: GameJournal): Promise { + let current = journal; + for (let i = 0; i < 500; 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"); +} + +/** Une course courte jouée jusqu'au bout et analysée (le joueur prend le + * choice-ième coup légal). */ +async function analysedRace(engine: Engine, seed: number, choice: number): Promise { + const game = Game.start(engine, { + setup: { position: "trsXAADs7mwAAA", firstTurn: "player" }, + level: "intermediate", + dice: seededDice(seed), + }); + await playToTheEnd(game, engine, choice); + return analyseAll(engine, game.journal); +} + +test("prompt du bilan : résultat, décomptes, taux et chance de l'écran, erreurs", async () => { + const engine = await loadNodeEngine(); + const journal = await analysedRace(engine, 3, 3); + const summary = summarizeAnalysis(journal); + assert.ok(summary.toReview !== null, "la partie devrait avoir une erreur"); + const position = await summaryPosition(engine, journal); + const prompt = summaryPrompt(templates, phrases, { journal, position }); + assertComplete(prompt, position); + const { player, computer, counts } = summary; + // Les chiffres de « Voir les chiffres du moteur » de l'analyse, et les + // trois cases du bilan. + assert.ok(prompt.includes(`Mon erreur par décision : ${signed(-player.errorRate.perDecision)} sur ${player.errorRate.decisions} décision`)); + assert.ok(prompt.includes(`soit le niveau ${MATCH_TEXTS.levels[player.errorRate.level].name} ;`)); + assert.ok(prompt.includes(`celle de l'ordinateur : ${signed(-computer.errorRate.perDecision)} sur`)); + assert.ok(prompt.includes(`La chance de mes dés par tour : ${signed(player.luck.perRoll)} (${diceWords(phrases, player.luck.rating)})`)); + assert.ok(prompt.includes(`celle de l'ordinateur : ${signed(computer.luck.perRoll)}.`)); + 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("Résultat : "), prompt); + assert.ok(prompt.includes("contre l'ordinateur (niveau Intermédiaire)"), prompt); + // Le coup à revoir : sa position, et sa ligne avec son écart. + const worst = summary.toReview.advice; + assert.equal(position.positionId, worst.position); + assert.ok(prompt.includes(`Le coup à revoir, ma plus grosse erreur (coup ${summary.toReview.turn})`)); + assert.ok(prompt.includes(`écart ${formatEquity(worst.equityLoss)} (${masterWords(phrases, worst).word})`), prompt); +}); + +test("prompt du bilan sans erreur : la position finale", async () => { + const engine = await loadNodeEngine(); + // Le joueur au trait : un pion sur 6, un sur 1 ; il joue 6/off 1/off et + // gagne (scénario « Revoir ma partie »). + const game = Game.start(engine, { + setup: { position: "BQAACAIAAAAAAA", firstTurn: "player" }, + dice: () => 6, + }); + await game.roll(); + const moves = await engine.legalMoves(game.state.position, game.state.roll ?? [6, 6]); + for (const halfMove of moves[0]?.halfMoves ?? []) { + await game.playHalfMove(halfMove); + } + await game.confirmMove(); + assert.equal(game.state.phase, "over"); + const journal = await analyseAll(engine, game.journal); + const position = await summaryPosition(engine, journal); + const prompt = summaryPrompt(templates, phrases, { journal, position }); + assertComplete(prompt, position); + assert.ok(prompt.includes("- aucune : j'ai joué comme GNU Backgammon."), prompt); + assert.ok(prompt.includes("Voici la position finale"), prompt); + assert.ok(prompt.includes("j'ai gagné une partie simple, 1 point"), prompt); +}); + +test("prompt d'un concept : la position de départ, ou celle de la partie en cours", async () => { + const engine = await loadNodeEngine(); + const alone = await conceptExample(engine, null, 2); + const prompt = conceptPrompt(templates, { + term: "Videau", + english: "Cube", + brief: "le dé des enchères.", + journal: null, + example: alone, + }); + 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.match(prompt, /chances de gagner \d+ %, équité [+−]?\d,\d{3}/); + + const played = await playOpening(engine, [3, 1], "24/23 13/10"); + played.game.recordAdvice(await adviceOn(engine, played)); + const during = await conceptExample(engine, played.game.journal, 2); + assert.ok(during.fromGame); + const second = conceptPrompt(templates, { + term: "Blot", + english: "Blot", + brief: "un pion seul sur une case.", + journal: played.game.journal, + example: during, + }); + 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); +}); + +test("prompt des exercices : les erreurs de plusieurs parties, la plus coûteuse en position", async () => { + const engine = await loadNodeEngine(); + const journals = [await analysedRace(engine, 3, 3), await analysedRace(engine, 7, 2)]; + const position = await exercisesPosition(engine, journals); + assert.ok(position !== null); + const prompt = exercisesPrompt(templates, phrases, { journals, position }); + assertComplete(prompt, position); + assert.ok(prompt.includes("Voici mes 2 dernières parties"), prompt); + assert.match(prompt, /- Partie [12], coup \d+/); + assert.ok(prompt.includes("La plus coûteuse (partie"), prompt); + assert.ok(prompt.includes("cinq exercices"), prompt); + assert.equal(await exercisesPosition(engine, []), null); +}); diff --git a/docs/glossaire/README.md b/docs/glossaire/README.md index ddf8fe0..fb393df 100644 --- a/docs/glossaire/README.md +++ b/docs/glossaire/README.md @@ -67,6 +67,7 @@ exportée. `make test` vérifie le lien dans les deux sens : chaque fiche avec | [Pli](pli.md) | Ply | | [Point](point.md) | Point | | [Prendre](prendre.md) | Take | +| [Prompt](prompt.md) | Prompt | | [Règle de Crawford](regle-de-crawford.md) | Crawford rule | | [Règle de Jacoby](regle-de-jacoby.md) | Jacoby rule | | [SGF](sgf.md) | SGF | diff --git a/docs/glossaire/prompt.md b/docs/glossaire/prompt.md new file mode 100644 index 0000000..5d8754c --- /dev/null +++ b/docs/glossaire/prompt.md @@ -0,0 +1,16 @@ +--- +term: Prompt +english: Prompt +code: Prompt +--- +# Prompt + +**En bref** : la question toute prête que l'application copie pour une IA (« Demander à une IA ») : la position, les chiffres de GNU Backgammon, et ce qu'on demande ; il suffit de la coller dans l'IA de votre choix. + +L'application n'appelle aucune IA et n'envoie rien : elle prépare le texte, +vous le relisez, vous le copiez. Le moteur décide, l'IA explique : le prompt +lui demande de ne pas contredire les évaluations de GNU Backgammon, qui +restent celles que l'application affiche. Il contient aussi le lien de la +position, pour la retrouver dans l'application. + +Voir aussi : [Lien de position](lien-de-position.md), [Moteur](moteur.md), [Conseil du maître](conseil-du-maitre.md) diff --git a/docs/prompts/README.md b/docs/prompts/README.md new file mode 100644 index 0000000..17bd4f0 --- /dev/null +++ b/docs/prompts/README.md @@ -0,0 +1,58 @@ +# Les prompts à copier pour une IA + +Les textes que l'application copie dans le presse-papiers quand on touche +« Demander à une IA » (étape 1f ; `docs/cadrage.md`, « IA générative en +V1 »). L'application n'appelle aucune IA : elle prépare une question +complète, que le joueur colle dans l'IA de son choix. Règle d'or : **le +moteur décide, l'IA explique**. + +Une seule source (ADR 0008) : `make site` copie ce dossier dans le site +publié (`docs/prompts/`), l'application lit les gabarits à l'exécution +(`app/src/domain/prompts/`), et `make test` remplit chacun d'eux avec de +vraies analyses du moteur (`app/tests/prompts.test.ts`) : aucun repère +laissé vide, et les chiffres du prompt sont ceux que l'application affiche. + +Textes sous licence **CC BY-SA 4.0**. Version 1, rédigée par Claude Code +d'après le cadrage, **à relire** par Pierre. + +## Les gabarits + +| Fichier | Bouton | Où | +| --- | --- | --- | +| [coup.md](coup.md) | Pourquoi ce coup est meilleur | Feuille du maître, bilan, analyse | +| [videau.md](videau.md) | Expliquer cette décision de videau | Feuille du maître, bilan, analyse | +| [bilan.md](bilan.md) | Bilan de ma partie | Bilan de fin de partie | +| [concept.md](concept.md) | Expliquer ce concept | Fiche d'un terme du glossaire | +| [exercices.md](exercices.md) | Créer des exercices | Progression (étape 1h, pas encore de bouton) | + +Chaque prompt contient : + +- le rôle de l'IA (un coach) et 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 + moteur ; +- les chiffres du moteur, avec le niveau d'évaluation (en plis) ; +- la consigne de ne pas recalculer ni contredire ces chiffres ; +- la langue de la réponse. + +## Format + +Dans chaque fichier, le gabarit est le bloc de texte qui suit le titre +`## Gabarit`. Le reste du fichier n'est pas lu. Les mots entre accolades sont +remplis par l'application ; un repère inconnu ou une valeur vide est une +erreur, vérifiée par les tests. + +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é » | +| `{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 | +| `{diagramme}` | le plateau en texte : vos pions X, ceux de l'ordinateur O, dans votre numérotation | +| `{score}` | « partie libre » ou le score du match | +| `{videau}` | sa valeur et qui le possède | +| `{niveau_eval}` | le niveau d'évaluation de GNU Backgammon, « analyse à 2 plis » | +| `{langue}` | la langue de la réponse : « français » | diff --git a/docs/prompts/bilan.md b/docs/prompts/bilan.md new file mode 100644 index 0000000..f1fab0e --- /dev/null +++ b/docs/prompts/bilan.md @@ -0,0 +1,44 @@ +# Bilan de ma partie + +Bouton « Demander à une IA » de la carte « Le coup à revoir » du bilan de +fin de partie. Texte sous licence CC BY-SA 4.0 (voir +[README.md](README.md)). + +Repères propres à ce gabarit : + +| Repère | Ce qu'il devient | +| --- | --- | +| `{ordinateur}` | le niveau de l'ordinateur, « Intermédiaire » | +| `{resultat}` | le résultat de la partie, et le score du match s'il y en a un | +| `{decomptes}` | vos décisions par appréciation, comme les trois cases du bilan | +| `{taux}`, `{decisions}`, `{niveau_jeu}` | votre erreur par décision, le nombre de décisions comptées et le niveau correspondant, comme « Voir les chiffres du moteur » de l'analyse | +| `{taux_ordinateur}` | la même mesure pour l'ordinateur | +| `{chance}`, `{chance_mots}`, `{chance_ordinateur}` | la chance par tour de vos dés, en chiffres et en mots, et celle de l'ordinateur | +| `{erreurs}` | vos imprécisions et erreurs, coup par coup, avec leur coût | +| `{position_titre}` | ce que montre la position : le coup à revoir, ou la position finale s'il n'y en a pas | + +## Gabarit + +```text +Tu es un coach de backgammon, bienveillant et précis. Je suis un joueur de niveau {niveau}. + +Je viens de finir une partie contre l'ordinateur (niveau {ordinateur}), analysée par GNU Backgammon ({niveau_eval}). +Résultat : {resultat}. +Mes décisions : {decomptes}. +Mon erreur par décision : {taux} sur {decisions}, soit le niveau {niveau_jeu} ; celle de l'ordinateur : {taux_ordinateur}. +La chance de mes dés par tour : {chance} ({chance_mots}) ; celle de l'ordinateur : {chance_ordinateur}. + +Mes imprécisions et erreurs, avec leur coût en équité : +{erreurs} + +{position_titre} (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} + +{diagramme} + +Score : {score}. Videau : {videau}. + +Ces évaluations font foi : ne les recalcule pas et ne les contredis pas. +À partir de ce bilan, identifie trois thèmes que je devrais travailler en priorité, avec pour chacun un conseil concret. +Réponds en {langue}, simplement, en moins de 300 mots. +``` diff --git a/docs/prompts/concept.md b/docs/prompts/concept.md new file mode 100644 index 0000000..ec21a14 --- /dev/null +++ b/docs/prompts/concept.md @@ -0,0 +1,36 @@ +# Expliquer ce concept + +Bouton « Demander à une IA » sur la fiche d'un terme du glossaire (« Les +mots du jeu »). La position d'exemple est celle de la partie en cours, ou la +position de départ s'il n'y en a pas. Texte sous licence CC BY-SA 4.0 (voir +[README.md](README.md)). + +Repères propres à ce gabarit : + +| Repère | Ce qu'il devient | +| --- | --- | +| `{concept}` | le terme, et son nom anglais s'il en a un | +| `{definition}` | la phrase « En bref » de sa fiche | +| `{position_titre}` | « une position de ma partie en cours » ou « la position de départ » | +| `{evaluation}` | les chances de gagner et l'équité du joueur au trait, avant de lancer les dés | + +## Gabarit + +```text +Tu es un coach de backgammon, bienveillant et précis. Je suis un joueur de niveau {niveau}. + +Explique-moi le concept {concept}. +Mon application le définit ainsi : {definition} + +Pour l'illustrer, voici {position_titre} (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} + +{diagramme} + +Score : {score}. Videau : {videau}. +Évaluation de GNU Backgammon ({niveau_eval}), pour le joueur qui a la main ({au_trait}), avant de lancer les dés : {evaluation}. + +Ces évaluations font foi : ne les recalcule pas et ne les contredis pas. +Explique ce concept simplement, avec une analogie de la vie courante, puis dis s'il joue un rôle dans cette position et comment. +Réponds en {langue}, en moins de 250 mots. +``` diff --git a/docs/prompts/coup.md b/docs/prompts/coup.md new file mode 100644 index 0000000..5f6695e --- /dev/null +++ b/docs/prompts/coup.md @@ -0,0 +1,36 @@ +# Pourquoi ce coup est meilleur + +Bouton « Demander à une IA » sur un coup du joueur : feuille d'explication +du maître, carte « Le coup à revoir » du bilan, visualiseur de l'analyse. +Texte sous licence CC BY-SA 4.0 (voir [README.md](README.md)). + +Repères propres à ce gabarit : + +| Repère | Ce qu'il devient | +| --- | --- | +| `{des}` | le jet joué, « 3-1 » | +| `{coup_joue}` | votre coup, dans la notation de GNU Backgammon | +| `{appreciation}` | le mot du maître : « Bon coup », « Imprécision », « Erreur » ou « Grosse erreur » | +| `{rang}` | « le meilleur coup » ou « le 4e choix sur 15 coups possibles » | +| `{classement}` | le classement du moteur : les trois meilleurs coups, et le vôtre s'il n'en est pas, avec leur équité et leur écart au meilleur, comme dans « Voir les chiffres du moteur » | + +## Gabarit + +```text +Tu es un coach de backgammon, bienveillant et précis. Je suis un joueur de niveau {niveau}. + +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} + +{diagramme} + +Score : {score}. Videau : {videau}. Dés : {des}. + +J'ai joué {coup_joue} : {rang}, jugé « {appreciation} ». +Classement de GNU Backgammon ({niveau_eval}), équités du point de vue du joueur qui joue : +{classement} + +Ces évaluations font foi : ne les recalcule pas et ne les contredis pas. +Explique-moi pourquoi le meilleur coup est supérieur au mien : quels concepts sont en jeu, et ce que je dois retenir pour la prochaine fois. +Réponds en {langue}, simplement, en moins de 200 mots. +``` diff --git a/docs/prompts/exercices.md b/docs/prompts/exercices.md new file mode 100644 index 0000000..3fbd899 --- /dev/null +++ b/docs/prompts/exercices.md @@ -0,0 +1,38 @@ +# Créer des exercices + +Prévu pour l'écran de progression (étape 1h) : le gabarit est écrit et +testé, sans bouton pour l'instant. Il rassemble les erreurs de vos +dernières parties analysées. Texte sous licence CC BY-SA 4.0 (voir +[README.md](README.md)). + +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}. + +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} + +{position_titre} (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} + +{diagramme} + +Score : {score}. Videau : {videau}. + +Ces évaluations font foi : ne les recalcule pas et ne les contredis pas. +Repère les thèmes qui reviennent dans ces erreurs, puis propose-moi cinq exercices ciblés : pour chacun, le thème, une position type décrite simplement, et ce que je dois y chercher. +Réponds en {langue}, en moins de 350 mots. +``` diff --git a/docs/prompts/videau.md b/docs/prompts/videau.md new file mode 100644 index 0000000..56bcfa5 --- /dev/null +++ b/docs/prompts/videau.md @@ -0,0 +1,40 @@ +# Expliquer cette décision de videau + +Bouton « Demander à une IA » sur une décision de videau du joueur (doubler, +lancer sans doubler, prendre, passer) : feuille d'explication du maître, +carte « Le coup à revoir » du bilan, visualiseur de l'analyse. Texte sous +licence CC BY-SA 4.0 (voir [README.md](README.md)). + +Repères propres à ce gabarit : + +| Repère | Ce qu'il devient | +| --- | --- | +| `{decision}` | la question posée : « doubler ou lancer les dés sans doubler » ou « prendre ou passer le double de l'ordinateur » | +| `{choix}` | votre décision, « passer » | +| `{choix_moteur}` | la décision que préfère GNU Backgammon | +| `{appreciation}` | le mot du maître : « Bonne décision », « Imprécision », « Erreur » ou « Grosse erreur » | +| `{qui_double}` | « moi » ou « l'ordinateur » : celui qui double ou non | +| `{equites}` | les équités sans double, double et prise, double et refus, comme dans « Voir les chiffres du moteur » | +| `{ecart}` | l'équité perdue par votre décision | + +## Gabarit + +```text +Tu es un coach de backgammon, bienveillant et précis. Je suis un joueur de niveau {niveau}. + +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} + +{diagramme} + +Score : {score}. Videau : {videau}. + +Il fallait choisir : {decision}. J'ai choisi de {choix}, jugé « {appreciation} » ; GNU Backgammon aurait choisi de {choix_moteur}. +Équités calculées par GNU Backgammon ({niveau_eval}), du point de vue de celui qui double ou non ({qui_double}), pour un videau de 1 : +{equites} +Écart de ma décision : {ecart}. + +Ces évaluations font foi : ne les recalcule pas et ne les contredis pas. +Explique-moi cette décision : pourquoi doubler ou non, prendre ou passer, quels repères de la position comptent, et ce que je dois retenir. +Réponds en {langue}, simplement, en moins de 200 mots. +```