diff --git a/src/modules/doantu/README.md b/src/modules/doantu/README.md
index 58eb3c7..4f053b5 100644
--- a/src/modules/doantu/README.md
+++ b/src/modules/doantu/README.md
@@ -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`.
diff --git a/src/modules/doantu/api-client.js b/src/modules/doantu/api-client.js
index 5f049f0..9e994b9 100644
--- a/src/modules/doantu/api-client.js
+++ b/src/modules/doantu/api-client.js
@@ -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);
+ },
};
}
diff --git a/src/modules/doantu/handlers.js b/src/modules/doantu/handlers.js
index 92bac6a..b60cedc 100644
--- a/src/modules/doantu/handlers.js
+++ b/src/modules/doantu/handlers.js
@@ -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 /doantu 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) => `• ${escapeHtml(p.word)}`).join("\n");
+ return ctx.reply(`💡 Hints — 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.");
diff --git a/src/modules/doantu/index.js b/src/modules/doantu/index.js
index c8003aa..568ac41 100644
--- a/src/modules/doantu/index.js
+++ b/src/modules/doantu/index.js
@@ -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",