Prompts : gabarits dans docs/prompts/ et remplissage par le domaine

Cinq gabarits sous CC BY-SA 4.0 (coup, videau, bilan, concept,
exercices), remplis par des fonctions pures de domain/prompts/ avec la
position lue par le moteur (identifiants, lien public, diagramme) et ses
chiffres, écrits comme à l'écran (domain/analysis/numbers.ts, partagé avec
la feuille du maître, le bilan et l'analyse). Testés sur de vraies
analyses : aucun repère vide ni inconnu, mêmes chiffres que l'écran.
Fiche du glossaire « Prompt ».

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Pierre MARTIN
2026-11-10 20:40:07 +01:00
co-authored by Claude Opus 5.5
parent f3f977bf66
commit 4f0611c24c
16 changed files with 1456 additions and 27 deletions
+58
View File
@@ -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;
}
+195
View File
@@ -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<PromptPosition> {
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<PromptPosition> {
return positionOf(engine, replay(journal === null ? [] : currentLine(journal)));
}
async function positionOf(engine: Engine, state: GameState): Promise<PromptPosition> {
// 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<Evaluation> {
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/<Position ID>/<Match ID>,
* 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<PromptPosition, "match">): 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<PromptPosition, "cube" | "match">): 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}`;
}
+467
View File
@@ -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<Record<ComputerLevel, string>> = {
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<Record<CubeAction, string>> = {
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<string, string> {
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<string, string> {
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<string, string> {
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<PromptPosition> {
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<ConceptExample> {
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<PromptPosition | null> {
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`,
});
}
+73
View File
@@ -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<Record<PromptKind, string>> = {
move: "coup.md",
cube: "videau.md",
summary: "bilan.md",
concept: "concept.md",
exercises: "exercices.md",
};
/** Les gabarits lus, par sorte. */
export type PromptTemplates = Readonly<Record<PromptKind, string>>;
/** 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<Record<string, string>>): 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;
});
}
+3 -5
View File
@@ -48,8 +48,6 @@ export const CUBE_GLOSSARY: Readonly<Record<CubeAction, { file: string; term: st
pass: { file: "passer", term: "Passer" },
};
/** 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;
}
/** 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";
+3 -12
View File
@@ -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),
+6 -10
View File
@@ -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