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