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:
co-authored by
Claude Opus 5.5
parent
f3f977bf66
commit
4f0611c24c
@@ -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;
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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`,
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user