feat(twentyq): LLM-generated category + initial hint from bare keyword seeds

Previously seeds carried hand-curated {category, target, initialHint}.
Now SEEDS is a flat string[] of keywords — at round-start, the model
generates {category, initialHint} on the fly. Benefits:
- adding a seed is trivial (just append a word)
- every round gets a fresh cryptic opener (varies across plays of the
  same word)
- HINT STYLE rules apply to the opening hint too, so the initial clue
  isn't a definitional giveaway

Implementation:
- prompts.buildStartRoundPrompt(target) — with good/bad examples
- ai-client.generateRoundStart(env, target) — same JSON-in-content
  approach as judge(), with defensive fallbacks + redactSecret
- handlers.startFreshGame now async; surfaces roundstart errors via the
  existing UPSTREAM_FAIL path

Tests: 449 pass (5 new for generateRoundStart, 1 for roundstart error path).
This commit is contained in:
tiennm99 committed 2026-04-24 16:26:55 +07:00
1 parent 1d41d27b2b
commit 1412166ad7
9 files changed
+266 -183

No files matched your search

+10 -9
View File
@@ -45,17 +45,18 @@ Hint: it uses wind through pipes to create sound.
## Data source
- **Secret pool:** `seeds.js` — ~60 hand-curated objects across 6 categories
(instrument, animal, food, vehicle, sport, household). Each entry ships with
a non-revealing initial hint.
- **Yes/no judging + hint generation:** Workers AI binding `env.AI` calling
`@cf/google/gemma-4-26b-a4b-it` with traditional function calling. The model
receives the secret + recent history each turn and emits a single
`submit_answer` tool call.
- **Secret pool:** `seeds.js` — flat array of keyword strings (~50+ nouns).
No hand-curated categories or hints — drop in any noun, the LLM figures out
the right category and produces a cryptic opening clue at round-start.
- **Round-start + per-turn responses:** Workers AI binding `env.AI` calling
`@cf/google/gemma-4-26b-a4b-it`. Prompts instruct the model to emit one-line
JSON that `parseJudgementJson` extracts even if wrapped in prose/fences.
- Round start: `generateRoundStart(env, target)` → `{category, initialHint}`
- Each turn: `judge(env, state, userInput)` → `{is_guess, answer, hint}`
The Gemma 4 26B A4B model runs on the Workers Free plan (10k Neurons/day).
Pricing: $0.10/M input + $0.30/M output. Each turn is ~250 input + ~50 output
tokens, well under the cap for normal play volume.
Pricing: $0.10/M input + $0.30/M output. Each round is one start call (~1
AI request) + one request per turn, well under the cap for normal play volume.
## Architecture
+59 -1
View File
@@ -11,7 +11,7 @@
* Rich logging on failure paths so wrangler tail surfaces the actual cause.
*/
import { buildSystemPrompt } from "./prompts.js";
import { buildStartRoundPrompt, buildSystemPrompt } from "./prompts.js";
export const MODEL_ID = "@cf/google/gemma-4-26b-a4b-it";
@@ -167,6 +167,7 @@ export async function judge(env, state, userInput) {
JSON.stringify({
msg: "twentyq_ai_unparseable",
model: MODEL_ID,
stage: "judge",
preview: text.slice(0, 200),
}),
);
@@ -175,3 +176,60 @@ export async function judge(env, state, userInput) {
judgement.hint = redactSecret(judgement.hint, state.target);
return judgement;
}
/**
* Generate { category, initialHint } for a fresh round's target keyword.
* Uses the same JSON-in-content approach as judge(). On parse failure,
* returns a safe generic fallback so the round can still start.
*
* @param {{ AI: { run: (model: string, body: object) => Promise<any> } }} env
* @param {string} target
* @returns {Promise<{ category: string, initialHint: string }>}
*/
export async function generateRoundStart(env, target) {
if (!env?.AI?.run) {
throw new UpstreamError("Workers AI binding not available");
}
const messages = [
{ role: "system", content: buildStartRoundPrompt(target) },
{ role: "user", content: "begin" },
];
let response;
try {
response = await env.AI.run(MODEL_ID, { messages });
} catch (err) {
console.log(
JSON.stringify({
msg: "twentyq_ai_throw",
model: MODEL_ID,
stage: "roundstart",
err: err instanceof Error ? { name: err.name, message: err.message } : String(err),
}),
);
throw new UpstreamError("env.AI.run threw (roundstart)", { cause: err });
}
const text = extractText(response);
const payload = parseJudgementJson(text);
if (!payload || typeof payload.category !== "string" || typeof payload.initialHint !== "string") {
console.log(
JSON.stringify({
msg: "twentyq_ai_unparseable",
model: MODEL_ID,
stage: "roundstart",
preview: text.slice(0, 200),
}),
);
return {
category: "object",
initialHint: "it is something you might encounter in everyday life",
};
}
const category = payload.category.trim() || "object";
const rawHint =
payload.initialHint.trim() || "it is something you might encounter in everyday life";
return {
category,
initialHint: redactSecret(rawHint, target),
};
}
+13 -7
View File
@@ -12,7 +12,7 @@
* /twentyq_stats → show per-subject stats
*/
import { UpstreamError, judge } from "./ai-client.js";
import { UpstreamError, generateRoundStart, judge } from "./ai-client.js";
import { formatBoard, formatGiveup, formatIntro, formatStats, formatTurnReply } from "./render.js";
import { getRandomSeed } from "./seeds.js";
import { clearGame, loadGame, loadStats, recordResult, saveGame } from "./state.js";
@@ -33,12 +33,13 @@ function argAfterCommand(text) {
return idx === -1 ? "" : text.slice(idx + 1).trim();
}
function startFreshGame() {
const seed = getRandomSeed();
async function startFreshGame(env) {
const target = getRandomSeed();
const { category, initialHint } = await generateRoundStart(env, target);
return {
category: seed.category,
target: seed.target,
initialHint: seed.initialHint,
category,
target,
initialHint,
startedAt: Date.now(),
solved: false,
turns: [],
@@ -71,7 +72,12 @@ export async function handleTwentyq(ctx, { db, env }) {
}
if (!game) {
game = startFreshGame();
try {
game = await startFreshGame(env);
} catch (err) {
logFail("roundstart", err);
return ctx.reply(UPSTREAM_FAIL);
}
await saveGame(db, subject, game);
if (!arg) return ctx.reply(formatIntro(game), { parse_mode: "HTML" });
// Fresh round + immediate question — show intro then process turn.
+40
View File
@@ -66,3 +66,43 @@ GOOD: {"is_guess": false, "answer": "no", "hint": "in its natural habitat you mi
BAD (too direct): {"is_guess": false, "answer": "yes", "hint": "it uses pipes to produce sound"}
BAD (names category synonym): {"is_guess": false, "answer": "yes", "hint": "it is a keyboard instrument"}`;
}
/**
* Build the round-start prompt. Given just a target keyword, we ask the
* model to emit { category, initialHint } so seeds don't need hand-curated
* metadata — drop in any noun, the model fills in the rest.
*
* @param {string} target
* @returns {string}
*/
export function buildStartRoundPrompt(target) {
return `You are opening a new round of the "20 questions" reverse-Akinator game.
Secret object: "${target}"
Your job: emit ONE line of JSON with these two fields:
{"category": "<short broad category>", "initialHint": "<cryptic opening clue>"}
Category rules:
- A SHORT, COMMON category word a player can start narrowing from (e.g. "instrument", "animal", "food", "vehicle", "sport", "household item", "tool", "clothing", "plant"). Prefer single words.
- Broad enough that many objects fall under it — NOT a narrow sub-category (don't say "brass instrument", say "instrument").
- Do not include the secret word in the category.
Initial hint rules (same HINT STYLE as the main game):
- Max 120 characters. TRUE about the secret. Indirect, oblique, riddle-like.
- Never include the secret word, its plural, its base form, or an obvious category synonym.
- Hint at ONE lateral property — a cultural association, a habitat, a use context, a historical fact.
- Player should think "ok that narrows it a bit" NOT "oh that's obviously X".
Examples (secret → output):
secret="organ" → {"category": "instrument", "initialHint": "it was once the largest mechanical object humans built"}
secret="elephant" → {"category": "animal", "initialHint": "the ancient Greeks believed it was terrified of mice"}
secret="pizza" → {"category": "food", "initialHint": "a popular version bears the colors of a national flag"}
secret="submarine" → {"category": "vehicle", "initialHint": "the first successful one was made of wood and powered by hand"}
BAD examples (do NOT emit these):
secret="organ" → {"category": "keyboard instrument", "initialHint": "it has pipes"} // category too specific + hint too direct
secret="elephant" → {"category": "animal", "initialHint": "it has a long trunk"} // hint too direct
Output ONLY the JSON line. No fences, no prose.`;
}
+67 -145
View File
@@ -1,157 +1,79 @@
/**
* @file Seed list of secret objects for the twentyq game.
* @file Seed keywords for the twentyq game.
*
* Each seed is a hand-curated `{ category, target, initialHint }` triple.
* The initial hint must NOT contain the target word or a close cognate —
* it should narrow the field without giving the answer.
*
* Add new entries freely; tests assert hint sanity (target not in hint).
* Just the target word. Category + initial hint are generated by the LLM at
* round start (see ai-client.generateRoundStart). Add any noun here — the
* model figures out the right category and produces a cryptic opening clue.
*/
/**
* @typedef {object} Seed
* @property {string} category
* @property {string} target — lowercased
* @property {string} initialHint — short clue, no target substring
*/
/** @type {Seed[]} */
/** @type {string[]} */
export const SEEDS = [
// instrument (8)
{ category: "instrument", target: "guitar", initialHint: "it has strings you pluck or strum" },
{ category: "instrument", target: "piano", initialHint: "it has black and white keys" },
{ category: "instrument", target: "drum", initialHint: "you hit it to make sound" },
{ category: "instrument", target: "violin", initialHint: "you draw a bow across its strings" },
{ category: "instrument", target: "flute", initialHint: "it uses wind to create sound" },
{
category: "instrument",
target: "trumpet",
initialHint: "brass family — buzz your lips into it",
},
{
category: "instrument",
target: "organ",
initialHint: "it uses wind through pipes to create sound",
},
{
category: "instrument",
target: "harmonica",
initialHint: "small enough to fit in one hand, played with the mouth",
},
// animal (10)
{ category: "animal", target: "elephant", initialHint: "the largest land mammal" },
{ category: "animal", target: "dolphin", initialHint: "a highly intelligent marine creature" },
{ category: "animal", target: "eagle", initialHint: "a bird of prey known for sharp eyesight" },
{
category: "animal",
target: "kangaroo",
initialHint: "this animal carries its young in a pouch",
},
{ category: "animal", target: "octopus", initialHint: "it has eight limbs and lives in the sea" },
{
category: "animal",
target: "penguin",
initialHint: "a flightless bird that swims in cold water",
},
{ category: "animal", target: "tiger", initialHint: "a large striped predator from Asia" },
{
category: "animal",
target: "horse",
initialHint: "domesticated for riding for thousands of years",
},
{ category: "animal", target: "snake", initialHint: "a legless reptile" },
{
category: "animal",
target: "owl",
initialHint: "mostly nocturnal, can rotate its head far around",
},
// food (10)
{ category: "food", target: "pizza", initialHint: "originated in Italy, often shared in slices" },
{
category: "food",
target: "sushi",
initialHint: "a Japanese dish often featuring rice and seafood",
},
{ category: "food", target: "burger", initialHint: "a sandwich with a patty in the middle" },
{ category: "food", target: "ramen", initialHint: "noodles served in a hot broth" },
{ category: "food", target: "taco", initialHint: "a folded shell holding savory fillings" },
{ category: "food", target: "pho", initialHint: "a Vietnamese noodle soup" },
{
category: "food",
target: "curry",
initialHint: "a heavily spiced sauce dish, popular in South Asia",
},
{ category: "food", target: "salad", initialHint: "usually cold, often leafy and raw" },
{ category: "food", target: "chocolate", initialHint: "made from cocoa, often eaten as a treat" },
{ category: "food", target: "cheese", initialHint: "a dairy product that can be soft or hard" },
// vehicle (8)
{ category: "vehicle", target: "bicycle", initialHint: "two wheels, powered by you" },
{ category: "vehicle", target: "car", initialHint: "the most common personal road vehicle" },
{
category: "vehicle",
target: "airplane",
initialHint: "it flies long distances carrying many people",
},
{ category: "vehicle", target: "boat", initialHint: "it travels on water" },
{ category: "vehicle", target: "train", initialHint: "it runs on fixed metal rails" },
{
category: "vehicle",
target: "motorcycle",
initialHint: "two wheels, but powered by an engine",
},
{ category: "vehicle", target: "helicopter", initialHint: "it flies but can hover in place" },
{ category: "vehicle", target: "submarine", initialHint: "it travels underwater" },
// sport (8)
{ category: "sport", target: "soccer", initialHint: "the world's most popular ball sport" },
{
category: "sport",
target: "basketball",
initialHint: "you score by putting a ball through a hoop",
},
{ category: "sport", target: "tennis", initialHint: "two or four players, a net, and rackets" },
{ category: "sport", target: "swimming", initialHint: "competed in water" },
{
category: "sport",
target: "boxing",
initialHint: "two opponents wear gloves and stand in a ring",
},
{ category: "sport", target: "golf", initialHint: "played on grass with clubs and a small ball" },
{ category: "sport", target: "chess", initialHint: "a turn-based game on a 64-square board" },
{ category: "sport", target: "skiing", initialHint: "performed on snow" },
// household (10)
{
category: "household",
target: "refrigerator",
initialHint: "it keeps things cold in the kitchen",
},
{ category: "household", target: "microwave", initialHint: "it heats food using radiation" },
{ category: "household", target: "vacuum", initialHint: "it cleans floors using suction" },
{
category: "household",
target: "toaster",
initialHint: "small kitchen appliance for crisping bread",
},
{ category: "household", target: "kettle", initialHint: "it's used to boil water" },
{
category: "household",
target: "blender",
initialHint: "it has fast-spinning blades for liquids",
},
{ category: "household", target: "lamp", initialHint: "provides light in a room" },
{ category: "household", target: "sofa", initialHint: "you sit on it, often in the living room" },
{ category: "household", target: "mirror", initialHint: "it reflects your image" },
{ category: "household", target: "broom", initialHint: "long handle, used for sweeping" },
// instruments
"guitar",
"piano",
"drum",
"violin",
"flute",
"trumpet",
"organ",
"harmonica",
// animals
"elephant",
"dolphin",
"eagle",
"kangaroo",
"octopus",
"penguin",
"tiger",
"horse",
"snake",
"owl",
// food
"pizza",
"sushi",
"burger",
"ramen",
"taco",
"pho",
"curry",
"salad",
"chocolate",
"cheese",
// vehicles
"bicycle",
"car",
"airplane",
"boat",
"train",
"motorcycle",
"helicopter",
"submarine",
// sports
"soccer",
"basketball",
"tennis",
"swimming",
"boxing",
"golf",
"chess",
"skiing",
// household items
"refrigerator",
"microwave",
"vacuum",
"toaster",
"kettle",
"blender",
"lamp",
"sofa",
"mirror",
"broom",
];
/**
* Pick one seed at random.
* Pick one target at random.
* @param {() => number} [rng] — defaults to Math.random; tests inject deterministic rng.
* @returns {Seed}
* @returns {string}
*/
export function getRandomSeed(rng = Math.random) {
const i = Math.floor(rng() * SEEDS.length);
+10
View File
@@ -23,6 +23,16 @@ export function mockJudgement(ai, { is_guess = false, answer = "no", hint = "def
});
}
/**
* Configure the next ai.run call to return a round-start response
* with { category, initialHint } JSON.
*/
export function mockRoundStart(ai, { category = "object", initialHint = "cryptic clue" } = {}) {
ai.run.mockResolvedValueOnce({
response: JSON.stringify({ category, initialHint }),
});
}
/** Configure the next call to throw (simulate Workers AI outage). */
export function mockFailure(ai, err = new Error("AI down")) {
ai.run.mockRejectedValueOnce(err);
+46 -1
View File
@@ -3,12 +3,13 @@ import {
MODEL_ID,
UpstreamError,
extractText,
generateRoundStart,
judge,
normalizeJudgement,
parseJudgementJson,
redactSecret,
} from "../../../src/modules/twentyq/ai-client.js";
import { makeFakeAi, mockFailure, mockJudgement } from "../../fakes/fake-ai.js";
import { makeFakeAi, mockFailure, mockJudgement, mockRoundStart } from "../../fakes/fake-ai.js";
const baseState = () => ({
category: "instrument",
@@ -168,4 +169,48 @@ describe("twentyq/ai-client", () => {
expect(body.messages).toBeDefined();
});
});
describe("generateRoundStart", () => {
it("returns parsed { category, initialHint } on happy path", async () => {
const ai = makeFakeAi();
mockRoundStart(ai, { category: "instrument", initialHint: "cryptic clue about organs" });
const r = await generateRoundStart({ AI: ai }, "organ");
expect(r.category).toBe("instrument");
expect(r.initialHint).toBe("cryptic clue about organs");
});
it("redacts the target word from the generated hint", async () => {
const ai = makeFakeAi();
mockRoundStart(ai, {
category: "instrument",
initialHint: "an organ is what you should think of",
});
const r = await generateRoundStart({ AI: ai }, "organ");
expect(r.initialHint).not.toContain("organ");
});
it("falls back to safe defaults when model output is unparseable", async () => {
const ai = makeFakeAi();
ai.run.mockResolvedValueOnce({ response: "no json here" });
const r = await generateRoundStart({ AI: ai }, "organ");
expect(r.category).toBeTruthy();
expect(r.initialHint).toBeTruthy();
});
it("falls back when model returns partial payload", async () => {
const ai = makeFakeAi();
ai.run.mockResolvedValueOnce({
response: JSON.stringify({ category: "instrument" }),
});
const r = await generateRoundStart({ AI: ai }, "organ");
expect(r.category).toBeTruthy();
expect(r.initialHint).toBeTruthy();
});
it("wraps AI exception in UpstreamError", async () => {
const ai = makeFakeAi();
mockFailure(ai, new Error("boom"));
await expect(generateRoundStart({ AI: ai }, "organ")).rejects.toBeInstanceOf(UpstreamError);
});
});
});
+15 -1
View File
@@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { createStore } from "../../../src/db/create-store.js";
import { handleGiveup, handleStats, handleTwentyq } from "../../../src/modules/twentyq/handlers.js";
import { loadGame, loadStats, saveGame } from "../../../src/modules/twentyq/state.js";
import { makeFakeAi, mockFailure, mockJudgement } from "../../fakes/fake-ai.js";
import { makeFakeAi, mockFailure, mockJudgement, mockRoundStart } from "../../fakes/fake-ai.js";
import { makeFakeKv } from "../../fakes/fake-kv-namespace.js";
function makeCtx(userId = 1, chatType = "private", msgText = "/twentyq") {
@@ -43,6 +43,7 @@ describe("twentyq/handlers", () => {
describe("handleTwentyq", () => {
it("starts a fresh round and shows intro when no game + no arg", async () => {
mockRoundStart(ai, { category: "instrument", initialHint: "cryptic opener" });
const ctx = makeCtx(1, "private", "/twentyq");
await handleTwentyq(ctx, { db, env });
expect(ctx.reply).toHaveBeenCalledOnce();
@@ -50,6 +51,8 @@ describe("twentyq/handlers", () => {
const game = await loadGame(db, 1);
expect(game).not.toBeNull();
expect(game.turns).toEqual([]);
expect(game.category).toBe("instrument");
expect(game.initialHint).toBe("cryptic opener");
});
it("shows board when game exists and no arg", async () => {
@@ -123,12 +126,14 @@ describe("twentyq/handlers", () => {
it("starts a fresh round transparently after a solved game", async () => {
await saveGame(db, 1, sampleGame({ solved: true }));
mockRoundStart(ai);
const ctx = makeCtx(1, "private", "/twentyq");
await handleTwentyq(ctx, { db, env });
expect(ctx.replies[0].text).toMatch(/I'm thinking/);
});
it("starts round and processes turn when /twentyq <text> with no prior game", async () => {
mockRoundStart(ai);
mockJudgement(ai, { is_guess: false, answer: "yes", hint: "yes hint" });
const ctx = makeCtx(1, "private", "/twentyq is it big?");
await handleTwentyq(ctx, { db, env });
@@ -137,6 +142,14 @@ describe("twentyq/handlers", () => {
expect(ctx.replies[1].text).toMatch(/Yes/);
});
it("surfaces roundstart UpstreamError as friendly message", async () => {
mockFailure(ai, new Error("roundstart down"));
const ctx = makeCtx(1, "private", "/twentyq");
await handleTwentyq(ctx, { db, env });
expect(ctx.replies[0].text).toMatch(/hiccup|try again/i);
expect(await loadGame(db, 1)).toBeNull();
});
it("surfaces UpstreamError as friendly message", async () => {
await saveGame(db, 1, sampleGame());
mockFailure(ai, new Error("boom"));
@@ -146,6 +159,7 @@ describe("twentyq/handlers", () => {
});
it("uses chat id as subject in groups", async () => {
mockRoundStart(ai);
mockJudgement(ai, { is_guess: false, answer: "no", hint: "h" });
const ctx = makeCtx(99, "group", "/twentyq is it big?");
ctx.chat.id = 12345;
+6 -19
View File
@@ -2,29 +2,16 @@ import { describe, expect, it } from "vitest";
import { SEEDS, getRandomSeed } from "../../../src/modules/twentyq/seeds.js";
describe("twentyq/seeds", () => {
it("every seed has non-empty category, target, initialHint", () => {
it("every seed is a non-empty lowercase string", () => {
for (const s of SEEDS) {
expect(s.category).toBeTruthy();
expect(s.target).toBeTruthy();
expect(s.initialHint).toBeTruthy();
expect(typeof s.category).toBe("string");
expect(typeof s.target).toBe("string");
expect(typeof s.initialHint).toBe("string");
expect(typeof s).toBe("string");
expect(s.length).toBeGreaterThan(0);
expect(s).toBe(s.toLowerCase());
}
});
it("all targets are lowercase", () => {
for (const s of SEEDS) {
expect(s.target).toBe(s.target.toLowerCase());
}
});
it("initialHint never contains the target word", () => {
for (const s of SEEDS) {
const hintLower = s.initialHint.toLowerCase();
const re = new RegExp(`\\b${s.target}\\b`, "i");
expect(re.test(hintLower)).toBe(false);
}
it("no duplicate seeds", () => {
expect(new Set(SEEDS).size).toBe(SEEDS.length);
});
it("getRandomSeed returns a member of SEEDS", () => {