feat(doantu): add /doantu_hint — 3 warm-but-not-hot related words

Uses phow2sim /neighbors. Filters out capitalized foreign place names
that leak in from the corpus (e.g. al-Qantara, Nam_Afrin) and requires
tokens look Vietnamese (diacritic or underscore compound) to dodge
pure-ASCII junk like "adiyeh". Samples 3 from the tail after skipping
the top 20% so the hint doesn't give away the answer.
This commit is contained in:
2026-04-23 13:23:53 +07:00
parent 58deb531e8
commit a9e11de92a
4 changed files with 92 additions and 1 deletions
+5
View File
@@ -13,6 +13,7 @@ native `/` autocomplete menu.
| Command | Visibility | Description |
|---------|-----------|-------------|
| `/doantu` | public | Show current board or submit a word guess |
| `/doantu_hint` | public | Reveal 3 related words (not the answer) as a nudge |
| `/doantu_giveup` | public | Reveal the answer and end the round (next `/doantu` starts a fresh one) |
| `/doantu_stats` | public | Show per-subject stats |
@@ -32,6 +33,10 @@ instance (default: `https://phow2sim.sg.miti99.com`). Wraps two endpoints:
so rounds stay guessable for casual players.
- `GET /similarity?a=…&b=…` — cosine similarity + canonical forms +
`in_vocab_a` / `in_vocab_b` flags.
- `GET /neighbors?word=…&topn=100` — top-N nearest words (used by
`/doantu_hint`). Results are filtered locally to skip capitalized
foreign place names that leak in from the corpus, then 3 are sampled
from the "warm but not hot" tail (skip the top 20%).
Override the base URL for local dev via `PHOW2SIM_API_URL`.
+12
View File
@@ -89,5 +89,17 @@ export function createClient(apiBase, { timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
similarity(a, b) {
return fetchJson(buildUrl(apiBase, "/similarity", { a, b }), timeoutMs);
},
/**
* Top-N nearest neighbors by cosine similarity.
* @param {string} word
* @param {number} [topn]
* @returns {Promise<{
* word: string, canonical: string|null, in_vocab: boolean,
* neighbors: { word: string, similarity: number }[]
* }>}
*/
neighbors(word, topn = 100) {
return fetchJson(buildUrl(apiBase, "/neighbors", { word, topn }), timeoutMs);
},
};
}
+68
View File
@@ -142,6 +142,74 @@ async function submitGuess(ctx, { db, client }, subject, game, arg) {
return ctx.reply(body, { parse_mode: "HTML" });
}
// Corpus leaks foreign place names (mixed-case Latin — "al-Qantara",
// "Nam_Afrin") into neighbors. Keep only lowercase tokens that look
// Vietnamese: either a diacritic (non-ASCII) or a compound marker (`_`).
const PLAYABLE_WORD = /^[\p{Ll}\p{M}_]+$/u;
/** @param {string} word — true if it has a diacritic or underscore compound. */
function looksVietnamese(word) {
if (word.includes("_")) return true;
for (let i = 0; i < word.length; i++) {
if (word.charCodeAt(i) > 0x7f) return true;
}
return false;
}
function pickHintWords(target, neighbors, alreadyGuessed, count) {
const guessedSet = new Set(alreadyGuessed);
const playable = neighbors.filter(
(n) =>
PLAYABLE_WORD.test(n.word) &&
looksVietnamese(n.word) &&
!n.word.includes(target) &&
!target.includes(n.word) &&
!guessedSet.has(n.word),
);
// "Warm but not hot" — skip the top 20% so a hint doesn't hand the answer away.
const skip = Math.min(Math.floor(playable.length * 0.2), 20);
const pool = playable.slice(skip);
if (pool.length === 0) return [];
// Fisher-Yates-ish sample without replacement.
const picks = [];
const used = new Set();
const want = Math.min(count, pool.length);
while (picks.length < want) {
const i = Math.floor(Math.random() * pool.length);
if (used.has(i)) continue;
used.add(i);
picks.push(pool[i]);
}
return picks;
}
export async function handleHint(ctx, { db, client }) {
const subject = getSubject(ctx);
if (subject == null) return ctx.reply("Cannot identify chat.");
const game = await loadGame(db, subject);
if (!game || game.solved) {
return ctx.reply("No active round. Send <code>/doantu</code> to start one.", {
parse_mode: "HTML",
});
}
let res;
try {
res = await client.neighbors(game.target, 100);
} catch (err) {
logFail("neighbors", err);
return ctx.reply(UPSTREAM_FAIL);
}
const alreadyGuessed = game.guesses.map((g) => g.canonical);
const picks = pickHintWords(game.target, res?.neighbors ?? [], alreadyGuessed, 3);
if (picks.length === 0) {
return ctx.reply("🤷 No usable hints available for this round.");
}
const list = picks.map((p) => `• <code>${escapeHtml(p.word)}</code>`).join("\n");
return ctx.reply(`💡 <b>Hints</b> — related words (not the answer):\n${list}`, {
parse_mode: "HTML",
});
}
export async function handleGiveup(ctx, { db }) {
const subject = getSubject(ctx);
if (subject == null) return ctx.reply("Cannot identify chat.");
+7 -1
View File
@@ -7,7 +7,7 @@
*/
import { createClient } from "./api-client.js";
import { handleDoantu, handleGiveup, handleStats } from "./handlers.js";
import { handleDoantu, handleGiveup, handleHint, handleStats } from "./handlers.js";
const DEFAULT_API_URL = "https://phow2sim.sg.miti99.com";
@@ -31,6 +31,12 @@ const doantuModule = {
description: "Đoán từ — Vietnamese semantic word guessing (unlimited tries)",
handler: (ctx) => handleDoantu(ctx, { db, client }),
},
{
name: "doantu_hint",
visibility: "public",
description: "Reveal 3 related words (not the answer) to nudge your guessing",
handler: (ctx) => handleHint(ctx, { db, client }),
},
{
name: "doantu_giveup",
visibility: "public",