From a9e11de92a8d53eff0b7d85a1c774559252cd351 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Thu, 23 Apr 2026 13:23:53 +0700 Subject: [PATCH] =?UTF-8?q?feat(doantu):=20add=20/doantu=5Fhint=20?= =?UTF-8?q?=E2=80=94=203=20warm-but-not-hot=20related=20words?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/modules/doantu/README.md | 5 +++ src/modules/doantu/api-client.js | 12 ++++++ src/modules/doantu/handlers.js | 68 ++++++++++++++++++++++++++++++++ src/modules/doantu/index.js | 8 +++- 4 files changed, 92 insertions(+), 1 deletion(-) 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",